tldraw 4.2.0-canary.2bb634d0af63 → 4.2.0-canary.2d8f5e848e20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist-cjs/index.d.ts +1 -1
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js +3 -3
  4. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js.map +2 -2
  5. package/dist-cjs/lib/shapes/shared/RichTextLabel.js +1 -1
  6. package/dist-cjs/lib/shapes/shared/RichTextLabel.js.map +2 -2
  7. package/dist-cjs/lib/tools/SelectTool/childStates/DraggingHandle.js +14 -6
  8. package/dist-cjs/lib/tools/SelectTool/childStates/DraggingHandle.js.map +2 -2
  9. package/dist-cjs/lib/tools/SelectTool/childStates/Idle.js +2 -2
  10. package/dist-cjs/lib/tools/SelectTool/childStates/Idle.js.map +2 -2
  11. package/dist-cjs/lib/ui/components/DefaultDebugPanel.js +1 -1
  12. package/dist-cjs/lib/ui/components/DefaultDebugPanel.js.map +2 -2
  13. package/dist-cjs/lib/ui/components/Toolbar/DefaultRichTextToolbarContent.js +2 -1
  14. package/dist-cjs/lib/ui/components/Toolbar/DefaultRichTextToolbarContent.js.map +2 -2
  15. package/dist-cjs/lib/ui/components/Toolbar/LinkEditor.js +1 -1
  16. package/dist-cjs/lib/ui/components/Toolbar/LinkEditor.js.map +2 -2
  17. package/dist-cjs/lib/ui/context/events.js.map +2 -2
  18. package/dist-cjs/lib/ui/getLocalFiles.js +18 -3
  19. package/dist-cjs/lib/ui/getLocalFiles.js.map +2 -2
  20. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js +18 -16
  21. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js.map +3 -3
  22. package/dist-cjs/lib/ui/hooks/useTranslation/useTranslation.js +1 -0
  23. package/dist-cjs/lib/ui/hooks/useTranslation/useTranslation.js.map +2 -2
  24. package/dist-cjs/lib/ui/version.js +3 -3
  25. package/dist-cjs/lib/ui/version.js.map +1 -1
  26. package/dist-esm/index.d.mts +1 -1
  27. package/dist-esm/index.mjs +1 -1
  28. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs +5 -5
  29. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs.map +2 -2
  30. package/dist-esm/lib/shapes/shared/RichTextLabel.mjs +2 -1
  31. package/dist-esm/lib/shapes/shared/RichTextLabel.mjs.map +2 -2
  32. package/dist-esm/lib/tools/SelectTool/childStates/DraggingHandle.mjs +14 -6
  33. package/dist-esm/lib/tools/SelectTool/childStates/DraggingHandle.mjs.map +2 -2
  34. package/dist-esm/lib/tools/SelectTool/childStates/Idle.mjs +2 -2
  35. package/dist-esm/lib/tools/SelectTool/childStates/Idle.mjs.map +2 -2
  36. package/dist-esm/lib/ui/components/DefaultDebugPanel.mjs +1 -1
  37. package/dist-esm/lib/ui/components/DefaultDebugPanel.mjs.map +2 -2
  38. package/dist-esm/lib/ui/components/Toolbar/DefaultRichTextToolbarContent.mjs +2 -1
  39. package/dist-esm/lib/ui/components/Toolbar/DefaultRichTextToolbarContent.mjs.map +2 -2
  40. package/dist-esm/lib/ui/components/Toolbar/LinkEditor.mjs +2 -2
  41. package/dist-esm/lib/ui/components/Toolbar/LinkEditor.mjs.map +2 -2
  42. package/dist-esm/lib/ui/context/events.mjs.map +2 -2
  43. package/dist-esm/lib/ui/getLocalFiles.mjs +18 -3
  44. package/dist-esm/lib/ui/getLocalFiles.mjs.map +2 -2
  45. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +18 -16
  46. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +3 -3
  47. package/dist-esm/lib/ui/hooks/useTranslation/useTranslation.mjs +1 -0
  48. package/dist-esm/lib/ui/hooks/useTranslation/useTranslation.mjs.map +2 -2
  49. package/dist-esm/lib/ui/version.mjs +3 -3
  50. package/dist-esm/lib/ui/version.mjs.map +1 -1
  51. package/package.json +3 -3
  52. package/src/lib/shapes/note/NoteShapeUtil.tsx +6 -5
  53. package/src/lib/shapes/shared/RichTextLabel.tsx +2 -1
  54. package/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx +19 -8
  55. package/src/lib/tools/SelectTool/childStates/Idle.ts +2 -2
  56. package/src/lib/ui/components/DefaultDebugPanel.tsx +1 -1
  57. package/src/lib/ui/components/Toolbar/DefaultRichTextToolbarContent.tsx +4 -1
  58. package/src/lib/ui/components/Toolbar/LinkEditor.tsx +2 -2
  59. package/src/lib/ui/context/events.tsx +1 -0
  60. package/src/lib/ui/getLocalFiles.ts +20 -3
  61. package/src/lib/ui/hooks/useClipboardEvents.ts +12 -9
  62. package/src/lib/ui/hooks/useTranslation/useTranslation.tsx +2 -1
  63. package/src/lib/ui/version.ts +3 -3
  64. package/src/test/TldrawEditor.test.tsx +74 -29
  65. package/src/test/customSnapping.test.tsx +185 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ui/version.ts"],
4
- "sourcesContent": ["// This file is automatically generated by internal/scripts/refresh-assets.ts.\n// Do not edit manually. Or do, I'm a comment, not a cop.\n\nexport const version = '4.2.0-canary.2bb634d0af63'\nexport const publishDates = {\n\tmajor: '2025-09-18T14:39:22.803Z',\n\tminor: '2025-10-21T13:14:44.760Z',\n\tpatch: '2025-10-21T13:14:44.760Z',\n}\n"],
4
+ "sourcesContent": ["// This file is automatically generated by internal/scripts/refresh-assets.ts.\n// Do not edit manually. Or do, I'm a comment, not a cop.\n\nexport const version = '4.2.0-canary.2d8f5e848e20'\nexport const publishDates = {\n\tmajor: '2025-09-18T14:39:22.803Z',\n\tminor: '2025-11-07T10:30:55.556Z',\n\tpatch: '2025-11-07T10:30:55.556Z',\n}\n"],
5
5
  "mappings": "AAGO,MAAM,UAAU;AAChB,MAAM,eAAe;AAAA,EAC3B,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AACR;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tldraw",
3
3
  "description": "A tiny little drawing editor.",
4
- "version": "4.2.0-canary.2bb634d0af63",
4
+ "version": "4.2.0-canary.2d8f5e848e20",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -62,8 +62,8 @@
62
62
  "@tiptap/pm": "3.6.2",
63
63
  "@tiptap/react": "3.6.2",
64
64
  "@tiptap/starter-kit": "3.6.2",
65
- "@tldraw/editor": "4.2.0-canary.2bb634d0af63",
66
- "@tldraw/store": "4.2.0-canary.2bb634d0af63",
65
+ "@tldraw/editor": "4.2.0-canary.2d8f5e848e20",
66
+ "@tldraw/store": "4.2.0-canary.2d8f5e848e20",
67
67
  "classnames": "^2.5.1",
68
68
  "hotkeys-js": "^3.13.9",
69
69
  "idb": "^7.1.1",
@@ -31,9 +31,9 @@ import {
31
31
  useEditor,
32
32
  useValue,
33
33
  } from '@tldraw/editor'
34
- import { useCallback } from 'react'
34
+ import { useCallback, useContext } from 'react'
35
35
  import { startEditingShapeWithLabel } from '../../tools/SelectTool/selectHelpers'
36
- import { useCurrentTranslation } from '../../ui/hooks/useTranslation/useTranslation'
36
+ import { TranslationsContext } from '../../ui/hooks/useTranslation/useTranslation'
37
37
  import {
38
38
  isEmptyRichText,
39
39
  renderHtmlFromRichTextForMeasurement,
@@ -493,7 +493,8 @@ function getLabelSize(editor: Editor, shape: TLNoteShape) {
493
493
 
494
494
  function useNoteKeydownHandler(id: TLShapeId) {
495
495
  const editor = useEditor()
496
- const translation = useCurrentTranslation()
496
+ // Try to get the translation context, but fallback to ltr if it doesn't exist
497
+ const translation = useContext(TranslationsContext)
497
498
 
498
499
  return useCallback(
499
500
  (e: KeyboardEvent) => {
@@ -512,7 +513,7 @@ function useNoteKeydownHandler(id: TLShapeId) {
512
513
  // tab controls x axis (shift inverts direction set by RTL)
513
514
  // cmd enter is the y axis (shift inverts direction)
514
515
  const isRTL = !!(
515
- translation.dir === 'rtl' ||
516
+ translation?.dir === 'rtl' ||
516
517
  // todo: can we check a partial of the text, so that we don't have to render the whole thing?
517
518
  isRightToLeftLanguage(renderPlaintextFromRichText(editor, shape.props.richText))
518
519
  )
@@ -540,7 +541,7 @@ function useNoteKeydownHandler(id: TLShapeId) {
540
541
  }
541
542
  }
542
543
  },
543
- [id, editor, translation.dir]
544
+ [id, editor, translation?.dir]
544
545
  )
545
546
  }
546
547
 
@@ -8,6 +8,7 @@ import {
8
8
  TLEventInfo,
9
9
  TLRichText,
10
10
  TLShapeId,
11
+ openWindow,
11
12
  preventDefault,
12
13
  useEditor,
13
14
  useReactor,
@@ -112,7 +113,7 @@ export const RichTextLabel = React.memo(function RichTextLabel({
112
113
  if (e.name !== 'pointer_up' || !link) return
113
114
 
114
115
  if (!isDragging.current) {
115
- window.open(link, '_blank', 'noopener, noreferrer')
116
+ openWindow(link, '_blank', false)
116
117
  }
117
118
  editor.off('event', handlePointerUp)
118
119
  }
@@ -83,24 +83,35 @@ export class DraggingHandle extends StateNode {
83
83
  // Find the adjacent handle
84
84
  this.initialAdjacentHandle = null
85
85
 
86
- // Start from the handle and work forward
87
- for (let i = index + 1; i < handles.length; i++) {
88
- const handle = handles[i]
89
- if (handle.type === 'vertex' && handle.id !== 'middle' && handle.id !== info.handle.id) {
90
- this.initialAdjacentHandle = handle
91
- break
86
+ // First, check if the handle specifies a custom reference handle
87
+ if (info.handle.snapReferenceHandleId) {
88
+ const customHandle = handles.find((h) => h.id === info.handle.snapReferenceHandleId)
89
+ if (customHandle) {
90
+ this.initialAdjacentHandle = customHandle
92
91
  }
93
92
  }
94
93
 
95
- // If still no handle, start from the end and work backward
94
+ // If no custom reference handle, use default behavior
96
95
  if (!this.initialAdjacentHandle) {
97
- for (let i = handles.length - 1; i >= 0; i--) {
96
+ // Start from the handle and work forward
97
+ for (let i = index + 1; i < handles.length; i++) {
98
98
  const handle = handles[i]
99
99
  if (handle.type === 'vertex' && handle.id !== 'middle' && handle.id !== info.handle.id) {
100
100
  this.initialAdjacentHandle = handle
101
101
  break
102
102
  }
103
103
  }
104
+
105
+ // If still no handle, start from the end and work backward
106
+ if (!this.initialAdjacentHandle) {
107
+ for (let i = handles.length - 1; i >= 0; i--) {
108
+ const handle = handles[i]
109
+ if (handle.type === 'vertex' && handle.id !== 'middle' && handle.id !== info.handle.id) {
110
+ this.initialAdjacentHandle = handle
111
+ break
112
+ }
113
+ }
114
+ }
104
115
  }
105
116
 
106
117
  // <!-- Only relevant to arrows
@@ -507,7 +507,7 @@ export class Idle extends StateNode {
507
507
  }
508
508
  case 'Tab': {
509
509
  const selectedShapes = this.editor.getSelectedShapes()
510
- if (selectedShapes.length) {
510
+ if (selectedShapes.length && !info.altKey) {
511
511
  this.editor.selectAdjacentShape(info.shiftKey ? 'prev' : 'next')
512
512
  }
513
513
  break
@@ -557,7 +557,7 @@ export class Idle extends StateNode {
557
557
  }
558
558
  case 'Tab': {
559
559
  const selectedShapes = this.editor.getSelectedShapes()
560
- if (selectedShapes.length) {
560
+ if (selectedShapes.length && !info.altKey) {
561
561
  this.editor.selectAdjacentShape(info.shiftKey ? 'prev' : 'next')
562
562
  }
563
563
  break
@@ -110,7 +110,7 @@ function FPS() {
110
110
  isSlow = !isSlow
111
111
  }
112
112
 
113
- fpsRef.current!.innerHTML = `FPS ${fps.toString()} (max: ${maxKnownFps})`
113
+ fpsRef.current!.innerHTML = `FPS ${fps.toString()}`
114
114
  fpsRef.current!.className =
115
115
  `tlui-debug-panel__fps` + (isSlow ? ` tlui-debug-panel__fps__slow` : ``)
116
116
 
@@ -54,6 +54,9 @@ export function DefaultRichTextToolbarContent({
54
54
  // todo: we could make this a prop
55
55
  const actions = useMemo(() => {
56
56
  function handleOp(name: string, op: string) {
57
+ // Check if the editor view is available before calling operations
58
+ if (!textEditor.view) return
59
+
57
60
  trackEvent('rich-text', { operation: name as any, source })
58
61
  // @ts-expect-error typing this is annoying at the moment.
59
62
  textEditor.chain().focus()[op]().run()
@@ -109,7 +112,7 @@ export function DefaultRichTextToolbarContent({
109
112
  }, [textEditor, trackEvent, onEditLinkStart])
110
113
 
111
114
  return actions.map(({ name, attrs, onSelect }) => {
112
- const isActive = textEditor.isActive(name, attrs)
115
+ const isActive = textEditor.view ? textEditor.isActive(name, attrs) : false
113
116
  return (
114
117
  <TldrawUiToolbarButton
115
118
  key={name}
@@ -1,4 +1,4 @@
1
- import { preventDefault, TiptapEditor, useEditor } from '@tldraw/editor'
1
+ import { openWindow, preventDefault, TiptapEditor, useEditor } from '@tldraw/editor'
2
2
  import { useEffect, useRef, useState } from 'react'
3
3
  import { useUiEvents } from '../../context/events'
4
4
  import { useTranslation } from '../../hooks/useTranslation/useTranslation'
@@ -44,7 +44,7 @@ export function LinkEditor({ textEditor, value: initialValue, onClose }: LinkEdi
44
44
 
45
45
  const handleVisitLink = () => {
46
46
  trackEvent('rich-text', { operation: 'link-visit', source })
47
- window.open(linkifiedValue, '_blank', 'noopener, noreferrer')
47
+ openWindow(linkifiedValue, '_blank')
48
48
  onClose()
49
49
  }
50
50
 
@@ -24,6 +24,7 @@ export type TLUiEventSource =
24
24
  | 'rich-text-menu'
25
25
  | 'image-toolbar'
26
26
  | 'video-toolbar'
27
+ | 'fairy-panel'
27
28
  | 'unknown'
28
29
 
29
30
  /** @public */
@@ -9,17 +9,34 @@ export function getLocalFiles(options?: {
9
9
  input.type = 'file'
10
10
  input.accept = mimeTypes?.join(',')
11
11
  input.multiple = allowMultiple
12
+ input.style.display = 'none'
13
+
14
+ function dispose() {
15
+ input.removeEventListener('change', onchange)
16
+ input.removeEventListener('cancel', oncancel)
17
+ input.remove()
18
+ }
12
19
 
13
20
  async function onchange(e: Event) {
14
21
  const fileList = (e.target as HTMLInputElement).files
15
- if (!fileList || fileList.length === 0) return
22
+ if (!fileList || fileList.length === 0) {
23
+ resolve([])
24
+ dispose()
25
+ return
26
+ }
16
27
  const files = Array.from(fileList)
17
28
  input.value = ''
18
29
  resolve(files)
19
- input.removeEventListener('change', onchange)
20
- input.remove()
30
+ dispose()
31
+ }
32
+
33
+ function oncancel() {
34
+ resolve([])
35
+ dispose()
21
36
  }
22
37
 
38
+ document.body.appendChild(input)
39
+ input.addEventListener('cancel', oncancel)
23
40
  input.addEventListener('change', onchange)
24
41
  input?.click()
25
42
  })
@@ -586,6 +586,8 @@ async function handleClipboardThings(editor: Editor, things: ClipboardThing[], p
586
586
  * @public
587
587
  */
588
588
  const handleNativeOrMenuCopy = async (editor: Editor) => {
589
+ const navigator =
590
+ editor.getContainer().ownerDocument?.defaultView?.navigator ?? globalThis.navigator
589
591
  const content = await editor.resolveAssetsInContent(
590
592
  editor.getContentFromCurrentPage(editor.getSelectedShapeIds())
591
593
  )
@@ -713,6 +715,7 @@ export function useMenuClipboardEvents() {
713
715
  /** @public */
714
716
  export function useNativeClipboardEvents() {
715
717
  const editor = useEditor()
718
+ const ownerDocument = editor.getContainer().ownerDocument
716
719
  const trackEvent = useUiEvents()
717
720
 
718
721
  const appIsFocused = useValue('editor.isFocused', () => editor.getInstanceState().isFocused, [
@@ -817,16 +820,16 @@ export function useNativeClipboardEvents() {
817
820
  trackEvent('paste', { source: 'kbd' })
818
821
  }
819
822
 
820
- document.addEventListener('copy', copy)
821
- document.addEventListener('cut', cut)
822
- document.addEventListener('paste', paste)
823
- document.addEventListener('pointerup', pointerUpHandler)
823
+ ownerDocument?.addEventListener('copy', copy)
824
+ ownerDocument?.addEventListener('cut', cut)
825
+ ownerDocument?.addEventListener('paste', paste)
826
+ ownerDocument?.addEventListener('pointerup', pointerUpHandler)
824
827
 
825
828
  return () => {
826
- document.removeEventListener('copy', copy)
827
- document.removeEventListener('cut', cut)
828
- document.removeEventListener('paste', paste)
829
- document.removeEventListener('pointerup', pointerUpHandler)
829
+ ownerDocument?.removeEventListener('copy', copy)
830
+ ownerDocument?.removeEventListener('cut', cut)
831
+ ownerDocument?.removeEventListener('paste', paste)
832
+ ownerDocument?.removeEventListener('pointerup', pointerUpHandler)
830
833
  }
831
- }, [editor, trackEvent, appIsFocused])
834
+ }, [editor, trackEvent, appIsFocused, ownerDocument])
832
835
  }
@@ -23,7 +23,8 @@ export interface TLUiTranslationProviderProps {
23
23
  /** @public */
24
24
  export type TLUiTranslationContextType = TLUiTranslation
25
25
 
26
- const TranslationsContext = React.createContext<TLUiTranslationContextType | null>(null)
26
+ /** @internal */
27
+ export const TranslationsContext = React.createContext<TLUiTranslationContextType | null>(null)
27
28
 
28
29
  /** @public */
29
30
  export function useCurrentTranslation() {
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '4.2.0-canary.2bb634d0af63'
4
+ export const version = '4.2.0-canary.2d8f5e848e20'
5
5
  export const publishDates = {
6
6
  major: '2025-09-18T14:39:22.803Z',
7
- minor: '2025-10-21T13:14:44.760Z',
8
- patch: '2025-10-21T13:14:44.760Z',
7
+ minor: '2025-11-07T10:30:55.556Z',
8
+ patch: '2025-11-07T10:30:55.556Z',
9
9
  }
@@ -6,16 +6,17 @@ import {
6
6
  HTMLContainer,
7
7
  TLAssetStore,
8
8
  TLBaseShape,
9
+ TLShapeId,
9
10
  TldrawEditor,
10
11
  createShapeId,
11
12
  createTLStore,
12
13
  noop,
14
+ toRichText,
13
15
  } from '@tldraw/editor'
14
16
  import { StrictMode } from 'react'
15
17
  import { vi } from 'vitest'
16
18
  import { defaultShapeUtils } from '../lib/defaultShapeUtils'
17
19
  import { defaultTools } from '../lib/defaultTools'
18
- import { GeoShapeUtil } from '../lib/shapes/geo/GeoShapeUtil'
19
20
  import { defaultAddFontsFromNode, tipTapDefaultExtensions } from '../lib/utils/text/richText'
20
21
  import {
21
22
  renderTldrawComponent,
@@ -169,7 +170,7 @@ describe('<TldrawEditor />', () => {
169
170
  let editor = {} as Editor
170
171
  await renderTldrawComponent(
171
172
  <TldrawEditor
172
- shapeUtils={[GeoShapeUtil]}
173
+ shapeUtils={defaultShapeUtils}
173
174
  initialState="select"
174
175
  tools={defaultTools}
175
176
  onMount={(editorApp) => {
@@ -185,39 +186,83 @@ describe('<TldrawEditor />', () => {
185
186
  editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
186
187
  })
187
188
 
188
- const id = createShapeId()
189
-
190
- await act(async () => {
191
- editor.createShapes([
192
- {
193
- id,
194
- type: 'geo',
195
- props: { w: 100, h: 100 },
189
+ // Test all shape types except group
190
+ const shapeTypesToTest = [
191
+ { type: 'arrow' as const, props: { start: { x: 0, y: 0 }, end: { x: 100, y: 100 } } },
192
+ { type: 'bookmark' as const, props: { w: 100, h: 100, url: 'https://example.com' } },
193
+ {
194
+ type: 'draw' as const,
195
+ props: { segments: [{ type: 'free' as const, points: [{ x: 0, y: 0, z: 0.5 }] }] },
196
+ },
197
+ { type: 'embed' as const, props: { w: 100, h: 100, url: 'https://example.com' } },
198
+ { type: 'frame' as const, props: { w: 100, h: 100 } },
199
+ { type: 'geo' as const, props: { w: 100, h: 100, geo: 'rectangle' } },
200
+ {
201
+ type: 'highlight' as const,
202
+ props: { segments: [{ type: 'free' as const, points: [{ x: 0, y: 0, z: 0.5 }] }] },
203
+ },
204
+ { type: 'image' as const, props: { w: 100, h: 100 } },
205
+ {
206
+ type: 'line' as const,
207
+ props: {
208
+ points: {
209
+ a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
210
+ a2: { id: 'a2', index: 'a2', x: 100, y: 100 },
211
+ },
196
212
  },
197
- ])
198
- })
213
+ },
214
+ { type: 'note' as const, props: { richText: toRichText('test') } },
215
+ { type: 'text' as const, props: { w: 100, richText: toRichText('test') } },
216
+ { type: 'video' as const, props: { w: 100, h: 100 } },
217
+ ]
218
+
219
+ const shapeIds: TLShapeId[] = []
220
+
221
+ for (let i = 0; i < shapeTypesToTest.length; i++) {
222
+ const shapeConfig = shapeTypesToTest[i]
223
+ const id = createShapeId()
224
+ shapeIds.push(id)
225
+
226
+ await act(async () => {
227
+ editor.createShapes([
228
+ {
229
+ id,
230
+ type: shapeConfig.type,
231
+ x: i * 150, // Space them out horizontally
232
+ y: 0,
233
+ props: shapeConfig.props,
234
+ },
235
+ ])
236
+ })
237
+
238
+ // Does the shape exist?
239
+ const shape = editor.getShape(id)
240
+ expect(shape).toBeTruthy()
241
+ expect(shape?.type).toBe(shapeConfig.type)
242
+
243
+ // Check that all shapes rendered without error boundaries
244
+ expect(
245
+ document.querySelectorAll('.tl-shape-error-boundary'),
246
+ `${shapeConfig.type} had an error while rendering`
247
+ ).toHaveLength(0)
248
+ }
199
249
 
200
- // Does the shape exist?
201
- expect(editor.getShape(id)).toMatchObject({
202
- id,
203
- type: 'geo',
204
- x: 0,
205
- y: 0,
206
- opacity: 1,
207
- props: { geo: 'rectangle', w: 100, h: 100 },
208
- })
250
+ // Check that all shape components are rendering
251
+ expect(document.querySelectorAll('.tl-shape').length).toBeGreaterThanOrEqual(
252
+ shapeTypesToTest.length
253
+ )
209
254
 
210
- // Is the shape's component rendering?
211
- expect(document.querySelectorAll('.tl-shape')).toHaveLength(1)
212
- // though indicator should be display none
213
- expect(document.querySelectorAll('.tl-shape-indicator')).toHaveLength(1)
255
+ // Check that all shape indicators are present
256
+ expect(document.querySelectorAll('.tl-shape-indicator').length).toBeGreaterThanOrEqual(
257
+ shapeTypesToTest.length
258
+ )
214
259
 
215
- // Select the shape
216
- await act(async () => editor.select(id))
260
+ // Select one of the shapes (the note shape)
261
+ const noteShapeId = shapeIds[9] // note is at index 9
262
+ await act(async () => editor.select(noteShapeId))
217
263
 
218
264
  expect(editor.getSelectedShapeIds().length).toBe(1)
219
- // though indicator it should be visible
220
- expect(document.querySelectorAll('.tl-shape-indicator')).toHaveLength(1)
265
+ expect(editor.getSelectedShapeIds()[0]).toBe(noteShapeId)
221
266
 
222
267
  // Select the eraser tool...
223
268
  await act(async () => editor.setCurrentTool('eraser'))
@@ -2,6 +2,7 @@ import {
2
2
  BaseBoxShapeUtil,
3
3
  IndexKey,
4
4
  Polyline2d,
5
+ ShapeUtil,
5
6
  TLAnyShapeUtilConstructor,
6
7
  TLBaseShape,
7
8
  TLHandle,
@@ -541,3 +542,187 @@ describe('custom handle snapping', () => {
541
542
  })
542
543
  })
543
544
  })
545
+
546
+ describe('custom adjacent handle for shift snapping', () => {
547
+ type BezierShape = TLBaseShape<
548
+ 'bezier',
549
+ {
550
+ start: VecModel
551
+ cp1: VecModel
552
+ cp2: VecModel
553
+ end: VecModel
554
+ }
555
+ >
556
+
557
+ class BezierShapeUtil extends ShapeUtil<BezierShape> {
558
+ static override type = 'bezier'
559
+ override getDefaultProps() {
560
+ return {
561
+ start: { x: 0, y: 0 },
562
+ cp1: { x: 50, y: 0 },
563
+ cp2: { x: 50, y: 100 },
564
+ end: { x: 100, y: 100 },
565
+ }
566
+ }
567
+ override component() {
568
+ throw new Error('Method not implemented.')
569
+ }
570
+ override indicator() {
571
+ throw new Error('Method not implemented.')
572
+ }
573
+ override getGeometry() {
574
+ return new Polyline2d({ points: [] })
575
+ }
576
+
577
+ override getHandles(shape: BezierShape): TLHandle[] {
578
+ return [
579
+ {
580
+ id: 'start',
581
+ type: 'vertex',
582
+ index: 'a0' as IndexKey,
583
+ x: shape.props.start.x,
584
+ y: shape.props.start.y,
585
+ },
586
+ {
587
+ id: 'cp1',
588
+ type: 'vertex',
589
+ index: 'a1' as IndexKey,
590
+ x: shape.props.cp1.x,
591
+ y: shape.props.cp1.y,
592
+ snapReferenceHandleId: 'start', // cp1 snaps relative to start
593
+ },
594
+ {
595
+ id: 'cp2',
596
+ type: 'vertex',
597
+ index: 'a2' as IndexKey,
598
+ x: shape.props.cp2.x,
599
+ y: shape.props.cp2.y,
600
+ snapReferenceHandleId: 'end', // cp2 snaps relative to end
601
+ },
602
+ {
603
+ id: 'end',
604
+ type: 'vertex',
605
+ index: 'a3' as IndexKey,
606
+ x: shape.props.end.x,
607
+ y: shape.props.end.y,
608
+ },
609
+ ]
610
+ }
611
+
612
+ override onHandleDrag(shape: BezierShape, { handle }: TLHandleDragInfo<BezierShape>) {
613
+ return {
614
+ ...shape,
615
+ props: {
616
+ ...shape.props,
617
+ [handle.id]: { x: handle.x, y: handle.y },
618
+ },
619
+ }
620
+ }
621
+ }
622
+
623
+ const shapeUtils = [BezierShapeUtil] as TLAnyShapeUtilConstructor[]
624
+
625
+ let editor: TestEditor
626
+ let ids: Record<string, TLShapeId>
627
+
628
+ beforeEach(() => {
629
+ editor = new TestEditor({ shapeUtils })
630
+ ids = editor.createShapesFromJsx([
631
+ <TL.bezier
632
+ ref="bezier"
633
+ x={0}
634
+ y={0}
635
+ w={100}
636
+ h={100}
637
+ start={{ x: 0, y: 0 }}
638
+ cp1={{ x: 50, y: 0 }}
639
+ cp2={{ x: 50, y: 100 }}
640
+ end={{ x: 100, y: 100 }}
641
+ />,
642
+ ])
643
+ })
644
+
645
+ test('cp1 snaps angle relative to start point when using custom adjacent handle', () => {
646
+ editor.select(ids.bezier)
647
+ const bezier = editor.getShape<BezierShape>(ids.bezier)!
648
+ const cp1Handle = editor.getShapeHandles(bezier)!.find((h) => h.id === 'cp1')!
649
+
650
+ // Start dragging cp1 handle
651
+ editor.pointerDown(cp1Handle.x, cp1Handle.y, {
652
+ target: 'handle',
653
+ shape: bezier,
654
+ handle: cp1Handle,
655
+ })
656
+
657
+ // Move with shift key - should snap angle relative to start (0, 0) not cp2
658
+ editor.pointerMove(60, 20, { shiftKey: true })
659
+
660
+ const updatedBezier = editor.getShape<BezierShape>(ids.bezier)!
661
+ const cp1Pos = updatedBezier.props.cp1
662
+ const startPos = updatedBezier.props.start
663
+
664
+ // The angle from start to cp1 should be snapped to nearest 15 degrees
665
+ const angle = Math.atan2(cp1Pos.y - startPos.y, cp1Pos.x - startPos.x)
666
+ const degrees = (angle * 180) / Math.PI
667
+
668
+ // Should snap to a multiple of 15 degrees (snapAngle uses 24 divisions = 15 degrees)
669
+ const remainder = ((degrees % 15) + 15) % 15
670
+ expect(Math.min(remainder, 15 - remainder)).toBeLessThan(1)
671
+ })
672
+
673
+ test('cp2 snaps angle relative to end point when using custom adjacent handle', () => {
674
+ editor.select(ids.bezier)
675
+ const bezier = editor.getShape<BezierShape>(ids.bezier)!
676
+ const cp2Handle = editor.getShapeHandles(bezier)!.find((h) => h.id === 'cp2')!
677
+
678
+ // Start dragging cp2 handle
679
+ editor.pointerDown(cp2Handle.x, cp2Handle.y, {
680
+ target: 'handle',
681
+ shape: bezier,
682
+ handle: cp2Handle,
683
+ })
684
+
685
+ // Move with shift key - should snap angle relative to end (100, 100)
686
+ editor.pointerMove(80, 80, { shiftKey: true })
687
+
688
+ const updatedBezier = editor.getShape<BezierShape>(ids.bezier)!
689
+ const cp2Pos = updatedBezier.props.cp2
690
+ const endPos = updatedBezier.props.end
691
+
692
+ // The angle from end to cp2 should be snapped to nearest 15 degrees
693
+ const angle = Math.atan2(cp2Pos.y - endPos.y, cp2Pos.x - endPos.x)
694
+ const degrees = (angle * 180) / Math.PI
695
+
696
+ // Should snap to a multiple of 15 degrees
697
+ const remainder = ((degrees % 15) + 15) % 15
698
+ expect(Math.min(remainder, 15 - remainder)).toBeLessThan(1)
699
+ })
700
+
701
+ test('default handles use default adjacent handle logic', () => {
702
+ editor.select(ids.bezier)
703
+ const bezier = editor.getShape<BezierShape>(ids.bezier)!
704
+ const startHandle = editor.getShapeHandles(bezier)!.find((h) => h.id === 'start')!
705
+
706
+ // Start dragging start handle
707
+ editor.pointerDown(startHandle.x, startHandle.y, {
708
+ target: 'handle',
709
+ shape: bezier,
710
+ handle: startHandle,
711
+ })
712
+
713
+ // Move with shift key - should use default logic (next vertex handle = cp1)
714
+ editor.pointerMove(10, 10, { shiftKey: true })
715
+
716
+ const updatedBezier = editor.getShape<BezierShape>(ids.bezier)!
717
+ const startPos = updatedBezier.props.start
718
+ const cp1Pos = updatedBezier.props.cp1
719
+
720
+ // The angle from cp1 to start should be snapped to nearest 15 degrees
721
+ const angle = Math.atan2(startPos.y - cp1Pos.y, startPos.x - cp1Pos.x)
722
+ const degrees = (angle * 180) / Math.PI
723
+
724
+ // Should snap to a multiple of 15 degrees
725
+ const remainder = ((degrees % 15) + 15) % 15
726
+ expect(Math.min(remainder, 15 - remainder)).toBeLessThan(1)
727
+ })
728
+ })