[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 { 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';
@ -893,7 +892,7 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">;
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
spline: EnumStyleProp<"cubic" | "line">;
handles: DictValidator<IndexKey, VecModel>;
points: ArrayOfValidator<VecModel>;
};
// (undocumented)
toSvg(shape: TLLineShape, ctx: SvgExportContext): SVGGElement;

View file

@ -10695,26 +10695,17 @@
},
{
"kind": "Content",
"text": "<\"cubic\" | \"line\">;\n handles: import(\"@tldraw/editor\")."
"text": "<\"cubic\" | \"line\">;\n points: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "DictValidator",
"canonicalReference": "@tldraw/validate!DictValidator:class"
"text": "ArrayOfValidator",
"canonicalReference": "@tldraw/validate!ArrayOfValidator:class"
},
{
"kind": "Content",
"text": "<import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "IndexKey",
"canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ", import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "VecModel",
@ -10735,7 +10726,7 @@
"name": "props",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 16
"endIndex": 14
},
"isStatic": true,
"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 { TL } from '../../../test/test-jsx'
@ -17,7 +17,10 @@ describe('Handle snapping', () => {
ref="line"
x={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,
y: 0,
props: {
handles: {
a1: { x: 0, y: 0 },
a2: { x: 10, y: 10 },
},
points: [
{ x: 0, y: 0 },
{ x: 10, y: 10 },
],
},
})
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]
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3)
expect(line.props.points.length).toBe(3)
})
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]
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles)
expect(handles.length).toBe(2)
expect(line.props.points.length).toBe(2)
})
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]
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3)
expect(line.props.points.length).toBe(3)
})
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]
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3)
expect(line.props.points.length).toBe(3)
})
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]
assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3)
expect(line.props.points.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 { TL } from '../../../test/test-jsx'
import { LineShapeUtil } from './LineShapeUtil'
jest.mock('nanoid', () => {
let i = 0
@ -18,37 +17,31 @@ beforeEach(() => {
editor
.selectAll()
.deleteShapes(editor.getSelectedShapeIds())
.createShapes([
.createShapes<TLLineShape>([
{
id: id,
type: 'line',
x: 150,
y: 150,
props: {
handles: {
a1: {
x: 0,
y: 0,
},
a2: {
x: 100,
y: 100,
},
},
points: [
{ x: 0, y: 0 },
{ x: 100, y: 100 },
],
},
},
])
})
const getShape = () => editor.getShape<TLLineShape>(id)!
const getHandles = () => (editor.getShapeUtil('line') as LineShapeUtil).getHandles(getShape())
const getHandles = () => editor.getShapeHandles<TLLineShape>(id)!
describe('Translating', () => {
it('updates the line', () => {
editor.select(id)
editor.pointerDown(25, 25, { target: 'shape', shape: getShape() })
editor.pointerMove(50, 50) // Move shape by 25, 25
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 175,
y: 175,
@ -64,7 +57,7 @@ describe('Translating', () => {
editor.pointerDown(250, 250, { target: 'shape', shape: shape })
editor.pointerMove(300, 400) // Move shape by 50, 150
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 200,
y: 300,
@ -79,23 +72,19 @@ describe('Mid-point handles', () => {
editor.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: {
id: 'mid-0',
type: 'create',
index: 'a1V' as IndexKey,
x: 50,
y: 50,
},
handle: getHandles()[1],
})
editor.pointerMove(349, 349).pointerMove(350, 350) // Move handle by 150, 150
editor.pointerUp()
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id: id,
props: {
handles: {
a1V: { x: 200, y: 200 },
},
points: [
{ x: 0, y: 0 },
{ x: 200, y: 200 },
{ x: 100, y: 100 },
],
},
})
})
@ -109,33 +98,35 @@ describe('Mid-point handles', () => {
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: getHandles().find((h) => h.id === 'mid-0')!,
handle: getHandles()[1],
})
.pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
handles: {
a1V: { x: 50, y: 80 },
},
},
})
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 })
})
it('allows snapping with created mid-point handles', () => {
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
editor
.select(id)
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: getHandles().find((h) => h.id === 'mid-0')!,
handle: getHandles().sort(sortByIndex)[1]!,
})
.pointerMove(230, 200)
.pointerMove(240, 200)
.pointerMove(200, 200)
.pointerUp()
@ -147,19 +138,17 @@ describe('Mid-point handles', () => {
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: getHandles().find((h) => h.id === 'a1V')!,
handle: getHandles().sort(sortByIndex)[2],
})
.pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
handles: {
a1V: { x: 50, y: 80 },
},
},
})
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 })
})
})
@ -169,12 +158,12 @@ describe('Snapping', () => {
id: id,
type: 'line',
props: {
handles: {
a1: { x: 0, y: 0 },
a2: { x: 100, y: 0 },
a3: { x: 100, y: 100 },
a4: { x: 0, y: 100 },
},
points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
},
})
})
@ -187,12 +176,15 @@ describe('Snapping', () => {
.pointerMove(50, 95, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id: id,
props: {
handles: {
a1: { x: 50, y: 100 },
},
points: [
{ 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 })
expect(editor.snaps.getIndicators()).toHaveLength(0)
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id: id,
props: {
handles: {
a1: { x: 5, y: 2 },
},
points: [
{ x: 5, y: 2 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
},
})
})
@ -220,20 +215,31 @@ describe('Snapping', () => {
<TL.line
x={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)
const handle = getHandles()[0]
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 })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
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', () => {
editor.select(id)
const shape = getShape()
const prevHandles = deepCopy(shape.props.handles)
const prevPoints = deepCopy(shape.props.points)
editor.updateShapes([
{
@ -253,11 +259,11 @@ describe('Misc', () => {
},
])
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id,
props: {
spline: 'cubic',
handles: prevHandles,
points: prevPoints,
},
})
})
@ -277,7 +283,7 @@ describe('Misc', () => {
editor.select(id)
editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 1, y: 0 })
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 151,
y: 150,
@ -285,7 +291,7 @@ describe('Misc', () => {
editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 0, y: 10 })
editor.expectShapeToMatch({
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 151,
y: 160,

View file

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

View file

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

View file

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

View file

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

View file

@ -8,11 +8,14 @@ export class PointingHandle extends StateNode {
override onEnter = (info: TLPointerEventInfo & { target: 'handle' }) => {
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') {
this.editor.setHintingShapes([initialTerminal.boundShapeId])
}
}
this.editor.updateInstanceState(
{ cursor: { type: 'grabbing', rotation: 0 } },

View file

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

View file

@ -1,6 +1,5 @@
import {
BaseBoxShapeUtil,
IndexKey,
Polyline2d,
TLAnyShapeUtilConstructor,
TLBaseShape,
@ -204,10 +203,10 @@ describe('custom handle snapping', () => {
ref="line"
x={0}
y={0}
handles={{
['a1' as IndexKey]: { x: 0, y: 0 },
['a2' as IndexKey]: { x: 100, y: 100 },
}}
points={[
{ x: 0, y: 0 },
{ x: 100, y: 100 },
]}
/>,
<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">;
size: EnumStyleProp<"l" | "m" | "s" | "xl">;
spline: EnumStyleProp<"cubic" | "line">;
handles: T.DictValidator<IndexKey, VecModel>;
points: T.ArrayOfValidator<VecModel>;
};
// @public (undocumented)

View file

@ -2756,25 +2756,16 @@
},
{
"kind": "Content",
"text": "<\"cubic\" | \"line\">;\n handles: "
"text": "<\"cubic\" | \"line\">;\n points: "
},
{
"kind": "Reference",
"text": "T.DictValidator",
"canonicalReference": "@tldraw/validate!DictValidator:class"
"text": "T.ArrayOfValidator",
"canonicalReference": "@tldraw/validate!ArrayOfValidator:class"
},
{
"kind": "Content",
"text": "<import(\"@tldraw/utils\")."
},
{
"kind": "Reference",
"text": "IndexKey",
"canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ", import(\"../misc/geometry-types\")."
"text": "<import(\"../misc/geometry-types\")."
},
{
"kind": "Reference",
@ -2792,7 +2783,7 @@
"name": "lineShapeProps",
"variableTypeTokenRange": {
"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 --- */
for (const migrator of allMigrators) {

View file

@ -1,5 +1,5 @@
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 { vecModelValidator } from '../misc/geometry-types'
import { StyleProp } from '../styles/StyleProp'
@ -23,7 +23,7 @@ export const lineShapeProps = {
dash: DefaultDashStyle,
size: DefaultSizeStyle,
spline: LineShapeSplineStyle,
handles: T.dict(T.indexKey, vecModelValidator),
points: T.arrayOf(vecModelValidator),
}
/** @public */
@ -36,11 +36,12 @@ export type TLLineShape = TLBaseShape<'line', TLLineShapeProps>
export const lineShapeVersions = {
AddSnapHandles: 1,
RemoveExtraHandleProps: 2,
HandlesToPoints: 3,
} as const
/** @internal */
export const lineShapeMigrations = defineMigrations({
currentVersion: lineShapeVersions.RemoveExtraHandleProps,
currentVersion: lineShapeVersions.HandlesToPoints,
migrators: {
[lineShapeVersions.AddSnapHandles]: {
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,
},
]
})
),
},
}
},
},
},
})