From fd4b5c6291bd3efe8ad461e4b546953737ad5dc9 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 21 Feb 2024 10:06:14 +0000 Subject: [PATCH] Add line IDs & fractional indexes (#2890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In #2856, we moved changed line handles into an array of points. This introduced an issue where some concurrent operations wouldn't work because they array indexes change. We need some sort of stable way of referring to these points. Our existing fractional indexing system is a good fit. In this version, instead of making the points be a map from index to x/y, we make the points be a map from id (the index) to x/y/index/id(also index). This is "kinda silly" (steve's words) but might be more familiar to devs who are expecting maps to be keyed on IDs rather than anything else. ### Change Type - [x] `major` — Breaking change --- packages/tldraw/api-report.md | 8 +- packages/tldraw/api/api.json | 12 +- .../src/lib/shapes/geo/GeoShapeUtil.test.tsx | 10 +- .../src/lib/shapes/line/LineShapeTool.test.ts | 18 +-- .../lib/shapes/line/LineShapeUtil.test.tsx | 121 ++++++++++-------- .../src/lib/shapes/line/LineShapeUtil.tsx | 105 ++++++--------- .../__snapshots__/LineShapeUtil.test.tsx.snap | 12 +- .../lib/shapes/line/toolStates/Pointing.ts | 16 ++- .../SelectTool/childStates/DraggingHandle.tsx | 11 +- .../tldraw/src/test/customSnapping.test.tsx | 9 +- packages/tlschema/api-report.md | 7 +- packages/tlschema/api/api.json | 12 +- packages/tlschema/src/migrations.test.ts | 46 +++++++ packages/tlschema/src/shapes/TLLineShape.ts | 53 +++++++- 14 files changed, 274 insertions(+), 166 deletions(-) diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 2ba88eab6..e7fbee7ba 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -14,6 +14,7 @@ import { Box } from '@tldraw/editor'; import { Circle2d } from '@tldraw/editor'; import { ComponentType } from 'react'; import { CubicSpline2d } from '@tldraw/editor'; +import { DictValidator } from '@tldraw/editor'; import { Editor } from '@tldraw/editor'; import { EMBED_DEFINITIONS } from '@tldraw/editor'; import { EmbedDefinition } from '@tldraw/editor'; @@ -888,7 +889,12 @@ export class LineShapeUtil extends ShapeUtil { dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">; size: EnumStyleProp<"l" | "m" | "s" | "xl">; spline: EnumStyleProp<"cubic" | "line">; - points: ArrayOfValidator; + points: DictValidator; }; // (undocumented) toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement; diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index e94d76b42..5d1993538 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -10637,21 +10637,21 @@ }, { "kind": "Reference", - "text": "ArrayOfValidator", - "canonicalReference": "@tldraw/validate!ArrayOfValidator:class" + "text": "DictValidator", + "canonicalReference": "@tldraw/validate!DictValidator:class" }, { "kind": "Content", - "text": ";\n }" + "text": ";\n }>;\n }" }, { "kind": "Content", diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx index b6386444f..c0278c619 100644 --- a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx @@ -1,4 +1,4 @@ -import { Group2d, TLShapeId } from '@tldraw/editor' +import { Group2d, IndexKey, TLShapeId } from '@tldraw/editor' import { TestEditor } from '../../../test/TestEditor' import { TL } from '../../../test/test-jsx' @@ -17,10 +17,10 @@ describe('Handle snapping', () => { ref="line" x={0} y={0} - points={[ - { x: 200, y: 0 }, - { x: 200, y: 100 }, - ]} + points={{ + a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 }, + a2: { id: 'a2', index: 'a2' as IndexKey, x: 200, y: 100 }, + }} />, ]) }) diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts b/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts index 89ea548b8..b87a69119 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts +++ b/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts @@ -65,10 +65,10 @@ describe('When dragging the line', () => { x: 0, y: 0, props: { - points: [ - { x: 0, y: 0 }, - { x: 10, y: 10 }, - ], + points: { + a1: { id: 'a1', index: 'a1', x: 0, y: 0 }, + a2: { id: 'a2', index: 'a2', x: 10, y: 10 }, + }, }, }) editor.expectToBeIn('select.dragging_handle') @@ -128,7 +128,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1] assert(editor.isShapeOfType(line, 'line')) - expect(line.props.points.length).toBe(3) + expect(Object.keys(line.props.points).length).toBe(3) }) it('extends a line after a click by shift-click dragging', () => { @@ -144,7 +144,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1] assert(editor.isShapeOfType(line, 'line')) - expect(line.props.points.length).toBe(2) + expect(Object.keys(line.props.points).length).toBe(2) }) it('extends a line by shift-click dragging', () => { @@ -161,7 +161,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1] assert(editor.isShapeOfType(line, 'line')) - expect(line.props.points.length).toBe(3) + expect(Object.keys(line.props.points).length).toBe(3) }) it('extends a line by shift-clicking even after canceling a pointerdown', () => { @@ -180,7 +180,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1] assert(editor.isShapeOfType(line, 'line')) - expect(line.props.points.length).toBe(3) + expect(Object.keys(line.props.points).length).toBe(3) }) it('extends a line by shift-clicking even after canceling a pointermove', () => { @@ -201,7 +201,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1] assert(editor.isShapeOfType(line, 'line')) - expect(line.props.points.length).toBe(3) + expect(Object.keys(line.props.points).length).toBe(3) }) }) diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx index 92834b851..3595afb92 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx @@ -1,4 +1,11 @@ -import { TLGeoShape, TLLineShape, createShapeId, deepCopy, sortByIndex } from '@tldraw/editor' +import { + IndexKey, + TLGeoShape, + TLLineShape, + createShapeId, + deepCopy, + sortByIndex, +} from '@tldraw/editor' import { TestEditor } from '../../../test/TestEditor' import { TL } from '../../../test/test-jsx' @@ -24,10 +31,10 @@ beforeEach(() => { x: 150, y: 150, props: { - points: [ - { x: 0, y: 0 }, - { x: 100, y: 100 }, - ], + points: { + a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 }, + a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 100 }, + }, }, }, ]) @@ -77,14 +84,14 @@ describe('Mid-point handles', () => { editor.pointerMove(349, 349).pointerMove(350, 350) // Move handle by 150, 150 editor.pointerUp() - editor.expectShapeToMatch({ + editor.expectShapeToMatch({ id: id, props: { - points: [ - { x: 0, y: 0 }, - { x: 200, y: 200 }, - { x: 100, y: 100 }, - ], + points: { + a1: { id: 'a1', index: 'a1', x: 0, y: 0 }, + a1V: { id: 'a1V', index: 'a1V', x: 200, y: 200 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 100 }, + }, }, }) }) @@ -105,10 +112,11 @@ describe('Mid-point handles', () => { expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2 const points = editor.getShape(id)!.props.points - expect(points).toHaveLength(3) - expect(points[0]).toMatchObject({ x: 0, y: 0 }) - expect(points[1]).toMatchObject({ x: 50, y: 80 }) - expect(points[2]).toMatchObject({ x: 100, y: 100 }) + expect(points).toStrictEqual({ + a1: { id: 'a1', index: 'a1', x: 0, y: 0 }, + a1V: { id: 'a1V', index: 'a1V', x: 50, y: 80 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 100 }, + }) }) it('allows snapping with created mid-point handles', () => { @@ -145,10 +153,11 @@ describe('Mid-point handles', () => { expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2 const points = editor.getShape(id)!.props.points - expect(points).toHaveLength(3) - expect(points[0]).toMatchObject({ x: 0, y: 0 }) - expect(points[1]).toMatchObject({ x: 50, y: 80 }) - expect(points[2]).toMatchObject({ x: 100, y: 100 }) + expect(points).toStrictEqual({ + a1: { id: 'a1', index: 'a1', x: 0, y: 0 }, + a1V: { id: 'a1V', index: 'a1V', x: 50, y: 80 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 100 }, + }) }) }) @@ -158,12 +167,12 @@ describe('Snapping', () => { id: id, type: 'line', props: { - points: [ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - { x: 100, y: 100 }, - { x: 0, y: 100 }, - ], + points: { + a1: { id: 'a1', index: 'a1', x: 0, y: 0 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 0 }, + a3: { id: 'a3', index: 'a3', x: 100, y: 100 }, + a4: { id: 'a4', index: 'a4', x: 0, y: 100 }, + }, }, }) }) @@ -176,15 +185,15 @@ describe('Snapping', () => { .pointerMove(50, 95, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) - editor.expectShapeToMatch({ + editor.expectShapeToMatch({ id: id, props: { - points: [ - { x: 50, y: 100 }, - { x: 100, y: 0 }, - { x: 100, y: 100 }, - { x: 0, y: 100 }, - ], + points: { + a1: { id: 'a1', index: 'a1', x: 50, y: 100 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 0 }, + a3: { id: 'a3', index: 'a3', x: 100, y: 100 }, + a4: { id: 'a4', index: 'a4', x: 0, y: 100 }, + }, }, }) }) @@ -200,12 +209,12 @@ describe('Snapping', () => { editor.expectShapeToMatch({ id: id, props: { - points: [ - { x: 0, y: 100 }, - { x: 100, y: 0 }, - { x: 100, y: 100 }, - { x: 0, y: 100 }, - ], + points: { + a1: { id: 'a1', index: 'a1', x: 0, y: 100 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 0 }, + a3: { id: 'a3', index: 'a3', x: 100, y: 100 }, + a4: { id: 'a4', index: 'a4', x: 0, y: 100 }, + }, }, }) }) @@ -218,15 +227,15 @@ describe('Snapping', () => { .pointerMove(5, 2, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) - editor.expectShapeToMatch({ + editor.expectShapeToMatch({ id: id, props: { - points: [ - { x: 5, y: 2 }, - { x: 100, y: 0 }, - { x: 100, y: 100 }, - { x: 0, y: 100 }, - ], + points: { + a1: { id: 'a1', index: 'a1', x: 5, y: 2 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 0 }, + a3: { id: 'a3', index: 'a3', x: 100, y: 100 }, + a4: { id: 'a4', index: 'a4', x: 0, y: 100 }, + }, }, }) }) @@ -236,10 +245,10 @@ describe('Snapping', () => { , ]) @@ -251,15 +260,15 @@ describe('Snapping', () => { .pointerMove(205, 1, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) - editor.expectShapeToMatch({ + editor.expectShapeToMatch({ id: id, props: { - points: [ - { x: 200, y: 0 }, - { x: 100, y: 0 }, - { x: 100, y: 100 }, - { x: 0, y: 100 }, - ], + points: { + a1: { id: 'a1', index: 'a1', x: 200, y: 0 }, + a2: { id: 'a2', index: 'a2', x: 100, y: 0 }, + a3: { id: 'a3', index: 'a3', x: 100, y: 100 }, + a4: { id: 'a4', index: 'a4', x: 0, y: 100 }, + }, }, }) }) diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx index 9c580cc41..635c393f9 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx @@ -13,11 +13,12 @@ import { TLOnResizeHandler, Vec, WeakMapCache, - ZERO_INDEX_KEY, getDefaultColorTheme, - getIndexAbove, + getIndexBetween, + getIndices, lineShapeMigrations, lineShapeProps, + mapObjectMapValues, sortByIndex, } from '@tldraw/editor' @@ -47,21 +48,16 @@ export class LineShapeUtil extends ShapeUtil { override hideSelectionBoundsBg = () => true override getDefaultProps(): TLLineShape['props'] { + const [start, end] = getIndices(2) return { dash: 'draw', size: 'm', color: 'black', spline: 'line', - points: [ - { - x: 0, - y: 0, - }, - { - x: 0.1, - y: 0.1, - }, - ], + points: { + [start]: { id: start, index: start, x: 0, y: 0 }, + [end]: { id: end, index: end, x: 0.1, y: 0.1 }, + }, } } @@ -74,38 +70,26 @@ export class LineShapeUtil extends ShapeUtil { return handlesCache.get(shape.props, () => { const spline = getGeometryForLineShape(shape) - const results: TLHandle[] = [] + const points = linePointsToArray(shape) + const results: TLHandle[] = points.map((point) => ({ + ...point, + id: point.index, + type: 'vertex', + canSnap: true, + })) - const { points } = shape.props - - let index = ZERO_INDEX_KEY - - for (let i = 0; i < points.length; i++) { - const handle = points[i] + for (let i = 0; i < points.length - 1; i++) { + const index = getIndexBetween(points[i].index, points[i + 1].index) + const segment = spline.segments[i] + const point = segment.midPoint() results.push({ - ...handle, id: index, + type: 'create', index, - type: 'vertex', - canBind: false, + x: point.x, + y: point.y, canSnap: true, }) - index = getIndexAbove(index) - - if (i < points.length - 1) { - const segment = spline.segments[i] - const point = segment.midPoint() - results.push({ - id: index, - type: 'create', - index, - x: point.x, - y: point.y, - canSnap: true, - canBind: false, - }) - index = getIndexAbove(index) - } } return results.sort(sortByIndex) @@ -119,36 +103,28 @@ export class LineShapeUtil extends ShapeUtil { return { props: { - points: shape.props.points.map(({ x, y }) => { - return { - x: x * scaleX, - y: y * scaleY, - } - }), + points: mapObjectMapValues(shape.props.points, (_, { id, index, x, y }) => ({ + id, + index, + x: x * scaleX, + y: y * scaleY, + })), }, } } override onHandleDrag: TLOnHandleDragHandler = (shape, { handle }) => { // we should only ever be dragging vertex handles - if (handle.type !== 'vertex') { - return shape - } - - // get the index of the point to which the vertex handle corresponds - const index = this.getHandles(shape) - .filter((h) => h.type === 'vertex') - .findIndex((h) => h.id === handle.id)! - - // splice in the new point - const points = [...shape.props.points] - points[index] = { x: handle.x, y: handle.y } + if (handle.type !== 'vertex') return return { ...shape, props: { ...shape.props, - points, + points: { + ...shape.props.points, + [handle.id]: { id: handle.id, index: handle.index, x: handle.x, y: handle.y }, + }, }, } } @@ -387,7 +363,7 @@ export class LineShapeUtil extends ShapeUtil { } override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry { - const { points } = shape.props + const points = linePointsToArray(shape) return { points, getSelfSnapPoints: (handle) => { @@ -418,17 +394,20 @@ export class LineShapeUtil extends ShapeUtil { } } +function linePointsToArray(shape: TLLineShape) { + return Object.values(shape.props.points).sort(sortByIndex) +} + /** @public */ export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Polyline2d { - const { spline, points } = shape.props - const handlePoints = points.map(Vec.From) + const points = linePointsToArray(shape).map(Vec.From) - switch (spline) { + switch (shape.props.spline) { case 'cubic': { - return new CubicSpline2d({ points: handlePoints }) + return new CubicSpline2d({ points }) } case 'line': { - return new Polyline2d({ points: handlePoints }) + return new Polyline2d({ points }) } } } diff --git a/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.tsx.snap b/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.tsx.snap index 4111578e1..8428150cd 100644 --- a/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.tsx.snap +++ b/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.tsx.snap @@ -11,16 +11,20 @@ exports[`Misc resizes: line shape after resize 1`] = ` "props": { "color": "black", "dash": "draw", - "points": [ - { + "points": { + "a1": { + "id": "a1", + "index": "a1", "x": 0, "y": 0, }, - { + "a2": { + "id": "a2", + "index": "a2", "x": 100, "y": 700, }, - ], + }, "size": "m", "spline": "line", }, diff --git a/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts b/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts index 276ef79e8..1ee5b49f3 100644 --- a/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts +++ b/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts @@ -7,6 +7,7 @@ import { TLShapeId, Vec, createShapeId, + getIndexAbove, last, sortByIndex, structuredClone, @@ -56,10 +57,21 @@ export class Pointing extends StateNode { Vec.Dist(nextPoint, endHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES ) { // Don't add a new point if the distance between the last two points is too small - points[points.length - 1] = nextPoint.toJson() + points[endHandle.id] = { + id: endHandle.id, + index: endHandle.index, + x: nextPoint.x, + y: nextPoint.y, + } } else { // Add a new point - points.push(nextPoint.toJson()) + const nextIndex = getIndexAbove(endHandle.index) + points[nextIndex] = { + id: nextIndex, + index: nextIndex, + x: nextPoint.x, + y: nextPoint.y, + } } this.editor.updateShapes([ diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx b/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx index f8e5cc3b3..471ca1275 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx @@ -62,18 +62,17 @@ export class DraggingHandle extends StateNode { // create a new vertex handle at that point; and make this handle // the handle that we're dragging. if (this.initialHandle.type === 'create') { - const handles = this.editor.getShapeHandles(shape)! - const index = handles.indexOf(handle) - const points = structuredClone(shape.props.points) - points.splice(Math.ceil(index / 2), 0, { x: handle.x, y: handle.y }) this.editor.updateShape({ ...shape, props: { - points, + points: { + ...shape.props.points, + [handle.index]: { id: handle.index, index: handle.index, x: handle.x, y: handle.y }, + }, }, }) const handlesAfter = this.editor.getShapeHandles(shape)! - const handleAfter = handlesAfter.find((h) => h.x === handle.x && h.y === handle.y)! + const handleAfter = handlesAfter.find((h) => h.index === handle.index)! this.initialHandle = structuredClone(handleAfter) } } diff --git a/packages/tldraw/src/test/customSnapping.test.tsx b/packages/tldraw/src/test/customSnapping.test.tsx index 52881892a..5329e8543 100644 --- a/packages/tldraw/src/test/customSnapping.test.tsx +++ b/packages/tldraw/src/test/customSnapping.test.tsx @@ -1,5 +1,6 @@ import { BaseBoxShapeUtil, + IndexKey, Polyline2d, TLAnyShapeUtilConstructor, TLBaseShape, @@ -238,10 +239,10 @@ describe('custom handle snapping', () => { ref="line" x={0} y={0} - points={[ - { x: 0, y: 0 }, - { x: 100, y: 100 }, - ]} + points={{ + a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 }, + a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 100 }, + }} />, , ]) diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 70334d14a..c15480d8d 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -680,7 +680,12 @@ export const lineShapeProps: { dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">; size: EnumStyleProp<"l" | "m" | "s" | "xl">; spline: EnumStyleProp<"cubic" | "line">; - points: T.ArrayOfValidator; + points: T.DictValidator; }; // @public (undocumented) diff --git a/packages/tlschema/api/api.json b/packages/tlschema/api/api.json index 2f54eb9e4..d7262cf79 100644 --- a/packages/tlschema/api/api.json +++ b/packages/tlschema/api/api.json @@ -2787,21 +2787,21 @@ }, { "kind": "Reference", - "text": "T.ArrayOfValidator", - "canonicalReference": "@tldraw/validate!ArrayOfValidator:class" + "text": "T.DictValidator", + "canonicalReference": "@tldraw/validate!DictValidator:class" }, { "kind": "Content", - "text": ";\n}" + "text": ";\n } & {}>;\n}" } ], "fileUrlPath": "packages/tlschema/src/shapes/TLLineShape.ts", diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index bd3d3c2f9..23365e99d 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -1983,6 +1983,52 @@ describe('Restore some handle props', () => { }) }) +describe('Fractional indexing for line points', () => { + const { up, down } = lineShapeMigrations.migrators[lineShapeVersions.PointIndexIds] + it('up works as expected', () => { + expect( + up({ + props: { + points: [ + { x: 0, y: 0 }, + { x: 76, y: 60 }, + { x: 190, y: -62 }, + ], + }, + }) + ).toEqual({ + props: { + points: { + a1: { id: 'a1', index: 'a1', x: 0, y: 0 }, + a2: { id: 'a2', index: 'a2', x: 76, y: 60 }, + a3: { id: 'a3', index: 'a3', x: 190, y: -62 }, + }, + }, + }) + }) + it('down works as expected', () => { + expect( + down({ + props: { + points: { + a1: { id: 'a1', index: 'a1', x: 0, y: 0 }, + a3: { id: 'a3', index: 'a3', x: 190, y: -62 }, + a2: { id: 'a2', index: 'a2', x: 76, y: 60 }, + }, + }, + }) + ).toEqual({ + props: { + points: [ + { x: 0, y: 0 }, + { x: 76, y: 60 }, + { x: 190, y: -62 }, + ], + }, + }) + }) +}) + /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */ for (const migrator of allMigrators) { diff --git a/packages/tlschema/src/shapes/TLLineShape.ts b/packages/tlschema/src/shapes/TLLineShape.ts index 5810fc349..12d9089e2 100644 --- a/packages/tlschema/src/shapes/TLLineShape.ts +++ b/packages/tlschema/src/shapes/TLLineShape.ts @@ -1,7 +1,6 @@ import { defineMigrations } from '@tldraw/store' import { IndexKey, deepCopy, getIndices, objectMapFromEntries, sortByIndex } from '@tldraw/utils' import { T } from '@tldraw/validate' -import { vecModelValidator } from '../misc/geometry-types' import { StyleProp } from '../styles/StyleProp' import { DefaultColorStyle } from '../styles/TLColorStyle' import { DefaultDashStyle } from '../styles/TLDashStyle' @@ -17,13 +16,20 @@ export const LineShapeSplineStyle = StyleProp.defineEnum('tldraw:spline', { /** @public */ export type TLLineShapeSplineStyle = T.TypeOf +const lineShapePointValidator = T.object({ + id: T.string, + index: T.indexKey, + x: T.number, + y: T.number, +}) + /** @public */ export const lineShapeProps = { color: DefaultColorStyle, dash: DefaultDashStyle, size: DefaultSizeStyle, spline: LineShapeSplineStyle, - points: T.arrayOf(vecModelValidator), + points: T.dict(T.string, lineShapePointValidator), } /** @public */ @@ -37,11 +43,12 @@ export const lineShapeVersions = { AddSnapHandles: 1, RemoveExtraHandleProps: 2, HandlesToPoints: 3, + PointIndexIds: 4, } as const /** @internal */ export const lineShapeMigrations = defineMigrations({ - currentVersion: lineShapeVersions.HandlesToPoints, + currentVersion: lineShapeVersions.PointIndexIds, migrators: { [lineShapeVersions.AddSnapHandles]: { up: (record: any) => { @@ -148,5 +155,45 @@ export const lineShapeMigrations = defineMigrations({ } }, }, + [lineShapeVersions.PointIndexIds]: { + up: (record: any) => { + const { points, ...props } = record.props + const indices = getIndices(points.length) + + return { + ...record, + props: { + ...props, + points: Object.fromEntries( + points.map((point: { x: number; y: number }, i: number) => { + const id = indices[i] + return [ + id, + { + id: id, + index: id, + x: point.x, + y: point.y, + }, + ] + }) + ), + }, + } + }, + down: (record: any) => { + const sortedHandles = ( + Object.values(record.props.points) as { x: number; y: number; index: IndexKey }[] + ).sort(sortByIndex) + + return { + ...record, + props: { + ...record.props, + points: sortedHandles.map(({ x, y }) => ({ x, y })), + }, + } + }, + }, }, })