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:
parent
dac814fd39
commit
fd4b5c6291
14 changed files with 274 additions and 166 deletions
|
@ -14,6 +14,7 @@ import { Box } from '@tldraw/editor';
|
||||||
import { Circle2d } from '@tldraw/editor';
|
import { Circle2d } from '@tldraw/editor';
|
||||||
import { ComponentType } from 'react';
|
import { ComponentType } from 'react';
|
||||||
import { CubicSpline2d } from '@tldraw/editor';
|
import { CubicSpline2d } from '@tldraw/editor';
|
||||||
|
import { DictValidator } from '@tldraw/editor';
|
||||||
import { Editor } from '@tldraw/editor';
|
import { Editor } from '@tldraw/editor';
|
||||||
import { EMBED_DEFINITIONS } from '@tldraw/editor';
|
import { EMBED_DEFINITIONS } from '@tldraw/editor';
|
||||||
import { EmbedDefinition } from '@tldraw/editor';
|
import { EmbedDefinition } from '@tldraw/editor';
|
||||||
|
@ -888,7 +889,12 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
|
||||||
dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">;
|
dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">;
|
||||||
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
|
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
|
||||||
spline: EnumStyleProp<"cubic" | "line">;
|
spline: EnumStyleProp<"cubic" | "line">;
|
||||||
points: ArrayOfValidator<VecModel>;
|
points: DictValidator<string, {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
index: IndexKey;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement;
|
toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement;
|
||||||
|
|
|
@ -10637,21 +10637,21 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Reference",
|
"kind": "Reference",
|
||||||
"text": "ArrayOfValidator",
|
"text": "DictValidator",
|
||||||
"canonicalReference": "@tldraw/validate!ArrayOfValidator:class"
|
"canonicalReference": "@tldraw/validate!DictValidator:class"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": "<import(\"@tldraw/editor\")."
|
"text": "<string, {\n id: string;\n x: number;\n y: number;\n index: import(\"@tldraw/editor\")."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Reference",
|
"kind": "Reference",
|
||||||
"text": "VecModel",
|
"text": "IndexKey",
|
||||||
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
|
"canonicalReference": "@tldraw/utils!IndexKey:type"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": ">;\n }"
|
"text": ";\n }>;\n }"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Group2d, TLShapeId } from '@tldraw/editor'
|
import { Group2d, IndexKey, TLShapeId } from '@tldraw/editor'
|
||||||
import { TestEditor } from '../../../test/TestEditor'
|
import { TestEditor } from '../../../test/TestEditor'
|
||||||
import { TL } from '../../../test/test-jsx'
|
import { TL } from '../../../test/test-jsx'
|
||||||
|
|
||||||
|
@ -17,10 +17,10 @@ describe('Handle snapping', () => {
|
||||||
ref="line"
|
ref="line"
|
||||||
x={0}
|
x={0}
|
||||||
y={0}
|
y={0}
|
||||||
points={[
|
points={{
|
||||||
{ x: 200, y: 0 },
|
a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 },
|
||||||
{ x: 200, y: 100 },
|
a2: { id: 'a2', index: 'a2' as IndexKey, x: 200, y: 100 },
|
||||||
]}
|
}}
|
||||||
/>,
|
/>,
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
|
@ -65,10 +65,10 @@ describe('When dragging the line', () => {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 0, y: 0 },
|
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
|
||||||
{ x: 10, y: 10 },
|
a2: { id: 'a2', index: 'a2', x: 10, y: 10 },
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
editor.expectToBeIn('select.dragging_handle')
|
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]
|
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
|
||||||
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
|
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', () => {
|
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]
|
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
|
||||||
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
|
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', () => {
|
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]
|
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
|
||||||
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
|
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', () => {
|
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]
|
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
|
||||||
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
|
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', () => {
|
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]
|
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
|
||||||
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
|
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
|
||||||
expect(line.props.points.length).toBe(3)
|
expect(Object.keys(line.props.points).length).toBe(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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 { TestEditor } from '../../../test/TestEditor'
|
||||||
import { TL } from '../../../test/test-jsx'
|
import { TL } from '../../../test/test-jsx'
|
||||||
|
|
||||||
|
@ -24,10 +31,10 @@ beforeEach(() => {
|
||||||
x: 150,
|
x: 150,
|
||||||
y: 150,
|
y: 150,
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 0, y: 0 },
|
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
|
||||||
{ x: 100, y: 100 },
|
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.pointerMove(349, 349).pointerMove(350, 350) // Move handle by 150, 150
|
||||||
editor.pointerUp()
|
editor.pointerUp()
|
||||||
|
|
||||||
editor.expectShapeToMatch<TLLineShape>({
|
editor.expectShapeToMatch({
|
||||||
id: id,
|
id: id,
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 0, y: 0 },
|
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
|
||||||
{ x: 200, y: 200 },
|
a1V: { id: 'a1V', index: 'a1V', x: 200, y: 200 },
|
||||||
{ x: 100, y: 100 },
|
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.snaps.getIndicators()).toHaveLength(1)
|
||||||
expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
|
expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
|
||||||
const points = editor.getShape<TLLineShape>(id)!.props.points
|
const points = editor.getShape<TLLineShape>(id)!.props.points
|
||||||
expect(points).toHaveLength(3)
|
expect(points).toStrictEqual({
|
||||||
expect(points[0]).toMatchObject({ x: 0, y: 0 })
|
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
|
||||||
expect(points[1]).toMatchObject({ x: 50, y: 80 })
|
a1V: { id: 'a1V', index: 'a1V', x: 50, y: 80 },
|
||||||
expect(points[2]).toMatchObject({ x: 100, y: 100 })
|
a2: { id: 'a2', index: 'a2', x: 100, y: 100 },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows snapping with created mid-point handles', () => {
|
it('allows snapping with created mid-point handles', () => {
|
||||||
|
@ -145,10 +153,11 @@ describe('Mid-point handles', () => {
|
||||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||||
expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
|
expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
|
||||||
const points = editor.getShape<TLLineShape>(id)!.props.points
|
const points = editor.getShape<TLLineShape>(id)!.props.points
|
||||||
expect(points).toHaveLength(3)
|
expect(points).toStrictEqual({
|
||||||
expect(points[0]).toMatchObject({ x: 0, y: 0 })
|
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
|
||||||
expect(points[1]).toMatchObject({ x: 50, y: 80 })
|
a1V: { id: 'a1V', index: 'a1V', x: 50, y: 80 },
|
||||||
expect(points[2]).toMatchObject({ x: 100, y: 100 })
|
a2: { id: 'a2', index: 'a2', x: 100, y: 100 },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -158,12 +167,12 @@ describe('Snapping', () => {
|
||||||
id: id,
|
id: id,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 0, y: 0 },
|
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
|
||||||
{ x: 100, y: 0 },
|
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
|
||||||
{ x: 100, y: 100 },
|
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
|
||||||
{ x: 0, y: 100 },
|
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -176,15 +185,15 @@ describe('Snapping', () => {
|
||||||
.pointerMove(50, 95, undefined, { ctrlKey: true })
|
.pointerMove(50, 95, undefined, { ctrlKey: true })
|
||||||
|
|
||||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||||
editor.expectShapeToMatch<TLLineShape>({
|
editor.expectShapeToMatch({
|
||||||
id: id,
|
id: id,
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 50, y: 100 },
|
a1: { id: 'a1', index: 'a1', x: 50, y: 100 },
|
||||||
{ x: 100, y: 0 },
|
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
|
||||||
{ x: 100, y: 100 },
|
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
|
||||||
{ x: 0, y: 100 },
|
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -200,12 +209,12 @@ describe('Snapping', () => {
|
||||||
editor.expectShapeToMatch({
|
editor.expectShapeToMatch({
|
||||||
id: id,
|
id: id,
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 0, y: 100 },
|
a1: { id: 'a1', index: 'a1', x: 0, y: 100 },
|
||||||
{ x: 100, y: 0 },
|
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
|
||||||
{ x: 100, y: 100 },
|
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
|
||||||
{ x: 0, y: 100 },
|
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -218,15 +227,15 @@ describe('Snapping', () => {
|
||||||
.pointerMove(5, 2, undefined, { ctrlKey: true })
|
.pointerMove(5, 2, undefined, { ctrlKey: true })
|
||||||
|
|
||||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||||
editor.expectShapeToMatch<TLLineShape>({
|
editor.expectShapeToMatch({
|
||||||
id: id,
|
id: id,
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 5, y: 2 },
|
a1: { id: 'a1', index: 'a1', x: 5, y: 2 },
|
||||||
{ x: 100, y: 0 },
|
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
|
||||||
{ x: 100, y: 100 },
|
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
|
||||||
{ x: 0, y: 100 },
|
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -236,10 +245,10 @@ describe('Snapping', () => {
|
||||||
<TL.line
|
<TL.line
|
||||||
x={150}
|
x={150}
|
||||||
y={150}
|
y={150}
|
||||||
points={[
|
points={{
|
||||||
{ x: 200, y: 0 },
|
a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 },
|
||||||
{ x: 300, 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 })
|
.pointerMove(205, 1, undefined, { ctrlKey: true })
|
||||||
|
|
||||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||||
editor.expectShapeToMatch<TLLineShape>({
|
editor.expectShapeToMatch({
|
||||||
id: id,
|
id: id,
|
||||||
props: {
|
props: {
|
||||||
points: [
|
points: {
|
||||||
{ x: 200, y: 0 },
|
a1: { id: 'a1', index: 'a1', x: 200, y: 0 },
|
||||||
{ x: 100, y: 0 },
|
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
|
||||||
{ x: 100, y: 100 },
|
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
|
||||||
{ x: 0, y: 100 },
|
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -13,11 +13,12 @@ import {
|
||||||
TLOnResizeHandler,
|
TLOnResizeHandler,
|
||||||
Vec,
|
Vec,
|
||||||
WeakMapCache,
|
WeakMapCache,
|
||||||
ZERO_INDEX_KEY,
|
|
||||||
getDefaultColorTheme,
|
getDefaultColorTheme,
|
||||||
getIndexAbove,
|
getIndexBetween,
|
||||||
|
getIndices,
|
||||||
lineShapeMigrations,
|
lineShapeMigrations,
|
||||||
lineShapeProps,
|
lineShapeProps,
|
||||||
|
mapObjectMapValues,
|
||||||
sortByIndex,
|
sortByIndex,
|
||||||
} from '@tldraw/editor'
|
} from '@tldraw/editor'
|
||||||
|
|
||||||
|
@ -47,21 +48,16 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
|
||||||
override hideSelectionBoundsBg = () => true
|
override hideSelectionBoundsBg = () => true
|
||||||
|
|
||||||
override getDefaultProps(): TLLineShape['props'] {
|
override getDefaultProps(): TLLineShape['props'] {
|
||||||
|
const [start, end] = getIndices(2)
|
||||||
return {
|
return {
|
||||||
dash: 'draw',
|
dash: 'draw',
|
||||||
size: 'm',
|
size: 'm',
|
||||||
color: 'black',
|
color: 'black',
|
||||||
spline: 'line',
|
spline: 'line',
|
||||||
points: [
|
points: {
|
||||||
{
|
[start]: { id: start, index: start, x: 0, y: 0 },
|
||||||
x: 0,
|
[end]: { id: end, index: end, x: 0.1, y: 0.1 },
|
||||||
y: 0,
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 0.1,
|
|
||||||
y: 0.1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,38 +70,26 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
|
||||||
return handlesCache.get(shape.props, () => {
|
return handlesCache.get(shape.props, () => {
|
||||||
const spline = getGeometryForLineShape(shape)
|
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
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const index = getIndexBetween(points[i].index, points[i + 1].index)
|
||||||
let index = ZERO_INDEX_KEY
|
const segment = spline.segments[i]
|
||||||
|
const point = segment.midPoint()
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
const handle = points[i]
|
|
||||||
results.push({
|
results.push({
|
||||||
...handle,
|
|
||||||
id: index,
|
id: index,
|
||||||
|
type: 'create',
|
||||||
index,
|
index,
|
||||||
type: 'vertex',
|
x: point.x,
|
||||||
canBind: false,
|
y: point.y,
|
||||||
canSnap: true,
|
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)
|
return results.sort(sortByIndex)
|
||||||
|
@ -119,36 +103,28 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
points: shape.props.points.map(({ x, y }) => {
|
points: mapObjectMapValues(shape.props.points, (_, { id, index, x, y }) => ({
|
||||||
return {
|
id,
|
||||||
x: x * scaleX,
|
index,
|
||||||
y: y * scaleY,
|
x: x * scaleX,
|
||||||
}
|
y: y * scaleY,
|
||||||
}),
|
})),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override onHandleDrag: TLOnHandleDragHandler<TLLineShape> = (shape, { handle }) => {
|
override onHandleDrag: TLOnHandleDragHandler<TLLineShape> = (shape, { handle }) => {
|
||||||
// we should only ever be dragging vertex handles
|
// we should only ever be dragging vertex handles
|
||||||
if (handle.type !== 'vertex') {
|
if (handle.type !== 'vertex') return
|
||||||
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 }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...shape,
|
...shape,
|
||||||
props: {
|
props: {
|
||||||
...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 {
|
override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry {
|
||||||
const { points } = shape.props
|
const points = linePointsToArray(shape)
|
||||||
return {
|
return {
|
||||||
points,
|
points,
|
||||||
getSelfSnapPoints: (handle) => {
|
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 */
|
/** @public */
|
||||||
export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Polyline2d {
|
export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Polyline2d {
|
||||||
const { spline, points } = shape.props
|
const points = linePointsToArray(shape).map(Vec.From)
|
||||||
const handlePoints = points.map(Vec.From)
|
|
||||||
|
|
||||||
switch (spline) {
|
switch (shape.props.spline) {
|
||||||
case 'cubic': {
|
case 'cubic': {
|
||||||
return new CubicSpline2d({ points: handlePoints })
|
return new CubicSpline2d({ points })
|
||||||
}
|
}
|
||||||
case 'line': {
|
case 'line': {
|
||||||
return new Polyline2d({ points: handlePoints })
|
return new Polyline2d({ points })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,16 +11,20 @@ exports[`Misc resizes: line shape after resize 1`] = `
|
||||||
"props": {
|
"props": {
|
||||||
"color": "black",
|
"color": "black",
|
||||||
"dash": "draw",
|
"dash": "draw",
|
||||||
"points": [
|
"points": {
|
||||||
{
|
"a1": {
|
||||||
|
"id": "a1",
|
||||||
|
"index": "a1",
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
{
|
"a2": {
|
||||||
|
"id": "a2",
|
||||||
|
"index": "a2",
|
||||||
"x": 100,
|
"x": 100,
|
||||||
"y": 700,
|
"y": 700,
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
"size": "m",
|
"size": "m",
|
||||||
"spline": "line",
|
"spline": "line",
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
TLShapeId,
|
TLShapeId,
|
||||||
Vec,
|
Vec,
|
||||||
createShapeId,
|
createShapeId,
|
||||||
|
getIndexAbove,
|
||||||
last,
|
last,
|
||||||
sortByIndex,
|
sortByIndex,
|
||||||
structuredClone,
|
structuredClone,
|
||||||
|
@ -56,10 +57,21 @@ export class Pointing extends StateNode {
|
||||||
Vec.Dist(nextPoint, endHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES
|
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
|
// 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 {
|
} else {
|
||||||
// Add a new point
|
// 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([
|
this.editor.updateShapes([
|
||||||
|
|
|
@ -62,18 +62,17 @@ export class DraggingHandle extends StateNode {
|
||||||
// create a new vertex handle at that point; and make this handle
|
// create a new vertex handle at that point; and make this handle
|
||||||
// the handle that we're dragging.
|
// the handle that we're dragging.
|
||||||
if (this.initialHandle.type === 'create') {
|
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({
|
this.editor.updateShape({
|
||||||
...shape,
|
...shape,
|
||||||
props: {
|
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 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)
|
this.initialHandle = structuredClone(handleAfter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
BaseBoxShapeUtil,
|
BaseBoxShapeUtil,
|
||||||
|
IndexKey,
|
||||||
Polyline2d,
|
Polyline2d,
|
||||||
TLAnyShapeUtilConstructor,
|
TLAnyShapeUtilConstructor,
|
||||||
TLBaseShape,
|
TLBaseShape,
|
||||||
|
@ -238,10 +239,10 @@ describe('custom handle snapping', () => {
|
||||||
ref="line"
|
ref="line"
|
||||||
x={0}
|
x={0}
|
||||||
y={0}
|
y={0}
|
||||||
points={[
|
points={{
|
||||||
{ x: 0, y: 0 },
|
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
|
||||||
{ x: 100, y: 100 },
|
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} />,
|
<TL.test ref="test" x={200} y={200} w={100} h={100} boundsSnapPoints={null} />,
|
||||||
])
|
])
|
||||||
|
|
|
@ -680,7 +680,12 @@ export const lineShapeProps: {
|
||||||
dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">;
|
dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">;
|
||||||
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
|
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
|
||||||
spline: EnumStyleProp<"cubic" | "line">;
|
spline: EnumStyleProp<"cubic" | "line">;
|
||||||
points: T.ArrayOfValidator<VecModel>;
|
points: T.DictValidator<string, {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
index: IndexKey;
|
||||||
|
} & {}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
|
|
|
@ -2787,21 +2787,21 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Reference",
|
"kind": "Reference",
|
||||||
"text": "T.ArrayOfValidator",
|
"text": "T.DictValidator",
|
||||||
"canonicalReference": "@tldraw/validate!ArrayOfValidator:class"
|
"canonicalReference": "@tldraw/validate!DictValidator:class"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": "<import(\"../misc/geometry-types\")."
|
"text": "<string, {\n id: string;\n x: number;\n y: number;\n index: "
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Reference",
|
"kind": "Reference",
|
||||||
"text": "VecModel",
|
"text": "IndexKey",
|
||||||
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
|
"canonicalReference": "@tldraw/utils!IndexKey:type"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": ">;\n}"
|
"text": ";\n } & {}>;\n}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fileUrlPath": "packages/tlschema/src/shapes/TLLineShape.ts",
|
"fileUrlPath": "packages/tlschema/src/shapes/TLLineShape.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 --- */
|
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
|
||||||
|
|
||||||
for (const migrator of allMigrators) {
|
for (const migrator of allMigrators) {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { defineMigrations } from '@tldraw/store'
|
import { defineMigrations } from '@tldraw/store'
|
||||||
import { IndexKey, deepCopy, getIndices, objectMapFromEntries, sortByIndex } from '@tldraw/utils'
|
import { IndexKey, deepCopy, getIndices, objectMapFromEntries, sortByIndex } from '@tldraw/utils'
|
||||||
import { T } from '@tldraw/validate'
|
import { T } from '@tldraw/validate'
|
||||||
import { vecModelValidator } from '../misc/geometry-types'
|
|
||||||
import { StyleProp } from '../styles/StyleProp'
|
import { StyleProp } from '../styles/StyleProp'
|
||||||
import { DefaultColorStyle } from '../styles/TLColorStyle'
|
import { DefaultColorStyle } from '../styles/TLColorStyle'
|
||||||
import { DefaultDashStyle } from '../styles/TLDashStyle'
|
import { DefaultDashStyle } from '../styles/TLDashStyle'
|
||||||
|
@ -17,13 +16,20 @@ export const LineShapeSplineStyle = StyleProp.defineEnum('tldraw:spline', {
|
||||||
/** @public */
|
/** @public */
|
||||||
export type TLLineShapeSplineStyle = T.TypeOf<typeof LineShapeSplineStyle>
|
export type TLLineShapeSplineStyle = T.TypeOf<typeof LineShapeSplineStyle>
|
||||||
|
|
||||||
|
const lineShapePointValidator = T.object({
|
||||||
|
id: T.string,
|
||||||
|
index: T.indexKey,
|
||||||
|
x: T.number,
|
||||||
|
y: T.number,
|
||||||
|
})
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export const lineShapeProps = {
|
export const lineShapeProps = {
|
||||||
color: DefaultColorStyle,
|
color: DefaultColorStyle,
|
||||||
dash: DefaultDashStyle,
|
dash: DefaultDashStyle,
|
||||||
size: DefaultSizeStyle,
|
size: DefaultSizeStyle,
|
||||||
spline: LineShapeSplineStyle,
|
spline: LineShapeSplineStyle,
|
||||||
points: T.arrayOf(vecModelValidator),
|
points: T.dict(T.string, lineShapePointValidator),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
|
@ -37,11 +43,12 @@ export const lineShapeVersions = {
|
||||||
AddSnapHandles: 1,
|
AddSnapHandles: 1,
|
||||||
RemoveExtraHandleProps: 2,
|
RemoveExtraHandleProps: 2,
|
||||||
HandlesToPoints: 3,
|
HandlesToPoints: 3,
|
||||||
|
PointIndexIds: 4,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export const lineShapeMigrations = defineMigrations({
|
export const lineShapeMigrations = defineMigrations({
|
||||||
currentVersion: lineShapeVersions.HandlesToPoints,
|
currentVersion: lineShapeVersions.PointIndexIds,
|
||||||
migrators: {
|
migrators: {
|
||||||
[lineShapeVersions.AddSnapHandles]: {
|
[lineShapeVersions.AddSnapHandles]: {
|
||||||
up: (record: any) => {
|
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 })),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue