Fix text bug on firefox, translate cloning for grouped shapes
This commit is contained in:
parent
a3ddfca0be
commit
fe9ff2dc2d
13 changed files with 291 additions and 269 deletions
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} />
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue