Fix text bug on firefox, translate cloning for grouped shapes

This commit is contained in:
Steve Ruiz 2021-09-02 21:13:54 +01:00
parent a3ddfca0be
commit fe9ff2dc2d
13 changed files with 291 additions and 269 deletions

View file

@ -39,11 +39,13 @@ export interface RendererProps<T extends TLShape, M extends Record<string, unkno
*/ */
hideHandles?: boolean hideHandles?: boolean
/** /**
* When true, the renderer will not show indicators for selected or hovered objects, * When true, the renderer will not show indicators for selected or
* hovered objects,
*/ */
hideIndicators?: boolean hideIndicators?: boolean
/** /**
* When true, the renderer will ignore all inputs that were not made by a stylus or pen-type device. * When true, the renderer will ignore all inputs that were not made
* by a stylus or pen-type device.
*/ */
isPenMode?: boolean isPenMode?: boolean
/** /**
@ -53,24 +55,10 @@ export interface RendererProps<T extends TLShape, M extends Record<string, unkno
} }
/** /**
The Renderer component is the main component of the library. It accepts the current `page`, the `shapeUtils` needed to interpret and render the shapes and bindings on the `page`, and the current `pageState`. * The Renderer component is the main component of the library. It
* accepts the current `page`, the `shapeUtils` needed to interpret
* It also (optionally) accepts several settings and visibility flags, * and render the shapes and bindings on the `page`, and the current
* a `theme` to use, and callbacks to respond to various user interactions. * `pageState`.
*
* ### Example
*
*```tsx
* <Renderer
* shapeUtils={shapeUtils}
* page={page}
* pageState={pageState}
* />
*```
*/
/**
* The Renderer component is the main component of the library. It accepts the current `page`, the `shapeUtils` needed to interpret and render the shapes and bindings on the `page`, and the current `pageState`.
* @param props * @param props
* @returns * @returns
*/ */
@ -86,6 +74,7 @@ export function Renderer<T extends TLShape, M extends Record<string, unknown>>({
...rest ...rest
}: RendererProps<T, M>): JSX.Element { }: RendererProps<T, M>): JSX.Element {
useTLTheme(theme) useTLTheme(theme)
const rScreenBounds = React.useRef<TLBounds>(null) const rScreenBounds = React.useRef<TLBounds>(null)
const rPageState = React.useRef<TLPageState>(pageState) const rPageState = React.useRef<TLPageState>(pageState)

View file

@ -23,6 +23,15 @@ export function EditingTextShape({
const ref = React.useRef<HTMLElement>(null) const ref = React.useRef<HTMLElement>(null)
React.useEffect(() => {
// Firefox fix?
setTimeout(() => {
if (document.activeElement !== ref.current) {
ref.current?.focus()
}
}, 0)
}, [shape.id])
return utils.render(shape, { return utils.render(shape, {
ref, ref,
isEditing, isEditing,

View file

@ -5,13 +5,17 @@ import type { Data } from '~types'
const isDebugModeSelector = (s: Data) => s.settings.isDebugMode const isDebugModeSelector = (s: Data) => s.settings.isDebugMode
const isDarkModeSelector = (s: Data) => s.settings.isDarkMode const isDarkModeSelector = (s: Data) => s.settings.isDarkMode
const isZoomSnapSelector = (s: Data) => s.settings.isZoomSnap
export function Preferences() { export function Preferences() {
const { tlstate, useSelector } = useTLDrawContext() const { tlstate, useSelector } = useTLDrawContext()
const isDebugMode = useSelector(isDebugModeSelector) const isDebugMode = useSelector(isDebugModeSelector)
const isDarkMode = useSelector(isDarkModeSelector) const isDarkMode = useSelector(isDarkModeSelector)
const isZoomSnap = useSelector(isZoomSnapSelector)
const toggleDebugMode = React.useCallback(() => { const toggleDebugMode = React.useCallback(() => {
tlstate.toggleDebugMode() tlstate.toggleDebugMode()
}, [tlstate]) }, [tlstate])
@ -20,11 +24,18 @@ export function Preferences() {
tlstate.toggleDarkMode() tlstate.toggleDarkMode()
}, [tlstate]) }, [tlstate])
const toggleZoomSnap = React.useCallback(() => {
tlstate.toggleZoomSnap()
}, [tlstate])
return ( return (
<DropdownMenuSubMenu label="Preferences"> <DropdownMenuSubMenu label="Preferences">
<DropdownMenuCheckboxItem checked={isDarkMode} onCheckedChange={toggleDarkMode}> <DropdownMenuCheckboxItem checked={isDarkMode} onCheckedChange={toggleDarkMode}>
<span>Dark Mode</span> <span>Dark Mode</span>
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={isZoomSnap} onCheckedChange={toggleZoomSnap}>
<span>Zoom Snap</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={isDebugMode} onCheckedChange={toggleDebugMode}> <DropdownMenuCheckboxItem checked={isDebugMode} onCheckedChange={toggleDebugMode}>
<span>Debug Mode</span> <span>Debug Mode</span>
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>

View file

@ -4,7 +4,7 @@ import { Renderer } from '@tldraw/core'
import styled from '~styles' import styled from '~styles'
import type { Data, TLDrawDocument } from '~types' import type { Data, TLDrawDocument } from '~types'
import { TLDrawState } from '~state' import { TLDrawState } from '~state'
import { useKeyboardShortcuts, TLDrawContext, useCustomFonts } from '~hooks' import { TLDrawContext, useCustomFonts, useKeyboardShortcuts, useTLDrawContext } from '~hooks'
import { tldrawShapeUtils } from '~shape' import { tldrawShapeUtils } from '~shape'
import { ContextMenu } from '~components/context-menu' import { ContextMenu } from '~components/context-menu'
import { StylePanel } from '~components/style-panel' import { StylePanel } from '~components/style-panel'
@ -61,19 +61,45 @@ export function TLDraw({ id, document, currentPageId, onMount, onChange: _onChan
return { tlstate, useSelector: tlstate.useStore } return { tlstate, useSelector: tlstate.useStore }
}) })
useKeyboardShortcuts(tlstate) React.useEffect(() => {
if (!document) return
tlstate.loadDocument(document, _onChange)
}, [document, tlstate])
React.useEffect(() => {
if (!currentPageId) return
tlstate.changePage(currentPageId)
}, [currentPageId, tlstate])
React.useEffect(() => {
onMount?.(tlstate)
}, [])
return (
<TLDrawContext.Provider value={context}>
<IdProvider>
<InnerTldraw />
</IdProvider>
</TLDrawContext.Provider>
)
}
function InnerTldraw() {
useCustomFonts() useCustomFonts()
const page = context.useSelector(pageSelector) const { tlstate, useSelector } = useTLDrawContext()
const pageState = context.useSelector(pageStateSelector) useKeyboardShortcuts()
const isDarkMode = context.useSelector(isDarkModeSelector) const page = useSelector(pageSelector)
const isSelecting = context.useSelector(isInSelectSelector) const pageState = useSelector(pageStateSelector)
const isSelectedHandlesShape = context.useSelector(isSelectedShapeWithHandlesSelector) const isDarkMode = useSelector(isDarkModeSelector)
const isSelecting = useSelector(isInSelectSelector)
const isSelectedHandlesShape = useSelector(isSelectedShapeWithHandlesSelector)
const isInSession = tlstate.session !== undefined const isInSession = tlstate.session !== undefined
@ -89,20 +115,7 @@ export function TLDraw({ id, document, currentPageId, onMount, onChange: _onChan
// Custom rendering meta, with dark mode for shapes // Custom rendering meta, with dark mode for shapes
const meta = React.useMemo(() => ({ isDarkMode }), [isDarkMode]) const meta = React.useMemo(() => ({ isDarkMode }), [isDarkMode])
React.useEffect(() => { // Custom theme, based on darkmode
if (!document) return
tlstate.loadDocument(document, _onChange)
}, [document, tlstate])
React.useEffect(() => {
if (!currentPageId) return
tlstate.changePage(currentPageId)
}, [currentPageId, tlstate])
React.useEffect(() => {
onMount?.(tlstate)
}, [])
const theme = React.useMemo(() => { const theme = React.useMemo(() => {
if (isDarkMode) { if (isDarkMode) {
return { return {
@ -119,8 +132,6 @@ export function TLDraw({ id, document, currentPageId, onMount, onChange: _onChan
}, [isDarkMode]) }, [isDarkMode])
return ( return (
<TLDrawContext.Provider value={context}>
<IdProvider>
<Layout> <Layout>
<ContextMenu> <ContextMenu>
<Renderer <Renderer
@ -191,8 +202,6 @@ export function TLDraw({ id, document, currentPageId, onMount, onChange: _onChan
<StylePanel /> <StylePanel />
<ToolsPanel /> <ToolsPanel />
</Layout> </Layout>
</IdProvider>
</TLDrawContext.Provider>
) )
} }

View file

@ -2,9 +2,11 @@ import * as React from 'react'
import { inputs } from '@tldraw/core' import { inputs } from '@tldraw/core'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { TLDrawShapeType } from '~types' import { TLDrawShapeType } from '~types'
import type { TLDrawState } from '~state' import { useTLDrawContext } from '~hooks'
export function useKeyboardShortcuts() {
const { tlstate } = useTLDrawContext()
export function useKeyboardShortcuts(tlstate: TLDrawState) {
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const info = inputs.keydown(e) const info = inputs.keydown(e)
@ -18,6 +20,7 @@ export function useKeyboardShortcuts(tlstate: TLDrawState) {
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp) window.addEventListener('keyup', handleKeyUp)
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp) window.removeEventListener('keyup', handleKeyUp)

View file

@ -107,7 +107,6 @@ export class Text extends TLDrawShapeUtil<TextShape> {
const { id, text, style } = shape const { id, text, style } = shape
const styles = getShapeStyle(style, meta.isDarkMode) const styles = getShapeStyle(style, meta.isDarkMode)
const font = getFontStyle(shape.style) const font = getFontStyle(shape.style)
const bounds = this.getBounds(shape) const bounds = this.getBounds(shape)
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) { function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
@ -150,9 +149,11 @@ export class Text extends TLDrawShapeUtil<TextShape> {
} }
function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) { function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) {
if (document.activeElement === e.currentTarget) {
e.currentTarget.select() e.currentTarget.select()
onTextFocus?.(id) onTextFocus?.(id)
} }
}
function handlePointerDown() { function handlePointerDown() {
if (ref && ref.current.selectionEnd !== 0) { if (ref && ref.current.selectionEnd !== 0) {
@ -161,6 +162,7 @@ export class Text extends TLDrawShapeUtil<TextShape> {
} }
const fontSize = getFontSize(shape.style.size) * (shape.style.scale || 1) const fontSize = getFontSize(shape.style.size) * (shape.style.scale || 1)
const lineHeight = fontSize * 1.3 const lineHeight = fontSize * 1.3
if (!isEditing) { if (!isEditing) {

View file

@ -118,6 +118,7 @@ export class ArrowSession implements Session {
// From all bindable shapes on the page... // From all bindable shapes on the page...
for (const id of this.bindableShapeIds) { for (const id of this.bindableShapeIds) {
if (id === initialShape.id) continue if (id === initialShape.id) continue
if (id === shape.parentId) continue
if (id === oppositeBinding?.toId) continue if (id === oppositeBinding?.toId) continue
target = TLDR.getShape(data, id, data.appState.currentPageId) target = TLDR.getShape(data, id, data.appState.currentPageId)
@ -345,6 +346,7 @@ function findBinding(
// From all bindable shapes on the page... // From all bindable shapes on the page...
for (const id of bindableShapeIds) { for (const id of bindableShapeIds) {
if (id === shape.id) continue if (id === shape.id) continue
if (id === shape.parentId) continue
if (id === oppositeBinding?.toId) continue if (id === oppositeBinding?.toId) continue
const target = TLDR.getShape(data, id, data.appState.currentPageId) const target = TLDR.getShape(data, id, data.appState.currentPageId)

View file

@ -1,6 +1,6 @@
import { TLDrawState } from '~state' import { TLDrawState } from '~state'
import { mockDocument } from '~test' import { mockDocument } from '~test'
import { ArrowShape, TLDrawShapeType, TLDrawStatus } from '~types' import { TLDrawShapeType, TLDrawStatus } from '~types'
describe('Translate session', () => { describe('Translate session', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
@ -111,19 +111,28 @@ describe('Translate session', () => {
}) })
it('destroys clones when last update is not cloning', () => { it('destroys clones when last update is not cloning', () => {
tlstate.resetDocument().loadDocument(mockDocument)
expect(Object.keys(tlstate.getPage().shapes).length).toBe(3)
tlstate tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2') .select('rect1', 'rect2')
.startTranslateSession([10, 10]) .startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true) .updateTranslateSession([20, 20], false, true)
.updateTranslateSession([30, 30])
.completeSession() expect(Object.keys(tlstate.getPage().shapes).length).toBe(5)
tlstate.updateTranslateSession([30, 30], false, false)
expect(Object.keys(tlstate.getPage().shapes).length).toBe(3)
tlstate.completeSession()
// Original position + delta // Original position + delta
expect(tlstate.getShape('rect1').point).toStrictEqual([30, 30]) expect(tlstate.getShape('rect1').point).toStrictEqual([30, 30])
expect(tlstate.getShape('rect2').point).toStrictEqual([130, 130]) expect(tlstate.getShape('rect2').point).toStrictEqual([130, 130])
expect(Object.keys(tlstate.getPage().shapes).length).toBe(3) expect(Object.keys(tlstate.page.shapes)).toStrictEqual(['rect1', 'rect2', 'rect3'])
}) })
it('destroys bindings from the translating shape', () => { it('destroys bindings from the translating shape', () => {
@ -175,5 +184,25 @@ describe('Translate session', () => {
// it.todo('clones a shape with a parent shape') // it.todo('clones a shape with a parent shape')
// it.todo('clones a shape with children') describe('when translating a shape with children', () => {
it('translates the shapes children', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.group()
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, false)
.completeSession()
})
it('clones the shapes and children', () => {
tlstate
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.group()
.startTranslateSession([10, 10])
.updateTranslateSession([20, 20], false, true)
.completeSession()
})
})
}) })

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLPageState, Utils, Vec } from '@tldraw/core' import { TLPageState, Utils, Vec } from '@tldraw/core'
import { import {
TLDrawShape, TLDrawShape,
@ -46,8 +47,10 @@ export class TranslateSession implements Session {
} }
update = (data: Data, point: number[], isAligned = false, isCloning = false) => { update = (data: Data, point: number[], isAligned = false, isCloning = false) => {
const { clones, initialShapes, bindingsToDelete } = this.snapshot const { selectedIds, initialParentChildren, clones, initialShapes, bindingsToDelete } =
this.snapshot
const { currentPageId } = data.appState
const nextBindings: Patch<Record<string, TLDrawBinding>> = {} const nextBindings: Patch<Record<string, TLDrawBinding>> = {}
const nextShapes: Patch<Record<string, TLDrawShape>> = {} const nextShapes: Patch<Record<string, TLDrawShape>> = {}
const nextPageState: Patch<TLPageState> = {} const nextPageState: Patch<TLPageState> = {}
@ -74,26 +77,40 @@ export class TranslateSession implements Session {
this.isCloning = true this.isCloning = true
// Put back any bindings we deleted // Put back any bindings we deleted
bindingsToDelete.forEach((binding) => (nextBindings[binding.id] = binding)) bindingsToDelete.forEach((binding) => (nextBindings[binding.id] = binding))
// Move original shapes back to start // Move original shapes back to start
initialShapes.forEach((shape) => (nextShapes[shape.id] = { point: shape.point })) initialShapes.forEach((shape) => (nextShapes[shape.id] = { point: shape.point }))
// Add the clones to the page
clones.forEach((clone) => { clones.forEach((clone) => {
nextShapes[clone.id] = { ...clone, point: Vec.round(Vec.add(clone.point, delta)) } nextShapes[clone.id] = { ...clone, point: Vec.round(Vec.add(clone.point, delta)) }
// Add clones to non-selected parents
if (
clone.parentId !== data.appState.currentPageId &&
!selectedIds.includes(clone.parentId)
) {
const children =
nextShapes[clone.parentId]?.children || initialParentChildren[clone.parentId]
nextShapes[clone.parentId] = {
...nextShapes[clone.parentId],
children: [...children, clone.id],
}
}
}) })
nextPageState.selectedIds = clones.map((shape) => shape.id) // Add the cloned bindings
for (const binding of this.snapshot.clonedBindings) { for (const binding of this.snapshot.clonedBindings) {
nextBindings[binding.id] = binding nextBindings[binding.id] = binding
} }
// Set the selected ids to the clones
nextPageState.selectedIds = clones.map((clone) => clone.id)
} }
// Either way, move the clones // Either way, move the clones
clones.forEach((shape) => { clones.forEach((shape) => {
const current = (nextShapes[shape.id] || const current = (nextShapes[shape.id] ||
TLDR.getShape(data, shape.id, data.appState.currentPageId)) as TLDrawShape TLDR.getShape(data, shape.id, data.appState.currentPageId)) as TLDrawShape
@ -117,7 +134,15 @@ export class TranslateSession implements Session {
bindingsToDelete.forEach((binding) => (nextBindings[binding.id] = undefined)) bindingsToDelete.forEach((binding) => (nextBindings[binding.id] = undefined))
// Delete the clones // Delete the clones
clones.forEach((clone) => (nextShapes[clone.id] = undefined)) clones.forEach((clone) => {
nextShapes[clone.id] = undefined
if (clone.parentId !== currentPageId) {
nextShapes[clone.parentId] = {
...nextShapes[clone.parentId],
children: initialParentChildren[clone.parentId],
}
}
})
// Move the original shapes back to the cursor position // Move the original shapes back to the cursor position
initialShapes.forEach((shape) => { initialShapes.forEach((shape) => {
@ -211,9 +236,9 @@ export class TranslateSession implements Session {
const afterBindings: Patch<Record<string, TLDrawBinding>> = {} const afterBindings: Patch<Record<string, TLDrawBinding>> = {}
const afterShapes: Patch<Record<string, TLDrawShape>> = {} const afterShapes: Patch<Record<string, TLDrawShape>> = {}
clones.forEach((shape) => { clones.forEach((clone) => {
beforeShapes[shape.id] = undefined beforeShapes[clone.id] = undefined
afterShapes[shape.id] = this.isCloning ? TLDR.getShape(data, shape.id, pageId) : undefined afterShapes[clone.id] = this.isCloning ? TLDR.getShape(data, clone.id, pageId) : undefined
}) })
initialShapes.forEach((shape) => { initialShapes.forEach((shape) => {
@ -296,21 +321,27 @@ export class TranslateSession implements Session {
export function getTranslateSnapshot(data: Data) { export function getTranslateSnapshot(data: Data) {
const { currentPageId } = data.appState const { currentPageId } = data.appState
const selectedIds = TLDR.getSelectedIds(data, currentPageId) const selectedIds = TLDR.getSelectedIds(data, currentPageId)
const page = TLDR.getPage(data, currentPageId)
const selectedShapes = TLDR.getSelectedShapeSnapshot(data, currentPageId) const selectedShapes = selectedIds.flatMap((id) => TLDR.getShape(data, id, currentPageId))
const hasUnlockedShapes = selectedShapes.length > 0 const hasUnlockedShapes = selectedShapes.length > 0
const page = TLDR.getPage(data, currentPageId) const shapesToMove: TLDrawShape[] = selectedShapes
.filter((shape) => !selectedIds.includes(shape.parentId))
.flatMap((shape) => {
return shape.children
? [shape, ...shape.children!.map((childId) => TLDR.getShape(data, childId, currentPageId))]
: [shape]
})
const initialParents = Array.from(new Set(selectedShapes.map((s) => s.parentId)).values()) const initialParentChildren: Record<string, string[]> = {}
Array.from(new Set(shapesToMove.map((s) => s.parentId)).values())
.filter((id) => id !== page.id) .filter((id) => id !== page.id)
.map((id) => { .forEach((id) => {
const shape = TLDR.getShape(data, id, currentPageId) const shape = TLDR.getShape(data, id, currentPageId)
return { initialParentChildren[id] = shape.children!
id,
children: shape.children,
}
}) })
const cloneMap: Record<string, string> = {} const cloneMap: Record<string, string> = {}
@ -318,35 +349,31 @@ export function getTranslateSnapshot(data: Data) {
const clonedBindings: TLDrawBinding[] = [] const clonedBindings: TLDrawBinding[] = []
// Create clones of selected shapes // Create clones of selected shapes
const clones = selectedShapes.flatMap((shape) => { const clones: TLDrawShape[] = []
shapesToMove.forEach((shape) => {
const newId = Utils.uniqueId() const newId = Utils.uniqueId()
cloneMap[shape.id] = newId cloneMap[shape.id] = newId
const clone: TLDrawShape = { clones.push({
...Utils.deepClone(shape), ...Utils.deepClone(shape),
id: newId, id: newId,
parentId: shape.parentId, parentId: shape.parentId,
childIndex: TLDR.getChildIndexAbove(data, shape.id, currentPageId), childIndex: TLDR.getChildIndexAbove(data, shape.id, currentPageId),
})
})
clones.forEach((clone) => {
if (clone.children !== undefined) {
clone.children = clone.children.map((childId) => cloneMap[childId])
} }
})
if (!shape.children) return clone clones.forEach((clone) => {
if (selectedIds.includes(clone.parentId)) {
// If the shape has children, also create clones for the children clone.parentId = cloneMap[clone.parentId]
return [
clone,
...shape.children.map((childId) => {
const child = TLDR.getShape(data, childId, currentPageId)
const newChildId = Utils.uniqueId()
cloneMap[shape.id] = newChildId
return {
...Utils.deepClone(child),
id: newChildId,
parentId: shape.parentId,
childIndex: TLDR.getChildIndexAbove(data, child.id, currentPageId),
} }
}),
]
}) })
// Potentially confusing name here: these are the ids of the // Potentially confusing name here: these are the ids of the
@ -388,12 +415,18 @@ export function getTranslateSnapshot(data: Data) {
} }
}) })
clones.forEach((clone) => {
if (page.shapes[clone.id]) {
throw Error("uh oh, we didn't clone correctly")
}
})
return { return {
selectedIds, selectedIds,
bindingsToDelete, bindingsToDelete,
hasUnlockedShapes, hasUnlockedShapes,
initialParents, initialParentChildren,
initialShapes: selectedShapes.map(({ id, point, parentId }) => ({ initialShapes: shapesToMove.map(({ id, point, parentId }) => ({
id, id,
point, point,
parentId, parentId,

View file

@ -14,6 +14,7 @@ import {
Vec, Vec,
brushUpdater, brushUpdater,
TLPointerInfo, TLPointerInfo,
inputs,
} from '@tldraw/core' } from '@tldraw/core'
import { import {
FlipType, FlipType,
@ -67,6 +68,7 @@ const initialData: Data = {
settings: { settings: {
isPenMode: false, isPenMode: false,
isDarkMode: false, isDarkMode: false,
isZoomSnap: true,
isDebugMode: process.env.NODE_ENV === 'development', isDebugMode: process.env.NODE_ENV === 'development',
isReadonlyMode: false, isReadonlyMode: false,
nudgeDistanceLarge: 10, nudgeDistanceLarge: 10,
@ -92,7 +94,7 @@ const initialData: Data = {
} }
export class TLDrawState extends StateManager<Data> { export class TLDrawState extends StateManager<Data> {
_onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void
selectHistory: SelectHistory = { selectHistory: SelectHistory = {
stack: [[]], stack: [[]],
@ -112,7 +114,15 @@ export class TLDrawState extends StateManager<Data> {
isCreating = false isCreating = false
constructor(id = Utils.uniqueId()) { constructor(id = Utils.uniqueId()) {
super(initialData, id, 1) super(initialData, id, 2, (prev, next, prevVersion) => {
const state = { ...prev }
if (prevVersion === 1)
state.settings = {
...state.settings,
isZoomSnap: next.settings.isZoomSnap,
}
return state
})
this.session = undefined this.session = undefined
this.pointedId = undefined this.pointedId = undefined
@ -182,7 +192,7 @@ export class TLDrawState extends StateManager<Data> {
// (unless the group is being deleted too) // (unless the group is being deleted too)
if (parentId && parentId !== pageId) { if (parentId && parentId !== pageId) {
const group = page.shapes[parentId] const group = page.shapes[parentId]
if (group) { if (group !== undefined) {
groupsToUpdate.add(page.shapes[parentId] as GroupShape) groupsToUpdate.add(page.shapes[parentId] as GroupShape)
} }
} }
@ -352,6 +362,19 @@ export class TLDrawState extends StateManager<Data> {
return this return this
} }
/**
* Toggle zoom snap.
* @returns this
*/
toggleZoomSnap = () => {
this.patchState(
{ settings: { isZoomSnap: !this.state.settings.isZoomSnap } },
`settings:toggled_zoom_snap`
)
this.persist()
return this
}
/** /**
* Toggle debug mode. * Toggle debug mode.
* @returns this * @returns this
@ -419,6 +442,7 @@ export class TLDrawState extends StateManager<Data> {
resetDocument = (): this => { resetDocument = (): this => {
this.loadDocument(defaultDocument) this.loadDocument(defaultDocument)
this.persist()
return this return this
} }
@ -906,9 +930,8 @@ export class TLDrawState extends StateManager<Data> {
* @param next The new zoom level. * @param next The new zoom level.
* @returns this * @returns this
*/ */
zoomTo = (next: number): this => { zoomTo = (next: number, center = [window.innerWidth / 2, window.innerHeight / 2]): this => {
const { zoom, point } = this.pageState.camera const { zoom, point } = this.pageState.camera
const center = [window.innerWidth / 2, window.innerHeight / 2]
const p0 = Vec.sub(Vec.div(center, zoom), point) const p0 = Vec.sub(Vec.div(center, zoom), point)
const p1 = Vec.sub(Vec.div(center, next), point) const p1 = Vec.sub(Vec.div(center, next), point)
return this.setCamera(Vec.round(Vec.add(point, Vec.sub(p1, p0))), next, `zoomed_camera`) return this.setCamera(Vec.round(Vec.add(point, Vec.sub(p1, p0))), next, `zoomed_camera`)
@ -1534,7 +1557,6 @@ export class TLDrawState extends StateManager<Data> {
* @returns this * @returns this
*/ */
style = (style: Partial<ShapeStyles>, ids = this.selectedIds): this => { style = (style: Partial<ShapeStyles>, ids = this.selectedIds): this => {
if (ids.length === 0) return this
return this.setState(Commands.style(this.state, ids, style)) return this.setState(Commands.style(this.state, ids, style))
} }
@ -2006,7 +2028,7 @@ export class TLDrawState extends StateManager<Data> {
} }
case TLDrawStatus.Translating: { case TLDrawStatus.Translating: {
if (key === 'Shift' || key === 'Alt') { if (key === 'Shift' || key === 'Alt') {
this.updateTransformSession(this.getPagePoint(info.point), info.shiftKey, info.altKey) this.updateTranslateSession(this.getPagePoint(info.point), info.shiftKey, info.altKey)
} }
break break
} }
@ -2035,6 +2057,11 @@ export class TLDrawState extends StateManager<Data> {
} }
onPinchEnd: TLPinchEventHandler = () => { onPinchEnd: TLPinchEventHandler = () => {
if (this.state.settings.isZoomSnap) {
const i = Math.round((this.pageState.camera.zoom * 100) / 25)
const nextZoom = TLDR.getCameraZoom(i * 0.25)
this.zoomTo(nextZoom, inputs.pointer?.point)
}
this.setStatus(this.appState.status.previous) this.setStatus(this.appState.status.previous)
} }

View file

@ -11,7 +11,7 @@ export const Wrapper: React.FC = ({ children }) => {
return { tlstate, useSelector: tlstate.useStore } return { tlstate, useSelector: tlstate.useStore }
}) })
useKeyboardShortcuts(tlstate) useKeyboardShortcuts()
React.useEffect(() => { React.useEffect(() => {
if (!document) return if (!document) return

View file

@ -23,6 +23,7 @@ export interface TLDrawSettings {
isDebugMode: boolean isDebugMode: boolean
isPenMode: boolean isPenMode: boolean
isReadonlyMode: boolean isReadonlyMode: boolean
isZoomSnap: boolean
nudgeDistanceSmall: number nudgeDistanceSmall: number
nudgeDistanceLarge: number nudgeDistanceLarge: number
} }

View file

@ -1,99 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { ColorStyle, DashStyle, SizeStyle, TLDrawShapeType, TLDrawState } from '@tldraw/tldraw' import { TLDraw } from '@tldraw/tldraw'
import { TLDraw, TLDrawDocument, TLDrawPatch } from '@tldraw/tldraw'
import { usePersistence } from '../hooks/usePersistence'
const initialDoc: TLDrawDocument = {
id: 'doc',
pages: {
page1: {
id: 'page1',
shapes: {
rect1: {
id: 'rect1',
parentId: 'page1',
name: 'Rectangle',
childIndex: 1,
type: TLDrawShapeType.Rectangle,
point: [32, 32],
size: [100, 100],
style: {
dash: DashStyle.Draw,
size: SizeStyle.Medium,
color: ColorStyle.Blue,
},
},
ellipse1: {
id: 'ellipse1',
parentId: 'page1',
name: 'Ellipse',
childIndex: 2,
type: TLDrawShapeType.Ellipse,
point: [132, 132],
radius: [50, 50],
style: {
dash: DashStyle.Draw,
size: SizeStyle.Medium,
color: ColorStyle.Cyan,
},
},
draw1: {
id: 'draw1',
parentId: 'page1',
name: 'Draw',
childIndex: 3,
type: TLDrawShapeType.Draw,
point: [232, 232],
points: [
[50, 0],
[100, 100],
[0, 100],
[50, 0],
[100, 100],
[0, 100],
[50, 0],
[56, 5],
],
style: {
dash: DashStyle.Draw,
size: SizeStyle.Medium,
color: ColorStyle.Green,
},
},
},
bindings: {},
},
},
pageStates: {
page1: {
id: 'page1',
selectedIds: [],
currentParentId: 'page1',
camera: {
point: [0, 0],
zoom: 1,
},
},
},
}
export default function Editor(): JSX.Element { export default function Editor(): JSX.Element {
const { value, setValue, status } = usePersistence('doc', initialDoc) return <TLDraw id="tldraw" />
const handleChange = React.useCallback(
(tlstate: TLDrawState, patch: TLDrawPatch, reason: string) => {
if (reason.startsWith('session')) {
return
}
setValue(tlstate.document)
},
[setValue]
)
if (status === 'loading' || value === null) {
return <div />
}
return <TLDraw document={value} onChange={handleChange} />
} }