import { BaseBoxShapeUtil, createShapeId, IndexKey, Polyline2d, ShapeUtil, TLAnyShapeUtilConstructor, TLHandle, TLHandleDragInfo, TLLineShape, TLShape, TLShapeId, Vec, VecModel, ZERO_INDEX_KEY, } from '@tldraw/editor' import { TestEditor } from './TestEditor' const TEST1_TYPE = 'test1' declare module '@tldraw/tlschema' { export interface TLGlobalShapePropsMap { [TEST1_TYPE]: { w: number; h: number; boundsSnapPoints: VecModel[] | null } } } type Test1Shape = TLShape describe('custom shape bounds snapping - translate', () => { class TestShapeUtil extends BaseBoxShapeUtil { static override type = TEST1_TYPE override getDefaultProps() { return { w: 100, h: 100, boundsSnapPoints: null } } override component() { throw new Error('Method not implemented.') } override getIndicatorPath(): undefined { throw new Error('Method not implemented.') } override getBoundsSnapGeometry(shape: Test1Shape) { return { points: shape.props.boundsSnapPoints ?? undefined, } } } const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[] let editor: TestEditor let ids: Record beforeEach(() => { editor = new TestEditor({ shapeUtils }) ids = { box: createShapeId('box'), test1: createShapeId('test1'), } editor.createShapes([ { id: ids.box, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }, { id: ids.test1, type: TEST1_TYPE, x: 200, y: 200, props: { w: 100, h: 100, boundsSnapPoints: null }, }, ]) }) describe('with default boundSnapPoints', () => { test('normal snapping works with default boundSnapPoints when moving test shape', () => { // start translating the test shape editor.setSelectedShapes([ids.test1]).pointerDown(250, 250) // move the left edge of the test shape to the right edge of the box shape - it should snap editor.pointerMove(155, 250, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.getOnlySelectedShape()?.x).toBe(100) // move the left edge of the test shape to the center of the box shape - it should snap editor.pointerMove(105, 250, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(2) expect(editor.getOnlySelectedShape()?.x).toBe(50) }) test('normal snapping works with default boundSnapPoints when snapping to test shape', () => { // start translating the box shape editor.setSelectedShapes([ids.box]).pointerDown(50, 50) // move the left edge of the box shape to the right edge of the test shape - it should snap editor.pointerMove(155, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.getOnlySelectedShape()?.x).toBe(100) // move the left edge of the box shape to the center of the test shape - it should snap editor.pointerMove(205, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(2) expect(editor.getOnlySelectedShape()?.x).toBe(150) }) }) describe('with only the center in boundSnapPoints', () => { beforeEach(() => { editor.updateShape({ id: ids.test1, type: TEST1_TYPE, props: { boundsSnapPoints: [{ x: 50, y: 50 }] }, }) }) describe('when moving the test shape', () => { beforeEach(() => editor.select(ids.test1).pointerDown(250, 250)) test('does not snap its edges to the box edges', () => { editor.pointerMove(155, 250, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(editor.getOnlySelectedShape()?.x).toBe(105) }) test('snaps its center to the box right edge', () => { editor.pointerMove(105, 250, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.getOnlySelectedShape()?.x).toBe(50) }) }) describe('when moving the box shape', () => { beforeEach(() => editor.select(ids.box).pointerDown(50, 50)) test('does not snap to the left edge of the test shape', () => { editor.pointerMove(155, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(editor.getOnlySelectedShape()?.x).toBe(105) }) test('snaps its right edge to the center of the test shape', () => { editor.pointerMove(205, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(editor.getOnlySelectedShape()?.x).toBe(150) }) }) }) describe('with empty boundSnapPoints', () => { beforeEach(() => { editor.updateShape({ id: ids.test1, type: TEST1_TYPE, props: { boundsSnapPoints: [] }, }) }) test('test shape does not snap to anything', () => { editor.select(ids.test1).pointerDown(250, 250) // try to snap our left edge to the right edge of the box shape - it should not snap editor.pointerMove(155, 250, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(editor.getOnlySelectedShape()?.x).toBe(105) // try to snap our left edge to the center of the box shape - it should not snap editor.pointerMove(105, 250, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(editor.getOnlySelectedShape()?.x).toBe(55) }) test('box shape does not snap to test shape', () => { editor.select(ids.box).pointerDown(50, 50) // try to snap our left edge to the right edge of the test shape - it should not snap editor.pointerMove(155, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(editor.getOnlySelectedShape()?.x).toBe(105) // try to snap our right edge to the center of the test shape - it should not snap editor.pointerMove(205, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(editor.getOnlySelectedShape()?.x).toBe(155) }) }) }) const TEST2_TYPE = 'test2' declare module '@tldraw/tlschema' { export interface TLGlobalShapePropsMap { [TEST2_TYPE]: { w: number h: number ownHandle: VecModel handleOutline: VecModel[] | 'default' | null handlePoints: VecModel[] | 'default' selfSnapOutline: VecModel[] | 'default' selfSnapPoints: VecModel[] | 'default' handleSnapType?: 'point' | 'align' } } } type Test2Shape = TLShape describe('custom handle snapping', () => { class TestShapeUtil extends BaseBoxShapeUtil { static override type = TEST2_TYPE override getDefaultProps(): Test2Shape['props'] { return { w: 100, h: 100, ownHandle: { x: 0, y: 0 }, handleOutline: 'default', handlePoints: 'default', selfSnapOutline: 'default', selfSnapPoints: 'default', } } override component() { throw new Error('Method not implemented.') } override getIndicatorPath(): undefined { throw new Error('Method not implemented.') } override getHandleSnapGeometry(shape: Test2Shape) { const { handleOutline, handlePoints, selfSnapOutline, selfSnapPoints } = shape.props return { outline: handleOutline === 'default' ? undefined : handleOutline === null ? null : new Polyline2d({ points: handleOutline.map(Vec.From) }), points: handlePoints === 'default' ? undefined : handlePoints, getSelfSnapOutline: selfSnapOutline === 'default' ? undefined : () => new Polyline2d({ points: selfSnapOutline.map(Vec.From) }), getSelfSnapPoints: selfSnapPoints === 'default' ? undefined : () => selfSnapPoints, } } override getHandles(shape: Test2Shape): TLHandle[] { const handle: TLHandle = { id: 'handle', label: 'handle', type: 'vertex', x: shape.props.ownHandle.x, y: shape.props.ownHandle.y, index: ZERO_INDEX_KEY, } if (shape.props.handleSnapType) { handle.snapType = shape.props.handleSnapType } else { // eslint-disable-next-line @typescript-eslint/no-deprecated handle.canSnap = true } return [handle] } override onHandleDrag(shape: Test2Shape, { handle }: TLHandleDragInfo) { return { ...shape, props: { ...shape.props, ownHandle: { x: handle.x, y: handle.y } } } } } const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[] let editor: TestEditor let ids: Record beforeEach(() => { editor = new TestEditor({ shapeUtils }) ids = { line: createShapeId('line'), test2: createShapeId('test2'), } editor.createShapes([ { id: ids.line, type: 'line', x: 0, y: 0, props: { points: { a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 }, a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 100 }, }, }, }, { id: ids.test2, type: TEST2_TYPE, x: 200, y: 200, props: { w: 100, h: 100 } }, ]) }) function startDraggingHandle() { const shape = editor.select(ids.line).getOnlySelectedShape()! as TLLineShape const handles = editor.getShapeHandles(shape)! editor.pointerDown(100, 100, { target: 'handle', shape, handle: handles[handles.length - 1] }) } function handlePosition() { const shape = editor.select(ids.line).getOnlySelectedShape()! as TLLineShape const handles = editor.getShapeHandles(shape)! const handle = handles[handles.length - 1] return { x: handle.x, y: handle.y } } describe('with default handleSnapGeometry.outline', () => { test('snaps handles to the box of the shape', () => { startDraggingHandle() editor.pointerMove(215, 205, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 215, y: 200 }) }) test("doesn't particularly snap to vertices", () => { startDraggingHandle() editor.pointerMove(204, 205, undefined, { ctrlKey: true }) // only snapped to the nearest edge, not the vertex expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 200, y: 205 }) }) test("doesn't snap to the center", () => { startDraggingHandle() editor.pointerMove(251, 251, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(handlePosition()).toMatchObject({ x: 251, y: 251 }) }) }) describe('with empty handleSnapGeometry.outline', () => { beforeEach(() => { editor.updateShape({ id: ids.test2, type: TEST2_TYPE, props: { handleOutline: null }, }) }) test("doesn't snap to the shape at all", () => { startDraggingHandle() editor.pointerMove(215, 205, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(handlePosition()).toMatchObject({ x: 215, y: 205 }) }) }) describe('with custom handleSnapGeometry.outline', () => { beforeEach(() => { editor.updateShape({ id: ids.test2, type: TEST2_TYPE, props: { // a diagonal line from the top left to the bottom right handleOutline: [ { x: 0, y: 0 }, { x: 100, y: 100 }, ], }, }) }) test('does not snap to the normal edges of the shape', () => { startDraggingHandle() editor.pointerMove(235, 205, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(handlePosition()).toMatchObject({ x: 235, y: 205 }) }) test('snaps to the custom geometry', () => { startDraggingHandle() editor.pointerMove(210, 214, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 212, y: 212 }) }) }) describe('with default handleSnapGeometry.points', () => { test('doesnt snap to the center', () => { startDraggingHandle() editor.pointerMove(251, 251, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(handlePosition()).toMatchObject({ x: 251, y: 251 }) }) test('doesnt snap to corners', () => { startDraggingHandle() editor.pointerMove(203, 202, undefined, { ctrlKey: true }) // snaps to edge, not corner: expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 203, y: 200 }) }) }) describe('with custom handleSnapGeometry.points', () => { beforeEach(() => { editor.updateShape({ id: ids.test2, type: TEST2_TYPE, props: { handlePoints: [ { x: 30, y: 30 }, { x: 70, y: 50 }, ], }, }) }) test('snaps to the custom points', () => { startDraggingHandle() editor.pointerMove(235, 235, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 230, y: 230 }) editor.snaps.clearIndicators() editor.pointerMove(265, 255, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 270, y: 250 }) }) }) describe('with custom handleSnapGeometry.points along the outline', () => { beforeEach(() => { editor.updateShape({ id: ids.test2, type: TEST2_TYPE, props: { handlePoints: editor .getShapeGeometry(ids.test2) .bounds.cornersAndCenter.map(({ x, y }) => ({ x, y })), }, }) }) test('snaps to points over outline', () => { startDraggingHandle() editor.pointerMove(203, 202, undefined, { ctrlKey: true }) // snaps to corner, not edge: expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 200, y: 200 }) }) test('can still snap to non-outline points', () => { startDraggingHandle() editor.pointerMove(255, 255, undefined, { ctrlKey: true }) // snaps to the center: expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 250, y: 250 }) }) test('can still snap to non-point outlines', () => { startDraggingHandle() editor.pointerMove(235, 205, undefined, { ctrlKey: true }) // snaps to the edge: expect(editor.snaps.getIndicators()).toHaveLength(1) expect(handlePosition()).toMatchObject({ x: 235, y: 200 }) }) }) describe('self snapping', () => { beforeEach(() => { editor.deleteShape(ids.line) editor.updateShape({ id: ids.test2, type: TEST2_TYPE, x: 0, y: 0, props: { handlePoints: [{ x: 0, y: 0 }], }, }) }) function startDraggingOwnHandle() { const shape = editor.select(ids.test2).getOnlySelectedShape()! const handles = editor.getShapeHandles(shape)! editor.pointerDown(0, 0, { target: 'handle', shape, handle: handles[0] }) } function ownHandlePosition() { const shape = editor.select(ids.test2).getOnlySelectedShape()! const handle = editor.getShapeHandles(shape)![0] return { x: handle.x, y: handle.y } } describe('by default', () => { test('does not snap to standard outline', () => { startDraggingOwnHandle() editor.pointerMove(3, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 }) }) test('does not snap to standard points', () => { startDraggingOwnHandle() editor.pointerMove(3, 3, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 }) }) }) describe('with custom self snap outline & points', () => { beforeEach(() => { editor.updateShape({ id: ids.test2, type: TEST2_TYPE, props: { selfSnapOutline: [ { x: 20, y: 50 }, { x: 80, y: 50 }, ], selfSnapPoints: [ { x: 20, y: 50 }, { x: 80, y: 50 }, ], }, }) }) test('does not snap to standard outline', () => { startDraggingOwnHandle() editor.pointerMove(3, 50, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 }) }) test('does not snap to standard points', () => { startDraggingOwnHandle() editor.pointerMove(3, 3, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(0) expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 }) }) test('snaps to the self-snap outline', () => { startDraggingOwnHandle() editor.pointerMove(50, 55, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(ownHandlePosition()).toMatchObject({ x: 50, y: 50 }) }) test('snaps to the self-snap points', () => { startDraggingOwnHandle() editor.pointerMove(23, 55, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(ownHandlePosition()).toMatchObject({ x: 20, y: 50 }) }) }) describe('with snapType set to align', () => { beforeEach(() => { editor.updateShape({ id: ids.test2, type: TEST2_TYPE, props: { selfSnapPoints: [ { x: 20, y: 50 }, { x: 60, y: 10 }, ], handleSnapType: 'align', }, }) }) test('snaps to the y axis', () => { startDraggingOwnHandle() editor.pointerMove(18, 0, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(ownHandlePosition()).toMatchObject({ x: 20, y: 0 }) }) test('snaps to the x axis', () => { startDraggingOwnHandle() editor.pointerMove(0, 48, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(1) expect(ownHandlePosition()).toMatchObject({ x: 0, y: 50 }) }) test('snaps to both axes', () => { startDraggingOwnHandle() editor.pointerMove(18, 9, undefined, { ctrlKey: true }) expect(editor.snaps.getIndicators()).toHaveLength(2) expect(ownHandlePosition()).toMatchObject({ x: 20, y: 10 }) }) }) }) }) const BEZIER_TYPE = 'bezier' declare module '@tldraw/tlschema' { export interface TLGlobalShapePropsMap { [BEZIER_TYPE]: { start: VecModel cp1: VecModel cp2: VecModel end: VecModel } } } type BezierShape = TLShape describe('custom adjacent handle for shift snapping', () => { class BezierShapeUtil extends ShapeUtil { static override type = BEZIER_TYPE override getDefaultProps() { return { start: { x: 0, y: 0 }, cp1: { x: 50, y: 0 }, cp2: { x: 50, y: 100 }, end: { x: 100, y: 100 }, } } override component() { throw new Error('Method not implemented.') } override getIndicatorPath(): undefined { throw new Error('Method not implemented.') } override getGeometry() { return new Polyline2d({ points: [] }) } override getHandles(shape: BezierShape): TLHandle[] { return [ { id: 'start', type: 'vertex', index: 'a0' as IndexKey, x: shape.props.start.x, y: shape.props.start.y, }, { id: 'cp1', type: 'vertex', index: 'a1' as IndexKey, x: shape.props.cp1.x, y: shape.props.cp1.y, snapReferenceHandleId: 'start', // cp1 snaps relative to start }, { id: 'cp2', type: 'vertex', index: 'a2' as IndexKey, x: shape.props.cp2.x, y: shape.props.cp2.y, snapReferenceHandleId: 'end', // cp2 snaps relative to end }, { id: 'end', type: 'vertex', index: 'a3' as IndexKey, x: shape.props.end.x, y: shape.props.end.y, }, ] } override onHandleDrag(shape: BezierShape, { handle }: TLHandleDragInfo) { return { ...shape, props: { ...shape.props, [handle.id]: { x: handle.x, y: handle.y }, }, } } } const shapeUtils = [BezierShapeUtil] as TLAnyShapeUtilConstructor[] let editor: TestEditor let ids: Record beforeEach(() => { editor = new TestEditor({ shapeUtils }) ids = { bezier: createShapeId('bezier'), } editor.createShapes([ { id: ids.bezier, type: 'bezier', x: 0, y: 0, props: { start: { x: 0, y: 0 }, cp1: { x: 50, y: 0 }, cp2: { x: 50, y: 100 }, end: { x: 100, y: 100 }, }, }, ]) }) test('cp1 snaps angle relative to start point when using custom adjacent handle', () => { editor.select(ids.bezier) const bezier = editor.getShape(ids.bezier)! const cp1Handle = editor.getShapeHandles(bezier)!.find((h) => h.id === 'cp1')! // Start dragging cp1 handle editor.pointerDown(cp1Handle.x, cp1Handle.y, { target: 'handle', shape: bezier, handle: cp1Handle, }) // Move with shift key - should snap angle relative to start (0, 0) not cp2 editor.pointerMove(60, 20, { shiftKey: true }) const updatedBezier = editor.getShape(ids.bezier)! const cp1Pos = updatedBezier.props.cp1 const startPos = updatedBezier.props.start // The angle from start to cp1 should be snapped to nearest 15 degrees const angle = Math.atan2(cp1Pos.y - startPos.y, cp1Pos.x - startPos.x) const degrees = (angle * 180) / Math.PI // Should snap to a multiple of 15 degrees (snapAngle uses 24 divisions = 15 degrees) const remainder = ((degrees % 15) + 15) % 15 expect(Math.min(remainder, 15 - remainder)).toBeLessThan(1) }) test('cp2 snaps angle relative to end point when using custom adjacent handle', () => { editor.select(ids.bezier) const bezier = editor.getShape(ids.bezier)! const cp2Handle = editor.getShapeHandles(bezier)!.find((h) => h.id === 'cp2')! // Start dragging cp2 handle editor.pointerDown(cp2Handle.x, cp2Handle.y, { target: 'handle', shape: bezier, handle: cp2Handle, }) // Move with shift key - should snap angle relative to end (100, 100) editor.pointerMove(80, 80, { shiftKey: true }) const updatedBezier = editor.getShape(ids.bezier)! const cp2Pos = updatedBezier.props.cp2 const endPos = updatedBezier.props.end // The angle from end to cp2 should be snapped to nearest 15 degrees const angle = Math.atan2(cp2Pos.y - endPos.y, cp2Pos.x - endPos.x) const degrees = (angle * 180) / Math.PI // Should snap to a multiple of 15 degrees const remainder = ((degrees % 15) + 15) % 15 expect(Math.min(remainder, 15 - remainder)).toBeLessThan(1) }) test('default handles use default adjacent handle logic', () => { editor.select(ids.bezier) const bezier = editor.getShape(ids.bezier)! const startHandle = editor.getShapeHandles(bezier)!.find((h) => h.id === 'start')! // Start dragging start handle editor.pointerDown(startHandle.x, startHandle.y, { target: 'handle', shape: bezier, handle: startHandle, }) // Move with shift key - should use default logic (next vertex handle = cp1) editor.pointerMove(10, 10, { shiftKey: true }) const updatedBezier = editor.getShape(ids.bezier)! const startPos = updatedBezier.props.start const cp1Pos = updatedBezier.props.cp1 // The angle from cp1 to start should be snapped to nearest 15 degrees const angle = Math.atan2(startPos.y - cp1Pos.y, startPos.x - cp1Pos.x) const degrees = (angle * 180) / Math.PI // Should snap to a multiple of 15 degrees const remainder = ((degrees % 15) + 15) % 15 expect(Math.min(remainder, 15 - remainder)).toBeLessThan(1) }) })