[Snapping 2/5] Fix line-handle mid-point snapping (#2831)

Currently, only the end handles of the line tool snap. It should be all
of them.

Line handles work kind of weirdly at the moment: instead of just storing
the positions, we store full `TLHandle` objects complete with IDs,
`canSnap`/`canBind` properties, etc. Currently, all the handles get
written to the store with `canSnap: false`, when really it should be up
to the shape util to decide which handles are snappable.

This diff replaces the current handles map (from arbitrary ID to
`TLHandle`) with just the data we need: a map from index to x/y. The
extra information that the `Editor` needs for `TLHandle` is hydrated at
runtime (with `canSnap` set to `true` this time!)

Fixes TLD-2200

This PR is part of a series - please don't merge it until the things
before it have landed!
1. #2827 
2. #2831 (you are here)
3. #2793
4. #2841
5. #2845

### Change Type

- [x] `major` — Breaking change


[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version

### Test Plan

1. Create a funky line shape on tldraw.com
2. Paste it into staging and make sure it comes across ok
3. Make some funky line shape in staging - make sure you use dragging,
mid-point creation, and shift-clicking

- [x] Unit Tests

### Release Notes

- Simplify the contents of `TLLineShape.props.handles`
This commit is contained in:
alex 2024-02-15 10:27:55 +00:00 committed by GitHub
parent 93c2ed615c
commit 4bfea7649d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 317 additions and 147 deletions

View file

@ -38,7 +38,6 @@ export class HandleSnaps {
let minDistance = snapThreshold let minDistance = snapThreshold
let nearestPoint: Vec | null = null let nearestPoint: Vec | null = null
let C: VecModel, D: VecModel, nearest: Vec, distance: number let C: VecModel, D: VecModel, nearest: Vec, distance: number
const allSegments = [...outlinesInPageSpace, ...additionalSegments] const allSegments = [...outlinesInPageSpace, ...additionalSegments]
for (const outline of allSegments) { for (const outline of allSegments) {
for (let i = 0; i < outline.length - 1; i++) { for (let i = 0; i < outline.length - 1; i++) {

View file

@ -921,7 +921,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<string, TLHandle>; handles: DictValidator<IndexKey, VecModel>;
}; };
// (undocumented) // (undocumented)
toSvg(shape: TLLineShape): SVGGElement; toSvg(shape: TLLineShape): SVGGElement;

View file

@ -10994,12 +10994,21 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<string, " "text": "<import(\"@tldraw/editor\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "TLHandle", "text": "IndexKey",
"canonicalReference": "@tldraw/tlschema!TLHandle:interface" "canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ", import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "VecModel",
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -11016,7 +11025,7 @@
"name": "props", "name": "props",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 14 "endIndex": 16
}, },
"isStatic": true, "isStatic": true,
"isProtected": false, "isProtected": false,

View file

@ -66,8 +66,8 @@ describe('When dragging the line', () => {
y: 0, y: 0,
props: { props: {
handles: { handles: {
start: { id: 'start', index: 'a1', type: 'vertex', x: 0, y: 0 }, a1: { x: 0, y: 0 },
end: { id: 'end', index: 'a2', type: 'vertex', x: 10, y: 10 }, a2: { x: 10, y: 10 },
}, },
}, },
}) })

View file

@ -1,5 +1,7 @@
import { IndexKey, TLGeoShape, TLLineShape, createShapeId, deepCopy } from '@tldraw/editor' import { IndexKey, TLGeoShape, TLLineShape, createShapeId, deepCopy } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor' import { TestEditor } from '../../../test/TestEditor'
import { TL } from '../../../test/test-jsx'
import { LineShapeUtil } from './LineShapeUtil'
jest.mock('nanoid', () => { jest.mock('nanoid', () => {
let i = 0 let i = 0
@ -24,19 +26,11 @@ beforeEach(() => {
y: 150, y: 150,
props: { props: {
handles: { handles: {
start: { a1: {
id: 'start',
type: 'vertex',
canBind: false,
index: 'a1',
x: 0, x: 0,
y: 0, y: 0,
}, },
end: { a2: {
id: 'end',
type: 'vertex',
canBind: false,
index: 'a2',
x: 100, x: 100,
y: 100, y: 100,
}, },
@ -75,39 +69,103 @@ describe('Translating', () => {
}) })
}) })
it('create new handle', () => { describe('Mid-point handles', () => {
editor.select(id) it('create new handle', () => {
editor.select(id)
const shape = editor.getShape<TLLineShape>(id)! const shape = editor.getShape<TLLineShape>(id)!
editor.pointerDown(200, 200, { editor.pointerDown(200, 200, {
target: 'handle', target: 'handle',
shape, shape,
handle: { handle: {
id: 'mid-0', id: 'mid-0',
type: 'create', type: 'create',
index: 'a1V' as IndexKey, index: 'a1V' as IndexKey,
x: 50, x: 50,
y: 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({
id: id, id: id,
props: { props: {
handles: { handles: {
...shape.props.handles, ...shape.props.handles,
'handle:a1V': { a1V: { x: 200, y: 200 },
id: 'handle:a1V',
type: 'vertex',
canBind: false,
index: 'a1V',
x: 200,
y: 200,
}, },
}, },
}, })
})
it('allows snapping with mid-point handles', () => {
editor.createShapesFromJsx([<TL.geo x={200} y={200} w={100} h={100} />])
editor.select(id)
const shape = editor.getShape<TLLineShape>(id)!
const util = editor.getShapeUtil('line') as LineShapeUtil
editor
.pointerDown(200, 200, {
target: 'handle',
shape,
handle: util.getHandles(shape).find((h) => h.id === 'mid-0')!,
})
.pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
handles: {
...shape.props.handles,
a1V: { x: 50, y: 80 },
},
},
})
})
it('allows snapping with created mid-point handles', () => {
editor.createShapesFromJsx([<TL.geo x={200} y={200} w={100} h={100} />])
editor.select(id)
const getShape = () => editor.getShape<TLLineShape>(id)!
const util = editor.getShapeUtil('line') as LineShapeUtil
// use a mid-point handle to create a new handle
editor
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: util.getHandles(getShape()).find((h) => h.id === 'mid-0')!,
})
.pointerMove(230, 200)
.pointerMove(200, 200)
.pointerUp()
// 3 actual points, plus 2 mid-points:
expect(util.getHandles(getShape())).toHaveLength(5)
// now, try dragging the newly created handle. it should still snap:
editor
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: util.getHandles(getShape()).find((h) => h.id === 'a1V')!,
})
.pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
handles: {
...getShape().props.handles,
a1V: { x: 50, y: 80 },
},
},
})
}) })
}) })

View file

@ -1,7 +1,6 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { import {
CubicSpline2d, CubicSpline2d,
IndexKey,
Polyline2d, Polyline2d,
SVGContainer, SVGContainer,
ShapeUtil, ShapeUtil,
@ -14,8 +13,10 @@ import {
deepCopy, deepCopy,
getDefaultColorTheme, getDefaultColorTheme,
getIndexBetween, getIndexBetween,
getIndices,
lineShapeMigrations, lineShapeMigrations,
lineShapeProps, lineShapeProps,
objectMapEntries,
sortByIndex, sortByIndex,
} from '@tldraw/editor' } from '@tldraw/editor'
@ -45,27 +46,18 @@ 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: { handles: {
start: { [startIndex]: {
id: 'start',
type: 'vertex',
canBind: false,
canSnap: true,
index: 'a1' as IndexKey,
x: 0, x: 0,
y: 0, y: 0,
}, },
end: { [endIndex]: {
id: 'end',
type: 'vertex',
canBind: false,
canSnap: true,
index: 'a2' as IndexKey,
x: 0.1, x: 0.1,
y: 0.1, y: 0.1,
}, },
@ -84,7 +76,18 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
const spline = getGeometryForLineShape(shape) const spline = getGeometryForLineShape(shape)
const sortedHandles = Object.values(handles).sort(sortByIndex) const sortedHandles = objectMapEntries(handles)
.map(
([index, handle]): TLHandle => ({
id: index,
index,
...handle,
type: 'vertex',
canBind: false,
canSnap: true,
})
)
.sort(sortByIndex)
const results = sortedHandles.slice() const results = sortedHandles.slice()
// Add "create" handles between each vertex handle // Add "create" handles between each vertex handle
@ -99,6 +102,8 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
index, index,
x: point.x, x: point.x,
y: point.y, y: point.y,
canSnap: true,
canBind: false,
}) })
} }
@ -118,9 +123,9 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
const handles = deepCopy(shape.props.handles) const handles = deepCopy(shape.props.handles)
Object.values(shape.props.handles).forEach(({ id, x, y }) => { objectMapEntries(shape.props.handles).forEach(([index, { x, y }]) => {
handles[id].x = x * scaleX handles[index].x = x * scaleX
handles[id].y = y * scaleY handles[index].y = y * scaleY
}) })
return { return {
@ -131,45 +136,16 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
} }
override onHandleDrag: TLOnHandleDragHandler<TLLineShape> = (shape, { handle }) => { override onHandleDrag: TLOnHandleDragHandler<TLLineShape> = (shape, { handle }) => {
const next = deepCopy(shape) return {
...shape,
switch (handle.id) { props: {
case 'start': ...shape.props,
case 'end': { handles: {
next.props.handles[handle.id] = { ...shape.props.handles,
...next.props.handles[handle.id], [handle.index]: { x: handle.x, y: handle.y },
x: handle.x, },
y: handle.y, },
}
break
}
default: {
const id = 'handle:' + handle.index
const existing = shape.props.handles[id]
if (existing) {
next.props.handles[id] = {
...existing,
x: handle.x,
y: handle.y,
}
} else {
next.props.handles[id] = {
id,
type: 'vertex',
canBind: false,
index: handle.index,
x: handle.x,
y: handle.y,
}
}
break
}
} }
return next
} }
component(shape: TLLineShape) { component(shape: TLLineShape) {
@ -409,7 +385,10 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
/** @public */ /** @public */
export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Polyline2d { export function getGeometryForLineShape(shape: TLLineShape): CubicSpline2d | Polyline2d {
const { spline, handles } = shape.props const { spline, handles } = shape.props
const handlePoints = Object.values(handles).sort(sortByIndex).map(Vec.From) const handlePoints = objectMapEntries(handles)
.map(([index, position]) => ({ index, ...position }))
.sort(sortByIndex)
.map(Vec.From)
switch (spline) { switch (spline) {
case 'cubic': { case 'cubic': {

View file

@ -12,22 +12,14 @@ exports[`Misc resizes: line shape after resize 1`] = `
"color": "black", "color": "black",
"dash": "draw", "dash": "draw",
"handles": { "handles": {
"end": { "a1": {
"canBind": false,
"id": "end",
"index": "a2",
"type": "vertex",
"x": 100,
"y": 700,
},
"start": {
"canBind": false,
"id": "start",
"index": "a1",
"type": "vertex",
"x": 0, "x": 0,
"y": 0, "y": 0,
}, },
"a2": {
"x": 100,
"y": 700,
},
}, },
"size": "m", "size": "m",
"spline": "line", "spline": "line",

View file

@ -3,11 +3,11 @@ import {
Mat, Mat,
StateNode, StateNode,
TLEventHandlers, TLEventHandlers,
TLHandle,
TLInterruptEvent, TLInterruptEvent,
TLLineShape, TLLineShape,
TLShapeId, TLShapeId,
Vec, Vec,
VecModel,
createShapeId, createShapeId,
getIndexAbove, getIndexAbove,
last, last,
@ -51,7 +51,7 @@ export class Pointing extends StateNode {
new Vec(this.shape.x, this.shape.y) new Vec(this.shape.x, this.shape.y)
) )
let nextEndHandleIndex: IndexKey, nextEndHandleId: string, nextEndHandle: TLHandle let nextEndHandleIndex: IndexKey, nextEndHandle: VecModel
const nextPoint = Vec.Sub(currentPagePoint, shapePagePoint) const nextPoint = Vec.Sub(currentPagePoint, shapePagePoint)
@ -61,29 +61,22 @@ export class Pointing extends StateNode {
) { ) {
// If the end handle is too close to the previous end handle, we'll just extend the previous end handle // If the end handle is too close to the previous end handle, we'll just extend the previous end handle
nextEndHandleIndex = endHandle.index nextEndHandleIndex = endHandle.index
nextEndHandleId = endHandle.id
nextEndHandle = { nextEndHandle = {
...endHandle,
x: nextPoint.x + 0.1, x: nextPoint.x + 0.1,
y: nextPoint.y + 0.1, y: nextPoint.y + 0.1,
} }
} else { } else {
// Otherwise, we'll create a new end handle // Otherwise, we'll create a new end handle
nextEndHandleIndex = getIndexAbove(endHandle.index) nextEndHandleIndex = getIndexAbove(endHandle.index)
nextEndHandleId = 'handle:' + nextEndHandleIndex
nextEndHandle = { nextEndHandle = {
id: nextEndHandleId,
type: 'vertex',
index: nextEndHandleIndex,
x: nextPoint.x + 0.1, x: nextPoint.x + 0.1,
y: nextPoint.y + 0.1, y: nextPoint.y + 0.1,
canBind: false,
} }
} }
const nextHandles = structuredClone(this.shape.props.handles) const nextHandles = structuredClone(this.shape.props.handles)
nextHandles[nextEndHandle.id] = nextEndHandle nextHandles[nextEndHandleIndex] = nextEndHandle
this.editor.updateShapes([ this.editor.updateShapes([
{ {

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<string, TLHandle>; handles: T.DictValidator<IndexKey, VecModel>;
}; };
// @public (undocumented) // @public (undocumented)

View file

@ -2765,12 +2765,21 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<string, import(\"../misc/TLHandle\")." "text": "<import(\"@tldraw/utils\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "TLHandle", "text": "IndexKey",
"canonicalReference": "@tldraw/tlschema!TLHandle:interface" "canonicalReference": "@tldraw/utils!IndexKey:type"
},
{
"kind": "Content",
"text": ", import(\"../misc/geometry-types\")."
},
{
"kind": "Reference",
"text": "VecModel",
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -2783,7 +2792,7 @@
"name": "lineShapeProps", "name": "lineShapeProps",
"variableTypeTokenRange": { "variableTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 14 "endIndex": 16
} }
}, },
{ {

View file

@ -1844,6 +1844,99 @@ describe('Add duplicate props to instance', () => {
}) })
}) })
describe('Remove extra handle props', () => {
const { up, down } = lineShapeMigrations.migrators[lineShapeVersions.RemoveExtraHandleProps]
it('up works as expected', () => {
expect(
up({
props: {
handles: {
start: {
id: 'start',
type: 'vertex',
canBind: false,
canSnap: true,
index: 'a1',
x: 0,
y: 0,
},
end: {
id: 'end',
type: 'vertex',
canBind: false,
canSnap: true,
index: 'a2',
x: 190,
y: -62,
},
'handle:a1V': {
id: 'handle:a1V',
type: 'vertex',
canBind: false,
index: 'a1V',
x: 76,
y: 60,
},
},
},
})
).toEqual({
props: {
handles: {
a1: { x: 0, y: 0 },
a1V: { x: 76, y: 60 },
a2: { x: 190, y: -62 },
},
},
})
})
it('down works as expected', () => {
expect(
down({
props: {
handles: {
a1: { x: 0, y: 0 },
a1V: { x: 76, y: 60 },
a2: { x: 190, y: -62 },
},
},
})
).toEqual({
props: {
handles: {
start: {
id: 'start',
type: 'vertex',
canBind: false,
canSnap: true,
index: 'a1',
x: 0,
y: 0,
},
end: {
id: 'end',
type: 'vertex',
canBind: false,
canSnap: true,
index: 'a2',
x: 190,
y: -62,
},
'handle:a1V': {
id: 'handle:a1V',
type: 'vertex',
canBind: false,
canSnap: true,
index: 'a1V',
x: 76,
y: 60,
},
},
},
})
})
})
/* --- 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,4 @@
import { IndexKey } from '@tldraw/utils' import { IndexKey } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { SetValue } from '../util-types' import { SetValue } from '../util-types'
/** /**
@ -29,14 +28,3 @@ export interface TLHandle {
x: number x: number
y: number y: number
} }
/** @internal */
export const handleValidator: T.Validator<TLHandle> = T.object({
id: T.string,
type: T.setEnum(TL_HANDLE_TYPES),
canBind: T.boolean.optional(),
canSnap: T.boolean.optional(),
index: T.indexKey,
x: T.number,
y: T.number,
})

View file

@ -1,7 +1,7 @@
import { defineMigrations } from '@tldraw/store' import { defineMigrations } from '@tldraw/store'
import { deepCopy } from '@tldraw/utils' import { deepCopy, objectMapFromEntries, sortByIndex } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { handleValidator } from '../misc/TLHandle' 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'
@ -23,7 +23,7 @@ export const lineShapeProps = {
dash: DefaultDashStyle, dash: DefaultDashStyle,
size: DefaultSizeStyle, size: DefaultSizeStyle,
spline: LineShapeSplineStyle, spline: LineShapeSplineStyle,
handles: T.dict(T.string, handleValidator), handles: T.dict(T.indexKey, vecModelValidator),
} }
/** @public */ /** @public */
@ -35,11 +35,12 @@ export type TLLineShape = TLBaseShape<'line', TLLineShapeProps>
/** @internal */ /** @internal */
export const lineShapeVersions = { export const lineShapeVersions = {
AddSnapHandles: 1, AddSnapHandles: 1,
RemoveExtraHandleProps: 2,
} as const } as const
/** @internal */ /** @internal */
export const lineShapeMigrations = defineMigrations({ export const lineShapeMigrations = defineMigrations({
currentVersion: lineShapeVersions.AddSnapHandles, currentVersion: lineShapeVersions.RemoveExtraHandleProps,
migrators: { migrators: {
[lineShapeVersions.AddSnapHandles]: { [lineShapeVersions.AddSnapHandles]: {
up: (record: any) => { up: (record: any) => {
@ -57,5 +58,54 @@ export const lineShapeMigrations = defineMigrations({
return { ...record, props: { ...record.props, handles } } return { ...record, props: { ...record.props, handles } }
}, },
}, },
[lineShapeVersions.RemoveExtraHandleProps]: {
up: (record: any) => {
return {
...record,
props: {
...record.props,
handles: objectMapFromEntries(
Object.values(record.props.handles).map((handle: any) => [
handle.index,
{
x: handle.x,
y: handle.y,
},
])
),
},
}
},
down: (record: any) => {
const handles = Object.entries(record.props.handles)
.map(([index, handle]: any) => ({ index, ...handle }))
.sort(sortByIndex)
return {
...record,
props: {
...record.props,
handles: Object.fromEntries(
handles.map((handle, i) => {
const id =
i === 0 ? 'start' : i === handles.length - 1 ? 'end' : `handle:${handle.index}`
return [
id,
{
id,
type: 'vertex',
canBind: false,
canSnap: true,
index: handle.index,
x: handle.x,
y: handle.y,
},
]
})
),
},
}
},
},
}, },
}) })