Use canvas bounds for viewport bounds (#2798)

This PR changes the way that viewport bounds are calculated by using the
canvas element as the source of truth, rather than the container. This
allows for cases where the canvas is not the same dimensions as the
component. (Given the way our UI and context works, there are cases
where this is desired, i.e. toolbars and other items overlaid on top of
the canvas area).

The editor's `getContainer` is now only used for the text measurement.
It would be good to get that out somehow.

# Pros

We can inset the canvas

# Cons

We can no longer imperatively call `updateScreenBounds`, as we need to
provide those bounds externally.

### Change Type

- [x] `major` — Breaking change

### Test Plan

1. Use the examples, including the new inset canvas example.

- [x] Unit Tests

### Release Notes

- Changes the source of truth for the viewport page bounds to be the
canvas instead.
pull/2754/head
Steve Ruiz 2024-02-12 15:03:25 +00:00 zatwierdzone przez GitHub
rodzic 430924f8b6
commit 79460cbf3a
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
19 zmienionych plików z 139 dodań i 84 usunięć

Wyświetl plik

@ -46,7 +46,6 @@ export function useUrlState(onChangeUrl: (params: UrlStateParams) => void) {
const url = new URL(location.href)
if (url.searchParams.has(PARAMS.viewport)) {
editor.updateViewportScreenBounds()
const newViewportRaw = url.searchParams.get(PARAMS.viewport)
if (newViewportRaw) {
try {

Wyświetl plik

@ -77,10 +77,14 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
transact(() => {
const isFocused = editor.getInstanceState().isFocused
const bounds = editor.getViewportScreenBounds().clone()
editor.store.clear()
editor.store.ensureStoreIsUsable()
editor.history.clear()
editor.updateViewportScreenBounds()
// Put the old bounds back in place
editor.updateViewportScreenBounds(bounds)
editor.updateRenderingBounds()
editor.updateInstanceState({ isFocused })
})

Wyświetl plik

@ -0,0 +1,11 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import './inset-canvas.css'
export default function InsetCanvasExample() {
return (
<div className="tldraw__editor tldraw__editor-with-inset-canvas">
<Tldraw />
</div>
)
}

Wyświetl plik

@ -0,0 +1,11 @@
---
title: Inset canvas
component: ./InsetCanvasExample.tsx
category: ui
---
Handling events when the canvas is inset within the editor.
---
If for some reason you need to move the canvas around, that should still work.

Wyświetl plik

@ -0,0 +1,4 @@
.tldraw__editor-with-inset-canvas .tl-canvas {
position: absolute;
inset: 100px 200px 100px 100px;
}

Wyświetl plik

@ -911,7 +911,7 @@ export class Editor extends EventEmitter<TLEventMap> {
updateRenderingBounds(): this;
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, historyOptions?: TLCommandHistoryOptions): this;
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[], historyOptions?: TLCommandHistoryOptions): this;
updateViewportScreenBounds(center?: boolean): this;
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
readonly user: UserPreferencesManager;
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
zoomIn(point?: Vec, animation?: TLAnimationOptions): this;

Wyświetl plik

@ -18754,7 +18754,16 @@
"excerptTokens": [
{
"kind": "Content",
"text": "updateViewportScreenBounds(center?: "
"text": "updateViewportScreenBounds(screenBounds: "
},
{
"kind": "Reference",
"text": "Box",
"canonicalReference": "@tldraw/editor!Box:class"
},
{
"kind": "Content",
"text": ", center?: "
},
{
"kind": "Content",
@ -18775,19 +18784,27 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "center",
"parameterName": "screenBounds",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "center",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": true
}
],

Wyświetl plik

@ -89,10 +89,7 @@
var(--a) var(--a) 0 var(--color-background), var(--b) var(--a) 0 var(--color-background);
/* own properties */
position: relative;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
overflow: clip;
}
@ -217,10 +214,7 @@ input,
.tl-canvas {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
color: var(--color-text);
z-index: var(--layer-canvas);
cursor: var(--tl-cursor);
@ -232,10 +226,7 @@ input,
.tl-fixed-layer {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
contain: strict;
pointer-events: none;
}
@ -286,10 +277,7 @@ input,
.tl-grid {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
touch-action: none;
pointer-events: none;
z-index: var(--layer-grid);
@ -351,10 +339,7 @@ input,
.tl-svg-container {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
pointer-events: none;
stroke-linecap: round;
stroke-linejoin: round;
@ -364,10 +349,7 @@ input,
.tl-html-container {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
pointer-events: none;
stroke-linecap: round;
stroke-linejoin: round;
@ -798,10 +780,7 @@ input,
.tl-text-input,
.tl-text-content {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
min-width: 1px;
min-height: 1px;
overflow: visible;
@ -1044,10 +1023,7 @@ input,
.tl-text-label__inner > .tl-text-input {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
padding: 16px;
z-index: 4;
}
@ -1222,10 +1198,7 @@ input,
.tl-note__scrim {
position: absolute;
z-index: 1;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
inset: 0px;
background-color: var(--color-background);
opacity: 0.28;
}

Wyświetl plik

@ -356,9 +356,6 @@ function Layout({
useFocusEvents(autoFocus)
useOnMount(onMount)
const editor = useEditor()
editor.updateViewportScreenBounds()
return children ?? <Canvas />
}

Wyświetl plik

@ -32,7 +32,7 @@ export function Canvas({ className }: { className?: string }) {
const rHtmlLayer = React.useRef<HTMLDivElement>(null)
const rHtmlLayer2 = React.useRef<HTMLDivElement>(null)
useScreenBounds()
useScreenBounds(rCanvas)
useDocumentEvents()
useCoarsePointer()

Wyświetl plik

@ -2709,17 +2709,9 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
updateViewportScreenBounds(center = false): this {
const container = this.getContainer()
if (!container) return this
const rect = container.getBoundingClientRect()
const screenBounds = new Box(
rect.left || rect.x,
rect.top || rect.y,
Math.max(rect.width, 1),
Math.max(rect.height, 1)
)
updateViewportScreenBounds(screenBounds: Box, center = false): this {
screenBounds.width = Math.max(screenBounds.width, 1)
screenBounds.height = Math.max(screenBounds.height, 1)
const insets = [
// top

Wyświetl plik

@ -1,33 +1,85 @@
import throttle from 'lodash.throttle'
import { useLayoutEffect } from 'react'
import { Box } from '../primitives/Box'
import { useEditor } from './useEditor'
export function useScreenBounds() {
export function useScreenBounds(ref: React.RefObject<HTMLElement>) {
const editor = useEditor()
useLayoutEffect(() => {
const updateBounds = throttle(
() => {
editor.updateViewportScreenBounds()
},
200,
{
trailing: true,
}
)
function updateScreenBounds() {
const container = ref.current
if (!container) return null
editor.updateViewportScreenBounds()
const rect = container.getBoundingClientRect()
editor.updateViewportScreenBounds(
new Box(
rect.left || rect.x,
rect.top || rect.y,
Math.max(rect.width, 1),
Math.max(rect.height, 1)
)
)
}
// Set the initial bounds
updateScreenBounds()
// Everything else uses a debounced update...
const updateBounds = throttle(updateScreenBounds, 200, {
trailing: true,
})
// Rather than running getClientRects on every frame, we'll
// run it once a second or when the window resizes / scrolls.
// run it once a second or when the window resizes.
const interval = setInterval(updateBounds, 1000)
window.addEventListener('resize', updateBounds)
window.addEventListener('scroll', updateBounds)
const resizeObserver = new ResizeObserver((entries) => {
if (!entries[0].contentRect) return
updateBounds()
})
const container = ref.current
let scrollingParent: HTMLElement | Document | null = null
if (container) {
// When the container's size changes, update the bounds
resizeObserver.observe(container)
// When the container's nearest scrollable parent scrolls, update the bounds
scrollingParent = getNearestScrollableContainer(container)
scrollingParent.addEventListener('scroll', updateBounds)
}
return () => {
clearInterval(interval)
window.removeEventListener('resize', updateBounds)
window.removeEventListener('scroll', updateBounds)
resizeObserver.disconnect()
scrollingParent?.removeEventListener('scroll', updateBounds)
}
}, [editor])
}, [editor, ref])
}
// Credits: from v1 by way of excalidraw
// https://github.com/tldraw/tldraw-v1/blob/main/packages/core/src/hooks/useResizeObserver.ts#L8
// https://github.com/excalidraw/excalidraw/blob/48c3465b19f10ec755b3eb84e21a01a468e96e43/packages/excalidraw/utils.ts#L600
const getNearestScrollableContainer = (element: HTMLElement): HTMLElement | Document => {
let parent = element.parentElement
while (parent) {
if (parent === document.body) {
return document
}
const { overflowY } = window.getComputedStyle(parent)
const hasScrollableContent = parent.scrollHeight > parent.clientHeight
if (
hasScrollableContent &&
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
) {
return parent
}
parent = parent.parentElement
}
return document
}

Wyświetl plik

@ -6,11 +6,9 @@ export function registerDefaultSideEffects(editor: Editor) {
if (prev.isFocused !== next.isFocused) {
if (next.isFocused) {
editor.getContainer().focus()
editor.updateViewportScreenBounds()
} else {
editor.complete() // stop any interaction
editor.getContainer().blur() // blur the container
editor.updateViewportScreenBounds()
}
}
}),

Wyświetl plik

@ -31,10 +31,6 @@ export class EditingShape extends StateNode {
// Check for changes on editing end
util.onEditEnd?.(shape)
setTimeout(() => {
this.editor.updateViewportScreenBounds()
}, 500)
}
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {

Wyświetl plik

@ -588,7 +588,6 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
editor.history.clear()
editor.selectNone()
editor.updateViewportScreenBounds()
const bounds = editor.getCurrentPageBounds()
if (bounds) {

Wyświetl plik

@ -279,6 +279,7 @@ export async function parseAndLoadDocument(
// this file before they'll get their camera etc.
// restored. we could change this in the future.
transact(() => {
const initialBounds = editor.getViewportScreenBounds().clone()
const isFocused = editor.getInstanceState().isFocused
editor.store.clear()
const [shapes, nonShapes] = partition(
@ -289,7 +290,8 @@ export async function parseAndLoadDocument(
editor.store.ensureStoreIsUsable()
editor.store.put(shapes, 'initialize')
editor.history.clear()
editor.updateViewportScreenBounds()
// Put the old bounds back in place
editor.updateViewportScreenBounds(initialBounds)
editor.updateRenderingBounds()
const bounds = editor.getCurrentPageBounds()

Wyświetl plik

@ -369,7 +369,6 @@ describe('isFocused', () => {
if (wasFocused !== isFocused) {
editor.updateInstanceState({ isFocused })
editor.updateViewportScreenBounds()
if (!isFocused) {
// When losing focus, run complete() to ensure that any interacts end

Wyświetl plik

@ -1,4 +1,5 @@
import {
Box,
BoxModel,
Editor,
HALF_PI,
@ -127,7 +128,7 @@ export class TestEditor extends Editor {
this.bounds.right = bounds.x + bounds.w
this.bounds.bottom = bounds.y + bounds.h
this.updateViewportScreenBounds(center)
this.updateViewportScreenBounds(Box.From(bounds), center)
this.updateRenderingBounds()
return this
}

Wyświetl plik

@ -38,7 +38,7 @@ describe('When resizing', () => {
})
})
it('clamps bounds to minimim 0,0,1,1', () => {
it('clamps bounds to minimim h/w of 1,1', () => {
editor.setScreenBounds({ x: -100, y: -200, w: -700, h: 0 })
expect(editor.getViewportScreenBounds()).toMatchObject({
x: -100,