[refactor] reordering shapes (#1718)

This PR:
- adds tests for shape reordering
- removes `Editor.getParentsMappedToChildren`
- removes `Editor.reorderShapes`
- moves reordering shapes code into its own file, outside of the editor

### Change Type

- [x] `major` — Breaking change (if you were using those APIs)

### Release Notes

- [api] removes `Editor.getParentsMappedToChildren`
- [api] removes `Editor.reorderShapes`
- [api] moves reordering shapes code into its own file, outside of the
editor
This commit is contained in:
Steve Ruiz 2023-07-07 12:29:31 +01:00 committed by GitHub
parent 85db9ba469
commit 103809b83e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 1150 additions and 219 deletions

View file

@ -503,7 +503,6 @@ export class Editor extends EventEmitter<TLEventMap> {
getPageTransformById(id: TLShapeId): Matrix2d | undefined; getPageTransformById(id: TLShapeId): Matrix2d | undefined;
getParentIdForNewShapeAtPoint(point: VecLike, shapeType: TLShape['type']): TLPageId | TLShapeId; getParentIdForNewShapeAtPoint(point: VecLike, shapeType: TLShape['type']): TLPageId | TLShapeId;
getParentShape(shape?: TLShape): TLShape | undefined; getParentShape(shape?: TLShape): TLShape | undefined;
getParentsMappedToChildren(ids: TLShapeId[]): Map<TLParentId, Set<TLShape>>;
getParentTransform(shape: TLShape): Matrix2d; getParentTransform(shape: TLShape): Matrix2d;
getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d; getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d;
getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d; getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d;
@ -631,7 +630,6 @@ export class Editor extends EventEmitter<TLEventMap> {
isInViewport: boolean; isInViewport: boolean;
maskedPageBounds: Box2d | undefined; maskedPageBounds: Box2d | undefined;
}[]; }[];
reorderShapes(operation: 'backward' | 'forward' | 'toBack' | 'toFront', ids: TLShapeId[]): this;
reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this; reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this;
replaceStoreContentsWithRecordsForOtherDocument(records: TLRecord[]): void; replaceStoreContentsWithRecordsForOtherDocument(records: TLRecord[]): void;
resetZoom(point?: Vec2d, opts?: TLAnimationOptions): this; resetZoom(point?: Vec2d, opts?: TLAnimationOptions): this;

View file

@ -111,6 +111,7 @@ import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/Sh
import { WeakMapCache } from '../utils/WeakMapCache' import { WeakMapCache } from '../utils/WeakMapCache'
import { dataUrlToFile } from '../utils/assets' import { dataUrlToFile } from '../utils/assets'
import { getIncrementedName, uniqueId } from '../utils/data' import { getIncrementedName, uniqueId } from '../utils/data'
import { getReorderingShapesChanges } from '../utils/reorderShapes'
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
import { arrowBindingsIndex } from './derivations/arrowBindingsIndex' import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
import { parentsToChildrenWithIndexes } from './derivations/parentsToChildrenWithIndexes' import { parentsToChildrenWithIndexes } from './derivations/parentsToChildrenWithIndexes'
@ -5148,31 +5149,6 @@ export class Editor extends EventEmitter<TLEventMap> {
return this.getDeltaInShapeSpace(parent, delta) 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<TLParentId, Set<TLShape>>()
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. * An array containing all of the shapes in the current page.
* *
@ -6014,179 +5990,6 @@ export class Editor extends EventEmitter<TLEventMap> {
return this 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. * Send shapes to the back of the page's object list.
* *
@ -6201,7 +6004,8 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
sendToBack(ids = this.pageState.selectedIds) { sendToBack(ids = this.pageState.selectedIds) {
this.reorderShapes('toBack', ids) const changes = getReorderingShapesChanges(this, 'toBack', ids)
if (changes) this.updateShapes(changes)
return this return this
} }
@ -6219,7 +6023,8 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
sendBackward(ids = this.pageState.selectedIds) { sendBackward(ids = this.pageState.selectedIds) {
this.reorderShapes('backward', ids) const changes = getReorderingShapesChanges(this, 'backward', ids)
if (changes) this.updateShapes(changes)
return this return this
} }
@ -6237,7 +6042,8 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
bringForward(ids = this.pageState.selectedIds) { bringForward(ids = this.pageState.selectedIds) {
this.reorderShapes('forward', ids) const changes = getReorderingShapesChanges(this, 'forward', ids)
if (changes) this.updateShapes(changes)
return this return this
} }
@ -6255,7 +6061,8 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
bringToFront(ids = this.pageState.selectedIds) { bringToFront(ids = this.pageState.selectedIds) {
this.reorderShapes('toFront', ids) const changes = getReorderingShapesChanges(this, 'toFront', ids)
if (changes) this.updateShapes(changes)
return this return this
} }

View file

@ -120,7 +120,7 @@ it('lists shapes in viewport sorted by id with correct indexes & background inde
]) ])
// Send B to the back // Send B to the back
editor.reorderShapes('toBack', [ids.B]) editor.sendToBack([ids.B])
// The items should still be sorted by id // The items should still be sorted by id
expect(normalizeIndexes(editor.renderingShapes)).toStrictEqual([ expect(normalizeIndexes(editor.renderingShapes)).toStrictEqual([

View file

@ -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(() => { function expectShapesInOrder(editor: TestEditor, ...ids: TLShapeId[]) {
// editor =new TestEditor() expect(editor.sortedShapesArray.map((shape) => shape.id)).toMatchObject(ids)
// }) }
describe('Send to Back', () => { function getSiblingBelow(editor: TestEditor, id: TLShapeId) {
it.todo('Reorders shapes to the back of the list') 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', () => { describe('When running zindex tests', () => {
it.todo('Reorders shapes backward in the list') 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', () => { describe('editor.getSiblingAbove', () => {
it.todo('Reorders shapes forward in the list') 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', () => { describe('editor.getSiblingAbove', () => {
it.todo('Reorders shapes to the front of the list') 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']
)
})
})

View file

@ -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<TLParentId, { moving: Set<TLShape>; 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<TLShape>, 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<TLShape>, 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<TLShape>, 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<TLShape>, 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
}
}
}
}