diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 1855b2c54..23ed27bf5 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -503,7 +503,6 @@ export class Editor extends EventEmitter { getPageTransformById(id: TLShapeId): Matrix2d | undefined; getParentIdForNewShapeAtPoint(point: VecLike, shapeType: TLShape['type']): TLPageId | TLShapeId; getParentShape(shape?: TLShape): TLShape | undefined; - getParentsMappedToChildren(ids: TLShapeId[]): Map>; getParentTransform(shape: TLShape): Matrix2d; getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d; getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d; @@ -631,7 +630,6 @@ export class Editor extends EventEmitter { isInViewport: boolean; maskedPageBounds: Box2d | undefined; }[]; - reorderShapes(operation: 'backward' | 'forward' | 'toBack' | 'toFront', ids: TLShapeId[]): this; reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this; replaceStoreContentsWithRecordsForOtherDocument(records: TLRecord[]): void; resetZoom(point?: Vec2d, opts?: TLAnimationOptions): this; diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index c29fb4bb4..69e1f4fbd 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -111,6 +111,7 @@ import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/Sh import { WeakMapCache } from '../utils/WeakMapCache' import { dataUrlToFile } from '../utils/assets' import { getIncrementedName, uniqueId } from '../utils/data' +import { getReorderingShapesChanges } from '../utils/reorderShapes' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { arrowBindingsIndex } from './derivations/arrowBindingsIndex' import { parentsToChildrenWithIndexes } from './derivations/parentsToChildrenWithIndexes' @@ -5148,31 +5149,6 @@ export class Editor extends EventEmitter { return this.getDeltaInShapeSpace(parent, delta) } - /** - * For a given set of ids, get a map containing the ids of their parents and the children of those - * parents. - * - * @example - * ```ts - * editor.getParentsMappedToChildren(['id1', 'id2', 'id3']) - * ``` - * - * @param ids - The ids to get the parents and children of. - * - * @public - */ - getParentsMappedToChildren(ids: TLShapeId[]) { - const shapes = ids.map((id) => this.store.get(id)!) - const parents = new Map>() - shapes.forEach((shape) => { - if (!parents.has(shape.parentId)) { - parents.set(shape.parentId, new Set()) - } - parents.get(shape.parentId)?.add(shape) - }) - return parents - } - /** * An array containing all of the shapes in the current page. * @@ -6014,179 +5990,6 @@ export class Editor extends EventEmitter { return this } - /** - * Reorder shapes. - * - * @param operation - The operation to perform. - * @param ids - The ids to reorder. - * - * @public - */ - reorderShapes(operation: 'toBack' | 'toFront' | 'forward' | 'backward', ids: TLShapeId[]) { - if (this.isReadOnly) return this - if (ids.length === 0) return this - // this.emit('reorder-shapes', { pageId: this.currentPageId, ids, operation }) - - const parents = this.getParentsMappedToChildren(ids) - - const changes: TLShapePartial[] = [] - - switch (operation) { - case 'toBack': { - parents.forEach((movingSet, parentId) => { - const siblings = compact( - this.getSortedChildIds(parentId).map((id) => this.getShapeById(id)) - ) - - if (movingSet.size === siblings.length) return - - let below: string | undefined - let above: string | undefined - - for (const shape of siblings) { - if (!movingSet.has(shape)) { - above = shape.index - break - } - movingSet.delete(shape) - below = shape.index - } - - if (movingSet.size === 0) return - - const indices = getIndicesBetween(below, above, movingSet.size) - - Array.from(movingSet.values()) - .sort(sortByIndex) - .forEach((node, i) => - changes.push({ id: node.id as any, type: node.type, index: indices[i] }) - ) - }) - - break - } - case 'toFront': { - parents.forEach((movingSet, parentId) => { - const siblings = compact( - this.getSortedChildIds(parentId).map((id) => this.getShapeById(id)) - ) - const len = siblings.length - - if (movingSet.size === len) return - - let below: string | undefined - let above: string | undefined - - for (let i = len - 1; i > -1; i--) { - const shape = siblings[i] - - if (!movingSet.has(shape)) { - below = shape.index - break - } - - movingSet.delete(shape) - above = shape.index - } - - if (movingSet.size === 0) return - - const indices = getIndicesBetween(below, above, movingSet.size) - - Array.from(movingSet.values()) - .sort(sortByIndex) - .forEach((node, i) => - changes.push({ id: node.id as any, type: node.type, index: indices[i] }) - ) - }) - - break - } - case 'forward': { - parents.forEach((movingSet, parentId) => { - const siblings = compact( - this.getSortedChildIds(parentId).map((id) => this.getShapeById(id)) - ) - const len = siblings.length - - if (movingSet.size === len) return - - const movingIndices = new Set(Array.from(movingSet).map((n) => siblings.indexOf(n))) - - let selectIndex = -1 - let isSelecting = false - let below: string | undefined - let above: string | undefined - let count: number - - for (let i = 0; i < len; i++) { - const isMoving = movingIndices.has(i) - - if (!isSelecting && isMoving) { - isSelecting = true - selectIndex = i - above = undefined - } else if (isSelecting && !isMoving) { - isSelecting = false - count = i - selectIndex - below = siblings[i].index - above = siblings[i + 1]?.index - - const indices = getIndicesBetween(below, above, count) - - for (let k = 0; k < count; k++) { - const node = siblings[selectIndex + k] - changes.push({ id: node.id as any, type: node.type, index: indices[k] }) - } - } - } - }) - - break - } - case 'backward': { - parents.forEach((movingSet, parentId) => { - const siblings = compact( - this.getSortedChildIds(parentId).map((id) => this.getShapeById(id)) - ) - const len = siblings.length - - if (movingSet.size === len) return - - const movingIndices = new Set(Array.from(movingSet).map((n) => siblings.indexOf(n))) - - let selectIndex = -1 - let isSelecting = false - let count: number - - for (let i = len - 1; i > -1; i--) { - const isMoving = movingIndices.has(i) - - if (!isSelecting && isMoving) { - isSelecting = true - selectIndex = i - } else if (isSelecting && !isMoving) { - isSelecting = false - count = selectIndex - i - - const indices = getIndicesBetween(siblings[i - 1]?.index, siblings[i].index, count) - - for (let k = 0; k < count; k++) { - const node = siblings[i + k + 1] - changes.push({ id: node.id as any, type: node.type, index: indices[k] }) - } - } - } - }) - - break - } - } - - this.updateShapes(changes) - return this - } - /** * Send shapes to the back of the page's object list. * @@ -6201,7 +6004,8 @@ export class Editor extends EventEmitter { * @public */ sendToBack(ids = this.pageState.selectedIds) { - this.reorderShapes('toBack', ids) + const changes = getReorderingShapesChanges(this, 'toBack', ids) + if (changes) this.updateShapes(changes) return this } @@ -6219,7 +6023,8 @@ export class Editor extends EventEmitter { * @public */ sendBackward(ids = this.pageState.selectedIds) { - this.reorderShapes('backward', ids) + const changes = getReorderingShapesChanges(this, 'backward', ids) + if (changes) this.updateShapes(changes) return this } @@ -6237,7 +6042,8 @@ export class Editor extends EventEmitter { * @public */ bringForward(ids = this.pageState.selectedIds) { - this.reorderShapes('forward', ids) + const changes = getReorderingShapesChanges(this, 'forward', ids) + if (changes) this.updateShapes(changes) return this } @@ -6255,7 +6061,8 @@ export class Editor extends EventEmitter { * @public */ bringToFront(ids = this.pageState.selectedIds) { - this.reorderShapes('toFront', ids) + const changes = getReorderingShapesChanges(this, 'toFront', ids) + if (changes) this.updateShapes(changes) return this } diff --git a/packages/editor/src/lib/test/commands/renderingShapes.test.tsx b/packages/editor/src/lib/test/commands/renderingShapes.test.tsx index d07386142..6a7c22c6a 100644 --- a/packages/editor/src/lib/test/commands/renderingShapes.test.tsx +++ b/packages/editor/src/lib/test/commands/renderingShapes.test.tsx @@ -120,7 +120,7 @@ it('lists shapes in viewport sorted by id with correct indexes & background inde ]) // Send B to the back - editor.reorderShapes('toBack', [ids.B]) + editor.sendToBack([ids.B]) // The items should still be sorted by id expect(normalizeIndexes(editor.renderingShapes)).toStrictEqual([ diff --git a/packages/editor/src/lib/test/commands/reorderShapes.test.ts b/packages/editor/src/lib/test/commands/reorderShapes.test.ts index 72b61afe7..44f482a5d 100644 --- a/packages/editor/src/lib/test/commands/reorderShapes.test.ts +++ b/packages/editor/src/lib/test/commands/reorderShapes.test.ts @@ -1,25 +1,918 @@ -// import { TestEditor } from '../TestEditor' +import { TLShapeId, createShapeId } from '@tldraw/tlschema' +import { TestEditor } from '../TestEditor' -// let editor: TestEditor +let editor: TestEditor -// beforeEach(() => { -// editor =new TestEditor() -// }) +function expectShapesInOrder(editor: TestEditor, ...ids: TLShapeId[]) { + expect(editor.sortedShapesArray.map((shape) => shape.id)).toMatchObject(ids) +} -describe('Send to Back', () => { - it.todo('Reorders shapes to the back of the list') +function getSiblingBelow(editor: TestEditor, id: TLShapeId) { + const shape = editor.getShapeById(id)! + const siblings = editor.getSortedChildIds(shape.parentId) + const index = siblings.indexOf(id) + return siblings[index - 1] +} + +function getSiblingAbove(editor: TestEditor, id: TLShapeId) { + const shape = editor.getShapeById(id)! + const siblings = editor.getSortedChildIds(shape.parentId) + const index = siblings.indexOf(id) + return siblings[index + 1] +} + +const ids = { + A: createShapeId('A'), + B: createShapeId('B'), + C: createShapeId('C'), + D: createShapeId('D'), + E: createShapeId('E'), + F: createShapeId('F'), + G: createShapeId('G'), +} + +beforeEach(() => { + editor?.dispose() + editor = new TestEditor() + editor.createShapes([ + { + id: ids['A'], + type: 'geo', + }, + { + id: ids['B'], + type: 'geo', + }, + { + id: ids['C'], + type: 'geo', + }, + { + id: ids['D'], + type: 'geo', + }, + { + id: ids['E'], + type: 'geo', + }, + { + id: ids['F'], + type: 'geo', + }, + { + id: ids['G'], + type: 'geo', + }, + ]) }) -describe('Send backward', () => { - it.todo('Reorders shapes backward in the list') +describe('When running zindex tests', () => { + it('Correctly initializes indices', () => { + expect(editor.sortedShapesArray.map((shape) => shape.index)).toMatchObject([ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + 'a7', + ]) + }) + + it('Correctly identifies shape orders', () => { + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) }) -describe('Bring forward', () => { - it.todo('Reorders shapes forward in the list') +describe('editor.getSiblingAbove', () => { + it('Gets the correct shape above', () => { + expect(getSiblingAbove(editor, ids['B'])).toBe(ids['C']) + expect(getSiblingAbove(editor, ids['C'])).toBe(ids['D']) + expect(getSiblingAbove(editor, ids['G'])).toBeUndefined() + }) }) -describe('Bring to Front', () => { - it.todo('Reorders shapes to the front of the list') +describe('editor.getSiblingAbove', () => { + it('Gets the correct shape above', () => { + expect(getSiblingBelow(editor, ids['A'])).toBeUndefined() + expect(getSiblingBelow(editor, ids['B'])).toBe(ids['A']) + expect(getSiblingBelow(editor, ids['C'])).toBe(ids['B']) + }) }) -it.todo('Does and undoes') +describe('When sending to back', () => { + it('Moves one shape to back', () => { + editor.sendToBack([ids['D']]) + expectShapesInOrder( + editor, + ids['D'], + ids['A'], + ids['B'], + ids['C'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendToBack([ids['D']]) // noop + expectShapesInOrder( + editor, + ids['D'], + ids['A'], + ids['B'], + ids['C'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves no shapes when selecting shapes at the back', () => { + editor.sendToBack([ids['A'], ids['B'], ids['C']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendToBack([ids['A'], ids['B'], ids['C']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves two adjacent shapes to back', () => { + editor.sendToBack([ids['D'], ids['E']]) + expectShapesInOrder( + editor, + ids['D'], + ids['E'], + ids['A'], + ids['B'], + ids['C'], + ids['F'], + ids['G'] + ) + editor.sendToBack([ids['D'], ids['E']]) + expectShapesInOrder( + editor, + ids['D'], + ids['E'], + ids['A'], + ids['B'], + ids['C'], + ids['F'], + ids['G'] + ) + }) + + it('Moves non-adjacent shapes to back', () => { + editor.sendToBack([ids['E'], ids['G']]) + expectShapesInOrder( + editor, + ids['E'], + ids['G'], + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['F'] + ) + editor.sendToBack([ids['E'], ids['G']]) + expectShapesInOrder( + editor, + ids['E'], + ids['G'], + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['F'] + ) + }) + + it('Moves non-adjacent shapes to back when one is at the back', () => { + editor.sendToBack([ids['A'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['G'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'] + ) + editor.sendToBack([ids['A'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['G'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'] + ) + }) +}) + +describe('When sending to front', () => { + it('Moves one shape to front', () => { + editor.bringToFront([ids['A']]) + expectShapesInOrder( + editor, + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'], + ids['A'] + ) + editor.bringToFront([ids['A']]) // noop + expectShapesInOrder( + editor, + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'], + ids['A'] + ) + }) + + it('Moves no shapes when selecting shapes at the front', () => { + editor.bringToFront([ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.bringToFront([ids['G']]) // noop + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves two adjacent shapes to front', () => { + editor.bringToFront([ids['D'], ids['E']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['F'], + ids['G'], + ids['D'], + ids['E'] + ) + editor.bringToFront([ids['D'], ids['E']]) // noop + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['F'], + ids['G'], + ids['D'], + ids['E'] + ) + }) + + it('Moves non-adjacent shapes to front', () => { + editor.bringToFront([ids['A'], ids['C']]) + expectShapesInOrder( + editor, + ids['B'], + ids['D'], + ids['E'], + ids['F'], + ids['G'], + ids['A'], + ids['C'] + ) + editor.bringToFront([ids['A'], ids['C']]) // noop + expectShapesInOrder( + editor, + ids['B'], + ids['D'], + ids['E'], + ids['F'], + ids['G'], + ids['A'], + ids['C'] + ) + }) + + it('Moves non-adjacent shapes to front when one is at the front', () => { + editor.bringToFront([ids['E'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['F'], + ids['E'], + ids['G'] + ) + editor.bringToFront([ids['E'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['F'], + ids['E'], + ids['G'] + ) + }) +}) + +describe('When sending backward', () => { + it('Moves one shape backward', () => { + editor.sendBackward([ids['C']]) + expectShapesInOrder( + editor, + ids['A'], + ids['C'], + ids['B'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['C']]) + expectShapesInOrder( + editor, + ids['C'], + ids['A'], + ids['B'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves shapes to the first position', () => { + editor.sendBackward([ids['B']]) + expectShapesInOrder( + editor, + ids['B'], + ids['A'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['A']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['B']]) + expectShapesInOrder( + editor, + ids['B'], + ids['A'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves two shapes to the first position', () => { + editor.sendBackward([ids['B'], ids['C']]) + expectShapesInOrder( + editor, + ids['B'], + ids['C'], + ids['A'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['C'], ids['A']]) + expectShapesInOrder( + editor, + ids['C'], + ids['A'], + ids['B'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['A'], ids['B']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves no shapes when sending shapes at the back', () => { + editor.sendBackward([ids['A'], ids['B'], ids['C']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['A'], ids['B'], ids['C']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves two adjacent shapes backward', () => { + editor.sendBackward([ids['D'], ids['E']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['D'], + ids['E'], + ids['C'], + ids['F'], + ids['G'] + ) + }) + + it('Moves two adjacent shapes backward when one is at the back', () => { + editor.sendBackward([ids['A'], ids['E']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['E'], + ids['D'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['A'], ids['E']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['E'], + ids['C'], + ids['D'], + ids['F'], + ids['G'] + ) + }) + + it('Moves non-adjacent shapes backward', () => { + editor.sendBackward([ids['E'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['E'], + ids['D'], + ids['G'], + ids['F'] + ) + editor.sendBackward([ids['E'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['E'], + ids['C'], + ids['G'], + ids['D'], + ids['F'] + ) + }) + + it('Moves non-adjacent shapes backward when one is at the back', () => { + editor.sendBackward([ids['A'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['G'], + ids['F'] + ) + editor.sendBackward([ids['A'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['G'], + ids['E'], + ids['F'] + ) + }) + + it('Moves non-adjacent shapes to backward when both are at the back', () => { + editor.sendBackward([ids['A'], ids['B']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.sendBackward([ids['A'], ids['B']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) +}) + +describe('When moving forward', () => { + it('Moves one shape forward', () => { + editor.bringForward([ids['A']]) + expectShapesInOrder( + editor, + ids['B'], + ids['A'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.bringForward([ids['A']]) + expectShapesInOrder( + editor, + ids['B'], + ids['C'], + ids['A'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves no shapes when sending shapes at the front', () => { + editor.bringForward([ids['E'], ids['F'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.bringForward([ids['E'], ids['F'], ids['G']]) // noop + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) + + it('Moves two adjacent shapes forward', () => { + editor.bringForward([ids['C'], ids['D']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['E'], + ids['C'], + ids['D'], + ids['F'], + ids['G'] + ) + editor.bringForward([ids['C'], ids['D']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['E'], + ids['F'], + ids['C'], + ids['D'], + ids['G'] + ) + }) + + it('Moves non-adjacent shapes forward', () => { + editor.bringForward([ids['A'], ids['C']]) + expectShapesInOrder( + editor, + ids['B'], + ids['A'], + ids['D'], + ids['C'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.bringForward([ids['A'], ids['C']]) + expectShapesInOrder( + editor, + ids['B'], + ids['D'], + ids['A'], + ids['E'], + ids['C'], + ids['F'], + ids['G'] + ) + }) + + it('Moves non-adjacent shapes to forward when one is at the front', () => { + editor.bringForward([ids['C'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['D'], + ids['C'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.bringForward([ids['C'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['D'], + ids['E'], + ids['C'], + ids['F'], + ids['G'] + ) + }) + + it('Moves adjacent shapes to forward when both are at the front', () => { + editor.bringForward([ids['F'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + editor.bringForward([ids['F'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) +}) + +// Edges + +describe('Edge cases...', () => { + it('When bringing forward, does not increment order if shapes at at the top', () => { + editor.bringForward([ids['F'], ids['G']]) + }) + it('When bringing forward, does not increment order with non-adjacent shapes if shapes at at the top', () => { + editor.bringForward([ids['E'], ids['G']]) + }) + + it('When bringing to front, does not change order of shapes already at top', () => { + editor.bringToFront([ids['E'], ids['G']]) + }) + + it('When sending to back, does not change order of shapes already at bottom', () => { + editor.sendToBack([ids['A'], ids['C']]) + }) + + it('When moving back to front...', () => { + editor.sendBackward([ids['F'], ids['G']]) + + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['F'], + ids['G'], + ids['E'] + ) + + editor.sendBackward([ids['F'], ids['G']]) + + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['F'], + ids['G'], + ids['D'], + ids['E'] + ) + + editor.sendBackward([ids['F'], ids['G']]) + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['F'], + ids['G'], + ids['C'], + ids['D'], + ids['E'] + ) + + editor.sendBackward([ids['F'], ids['G']]) + + expectShapesInOrder( + editor, + ids['A'], + ids['F'], + ids['G'], + ids['B'], + ids['C'], + ids['D'], + ids['E'] + ) + + editor.sendBackward([ids['F'], ids['G']]) + + expectShapesInOrder( + editor, + ids['F'], + ids['G'], + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'] + ) + + editor + .bringForward([ids['F'], ids['G']]) + .bringForward([ids['F'], ids['G']]) + .bringForward([ids['F'], ids['G']]) + .bringForward([ids['F'], ids['G']]) + .bringForward([ids['F'], ids['G']]) + + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + }) +}) + +describe('When undoing and redoing...', () => { + it('Undoes and redoes', () => { + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + + editor.mark() + editor.sendBackward([ids['F'], ids['G']]) + + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['F'], + ids['G'], + ids['E'] + ) + + editor.undo() + + expectShapesInOrder( + editor, + ids['A'], + ids['B'], + ids['C'], + ids['D'], + ids['E'], + ids['F'], + ids['G'] + ) + // .redo() + // .expectShapesInOrder(ids['A'], ids['B'], ids['C'], ids['D'], ids['F'], ids['G'], ids['E']) + }) +}) + +describe('When shapes are parented...', () => { + it('Sorted correctly by pageIndex', () => { + editor.reparentShapesById([ids['C']], ids['A']).reparentShapesById([ids['B']], ids['D']) + + expectShapesInOrder( + editor, + ids['A'], + ids['C'], + ids['D'], + ids['B'], + ids['E'], + ids['F'], + ids['G'] + ) + }) +}) diff --git a/packages/editor/src/lib/utils/reorderShapes.ts b/packages/editor/src/lib/utils/reorderShapes.ts new file mode 100644 index 000000000..1d1a09a91 --- /dev/null +++ b/packages/editor/src/lib/utils/reorderShapes.ts @@ -0,0 +1,233 @@ +import { getIndicesBetween, sortByIndex } from '@tldraw/indices' +import { TLParentId, TLShape, TLShapeId, TLShapePartial } from '@tldraw/tlschema' +import { compact } from '@tldraw/utils' +import { Editor } from '../editor/Editor' + +export function getReorderingShapesChanges( + editor: Editor, + operation: 'toBack' | 'toFront' | 'forward' | 'backward', + ids: TLShapeId[] +) { + if (ids.length === 0) return [] + + // From the ids that are moving, collect the parents, their children, and which of those children are moving + const parents = new Map; children: TLShape[] }>() + + for (const shape of compact(ids.map((id) => editor.getShapeById(id)))) { + const { parentId } = shape + if (!parents.has(parentId)) { + parents.set(parentId, { + children: compact(editor.getSortedChildIds(parentId).map((id) => editor.getShapeById(id))), + moving: new Set(), + }) + } + parents.get(parentId)!.moving.add(shape) + } + + const changes: TLShapePartial[] = [] + + switch (operation) { + case 'toBack': { + parents.forEach(({ moving, children }) => reorderToBack(moving, children, changes)) + break + } + case 'toFront': { + parents.forEach(({ moving, children }) => reorderToFront(moving, children, changes)) + break + } + case 'forward': { + parents.forEach(({ moving, children }) => reorderForward(moving, children, changes)) + break + } + case 'backward': { + parents.forEach(({ moving, children }) => reorderBackward(moving, children, changes)) + break + } + } + + return changes +} + +/** + * Reorders the moving shapes to the back of the parent's children. + * + * @param moving The set of shapes that are moving + * @param children The parent's children + * @param changes The changes array to push changes to + */ +function reorderToBack(moving: Set, children: TLShape[], changes: TLShapePartial[]) { + const len = children.length + + // If all of the children are moving, there's nothing to do + if (moving.size === len) return + + let below: string | undefined + let above: string | undefined + + // Starting at the bottom of this parent's children... + for (let i = 0; i < len; i++) { + const shape = children[i] + + if (moving.has(shape)) { + // If we've found a moving shape before we've found a non-moving shape, + // then that shape is already at the back; we can remove it from the + // moving set and mark it as the shape that will be below the moved shapes. + below = shape.index + moving.delete(shape) + } else { + // The first non-moving shape we find will be above our moved shapes; we'll + // put our moving shapes between it and the shape marked as below (if any). + above = shape.index + break + } + } + + if (moving.size === 0) { + // If our moving set is empty, there's nothing to do; all of our shapes were + // already at the back of the parent's children. + return + } else { + // Sort the moving shapes by their current index, then apply the new indices + const indices = getIndicesBetween(below, above, moving.size) + changes.push( + ...Array.from(moving.values()) + .sort(sortByIndex) + .map((shape, i) => ({ ...shape, index: indices[i] })) + ) + } +} + +/** + * Reorders the moving shapes to the front of the parent's children. + * + * @param moving The set of shapes that are moving + * @param children The parent's children + * @param changes The changes array to push changes to + */ +function reorderToFront(moving: Set, children: TLShape[], changes: TLShapePartial[]) { + const len = children.length + + // If all of the children are moving, there's nothing to do + if (moving.size === len) return + + let below: string | undefined + let above: string | undefined + + // Starting at the top of this parent's children... + for (let i = len - 1; i > -1; i--) { + const shape = children[i] + + if (moving.has(shape)) { + // If we've found a moving shape before we've found a non-moving shape, + // then that shape is already at the front; we can remove it from the + // moving set and mark it as the shape that will be above the moved shapes. + above = shape.index + moving.delete(shape) + } else { + // The first non-moving shape we find will be below our moved shapes; we'll + // put our moving shapes between it and the shape marked as above (if any). + below = shape.index + break + } + } + + if (moving.size === 0) { + // If our moving set is empty, there's nothing to do; all of our shapes were + // already at the front of the parent's children. + return + } else { + // Sort the moving shapes by their current index, then apply the new indices + const indices = getIndicesBetween(below, above, moving.size) + changes.push( + ...Array.from(moving.values()) + .sort(sortByIndex) + .map((shape, i) => ({ ...shape, index: indices[i] })) + ) + } +} + +/** + * Reorders the moving shapes forward in the parent's children. + * + * @param moving The set of shapes that are moving + * @param children The parent's children + * @param changes The changes array to push changes to + */ +function reorderForward(moving: Set, children: TLShape[], changes: TLShapePartial[]) { + const len = children.length + + // If all of the children are moving, there's nothing to do + if (moving.size === len) return + + let state = { name: 'skipping' } as + | { name: 'skipping' } + | { name: 'selecting'; selectIndex: number } + + // Starting at the bottom of this parent's children... + for (let i = 0; i < len; i++) { + const isMoving = moving.has(children[i]) + + switch (state.name) { + case 'skipping': { + if (!isMoving) continue + // If we find a moving shape while skipping, start selecting + state = { name: 'selecting', selectIndex: i } + break + } + case 'selecting': { + if (isMoving) continue + // if we find a non-moving shape while selecting, move all selected + // shapes in front of the not moving shape; and start skipping + const { selectIndex } = state + getIndicesBetween(children[i].index, children[i + 1]?.index, i - selectIndex).forEach( + (index, k) => changes.push({ ...children[selectIndex + k], index }) + ) + state = { name: 'skipping' } + break + } + } + } +} + +/** + * Reorders the moving shapes backward in the parent's children. + * + * @param moving The set of shapes that are moving + * @param children The parent's children + * @param changes The changes array to push changes to + */ +function reorderBackward(moving: Set, children: TLShape[], changes: TLShapePartial[]) { + const len = children.length + + if (moving.size === len) return + + let state = { name: 'skipping' } as + | { name: 'skipping' } + | { name: 'selecting'; selectIndex: number } + + // Starting at the top of this parent's children... + for (let i = len - 1; i > -1; i--) { + const isMoving = moving.has(children[i]) + + switch (state.name) { + case 'skipping': { + if (!isMoving) continue + // If we find a moving shape while skipping, start selecting + state = { name: 'selecting', selectIndex: i } + break + } + case 'selecting': { + if (isMoving) continue + // if we find a non-moving shape while selecting, move all selected + // shapes in behind of the not moving shape; and start skipping + getIndicesBetween(children[i - 1]?.index, children[i].index, state.selectIndex - i).forEach( + (index, k) => { + changes.push({ ...children[i + k + 1], index }) + } + ) + state = { name: 'skipping' } + break + } + } + } +}