diff --git a/packages/tldraw/src/components/context-menu/context-menu.tsx b/packages/tldraw/src/components/context-menu/context-menu.tsx
index 8e74de0d3..ab677a430 100644
--- a/packages/tldraw/src/components/context-menu/context-menu.tsx
+++ b/packages/tldraw/src/components/context-menu/context-menu.tsx
@@ -179,11 +179,11 @@ export const ContextMenu = React.memo(({ children }: ContextMenuProps): JSX.Elem
#⇧[
+
{hasTwoOrMore && (
)}
- {/* */}
Copy
#C
@@ -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 (
-//
-//
-// Move To Page
-//
-//
-//
-//
-//
-// {sorted.map(({ id, name }) => (
-// state.send('MOVED_TO_PAGE', { id })}
-// >
-// {name}
-//
-// ))}
-//
-//
-//
-// )
-// }
+ console.log(sorted)
+
+ return (
+
+
+ Move To Page
+
+
+
+
+
+ {sorted.map(({ id, name }, i) => (
+ tlstate.moveToPage(id)}
+ >
+ {name || `Page ${i}`}
+
+ ))}
+
+
+
+ )
+}
diff --git a/packages/tldraw/src/state/command/create/create.command.ts b/packages/tldraw/src/state/command/create/create.command.ts
index ce1fdcb72..12959acf4 100644
--- a/packages/tldraw/src/state/command/create/create.command.ts
+++ b/packages/tldraw/src/state/command/create/create.command.ts
@@ -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 | undefined> = {}
const afterShapes: Record | undefined> = {}
diff --git a/packages/tldraw/src/state/command/delete/delete.command.spec.ts b/packages/tldraw/src/state/command/delete/delete.command.spec.ts
index b3c4c5738..0051d0393 100644
--- a/packages/tldraw/src/state/command/delete/delete.command.spec.ts
+++ b/packages/tldraw/src/state/command/delete/delete.command.spec.ts
@@ -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()
})
})
})
diff --git a/packages/tldraw/src/state/command/delete/delete.command.ts b/packages/tldraw/src/state/command/delete/delete.command.ts
index 7d21a7f30..d9cf709e0 100644
--- a/packages/tldraw/src/state/command/delete/delete.command.ts
+++ b/packages/tldraw/src/state/command/delete/delete.command.ts
@@ -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',
diff --git a/packages/tldraw/src/state/command/index.ts b/packages/tldraw/src/state/command/index.ts
index e9a58b7f9..74a91480a 100644
--- a/packages/tldraw/src/state/command/index.ts
+++ b/packages/tldraw/src/state/command/index.ts
@@ -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'
diff --git a/packages/tldraw/src/state/command/move-to-page/index.ts b/packages/tldraw/src/state/command/move-to-page/index.ts
new file mode 100644
index 000000000..21ecc899c
--- /dev/null
+++ b/packages/tldraw/src/state/command/move-to-page/index.ts
@@ -0,0 +1 @@
+export * from './move-to-page.command'
diff --git a/packages/tldraw/src/state/command/move-to-page/move-to-page.command.spec.ts b/packages/tldraw/src/state/command/move-to-page/move-to-page.command.spec.ts
new file mode 100644
index 000000000..05dd9d995
--- /dev/null
+++ b/packages/tldraw/src/state/command/move-to-page/move-to-page.command.spec.ts
@@ -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('arrow1').handles.start.bindingId).toBe(bindingId)
+
+ tlstate.createPage('page2').changePage('page1').select('target1').moveToPage('page2')
+
+ expect(
+ tlstate.getShape('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('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('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('arrow1').handles.start.bindingId).toBe(bindingId)
+
+ tlstate.createPage('page2').changePage('page1').select('arrow1').moveToPage('page2')
+
+ expect(
+ tlstate.getShape('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('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('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('arrow1').handles.start.bindingId).toBe(bindingId)
+
+ tlstate
+ .createPage('page2')
+ .changePage('page1')
+ .select('arrow1', 'target1')
+ .moveToPage('page2')
+
+ expect(tlstate.getShape('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('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('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',
+ ])
+ })
+ })
+})
diff --git a/packages/tldraw/src/state/command/move-to-page/move-to-page.command.ts b/packages/tldraw/src/state/command/move-to-page/move-to-page.command.ts
new file mode 100644
index 000000000..a0cbbe97e
--- /dev/null
+++ b/packages/tldraw/src/state/command/move-to-page/move-to-page.command.ts
@@ -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 = {
+ before: {
+ shapes: {},
+ bindings: {},
+ },
+ after: {
+ shapes: {},
+ bindings: {},
+ },
+ }
+
+ const toPage: Record = {
+ before: {
+ shapes: {},
+ bindings: {},
+ },
+ after: {
+ shapes: {},
+ bindings: {},
+ },
+ }
+
+ // Collect all the shapes to move and their keys.
+ const movingShapeIds = new Set()
+ const shapesToMove = new Set()
+
+ 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,
+ },
+ },
+ },
+ },
+ },
+ }
+}
diff --git a/packages/tldraw/src/state/command/utils/removeShapesFromPage.ts b/packages/tldraw/src/state/command/utils/removeShapesFromPage.ts
new file mode 100644
index 000000000..11eda540d
--- /dev/null
+++ b/packages/tldraw/src/state/command/utils/removeShapesFromPage.ts
@@ -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 }
+}
diff --git a/packages/tldraw/src/state/session/sessions/arrow/arrow.session.spec.ts b/packages/tldraw/src/state/session/sessions/arrow/arrow.session.spec.ts
index 606198591..7cbc4a618 100644
--- a/packages/tldraw/src/state/session/sessions/arrow/arrow.session.spec.ts
+++ b/packages/tldraw/src/state/session/sessions/arrow/arrow.session.spec.ts
@@ -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
diff --git a/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts b/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts
index 825ea2097..278f4321e 100644
--- a/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts
+++ b/packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts
@@ -317,71 +317,3 @@ export class ArrowSession implements Session {
}
}
}
-
-function findBinding(
- data: Data,
- shape: ArrowShape,
- handle: TLHandle,
- newBindingId: string,
- bindableShapeIds: string[],
- bindings: Record,
- 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 }
-}
diff --git a/packages/tldraw/src/state/tldr.ts b/packages/tldraw/src/state/tldr.ts
index 16897ec18..232858af3 100644
--- a/packages/tldraw/src/state/tldr.ts
+++ b/packages/tldraw/src/state/tldr.ts
@@ -871,6 +871,15 @@ export class TLDR {
.reduce((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 */
/* -------------------------------------------------- */
diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts
index 174b87187..84f69ad18 100644
--- a/packages/tldraw/src/state/tlstate.ts
+++ b/packages/tldraw/src/state/tlstate.ts
@@ -996,7 +996,7 @@ export class TLDrawState extends StateManager {
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 {
* @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 {
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 {
* @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 {
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).