tldraw 5.3.1 → 5.4.0-canary.042da660fa66

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 (99) hide show
  1. package/DOCS.md +2822 -3224
  2. package/RELEASE_NOTES.md +21 -33
  3. package/dist-cjs/index.d.ts +264 -275
  4. package/dist-cjs/index.js +1 -1
  5. package/dist-cjs/lib/defaultExternalContentHandlers.js +2 -2
  6. package/dist-cjs/lib/defaultExternalContentHandlers.js.map +2 -2
  7. package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js +2 -2
  8. package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js.map +2 -2
  9. package/dist-cjs/lib/shapes/frame/frameHelpers.js +3 -0
  10. package/dist-cjs/lib/shapes/frame/frameHelpers.js.map +2 -2
  11. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js +3 -2
  12. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js.map +2 -2
  13. package/dist-cjs/lib/ui/TldrawUi.js +2 -3
  14. package/dist-cjs/lib/ui/TldrawUi.js.map +2 -2
  15. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js +0 -3
  16. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js.map +2 -2
  17. package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js +1 -1
  18. package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js.map +2 -2
  19. package/dist-cjs/lib/ui/components/Dialogs.js +8 -0
  20. package/dist-cjs/lib/ui/components/Dialogs.js.map +2 -2
  21. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js +1 -2
  22. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js.map +2 -2
  23. package/dist-cjs/lib/ui/context/actions.js +0 -1
  24. package/dist-cjs/lib/ui/context/actions.js.map +2 -2
  25. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js +2 -0
  26. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js.map +2 -2
  27. package/dist-cjs/lib/ui/hooks/useCommentingEnabled.js +1 -1
  28. package/dist-cjs/lib/ui/hooks/useCommentingEnabled.js.map +2 -2
  29. package/dist-cjs/lib/ui/hooks/useFlatten.js +42 -23
  30. package/dist-cjs/lib/ui/hooks/useFlatten.js.map +2 -2
  31. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js +5 -17
  32. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js.map +2 -2
  33. package/dist-cjs/lib/ui/kbd-utils.js +22 -3
  34. package/dist-cjs/lib/ui/kbd-utils.js.map +2 -2
  35. package/dist-cjs/lib/ui/version.js +3 -3
  36. package/dist-cjs/lib/ui/version.js.map +1 -1
  37. package/dist-esm/index.d.mts +264 -275
  38. package/dist-esm/index.mjs +1 -1
  39. package/dist-esm/lib/defaultExternalContentHandlers.mjs +2 -2
  40. package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
  41. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs +2 -2
  42. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs.map +2 -2
  43. package/dist-esm/lib/shapes/frame/frameHelpers.mjs +3 -0
  44. package/dist-esm/lib/shapes/frame/frameHelpers.mjs.map +2 -2
  45. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +3 -2
  46. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
  47. package/dist-esm/lib/ui/TldrawUi.mjs +2 -3
  48. package/dist-esm/lib/ui/TldrawUi.mjs.map +2 -2
  49. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs +2 -5
  50. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs.map +2 -2
  51. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs +1 -1
  52. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs.map +2 -2
  53. package/dist-esm/lib/ui/components/Dialogs.mjs +9 -1
  54. package/dist-esm/lib/ui/components/Dialogs.mjs.map +2 -2
  55. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs +1 -2
  56. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs.map +2 -2
  57. package/dist-esm/lib/ui/context/actions.mjs +0 -1
  58. package/dist-esm/lib/ui/context/actions.mjs.map +2 -2
  59. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +2 -0
  60. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +2 -2
  61. package/dist-esm/lib/ui/hooks/useCommentingEnabled.mjs +2 -2
  62. package/dist-esm/lib/ui/hooks/useCommentingEnabled.mjs.map +2 -2
  63. package/dist-esm/lib/ui/hooks/useFlatten.mjs +42 -23
  64. package/dist-esm/lib/ui/hooks/useFlatten.mjs.map +2 -2
  65. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs +5 -17
  66. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs.map +2 -2
  67. package/dist-esm/lib/ui/kbd-utils.mjs +22 -3
  68. package/dist-esm/lib/ui/kbd-utils.mjs.map +2 -2
  69. package/dist-esm/lib/ui/version.mjs +3 -3
  70. package/dist-esm/lib/ui/version.mjs.map +1 -1
  71. package/package.json +12 -6
  72. package/src/lib/defaultExternalContentHandlers.ts +2 -2
  73. package/src/lib/shapes/draw/DrawShapeTool.test.ts +19 -0
  74. package/src/lib/shapes/draw/toolStates/Drawing.ts +2 -2
  75. package/src/lib/shapes/frame/frameHelpers.ts +10 -1
  76. package/src/lib/shapes/video/VideoShapeUtil.tsx +4 -2
  77. package/src/lib/ui/TldrawUi.tsx +1 -2
  78. package/src/lib/ui/components/ActionsMenu/DefaultActionsMenu.tsx +2 -6
  79. package/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx +5 -1
  80. package/src/lib/ui/components/Dialogs.tsx +17 -1
  81. package/src/lib/ui/components/SharePanel/DefaultPeopleMenuItem.tsx +2 -3
  82. package/src/lib/ui/context/actions.tsx +0 -1
  83. package/src/lib/ui/hooks/useClipboardEvents.ts +2 -0
  84. package/src/lib/ui/hooks/useCommentingEnabled.ts +5 -3
  85. package/src/lib/ui/hooks/useFlatten.ts +56 -23
  86. package/src/lib/ui/hooks/useKeyboardShortcuts.ts +5 -23
  87. package/src/lib/ui/kbd-utils.test.ts +11 -0
  88. package/src/lib/ui/kbd-utils.ts +27 -4
  89. package/src/lib/ui/version.ts +3 -3
  90. package/src/lib/ui.css +7 -16
  91. package/src/test/TldrawEditor.test.tsx +3 -0
  92. package/src/test/VideoShapeUtil.test.tsx +126 -0
  93. package/src/test/bookmark-shapes.test.ts +110 -0
  94. package/src/test/commands/alignShapes.test.tsx +19 -10
  95. package/src/test/commands/clipboard.test.ts +36 -1
  96. package/src/test/flatten.test.ts +135 -0
  97. package/src/test/modifiers.test.ts +104 -0
  98. package/src/test/ui-outside-editor.test.tsx +74 -0
  99. package/tldraw.css +7 -16
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/lib/ui/hooks/useKeyboardShortcuts.ts"],
4
- "sourcesContent": ["/*!\n * The kbd-string splitter (`getKeys`) and the form-input filter pattern in `shouldSkipEvent`\n * (including its list of non-text INPUT types) are adapted from hotkeys-js, which this hook\n * previously depended on.\n *\n * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE\n * Copyright (c) 2015-present, Kenny Wong\n * Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)\n * Source: https://github.com/jaywcjlove/hotkeys-js\n */\n\nimport {\n\tEditor,\n\tTLPointerEventInfo,\n\tisAccelKey,\n\tpreventDefault,\n\tuseEditor,\n\tuseValue,\n} from '@tldraw/editor'\nimport { useEffect } from 'react'\nimport { useActions } from '../context/actions'\nimport { useCommentingEnabled } from './useCommentingEnabled'\nimport { useReadonly } from './useReadonly'\nimport { useTools } from './useTools'\n\nconst SKIP_KBDS = [\n\t// we set these in useNativeClipboardEvents instead\n\t'copy',\n\t'cut',\n\t'paste',\n\t// There's also an upload asset action, so we don't want to set the kbd twice\n\t'asset',\n]\n\n/** @public */\nexport function useKeyboardShortcuts() {\n\tconst editor = useEditor()\n\n\tconst isReadonlyMode = useReadonly()\n\tconst actions = useActions()\n\tconst tools = useTools()\n\t// The comment tool's shortcut is gated by the same license as its toolbar button.\n\tconst commentingEnabled = useCommentingEnabled()\n\tconst isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor])\n\tuseEffect(() => {\n\t\tif (!isFocused) return\n\n\t\tconst registry: Registration[] = []\n\n\t\tconst register = (kbd: string, onKeyDown?: KbdHandler, onKeyUp?: KbdHandler) => {\n\t\t\tconst parsed = parseKbd(kbd)\n\t\t\tif (parsed.length === 0) return\n\t\t\tregistry.push({ parsed, onKeyDown, onKeyUp })\n\t\t}\n\n\t\t// Add hotkeys for actions and tools.\n\t\t// Except those that in SKIP_KBDS!\n\t\tfor (const action of Object.values(actions)) {\n\t\t\tif (!action.kbd) continue\n\t\t\tif (isReadonlyMode && !action.readonlyOk) continue\n\t\t\tif (SKIP_KBDS.includes(action.id)) continue\n\n\t\t\tregister(getHotkeysStringFromKbd(action.kbd), (event) => {\n\t\t\t\tif (areShortcutsDisabled(editor) && !action.isRequiredA11yAction) return\n\t\t\t\tpreventDefault(event)\n\t\t\t\taction.onSelect('kbd')\n\t\t\t})\n\t\t}\n\n\t\tfor (const tool of Object.values(tools)) {\n\t\t\tif (!tool.kbd || (!tool.readonlyOk && editor.getIsReadonly())) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif (SKIP_KBDS.includes(tool.id)) continue\n\n\t\t\t// The comment tool is a licensed feature; skip its shortcut when commenting isn't enabled.\n\t\t\tif (tool.id === 'comment' && !commentingEnabled) continue\n\n\t\t\tregister(getHotkeysStringFromKbd(tool.kbd), (event) => {\n\t\t\t\tif (areShortcutsDisabled(editor)) return\n\t\t\t\tpreventDefault(event)\n\t\t\t\ttool.onSelect('kbd')\n\t\t\t})\n\t\t}\n\n\t\tregister(\n\t\t\t',',\n\t\t\t(e) => {\n\t\t\t\t// Skip if shortcuts are disabled\n\t\t\t\tif (areShortcutsDisabled(editor)) return\n\n\t\t\t\t// Don't press again if already pressed\n\t\t\t\tif (editor.inputs.keys.has('Comma')) return\n\n\t\t\t\tpreventDefault(e) // prevent whatever would normally happen\n\t\t\t\teditor.focus() // Focus if not already focused\n\n\t\t\t\teditor.inputs.keys.add('Comma')\n\n\t\t\t\tconst { x, y, z } = editor.inputs.getCurrentPagePoint()\n\t\t\t\tconst screenpoints = editor.pageToScreen({ x, y })\n\n\t\t\t\tconst info: TLPointerEventInfo = {\n\t\t\t\t\ttype: 'pointer',\n\t\t\t\t\tname: 'pointer_down',\n\t\t\t\t\tpoint: { x: screenpoints.x, y: screenpoints.y, z },\n\t\t\t\t\tshiftKey: e.shiftKey,\n\t\t\t\t\taltKey: e.altKey,\n\t\t\t\t\tctrlKey: e.metaKey || e.ctrlKey,\n\t\t\t\t\tmetaKey: e.metaKey,\n\t\t\t\t\taccelKey: isAccelKey(e),\n\t\t\t\t\tpointerId: 0,\n\t\t\t\t\tbutton: 0,\n\t\t\t\t\tisPen: editor.getInstanceState().isPenMode,\n\t\t\t\t\ttarget: 'canvas',\n\t\t\t\t}\n\n\t\t\t\teditor.dispatch(info)\n\t\t\t},\n\t\t\t(e) => {\n\t\t\t\tif (areShortcutsDisabled(editor)) return\n\t\t\t\tif (!editor.inputs.keys.has('Comma')) return\n\n\t\t\t\teditor.inputs.keys.delete('Comma')\n\n\t\t\t\tconst { x, y, z } = editor.inputs.getCurrentPagePoint()\n\t\t\t\tconst screenPoint = editor.pageToScreen({ x, y })\n\t\t\t\tconst info: TLPointerEventInfo = {\n\t\t\t\t\ttype: 'pointer',\n\t\t\t\t\tname: 'pointer_up',\n\t\t\t\t\tpoint: { x: screenPoint.x, y: screenPoint.y, z },\n\t\t\t\t\tshiftKey: e.shiftKey,\n\t\t\t\t\taltKey: e.altKey,\n\t\t\t\t\tctrlKey: e.metaKey || e.ctrlKey,\n\t\t\t\t\tmetaKey: e.metaKey,\n\t\t\t\t\taccelKey: isAccelKey(e),\n\t\t\t\t\tpointerId: 0,\n\t\t\t\t\tbutton: 0,\n\t\t\t\t\tisPen: editor.getInstanceState().isPenMode,\n\t\t\t\t\ttarget: 'canvas',\n\t\t\t\t}\n\n\t\t\t\teditor.dispatch(info)\n\t\t\t}\n\t\t)\n\n\t\tconst body = editor.getContainerDocument().body\n\n\t\t// Track which registration each physically-held key first triggered, keyed by\n\t\t// `event.code`. While a key is held down, releasing a modifier should not let the\n\t\t// auto-repeat keydown events trigger an adjacent shortcut (e.g. releasing shift while\n\t\t// still holding shift+q shouldn't start firing the plain `q` shortcut). The same\n\t\t// registration is still allowed to repeat (e.g. holding `=` to keep zooming).\n\t\tconst heldKeyRegistrations = new Map<string, Registration>()\n\n\t\tconst handleKeyDown = (e: KeyboardEvent) => {\n\t\t\tif (shouldSkipEvent(e)) return\n\t\t\tconst code = e.code\n\t\t\tfor (const reg of registry) {\n\t\t\t\tif (!reg.onKeyDown) continue\n\t\t\t\tfor (const p of reg.parsed) {\n\t\t\t\t\tif (matchesEvent(e, p)) {\n\t\t\t\t\t\tif (code) {\n\t\t\t\t\t\t\t// We only guard auto-repeat events. A fresh keypress (`e.repeat` is\n\t\t\t\t\t\t\t// false) is always free to trigger whatever it matches, even on the same\n\t\t\t\t\t\t\t// physical key \u2014 e.g. cmd+z (undo) then cmd+shift+z (redo), where macOS\n\t\t\t\t\t\t\t// swallows the `z` keyup while cmd stays held, so we can't rely on keyup\n\t\t\t\t\t\t\t// to clear the previous registration.\n\t\t\t\t\t\t\tif (e.repeat) {\n\t\t\t\t\t\t\t\tconst prev = heldKeyRegistrations.get(code)\n\t\t\t\t\t\t\t\t// The held key already triggered a different shortcut; don't fall back\n\t\t\t\t\t\t\t\t// to this one (or anything else) just because a modifier was released.\n\t\t\t\t\t\t\t\tif (prev && prev !== reg) return\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\theldKeyRegistrations.set(code, reg)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treg.onKeyDown(e)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst handleKeyUp = (e: KeyboardEvent) => {\n\t\t\t// Always release the held-key tracking, even for events we'd otherwise skip (e.g. the\n\t\t\t// key was released after focus moved into a text input), so a stale entry can't block\n\t\t\t// later shortcuts.\n\t\t\tif (e.code) heldKeyRegistrations.delete(e.code)\n\t\t\tif (shouldSkipEvent(e)) return\n\t\t\tfor (const reg of registry) {\n\t\t\t\tif (!reg.onKeyUp) continue\n\t\t\t\tfor (const p of reg.parsed) {\n\t\t\t\t\tif (matchesEvent(e, p)) {\n\t\t\t\t\t\treg.onKeyUp(e)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tbody.addEventListener('keydown', handleKeyDown)\n\t\tbody.addEventListener('keyup', handleKeyUp)\n\n\t\treturn () => {\n\t\t\tbody.removeEventListener('keydown', handleKeyDown)\n\t\t\tbody.removeEventListener('keyup', handleKeyUp)\n\t\t}\n\t}, [actions, tools, isReadonlyMode, editor, isFocused, commentingEnabled])\n}\n\nexport function areShortcutsDisabled(editor: Editor) {\n\treturn (\n\t\teditor.menus.hasAnyOpenMenus() ||\n\t\teditor.getEditingShapeId() !== null ||\n\t\teditor.getCrashingError() ||\n\t\t!editor.user.getAreKeyboardShortcutsEnabled()\n\t)\n}\n\n// kbd parsing & native event matching\n// -----------------------------------\n// We deliberately do NOT use `event.code` (physical key position) for primary matching.\n// Doing so breaks alternative Latin keyboard layouts (Dvorak, Colemak, AZERTY) because\n// `event.code` always reflects the US-QWERTY position regardless of what the user typed.\n// Instead we match the typed character (`event.key`) and only fall back to `event.code` for\n// non-Latin layouts (Cyrillic, Greek, etc.) and macOS Option-letter dead-key combinations,\n// where `event.key` is a non-ASCII glyph.\n\n/**\n * @internal\n */\nexport interface ParsedKbd {\n\tkey: string\n\tshift: boolean\n\talt: boolean\n\tctrl: boolean\n\tmeta: boolean\n}\n\ntype KbdHandler = (event: KeyboardEvent) => void\n\ninterface Registration {\n\tparsed: ParsedKbd[]\n\tonKeyDown?: KbdHandler\n\tonKeyUp?: KbdHandler\n}\n\nconst MODIFIER_ALIASES: Record<string, 'shift' | 'alt' | 'ctrl' | 'meta'> = {\n\tshift: 'shift',\n\t'\u21E7': 'shift',\n\talt: 'alt',\n\toption: 'alt',\n\t'\u2325': 'alt',\n\tctrl: 'ctrl',\n\tcontrol: 'ctrl',\n\t'\u2303': 'ctrl',\n\tcmd: 'meta',\n\tcommand: 'meta',\n\tmeta: 'meta',\n\t'\u2318': 'meta',\n}\n\n// Aliases for named keys. Values match the lowercased `event.key` for that key.\nconst KEY_ALIASES: Record<string, string> = {\n\t'\u232B': 'backspace',\n\t'\u21A9': 'enter',\n\treturn: 'enter',\n\tesc: 'escape',\n\tdel: 'delete',\n\tins: 'insert',\n\tleft: 'arrowleft',\n\tright: 'arrowright',\n\tup: 'arrowup',\n\tdown: 'arrowdown',\n\tspace: ' ',\n}\n\n// When shift is held, `event.key` reports the shifted character (e.g. '<' for shift+,).\n// Map those back so a `shift+,` shortcut definition matches.\nconst SHIFT_KEY_TO_BASE: Record<string, string> = {\n\t'<': ',',\n\t'>': '.',\n\t'?': '/',\n\t':': ';',\n\t'\"': \"'\",\n\t'{': '[',\n\t'}': ']',\n\t'|': '\\\\',\n\t_: '-',\n\t'+': '=',\n\t'~': '`',\n\t'!': '1',\n\t'@': '2',\n\t'#': '3',\n\t$: '4',\n\t'%': '5',\n\t'^': '6',\n\t'&': '7',\n\t'*': '8',\n\t'(': '9',\n\t')': '0',\n}\n\n// Physical key code -> US-QWERTY-equivalent character. Used as a fallback when `event.key`\n// is non-ASCII (Cyrillic, Greek, macOS Option dead-keys, etc.).\nconst PHYSICAL_KEY_MAP: Record<string, string> = {\n\tKeyA: 'a',\n\tKeyB: 'b',\n\tKeyC: 'c',\n\tKeyD: 'd',\n\tKeyE: 'e',\n\tKeyF: 'f',\n\tKeyG: 'g',\n\tKeyH: 'h',\n\tKeyI: 'i',\n\tKeyJ: 'j',\n\tKeyK: 'k',\n\tKeyL: 'l',\n\tKeyM: 'm',\n\tKeyN: 'n',\n\tKeyO: 'o',\n\tKeyP: 'p',\n\tKeyQ: 'q',\n\tKeyR: 'r',\n\tKeyS: 's',\n\tKeyT: 't',\n\tKeyU: 'u',\n\tKeyV: 'v',\n\tKeyW: 'w',\n\tKeyX: 'x',\n\tKeyY: 'y',\n\tKeyZ: 'z',\n\tDigit0: '0',\n\tDigit1: '1',\n\tDigit2: '2',\n\tDigit3: '3',\n\tDigit4: '4',\n\tDigit5: '5',\n\tDigit6: '6',\n\tDigit7: '7',\n\tDigit8: '8',\n\tDigit9: '9',\n\tComma: ',',\n\tPeriod: '.',\n\tSlash: '/',\n\tSemicolon: ';',\n\tQuote: \"'\",\n\tBracketLeft: '[',\n\tBracketRight: ']',\n\tBackslash: '\\\\',\n\tMinus: '-',\n\tEqual: '=',\n\tBackquote: '`',\n}\n\n/**\n * @internal\n */\nexport function parseKbd(kbd: string): ParsedKbd[] {\n\tconst out: ParsedKbd[] = []\n\tfor (const shortcut of getKeys(kbd)) {\n\t\tconst parsed = parseShortcut(shortcut)\n\t\tif (parsed) out.push(parsed)\n\t}\n\treturn out\n}\n\nfunction parseShortcut(shortcut: string): ParsedKbd | null {\n\tconst parts = shortcut.split('+')\n\tconst result: ParsedKbd = {\n\t\tkey: '',\n\t\tshift: false,\n\t\talt: false,\n\t\tctrl: false,\n\t\tmeta: false,\n\t}\n\n\tlet keyPart = ''\n\tfor (let i = 0; i < parts.length; i++) {\n\t\tconst part = parts[i]\n\t\tconst isLast = i === parts.length - 1\n\t\tif (!isLast) {\n\t\t\tconst modAlias = MODIFIER_ALIASES[part.toLowerCase()]\n\t\t\tif (modAlias) result[modAlias] = true\n\t\t\t// silently drop unknown leading parts\n\t\t} else {\n\t\t\tkeyPart = part\n\t\t}\n\t}\n\n\tif (!keyPart) return null\n\n\tlet key = keyPart.toLowerCase()\n\tif (KEY_ALIASES[key]) key = KEY_ALIASES[key]\n\tresult.key = key\n\treturn result\n}\n\nfunction getEventKey(e: KeyboardEvent): string {\n\tlet key = e.key.toLowerCase()\n\tif (e.shiftKey && SHIFT_KEY_TO_BASE[key]) {\n\t\tkey = SHIFT_KEY_TO_BASE[key]\n\t}\n\treturn key\n}\n\nfunction matchesEvent(e: KeyboardEvent, parsed: ParsedKbd): boolean {\n\tif (e.shiftKey !== parsed.shift) return false\n\tif (e.altKey !== parsed.alt) return false\n\tif (e.ctrlKey !== parsed.ctrl) return false\n\tif (e.metaKey !== parsed.meta) return false\n\n\t// With shift held, match number-row digits by physical position rather than the typed glyph: the\n\t// digit keys sit in the same place on every Latin layout, but their shifted glyph varies\n\t// (shift+2 is '@' on US, '\"' on British PC and German), so glyph matching misses them. Returning\n\t// here also stops a digit press from matching a symbol shortcut that shares its glyph \u2014 on\n\t// German, shift+0 types '=', which must run zoom-to-100, not the '=' zoom-in shortcut it would\n\t// otherwise alias to. Unshifted presses keep glyph matching, because layouts put real symbol\n\t// shortcuts on the number row: AZERTY types '-' on Digit6, and that must still zoom out.\n\tif (e.shiftKey) {\n\t\tconst digitCode = /^Digit([0-9])$/.exec(e.code)\n\t\tif (digitCode) return parsed.key === digitCode[1]\n\t}\n\n\tconst eventKey = getEventKey(e)\n\tif (eventKey === parsed.key) return true\n\n\t// Fallback for non-Latin layouts (Cyrillic, Greek, etc.) and macOS Option dead-keys,\n\t// where event.key is a non-ASCII glyph that wouldn't match any of our shortcut keys.\n\t// We re-derive the intended key from event.code's US-QWERTY equivalent. Importantly,\n\t// we only use this fallback when event.key is non-ASCII so that Dvorak/Colemak/AZERTY\n\t// users \u2014 whose event.key IS the Latin character they typed \u2014 keep getting the right match.\n\tif (eventKey.length === 1 && /^[\\x20-\\x7e]$/.test(eventKey)) return false\n\tconst codeKey = PHYSICAL_KEY_MAP[e.code]\n\treturn codeKey === parsed.key\n}\n\nfunction shouldSkipEvent(e: KeyboardEvent): boolean {\n\tif (e.isComposing) return true\n\tconst target = e.target as HTMLElement | null\n\tif (!target) return false\n\tif (target.isContentEditable) return true\n\tconst tagName = target.tagName\n\tif (tagName === 'SELECT') return true\n\tif (tagName === 'TEXTAREA') {\n\t\treturn !(target as HTMLTextAreaElement).readOnly\n\t}\n\tif (tagName === 'INPUT') {\n\t\tconst input = target as HTMLInputElement\n\t\t// Form inputs that don't accept text input should not block shortcuts.\n\t\tif (\n\t\t\t['checkbox', 'radio', 'range', 'button', 'file', 'reset', 'submit', 'color'].includes(\n\t\t\t\tinput.type\n\t\t\t)\n\t\t) {\n\t\t\treturn false\n\t\t}\n\t\treturn !input.readOnly\n\t}\n\treturn false\n}\n\n/**\n * The \"raw\" kbd here will look something like \"a\" or a combination of keys\n * \"del,backspace\". We need to first split them up by comma, then parse each\n * key to ensure backwards compatibility with the old kbd format. We used to\n * have symbols to denote cmd/alt/shift, using ! for shift, $ for cmd, and ?\n * for alt.\n *\n * @internal\n */\nexport function getHotkeysStringFromKbd(kbd: string) {\n\treturn getKeys(kbd)\n\t\t.map((kbd) => {\n\t\t\tlet str = ''\n\n\t\t\tconst shift = kbd.includes('!')\n\t\t\tconst alt = kbd.includes('?')\n\t\t\tconst cmd = kbd.includes('$')\n\n\t\t\t// remove the modifiers; the remaining string are the actual key\n\t\t\tconst k = kbd.replace(/[!?$]/g, '')\n\n\t\t\tif (shift && alt && cmd) {\n\t\t\t\tstr = `cmd+shift+alt+${k},ctrl+shift+alt+${k}`\n\t\t\t} else if (shift && cmd) {\n\t\t\t\tstr = `cmd+shift+${k},ctrl+shift+${k}`\n\t\t\t} else if (alt && cmd) {\n\t\t\t\tstr = `cmd+alt+${k},ctrl+alt+${k}`\n\t\t\t} else if (alt && shift) {\n\t\t\t\tstr = `shift+alt+${k}`\n\t\t\t} else if (shift) {\n\t\t\t\tstr = `shift+${k}`\n\t\t\t} else if (alt) {\n\t\t\t\tstr = `alt+${k}`\n\t\t\t} else if (cmd) {\n\t\t\t\tstr = `cmd+${k},ctrl+${k}`\n\t\t\t} else {\n\t\t\t\tstr = k\n\t\t\t}\n\n\t\t\treturn str\n\t\t})\n\t\t.join(',')\n}\n\n// Split a kbd string on commas, treating an empty entry produced by \"x,,\" as a literal\n// trailing comma on the previous entry. Verbatim port of the splitter from hotkeys-js\n// (MIT, see top-of-file attribution).\nfunction getKeys(key: string) {\n\tif (typeof key !== 'string') key = ''\n\tkey = key.replace(/\\s/g, '')\n\tconst keys = key.split(',')\n\tlet index = keys.lastIndexOf('')\n\n\tfor (; index >= 0; ) {\n\t\tkeys[index - 1] += ','\n\t\tkeys.splice(index, 1)\n\t\tindex = keys.lastIndexOf('')\n\t}\n\n\treturn keys\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/*!\n * The form-input filter pattern in `shouldSkipEvent` (including its list of non-text INPUT\n * types) is adapted from hotkeys-js, which this hook previously depended on.\n *\n * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE\n * Copyright (c) 2015-present, Kenny Wong\n * Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)\n * Source: https://github.com/jaywcjlove/hotkeys-js\n */\n\nimport {\n\tEditor,\n\tTLPointerEventInfo,\n\tisAccelKey,\n\tpreventDefault,\n\tuseEditor,\n\tuseValue,\n} from '@tldraw/editor'\nimport { useEffect } from 'react'\nimport { useActions } from '../context/actions'\nimport { splitKbd } from '../kbd-utils'\nimport { useCommentingEnabled } from './useCommentingEnabled'\nimport { useReadonly } from './useReadonly'\nimport { useTools } from './useTools'\n\nconst SKIP_KBDS = [\n\t// we set these in useNativeClipboardEvents instead\n\t'copy',\n\t'cut',\n\t'paste',\n\t// There's also an upload asset action, so we don't want to set the kbd twice\n\t'asset',\n]\n\n/** @public */\nexport function useKeyboardShortcuts() {\n\tconst editor = useEditor()\n\n\tconst isReadonlyMode = useReadonly()\n\tconst actions = useActions()\n\tconst tools = useTools()\n\t// The comment tool's shortcut is gated by the same license as its toolbar button.\n\tconst commentingEnabled = useCommentingEnabled()\n\tconst isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor])\n\tuseEffect(() => {\n\t\tif (!isFocused) return\n\n\t\tconst registry: Registration[] = []\n\n\t\tconst register = (kbd: string, onKeyDown?: KbdHandler, onKeyUp?: KbdHandler) => {\n\t\t\tconst parsed = parseKbd(kbd)\n\t\t\tif (parsed.length === 0) return\n\t\t\tregistry.push({ parsed, onKeyDown, onKeyUp })\n\t\t}\n\n\t\t// Add hotkeys for actions and tools.\n\t\t// Except those that in SKIP_KBDS!\n\t\tfor (const action of Object.values(actions)) {\n\t\t\tif (!action.kbd) continue\n\t\t\tif (isReadonlyMode && !action.readonlyOk) continue\n\t\t\tif (SKIP_KBDS.includes(action.id)) continue\n\n\t\t\tregister(getHotkeysStringFromKbd(action.kbd), (event) => {\n\t\t\t\tif (areShortcutsDisabled(editor) && !action.isRequiredA11yAction) return\n\t\t\t\tpreventDefault(event)\n\t\t\t\taction.onSelect('kbd')\n\t\t\t})\n\t\t}\n\n\t\tfor (const tool of Object.values(tools)) {\n\t\t\tif (!tool.kbd || (!tool.readonlyOk && editor.getIsReadonly())) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif (SKIP_KBDS.includes(tool.id)) continue\n\n\t\t\t// The comment tool is a licensed feature; skip its shortcut when commenting isn't enabled.\n\t\t\tif (tool.id === 'comment' && !commentingEnabled) continue\n\n\t\t\tregister(getHotkeysStringFromKbd(tool.kbd), (event) => {\n\t\t\t\tif (areShortcutsDisabled(editor)) return\n\t\t\t\tpreventDefault(event)\n\t\t\t\ttool.onSelect('kbd')\n\t\t\t})\n\t\t}\n\n\t\tregister(\n\t\t\t',',\n\t\t\t(e) => {\n\t\t\t\t// Skip if shortcuts are disabled\n\t\t\t\tif (areShortcutsDisabled(editor)) return\n\n\t\t\t\t// Don't press again if already pressed\n\t\t\t\tif (editor.inputs.keys.has('Comma')) return\n\n\t\t\t\tpreventDefault(e) // prevent whatever would normally happen\n\t\t\t\teditor.focus() // Focus if not already focused\n\n\t\t\t\teditor.inputs.keys.add('Comma')\n\n\t\t\t\tconst { x, y, z } = editor.inputs.getCurrentPagePoint()\n\t\t\t\tconst screenpoints = editor.pageToScreen({ x, y })\n\n\t\t\t\tconst info: TLPointerEventInfo = {\n\t\t\t\t\ttype: 'pointer',\n\t\t\t\t\tname: 'pointer_down',\n\t\t\t\t\tpoint: { x: screenpoints.x, y: screenpoints.y, z },\n\t\t\t\t\tshiftKey: e.shiftKey,\n\t\t\t\t\taltKey: e.altKey,\n\t\t\t\t\tctrlKey: e.metaKey || e.ctrlKey,\n\t\t\t\t\tmetaKey: e.metaKey,\n\t\t\t\t\taccelKey: isAccelKey(e),\n\t\t\t\t\tpointerId: 0,\n\t\t\t\t\tbutton: 0,\n\t\t\t\t\tisPen: editor.getInstanceState().isPenMode,\n\t\t\t\t\ttarget: 'canvas',\n\t\t\t\t}\n\n\t\t\t\teditor.dispatch(info)\n\t\t\t},\n\t\t\t(e) => {\n\t\t\t\tif (areShortcutsDisabled(editor)) return\n\t\t\t\tif (!editor.inputs.keys.has('Comma')) return\n\n\t\t\t\teditor.inputs.keys.delete('Comma')\n\n\t\t\t\tconst { x, y, z } = editor.inputs.getCurrentPagePoint()\n\t\t\t\tconst screenPoint = editor.pageToScreen({ x, y })\n\t\t\t\tconst info: TLPointerEventInfo = {\n\t\t\t\t\ttype: 'pointer',\n\t\t\t\t\tname: 'pointer_up',\n\t\t\t\t\tpoint: { x: screenPoint.x, y: screenPoint.y, z },\n\t\t\t\t\tshiftKey: e.shiftKey,\n\t\t\t\t\taltKey: e.altKey,\n\t\t\t\t\tctrlKey: e.metaKey || e.ctrlKey,\n\t\t\t\t\tmetaKey: e.metaKey,\n\t\t\t\t\taccelKey: isAccelKey(e),\n\t\t\t\t\tpointerId: 0,\n\t\t\t\t\tbutton: 0,\n\t\t\t\t\tisPen: editor.getInstanceState().isPenMode,\n\t\t\t\t\ttarget: 'canvas',\n\t\t\t\t}\n\n\t\t\t\teditor.dispatch(info)\n\t\t\t}\n\t\t)\n\n\t\tconst body = editor.getContainerDocument().body\n\n\t\t// Track which registration each physically-held key first triggered, keyed by\n\t\t// `event.code`. While a key is held down, releasing a modifier should not let the\n\t\t// auto-repeat keydown events trigger an adjacent shortcut (e.g. releasing shift while\n\t\t// still holding shift+q shouldn't start firing the plain `q` shortcut). The same\n\t\t// registration is still allowed to repeat (e.g. holding `=` to keep zooming).\n\t\tconst heldKeyRegistrations = new Map<string, Registration>()\n\n\t\tconst handleKeyDown = (e: KeyboardEvent) => {\n\t\t\tif (shouldSkipEvent(e)) return\n\t\t\tconst code = e.code\n\t\t\tfor (const reg of registry) {\n\t\t\t\tif (!reg.onKeyDown) continue\n\t\t\t\tfor (const p of reg.parsed) {\n\t\t\t\t\tif (matchesEvent(e, p)) {\n\t\t\t\t\t\tif (code) {\n\t\t\t\t\t\t\t// We only guard auto-repeat events. A fresh keypress (`e.repeat` is\n\t\t\t\t\t\t\t// false) is always free to trigger whatever it matches, even on the same\n\t\t\t\t\t\t\t// physical key \u2014 e.g. cmd+z (undo) then cmd+shift+z (redo), where macOS\n\t\t\t\t\t\t\t// swallows the `z` keyup while cmd stays held, so we can't rely on keyup\n\t\t\t\t\t\t\t// to clear the previous registration.\n\t\t\t\t\t\t\tif (e.repeat) {\n\t\t\t\t\t\t\t\tconst prev = heldKeyRegistrations.get(code)\n\t\t\t\t\t\t\t\t// The held key already triggered a different shortcut; don't fall back\n\t\t\t\t\t\t\t\t// to this one (or anything else) just because a modifier was released.\n\t\t\t\t\t\t\t\tif (prev && prev !== reg) return\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\theldKeyRegistrations.set(code, reg)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\treg.onKeyDown(e)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst handleKeyUp = (e: KeyboardEvent) => {\n\t\t\t// Always release the held-key tracking, even for events we'd otherwise skip (e.g. the\n\t\t\t// key was released after focus moved into a text input), so a stale entry can't block\n\t\t\t// later shortcuts.\n\t\t\tif (e.code) heldKeyRegistrations.delete(e.code)\n\t\t\tif (shouldSkipEvent(e)) return\n\t\t\tfor (const reg of registry) {\n\t\t\t\tif (!reg.onKeyUp) continue\n\t\t\t\tfor (const p of reg.parsed) {\n\t\t\t\t\tif (matchesEvent(e, p)) {\n\t\t\t\t\t\treg.onKeyUp(e)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tbody.addEventListener('keydown', handleKeyDown)\n\t\tbody.addEventListener('keyup', handleKeyUp)\n\n\t\treturn () => {\n\t\t\tbody.removeEventListener('keydown', handleKeyDown)\n\t\t\tbody.removeEventListener('keyup', handleKeyUp)\n\t\t}\n\t}, [actions, tools, isReadonlyMode, editor, isFocused, commentingEnabled])\n}\n\nexport function areShortcutsDisabled(editor: Editor) {\n\treturn (\n\t\teditor.menus.hasAnyOpenMenus() ||\n\t\teditor.getEditingShapeId() !== null ||\n\t\teditor.getCrashingError() ||\n\t\t!editor.user.getAreKeyboardShortcutsEnabled()\n\t)\n}\n\n// kbd parsing & native event matching\n// -----------------------------------\n// We deliberately do NOT use `event.code` (physical key position) for primary matching.\n// Doing so breaks alternative Latin keyboard layouts (Dvorak, Colemak, AZERTY) because\n// `event.code` always reflects the US-QWERTY position regardless of what the user typed.\n// Instead we match the typed character (`event.key`) and only fall back to `event.code` for\n// non-Latin layouts (Cyrillic, Greek, etc.) and macOS Option-letter dead-key combinations,\n// where `event.key` is a non-ASCII glyph.\n\n/**\n * @internal\n */\nexport interface ParsedKbd {\n\tkey: string\n\tshift: boolean\n\talt: boolean\n\tctrl: boolean\n\tmeta: boolean\n}\n\ntype KbdHandler = (event: KeyboardEvent) => void\n\ninterface Registration {\n\tparsed: ParsedKbd[]\n\tonKeyDown?: KbdHandler\n\tonKeyUp?: KbdHandler\n}\n\nconst MODIFIER_ALIASES: Record<string, 'shift' | 'alt' | 'ctrl' | 'meta'> = {\n\tshift: 'shift',\n\t'\u21E7': 'shift',\n\talt: 'alt',\n\toption: 'alt',\n\t'\u2325': 'alt',\n\tctrl: 'ctrl',\n\tcontrol: 'ctrl',\n\t'\u2303': 'ctrl',\n\tcmd: 'meta',\n\tcommand: 'meta',\n\tmeta: 'meta',\n\t'\u2318': 'meta',\n}\n\n// Aliases for named keys. Values match the lowercased `event.key` for that key.\nconst KEY_ALIASES: Record<string, string> = {\n\t'\u232B': 'backspace',\n\t'\u21A9': 'enter',\n\treturn: 'enter',\n\tesc: 'escape',\n\tdel: 'delete',\n\tins: 'insert',\n\tleft: 'arrowleft',\n\tright: 'arrowright',\n\tup: 'arrowup',\n\tdown: 'arrowdown',\n\tspace: ' ',\n}\n\n// When shift is held, `event.key` reports the shifted character (e.g. '<' for shift+,).\n// Map those back so a `shift+,` shortcut definition matches.\nconst SHIFT_KEY_TO_BASE: Record<string, string> = {\n\t'<': ',',\n\t'>': '.',\n\t'?': '/',\n\t':': ';',\n\t'\"': \"'\",\n\t'{': '[',\n\t'}': ']',\n\t'|': '\\\\',\n\t_: '-',\n\t'+': '=',\n\t'~': '`',\n\t'!': '1',\n\t'@': '2',\n\t'#': '3',\n\t$: '4',\n\t'%': '5',\n\t'^': '6',\n\t'&': '7',\n\t'*': '8',\n\t'(': '9',\n\t')': '0',\n}\n\n// Physical key code -> US-QWERTY-equivalent character. Used as a fallback when `event.key`\n// is non-ASCII (Cyrillic, Greek, macOS Option dead-keys, etc.).\nconst PHYSICAL_KEY_MAP: Record<string, string> = {\n\tKeyA: 'a',\n\tKeyB: 'b',\n\tKeyC: 'c',\n\tKeyD: 'd',\n\tKeyE: 'e',\n\tKeyF: 'f',\n\tKeyG: 'g',\n\tKeyH: 'h',\n\tKeyI: 'i',\n\tKeyJ: 'j',\n\tKeyK: 'k',\n\tKeyL: 'l',\n\tKeyM: 'm',\n\tKeyN: 'n',\n\tKeyO: 'o',\n\tKeyP: 'p',\n\tKeyQ: 'q',\n\tKeyR: 'r',\n\tKeyS: 's',\n\tKeyT: 't',\n\tKeyU: 'u',\n\tKeyV: 'v',\n\tKeyW: 'w',\n\tKeyX: 'x',\n\tKeyY: 'y',\n\tKeyZ: 'z',\n\tDigit0: '0',\n\tDigit1: '1',\n\tDigit2: '2',\n\tDigit3: '3',\n\tDigit4: '4',\n\tDigit5: '5',\n\tDigit6: '6',\n\tDigit7: '7',\n\tDigit8: '8',\n\tDigit9: '9',\n\tComma: ',',\n\tPeriod: '.',\n\tSlash: '/',\n\tSemicolon: ';',\n\tQuote: \"'\",\n\tBracketLeft: '[',\n\tBracketRight: ']',\n\tBackslash: '\\\\',\n\tMinus: '-',\n\tEqual: '=',\n\tBackquote: '`',\n}\n\n/**\n * @internal\n */\nexport function parseKbd(kbd: string): ParsedKbd[] {\n\tconst out: ParsedKbd[] = []\n\tfor (const shortcut of splitKbd(kbd.replace(/\\s/g, ''))) {\n\t\tconst parsed = parseShortcut(shortcut)\n\t\tif (parsed) out.push(parsed)\n\t}\n\treturn out\n}\n\nfunction parseShortcut(shortcut: string): ParsedKbd | null {\n\tconst parts = shortcut.split('+')\n\tconst result: ParsedKbd = {\n\t\tkey: '',\n\t\tshift: false,\n\t\talt: false,\n\t\tctrl: false,\n\t\tmeta: false,\n\t}\n\n\tlet keyPart = ''\n\tfor (let i = 0; i < parts.length; i++) {\n\t\tconst part = parts[i]\n\t\tconst isLast = i === parts.length - 1\n\t\tif (!isLast) {\n\t\t\tconst modAlias = MODIFIER_ALIASES[part.toLowerCase()]\n\t\t\tif (modAlias) result[modAlias] = true\n\t\t\t// silently drop unknown leading parts\n\t\t} else {\n\t\t\tkeyPart = part\n\t\t}\n\t}\n\n\tif (!keyPart) return null\n\n\tlet key = keyPart.toLowerCase()\n\tif (KEY_ALIASES[key]) key = KEY_ALIASES[key]\n\tresult.key = key\n\treturn result\n}\n\nfunction getEventKey(e: KeyboardEvent): string {\n\tlet key = e.key.toLowerCase()\n\tif (e.shiftKey && SHIFT_KEY_TO_BASE[key]) {\n\t\tkey = SHIFT_KEY_TO_BASE[key]\n\t}\n\treturn key\n}\n\nfunction matchesEvent(e: KeyboardEvent, parsed: ParsedKbd): boolean {\n\tif (e.shiftKey !== parsed.shift) return false\n\tif (e.altKey !== parsed.alt) return false\n\tif (e.ctrlKey !== parsed.ctrl) return false\n\tif (e.metaKey !== parsed.meta) return false\n\n\t// With shift held, match number-row digits by physical position rather than the typed glyph: the\n\t// digit keys sit in the same place on every Latin layout, but their shifted glyph varies\n\t// (shift+2 is '@' on US, '\"' on British PC and German), so glyph matching misses them. Returning\n\t// here also stops a digit press from matching a symbol shortcut that shares its glyph \u2014 on\n\t// German, shift+0 types '=', which must run zoom-to-100, not the '=' zoom-in shortcut it would\n\t// otherwise alias to. Unshifted presses keep glyph matching, because layouts put real symbol\n\t// shortcuts on the number row: AZERTY types '-' on Digit6, and that must still zoom out.\n\tif (e.shiftKey) {\n\t\tconst digitCode = /^Digit([0-9])$/.exec(e.code)\n\t\tif (digitCode) return parsed.key === digitCode[1]\n\t}\n\n\tconst eventKey = getEventKey(e)\n\tif (eventKey === parsed.key) return true\n\n\t// Fallback for non-Latin layouts (Cyrillic, Greek, etc.) and macOS Option dead-keys,\n\t// where event.key is a non-ASCII glyph that wouldn't match any of our shortcut keys.\n\t// We re-derive the intended key from event.code's US-QWERTY equivalent. Importantly,\n\t// we only use this fallback when event.key is non-ASCII so that Dvorak/Colemak/AZERTY\n\t// users \u2014 whose event.key IS the Latin character they typed \u2014 keep getting the right match.\n\tif (eventKey.length === 1 && /^[\\x20-\\x7e]$/.test(eventKey)) return false\n\tconst codeKey = PHYSICAL_KEY_MAP[e.code]\n\treturn codeKey === parsed.key\n}\n\nfunction shouldSkipEvent(e: KeyboardEvent): boolean {\n\tif (e.isComposing) return true\n\tconst target = e.target as HTMLElement | null\n\tif (!target) return false\n\tif (target.isContentEditable) return true\n\tconst tagName = target.tagName\n\tif (tagName === 'SELECT') return true\n\tif (tagName === 'TEXTAREA') {\n\t\treturn !(target as HTMLTextAreaElement).readOnly\n\t}\n\tif (tagName === 'INPUT') {\n\t\tconst input = target as HTMLInputElement\n\t\t// Form inputs that don't accept text input should not block shortcuts.\n\t\tif (\n\t\t\t['checkbox', 'radio', 'range', 'button', 'file', 'reset', 'submit', 'color'].includes(\n\t\t\t\tinput.type\n\t\t\t)\n\t\t) {\n\t\t\treturn false\n\t\t}\n\t\treturn !input.readOnly\n\t}\n\treturn false\n}\n\n/**\n * The \"raw\" kbd here will look something like \"a\" or a combination of keys\n * \"del,backspace\". We need to first split them up by comma, then parse each\n * key to ensure backwards compatibility with the old kbd format. We used to\n * have symbols to denote cmd/alt/shift, using ! for shift, $ for cmd, and ?\n * for alt.\n *\n * @internal\n */\nexport function getHotkeysStringFromKbd(kbd: string) {\n\treturn splitKbd(kbd.replace(/\\s/g, ''))\n\t\t.map((kbd) => {\n\t\t\tlet str = ''\n\n\t\t\tconst shift = kbd.includes('!')\n\t\t\tconst alt = kbd.includes('?')\n\t\t\tconst cmd = kbd.includes('$')\n\n\t\t\t// remove the modifiers; the remaining string are the actual key\n\t\t\tconst k = kbd.replace(/[!?$]/g, '')\n\n\t\t\tif (shift && alt && cmd) {\n\t\t\t\tstr = `cmd+shift+alt+${k},ctrl+shift+alt+${k}`\n\t\t\t} else if (shift && cmd) {\n\t\t\t\tstr = `cmd+shift+${k},ctrl+shift+${k}`\n\t\t\t} else if (alt && cmd) {\n\t\t\t\tstr = `cmd+alt+${k},ctrl+alt+${k}`\n\t\t\t} else if (alt && shift) {\n\t\t\t\tstr = `shift+alt+${k}`\n\t\t\t} else if (shift) {\n\t\t\t\tstr = `shift+${k}`\n\t\t\t} else if (alt) {\n\t\t\t\tstr = `alt+${k}`\n\t\t\t} else if (cmd) {\n\t\t\t\tstr = `cmd+${k},ctrl+${k}`\n\t\t\t} else {\n\t\t\t\tstr = k\n\t\t\t}\n\n\t\t\treturn str\n\t\t})\n\t\t.join(',')\n}\n"],
5
+ "mappings": "AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUA;AAAA,EAGC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACM;AACP,SAAS,iBAAiB;AAC1B,SAAS,kBAAkB;AAC3B,SAAS,gBAAgB;AACzB,SAAS,4BAA4B;AACrC,SAAS,mBAAmB;AAC5B,SAAS,gBAAgB;AAEzB,MAAM,YAAY;AAAA;AAAA,EAEjB;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAEA;AACD;AAGO,SAAS,uBAAuB;AACtC,QAAM,SAAS,UAAU;AAEzB,QAAM,iBAAiB,YAAY;AACnC,QAAM,UAAU,WAAW;AAC3B,QAAM,QAAQ,SAAS;AAEvB,QAAM,oBAAoB,qBAAqB;AAC/C,QAAM,YAAY,SAAS,cAAc,MAAM,OAAO,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC;AAC5F,YAAU,MAAM;AACf,QAAI,CAAC,UAAW;AAEhB,UAAM,WAA2B,CAAC;AAElC,UAAM,WAAW,CAAC,KAAa,WAAwB,YAAyB;AAC/E,YAAM,SAAS,SAAS,GAAG;AAC3B,UAAI,OAAO,WAAW,EAAG;AACzB,eAAS,KAAK,EAAE,QAAQ,WAAW,QAAQ,CAAC;AAAA,IAC7C;AAIA,eAAW,UAAU,OAAO,OAAO,OAAO,GAAG;AAC5C,UAAI,CAAC,OAAO,IAAK;AACjB,UAAI,kBAAkB,CAAC,OAAO,WAAY;AAC1C,UAAI,UAAU,SAAS,OAAO,EAAE,EAAG;AAEnC,eAAS,wBAAwB,OAAO,GAAG,GAAG,CAAC,UAAU;AACxD,YAAI,qBAAqB,MAAM,KAAK,CAAC,OAAO,qBAAsB;AAClE,uBAAe,KAAK;AACpB,eAAO,SAAS,KAAK;AAAA,MACtB,CAAC;AAAA,IACF;AAEA,eAAW,QAAQ,OAAO,OAAO,KAAK,GAAG;AACxC,UAAI,CAAC,KAAK,OAAQ,CAAC,KAAK,cAAc,OAAO,cAAc,GAAI;AAC9D;AAAA,MACD;AAEA,UAAI,UAAU,SAAS,KAAK,EAAE,EAAG;AAGjC,UAAI,KAAK,OAAO,aAAa,CAAC,kBAAmB;AAEjD,eAAS,wBAAwB,KAAK,GAAG,GAAG,CAAC,UAAU;AACtD,YAAI,qBAAqB,MAAM,EAAG;AAClC,uBAAe,KAAK;AACpB,aAAK,SAAS,KAAK;AAAA,MACpB,CAAC;AAAA,IACF;AAEA;AAAA,MACC;AAAA,MACA,CAAC,MAAM;AAEN,YAAI,qBAAqB,MAAM,EAAG;AAGlC,YAAI,OAAO,OAAO,KAAK,IAAI,OAAO,EAAG;AAErC,uBAAe,CAAC;AAChB,eAAO,MAAM;AAEb,eAAO,OAAO,KAAK,IAAI,OAAO;AAE9B,cAAM,EAAE,GAAG,GAAG,EAAE,IAAI,OAAO,OAAO,oBAAoB;AACtD,cAAM,eAAe,OAAO,aAAa,EAAE,GAAG,EAAE,CAAC;AAEjD,cAAM,OAA2B;AAAA,UAChC,MAAM;AAAA,UACN,MAAM;AAAA,UACN,OAAO,EAAE,GAAG,aAAa,GAAG,GAAG,aAAa,GAAG,EAAE;AAAA,UACjD,UAAU,EAAE;AAAA,UACZ,QAAQ,EAAE;AAAA,UACV,SAAS,EAAE,WAAW,EAAE;AAAA,UACxB,SAAS,EAAE;AAAA,UACX,UAAU,WAAW,CAAC;AAAA,UACtB,WAAW;AAAA,UACX,QAAQ;AAAA,UACR,OAAO,OAAO,iBAAiB,EAAE;AAAA,UACjC,QAAQ;AAAA,QACT;AAEA,eAAO,SAAS,IAAI;AAAA,MACrB;AAAA,MACA,CAAC,MAAM;AACN,YAAI,qBAAqB,MAAM,EAAG;AAClC,YAAI,CAAC,OAAO,OAAO,KAAK,IAAI,OAAO,EAAG;AAEtC,eAAO,OAAO,KAAK,OAAO,OAAO;AAEjC,cAAM,EAAE,GAAG,GAAG,EAAE,IAAI,OAAO,OAAO,oBAAoB;AACtD,cAAM,cAAc,OAAO,aAAa,EAAE,GAAG,EAAE,CAAC;AAChD,cAAM,OAA2B;AAAA,UAChC,MAAM;AAAA,UACN,MAAM;AAAA,UACN,OAAO,EAAE,GAAG,YAAY,GAAG,GAAG,YAAY,GAAG,EAAE;AAAA,UAC/C,UAAU,EAAE;AAAA,UACZ,QAAQ,EAAE;AAAA,UACV,SAAS,EAAE,WAAW,EAAE;AAAA,UACxB,SAAS,EAAE;AAAA,UACX,UAAU,WAAW,CAAC;AAAA,UACtB,WAAW;AAAA,UACX,QAAQ;AAAA,UACR,OAAO,OAAO,iBAAiB,EAAE;AAAA,UACjC,QAAQ;AAAA,QACT;AAEA,eAAO,SAAS,IAAI;AAAA,MACrB;AAAA,IACD;AAEA,UAAM,OAAO,OAAO,qBAAqB,EAAE;AAO3C,UAAM,uBAAuB,oBAAI,IAA0B;AAE3D,UAAM,gBAAgB,CAAC,MAAqB;AAC3C,UAAI,gBAAgB,CAAC,EAAG;AACxB,YAAM,OAAO,EAAE;AACf,iBAAW,OAAO,UAAU;AAC3B,YAAI,CAAC,IAAI,UAAW;AACpB,mBAAW,KAAK,IAAI,QAAQ;AAC3B,cAAI,aAAa,GAAG,CAAC,GAAG;AACvB,gBAAI,MAAM;AAMT,kBAAI,EAAE,QAAQ;AACb,sBAAM,OAAO,qBAAqB,IAAI,IAAI;AAG1C,oBAAI,QAAQ,SAAS,IAAK;AAAA,cAC3B,OAAO;AACN,qCAAqB,IAAI,MAAM,GAAG;AAAA,cACnC;AAAA,YACD;AACA,gBAAI,UAAU,CAAC;AACf;AAAA,UACD;AAAA,QACD;AAAA,MACD;AAAA,IACD;AAEA,UAAM,cAAc,CAAC,MAAqB;AAIzC,UAAI,EAAE,KAAM,sBAAqB,OAAO,EAAE,IAAI;AAC9C,UAAI,gBAAgB,CAAC,EAAG;AACxB,iBAAW,OAAO,UAAU;AAC3B,YAAI,CAAC,IAAI,QAAS;AAClB,mBAAW,KAAK,IAAI,QAAQ;AAC3B,cAAI,aAAa,GAAG,CAAC,GAAG;AACvB,gBAAI,QAAQ,CAAC;AACb;AAAA,UACD;AAAA,QACD;AAAA,MACD;AAAA,IACD;AAEA,SAAK,iBAAiB,WAAW,aAAa;AAC9C,SAAK,iBAAiB,SAAS,WAAW;AAE1C,WAAO,MAAM;AACZ,WAAK,oBAAoB,WAAW,aAAa;AACjD,WAAK,oBAAoB,SAAS,WAAW;AAAA,IAC9C;AAAA,EACD,GAAG,CAAC,SAAS,OAAO,gBAAgB,QAAQ,WAAW,iBAAiB,CAAC;AAC1E;AAEO,SAAS,qBAAqB,QAAgB;AACpD,SACC,OAAO,MAAM,gBAAgB,KAC7B,OAAO,kBAAkB,MAAM,QAC/B,OAAO,iBAAiB,KACxB,CAAC,OAAO,KAAK,+BAA+B;AAE9C;AA8BA,MAAM,mBAAsE;AAAA,EAC3E,OAAO;AAAA,EACP,UAAK;AAAA,EACL,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,UAAK;AAAA,EACL,MAAM;AAAA,EACN,SAAS;AAAA,EACT,UAAK;AAAA,EACL,KAAK;AAAA,EACL,SAAS;AAAA,EACT,MAAM;AAAA,EACN,UAAK;AACN;AAGA,MAAM,cAAsC;AAAA,EAC3C,UAAK;AAAA,EACL,UAAK;AAAA,EACL,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,OAAO;AACR;AAIA,MAAM,oBAA4C;AAAA,EACjD,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,GAAG;AAAA,EACH,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,GAAG;AAAA,EACH,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACN;AAIA,MAAM,mBAA2C;AAAA,EAChD,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,WAAW;AAAA,EACX,OAAO;AAAA,EACP,aAAa;AAAA,EACb,cAAc;AAAA,EACd,WAAW;AAAA,EACX,OAAO;AAAA,EACP,OAAO;AAAA,EACP,WAAW;AACZ;AAKO,SAAS,SAAS,KAA0B;AAClD,QAAM,MAAmB,CAAC;AAC1B,aAAW,YAAY,SAAS,IAAI,QAAQ,OAAO,EAAE,CAAC,GAAG;AACxD,UAAM,SAAS,cAAc,QAAQ;AACrC,QAAI,OAAQ,KAAI,KAAK,MAAM;AAAA,EAC5B;AACA,SAAO;AACR;AAEA,SAAS,cAAc,UAAoC;AAC1D,QAAM,QAAQ,SAAS,MAAM,GAAG;AAChC,QAAM,SAAoB;AAAA,IACzB,KAAK;AAAA,IACL,OAAO;AAAA,IACP,KAAK;AAAA,IACL,MAAM;AAAA,IACN,MAAM;AAAA,EACP;AAEA,MAAI,UAAU;AACd,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACtC,UAAM,OAAO,MAAM,CAAC;AACpB,UAAM,SAAS,MAAM,MAAM,SAAS;AACpC,QAAI,CAAC,QAAQ;AACZ,YAAM,WAAW,iBAAiB,KAAK,YAAY,CAAC;AACpD,UAAI,SAAU,QAAO,QAAQ,IAAI;AAAA,IAElC,OAAO;AACN,gBAAU;AAAA,IACX;AAAA,EACD;AAEA,MAAI,CAAC,QAAS,QAAO;AAErB,MAAI,MAAM,QAAQ,YAAY;AAC9B,MAAI,YAAY,GAAG,EAAG,OAAM,YAAY,GAAG;AAC3C,SAAO,MAAM;AACb,SAAO;AACR;AAEA,SAAS,YAAY,GAA0B;AAC9C,MAAI,MAAM,EAAE,IAAI,YAAY;AAC5B,MAAI,EAAE,YAAY,kBAAkB,GAAG,GAAG;AACzC,UAAM,kBAAkB,GAAG;AAAA,EAC5B;AACA,SAAO;AACR;AAEA,SAAS,aAAa,GAAkB,QAA4B;AACnE,MAAI,EAAE,aAAa,OAAO,MAAO,QAAO;AACxC,MAAI,EAAE,WAAW,OAAO,IAAK,QAAO;AACpC,MAAI,EAAE,YAAY,OAAO,KAAM,QAAO;AACtC,MAAI,EAAE,YAAY,OAAO,KAAM,QAAO;AAStC,MAAI,EAAE,UAAU;AACf,UAAM,YAAY,iBAAiB,KAAK,EAAE,IAAI;AAC9C,QAAI,UAAW,QAAO,OAAO,QAAQ,UAAU,CAAC;AAAA,EACjD;AAEA,QAAM,WAAW,YAAY,CAAC;AAC9B,MAAI,aAAa,OAAO,IAAK,QAAO;AAOpC,MAAI,SAAS,WAAW,KAAK,gBAAgB,KAAK,QAAQ,EAAG,QAAO;AACpE,QAAM,UAAU,iBAAiB,EAAE,IAAI;AACvC,SAAO,YAAY,OAAO;AAC3B;AAEA,SAAS,gBAAgB,GAA2B;AACnD,MAAI,EAAE,YAAa,QAAO;AAC1B,QAAM,SAAS,EAAE;AACjB,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,OAAO,kBAAmB,QAAO;AACrC,QAAM,UAAU,OAAO;AACvB,MAAI,YAAY,SAAU,QAAO;AACjC,MAAI,YAAY,YAAY;AAC3B,WAAO,CAAE,OAA+B;AAAA,EACzC;AACA,MAAI,YAAY,SAAS;AACxB,UAAM,QAAQ;AAEd,QACC,CAAC,YAAY,SAAS,SAAS,UAAU,QAAQ,SAAS,UAAU,OAAO,EAAE;AAAA,MAC5E,MAAM;AAAA,IACP,GACC;AACD,aAAO;AAAA,IACR;AACA,WAAO,CAAC,MAAM;AAAA,EACf;AACA,SAAO;AACR;AAWO,SAAS,wBAAwB,KAAa;AACpD,SAAO,SAAS,IAAI,QAAQ,OAAO,EAAE,CAAC,EACpC,IAAI,CAACA,SAAQ;AACb,QAAI,MAAM;AAEV,UAAM,QAAQA,KAAI,SAAS,GAAG;AAC9B,UAAM,MAAMA,KAAI,SAAS,GAAG;AAC5B,UAAM,MAAMA,KAAI,SAAS,GAAG;AAG5B,UAAM,IAAIA,KAAI,QAAQ,UAAU,EAAE;AAElC,QAAI,SAAS,OAAO,KAAK;AACxB,YAAM,iBAAiB,CAAC,mBAAmB,CAAC;AAAA,IAC7C,WAAW,SAAS,KAAK;AACxB,YAAM,aAAa,CAAC,eAAe,CAAC;AAAA,IACrC,WAAW,OAAO,KAAK;AACtB,YAAM,WAAW,CAAC,aAAa,CAAC;AAAA,IACjC,WAAW,OAAO,OAAO;AACxB,YAAM,aAAa,CAAC;AAAA,IACrB,WAAW,OAAO;AACjB,YAAM,SAAS,CAAC;AAAA,IACjB,WAAW,KAAK;AACf,YAAM,OAAO,CAAC;AAAA,IACf,WAAW,KAAK;AACf,YAAM,OAAO,CAAC,SAAS,CAAC;AAAA,IACzB,OAAO;AACN,YAAM;AAAA,IACP;AAEA,WAAO;AAAA,EACR,CAAC,EACA,KAAK,GAAG;AACX;",
6
6
  "names": ["kbd"]
7
7
  }
@@ -1,10 +1,17 @@
1
1
  import { tlenv } from "@tldraw/editor";
2
+ /*!
3
+ * `splitKbd` is adapted from hotkeys-js.
4
+ *
5
+ * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
6
+ * Copyright (c) 2015-present, Kenny Wong
7
+ * Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)
8
+ * Source: https://github.com/jaywcjlove/hotkeys-js
9
+ */
2
10
  const cmdKey = tlenv.isDarwin ? "\u2318" : "__CTRL__";
3
11
  const ctrlKey = tlenv.isDarwin ? "\u2303" : "__CTRL__";
4
12
  const altKey = tlenv.isDarwin ? "\u2325" : "__ALT__";
5
13
  function kbd(str) {
6
- if (str === ",") return [","];
7
- return str.split(",")[0].split(/(\[\[[^\]]+\]\])/g).map(
14
+ return (splitKbd(str)[0] ?? "").split(/(\[\[[^\]]+\]\])/g).map(
8
15
  (s) => s.startsWith("[[") ? s.replace(/[[\]]/g, "") : s.replace(/cmd\+/g, cmdKey).replace(/ctrl\+/g, ctrlKey).replace(/alt\+/g, altKey).replace(/shift\+/g, "\u21E7").replace(/\$/g, cmdKey).replace(/\?/g, altKey).replace(/!/g, "\u21E7").match(/__CTRL__|__ALT__|./g) || []
9
16
  ).flat().map((sub, index) => {
10
17
  if (sub[0] === "+") return [];
@@ -19,11 +26,23 @@ function kbd(str) {
19
26
  return tlenv.isDarwin || !index ? modifiedKey : ["+", modifiedKey];
20
27
  }).flat();
21
28
  }
29
+ function splitKbd(key) {
30
+ if (!key) return [];
31
+ const keys = key.split(",");
32
+ let index = keys.lastIndexOf("");
33
+ for (; index >= 0; ) {
34
+ keys[index - 1] += ",";
35
+ keys.splice(index, 1);
36
+ index = keys.lastIndexOf("");
37
+ }
38
+ return keys;
39
+ }
22
40
  function kbdStr(str) {
23
41
  return "\u2014 " + kbd(str).join("\u2009");
24
42
  }
25
43
  export {
26
44
  kbd,
27
- kbdStr
45
+ kbdStr,
46
+ splitKbd
28
47
  };
29
48
  //# sourceMappingURL=kbd-utils.mjs.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ui/kbd-utils.ts"],
4
- "sourcesContent": ["import { tlenv } from '@tldraw/editor'\n\n// N.B. We rework these Windows placeholders down below.\nconst cmdKey = tlenv.isDarwin ? '\u2318' : '__CTRL__'\nconst ctrlKey = tlenv.isDarwin ? '\u2303' : '__CTRL__'\nconst altKey = tlenv.isDarwin ? '\u2325' : '__ALT__'\n\n/** @public */\nexport function kbd(str: string) {\n\tif (str === ',') return [',']\n\n\treturn (\n\t\tstr\n\t\t\t.split(',')[0]\n\t\t\t// If the string contains [[Tab]], we don't split these up\n\t\t\t// as they're meant to be atomic.\n\t\t\t.split(/(\\[\\[[^\\]]+\\]\\])/g)\n\t\t\t.map((s) =>\n\t\t\t\ts.startsWith('[[')\n\t\t\t\t\t? s.replace(/[[\\]]/g, '')\n\t\t\t\t\t: s\n\t\t\t\t\t\t\t.replace(/cmd\\+/g, cmdKey)\n\t\t\t\t\t\t\t.replace(/ctrl\\+/g, ctrlKey)\n\t\t\t\t\t\t\t.replace(/alt\\+/g, altKey)\n\t\t\t\t\t\t\t.replace(/shift\\+/g, '\u21E7')\n\t\t\t\t\t\t\t// Backwards compatibility with the old system.\n\t\t\t\t\t\t\t.replace(/\\$/g, cmdKey)\n\t\t\t\t\t\t\t.replace(/\\?/g, altKey)\n\t\t\t\t\t\t\t.replace(/!/g, '\u21E7')\n\t\t\t\t\t\t\t.match(/__CTRL__|__ALT__|./g) || []\n\t\t\t)\n\t\t\t.flat()\n\t\t\t.map((sub, index) => {\n\t\t\t\tif (sub[0] === '+') return []\n\n\t\t\t\tlet modifiedKey\n\t\t\t\tif (sub === '__CTRL__') {\n\t\t\t\t\tmodifiedKey = 'Ctrl'\n\t\t\t\t} else if (sub === '__ALT__') {\n\t\t\t\t\tmodifiedKey = 'Alt'\n\t\t\t\t} else {\n\t\t\t\t\tmodifiedKey = sub[0].toUpperCase() + sub.slice(1)\n\t\t\t\t}\n\t\t\t\treturn tlenv.isDarwin || !index ? modifiedKey : ['+', modifiedKey]\n\t\t\t})\n\t\t\t.flat()\n\t)\n}\n\n/** @public */\nexport function kbdStr(str: string) {\n\treturn '\u2014 ' + kbd(str).join('\u2009')\n}\n"],
5
- "mappings": "AAAA,SAAS,aAAa;AAGtB,MAAM,SAAS,MAAM,WAAW,WAAM;AACtC,MAAM,UAAU,MAAM,WAAW,WAAM;AACvC,MAAM,SAAS,MAAM,WAAW,WAAM;AAG/B,SAAS,IAAI,KAAa;AAChC,MAAI,QAAQ,IAAK,QAAO,CAAC,GAAG;AAE5B,SACC,IACE,MAAM,GAAG,EAAE,CAAC,EAGZ,MAAM,mBAAmB,EACzB;AAAA,IAAI,CAAC,MACL,EAAE,WAAW,IAAI,IACd,EAAE,QAAQ,UAAU,EAAE,IACtB,EACC,QAAQ,UAAU,MAAM,EACxB,QAAQ,WAAW,OAAO,EAC1B,QAAQ,UAAU,MAAM,EACxB,QAAQ,YAAY,QAAG,EAEvB,QAAQ,OAAO,MAAM,EACrB,QAAQ,OAAO,MAAM,EACrB,QAAQ,MAAM,QAAG,EACjB,MAAM,qBAAqB,KAAK,CAAC;AAAA,EACtC,EACC,KAAK,EACL,IAAI,CAAC,KAAK,UAAU;AACpB,QAAI,IAAI,CAAC,MAAM,IAAK,QAAO,CAAC;AAE5B,QAAI;AACJ,QAAI,QAAQ,YAAY;AACvB,oBAAc;AAAA,IACf,WAAW,QAAQ,WAAW;AAC7B,oBAAc;AAAA,IACf,OAAO;AACN,oBAAc,IAAI,CAAC,EAAE,YAAY,IAAI,IAAI,MAAM,CAAC;AAAA,IACjD;AACA,WAAO,MAAM,YAAY,CAAC,QAAQ,cAAc,CAAC,KAAK,WAAW;AAAA,EAClE,CAAC,EACA,KAAK;AAET;AAGO,SAAS,OAAO,KAAa;AACnC,SAAO,YAAO,IAAI,GAAG,EAAE,KAAK,QAAG;AAChC;",
4
+ "sourcesContent": ["import { tlenv } from '@tldraw/editor'\n\n/*!\n * `splitKbd` is adapted from hotkeys-js.\n *\n * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE\n * Copyright (c) 2015-present, Kenny Wong\n * Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)\n * Source: https://github.com/jaywcjlove/hotkeys-js\n */\n\n// N.B. We rework these Windows placeholders down below.\nconst cmdKey = tlenv.isDarwin ? '\u2318' : '__CTRL__'\nconst ctrlKey = tlenv.isDarwin ? '\u2303' : '__CTRL__'\nconst altKey = tlenv.isDarwin ? '\u2325' : '__ALT__'\n\n/** @public */\nexport function kbd(str: string) {\n\treturn (\n\t\t(splitKbd(str)[0] ?? '')\n\t\t\t// If the string contains [[Tab]], we don't split these up\n\t\t\t// as they're meant to be atomic.\n\t\t\t.split(/(\\[\\[[^\\]]+\\]\\])/g)\n\t\t\t.map((s) =>\n\t\t\t\ts.startsWith('[[')\n\t\t\t\t\t? s.replace(/[[\\]]/g, '')\n\t\t\t\t\t: s\n\t\t\t\t\t\t\t.replace(/cmd\\+/g, cmdKey)\n\t\t\t\t\t\t\t.replace(/ctrl\\+/g, ctrlKey)\n\t\t\t\t\t\t\t.replace(/alt\\+/g, altKey)\n\t\t\t\t\t\t\t.replace(/shift\\+/g, '\u21E7')\n\t\t\t\t\t\t\t// Backwards compatibility with the old system.\n\t\t\t\t\t\t\t.replace(/\\$/g, cmdKey)\n\t\t\t\t\t\t\t.replace(/\\?/g, altKey)\n\t\t\t\t\t\t\t.replace(/!/g, '\u21E7')\n\t\t\t\t\t\t\t.match(/__CTRL__|__ALT__|./g) || []\n\t\t\t)\n\t\t\t.flat()\n\t\t\t.map((sub, index) => {\n\t\t\t\tif (sub[0] === '+') return []\n\n\t\t\t\tlet modifiedKey\n\t\t\t\tif (sub === '__CTRL__') {\n\t\t\t\t\tmodifiedKey = 'Ctrl'\n\t\t\t\t} else if (sub === '__ALT__') {\n\t\t\t\t\tmodifiedKey = 'Alt'\n\t\t\t\t} else {\n\t\t\t\t\tmodifiedKey = sub[0].toUpperCase() + sub.slice(1)\n\t\t\t\t}\n\t\t\t\treturn tlenv.isDarwin || !index ? modifiedKey : ['+', modifiedKey]\n\t\t\t})\n\t\t\t.flat()\n\t)\n}\n\n// Split a kbd string on commas, treating an empty entry produced by \"x,,\" as a literal\n// trailing comma on the previous entry.\n/** @internal */\nexport function splitKbd(key: string) {\n\tif (!key) return []\n\tconst keys = key.split(',')\n\tlet index = keys.lastIndexOf('')\n\n\tfor (; index >= 0; ) {\n\t\tkeys[index - 1] += ','\n\t\tkeys.splice(index, 1)\n\t\tindex = keys.lastIndexOf('')\n\t}\n\n\treturn keys\n}\n\n/** @public */\nexport function kbdStr(str: string) {\n\treturn '\u2014 ' + kbd(str).join('\u2009')\n}\n"],
5
+ "mappings": "AAAA,SAAS,aAAa;AAEtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUA,MAAM,SAAS,MAAM,WAAW,WAAM;AACtC,MAAM,UAAU,MAAM,WAAW,WAAM;AACvC,MAAM,SAAS,MAAM,WAAW,WAAM;AAG/B,SAAS,IAAI,KAAa;AAChC,UACE,SAAS,GAAG,EAAE,CAAC,KAAK,IAGnB,MAAM,mBAAmB,EACzB;AAAA,IAAI,CAAC,MACL,EAAE,WAAW,IAAI,IACd,EAAE,QAAQ,UAAU,EAAE,IACtB,EACC,QAAQ,UAAU,MAAM,EACxB,QAAQ,WAAW,OAAO,EAC1B,QAAQ,UAAU,MAAM,EACxB,QAAQ,YAAY,QAAG,EAEvB,QAAQ,OAAO,MAAM,EACrB,QAAQ,OAAO,MAAM,EACrB,QAAQ,MAAM,QAAG,EACjB,MAAM,qBAAqB,KAAK,CAAC;AAAA,EACtC,EACC,KAAK,EACL,IAAI,CAAC,KAAK,UAAU;AACpB,QAAI,IAAI,CAAC,MAAM,IAAK,QAAO,CAAC;AAE5B,QAAI;AACJ,QAAI,QAAQ,YAAY;AACvB,oBAAc;AAAA,IACf,WAAW,QAAQ,WAAW;AAC7B,oBAAc;AAAA,IACf,OAAO;AACN,oBAAc,IAAI,CAAC,EAAE,YAAY,IAAI,IAAI,MAAM,CAAC;AAAA,IACjD;AACA,WAAO,MAAM,YAAY,CAAC,QAAQ,cAAc,CAAC,KAAK,WAAW;AAAA,EAClE,CAAC,EACA,KAAK;AAET;AAKO,SAAS,SAAS,KAAa;AACrC,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,QAAM,OAAO,IAAI,MAAM,GAAG;AAC1B,MAAI,QAAQ,KAAK,YAAY,EAAE;AAE/B,SAAO,SAAS,KAAK;AACpB,SAAK,QAAQ,CAAC,KAAK;AACnB,SAAK,OAAO,OAAO,CAAC;AACpB,YAAQ,KAAK,YAAY,EAAE;AAAA,EAC5B;AAEA,SAAO;AACR;AAGO,SAAS,OAAO,KAAa;AACnC,SAAO,YAAO,IAAI,GAAG,EAAE,KAAK,QAAG;AAChC;",
6
6
  "names": []
7
7
  }
@@ -1,8 +1,8 @@
1
- const version = "5.3.1";
1
+ const version = "5.4.0-canary.042da660fa66";
2
2
  const publishDates = {
3
3
  major: "2026-05-06T16:28:18.473Z",
4
- minor: "2026-08-05T11:39:54.978Z",
5
- patch: "2026-08-14T10:46:20.010Z"
4
+ minor: "2026-08-19T12:47:12.111Z",
5
+ patch: "2026-08-19T12:47:12.111Z"
6
6
  };
7
7
  export {
8
8
  publishDates,
@@ -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 = '5.3.1'\nexport const publishDates = {\n\tmajor: '2026-05-06T16:28:18.473Z',\n\tminor: '2026-08-05T11:39:54.978Z',\n\tpatch: '2026-08-14T10:46:20.010Z',\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 = '5.4.0-canary.042da660fa66'\nexport const publishDates = {\n\tmajor: '2026-05-06T16:28:18.473Z',\n\tminor: '2026-08-19T12:47:12.111Z',\n\tpatch: '2026-08-19T12:47:12.111Z',\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,13 +1,19 @@
1
1
  {
2
2
  "name": "tldraw",
3
3
  "description": "A tiny little drawing editor.",
4
- "version": "5.3.1",
4
+ "version": "5.4.0-canary.042da660fa66",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
8
8
  },
9
9
  "homepage": "https://tldraw.dev",
10
10
  "license": "SEE LICENSE IN LICENSE.md",
11
+ "tldraw_product": {
12
+ "stableId": "tldraw:sdk-core",
13
+ "name": "SDK",
14
+ "type": "product",
15
+ "premium": false
16
+ },
11
17
  "repository": {
12
18
  "type": "git",
13
19
  "url": "https://github.com/tldraw/tldraw"
@@ -63,11 +69,11 @@
63
69
  "@tiptap/pm": "^3.12.1",
64
70
  "@tiptap/react": "^3.12.1",
65
71
  "@tiptap/starter-kit": "^3.12.1",
66
- "@tldraw/driver": "5.3.1",
67
- "@tldraw/editor": "5.3.1",
68
- "@tldraw/state": "5.3.1",
69
- "@tldraw/store": "5.3.1",
70
- "@tldraw/tlschema": "5.3.1",
72
+ "@tldraw/driver": "5.4.0-canary.042da660fa66",
73
+ "@tldraw/editor": "5.4.0-canary.042da660fa66",
74
+ "@tldraw/state": "5.4.0-canary.042da660fa66",
75
+ "@tldraw/store": "5.4.0-canary.042da660fa66",
76
+ "@tldraw/tlschema": "5.4.0-canary.042da660fa66",
71
77
  "classnames": "^2.5.1",
72
78
  "idb": "^7.1.1",
73
79
  "lz-string": "^1.5.0",
@@ -278,10 +278,10 @@ export async function defaultHandleExternalUrlAsset(
278
278
  description:
279
279
  doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
280
280
  }
281
- if (!meta.image.startsWith('http')) {
281
+ if (meta.image && !meta.image.startsWith('http')) {
282
282
  meta.image = new URL(meta.image, url).href
283
283
  }
284
- if (!meta.favicon.startsWith('http')) {
284
+ if (meta.favicon && !meta.favicon.startsWith('http')) {
285
285
  meta.favicon = new URL(meta.favicon, url).href
286
286
  }
287
287
  } catch (error) {
@@ -109,6 +109,25 @@ describe('Close threshold with zoom', () => {
109
109
  expect(shape.props.isClosed).toBe(true)
110
110
  })
111
111
 
112
+ it('Closes a small shape with densely sampled points', () => {
113
+ // Match the point density of the reported shape that exposed the length calculation bug.
114
+ const samples = 476
115
+ const radius = 20
116
+ const center = { x: 100, y: 100 }
117
+
118
+ editor.setCurrentTool('draw')
119
+ editor.pointerDown(center.x + radius, center.y)
120
+ for (let i = 1; i <= samples; i++) {
121
+ const angle = (i / samples) * Math.PI * 2
122
+ editor.pointerMove(center.x + Math.cos(angle) * radius, center.y + Math.sin(angle) * radius)
123
+ }
124
+ editor.pointerUp()
125
+
126
+ const shapes = editor.getCurrentPageShapes() as TLDrawShape[]
127
+ expect(shapes).toHaveLength(1)
128
+ expect(shapes[0].props.isClosed).toBe(true)
129
+ })
130
+
112
131
  it('Does not close a shape when the endpoint is far from the start', () => {
113
132
  const shape = drawNearlyClosedShape(50)
114
133
  expect(shape.props.isClosed).toBe(false)
@@ -733,11 +733,11 @@ export class Drawing extends StateNode {
733
733
  for (let j = 0; j < segments.length; j++) {
734
734
  const points = b64Vecs.decodePoints(segments[j].path, segments[j].dim)
735
735
  for (let i = 0; i < points.length - 1; i++) {
736
- length += Vec.Dist2(points[i], points[i + 1])
736
+ length += Vec.Dist(points[i], points[i + 1])
737
737
  }
738
738
  }
739
739
 
740
- return Math.sqrt(length)
740
+ return length
741
741
  }
742
742
 
743
743
  override onPointerUp() {
@@ -54,7 +54,16 @@ export function getFrameHeadingSize(
54
54
  const frameTitle = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203)
55
55
  const spans = editor.textMeasure.measureTextSpans(frameTitle, opts)
56
56
  const firstSpan = spans[0]
57
- const lastSpan = last(spans)!
57
+ const lastSpan = last(spans)
58
+
59
+ if (!firstSpan || !lastSpan) {
60
+ // measureTextSpans can return no spans when the text can't be laid
61
+ // out yet — e.g. the measurement element isn't attached/visible
62
+ // during the initial editor mount, or every grapheme lacks a layout
63
+ // rect. Fall back to a zero-width heading and don't cache it, so the
64
+ // heading is remeasured once measurement is possible.
65
+ return new Box(0, -opts.height, 0, opts.height)
66
+ }
58
67
 
59
68
  width = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x
60
69
  measurementWeakmap.set(shape.props, width)
@@ -175,14 +175,16 @@ const VideoShape = memo(function VideoShape({ shape }: { shape: TLVideoShape })
175
175
  }
176
176
  }, [isEditing, isLoaded])
177
177
 
178
+ const hasVideo = !!asset?.props.src && !!url && isLoaded
179
+
178
180
  return (
179
181
  <>
180
182
  <HTMLContainer
181
183
  id={shape.id}
182
184
  style={{
183
185
  color: 'var(--tl-color-text-3)',
184
- backgroundColor: asset ? 'transparent' : 'var(--tl-color-low)',
185
- border: asset ? 'none' : '1px solid var(--tl-color-low-border)',
186
+ backgroundColor: hasVideo ? 'transparent' : 'var(--tl-color-low)',
187
+ border: hasVideo ? 'none' : '1px solid var(--tl-color-low-border)',
186
188
  }}
187
189
  >
188
190
  <div className="tl-counter-scaled">
@@ -75,7 +75,6 @@ interface TldrawUiContentProps {
75
75
  const TldrawUiInner = React.memo(function TldrawUiInner({
76
76
  children,
77
77
  hideUi,
78
- ...rest
79
78
  }: TldrawUiContentProps & { children: ReactNode }) {
80
79
  // The hideUi prop should prevent the UI from mounting.
81
80
  // If we ever need want the UI to mount and preserve state, then
@@ -89,7 +88,7 @@ const TldrawUiInner = React.memo(function TldrawUiInner({
89
88
  return (
90
89
  <>
91
90
  {children}
92
- {hideUi ? null : <TldrawUiContent {...rest} />}
91
+ {hideUi ? null : <TldrawUiContent />}
93
92
  </>
94
93
  )
95
94
  })
@@ -1,5 +1,5 @@
1
- import { useEditor, usePassThroughWheelEvents, useValue } from '@tldraw/editor'
2
- import { ReactNode, memo, useRef } from 'react'
1
+ import { useEditor, useValue } from '@tldraw/editor'
2
+ import { ReactNode, memo } from 'react'
3
3
  import { PORTRAIT_BREAKPOINT } from '../../constants'
4
4
  import { useBreakpoint } from '../../context/breakpoints'
5
5
  import { useReadonly } from '../../hooks/useReadonly'
@@ -29,9 +29,6 @@ export const DefaultActionsMenu = memo(function DefaultActionsMenu({
29
29
  const isReadonlyMode = useReadonly()
30
30
  const { orientation } = useTldrawUiOrientation()
31
31
 
32
- const ref = useRef<HTMLDivElement>(null)
33
- usePassThroughWheelEvents(ref)
34
-
35
32
  const editor = useEditor()
36
33
  const isInAcceptableReadonlyState = useValue(
37
34
  'should display quick actions when in readonly',
@@ -71,7 +68,6 @@ export const DefaultActionsMenu = memo(function DefaultActionsMenu({
71
68
  sideOffset={6}
72
69
  >
73
70
  <TldrawUiToolbar
74
- ref={ref}
75
71
  label={msg('actions-menu.title')}
76
72
  className="tlui-actions-menu"
77
73
  data-testid="actions-menu.content"
@@ -29,7 +29,11 @@ export function DefaultContextMenuContent() {
29
29
 
30
30
  return (
31
31
  <>
32
- {showCollaborationUi && <CursorChatItem />}
32
+ {showCollaborationUi && (
33
+ <TldrawUiMenuGroup id="cursor-chat">
34
+ <CursorChatItem />
35
+ </TldrawUiMenuGroup>
36
+ )}
33
37
  <TldrawUiMenuGroup id="modify">
34
38
  <EditMenuSubmenu />
35
39
  <ArrangeMenuSubmenu />
@@ -1,6 +1,6 @@
1
1
  import { useContainer, useValue } from '@tldraw/editor'
2
2
  import { Dialog as _Dialog } from 'radix-ui'
3
- import { memo, useCallback } from 'react'
3
+ import { memo, useCallback, useRef } from 'react'
4
4
  import { TLUiDialog, useDialogs } from '../context/dialogs'
5
5
  import { useDirection } from '../hooks/useTranslation/useTranslation'
6
6
 
@@ -10,6 +10,7 @@ const TldrawUiDialog = ({ id, component: ModalContent, preventBackgroundClose }:
10
10
 
11
11
  const container = useContainer()
12
12
  const dir = useDirection()
13
+ const contentRef = useRef<HTMLDivElement>(null)
13
14
 
14
15
  const handleOpenChange = useCallback(
15
16
  (isOpen: boolean) => {
@@ -29,6 +30,7 @@ const TldrawUiDialog = ({ id, component: ModalContent, preventBackgroundClose }:
29
30
  <_Dialog.Overlay dir={dir} className="tlui-dialog__overlay" />
30
31
  <div dir={dir} className="tlui-dialog__positioner">
31
32
  <_Dialog.Content
33
+ ref={contentRef}
32
34
  dir={dir}
33
35
  className="tlui-dialog__content"
34
36
  aria-describedby={undefined}
@@ -39,6 +41,20 @@ const TldrawUiDialog = ({ id, component: ModalContent, preventBackgroundClose }:
39
41
  // both a pointer press and a focus shift outside the dialog; opt out of both
40
42
  // when the dialog asks to stay open on background interactions (escape still
41
43
  // closes it).
44
+
45
+ // A touch press is checked against the layers at the following click rather
46
+ // than at the press itself, so a press that closes a dialog stacked on top of
47
+ // this one arrives here once this one is topmost, and reads as a press
48
+ // outside it. A press inside another dialog is never outside this one, and
49
+ // stays so after that dialog unmounts and takes the press's target with it.
50
+ const target = e.detail.originalEvent.target
51
+ const pressedDialog =
52
+ target instanceof Element ? target.closest('.tlui-dialog__content') : null
53
+ if (pressedDialog && pressedDialog !== contentRef.current) {
54
+ e.preventDefault()
55
+ return
56
+ }
57
+
42
58
  if (preventBackgroundClose) {
43
59
  e.preventDefault()
44
60
  }
@@ -4,7 +4,6 @@ import { useUiEvents } from '../../context/events'
4
4
  import { useTranslation } from '../../hooks/useTranslation/useTranslation'
5
5
  import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
6
6
  import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
7
- import { TldrawUiRow } from '../primitives/layout'
8
7
  import { TldrawUiIcon } from '../primitives/TldrawUiIcon'
9
8
 
10
9
  /** @public */
@@ -38,7 +37,7 @@ export const DefaultPeopleMenuItem = track(function DefaultPeopleMenuItem({
38
37
  if (!presence) return null
39
38
 
40
39
  return (
41
- <TldrawUiRow
40
+ <div
42
41
  className="tlui-people-menu__item"
43
42
  data-follow={youAreFollowingThem || theyAreFollowingYou}
44
43
  >
@@ -70,6 +69,6 @@ export const DefaultPeopleMenuItem = track(function DefaultPeopleMenuItem({
70
69
  icon={theyAreFollowingYou ? 'leading' : youAreFollowingThem ? 'following' : 'follow'}
71
70
  />
72
71
  </TldrawUiButton>
73
- </TldrawUiRow>
72
+ </div>
74
73
  )
75
74
  })
@@ -1031,7 +1031,6 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
1031
1031
  if (!canApplySelectionAction()) return
1032
1032
  if (mustGoBackToSelectToolFirst()) return
1033
1033
 
1034
- editor.markHistoryStoppingPoint('cut')
1035
1034
  helpers.cut(source)
1036
1035
  },
1037
1036
  },
@@ -803,6 +803,7 @@ export function useMenuClipboardEvents() {
803
803
 
804
804
  const didCopy = await handleNativeOrMenuCopy(editor, { operation: 'cut', source: 'menu' })
805
805
  if (didCopy) {
806
+ editor.markHistoryStoppingPoint('cut')
806
807
  editor.deleteShapes(editor.getSelectedShapeIds())
807
808
  trackEvent('cut', { source })
808
809
  }
@@ -900,6 +901,7 @@ export function useNativeClipboardEvents() {
900
901
 
901
902
  const didCopy = await handleNativeOrMenuCopy(editor, { operation: 'cut', source: 'native' })
902
903
  if (didCopy) {
904
+ editor.markHistoryStoppingPoint('cut')
903
905
  editor.deleteShapes(editor.getSelectedShapeIds())
904
906
  trackEvent('cut', { source: 'kbd' })
905
907
  }
@@ -1,13 +1,15 @@
1
- import { useLicenseContext, useLicenseFeatureFlag } from '@tldraw/editor'
1
+ import { useLicenseFeatureFlag, useMaybeLicenseManager } from '@tldraw/editor'
2
2
 
3
3
  /**
4
4
  * Whether the commenting feature is licensed for this editor. The default comment quick action and
5
5
  * its keyboard shortcut are hidden/disabled unless this is true. Enabled in development; in
6
6
  * production it requires a license that includes the commenting feature (or the collaboration
7
- * umbrella that grants it). Reactive: re-reads when license validation resolves.
7
+ * umbrella that grants it). Reactive: re-reads when license validation resolves. Works outside
8
+ * `<Tldraw />` too: UI mounted via `EditorProvider` resolves the license through the editor, and
9
+ * with no editor at all this is `false`.
8
10
  *
9
11
  * @internal
10
12
  */
11
13
  export function useCommentingEnabled(): boolean {
12
- return useLicenseFeatureFlag(useLicenseContext(), 'commenting')
14
+ return useLicenseFeatureFlag(useMaybeLicenseManager(), 'commenting')
13
15
  }
@@ -49,34 +49,70 @@ export async function flattenShapesToImages(
49
49
  }
50
50
  })
51
51
 
52
- for (let i = 0; i < expandedBounds.length; i++) {
53
- const item = expandedBounds[i]
54
- if (i === 0) {
55
- groups[0] = {
56
- shapes: [item.shape],
57
- bounds: item.bounds,
58
- }
59
- continue
52
+ // Cluster shapes by connected components: any two shapes whose expanded
53
+ // bounds intersect end up in the same group. Using union-find here
54
+ // instead of a greedy first-fit pass means the result is independent of
55
+ // the order shapes appear in `shapeIds`, and a shape that bridges two
56
+ // otherwise-disjoint clusters merges them in one pass.
57
+ const parents = expandedBounds.map((_, i) => i)
58
+ const find = (i: number): number => {
59
+ let root = i
60
+ while (parents[root] !== root) root = parents[root]
61
+ while (parents[i] !== root) {
62
+ const next = parents[i]
63
+ parents[i] = root
64
+ i = next
60
65
  }
66
+ return root
67
+ }
68
+ const union = (i: number, j: number) => {
69
+ const ri = find(i)
70
+ const rj = find(j)
71
+ if (ri !== rj) parents[ri] = rj
72
+ }
61
73
 
62
- let didLand = false
74
+ // Broad-phase sweep: sort by minX and, for each box, only compare against
75
+ // later boxes until one starts past the current box's maxX. Boxes that
76
+ // don't overlap on the x-axis can't intersect, so this skips the bulk of
77
+ // the pairwise checks for spread-out selections while still finding every
78
+ // intersecting pair (Box.includes is symmetric, so each pair is tested once).
79
+ const order = expandedBounds
80
+ .map((_, i) => i)
81
+ .sort((a, b) => {
82
+ return expandedBounds[a].bounds.minX - expandedBounds[b].bounds.minX
83
+ })
63
84
 
64
- for (const group of groups) {
65
- if (group.bounds.includes(item.bounds)) {
66
- group.shapes.push(item.shape)
67
- group.bounds.expand(item.bounds)
68
- didLand = true
69
- break
85
+ for (let a = 0; a < order.length; a++) {
86
+ const i = order[a]
87
+ const boundsI = expandedBounds[i].bounds
88
+ for (let b = a + 1; b < order.length; b++) {
89
+ const j = order[b]
90
+ // sorted by minX, so once a box starts past i's maxX no later box can overlap i
91
+ if (expandedBounds[j].bounds.minX > boundsI.maxX) break
92
+ if (find(i) === find(j)) continue
93
+ if (boundsI.includes(expandedBounds[j].bounds)) {
94
+ union(i, j)
70
95
  }
71
96
  }
97
+ }
72
98
 
73
- if (!didLand) {
74
- groups.push({
99
+ const groupsByRoot = new Map<number, { shapes: TLShape[]; bounds: Box }>()
100
+ for (let i = 0; i < expandedBounds.length; i++) {
101
+ const root = find(i)
102
+ const item = expandedBounds[i]
103
+ const existing = groupsByRoot.get(root)
104
+ if (existing) {
105
+ existing.shapes.push(item.shape)
106
+ existing.bounds.expand(item.bounds)
107
+ } else {
108
+ groupsByRoot.set(root, {
75
109
  shapes: [item.shape],
76
- bounds: item.bounds,
110
+ bounds: item.bounds.clone(),
77
111
  })
78
112
  }
79
113
  }
114
+
115
+ groups.push(...groupsByRoot.values())
80
116
  } else {
81
117
  const bounds = Box.Common(shapes.map((shape) => editor.getShapeMaskedPageBounds(shape)!))
82
118
  groups.push({
@@ -123,11 +159,8 @@ export async function flattenShapesToImages(
123
159
 
124
160
  let index: IndexKey = 'a1' as IndexKey
125
161
  for (const shape of shapes) {
126
- if (shape.parentId === commonAncestorId) {
127
- if (shape.index > index) {
128
- index = shape.index
129
- }
130
- break
162
+ if (shape.parentId === commonAncestorId && shape.index > index) {
163
+ index = shape.index
131
164
  }
132
165
  }
133
166