From 4e13b0e07bd89484e17dcd27d567f17babcc141f Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 8 Sep 2021 11:16:10 +0100 Subject: [PATCH] Allow for resets when id changes --- .../core/src/components/canvas/canvas.tsx | 4 +- packages/core/src/components/page/page.tsx | 7 +- .../core/src/components/renderer/renderer.tsx | 30 +- packages/core/src/components/shape/shape.tsx | 98 ++-- packages/core/src/hooks/useShapeEvents.tsx | 4 +- packages/core/src/hooks/useTLContext.tsx | 1 + packages/dev/src/app.tsx | 3 +- packages/dev/src/newId.tsx | 14 + .../components/context-menu/context-menu.tsx | 2 - .../tldraw/src/components/tldraw/tldraw.tsx | 30 +- .../src/components/tools-panel/status-bar.tsx | 4 +- .../components/tools-panel/tools-panel.tsx | 4 + .../tldraw/src/hooks/useKeyboardShortcuts.tsx | 441 +++++++++++++----- packages/tldraw/src/state/tlstate.spec.ts | 23 + packages/tldraw/src/state/tlstate.ts | 88 ++-- 15 files changed, 509 insertions(+), 244 deletions(-) create mode 100644 packages/dev/src/newId.tsx diff --git a/packages/core/src/components/canvas/canvas.tsx b/packages/core/src/components/canvas/canvas.tsx index 39f1070b1..2234dea82 100644 --- a/packages/core/src/components/canvas/canvas.tsx +++ b/packages/core/src/components/canvas/canvas.tsx @@ -26,7 +26,7 @@ interface CanvasProps { meta?: Record } -export const Canvas = React.memo(function Canvas({ +export function Canvas({ page, pageState, meta, @@ -66,4 +66,4 @@ export const Canvas = React.memo(function Canvas({ ) -}) +} diff --git a/packages/core/src/components/page/page.tsx b/packages/core/src/components/page/page.tsx index 732afc802..ab1f09e55 100644 --- a/packages/core/src/components/page/page.tsx +++ b/packages/core/src/components/page/page.tsx @@ -18,12 +18,7 @@ interface PageProps { /** * The Page component renders the current page. - * - * ### Example - * - *```ts - * example - *``` */ + */ export function Page({ page, pageState, diff --git a/packages/core/src/components/renderer/renderer.tsx b/packages/core/src/components/renderer/renderer.tsx index f26092dbb..0b7976543 100644 --- a/packages/core/src/components/renderer/renderer.tsx +++ b/packages/core/src/components/renderer/renderer.tsx @@ -10,10 +10,15 @@ import type { TLBinding, } from '../../types' import { Canvas } from '../canvas' -import { useTLTheme, TLContext } from '../../hooks' +import { useTLTheme, TLContext, TLContextType } from '../../hooks' export interface RendererProps> extends Partial { + /** + * An id representing the current document. Changing the id will + * update the context and trigger a re-render. + */ + id?: string /** * An object containing instances of your shape classes. */ @@ -52,6 +57,8 @@ export interface RendererProps void } /** @@ -63,6 +70,7 @@ export interface RendererProps>({ + id, shapeUtils, page, pageState, @@ -82,13 +90,31 @@ export function Renderer>({ rPageState.current = pageState }, [pageState]) - const [context] = React.useState(() => ({ + const rId = React.useRef(id) + + const [context, setContext] = React.useState(() => ({ + id, callbacks: rest, shapeUtils, rScreenBounds, rPageState, })) + React.useEffect(() => { + if (id !== rId.current) { + rest.onTest?.() + setContext({ + id, + callbacks: rest, + shapeUtils, + rScreenBounds, + rPageState, + }) + + rId.current = id + } + }, [id]) + return ( >({ - shape, - isEditing, - isBinding, - isHovered, - isSelected, - isCurrentParent, - meta, - }: IShapeTreeNode) => { - const { shapeUtils } = useTLContext() - const events = useShapeEvents(shape.id, isCurrentParent) - const utils = shapeUtils[shape.type] +export const Shape = >({ + shape, + isEditing, + isBinding, + isHovered, + isSelected, + isCurrentParent, + meta, +}: IShapeTreeNode) => { + const { shapeUtils } = useTLContext() + const events = useShapeEvents(shape.id, isCurrentParent) + const utils = shapeUtils[shape.type] - const center = utils.getCenter(shape) - const rotation = (shape.rotation || 0) * (180 / Math.PI) - const transform = `rotate(${rotation}, ${center}) translate(${shape.point})` + const center = utils.getCenter(shape) + const rotation = (shape.rotation || 0) * (180 / Math.PI) + const transform = `rotate(${rotation}, ${center}) translate(${shape.point})` - return ( - - {isEditing && utils.isEditableText ? ( - - ) : ( - - )} - - ) - } -) + return ( + + {isEditing && utils.isEditableText ? ( + + ) : ( + + )} + + ) +} diff --git a/packages/core/src/hooks/useShapeEvents.tsx b/packages/core/src/hooks/useShapeEvents.tsx index 3b6bb5f40..f4d0d5d49 100644 --- a/packages/core/src/hooks/useShapeEvents.tsx +++ b/packages/core/src/hooks/useShapeEvents.tsx @@ -1,10 +1,10 @@ import * as React from 'react' import { inputs } from '+inputs' -import { useTLContext } from './useTLContext' import { Utils } from '+utils' +import { TLContext } from '+hooks' export function useShapeEvents(id: string, disable = false) { - const { rPageState, rScreenBounds, callbacks } = useTLContext() + const { rPageState, rScreenBounds, callbacks } = React.useContext(TLContext) const onPointerDown = React.useCallback( (e: React.PointerEvent) => { diff --git a/packages/core/src/hooks/useTLContext.tsx b/packages/core/src/hooks/useTLContext.tsx index d32e394b4..f555e6b7b 100644 --- a/packages/core/src/hooks/useTLContext.tsx +++ b/packages/core/src/hooks/useTLContext.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types' export interface TLContextType { + id?: string callbacks: Partial shapeUtils: TLShapeUtils rPageState: React.MutableRefObject diff --git a/packages/dev/src/app.tsx b/packages/dev/src/app.tsx index 5896eb292..534080ace 100644 --- a/packages/dev/src/app.tsx +++ b/packages/dev/src/app.tsx @@ -1,7 +1,8 @@ import * as React from 'react' import Controlled from './controlled' import Basic from './basic' +import NewId from './newId' export default function App(): JSX.Element { - return + return } diff --git a/packages/dev/src/newId.tsx b/packages/dev/src/newId.tsx new file mode 100644 index 000000000..f7092e5c4 --- /dev/null +++ b/packages/dev/src/newId.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' +import { TLDraw } from '@tldraw/tldraw' + +export default function NewId() { + const [id, setId] = React.useState('example') + + React.useEffect(() => { + const timeout = setTimeout(() => setId('example2'), 2000) + + return () => clearTimeout(timeout) + }, []) + + return +} diff --git a/packages/tldraw/src/components/context-menu/context-menu.tsx b/packages/tldraw/src/components/context-menu/context-menu.tsx index ab677a430..4ed88a351 100644 --- a/packages/tldraw/src/components/context-menu/context-menu.tsx +++ b/packages/tldraw/src/components/context-menu/context-menu.tsx @@ -359,8 +359,6 @@ function MoveToPageMenu(): JSX.Element | null { if (sorted.length === 0) return null - console.log(sorted) - return ( diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx index 9b28157da..98e4aee8a 100644 --- a/packages/tldraw/src/components/tldraw/tldraw.tsx +++ b/packages/tldraw/src/components/tldraw/tldraw.tsx @@ -51,29 +51,41 @@ export interface TLDrawProps { } export function TLDraw({ id, document, currentPageId, onMount, onChange }: TLDrawProps) { + const [sId, setSId] = React.useState(id) const [tlstate, setTlstate] = React.useState(() => new TLDrawState(id)) + const [context, setContext] = React.useState(() => ({ tlstate, useSelector: tlstate.useStore })) React.useEffect(() => { - setTlstate(new TLDrawState(id, onChange, onMount)) - }, [id]) + if (id === sId) return + // If a new id is loaded, replace the entire state + const newState = new TLDrawState(id, onChange, onMount) + setTlstate(newState) + setContext({ tlstate: newState, useSelector: newState.useStore }) + setSId(id) + }, [sId, id]) - const [context] = React.useState(() => { - return { tlstate, useSelector: tlstate.useStore } - }) + // Use the `key` to ensure that new selector hooks are made when the id changes return ( - + ) } function InnerTldraw({ + id, currentPageId, document, }: { + id?: string currentPageId?: string document?: TLDrawDocument }) { @@ -138,10 +150,16 @@ function InnerTldraw({ tlstate.changePage(currentPageId) }, [currentPageId, tlstate]) + React.useEffect(() => { + 'Id Changed!' + console.log(id, tlstate.id) + }, [id]) + return ( s.appState.status.current const activeToolSelector = (s: Data) => s.appState.activeTool export function StatusBar(): JSX.Element | null { - const { useSelector } = useTLDrawContext() + const { tlstate, useSelector } = useTLDrawContext() const status = useSelector(statusSelector) const activeTool = useSelector(activeToolSelector) return (
- {activeTool} | {status} + {tlstate.id} | {activeTool} | {status}
) diff --git a/packages/tldraw/src/components/tools-panel/tools-panel.tsx b/packages/tldraw/src/components/tools-panel/tools-panel.tsx index a9927b5b7..a735b85b1 100644 --- a/packages/tldraw/src/components/tools-panel/tools-panel.tsx +++ b/packages/tldraw/src/components/tools-panel/tools-panel.tsx @@ -32,11 +32,15 @@ export const ToolsPanel = React.memo((): JSX.Element => { const isDebugMode = useSelector(isDebugModeSelector) + console.log(activeTool) + const selectSelectTool = React.useCallback(() => { + console.log(tlstate.id) tlstate.selectTool('select') }, [tlstate]) const selectDrawTool = React.useCallback(() => { + console.log(tlstate.id) tlstate.selectTool(TLDrawShapeType.Draw) }, [tlstate]) diff --git a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx index 7cedfc97d..8476b0d7c 100644 --- a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx +++ b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx @@ -29,195 +29,390 @@ export function useKeyboardShortcuts() { /* ---------------------- Tools --------------------- */ - useHotkeys('v,1', () => { - tlstate.selectTool('select') - }) + useHotkeys( + 'v,1', + () => { + tlstate.selectTool('select') + }, + undefined, + [tlstate] + ) - useHotkeys('d,2', () => { - tlstate.selectTool(TLDrawShapeType.Draw) - }) + useHotkeys( + 'd,2', + () => { + tlstate.selectTool(TLDrawShapeType.Draw) + }, + undefined, + [tlstate] + ) - useHotkeys('r,3', () => { - tlstate.selectTool(TLDrawShapeType.Rectangle) - }) + useHotkeys( + 'r,3', + () => { + tlstate.selectTool(TLDrawShapeType.Rectangle) + }, + undefined, + [tlstate] + ) - useHotkeys('e,4', () => { - tlstate.selectTool(TLDrawShapeType.Ellipse) - }) + useHotkeys( + 'e,4', + () => { + tlstate.selectTool(TLDrawShapeType.Ellipse) + }, + undefined, + [tlstate] + ) - useHotkeys('a,5', () => { - tlstate.selectTool(TLDrawShapeType.Arrow) - }) + useHotkeys( + 'a,5', + () => { + tlstate.selectTool(TLDrawShapeType.Arrow) + }, + undefined, + [tlstate] + ) - useHotkeys('t,6', () => { - tlstate.selectTool(TLDrawShapeType.Text) - }) + useHotkeys( + 't,6', + () => { + tlstate.selectTool(TLDrawShapeType.Text) + }, + undefined, + [tlstate] + ) /* ---------------------- Misc ---------------------- */ // Save - useHotkeys('ctrl+s,command+s', () => { - tlstate.saveProject() - }) + useHotkeys( + 'ctrl+s,command+s', + () => { + tlstate.saveProject() + }, + undefined, + [tlstate] + ) // Undo Redo - useHotkeys('command+z,ctrl+z', () => { - tlstate.undo() - }) + useHotkeys( + 'command+z,ctrl+z', + () => { + tlstate.undo() + }, + undefined, + [tlstate] + ) - useHotkeys('ctrl+shift-z,command+shift+z', () => { - tlstate.redo() - }) + useHotkeys( + 'ctrl+shift-z,command+shift+z', + () => { + tlstate.redo() + }, + undefined, + [tlstate] + ) // Undo Redo - useHotkeys('command+u,ctrl+u', () => { - tlstate.undoSelect() - }) + useHotkeys( + 'command+u,ctrl+u', + () => { + tlstate.undoSelect() + }, + undefined, + [tlstate] + ) - useHotkeys('ctrl+shift-u,command+shift+u', () => { - tlstate.redoSelect() - }) + useHotkeys( + 'ctrl+shift-u,command+shift+u', + () => { + tlstate.redoSelect() + }, + undefined, + [tlstate] + ) /* -------------------- Commands -------------------- */ // Camera - useHotkeys('ctrl+=,command+=', (e) => { - tlstate.zoomIn() - e.preventDefault() - }) + useHotkeys( + 'ctrl+=,command+=', + (e) => { + tlstate.zoomIn() + e.preventDefault() + }, + undefined, + [tlstate] + ) - useHotkeys('ctrl+-,command+-', (e) => { - tlstate.zoomOut() - e.preventDefault() - }) + useHotkeys( + 'ctrl+-,command+-', + (e) => { + tlstate.zoomOut() + e.preventDefault() + }, + undefined, + [tlstate] + ) - useHotkeys('shift+1', () => { - tlstate.zoomToFit() - }) + useHotkeys( + 'shift+1', + () => { + tlstate.zoomToFit() + }, + undefined, + [tlstate] + ) - useHotkeys('shift+2', () => { - tlstate.zoomToSelection() - }) + useHotkeys( + 'shift+2', + () => { + tlstate.zoomToSelection() + }, + undefined, + [tlstate] + ) - useHotkeys('shift+0', () => { - tlstate.zoomToActual() - }) + useHotkeys( + 'shift+0', + () => { + tlstate.zoomToActual() + }, + undefined, + [tlstate] + ) // Duplicate - useHotkeys('ctrl+d,command+d', (e) => { - tlstate.duplicate() - e.preventDefault() - }) + useHotkeys( + 'ctrl+d,command+d', + (e) => { + tlstate.duplicate() + e.preventDefault() + }, + undefined, + [tlstate] + ) // Flip - useHotkeys('shift+h', () => { - tlstate.flipHorizontal() - }) + useHotkeys( + 'shift+h', + () => { + tlstate.flipHorizontal() + }, + undefined, + [tlstate] + ) - useHotkeys('shift+v', () => { - tlstate.flipVertical() - }) + useHotkeys( + 'shift+v', + () => { + tlstate.flipVertical() + }, + undefined, + [tlstate] + ) // Cancel - useHotkeys('escape', () => { - tlstate.cancel() - }) + useHotkeys( + 'escape', + () => { + tlstate.cancel() + }, + undefined, + [tlstate] + ) // Delete - useHotkeys('backspace', () => { - tlstate.delete() - }) + useHotkeys( + 'backspace', + () => { + tlstate.delete() + }, + undefined, + [tlstate] + ) // Select All - useHotkeys('command+a,ctrl+a', () => { - tlstate.selectAll() - }) + useHotkeys( + 'command+a,ctrl+a', + () => { + tlstate.selectAll() + }, + undefined, + [tlstate] + ) // Nudge - useHotkeys('up', () => { - tlstate.nudge([0, -1], false) - }) + useHotkeys( + 'up', + () => { + tlstate.nudge([0, -1], false) + }, + undefined, + [tlstate] + ) - useHotkeys('right', () => { - tlstate.nudge([1, 0], false) - }) + useHotkeys( + 'right', + () => { + tlstate.nudge([1, 0], false) + }, + undefined, + [tlstate] + ) - useHotkeys('down', () => { - tlstate.nudge([0, 1], false) - }) + useHotkeys( + 'down', + () => { + tlstate.nudge([0, 1], false) + }, + undefined, + [tlstate] + ) - useHotkeys('left', () => { - tlstate.nudge([-1, 0], false) - }) + useHotkeys( + 'left', + () => { + tlstate.nudge([-1, 0], false) + }, + undefined, + [tlstate] + ) - useHotkeys('shift+up', () => { - tlstate.nudge([0, -1], true) - }) + useHotkeys( + 'shift+up', + () => { + tlstate.nudge([0, -1], true) + }, + undefined, + [tlstate] + ) - useHotkeys('shift+right', () => { - tlstate.nudge([1, 0], true) - }) + useHotkeys( + 'shift+right', + () => { + tlstate.nudge([1, 0], true) + }, + undefined, + [tlstate] + ) - useHotkeys('shift+down', () => { - tlstate.nudge([0, 1], true) - }) + useHotkeys( + 'shift+down', + () => { + tlstate.nudge([0, 1], true) + }, + undefined, + [tlstate] + ) - useHotkeys('shift+left', () => { - tlstate.nudge([-1, 0], true) - }) + useHotkeys( + 'shift+left', + () => { + tlstate.nudge([-1, 0], true) + }, + undefined, + [tlstate] + ) // Copy & Paste - useHotkeys('command+c,ctrl+c', () => { - tlstate.copy() - }) + useHotkeys( + 'command+c,ctrl+c', + () => { + tlstate.copy() + }, + undefined, + [tlstate] + ) - useHotkeys('command+v,ctrl+v', () => { - tlstate.paste() - }) + useHotkeys( + 'command+v,ctrl+v', + () => { + tlstate.paste() + }, + undefined, + [tlstate] + ) // Group & Ungroup - useHotkeys('command+g,ctrl+g', (e) => { - tlstate.group() - e.preventDefault() - }) + useHotkeys( + 'command+g,ctrl+g', + (e) => { + tlstate.group() + e.preventDefault() + }, + undefined, + [tlstate] + ) - useHotkeys('command+shift+g,ctrl+shift+g', (e) => { - tlstate.ungroup() - e.preventDefault() - }) + useHotkeys( + 'command+shift+g,ctrl+shift+g', + (e) => { + tlstate.ungroup() + e.preventDefault() + }, + undefined, + [tlstate] + ) // Move - useHotkeys('[', () => { - tlstate.moveBackward() - }) + useHotkeys( + '[', + () => { + tlstate.moveBackward() + }, + undefined, + [tlstate] + ) - useHotkeys(']', () => { - tlstate.moveForward() - }) + useHotkeys( + ']', + () => { + tlstate.moveForward() + }, + undefined, + [tlstate] + ) - useHotkeys('shift+[', () => { - tlstate.moveToBack() - }) + useHotkeys( + 'shift+[', + () => { + tlstate.moveToBack() + }, + undefined, + [tlstate] + ) - useHotkeys('shift+]', () => { - tlstate.moveToFront() - }) + useHotkeys( + 'shift+]', + () => { + tlstate.moveToFront() + }, + undefined, + [tlstate] + ) - useHotkeys('command+shift+backspace', (e) => { - tlstate.resetDocument() - e.preventDefault() - }) + useHotkeys( + 'command+shift+backspace', + (e) => { + tlstate.resetDocument() + e.preventDefault() + }, + undefined, + [tlstate] + ) } diff --git a/packages/tldraw/src/state/tlstate.spec.ts b/packages/tldraw/src/state/tlstate.spec.ts index 02a59373d..d87ce7874 100644 --- a/packages/tldraw/src/state/tlstate.spec.ts +++ b/packages/tldraw/src/state/tlstate.spec.ts @@ -445,4 +445,27 @@ describe('TLDrawState', () => { expect(tlstate2.shapes.length).toBe(1) }) + + describe('when the document prop changes', () => { + it.todo('updates the document if the new id is the same as the old one') + + it.todo('replaces the document if the ids are different') + }) + /* + We want to be able to use the `document` property to update the + document without blowing out the current state. For example, we + may want to patch in changes that occurred from another user. + + When the `document` prop changes in the TLDraw component, we want + to update the document in a way that preserves the identity of as + much as possible, while still protecting against invalid states. + + If this isn't possible, then we should guide the developer to + instead use a helper like `patchDocument` to update the document. + + If the `id` property of the new document is the same as the + previous document, then we call `updateDocument`. Otherwise, we + call `replaceDocument`, which does a harder reset of the state's + internal state. + */ }) diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts index 34107be69..848ba31ed 100644 --- a/packages/tldraw/src/state/tlstate.ts +++ b/packages/tldraw/src/state/tlstate.ts @@ -97,6 +97,10 @@ const defaultState: Data = { } export class TLDrawState extends StateManager { + get id() { + return this._idbId + } + private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void private _onMount?: (tlstate: TLDrawState) => void @@ -482,62 +486,50 @@ export class TLDrawState extends StateManager { * @param document */ updateDocument = (document: TLDrawDocument, reason = 'updated_document'): this => { - console.log(reason) + const prevState = this.state - const state = this.state + const nextState = { ...prevState, document: { ...prevState.document } } - const currentPageId = document.pages[this.currentPageId] - ? this.currentPageId - : Object.keys(document.pages)[0] + if (!document.pages[this.currentPageId]) { + nextState.appState = { + ...prevState.appState, + currentPageId: Object.keys(document.pages)[0], + } + } - this.replaceState( - { - ...this.state, - appState: { - ...this.appState, - currentPageId, - }, - document: { - ...document, - pages: Object.fromEntries( - Object.entries(document.pages) - .sort((a, b) => (a[1].childIndex || 0) - (b[1].childIndex || 0)) - .map(([pageId, page], i) => { - const nextPage = { ...page } - if (!nextPage.name) nextPage.name = `Page ${i + 1}` - return [pageId, nextPage] - }) - ), - pageStates: Object.fromEntries( - Object.entries(document.pageStates).map(([pageId, pageState]) => { - const page = document.pages[pageId] - const nextPageState = { ...pageState } - const keysToCheck = ['bindingId', 'editingId', 'hoveredId', 'pointedId'] as const + let i = 1 - for (const key of keysToCheck) { - if (!page.shapes[key]) { - nextPageState[key] = undefined - } - } + for (const nextPage of Object.values(document.pages)) { + if (nextPage !== prevState.document.pages[nextPage.id]) { + nextState.document.pages[nextPage.id] = nextPage - nextPageState.selectedIds = pageState.selectedIds.filter( - (id) => !!document.pages[pageId].shapes[id] - ) + if (!nextPage.name) { + nextState.document.pages[nextPage.id].name = `Page ${i + 1}` + i++ + } + } + } - return [pageId, nextPageState] - }) - ), - }, - }, - `${reason}:${document.id}` - ) + for (const nextPageState of Object.values(document.pageStates)) { + if (nextPageState !== prevState.document.pageStates[nextPageState.id]) { + nextState.document.pageStates[nextPageState.id] = nextPageState - console.log( - 'did page change?', - this.state.document.pages['page1'] !== state.document.pages['page1'] - ) + const nextPage = document.pages[nextPageState.id] + const keysToCheck = ['bindingId', 'editingId', 'hoveredId', 'pointedId'] as const - return this + for (const key of keysToCheck) { + if (!nextPage.shapes[key]) { + nextPageState[key] = undefined + } + } + + nextPageState.selectedIds = nextPageState.selectedIds.filter( + (id) => !!document.pages[nextPage.id].shapes[id] + ) + } + } + + return this.replaceState(nextState, `${reason}:${document.id}`) } /**