Adds move to page

This commit is contained in:
Steve Ruiz 2021-09-04 16:00:13 +01:00
parent dbed5f6bf4
commit 8ecddcbdbc
13 changed files with 657 additions and 199 deletions

View file

@ -179,11 +179,11 @@ export const ContextMenu = React.memo(({ children }: ContextMenuProps): JSX.Elem
<Kbd variant="menu">#[</Kbd> <Kbd variant="menu">#[</Kbd>
</ContextMenuButton> </ContextMenuButton>
</ContextMenuSubMenu> </ContextMenuSubMenu>
<MoveToPageMenu />
{hasTwoOrMore && ( {hasTwoOrMore && (
<AlignDistributeSubMenu hasTwoOrMore={hasTwoOrMore} hasThreeOrMore={hasThreeOrMore} /> <AlignDistributeSubMenu hasTwoOrMore={hasTwoOrMore} hasThreeOrMore={hasThreeOrMore} />
)} )}
<ContextMenuDivider /> <ContextMenuDivider />
{/* <MoveToPageMenu /> */}
<ContextMenuButton onSelect={handleCopy}> <ContextMenuButton onSelect={handleCopy}>
<span>Copy</span> <span>Copy</span>
<Kbd variant="menu">#C</Kbd> <Kbd variant="menu">#C</Kbd>
@ -345,38 +345,42 @@ const StyledGrid = styled(MenuContent, {
}, },
}) })
// function MoveToPageMenu() { const currentPageIdSelector = (s: Data) => s.appState.currentPageId
// const documentPages = useSelector((s) => s.data.document.pages) const documentPagesSelector = (s: Data) => s.document.pages
// const currentPageId = useSelector((s) => s.data.currentPageId)
// if (!documentPages[currentPageId]) return null function MoveToPageMenu(): JSX.Element | null {
const { tlstate, useSelector } = useTLDrawContext()
const currentPageId = useSelector(currentPageIdSelector)
const documentPages = useSelector(documentPagesSelector)
// const sorted = Object.values(documentPages) const sorted = Object.values(documentPages)
// .sort((a, b) => a.childIndex - b.childIndex) .sort((a, b) => (a.childIndex || 0) - (b.childIndex || 0))
// .filter((a) => a.id !== currentPageId) .filter((a) => a.id !== currentPageId)
// if (sorted.length === 0) return null if (sorted.length === 0) return null
// return ( console.log(sorted)
// <ContextMenuRoot>
// <ContextMenuButton> return (
// <span>Move To Page</span> <ContextMenuRoot>
// <IconWrapper size="small"> <RadixContextMenu.TriggerItem as={RowButton} bp={breakpoints}>
// <ChevronRightIcon /> <span>Move To Page</span>
// </IconWrapper> <IconWrapper size="small">
// </ContextMenuButton> <ChevronRightIcon />
// <MenuContent as={RadixContextMenu.Content} sideOffset={2} alignOffset={-2}> </IconWrapper>
// {sorted.map(({ id, name }) => ( </RadixContextMenu.TriggerItem>
// <ContextMenuButton <MenuContent as={RadixContextMenu.Content} sideOffset={2} alignOffset={-2}>
// key={id} {sorted.map(({ id, name }, i) => (
// disabled={id === currentPageId} <ContextMenuButton
// onSelect={() => state.send('MOVED_TO_PAGE', { id })} key={id}
// > disabled={id === currentPageId}
// <span>{name}</span> onSelect={() => tlstate.moveToPage(id)}
// </ContextMenuButton> >
// ))} <span>{name || `Page ${i}`}</span>
// <ContextMenuArrow offset={13} /> </ContextMenuButton>
// </MenuContent> ))}
// </ContextMenuRoot> <ContextMenuArrow offset={13} />
// ) </MenuContent>
// } </ContextMenuRoot>
)
}

View file

@ -4,6 +4,7 @@ import type { TLDrawShape, Data, TLDrawCommand } from '~types'
export function create(data: Data, shapes: TLDrawShape[]): TLDrawCommand { export function create(data: Data, shapes: TLDrawShape[]): TLDrawCommand {
const { currentPageId } = data.appState const { currentPageId } = data.appState
const beforeShapes: Record<string, Patch<TLDrawShape> | undefined> = {} const beforeShapes: Record<string, Patch<TLDrawShape> | undefined> = {}
const afterShapes: Record<string, Patch<TLDrawShape> | undefined> = {} const afterShapes: Record<string, Patch<TLDrawShape> | undefined> = {}

View file

@ -90,9 +90,26 @@ describe('Delete command', () => {
it('updates the group', () => { it('updates the group', () => {
tlstate tlstate
.loadDocument(mockDocument) .loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'newGroup') .group(['rect1', 'rect2', 'rect3'], 'newGroup')
.select('rect1') .select('rect1')
.delete() .delete()
expect(tlstate.getShape('rect1')).toBeUndefined()
expect(tlstate.getShape('newGroup').children).toStrictEqual(['rect2', 'rect3'])
})
})
describe('when deleting shapes with children', () => {
it('also deletes the children', () => {
tlstate
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'newGroup')
.select('newGroup')
.delete()
expect(tlstate.getShape('rect1')).toBeUndefined()
expect(tlstate.getShape('rect2')).toBeUndefined()
expect(tlstate.getShape('newGroup')).toBeUndefined()
}) })
}) })
}) })

View file

@ -1,5 +1,6 @@
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
import type { Data, TLDrawCommand, PagePartial, TLDrawShape, GroupShape } from '~types' import type { Data, TLDrawCommand } from '~types'
import { removeShapesFromPage } from '../utils/removeShapesFromPage'
// - [ ] Update parents and possibly delete parents // - [ ] Update parents and possibly delete parents
@ -8,82 +9,7 @@ export function deleteShapes(
ids: string[], ids: string[],
pageId = data.appState.currentPageId pageId = data.appState.currentPageId
): TLDrawCommand { ): TLDrawCommand {
const before: PagePartial = { const { before, after } = removeShapesFromPage(data, ids, pageId)
shapes: {},
bindings: {},
}
const after: PagePartial = {
shapes: {},
bindings: {},
}
const parentsToUpdate: GroupShape[] = []
const deletedIds = [...ids]
// These are the shapes we're definitely going to delete
ids.forEach((id) => {
const shape = TLDR.getShape(data, id, pageId)
before.shapes[id] = shape
after.shapes[id] = undefined
if (shape.parentId !== pageId) {
parentsToUpdate.push(TLDR.getShape(data, shape.parentId, pageId))
}
})
parentsToUpdate.forEach((parent) => {
if (ids.includes(parent.id)) return
deletedIds.push(parent.id)
before.shapes[parent.id] = { children: parent.children }
after.shapes[parent.id] = { children: parent.children.filter((id) => !ids.includes(id)) }
})
// Recursively check for empty parents?
const page = TLDR.getPage(data, pageId)
// We also need to delete bindings that reference the deleted shapes
Object.values(page.bindings).forEach((binding) => {
for (const id of [binding.toId, binding.fromId]) {
// If the binding references a deleted shape...
if (after.shapes[id] === undefined) {
// Delete this binding
before.bindings[binding.id] = binding
after.bindings[binding.id] = undefined
// Let's also look each the bound shape...
const shape = TLDR.getShape(data, id, pageId)
// If the bound shape has a handle that references the deleted binding...
if (shape.handles) {
Object.values(shape.handles)
.filter((handle) => handle.bindingId === binding.id)
.forEach((handle) => {
// Save the binding reference in the before patch
before.shapes[id] = {
...before.shapes[id],
handles: {
...before.shapes[id]?.handles,
[handle.id]: { bindingId: binding.id },
},
}
// Unless we're currently deleting the shape, remove the
// binding reference from the after patch
if (!deletedIds.includes(id)) {
after.shapes[id] = {
...after.shapes[id],
handles: { ...after.shapes[id]?.handles, [handle.id]: { bindingId: undefined } },
}
}
})
}
}
}
})
return { return {
id: 'delete_shapes', id: 'delete_shapes',

View file

@ -8,6 +8,7 @@ export * from './distribute'
export * from './duplicate-page' export * from './duplicate-page'
export * from './duplicate' export * from './duplicate'
export * from './flip' export * from './flip'
export * from './group'
export * from './move' export * from './move'
export * from './rename-page' export * from './rename-page'
export * from './rotate' export * from './rotate'
@ -17,4 +18,4 @@ export * from './toggle-decoration'
export * from './toggle' export * from './toggle'
export * from './translate' export * from './translate'
export * from './update' export * from './update'
export * from './group' export * from './move-to-page'

View file

@ -0,0 +1 @@
export * from './move-to-page.command'

View file

@ -0,0 +1,250 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { ArrowShape, TLDrawShapeType } from '~types'
describe('Move to page command', () => {
const tlstate = new TLDrawState()
/*
Moving shapes to a new page should remove those shapes from the
current page and add them to the specifed page. If bindings exist
that effect the moved shapes, then the bindings should be destroyed
on the old page and created on the new page only if both the "to"
and "from" shapes were moved. The app should then change pages to
the new page.
*/
it('does, undoes and redoes command', () => {
tlstate
.loadDocument(mockDocument)
.createPage('page2')
.changePage('page1')
.select('rect1', 'rect2')
.moveToPage('page2')
expect(tlstate.currentPageId).toBe('page2')
expect(tlstate.getShape('rect1', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect1', 'page2')).toBeDefined()
expect(tlstate.getShape('rect2', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect2', 'page2')).toBeDefined()
expect(tlstate.selectedIds).toStrictEqual(['rect1', 'rect2'])
tlstate.undo()
expect(tlstate.getShape('rect1', 'page1')).toBeDefined()
expect(tlstate.getShape('rect1', 'page2')).toBeUndefined()
expect(tlstate.getShape('rect2', 'page1')).toBeDefined()
expect(tlstate.getShape('rect2', 'page2')).toBeUndefined()
expect(tlstate.selectedIds).toStrictEqual(['rect1', 'rect2'])
expect(tlstate.currentPageId).toBe('page1')
tlstate.redo()
expect(tlstate.getShape('rect1', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect1', 'page2')).toBeDefined()
expect(tlstate.getShape('rect2', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect2', 'page2')).toBeDefined()
expect(tlstate.selectedIds).toStrictEqual(['rect1', 'rect2'])
expect(tlstate.currentPageId).toBe('page2')
})
describe('when moving shapes with bindings', () => {
it('deletes bindings when only the bound-to shape is moved', () => {
tlstate
.loadDocument(mockDocument)
.selectAll()
.delete()
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.completeSession()
const bindingId = tlstate.bindings[0].id
expect(tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId).toBe(bindingId)
tlstate.createPage('page2').changePage('page1').select('target1').moveToPage('page2')
expect(
tlstate.getShape<ArrowShape>('arrow1', 'page1').handles.start.bindingId
).toBeUndefined()
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeUndefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeUndefined()
tlstate.undo()
expect(tlstate.getShape<ArrowShape>('arrow1', 'page1').handles.start.bindingId).toBe(
bindingId
)
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeDefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeUndefined()
tlstate.redo()
expect(
tlstate.getShape<ArrowShape>('arrow1', 'page1').handles.start.bindingId
).toBeUndefined()
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeUndefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeUndefined()
})
it('deletes bindings when only the bound-from shape is moved', () => {
tlstate
.loadDocument(mockDocument)
.selectAll()
.delete()
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.completeSession()
const bindingId = tlstate.bindings[0].id
expect(tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId).toBe(bindingId)
tlstate.createPage('page2').changePage('page1').select('arrow1').moveToPage('page2')
expect(
tlstate.getShape<ArrowShape>('arrow1', 'page2').handles.start.bindingId
).toBeUndefined()
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeUndefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeUndefined()
tlstate.undo()
expect(tlstate.getShape<ArrowShape>('arrow1', 'page1').handles.start.bindingId).toBe(
bindingId
)
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeDefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeUndefined()
tlstate.redo()
expect(
tlstate.getShape<ArrowShape>('arrow1', 'page2').handles.start.bindingId
).toBeUndefined()
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeUndefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeUndefined()
})
it('moves bindings when both shapes are moved', () => {
tlstate
.loadDocument(mockDocument)
.selectAll()
.delete()
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([50, 50])
.completeSession()
const bindingId = tlstate.bindings[0].id
expect(tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId).toBe(bindingId)
tlstate
.createPage('page2')
.changePage('page1')
.select('arrow1', 'target1')
.moveToPage('page2')
expect(tlstate.getShape<ArrowShape>('arrow1', 'page2').handles.start.bindingId).toBe(
bindingId
)
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeUndefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeDefined()
tlstate.undo()
expect(tlstate.getShape<ArrowShape>('arrow1', 'page1').handles.start.bindingId).toBe(
bindingId
)
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeDefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeUndefined()
tlstate.redo()
expect(tlstate.getShape<ArrowShape>('arrow1', 'page2').handles.start.bindingId).toBe(
bindingId
)
expect(tlstate.document.pages['page1'].bindings[bindingId]).toBeUndefined()
expect(tlstate.document.pages['page2'].bindings[bindingId]).toBeDefined()
})
})
describe('when moving grouped shapes', () => {
it('moves groups and their children', () => {
tlstate
.loadDocument(mockDocument)
.createPage('page2')
.changePage('page1')
.group(['rect1', 'rect2'], 'groupA')
.moveToPage('page2')
expect(tlstate.getShape('rect1', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect2', 'page1')).toBeUndefined()
expect(tlstate.getShape('groupA', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect1', 'page2')).toBeDefined()
expect(tlstate.getShape('rect2', 'page2')).toBeDefined()
expect(tlstate.getShape('groupA', 'page2')).toBeDefined()
tlstate.undo()
expect(tlstate.getShape('rect1', 'page2')).toBeUndefined()
expect(tlstate.getShape('rect2', 'page2')).toBeUndefined()
expect(tlstate.getShape('groupA', 'page2')).toBeUndefined()
expect(tlstate.getShape('rect1', 'page1')).toBeDefined()
expect(tlstate.getShape('rect2', 'page1')).toBeDefined()
expect(tlstate.getShape('groupA', 'page1')).toBeDefined()
tlstate.redo()
expect(tlstate.getShape('rect1', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect2', 'page1')).toBeUndefined()
expect(tlstate.getShape('groupA', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect1', 'page2')).toBeDefined()
expect(tlstate.getShape('rect2', 'page2')).toBeDefined()
expect(tlstate.getShape('groupA', 'page2')).toBeDefined()
})
it('deletes groups shapes if the groups children were all moved', () => {
// ...
})
it('reparents grouped shapes if the group is not moved', () => {
tlstate
.loadDocument(mockDocument)
.createPage('page2')
.changePage('page1')
.group(['rect1', 'rect2', 'rect3'], 'groupA')
.select('rect1')
.moveToPage('page2')
expect(tlstate.getShape('rect1', 'page1')).toBeUndefined()
expect(tlstate.getShape('rect1', 'page2')).toBeDefined()
expect(tlstate.getShape('rect1', 'page2').parentId).toBe('page2')
expect(tlstate.getShape('groupA', 'page1').children).toStrictEqual(['rect2', 'rect3'])
tlstate.undo()
expect(tlstate.getShape('rect1', 'page2')).toBeUndefined()
expect(tlstate.getShape('rect1', 'page1').parentId).toBe('groupA')
expect(tlstate.getShape('groupA', 'page1').children).toStrictEqual([
'rect1',
'rect2',
'rect3',
])
})
})
})

View file

@ -0,0 +1,211 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { ArrowShape, Data, PagePartial, TLDrawCommand, TLDrawShape } from '~types'
import { TLDR } from '~state/tldr'
import { Utils, Vec } from '@tldraw/core'
export function moveToPage(
data: Data,
ids: string[],
fromPageId: string,
toPageId: string
): TLDrawCommand {
const { currentPageId } = data.appState
const page = TLDR.getPage(data, currentPageId)
const fromPage: Record<string, PagePartial> = {
before: {
shapes: {},
bindings: {},
},
after: {
shapes: {},
bindings: {},
},
}
const toPage: Record<string, PagePartial> = {
before: {
shapes: {},
bindings: {},
},
after: {
shapes: {},
bindings: {},
},
}
// Collect all the shapes to move and their keys.
const movingShapeIds = new Set<string>()
const shapesToMove = new Set<TLDrawShape>()
ids
.map((id) => TLDR.getShape(data, id, fromPageId))
.forEach((shape) => {
movingShapeIds.add(shape.id)
shapesToMove.add(shape)
if (shape.children !== undefined) {
shape.children.forEach((childId) => {
movingShapeIds.add(childId)
shapesToMove.add(TLDR.getShape(data, childId, fromPageId))
})
}
})
// Where should we put start putting shapes on the "to" page?
const startingChildIndex = TLDR.getTopChildIndex(data, toPageId)
// Which shapes are we moving?
const movingShapes = Array.from(shapesToMove.values())
movingShapes.forEach((shape, i) => {
// Remove the shape from the fromPage
fromPage.before.shapes[shape.id] = shape
fromPage.after.shapes[shape.id] = undefined
// But the moved shape on the "to" page
toPage.before.shapes[shape.id] = undefined
toPage.after.shapes[shape.id] = shape
// If the shape's parent isn't moving too, reparent the shape to
// the "to" page, at the top of the z stack
if (!movingShapeIds.has(shape.parentId)) {
toPage.after.shapes[shape.id] = {
...shape,
parentId: toPageId,
childIndex: startingChildIndex + i,
}
// If the shape was in a group, then pull the shape from the
// parent's children array.
if (shape.parentId !== fromPageId) {
const parent = TLDR.getShape(data, shape.parentId, fromPageId)
fromPage.before.shapes[parent.id] = {
children: parent.children,
}
fromPage.after.shapes[parent.id] = {
children: parent.children!.filter((childId) => childId !== shape.id),
}
}
}
})
// Handle bindings that effect duplicated shapes
Object.values(page.bindings)
.filter((binding) => movingShapeIds.has(binding.fromId) || movingShapeIds.has(binding.toId))
.forEach((binding) => {
// Always delete the binding from the from page
fromPage.before.bindings[binding.id] = binding
fromPage.after.bindings[binding.id] = undefined
// Delete the reference from the binding's fromShape
const fromBoundShape = TLDR.getShape(data, binding.fromId, fromPageId)
// Will we be copying this binding to the new page?
const shouldCopy = movingShapeIds.has(binding.fromId) && movingShapeIds.has(binding.toId)
if (shouldCopy) {
// Just move the binding to the new page
toPage.before.bindings[binding.id] = undefined
toPage.after.bindings[binding.id] = binding
} else {
if (movingShapeIds.has(binding.fromId)) {
// If we are only moving the "from" shape, we need to delete
// the binding reference from the "from" shapes handles
const fromShape = TLDR.getShape(data, binding.fromId, fromPageId)
const handle = Object.values(fromBoundShape.handles!).find(
(handle) => handle.bindingId === binding.id
)!
// Remove the handle from the shape on the toPage
const handleId = handle.id as keyof ArrowShape['handles']
const toPageShape = toPage.after.shapes[fromShape.id]!
toPageShape.handles = {
...toPageShape.handles,
[handleId]: {
...toPageShape.handles![handleId],
bindingId: undefined,
},
}
} else {
// If we are only moving the "to" shape, we need to delete
// the binding reference from the "from" shape's handles
const fromShape = TLDR.getShape(data, binding.fromId, fromPageId)
const handle = Object.values(fromBoundShape.handles!).find(
(handle) => handle.bindingId === binding.id
)!
fromPage.before.shapes[fromShape.id] = {
handles: { [handle.id]: { bindingId: binding.id } },
}
fromPage.after.shapes[fromShape.id] = {
handles: { [handle.id]: { bindingId: undefined } },
}
}
}
})
// Finally, center camera on selection
const toPageState = data.document.pageStates[toPageId]
const bounds = Utils.getCommonBounds(movingShapes.map((shape) => TLDR.getBounds(shape)))
const zoom = TLDR.getCameraZoom(
window.innerWidth < window.innerHeight
? (window.innerWidth - 128) / bounds.width
: (window.innerHeight - 128) / bounds.height
)
const mx = (window.innerWidth - bounds.width * zoom) / 2 / zoom
const my = (window.innerHeight - bounds.height * zoom) / 2 / zoom
const point = Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my]))
return {
id: 'move_to_page',
before: {
appState: {
currentPageId: fromPageId,
},
document: {
pages: {
[fromPageId]: fromPage.before,
[toPageId]: toPage.before,
},
pageStates: {
[fromPageId]: { selectedIds: ids },
[toPageId]: {
selectedIds: toPageState.selectedIds,
camera: toPageState.camera,
},
},
},
},
after: {
appState: {
currentPageId: toPageId,
},
document: {
pages: {
[fromPageId]: fromPage.after,
[toPageId]: toPage.after,
},
pageStates: {
[fromPageId]: { selectedIds: [] },
[toPageId]: {
selectedIds: ids,
camera: {
zoom,
point,
},
},
},
},
},
}
}

View file

@ -0,0 +1,95 @@
import { TLDR } from '~state/tldr'
import type { Data, GroupShape, PagePartial } from '~types'
export function removeShapesFromPage(data: Data, ids: string[], pageId: string) {
const before: PagePartial = {
shapes: {},
bindings: {},
}
const after: PagePartial = {
shapes: {},
bindings: {},
}
const parentsToUpdate: GroupShape[] = []
const deletedIds = new Set()
// These are the shapes we're definitely going to delete
ids.forEach((id) => {
deletedIds.add(id)
const shape = TLDR.getShape(data, id, pageId)
before.shapes[id] = shape
after.shapes[id] = undefined
// Also delete the shape's children
if (shape.children !== undefined) {
shape.children.forEach((childId) => {
deletedIds.add(childId)
const child = TLDR.getShape(data, childId, pageId)
before.shapes[childId] = child
after.shapes[childId] = undefined
})
}
if (shape.parentId !== pageId) {
parentsToUpdate.push(TLDR.getShape(data, shape.parentId, pageId))
}
})
parentsToUpdate.forEach((parent) => {
if (ids.includes(parent.id)) return
deletedIds.add(parent.id)
before.shapes[parent.id] = { children: parent.children }
after.shapes[parent.id] = { children: parent.children.filter((id) => !ids.includes(id)) }
})
// Recursively check for empty parents?
const page = TLDR.getPage(data, pageId)
// We also need to delete bindings that reference the deleted shapes
Object.values(page.bindings).forEach((binding) => {
for (const id of [binding.toId, binding.fromId]) {
// If the binding references a deleted shape...
if (after.shapes[id] === undefined) {
// Delete this binding
before.bindings[binding.id] = binding
after.bindings[binding.id] = undefined
// Let's also look each the bound shape...
const shape = TLDR.getShape(data, id, pageId)
// If the bound shape has a handle that references the deleted binding...
if (shape.handles) {
Object.values(shape.handles)
.filter((handle) => handle.bindingId === binding.id)
.forEach((handle) => {
// Save the binding reference in the before patch
before.shapes[id] = {
...before.shapes[id],
handles: {
...before.shapes[id]?.handles,
[handle.id]: { bindingId: binding.id },
},
}
// Unless we're currently deleting the shape, remove the
// binding reference from the after patch
if (!deletedIds.has(id)) {
after.shapes[id] = {
...after.shapes[id],
handles: { ...after.shapes[id]?.handles, [handle.id]: { bindingId: undefined } },
}
}
})
}
}
}
})
return { before, after }
}

View file

@ -1,7 +1,6 @@
import { TLDrawState } from '~state' import { TLDrawState } from '~state'
import { mockDocument } from '~test' import { mockDocument } from '~test'
import { TLDR } from '~state/tldr' import { ArrowShape, TLDrawShapeType, TLDrawStatus } from '~types'
import { ArrowShape, TLDrawShape, TLDrawStatus } from '~types'
describe('Arrow session', () => { describe('Arrow session', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
@ -9,20 +8,9 @@ describe('Arrow session', () => {
.loadDocument(mockDocument) .loadDocument(mockDocument)
.selectAll() .selectAll()
.delete() .delete()
.create( .createShapes(
TLDR.getShapeUtils({ type: 'rectangle' } as TLDrawShape).create({ { type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
id: 'target1', { type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
parentId: 'page1',
point: [0, 0],
size: [100, 100],
})
)
.create(
TLDR.getShapeUtils({ type: 'arrow' } as TLDrawShape).create({
id: 'arrow1',
parentId: 'page1',
point: [200, 200],
})
) )
const restoreDoc = tlstate.document const restoreDoc = tlstate.document

View file

@ -317,71 +317,3 @@ export class ArrowSession implements Session {
} }
} }
} }
function findBinding(
data: Data,
shape: ArrowShape,
handle: TLHandle,
newBindingId: string,
bindableShapeIds: string[],
bindings: Record<string, TLDrawBinding | undefined>,
altKey: boolean,
metaKey: boolean
) {
let nextBinding: ArrowBinding | undefined = undefined
let nextTarget: TLDrawShape | undefined = undefined
if (!altKey) {
const oppositeHandle = shape.handles[handle.id === 'start' ? 'end' : 'start']
// Find the origin and direction of the handle
const rayOrigin = Vec.add(oppositeHandle.point, shape.point)
const rayPoint = Vec.add(handle.point, shape.point)
const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
const oppositeBinding = oppositeHandle.bindingId
? bindings[oppositeHandle.bindingId]
: undefined
// From all bindable shapes on the page...
for (const id of bindableShapeIds) {
if (id === shape.id) continue
if (id === shape.parentId) continue
if (id === oppositeBinding?.toId) continue
const target = TLDR.getShape(data, id, data.appState.currentPageId)
const util = TLDR.getShapeUtils(target)
const bindingPoint = util.getBindingPoint(
target,
shape,
rayPoint,
rayOrigin,
rayDirection,
32,
metaKey
)
// Not all shapes will produce a binding point
if (!bindingPoint) continue
// Stop at the first shape that will produce a binding point
nextTarget = target
nextBinding = {
id: newBindingId,
type: 'arrow',
fromId: shape.id,
handleId: handle.id as keyof ArrowShape['handles'],
toId: target.id,
point: Vec.round(bindingPoint.point),
distance: bindingPoint.distance,
}
break
}
}
return { target: nextTarget, binding: nextBinding }
}

View file

@ -871,6 +871,15 @@ export class TLDR {
.reduce<TLDrawShape[]>((acc, shape) => [...acc, ...TLDR.flattenShape(data, shape)], []) .reduce<TLDrawShape[]>((acc, shape) => [...acc, ...TLDR.flattenShape(data, shape)], [])
} }
static getTopChildIndex = (data: Data, pageId: string): number => {
const shapes = TLDR.getShapes(data, pageId)
return shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === pageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
}
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Assertions */ /* Assertions */
/* -------------------------------------------------- */ /* -------------------------------------------------- */

View file

@ -996,7 +996,7 @@ export class TLDrawState extends StateManager<Data> {
const bounds = Utils.getCommonBounds(Object.values(shapes).map(TLDR.getBounds)) const bounds = Utils.getCommonBounds(Object.values(shapes).map(TLDR.getBounds))
const zoom = TLDR.getCameraZoom( const zoom = TLDR.getCameraZoom(
bounds.width > bounds.height window.innerWidth < window.innerHeight
? (window.innerWidth - 128) / bounds.width ? (window.innerWidth - 128) / bounds.width
: (window.innerHeight - 128) / bounds.height : (window.innerHeight - 128) / bounds.height
) )
@ -1016,12 +1016,12 @@ export class TLDrawState extends StateManager<Data> {
* @returns this * @returns this
*/ */
zoomToSelection = (): this => { zoomToSelection = (): this => {
if (this.pageState.selectedIds.length === 0) return this if (this.selectedIds.length === 0) return this
const bounds = TLDR.getSelectedBounds(this.state) const bounds = TLDR.getSelectedBounds(this.state)
const zoom = TLDR.getCameraZoom( const zoom = TLDR.getCameraZoom(
bounds.width > bounds.height window.innerWidth < window.innerHeight
? (window.innerWidth - 128) / bounds.width ? (window.innerWidth - 128) / bounds.width
: (window.innerHeight - 128) / bounds.height : (window.innerHeight - 128) / bounds.height
) )
@ -1031,7 +1031,7 @@ export class TLDrawState extends StateManager<Data> {
return this.setCamera( return this.setCamera(
Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my])), Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my])),
this.pageState.camera.zoom, zoom,
`zoomed_to_selection` `zoomed_to_selection`
) )
} }
@ -1157,6 +1157,11 @@ export class TLDrawState extends StateManager<Data> {
* @returns this * @returns this
*/ */
select = (...ids: string[]): this => { select = (...ids: string[]): this => {
ids.forEach((id) => {
if (!this.page.shapes[id]) {
throw Error(`That shape does not exist on page ${this.currentPageId}`)
}
})
this.setSelectedIds(ids) this.setSelectedIds(ids)
this.addToSelectHistory(ids) this.addToSelectHistory(ids)
return this return this
@ -1644,6 +1649,24 @@ export class TLDrawState extends StateManager<Data> {
return this.setState(Commands.flip(this.state, ids, FlipType.Vertical)) return this.setState(Commands.flip(this.state, ids, FlipType.Vertical))
} }
/**
* Move one or more shapes to a new page. Will also break or move bindings.
* @param toPage The id of the page to move the shapes to.
* @param fromPage The id of the page to move the shapes from
*(defaults to current page).
* @param ids The ids of the shapes to move (defaults to selection).
* @returns this
*/
moveToPage = (
toPageId: string,
fromPageId = this.currentPageId,
ids = this.selectedIds
): this => {
if (ids.length === 0) return this
this.setState(Commands.moveToPage(this.state, ids, fromPageId, toPageId))
return this
}
/** /**
* Move one or more shapes to the back of the page. * Move one or more shapes to the back of the page.
* @param ids The ids of the shapes to change (defaults to selection). * @param ids The ids of the shapes to change (defaults to selection).