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).