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>
}
export const Canvas = React.memo(function Canvas<T extends TLShape>({
export function Canvas<T extends TLShape>({
page,
pageState,
meta,
@ -66,4 +66,4 @@ export const Canvas = React.memo(function Canvas<T extends TLShape>({
</svg>
</div>
)
})
}

View file

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

View file

@ -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<T extends TLShape, M extends Record<string, unknown>>
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.
*/
@ -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.
*/
meta?: M
// Temp
onTest?: () => void
}
/**
@ -63,6 +70,7 @@ export interface RendererProps<T extends TLShape, M extends Record<string, unkno
* @returns
*/
export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
id,
shapeUtils,
page,
pageState,
@ -82,13 +90,31 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
rPageState.current = pageState
}, [pageState])
const [context] = React.useState(() => ({
const rId = React.useRef(id)
const [context, setContext] = React.useState<TLContextType>(() => ({
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 (
<TLContext.Provider value={context}>
<Canvas

View file

@ -4,55 +4,53 @@ import type { IShapeTreeNode } from '+types'
import { RenderedShape } from './rendered-shape'
import { EditingTextShape } from './editing-text-shape'
export const Shape = React.memo(
<M extends Record<string, unknown>>({
shape,
isEditing,
isBinding,
isHovered,
isSelected,
isCurrentParent,
meta,
}: IShapeTreeNode<M>) => {
const { shapeUtils } = useTLContext()
const events = useShapeEvents(shape.id, isCurrentParent)
const utils = shapeUtils[shape.type]
export const Shape = <M extends Record<string, unknown>>({
shape,
isEditing,
isBinding,
isHovered,
isSelected,
isCurrentParent,
meta,
}: IShapeTreeNode<M>) => {
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 (
<g
className={isCurrentParent ? 'tl-shape-group tl-current-parent' : 'tl-shape-group'}
id={shape.id}
transform={transform}
{...events}
>
{isEditing && utils.isEditableText ? (
<EditingTextShape
shape={shape}
isBinding={false}
isCurrentParent={false}
isEditing={true}
isHovered={isHovered}
isSelected={isSelected}
utils={utils}
meta={meta}
/>
) : (
<RenderedShape
shape={shape}
utils={utils}
isBinding={isBinding}
isCurrentParent={isCurrentParent}
isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
meta={meta}
/>
)}
</g>
)
}
)
return (
<g
className={isCurrentParent ? 'tl-shape-group tl-current-parent' : 'tl-shape-group'}
id={shape.id}
transform={transform}
{...events}
>
{isEditing && utils.isEditableText ? (
<EditingTextShape
shape={shape}
isBinding={false}
isCurrentParent={false}
isEditing={true}
isHovered={isHovered}
isSelected={isSelected}
utils={utils}
meta={meta}
/>
) : (
<RenderedShape
shape={shape}
utils={utils}
isBinding={isBinding}
isCurrentParent={isCurrentParent}
isEditing={isEditing}
isHovered={isHovered}
isSelected={isSelected}
meta={meta}
/>
)}
</g>
)
}

View file

@ -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) => {

View file

@ -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<TLCallbacks>
shapeUtils: TLShapeUtils<TLShape>
rPageState: React.MutableRefObject<TLPageState>

View file

@ -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 <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
console.log(sorted)
return (
<ContextMenuRoot>
<RadixContextMenu.TriggerItem as={RowButton} bp={breakpoints}>

View file

@ -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 (
<TLDrawContext.Provider value={context}>
<IdProvider>
<InnerTldraw currentPageId={currentPageId} document={document} />
<InnerTldraw
key={sId || 'tldraw'}
id={sId}
currentPageId={currentPageId}
document={document}
/>
</IdProvider>
</TLDrawContext.Provider>
)
}
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 (
<Layout>
<ContextMenu>
<Renderer
id={id}
page={page}
pageState={pageState}
shapeUtils={tldrawShapeUtils}

View file

@ -7,14 +7,14 @@ const statusSelector = (s: Data) => 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 (
<StatusBarContainer size={{ '@sm': 'small' }}>
<Section>
{activeTool} | {status}
{tlstate.id} | {activeTool} | {status}
</Section>
</StatusBarContainer>
)

View file

@ -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])

View file

@ -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]
)
}

View file

@ -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.
*/
})

View file

@ -97,6 +97,10 @@ const defaultState: Data = {
}
export class TLDrawState extends StateManager<Data> {
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<Data> {
* @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}`)
}
/**