Allow for resets when id changes

This commit is contained in:
Steve Ruiz 2021-09-08 11:16:10 +01:00
parent 2aeb513342
commit 4e13b0e07b
15 changed files with 509 additions and 244 deletions

View file

@ -26,7 +26,7 @@ interface CanvasProps<T extends TLShape> {
meta?: Record<string, unknown> meta?: Record<string, unknown>
} }
export const Canvas = React.memo(function Canvas<T extends TLShape>({ export function Canvas<T extends TLShape>({
page, page,
pageState, pageState,
meta, meta,
@ -66,4 +66,4 @@ export const Canvas = React.memo(function Canvas<T extends TLShape>({
</svg> </svg>
</div> </div>
) )
}) }

View file

@ -18,12 +18,7 @@ interface PageProps<T extends TLShape> {
/** /**
* The Page component renders the current page. * The Page component renders the current page.
* */
* ### Example
*
*```ts
* example
*``` */
export function Page<T extends TLShape>({ export function Page<T extends TLShape>({
page, page,
pageState, pageState,

View file

@ -10,10 +10,15 @@ import type {
TLBinding, TLBinding,
} from '../../types' } from '../../types'
import { Canvas } from '../canvas' import { Canvas } from '../canvas'
import { useTLTheme, TLContext } from '../../hooks' import { useTLTheme, TLContext, TLContextType } from '../../hooks'
export interface RendererProps<T extends TLShape, M extends Record<string, unknown>> export interface RendererProps<T extends TLShape, M extends Record<string, unknown>>
extends Partial<TLCallbacks> { extends Partial<TLCallbacks> {
/**
* 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. * An object containing instances of your shape classes.
*/ */
@ -52,6 +57,8 @@ export interface RendererProps<T extends TLShape, M extends Record<string, unkno
* An object of custom options that should be passed to rendered shapes. * An object of custom options that should be passed to rendered shapes.
*/ */
meta?: M meta?: M
// Temp
onTest?: () => void
} }
/** /**
@ -63,6 +70,7 @@ export interface RendererProps<T extends TLShape, M extends Record<string, unkno
* @returns * @returns
*/ */
export function Renderer<T extends TLShape, M extends Record<string, unknown>>({ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
id,
shapeUtils, shapeUtils,
page, page,
pageState, pageState,
@ -82,13 +90,31 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
rPageState.current = pageState rPageState.current = pageState
}, [pageState]) }, [pageState])
const [context] = React.useState(() => ({ const rId = React.useRef(id)
const [context, setContext] = React.useState<TLContextType>(() => ({
id,
callbacks: rest, callbacks: rest,
shapeUtils, shapeUtils,
rScreenBounds, rScreenBounds,
rPageState, rPageState,
})) }))
React.useEffect(() => {
if (id !== rId.current) {
rest.onTest?.()
setContext({
id,
callbacks: rest,
shapeUtils,
rScreenBounds,
rPageState,
})
rId.current = id
}
}, [id])
return ( return (
<TLContext.Provider value={context}> <TLContext.Provider value={context}>
<Canvas <Canvas

View file

@ -4,8 +4,7 @@ import type { IShapeTreeNode } from '+types'
import { RenderedShape } from './rendered-shape' import { RenderedShape } from './rendered-shape'
import { EditingTextShape } from './editing-text-shape' import { EditingTextShape } from './editing-text-shape'
export const Shape = React.memo( export const Shape = <M extends Record<string, unknown>>({
<M extends Record<string, unknown>>({
shape, shape,
isEditing, isEditing,
isBinding, isBinding,
@ -55,4 +54,3 @@ export const Shape = React.memo(
</g> </g>
) )
} }
)

View file

@ -1,10 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { inputs } from '+inputs' import { inputs } from '+inputs'
import { useTLContext } from './useTLContext'
import { Utils } from '+utils' import { Utils } from '+utils'
import { TLContext } from '+hooks'
export function useShapeEvents(id: string, disable = false) { export function useShapeEvents(id: string, disable = false) {
const { rPageState, rScreenBounds, callbacks } = useTLContext() const { rPageState, rScreenBounds, callbacks } = React.useContext(TLContext)
const onPointerDown = React.useCallback( const onPointerDown = React.useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {

View file

@ -2,6 +2,7 @@ import * as React from 'react'
import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types' import type { TLCallbacks, TLShape, TLBounds, TLPageState, TLShapeUtils } from '+types'
export interface TLContextType { export interface TLContextType {
id?: string
callbacks: Partial<TLCallbacks> callbacks: Partial<TLCallbacks>
shapeUtils: TLShapeUtils<TLShape> shapeUtils: TLShapeUtils<TLShape>
rPageState: React.MutableRefObject<TLPageState> rPageState: React.MutableRefObject<TLPageState>

View file

@ -1,7 +1,8 @@
import * as React from 'react' import * as React from 'react'
import Controlled from './controlled' import Controlled from './controlled'
import Basic from './basic' import Basic from './basic'
import NewId from './newId'
export default function App(): JSX.Element { export default function App(): JSX.Element {
return <Controlled /> return <NewId />
} }

View file

@ -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 <TLDraw id={id} />
}

View file

@ -359,8 +359,6 @@ function MoveToPageMenu(): JSX.Element | null {
if (sorted.length === 0) return null if (sorted.length === 0) return null
console.log(sorted)
return ( return (
<ContextMenuRoot> <ContextMenuRoot>
<RadixContextMenu.TriggerItem as={RowButton} bp={breakpoints}> <RadixContextMenu.TriggerItem as={RowButton} bp={breakpoints}>

View file

@ -51,29 +51,41 @@ export interface TLDrawProps {
} }
export function TLDraw({ id, document, currentPageId, onMount, onChange }: 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 [tlstate, setTlstate] = React.useState(() => new TLDrawState(id))
const [context, setContext] = React.useState(() => ({ tlstate, useSelector: tlstate.useStore }))
React.useEffect(() => { React.useEffect(() => {
setTlstate(new TLDrawState(id, onChange, onMount)) if (id === sId) return
}, [id]) // 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(() => { // Use the `key` to ensure that new selector hooks are made when the id changes
return { tlstate, useSelector: tlstate.useStore }
})
return ( return (
<TLDrawContext.Provider value={context}> <TLDrawContext.Provider value={context}>
<IdProvider> <IdProvider>
<InnerTldraw currentPageId={currentPageId} document={document} /> <InnerTldraw
key={sId || 'tldraw'}
id={sId}
currentPageId={currentPageId}
document={document}
/>
</IdProvider> </IdProvider>
</TLDrawContext.Provider> </TLDrawContext.Provider>
) )
} }
function InnerTldraw({ function InnerTldraw({
id,
currentPageId, currentPageId,
document, document,
}: { }: {
id?: string
currentPageId?: string currentPageId?: string
document?: TLDrawDocument document?: TLDrawDocument
}) { }) {
@ -138,10 +150,16 @@ function InnerTldraw({
tlstate.changePage(currentPageId) tlstate.changePage(currentPageId)
}, [currentPageId, tlstate]) }, [currentPageId, tlstate])
React.useEffect(() => {
'Id Changed!'
console.log(id, tlstate.id)
}, [id])
return ( return (
<Layout> <Layout>
<ContextMenu> <ContextMenu>
<Renderer <Renderer
id={id}
page={page} page={page}
pageState={pageState} pageState={pageState}
shapeUtils={tldrawShapeUtils} shapeUtils={tldrawShapeUtils}

View file

@ -7,14 +7,14 @@ const statusSelector = (s: Data) => s.appState.status.current
const activeToolSelector = (s: Data) => s.appState.activeTool const activeToolSelector = (s: Data) => s.appState.activeTool
export function StatusBar(): JSX.Element | null { export function StatusBar(): JSX.Element | null {
const { useSelector } = useTLDrawContext() const { tlstate, useSelector } = useTLDrawContext()
const status = useSelector(statusSelector) const status = useSelector(statusSelector)
const activeTool = useSelector(activeToolSelector) const activeTool = useSelector(activeToolSelector)
return ( return (
<StatusBarContainer size={{ '@sm': 'small' }}> <StatusBarContainer size={{ '@sm': 'small' }}>
<Section> <Section>
{activeTool} | {status} {tlstate.id} | {activeTool} | {status}
</Section> </Section>
</StatusBarContainer> </StatusBarContainer>
) )

View file

@ -32,11 +32,15 @@ export const ToolsPanel = React.memo((): JSX.Element => {
const isDebugMode = useSelector(isDebugModeSelector) const isDebugMode = useSelector(isDebugModeSelector)
console.log(activeTool)
const selectSelectTool = React.useCallback(() => { const selectSelectTool = React.useCallback(() => {
console.log(tlstate.id)
tlstate.selectTool('select') tlstate.selectTool('select')
}, [tlstate]) }, [tlstate])
const selectDrawTool = React.useCallback(() => { const selectDrawTool = React.useCallback(() => {
console.log(tlstate.id)
tlstate.selectTool(TLDrawShapeType.Draw) tlstate.selectTool(TLDrawShapeType.Draw)
}, [tlstate]) }, [tlstate])

View file

@ -29,195 +29,390 @@ export function useKeyboardShortcuts() {
/* ---------------------- Tools --------------------- */ /* ---------------------- Tools --------------------- */
useHotkeys('v,1', () => { useHotkeys(
'v,1',
() => {
tlstate.selectTool('select') tlstate.selectTool('select')
}) },
undefined,
[tlstate]
)
useHotkeys('d,2', () => { useHotkeys(
'd,2',
() => {
tlstate.selectTool(TLDrawShapeType.Draw) tlstate.selectTool(TLDrawShapeType.Draw)
}) },
undefined,
[tlstate]
)
useHotkeys('r,3', () => { useHotkeys(
'r,3',
() => {
tlstate.selectTool(TLDrawShapeType.Rectangle) tlstate.selectTool(TLDrawShapeType.Rectangle)
}) },
undefined,
[tlstate]
)
useHotkeys('e,4', () => { useHotkeys(
'e,4',
() => {
tlstate.selectTool(TLDrawShapeType.Ellipse) tlstate.selectTool(TLDrawShapeType.Ellipse)
}) },
undefined,
[tlstate]
)
useHotkeys('a,5', () => { useHotkeys(
'a,5',
() => {
tlstate.selectTool(TLDrawShapeType.Arrow) tlstate.selectTool(TLDrawShapeType.Arrow)
}) },
undefined,
[tlstate]
)
useHotkeys('t,6', () => { useHotkeys(
't,6',
() => {
tlstate.selectTool(TLDrawShapeType.Text) tlstate.selectTool(TLDrawShapeType.Text)
}) },
undefined,
[tlstate]
)
/* ---------------------- Misc ---------------------- */ /* ---------------------- Misc ---------------------- */
// Save // Save
useHotkeys('ctrl+s,command+s', () => { useHotkeys(
'ctrl+s,command+s',
() => {
tlstate.saveProject() tlstate.saveProject()
}) },
undefined,
[tlstate]
)
// Undo Redo // Undo Redo
useHotkeys('command+z,ctrl+z', () => { useHotkeys(
'command+z,ctrl+z',
() => {
tlstate.undo() tlstate.undo()
}) },
undefined,
[tlstate]
)
useHotkeys('ctrl+shift-z,command+shift+z', () => { useHotkeys(
'ctrl+shift-z,command+shift+z',
() => {
tlstate.redo() tlstate.redo()
}) },
undefined,
[tlstate]
)
// Undo Redo // Undo Redo
useHotkeys('command+u,ctrl+u', () => { useHotkeys(
'command+u,ctrl+u',
() => {
tlstate.undoSelect() tlstate.undoSelect()
}) },
undefined,
[tlstate]
)
useHotkeys('ctrl+shift-u,command+shift+u', () => { useHotkeys(
'ctrl+shift-u,command+shift+u',
() => {
tlstate.redoSelect() tlstate.redoSelect()
}) },
undefined,
[tlstate]
)
/* -------------------- Commands -------------------- */ /* -------------------- Commands -------------------- */
// Camera // Camera
useHotkeys('ctrl+=,command+=', (e) => { useHotkeys(
'ctrl+=,command+=',
(e) => {
tlstate.zoomIn() tlstate.zoomIn()
e.preventDefault() e.preventDefault()
}) },
undefined,
[tlstate]
)
useHotkeys('ctrl+-,command+-', (e) => { useHotkeys(
'ctrl+-,command+-',
(e) => {
tlstate.zoomOut() tlstate.zoomOut()
e.preventDefault() e.preventDefault()
}) },
undefined,
[tlstate]
)
useHotkeys('shift+1', () => { useHotkeys(
'shift+1',
() => {
tlstate.zoomToFit() tlstate.zoomToFit()
}) },
undefined,
[tlstate]
)
useHotkeys('shift+2', () => { useHotkeys(
'shift+2',
() => {
tlstate.zoomToSelection() tlstate.zoomToSelection()
}) },
undefined,
[tlstate]
)
useHotkeys('shift+0', () => { useHotkeys(
'shift+0',
() => {
tlstate.zoomToActual() tlstate.zoomToActual()
}) },
undefined,
[tlstate]
)
// Duplicate // Duplicate
useHotkeys('ctrl+d,command+d', (e) => { useHotkeys(
'ctrl+d,command+d',
(e) => {
tlstate.duplicate() tlstate.duplicate()
e.preventDefault() e.preventDefault()
}) },
undefined,
[tlstate]
)
// Flip // Flip
useHotkeys('shift+h', () => { useHotkeys(
'shift+h',
() => {
tlstate.flipHorizontal() tlstate.flipHorizontal()
}) },
undefined,
[tlstate]
)
useHotkeys('shift+v', () => { useHotkeys(
'shift+v',
() => {
tlstate.flipVertical() tlstate.flipVertical()
}) },
undefined,
[tlstate]
)
// Cancel // Cancel
useHotkeys('escape', () => { useHotkeys(
'escape',
() => {
tlstate.cancel() tlstate.cancel()
}) },
undefined,
[tlstate]
)
// Delete // Delete
useHotkeys('backspace', () => { useHotkeys(
'backspace',
() => {
tlstate.delete() tlstate.delete()
}) },
undefined,
[tlstate]
)
// Select All // Select All
useHotkeys('command+a,ctrl+a', () => { useHotkeys(
'command+a,ctrl+a',
() => {
tlstate.selectAll() tlstate.selectAll()
}) },
undefined,
[tlstate]
)
// Nudge // Nudge
useHotkeys('up', () => { useHotkeys(
'up',
() => {
tlstate.nudge([0, -1], false) tlstate.nudge([0, -1], false)
}) },
undefined,
[tlstate]
)
useHotkeys('right', () => { useHotkeys(
'right',
() => {
tlstate.nudge([1, 0], false) tlstate.nudge([1, 0], false)
}) },
undefined,
[tlstate]
)
useHotkeys('down', () => { useHotkeys(
'down',
() => {
tlstate.nudge([0, 1], false) tlstate.nudge([0, 1], false)
}) },
undefined,
[tlstate]
)
useHotkeys('left', () => { useHotkeys(
'left',
() => {
tlstate.nudge([-1, 0], false) tlstate.nudge([-1, 0], false)
}) },
undefined,
[tlstate]
)
useHotkeys('shift+up', () => { useHotkeys(
'shift+up',
() => {
tlstate.nudge([0, -1], true) tlstate.nudge([0, -1], true)
}) },
undefined,
[tlstate]
)
useHotkeys('shift+right', () => { useHotkeys(
'shift+right',
() => {
tlstate.nudge([1, 0], true) tlstate.nudge([1, 0], true)
}) },
undefined,
[tlstate]
)
useHotkeys('shift+down', () => { useHotkeys(
'shift+down',
() => {
tlstate.nudge([0, 1], true) tlstate.nudge([0, 1], true)
}) },
undefined,
[tlstate]
)
useHotkeys('shift+left', () => { useHotkeys(
'shift+left',
() => {
tlstate.nudge([-1, 0], true) tlstate.nudge([-1, 0], true)
}) },
undefined,
[tlstate]
)
// Copy & Paste // Copy & Paste
useHotkeys('command+c,ctrl+c', () => { useHotkeys(
'command+c,ctrl+c',
() => {
tlstate.copy() tlstate.copy()
}) },
undefined,
[tlstate]
)
useHotkeys('command+v,ctrl+v', () => { useHotkeys(
'command+v,ctrl+v',
() => {
tlstate.paste() tlstate.paste()
}) },
undefined,
[tlstate]
)
// Group & Ungroup // Group & Ungroup
useHotkeys('command+g,ctrl+g', (e) => { useHotkeys(
'command+g,ctrl+g',
(e) => {
tlstate.group() tlstate.group()
e.preventDefault() e.preventDefault()
}) },
undefined,
[tlstate]
)
useHotkeys('command+shift+g,ctrl+shift+g', (e) => { useHotkeys(
'command+shift+g,ctrl+shift+g',
(e) => {
tlstate.ungroup() tlstate.ungroup()
e.preventDefault() e.preventDefault()
}) },
undefined,
[tlstate]
)
// Move // Move
useHotkeys('[', () => { useHotkeys(
'[',
() => {
tlstate.moveBackward() tlstate.moveBackward()
}) },
undefined,
[tlstate]
)
useHotkeys(']', () => { useHotkeys(
']',
() => {
tlstate.moveForward() tlstate.moveForward()
}) },
undefined,
[tlstate]
)
useHotkeys('shift+[', () => { useHotkeys(
'shift+[',
() => {
tlstate.moveToBack() tlstate.moveToBack()
}) },
undefined,
[tlstate]
)
useHotkeys('shift+]', () => { useHotkeys(
'shift+]',
() => {
tlstate.moveToFront() tlstate.moveToFront()
}) },
undefined,
[tlstate]
)
useHotkeys('command+shift+backspace', (e) => { useHotkeys(
'command+shift+backspace',
(e) => {
tlstate.resetDocument() tlstate.resetDocument()
e.preventDefault() e.preventDefault()
}) },
undefined,
[tlstate]
)
} }

View file

@ -445,4 +445,27 @@ describe('TLDrawState', () => {
expect(tlstate2.shapes.length).toBe(1) 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.
*/
}) })

View file

@ -97,6 +97,10 @@ const defaultState: Data = {
} }
export class TLDrawState extends StateManager<Data> { export class TLDrawState extends StateManager<Data> {
get id() {
return this._idbId
}
private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void
private _onMount?: (tlstate: TLDrawState) => void private _onMount?: (tlstate: TLDrawState) => void
@ -482,62 +486,50 @@ export class TLDrawState extends StateManager<Data> {
* @param document * @param document
*/ */
updateDocument = (document: TLDrawDocument, reason = 'updated_document'): this => { 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] if (!document.pages[this.currentPageId]) {
? this.currentPageId nextState.appState = {
: Object.keys(document.pages)[0] ...prevState.appState,
currentPageId: Object.keys(document.pages)[0],
}
}
this.replaceState( let i = 1
{
...this.state, for (const nextPage of Object.values(document.pages)) {
appState: { if (nextPage !== prevState.document.pages[nextPage.id]) {
...this.appState, nextState.document.pages[nextPage.id] = nextPage
currentPageId,
}, if (!nextPage.name) {
document: { nextState.document.pages[nextPage.id].name = `Page ${i + 1}`
...document, i++
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 } for (const nextPageState of Object.values(document.pageStates)) {
if (!nextPage.name) nextPage.name = `Page ${i + 1}` if (nextPageState !== prevState.document.pageStates[nextPageState.id]) {
return [pageId, nextPage] nextState.document.pageStates[nextPageState.id] = nextPageState
})
), const nextPage = document.pages[nextPageState.id]
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 const keysToCheck = ['bindingId', 'editingId', 'hoveredId', 'pointedId'] as const
for (const key of keysToCheck) { for (const key of keysToCheck) {
if (!page.shapes[key]) { if (!nextPage.shapes[key]) {
nextPageState[key] = undefined nextPageState[key] = undefined
} }
} }
nextPageState.selectedIds = pageState.selectedIds.filter( nextPageState.selectedIds = nextPageState.selectedIds.filter(
(id) => !!document.pages[pageId].shapes[id] (id) => !!document.pages[nextPage.id].shapes[id]
) )
}
}
return [pageId, nextPageState] return this.replaceState(nextState, `${reason}:${document.id}`)
})
),
},
},
`${reason}:${document.id}`
)
console.log(
'did page change?',
this.state.document.pages['page1'] !== state.document.pages['page1']
)
return this
} }
/** /**