[handles] Line shape handles -> points (#2856)

This PR replaces the line shape's `handles` prop with `points`, an array
of `VecModel`s.

### Change Type

- [x] `minor` — New feature

### Test Plan

- [x] Unit Tests
- [ ] End to end tests
This commit is contained in:
Steve Ruiz 2024-02-19 17:10:31 +00:00 committed by GitHub
parent b1b821e529
commit 31ce1c1a89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 313 additions and 222 deletions

View file

@ -14,7 +14,6 @@ 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';
@ -893,7 +892,7 @@ 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">;
handles: DictValidator<IndexKey, VecModel>; points: ArrayOfValidator<VecModel>;
}; };
// (undocumented) // (undocumented)
toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement; toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement;

View file

@ -10695,26 +10695,17 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<\"cubic\" | \"line\">;\n handles: import(\"@tldraw/editor\")." "text": "<\"cubic\" | \"line\">;\n points: import(\"@tldraw/editor\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "DictValidator", "text": "ArrayOfValidator",
"canonicalReference": "@tldraw/validate!DictValidator:class" "canonicalReference": "@tldraw/validate!ArrayOfValidator:class"
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<import(\"@tldraw/editor\")." "text": "<import(\"@tldraw/editor\")."
}, },
{
"kind": "Reference",
"text": "IndexKey",
"canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ", import(\"@tldraw/editor\")."
},
{ {
"kind": "Reference", "kind": "Reference",
"text": "VecModel", "text": "VecModel",
@ -10735,7 +10726,7 @@
"name": "props", "name": "props",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 16 "endIndex": 14
}, },
"isStatic": true, "isStatic": true,
"isProtected": false, "isProtected": false,

View file

@ -1,4 +1,4 @@
import { Group2d, IndexKey, TLShapeId } from '@tldraw/editor' import { Group2d, 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,7 +17,10 @@ describe('Handle snapping', () => {
ref="line" ref="line"
x={0} x={0}
y={0} y={0}
handles={{ ['a1' as IndexKey]: { x: 200, y: 0 }, ['a2' as IndexKey]: { x: 200, y: 100 } }} points={[
{ x: 200, y: 0 },
{ x: 200, y: 100 },
]}
/>, />,
]) ])
}) })

View file

@ -65,10 +65,10 @@ describe('When dragging the line', () => {
x: 0, x: 0,
y: 0, y: 0,
props: { props: {
handles: { points: [
a1: { x: 0, y: 0 }, { x: 0, y: 0 },
a2: { x: 10, y: 10 }, { x: 10, y: 10 },
}, ],
}, },
}) })
editor.expectToBeIn('select.dragging_handle') editor.expectToBeIn('select.dragging_handle')
@ -128,8 +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'))
const handles = Object.values(line.props.handles) expect(line.props.points.length).toBe(3)
expect(handles.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', () => {
@ -145,8 +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'))
const handles = Object.values(line.props.handles) expect(line.props.points.length).toBe(2)
expect(handles.length).toBe(2)
}) })
it('extends a line by shift-click dragging', () => { it('extends a line by shift-click dragging', () => {
@ -163,8 +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'))
const handles = Object.values(line.props.handles) expect(line.props.points.length).toBe(3)
expect(handles.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', () => {
@ -183,8 +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'))
const handles = Object.values(line.props.handles) expect(line.props.points.length).toBe(3)
expect(handles.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', () => {
@ -205,8 +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'))
const handles = Object.values(line.props.handles) expect(line.props.points.length).toBe(3)
expect(handles.length).toBe(3)
}) })
}) })

View file

@ -1,7 +1,6 @@
import { IndexKey, TLGeoShape, TLLineShape, createShapeId, deepCopy } from '@tldraw/editor' import { 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'
import { LineShapeUtil } from './LineShapeUtil'
jest.mock('nanoid', () => { jest.mock('nanoid', () => {
let i = 0 let i = 0
@ -18,37 +17,31 @@ beforeEach(() => {
editor editor
.selectAll() .selectAll()
.deleteShapes(editor.getSelectedShapeIds()) .deleteShapes(editor.getSelectedShapeIds())
.createShapes([ .createShapes<TLLineShape>([
{ {
id: id, id: id,
type: 'line', type: 'line',
x: 150, x: 150,
y: 150, y: 150,
props: { props: {
handles: { points: [
a1: { { x: 0, y: 0 },
x: 0, { x: 100, y: 100 },
y: 0, ],
},
a2: {
x: 100,
y: 100,
},
},
}, },
}, },
]) ])
}) })
const getShape = () => editor.getShape<TLLineShape>(id)! const getShape = () => editor.getShape<TLLineShape>(id)!
const getHandles = () => (editor.getShapeUtil('line') as LineShapeUtil).getHandles(getShape()) const getHandles = () => editor.getShapeHandles<TLLineShape>(id)!
describe('Translating', () => { describe('Translating', () => {
it('updates the line', () => { it('updates the line', () => {
editor.select(id) editor.select(id)
editor.pointerDown(25, 25, { target: 'shape', shape: getShape() }) editor.pointerDown(25, 25, { target: 'shape', shape: getShape() })
editor.pointerMove(50, 50) // Move shape by 25, 25 editor.pointerMove(50, 50) // Move shape by 25, 25
editor.expectShapeToMatch({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
x: 175, x: 175,
y: 175, y: 175,
@ -64,7 +57,7 @@ describe('Translating', () => {
editor.pointerDown(250, 250, { target: 'shape', shape: shape }) editor.pointerDown(250, 250, { target: 'shape', shape: shape })
editor.pointerMove(300, 400) // Move shape by 50, 150 editor.pointerMove(300, 400) // Move shape by 50, 150
editor.expectShapeToMatch({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
x: 200, x: 200,
y: 300, y: 300,
@ -79,23 +72,19 @@ describe('Mid-point handles', () => {
editor.pointerDown(200, 200, { editor.pointerDown(200, 200, {
target: 'handle', target: 'handle',
shape: getShape(), shape: getShape(),
handle: { handle: getHandles()[1],
id: 'mid-0',
type: 'create',
index: 'a1V' as IndexKey,
x: 50,
y: 50,
},
}) })
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({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
props: { props: {
handles: { points: [
a1V: { x: 200, y: 200 }, { x: 0, y: 0 },
}, { x: 200, y: 200 },
{ x: 100, y: 100 },
],
}, },
}) })
}) })
@ -109,33 +98,35 @@ describe('Mid-point handles', () => {
.pointerDown(200, 200, { .pointerDown(200, 200, {
target: 'handle', target: 'handle',
shape: getShape(), shape: getShape(),
handle: getHandles().find((h) => h.id === 'mid-0')!, handle: getHandles()[1],
}) })
.pointerMove(198, 230, undefined, { ctrlKey: true }) .pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({ expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
id: id, const points = editor.getShape<TLLineShape>(id)!.props.points
props: { expect(points).toHaveLength(3)
handles: { expect(points[0]).toMatchObject({ x: 0, y: 0 })
a1V: { x: 50, y: 80 }, expect(points[1]).toMatchObject({ x: 50, y: 80 })
}, expect(points[2]).toMatchObject({ x: 100, y: 100 })
},
})
}) })
it('allows snapping with created mid-point handles', () => { it('allows snapping with created mid-point handles', () => {
editor.createShapesFromJsx([<TL.geo x={200} y={200} w={100} h={100} />]) editor.createShapesFromJsx([<TL.geo x={200} y={200} w={100} h={100} />])
editor.select(id)
// 2 actual points, plus 1 mid-points:
expect(getHandles()).toHaveLength(3)
// use a mid-point handle to create a new handle // use a mid-point handle to create a new handle
editor editor
.select(id)
.pointerDown(200, 200, { .pointerDown(200, 200, {
target: 'handle', target: 'handle',
shape: getShape(), shape: getShape(),
handle: getHandles().find((h) => h.id === 'mid-0')!, handle: getHandles().sort(sortByIndex)[1]!,
}) })
.pointerMove(230, 200) .pointerMove(230, 200)
.pointerMove(240, 200)
.pointerMove(200, 200) .pointerMove(200, 200)
.pointerUp() .pointerUp()
@ -147,19 +138,17 @@ describe('Mid-point handles', () => {
.pointerDown(200, 200, { .pointerDown(200, 200, {
target: 'handle', target: 'handle',
shape: getShape(), shape: getShape(),
handle: getHandles().find((h) => h.id === 'a1V')!, handle: getHandles().sort(sortByIndex)[2],
}) })
.pointerMove(198, 230, undefined, { ctrlKey: true }) .pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({ expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
id: id, const points = editor.getShape<TLLineShape>(id)!.props.points
props: { expect(points).toHaveLength(3)
handles: { expect(points[0]).toMatchObject({ x: 0, y: 0 })
a1V: { x: 50, y: 80 }, expect(points[1]).toMatchObject({ x: 50, y: 80 })
}, expect(points[2]).toMatchObject({ x: 100, y: 100 })
},
})
}) })
}) })
@ -169,12 +158,12 @@ describe('Snapping', () => {
id: id, id: id,
type: 'line', type: 'line',
props: { props: {
handles: { points: [
a1: { x: 0, y: 0 }, { x: 0, y: 0 },
a2: { x: 100, y: 0 }, { x: 100, y: 0 },
a3: { x: 100, y: 100 }, { x: 100, y: 100 },
a4: { x: 0, y: 100 }, { x: 0, y: 100 },
}, ],
}, },
}) })
}) })
@ -187,12 +176,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({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
props: { props: {
handles: { points: [
a1: { x: 50, y: 100 }, { x: 50, y: 100 },
}, { x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
}, },
}) })
}) })
@ -205,12 +197,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({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
props: { props: {
handles: { points: [
a1: { x: 5, y: 2 }, { x: 5, y: 2 },
}, { x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
}, },
}) })
}) })
@ -220,20 +215,31 @@ describe('Snapping', () => {
<TL.line <TL.line
x={150} x={150}
y={150} y={150}
handles={{ ['a1' as IndexKey]: { x: 200, y: 0 }, ['a2' as IndexKey]: { x: 300, y: 0 } }} points={[
{ x: 200, y: 0 },
{ x: 300, y: 0 },
]}
/>, />,
]) ])
editor.select(id) editor.select(id)
const handle = getHandles()[0]
editor editor
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] }) .pointerDown(handle.x, handle.y, { target: 'handle', shape: getShape(), handle })
.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({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
props: { handles: { a1: { x: 200, y: 0 } } }, props: {
points: [
{ x: 200, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
},
}) })
}) })
}) })
@ -242,7 +248,7 @@ describe('Misc', () => {
it('preserves handle positions on spline type change', () => { it('preserves handle positions on spline type change', () => {
editor.select(id) editor.select(id)
const shape = getShape() const shape = getShape()
const prevHandles = deepCopy(shape.props.handles) const prevPoints = deepCopy(shape.props.points)
editor.updateShapes([ editor.updateShapes([
{ {
@ -253,11 +259,11 @@ describe('Misc', () => {
}, },
]) ])
editor.expectShapeToMatch({ editor.expectShapeToMatch<TLLineShape>({
id, id,
props: { props: {
spline: 'cubic', spline: 'cubic',
handles: prevHandles, points: prevPoints,
}, },
}) })
}) })
@ -277,7 +283,7 @@ describe('Misc', () => {
editor.select(id) editor.select(id)
editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 1, y: 0 }) editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 1, y: 0 })
editor.expectShapeToMatch({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
x: 151, x: 151,
y: 150, y: 150,
@ -285,7 +291,7 @@ describe('Misc', () => {
editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 0, y: 10 }) editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 0, y: 10 })
editor.expectShapeToMatch({ editor.expectShapeToMatch<TLLineShape>({
id: id, id: id,
x: 151, x: 151,
y: 160, y: 160,

View file

@ -11,13 +11,11 @@ import {
TLOnResizeHandler, TLOnResizeHandler,
Vec, Vec,
WeakMapCache, WeakMapCache,
deepCopy, ZERO_INDEX_KEY,
getDefaultColorTheme, getDefaultColorTheme,
getIndexBetween, getIndexAbove,
getIndices,
lineShapeMigrations, lineShapeMigrations,
lineShapeProps, lineShapeProps,
objectMapEntries,
sortByIndex, sortByIndex,
} from '@tldraw/editor' } from '@tldraw/editor'
@ -47,22 +45,21 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
override hideSelectionBoundsBg = () => true override hideSelectionBoundsBg = () => true
override getDefaultProps(): TLLineShape['props'] { override getDefaultProps(): TLLineShape['props'] {
const [startIndex, endIndex] = getIndices(2)
return { return {
dash: 'draw', dash: 'draw',
size: 'm', size: 'm',
color: 'black', color: 'black',
spline: 'line', spline: 'line',
handles: { points: [
[startIndex]: { {
x: 0, x: 0,
y: 0, y: 0,
}, },
[endIndex]: { {
x: 0.1, x: 0.1,
y: 0.1, y: 0.1,
}, },
}, ],
} }
} }
@ -73,39 +70,40 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
override getHandles(shape: TLLineShape) { override getHandles(shape: TLLineShape) {
return handlesCache.get(shape.props, () => { return handlesCache.get(shape.props, () => {
const handles = shape.props.handles
const spline = getGeometryForLineShape(shape) const spline = getGeometryForLineShape(shape)
const sortedHandles = objectMapEntries(handles) const results: TLHandle[] = []
.map(
([index, handle]): TLHandle => ({
id: index,
index,
...handle,
type: 'vertex',
canBind: false,
canSnap: true,
})
)
.sort(sortByIndex)
const results = sortedHandles.slice()
// Add "create" handles between each vertex handle const { points } = shape.props
for (let i = 0; i < spline.segments.length; i++) {
const segment = spline.segments[i]
const point = segment.midPoint()
const index = getIndexBetween(sortedHandles[i].index, sortedHandles[i + 1].index)
let index = ZERO_INDEX_KEY
for (let i = 0; i < points.length; i++) {
const handle = points[i]
results.push({ results.push({
id: `mid-${i}`, ...handle,
type: 'create', id: index,
index, index,
x: point.x, type: 'vertex',
y: point.y,
canSnap: true,
canBind: false, canBind: false,
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)
@ -122,29 +120,38 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
override onResize: TLOnResizeHandler<TLLineShape> = (shape, info) => { override onResize: TLOnResizeHandler<TLLineShape> = (shape, info) => {
const { scaleX, scaleY } = info const { scaleX, scaleY } = info
const handles = deepCopy(shape.props.handles)
objectMapEntries(shape.props.handles).forEach(([index, { x, y }]) => {
handles[index].x = x * scaleX
handles[index].y = y * scaleY
})
return { return {
props: { props: {
handles, points: shape.props.points.map(({ x, y }) => {
return {
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
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 }
return { return {
...shape, ...shape,
props: { props: {
...shape.props, ...shape.props,
handles: { points,
...shape.props.handles,
[handle.index]: { x: handle.x, y: handle.y },
},
}, },
} }
} }
@ -384,18 +391,15 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
override getHandleSnapGeometry(shape: TLLineShape) { override getHandleSnapGeometry(shape: TLLineShape) {
return { return {
points: Object.values(shape.props.handles), points: shape.props.points,
} }
} }
} }
/** @public */ /** @public */
export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Polyline2d { export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Polyline2d {
const { spline, handles } = shape.props const { spline, points } = shape.props
const handlePoints = objectMapEntries(handles) const handlePoints = points.map(Vec.From)
.map(([index, position]) => ({ index, ...position }))
.sort(sortByIndex)
.map(Vec.From)
switch (spline) { switch (spline) {
case 'cubic': { case 'cubic': {

View file

@ -11,16 +11,16 @@ exports[`Misc resizes: line shape after resize 1`] = `
"props": { "props": {
"color": "black", "color": "black",
"dash": "draw", "dash": "draw",
"handles": { "points": [
"a1": { {
"x": 0, "x": 0,
"y": 0, "y": 0,
}, },
"a2": { {
"x": 100, "x": 100,
"y": 700, "y": 700,
}, },
}, ],
"size": "m", "size": "m",
"spline": "line", "spline": "line",
}, },

View file

@ -1,5 +1,4 @@
import { import {
IndexKey,
Mat, Mat,
StateNode, StateNode,
TLEventHandlers, TLEventHandlers,
@ -7,9 +6,7 @@ import {
TLLineShape, TLLineShape,
TLShapeId, TLShapeId,
Vec, Vec,
VecModel,
createShapeId, createShapeId,
getIndexAbove,
last, last,
sortByIndex, sortByIndex,
structuredClone, structuredClone,
@ -51,39 +48,26 @@ export class Pointing extends StateNode {
new Vec(this.shape.x, this.shape.y) new Vec(this.shape.x, this.shape.y)
) )
let nextEndHandleIndex: IndexKey, nextEndHandle: VecModel const nextPoint = Vec.Sub(currentPagePoint, shapePagePoint).addXY(0.1, 0.1)
const points = structuredClone(this.shape.props.points)
const nextPoint = Vec.Sub(currentPagePoint, shapePagePoint)
if ( if (
Vec.Dist(endHandle, prevEndHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES || Vec.Dist(endHandle, prevEndHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES ||
Vec.Dist(nextPoint, endHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES Vec.Dist(nextPoint, endHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES
) { ) {
// If the end handle is too close to the previous end handle, we'll just extend the previous end handle // Don't add a new point if the distance between the last two points is too small
nextEndHandleIndex = endHandle.index points[points.length - 1] = nextPoint.toJson()
nextEndHandle = {
x: nextPoint.x + 0.1,
y: nextPoint.y + 0.1,
}
} else { } else {
// Otherwise, we'll create a new end handle // Add a new point
nextEndHandleIndex = getIndexAbove(endHandle.index) points.push(nextPoint.toJson())
nextEndHandle = {
x: nextPoint.x + 0.1,
y: nextPoint.y + 0.1,
}
} }
const nextHandles = structuredClone(this.shape.props.handles)
nextHandles[nextEndHandleIndex] = nextEndHandle
this.editor.updateShapes([ this.editor.updateShapes([
{ {
id: this.shape.id, id: this.shape.id,
type: this.shape.type, type: this.shape.type,
props: { props: {
handles: nextHandles, points,
}, },
}, },
]) ])

View file

@ -8,13 +8,14 @@ import {
TLEventHandlers, TLEventHandlers,
TLHandle, TLHandle,
TLKeyboardEvent, TLKeyboardEvent,
TLLineShape,
TLPointerEventInfo, TLPointerEventInfo,
TLShapeId, TLShapeId,
TLShapePartial, TLShapePartial,
Vec, Vec,
deepCopy,
snapAngle, snapAngle,
sortByIndex, sortByIndex,
structuredClone,
} from '@tldraw/editor' } from '@tldraw/editor'
export class DraggingHandle extends StateNode { export class DraggingHandle extends StateNode {
@ -30,7 +31,7 @@ export class DraggingHandle extends StateNode {
initialPageRotation: any initialPageRotation: any
info = {} as TLPointerEventInfo & { info = {} as TLPointerEventInfo & {
shape: TLArrowShape shape: TLArrowShape | TLLineShape
target: 'handle' target: 'handle'
onInteractionEnd?: string onInteractionEnd?: string
isCreating: boolean isCreating: boolean
@ -42,7 +43,7 @@ export class DraggingHandle extends StateNode {
override onEnter: TLEnterEventHandler = ( override onEnter: TLEnterEventHandler = (
info: TLPointerEventInfo & { info: TLPointerEventInfo & {
shape: TLArrowShape shape: TLArrowShape | TLLineShape
target: 'handle' target: 'handle'
onInteractionEnd?: string onInteractionEnd?: string
isCreating: boolean isCreating: boolean
@ -54,7 +55,30 @@ export class DraggingHandle extends StateNode {
this.shapeId = shape.id this.shapeId = shape.id
this.markId = isCreating ? `creating:${shape.id}` : 'dragging handle' this.markId = isCreating ? `creating:${shape.id}` : 'dragging handle'
if (!isCreating) this.editor.mark(this.markId) if (!isCreating) this.editor.mark(this.markId)
this.initialHandle = deepCopy(handle)
this.initialHandle = structuredClone(handle)
if (this.editor.isShapeOfType<TLLineShape>(shape, 'line')) {
// For line shapes, if we're dragging a "create" handle, then
// 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,
},
})
const handlesAfter = this.editor.getShapeHandles(shape)!
const handleAfter = handlesAfter.find((h) => h.x === handle.x && h.y === handle.y)!
this.initialHandle = structuredClone(handleAfter)
}
}
this.initialPageTransform = this.editor.getShapePageTransform(shape)! this.initialPageTransform = this.editor.getShapePageTransform(shape)!
this.initialPageRotation = this.initialPageTransform.rotation() this.initialPageRotation = this.initialPageTransform.rotation()
this.initialPagePoint = this.editor.inputs.originPagePoint.clone() this.initialPagePoint = this.editor.inputs.originPagePoint.clone()
@ -64,7 +88,6 @@ export class DraggingHandle extends StateNode {
{ ephemeral: true } { ephemeral: true }
) )
// <!-- Only relevant to arrows
const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex) const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex)
const index = handles.findIndex((h) => h.id === info.handle.id) const index = handles.findIndex((h) => h.id === info.handle.id)
@ -91,21 +114,24 @@ export class DraggingHandle extends StateNode {
} }
} }
const initialTerminal = shape.props[info.handle.id as 'start' | 'end'] // <!-- Only relevant to arrows
if (this.editor.isShapeOfType<TLArrowShape>(shape, 'arrow')) {
const initialTerminal = shape.props[info.handle.id as 'start' | 'end']
this.isPrecise = false this.isPrecise = false
if (initialTerminal?.type === 'binding') { if (initialTerminal?.type === 'binding') {
this.editor.setHintingShapes([initialTerminal.boundShapeId]) this.editor.setHintingShapes([initialTerminal.boundShapeId])
this.isPrecise = initialTerminal.isPrecise this.isPrecise = initialTerminal.isPrecise
if (this.isPrecise) { if (this.isPrecise) {
this.isPreciseId = initialTerminal.boundShapeId this.isPreciseId = initialTerminal.boundShapeId
} else {
this.resetExactTimeout()
}
} else { } else {
this.resetExactTimeout() this.editor.setHintingShapes([])
} }
} else {
this.editor.setHintingShapes([])
} }
// --> // -->
@ -216,6 +242,7 @@ export class DraggingHandle extends StateNode {
} = editor } = editor
const initial = this.info.shape const initial = this.info.shape
const shape = editor.getShape(shapeId) const shape = editor.getShape(shapeId)
if (!shape) return if (!shape) return
const util = editor.getShapeUtil(shape) const util = editor.getShapeUtil(shape)

View file

@ -8,10 +8,13 @@ export class PointingHandle extends StateNode {
override onEnter = (info: TLPointerEventInfo & { target: 'handle' }) => { override onEnter = (info: TLPointerEventInfo & { target: 'handle' }) => {
this.info = info this.info = info
const initialTerminal = (info.shape as TLArrowShape).props[info.handle.id as 'start' | 'end'] const { shape } = info
if (this.editor.isShapeOfType<TLArrowShape>(shape, 'arrow')) {
const initialTerminal = shape.props[info.handle.id as 'start' | 'end']
if (initialTerminal?.type === 'binding') { if (initialTerminal?.type === 'binding') {
this.editor.setHintingShapes([initialTerminal.boundShapeId]) this.editor.setHintingShapes([initialTerminal.boundShapeId])
}
} }
this.editor.updateInstanceState( this.editor.updateInstanceState(

View file

@ -207,7 +207,9 @@ export class TestEditor extends Editor {
}).toCloselyMatchObject({ x, y, z }) }).toCloselyMatchObject({ x, y, z })
} }
expectShapeToMatch = (...model: RequiredKeys<TLShapePartial, 'id'>[]) => { expectShapeToMatch = <T extends TLShape = TLShape>(
...model: RequiredKeys<TLShapePartial<T>, 'id'>[]
) => {
model.forEach((model) => { model.forEach((model) => {
const shape = this.getShape(model.id)! const shape = this.getShape(model.id)!
const next = { ...shape, ...model } const next = { ...shape, ...model }

View file

@ -1,6 +1,5 @@
import { import {
BaseBoxShapeUtil, BaseBoxShapeUtil,
IndexKey,
Polyline2d, Polyline2d,
TLAnyShapeUtilConstructor, TLAnyShapeUtilConstructor,
TLBaseShape, TLBaseShape,
@ -204,10 +203,10 @@ describe('custom handle snapping', () => {
ref="line" ref="line"
x={0} x={0}
y={0} y={0}
handles={{ points={[
['a1' as IndexKey]: { x: 0, y: 0 }, { x: 0, y: 0 },
['a2' as IndexKey]: { x: 100, y: 100 }, { 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} />,
]) ])

View file

@ -674,7 +674,7 @@ 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">;
handles: T.DictValidator<IndexKey, VecModel>; points: T.ArrayOfValidator<VecModel>;
}; };
// @public (undocumented) // @public (undocumented)

View file

@ -2756,25 +2756,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<\"cubic\" | \"line\">;\n handles: " "text": "<\"cubic\" | \"line\">;\n points: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "T.DictValidator", "text": "T.ArrayOfValidator",
"canonicalReference": "@tldraw/validate!DictValidator:class" "canonicalReference": "@tldraw/validate!ArrayOfValidator:class"
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<import(\"@tldraw/utils\")." "text": "<import(\"../misc/geometry-types\")."
},
{
"kind": "Reference",
"text": "IndexKey",
"canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ", import(\"../misc/geometry-types\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -2792,7 +2783,7 @@
"name": "lineShapeProps", "name": "lineShapeProps",
"variableTypeTokenRange": { "variableTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 16 "endIndex": 14
} }
}, },
{ {

View file

@ -1937,6 +1937,52 @@ describe('Remove extra handle props', () => {
}) })
}) })
describe('Restore some handle props', () => {
const { up, down } = lineShapeMigrations.migrators[lineShapeVersions.HandlesToPoints]
it('up works as expected', () => {
expect(
up({
props: {
handles: {
a1: { x: 0, y: 0 },
a1V: { x: 76, y: 60 },
a2: { x: 190, y: -62 },
},
},
})
).toEqual({
props: {
points: [
{ x: 0, y: 0 },
{ x: 76, y: 60 },
{ x: 190, y: -62 },
],
},
})
})
it('down works as expected', () => {
expect(
down({
props: {
points: [
{ x: 0, y: 0 },
{ x: 76, y: 60 },
{ x: 190, y: -62 },
],
},
})
).toEqual({
props: {
handles: {
a1: { x: 0, y: 0 },
a2: { x: 76, y: 60 },
a3: { 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) {

View file

@ -1,5 +1,5 @@
import { defineMigrations } from '@tldraw/store' import { defineMigrations } from '@tldraw/store'
import { deepCopy, 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 { vecModelValidator } from '../misc/geometry-types'
import { StyleProp } from '../styles/StyleProp' import { StyleProp } from '../styles/StyleProp'
@ -23,7 +23,7 @@ export const lineShapeProps = {
dash: DefaultDashStyle, dash: DefaultDashStyle,
size: DefaultSizeStyle, size: DefaultSizeStyle,
spline: LineShapeSplineStyle, spline: LineShapeSplineStyle,
handles: T.dict(T.indexKey, vecModelValidator), points: T.arrayOf(vecModelValidator),
} }
/** @public */ /** @public */
@ -36,11 +36,12 @@ export type TLLineShape = TLBaseShape<'line', TLLineShapeProps>
export const lineShapeVersions = { export const lineShapeVersions = {
AddSnapHandles: 1, AddSnapHandles: 1,
RemoveExtraHandleProps: 2, RemoveExtraHandleProps: 2,
HandlesToPoints: 3,
} as const } as const
/** @internal */ /** @internal */
export const lineShapeMigrations = defineMigrations({ export const lineShapeMigrations = defineMigrations({
currentVersion: lineShapeVersions.RemoveExtraHandleProps, currentVersion: lineShapeVersions.HandlesToPoints,
migrators: { migrators: {
[lineShapeVersions.AddSnapHandles]: { [lineShapeVersions.AddSnapHandles]: {
up: (record: any) => { up: (record: any) => {
@ -107,5 +108,45 @@ export const lineShapeMigrations = defineMigrations({
} }
}, },
}, },
[lineShapeVersions.HandlesToPoints]: {
up: (record: any) => {
const { handles, ...props } = record.props
const sortedHandles = (Object.entries(handles) as [IndexKey, { x: number; y: number }][])
.map(([index, { x, y }]) => ({ x, y, index }))
.sort(sortByIndex)
return {
...record,
props: {
...props,
points: sortedHandles.map(({ x, y }) => ({ x, y })),
},
}
},
down: (record: any) => {
const { points, ...props } = record.props
const indices = getIndices(points.length)
return {
...record,
props: {
...props,
handles: Object.fromEntries(
points.map((handle: { x: number; y: number }, i: number) => {
const index = indices[i]
return [
index,
{
x: handle.x,
y: handle.y,
},
]
})
),
},
}
},
},
}, },
}) })