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.
This commit is contained in:
parent
430924f8b6
commit
79460cbf3a
19 changed files with 139 additions and 84 deletions
|
@ -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 {
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
11
apps/examples/src/examples/inset-canvas/README.md
Normal file
11
apps/examples/src/examples/inset-canvas/README.md
Normal file
|
@ -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.
|
4
apps/examples/src/examples/inset-canvas/inset-canvas.css
Normal file
4
apps/examples/src/examples/inset-canvas/inset-canvas.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.tldraw__editor-with-inset-canvas .tl-canvas {
|
||||
position: absolute;
|
||||
inset: 100px 200px 100px 100px;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -356,9 +356,6 @@ function Layout({
|
|||
useFocusEvents(autoFocus)
|
||||
useOnMount(onMount)
|
||||
|
||||
const editor = useEditor()
|
||||
editor.updateViewportScreenBounds()
|
||||
|
||||
return children ?? <Canvas />
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -588,7 +588,6 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
|
|||
|
||||
editor.history.clear()
|
||||
editor.selectNone()
|
||||
editor.updateViewportScreenBounds()
|
||||
|
||||
const bounds = editor.getCurrentPageBounds()
|
||||
if (bounds) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue