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>
</ContextMenuButton>
</ContextMenuSubMenu>
<MoveToPageMenu />
{hasTwoOrMore && (
<AlignDistributeSubMenu hasTwoOrMore={hasTwoOrMore} hasThreeOrMore={hasThreeOrMore} />
)}
<ContextMenuDivider />
{/* <MoveToPageMenu /> */}
<ContextMenuButton onSelect={handleCopy}>
<span>Copy</span>
<Kbd variant="menu">#C</Kbd>
@ -345,38 +345,42 @@ const StyledGrid = styled(MenuContent, {
},
})
// function MoveToPageMenu() {
// const documentPages = useSelector((s) => s.data.document.pages)
// const currentPageId = useSelector((s) => s.data.currentPageId)
const currentPageIdSelector = (s: Data) => s.appState.currentPageId
const documentPagesSelector = (s: Data) => s.document.pages
// 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)
// .sort((a, b) => a.childIndex - b.childIndex)
// .filter((a) => a.id !== currentPageId)
const sorted = Object.values(documentPages)
.sort((a, b) => (a.childIndex || 0) - (b.childIndex || 0))
.filter((a) => a.id !== currentPageId)
// if (sorted.length === 0) return null
if (sorted.length === 0) return null
// return (
// <ContextMenuRoot>
// <ContextMenuButton>
// <span>Move To Page</span>
// <IconWrapper size="small">
// <ChevronRightIcon />
// </IconWrapper>
// </ContextMenuButton>
// <MenuContent as={RadixContextMenu.Content} sideOffset={2} alignOffset={-2}>
// {sorted.map(({ id, name }) => (
// <ContextMenuButton
// key={id}
// disabled={id === currentPageId}
// onSelect={() => state.send('MOVED_TO_PAGE', { id })}
// >
// <span>{name}</span>
// </ContextMenuButton>
// ))}
// <ContextMenuArrow offset={13} />
// </MenuContent>
// </ContextMenuRoot>
// )
// }
console.log(sorted)
return (
<ContextMenuRoot>
<RadixContextMenu.TriggerItem as={RowButton} bp={breakpoints}>
<span>Move To Page</span>
<IconWrapper size="small">
<ChevronRightIcon />
</IconWrapper>
</RadixContextMenu.TriggerItem>
<MenuContent as={RadixContextMenu.Content} sideOffset={2} alignOffset={-2}>
{sorted.map(({ id, name }, i) => (
<ContextMenuButton
key={id}
disabled={id === currentPageId}
onSelect={() => tlstate.moveToPage(id)}
>
<span>{name || `Page ${i}`}</span>
</ContextMenuButton>
))}
<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 {
const { currentPageId } = data.appState
const beforeShapes: 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', () => {
tlstate
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'newGroup')
.group(['rect1', 'rect2', 'rect3'], 'newGroup')
.select('rect1')
.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 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
@ -8,82 +9,7 @@ export function deleteShapes(
ids: string[],
pageId = data.appState.currentPageId
): TLDrawCommand {
const before: PagePartial = {
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 } },
}
}
})
}
}
}
})
const { before, after } = removeShapesFromPage(data, ids, pageId)
return {
id: 'delete_shapes',

View file

@ -8,6 +8,7 @@ export * from './distribute'
export * from './duplicate-page'
export * from './duplicate'
export * from './flip'
export * from './group'
export * from './move'
export * from './rename-page'
export * from './rotate'
@ -17,4 +18,4 @@ export * from './toggle-decoration'
export * from './toggle'
export * from './translate'
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 { mockDocument } from '~test'
import { TLDR } from '~state/tldr'
import { ArrowShape, TLDrawShape, TLDrawStatus } from '~types'
import { ArrowShape, TLDrawShapeType, TLDrawStatus } from '~types'
describe('Arrow session', () => {
const tlstate = new TLDrawState()
@ -9,20 +8,9 @@ describe('Arrow session', () => {
.loadDocument(mockDocument)
.selectAll()
.delete()
.create(
TLDR.getShapeUtils({ type: 'rectangle' } as TLDrawShape).create({
id: 'target1',
parentId: 'page1',
point: [0, 0],
size: [100, 100],
})
)
.create(
TLDR.getShapeUtils({ type: 'arrow' } as TLDrawShape).create({
id: 'arrow1',
parentId: 'page1',
point: [200, 200],
})
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
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)], [])
}
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 */
/* -------------------------------------------------- */

View file

@ -996,7 +996,7 @@ export class TLDrawState extends StateManager<Data> {
const bounds = Utils.getCommonBounds(Object.values(shapes).map(TLDR.getBounds))
const zoom = TLDR.getCameraZoom(
bounds.width > bounds.height
window.innerWidth < window.innerHeight
? (window.innerWidth - 128) / bounds.width
: (window.innerHeight - 128) / bounds.height
)
@ -1016,12 +1016,12 @@ export class TLDrawState extends StateManager<Data> {
* @returns 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 zoom = TLDR.getCameraZoom(
bounds.width > bounds.height
window.innerWidth < window.innerHeight
? (window.innerWidth - 128) / bounds.width
: (window.innerHeight - 128) / bounds.height
)
@ -1031,7 +1031,7 @@ export class TLDrawState extends StateManager<Data> {
return this.setCamera(
Vec.round(Vec.add([-bounds.minX, -bounds.minY], [mx, my])),
this.pageState.camera.zoom,
zoom,
`zoomed_to_selection`
)
}
@ -1157,6 +1157,11 @@ export class TLDrawState extends StateManager<Data> {
* @returns 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.addToSelectHistory(ids)
return this
@ -1644,6 +1649,24 @@ export class TLDrawState extends StateManager<Data> {
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.
* @param ids The ids of the shapes to change (defaults to selection).