diff --git a/packages/core/src/components/page/page.tsx b/packages/core/src/components/page/page.tsx index ee6d97977..b0ec2cf27 100644 --- a/packages/core/src/components/page/page.tsx +++ b/packages/core/src/components/page/page.tsx @@ -34,7 +34,7 @@ export function Page>({ page, pageState, shapeUtils, - inputs.size, + [inputs.bounds.width, inputs.bounds.height], meta, callbacks.onRenderCountChange ) @@ -59,7 +59,7 @@ export function Page>({ diff --git a/packages/core/src/components/renderer/renderer.tsx b/packages/core/src/components/renderer/renderer.tsx index 1c3c42ab1..4f4354f37 100644 --- a/packages/core/src/components/renderer/renderer.tsx +++ b/packages/core/src/components/renderer/renderer.tsx @@ -56,9 +56,13 @@ export interface RendererProps void + /** + * (optional) A callback that is fired when the editor's client bounding box changes. + */ + onBoundsChange?: (bounds: TLBounds) => void } /** @@ -83,7 +87,7 @@ export function Renderer): JSX.Element { useTLTheme(theme) - const rScreenBounds = React.useRef(null) + const rSelectionBounds = React.useRef(null) const rPageState = React.useRef(pageState) @@ -96,7 +100,7 @@ export function Renderer>(() => ({ callbacks: rest, shapeUtils, - rScreenBounds, + rSelectionBounds, rPageState, inputs: new Inputs(), })) diff --git a/packages/core/src/hooks/useCameraCss.tsx b/packages/core/src/hooks/useCameraCss.tsx index ee7faa65b..69e657892 100644 --- a/packages/core/src/hooks/useCameraCss.tsx +++ b/packages/core/src/hooks/useCameraCss.tsx @@ -15,17 +15,5 @@ export function useCameraCss(ref: React.RefObject, pageState: TL ref.current!.style.setProperty('--tl-camera-y', pageState.camera.point[1] + 'px') }, [pageState.camera.point]) - // Update the group's position when the camera moves or zooms - // React.useEffect(() => { - // const { - // zoom, - // point: [x = 0, y = 0], - // } = pageState.camera - // rLayer.current?.style.setProperty( - // 'transform', - // `scale(${zoom},${zoom}) translate(${x}px,${y}px)` - // ) - // }, [pageState.camera]) - return rLayer } diff --git a/packages/core/src/hooks/usePosition.ts b/packages/core/src/hooks/usePosition.ts index 702e4aaab..1d15bbe22 100644 --- a/packages/core/src/hooks/usePosition.ts +++ b/packages/core/src/hooks/usePosition.ts @@ -5,11 +5,18 @@ import type { TLBounds } from '+types' export function usePosition(bounds: TLBounds, rotation = 0) { const rBounds = React.useRef(null) + // Update the transform React.useLayoutEffect(() => { const elm = rBounds.current! + const transform = ` - translate(calc(${bounds.minX}px - var(--tl-padding)),calc(${bounds.minY}px - var(--tl-padding))) + translate3d( + calc(${bounds.minX}px - var(--tl-padding)), + calc(${bounds.minY}px - var(--tl-padding)), + 0px + ) rotate(${rotation + (bounds.rotation || 0)}rad)` + elm.style.setProperty('transform', transform) elm.style.setProperty('width', `calc(${Math.floor(bounds.width)}px + (var(--tl-padding) * 2))`) diff --git a/packages/core/src/hooks/useResizeObserver.ts b/packages/core/src/hooks/useResizeObserver.ts index 55df48bdd..4255ea9cf 100644 --- a/packages/core/src/hooks/useResizeObserver.ts +++ b/packages/core/src/hooks/useResizeObserver.ts @@ -3,31 +3,48 @@ import * as React from 'react' import { Utils } from '+utils' export function useResizeObserver(ref: React.RefObject) { - const { inputs } = useTLContext() + const { inputs, callbacks } = useTLContext() + const rIsMounted = React.useRef(false) + const forceUpdate = React.useReducer((x) => x + 1, 0)[1] - const updateOffsets = React.useCallback(() => { + // When the element resizes, update the bounds (stored in inputs) + // and broadcast via the onBoundsChange callback prop. + const updateBounds = React.useCallback(() => { if (rIsMounted.current) { const rect = ref.current?.getBoundingClientRect() + if (rect) { - inputs.offset = [rect.left, rect.top] - inputs.size = [rect.width, rect.height] + inputs.bounds = { + minX: rect.left, + maxX: rect.left + rect.width, + minY: rect.top, + maxY: rect.top + rect.height, + width: rect.width, + height: rect.height, + } + + callbacks.onBoundsChange?.(inputs.bounds) + + // Force an update for a second mount forceUpdate() } + } else { + // Skip the first mount + rIsMounted.current = true } - rIsMounted.current = true - }, [ref, forceUpdate]) + }, [ref, forceUpdate, inputs, callbacks.onBoundsChange]) React.useEffect(() => { - const debouncedUpdateOffsets = Utils.debounce(updateOffsets, 100) - window.addEventListener('scroll', debouncedUpdateOffsets) - window.addEventListener('resize', debouncedUpdateOffsets) + const debouncedupdateBounds = Utils.debounce(updateBounds, 100) + window.addEventListener('scroll', debouncedupdateBounds) + window.addEventListener('resize', debouncedupdateBounds) return () => { - window.removeEventListener('scroll', debouncedUpdateOffsets) - window.removeEventListener('resize', debouncedUpdateOffsets) + window.removeEventListener('scroll', debouncedupdateBounds) + window.removeEventListener('resize', debouncedupdateBounds) } - }, [inputs]) + }, []) React.useEffect(() => { const resizeObserver = new ResizeObserver((entries) => { @@ -36,7 +53,7 @@ export function useResizeObserver(ref: React.RefObject) { } if (entries[0].contentRect) { - updateOffsets() + updateBounds() } }) @@ -50,6 +67,6 @@ export function useResizeObserver(ref: React.RefObject) { }, [ref, inputs]) React.useEffect(() => { - updateOffsets() + updateBounds() }, [ref]) } diff --git a/packages/core/src/hooks/useSelection.tsx b/packages/core/src/hooks/useSelection.tsx index 78beaa81d..d40a07fd6 100644 --- a/packages/core/src/hooks/useSelection.tsx +++ b/packages/core/src/hooks/useSelection.tsx @@ -11,7 +11,7 @@ export function useSelection( pageState: TLPageState, shapeUtils: TLShapeUtils ) { - const { rScreenBounds } = useTLContext() + const { rSelectionBounds } = useTLContext() const { selectedIds } = pageState let bounds: TLBounds | undefined = undefined @@ -50,7 +50,7 @@ export function useSelection( const [minX, minY] = canvasToScreen([bounds.minX, bounds.minY], pageState.camera) const [maxX, maxY] = canvasToScreen([bounds.maxX, bounds.maxY], pageState.camera) - rScreenBounds.current = { + rSelectionBounds.current = { minX, minY, maxX, @@ -59,7 +59,7 @@ export function useSelection( height: maxY - minY, } } else { - rScreenBounds.current = null + rSelectionBounds.current = null } return { bounds, rotation, isLocked } diff --git a/packages/core/src/hooks/useShapeEvents.tsx b/packages/core/src/hooks/useShapeEvents.tsx index a0fb8c81c..22697caf1 100644 --- a/packages/core/src/hooks/useShapeEvents.tsx +++ b/packages/core/src/hooks/useShapeEvents.tsx @@ -3,7 +3,7 @@ import { Utils } from '+utils' import { TLContext } from '+hooks' export function useShapeEvents(id: string, disable = false) { - const { rPageState, rScreenBounds, callbacks, inputs } = React.useContext(TLContext) + const { rPageState, rSelectionBounds, callbacks, inputs } = React.useContext(TLContext) const onPointerDown = React.useCallback( (e: React.PointerEvent) => { @@ -26,8 +26,8 @@ export function useShapeEvents(id: string, disable = false) { // treat the event as a bounding box click. Unfortunately there's no way I know to pipe // the event to the actual bounds background element. if ( - rScreenBounds.current && - Utils.pointInBounds(info.point, rScreenBounds.current) && + rSelectionBounds.current && + Utils.pointInBounds(info.point, rSelectionBounds.current) && !rPageState.current.selectedIds.includes(id) ) { callbacks.onPointBounds?.(inputs.pointerDown(e, 'bounds'), e) diff --git a/packages/core/src/hooks/useStyle.tsx b/packages/core/src/hooks/useStyle.tsx index 7b6df4a88..3610f9484 100644 --- a/packages/core/src/hooks/useStyle.tsx +++ b/packages/core/src/hooks/useStyle.tsx @@ -151,8 +151,7 @@ const tlcss = css` height: 0; width: 0; contain: layout size; - transform: scale(var(--tl-zoom), var(--tl-zoom)) - translate(var(--tl-camera-x), var(--tl-camera-y)); + transform: scale(var(--tl-zoom)) translate3d(var(--tl-camera-x), var(--tl-camera-y), 0px); } .tl-absolute { diff --git a/packages/core/src/hooks/useTLContext.tsx b/packages/core/src/hooks/useTLContext.tsx index dcf0f9e61..9ea4c787c 100644 --- a/packages/core/src/hooks/useTLContext.tsx +++ b/packages/core/src/hooks/useTLContext.tsx @@ -2,12 +2,13 @@ import * as React from 'react' import type { Inputs } from '+inputs' import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types' +// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface TLContextType { id?: string callbacks: Partial> shapeUtils: TLShapeUtils rPageState: React.MutableRefObject - rScreenBounds: React.MutableRefObject + rSelectionBounds: React.MutableRefObject inputs: Inputs } diff --git a/packages/core/src/inputs.ts b/packages/core/src/inputs.ts index 0600293c4..28ef99f9f 100644 --- a/packages/core/src/inputs.ts +++ b/packages/core/src/inputs.ts @@ -2,17 +2,27 @@ import type React from 'react' import type { TLKeyboardInfo, TLPointerInfo } from './types' import { Utils } from './utils' import { Vec } from '@tldraw/vec' +import type { TLBounds } from '+index' const DOUBLE_CLICK_DURATION = 250 export class Inputs { pointer?: TLPointerInfo + keyboard?: TLKeyboardInfo + keys: Record = {} + isPinching = false - offset = [0, 0] - size = [10, 10] + bounds: TLBounds = { + minX: 0, + maxX: 640, + minY: 0, + maxY: 480, + width: 640, + height: 480, + } pointerUpTime = 0 @@ -41,9 +51,9 @@ export class Inputs { const info: TLPointerInfo = { target, pointerId: touch.identifier, - origin: Inputs.getPoint(touch), + origin: Inputs.getPoint(touch, this.bounds), delta: [0, 0], - point: Inputs.getPoint(touch), + point: Inputs.getPoint(touch, this.bounds), pressure: Inputs.getPressure(touch), shiftKey, ctrlKey, @@ -64,9 +74,9 @@ export class Inputs { const info: TLPointerInfo = { target, pointerId: touch.identifier, - origin: Inputs.getPoint(touch), + origin: Inputs.getPoint(touch, this.bounds), delta: [0, 0], - point: Inputs.getPoint(touch), + point: Inputs.getPoint(touch, this.bounds), pressure: Inputs.getPressure(touch), shiftKey, ctrlKey, @@ -88,7 +98,7 @@ export class Inputs { const prev = this.pointer - const point = Inputs.getPoint(touch) + const point = Inputs.getPoint(touch, this.bounds) const delta = prev?.point ? Vec.sub(point, prev.point) : [0, 0] @@ -114,7 +124,7 @@ export class Inputs { pointerDown(e: PointerEvent | React.PointerEvent, target: T): TLPointerInfo { const { shiftKey, ctrlKey, metaKey, altKey } = e - const point = Inputs.getPoint(e, this.offset) + const point = Inputs.getPoint(e, this.bounds) this.activePointer = e.pointerId @@ -142,7 +152,7 @@ export class Inputs { ): TLPointerInfo { const { shiftKey, ctrlKey, metaKey, altKey } = e - const point = Inputs.getPoint(e, this.offset) + const point = Inputs.getPoint(e, this.bounds) const info: TLPointerInfo = { target, @@ -167,7 +177,7 @@ export class Inputs { const prev = this.pointer - const point = Inputs.getPoint(e, this.offset) + const point = Inputs.getPoint(e, this.bounds) const delta = prev?.point ? Vec.sub(point, prev.point) : [0, 0] @@ -195,7 +205,7 @@ export class Inputs { const prev = this.pointer - const point = Inputs.getPoint(e, this.offset) + const point = Inputs.getPoint(e, this.bounds) const delta = prev?.point ? Vec.sub(point, prev.point) : [0, 0] @@ -231,7 +241,7 @@ export class Inputs { origin: this.pointer?.origin || [0, 0], delta: [0, 0], pressure: 0.5, - point: Inputs.getPoint(e, this.offset), + point: Inputs.getPoint(e, this.bounds), shiftKey, ctrlKey, metaKey, @@ -252,7 +262,7 @@ export class Inputs { const prev = this.pointer - const point = Inputs.getPoint(e, this.offset) + const point = Inputs.getPoint(e, this.bounds) const info: TLPointerInfo<'wheel'> = { ...prev, @@ -330,7 +340,7 @@ export class Inputs { target: 'pinch', origin, delta: delta, - point: Vec.sub(Vec.round(point), this.offset), + point: Vec.sub(Vec.round(point), [this.bounds.minX, this.bounds.minY]), pressure: 0.5, shiftKey, ctrlKey, @@ -353,9 +363,9 @@ export class Inputs { static getPoint( e: PointerEvent | React.PointerEvent | Touch | React.Touch | WheelEvent, - offset = [0, 0] + bounds: TLBounds ): number[] { - return [+e.clientX.toFixed(2) - offset[0], +e.clientY.toFixed(2) - offset[1]] + return [+e.clientX.toFixed(2) - bounds.minX, +e.clientY.toFixed(2) - bounds.minY] } static getPressure(e: PointerEvent | React.PointerEvent | Touch | React.Touch | WheelEvent) { diff --git a/packages/core/src/test/context-wrapper.tsx b/packages/core/src/test/context-wrapper.tsx index a97b54b0b..f472713de 100644 --- a/packages/core/src/test/context-wrapper.tsx +++ b/packages/core/src/test/context-wrapper.tsx @@ -7,13 +7,13 @@ import { Inputs } from '+inputs' export const ContextWrapper: React.FC = ({ children }) => { useTLTheme() - const rScreenBounds = React.useRef(null) + const rSelectionBounds = React.useRef(null) const rPageState = React.useRef(mockDocument.pageState) const [context] = React.useState(() => ({ callbacks: {}, shapeUtils: mockUtils, - rScreenBounds, + rSelectionBounds, rPageState, inputs: new Inputs(), })) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec1158e32..2d00170b0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -205,6 +205,7 @@ export interface TLCallbacks { onShapeBlur: TLShapeBlurHandler onRenderCountChange: (ids: string[]) => void onError: (error: Error) => void + onBoundsChange: (bounds: TLBounds) => void } export interface TLBounds { diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx index 1f6a9d385..4238e90da 100644 --- a/packages/tldraw/src/components/tldraw/tldraw.tsx +++ b/packages/tldraw/src/components/tldraw/tldraw.tsx @@ -220,7 +220,7 @@ function InnerTldraw({ onRenderCountChange={tlstate.onRenderCountChange} onShapeChange={tlstate.onShapeChange} onShapeBlur={tlstate.onShapeBlur} - onMount={tlstate.handleMount} + onBoundsChange={tlstate.updateBounds} />
diff --git a/packages/tldraw/src/state/tldr.ts b/packages/tldraw/src/state/tldr.ts index 2d9d6b4a4..c09acd5bc 100644 --- a/packages/tldraw/src/state/tldr.ts +++ b/packages/tldraw/src/state/tldr.ts @@ -33,20 +33,6 @@ export class TLDR { return Vec.sub(Vec.div(point, camera.zoom), camera.point) } - static getViewport(data: Data): TLBounds { - const [minX, minY] = TLDR.screenToWorld(data, [0, 0]) - const [maxX, maxY] = TLDR.screenToWorld(data, [window.innerWidth, window.innerHeight]) - - return { - minX, - minY, - maxX, - maxY, - height: maxX - minX, - width: maxY - minY, - } - } - static getCameraZoom(zoom: number) { return Utils.clamp(zoom, 0.1, 5) } diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts index b236254d7..1c5ce8e06 100644 --- a/packages/tldraw/src/state/tlstate.ts +++ b/packages/tldraw/src/state/tlstate.ts @@ -119,6 +119,16 @@ export class TLDrawState extends StateManager { selectedGroupId?: string + // The editor's bounding client rect + bounds: TLBounds = { + minX: 0, + minY: 0, + maxX: 640, + maxY: 480, + width: 640, + height: 480, + } + private pasteInfo = { center: [0, 0], offset: [0, 0], @@ -354,6 +364,14 @@ export class TLDrawState extends StateManager { this.inputs = inputs } + /** + * Update the bounding box when the renderer's bounds change. + * @param bounds + */ + updateBounds = (bounds: TLBounds) => { + this.bounds = { ...bounds } + } + /* -------------------------------------------------- */ /* Settings & UI */ /* -------------------------------------------------- */ @@ -861,14 +879,7 @@ export class TLDrawState extends StateManager { const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds)) - let center = Vec.round( - this.getPagePoint( - point || - (this.inputs - ? [this.inputs.size[0] / 2, this.inputs.size[1] / 2] - : [window.innerWidth / 2, window.innerHeight / 2]) - ) - ) + let center = Vec.round(this.getPagePoint(point || this.centerPoint)) if ( Vec.dist(center, this.pasteInfo.center) < 2 || @@ -914,10 +925,7 @@ export class TLDrawState extends StateManager { type: TLDrawShapeType.Text, parentId: this.appState.currentPageId, text: result, - point: this.getPagePoint( - [window.innerWidth / 2, window.innerHeight / 2], - this.currentPageId - ), + point: this.getPagePoint(this.centerPoint, this.currentPageId), style: { ...this.appState.currentStyle }, }) @@ -1030,11 +1038,7 @@ export class TLDrawState extends StateManager { * Reset the camera to the default position */ resetCamera = (): this => { - return this.setCamera( - Vec.round([window.innerWidth / 2, window.innerHeight / 2]), - 1, - `reset_camera` - ) + return this.setCamera(this.centerPoint, 1, `reset_camera`) } /** @@ -1066,7 +1070,7 @@ export class TLDrawState extends StateManager { * @param next The new zoom level. * @param center The point to zoom towards (defaults to screen center). */ - zoomTo = (next: number, center = [window.innerWidth / 2, window.innerHeight / 2]): this => { + zoomTo = (next: number, center = this.centerPoint): this => { const { zoom, point } = this.pageState.camera const p0 = Vec.sub(Vec.div(center, zoom), point) const p1 = Vec.sub(Vec.div(center, next), point) @@ -1102,13 +1106,13 @@ export class TLDrawState extends StateManager { const bounds = Utils.getCommonBounds(Object.values(shapes).map(TLDR.getBounds)) const zoom = TLDR.getCameraZoom( - window.innerWidth < window.innerHeight - ? (window.innerWidth - 128) / bounds.width - : (window.innerHeight - 128) / bounds.height + this.bounds.width < this.bounds.height + ? (this.bounds.width - 128) / bounds.width + : (this.bounds.height - 128) / bounds.height ) - const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom - const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom + const mx = (this.bounds.width - bounds.width * zoom) / 2 / zoom + const my = (this.bounds.height - bounds.height * zoom) / 2 / zoom return this.setCamera( Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my])), @@ -1126,13 +1130,13 @@ export class TLDrawState extends StateManager { const bounds = TLDR.getSelectedBounds(this.state) const zoom = TLDR.getCameraZoom( - window.innerWidth < window.innerHeight - ? (window.innerWidth - 128) / bounds.width - : (window.innerHeight - 128) / bounds.height + this.bounds.width < this.bounds.height + ? (this.bounds.width - 128) / bounds.width + : (this.bounds.height - 128) / bounds.height ) - const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom - const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom + const mx = (this.bounds.width - bounds.width * zoom) / 2 / zoom + const my = (this.bounds.height - bounds.height * zoom) / 2 / zoom return this.setCamera( Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my])), @@ -1153,8 +1157,8 @@ export class TLDrawState extends StateManager { const bounds = Utils.getCommonBounds(Object.values(shapes).map(TLDR.getBounds)) const { zoom } = pageState.camera - const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom - const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom + const mx = (this.bounds.width - bounds.width * zoom) / 2 / zoom + const my = (this.bounds.height - bounds.height * zoom) / 2 / zoom return this.setCamera( Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my])), @@ -2813,4 +2817,8 @@ export class TLDrawState extends StateManager { onError = () => { // TODO } + + get centerPoint() { + return Vec.round(Utils.getBoundsCenter(this.bounds)) + } }