Add line IDs & fractional indexes (#2890)

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
This commit is contained in:
alex 2024-02-21 10:06:14 +00:00 committed by GitHub
parent dac814fd39
commit fd4b5c6291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 274 additions and 166 deletions

View file

@ -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<TLLineShape> {
dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">;
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
spline: EnumStyleProp<"cubic" | "line">;
points: ArrayOfValidator<VecModel>;
points: DictValidator<string, {
id: string;
x: number;
y: number;
index: IndexKey;
}>;
};
// (undocumented)
toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement;

View file

@ -10637,21 +10637,21 @@
},
{
"kind": "Reference",
"text": "ArrayOfValidator",
"canonicalReference": "@tldraw/validate!ArrayOfValidator:class"
"text": "DictValidator",
"canonicalReference": "@tldraw/validate!DictValidator:class"
},
{
"kind": "Content",
"text": "<import(\"@tldraw/editor\")."
"text": "<string, {\n id: string;\n x: number;\n y: number;\n index: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "VecModel",
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
"text": "IndexKey",
"canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ">;\n }"
"text": ";\n }>;\n }"
},
{
"kind": "Content",

View file

@ -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 },
}}
/>,
])
})

View file

@ -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<TLLineShape>(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<TLLineShape>(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<TLLineShape>(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<TLLineShape>(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<TLLineShape>(line, 'line'))
expect(line.props.points.length).toBe(3)
expect(Object.keys(line.props.points).length).toBe(3)
})
})

View file

@ -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<TLLineShape>({
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<TLLineShape>(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<TLLineShape>(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<TLLineShape>({
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<TLLineShape>({
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', () => {
<TL.line
x={150}
y={150}
points={[
{ x: 200, y: 0 },
{ x: 300, y: 0 },
]}
points={{
a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 300, y: 0 },
}}
/>,
])
@ -251,15 +260,15 @@ describe('Snapping', () => {
.pointerMove(205, 1, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch<TLLineShape>({
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 },
},
},
})
})

View file

@ -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<TLLineShape> {
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<TLLineShape> {
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<TLLineShape> {
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<TLLineShape> = (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<TLLineShape> {
}
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<TLLineShape> {
}
}
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 })
}
}
}

View file

@ -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",
},

View file

@ -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([

View file

@ -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)
}
}

View file

@ -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 },
}}
/>,
<TL.test ref="test" x={200} y={200} w={100} h={100} boundsSnapPoints={null} />,
])

View file

@ -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<VecModel>;
points: T.DictValidator<string, {
id: string;
x: number;
y: number;
index: IndexKey;
} & {}>;
};
// @public (undocumented)

View file

@ -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": "<import(\"../misc/geometry-types\")."
"text": "<string, {\n id: string;\n x: number;\n y: number;\n index: "
},
{
"kind": "Reference",
"text": "VecModel",
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
"text": "IndexKey",
"canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ">;\n}"
"text": ";\n } & {}>;\n}"
}
],
"fileUrlPath": "packages/tlschema/src/shapes/TLLineShape.ts",

View file

@ -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) {

View file

@ -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<typeof LineShapeSplineStyle>
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 })),
},
}
},
},
},
})