tldraw 5.3.2 → 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 (93) hide show
  1. package/DOCS.md +2822 -3224
  2. package/RELEASE_NOTES.md +11 -39
  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/useFlatten.js +42 -23
  28. package/dist-cjs/lib/ui/hooks/useFlatten.js.map +2 -2
  29. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js +5 -17
  30. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js.map +2 -2
  31. package/dist-cjs/lib/ui/kbd-utils.js +22 -3
  32. package/dist-cjs/lib/ui/kbd-utils.js.map +2 -2
  33. package/dist-cjs/lib/ui/version.js +3 -3
  34. package/dist-cjs/lib/ui/version.js.map +1 -1
  35. package/dist-esm/index.d.mts +264 -275
  36. package/dist-esm/index.mjs +1 -1
  37. package/dist-esm/lib/defaultExternalContentHandlers.mjs +2 -2
  38. package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
  39. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs +2 -2
  40. package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs.map +2 -2
  41. package/dist-esm/lib/shapes/frame/frameHelpers.mjs +3 -0
  42. package/dist-esm/lib/shapes/frame/frameHelpers.mjs.map +2 -2
  43. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +3 -2
  44. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
  45. package/dist-esm/lib/ui/TldrawUi.mjs +2 -3
  46. package/dist-esm/lib/ui/TldrawUi.mjs.map +2 -2
  47. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs +2 -5
  48. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs.map +2 -2
  49. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs +1 -1
  50. package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs.map +2 -2
  51. package/dist-esm/lib/ui/components/Dialogs.mjs +9 -1
  52. package/dist-esm/lib/ui/components/Dialogs.mjs.map +2 -2
  53. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs +1 -2
  54. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs.map +2 -2
  55. package/dist-esm/lib/ui/context/actions.mjs +0 -1
  56. package/dist-esm/lib/ui/context/actions.mjs.map +2 -2
  57. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +2 -0
  58. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +2 -2
  59. package/dist-esm/lib/ui/hooks/useFlatten.mjs +42 -23
  60. package/dist-esm/lib/ui/hooks/useFlatten.mjs.map +2 -2
  61. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs +5 -17
  62. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs.map +2 -2
  63. package/dist-esm/lib/ui/kbd-utils.mjs +22 -3
  64. package/dist-esm/lib/ui/kbd-utils.mjs.map +2 -2
  65. package/dist-esm/lib/ui/version.mjs +3 -3
  66. package/dist-esm/lib/ui/version.mjs.map +1 -1
  67. package/package.json +6 -6
  68. package/src/lib/defaultExternalContentHandlers.ts +2 -2
  69. package/src/lib/shapes/draw/DrawShapeTool.test.ts +19 -0
  70. package/src/lib/shapes/draw/toolStates/Drawing.ts +2 -2
  71. package/src/lib/shapes/frame/frameHelpers.ts +10 -1
  72. package/src/lib/shapes/video/VideoShapeUtil.tsx +4 -2
  73. package/src/lib/ui/TldrawUi.tsx +1 -2
  74. package/src/lib/ui/components/ActionsMenu/DefaultActionsMenu.tsx +2 -6
  75. package/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx +5 -1
  76. package/src/lib/ui/components/Dialogs.tsx +17 -1
  77. package/src/lib/ui/components/SharePanel/DefaultPeopleMenuItem.tsx +2 -3
  78. package/src/lib/ui/context/actions.tsx +0 -1
  79. package/src/lib/ui/hooks/useClipboardEvents.ts +2 -0
  80. package/src/lib/ui/hooks/useFlatten.ts +56 -23
  81. package/src/lib/ui/hooks/useKeyboardShortcuts.ts +5 -23
  82. package/src/lib/ui/kbd-utils.test.ts +11 -0
  83. package/src/lib/ui/kbd-utils.ts +27 -4
  84. package/src/lib/ui/version.ts +3 -3
  85. package/src/lib/ui.css +7 -16
  86. package/src/test/TldrawEditor.test.tsx +3 -0
  87. package/src/test/VideoShapeUtil.test.tsx +126 -0
  88. package/src/test/bookmark-shapes.test.ts +110 -0
  89. package/src/test/commands/alignShapes.test.tsx +19 -10
  90. package/src/test/commands/clipboard.test.ts +36 -1
  91. package/src/test/flatten.test.ts +135 -0
  92. package/src/test/modifiers.test.ts +104 -0
  93. package/tldraw.css +7 -16
@@ -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.2";
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-18T09:59:40.172Z"
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.2'\nexport const publishDates = {\n\tmajor: '2026-05-06T16:28:18.473Z',\n\tminor: '2026-08-05T11:39:54.978Z',\n\tpatch: '2026-08-18T09:59:40.172Z',\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,7 +1,7 @@
1
1
  {
2
2
  "name": "tldraw",
3
3
  "description": "A tiny little drawing editor.",
4
- "version": "5.3.2",
4
+ "version": "5.4.0-canary.042da660fa66",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -69,11 +69,11 @@
69
69
  "@tiptap/pm": "^3.12.1",
70
70
  "@tiptap/react": "^3.12.1",
71
71
  "@tiptap/starter-kit": "^3.12.1",
72
- "@tldraw/driver": "5.3.2",
73
- "@tldraw/editor": "5.3.2",
74
- "@tldraw/state": "5.3.2",
75
- "@tldraw/store": "5.3.2",
76
- "@tldraw/tlschema": "5.3.2",
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",
77
77
  "classnames": "^2.5.1",
78
78
  "idb": "^7.1.1",
79
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
  }
@@ -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
 
@@ -1,7 +1,6 @@
1
1
  /*!
2
- * The kbd-string splitter (`getKeys`) and the form-input filter pattern in `shouldSkipEvent`
3
- * (including its list of non-text INPUT types) are adapted from hotkeys-js, which this hook
4
- * previously depended on.
2
+ * The form-input filter pattern in `shouldSkipEvent` (including its list of non-text INPUT
3
+ * types) is adapted from hotkeys-js, which this hook previously depended on.
5
4
  *
6
5
  * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
7
6
  * Copyright (c) 2015-present, Kenny Wong
@@ -19,6 +18,7 @@ import {
19
18
  } from '@tldraw/editor'
20
19
  import { useEffect } from 'react'
21
20
  import { useActions } from '../context/actions'
21
+ import { splitKbd } from '../kbd-utils'
22
22
  import { useCommentingEnabled } from './useCommentingEnabled'
23
23
  import { useReadonly } from './useReadonly'
24
24
  import { useTools } from './useTools'
@@ -360,7 +360,7 @@ const PHYSICAL_KEY_MAP: Record<string, string> = {
360
360
  */
361
361
  export function parseKbd(kbd: string): ParsedKbd[] {
362
362
  const out: ParsedKbd[] = []
363
- for (const shortcut of getKeys(kbd)) {
363
+ for (const shortcut of splitKbd(kbd.replace(/\s/g, ''))) {
364
364
  const parsed = parseShortcut(shortcut)
365
365
  if (parsed) out.push(parsed)
366
366
  }
@@ -472,7 +472,7 @@ function shouldSkipEvent(e: KeyboardEvent): boolean {
472
472
  * @internal
473
473
  */
474
474
  export function getHotkeysStringFromKbd(kbd: string) {
475
- return getKeys(kbd)
475
+ return splitKbd(kbd.replace(/\s/g, ''))
476
476
  .map((kbd) => {
477
477
  let str = ''
478
478
 
@@ -505,21 +505,3 @@ export function getHotkeysStringFromKbd(kbd: string) {
505
505
  })
506
506
  .join(',')
507
507
  }
508
-
509
- // Split a kbd string on commas, treating an empty entry produced by "x,," as a literal
510
- // trailing comma on the previous entry. Verbatim port of the splitter from hotkeys-js
511
- // (MIT, see top-of-file attribution).
512
- function getKeys(key: string) {
513
- if (typeof key !== 'string') key = ''
514
- key = key.replace(/\s/g, '')
515
- const keys = key.split(',')
516
- let index = keys.lastIndexOf('')
517
-
518
- for (; index >= 0; ) {
519
- keys[index - 1] += ','
520
- keys.splice(index, 1)
521
- index = keys.lastIndexOf('')
522
- }
523
-
524
- return keys
525
- }
@@ -0,0 +1,11 @@
1
+ import { kbd } from './kbd-utils'
2
+
3
+ describe('kbd', () => {
4
+ it('displays a comma key with modifiers', () => {
5
+ expect(kbd('shift+,,shift+alt+,')).toEqual([...kbd('shift+.').slice(0, -1), ','])
6
+ })
7
+
8
+ it('preserves spaces in atomic key labels', () => {
9
+ expect(kbd('[[Page Up]]')).toEqual(['Page Up'])
10
+ })
11
+ })
@@ -1,5 +1,14 @@
1
1
  import { tlenv } from '@tldraw/editor'
2
2
 
3
+ /*!
4
+ * `splitKbd` is adapted from hotkeys-js.
5
+ *
6
+ * MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
7
+ * Copyright (c) 2015-present, Kenny Wong
8
+ * Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)
9
+ * Source: https://github.com/jaywcjlove/hotkeys-js
10
+ */
11
+
3
12
  // N.B. We rework these Windows placeholders down below.
4
13
  const cmdKey = tlenv.isDarwin ? '⌘' : '__CTRL__'
5
14
  const ctrlKey = tlenv.isDarwin ? '⌃' : '__CTRL__'
@@ -7,11 +16,8 @@ const altKey = tlenv.isDarwin ? '⌥' : '__ALT__'
7
16
 
8
17
  /** @public */
9
18
  export function kbd(str: string) {
10
- if (str === ',') return [',']
11
-
12
19
  return (
13
- str
14
- .split(',')[0]
20
+ (splitKbd(str)[0] ?? '')
15
21
  // If the string contains [[Tab]], we don't split these up
16
22
  // as they're meant to be atomic.
17
23
  .split(/(\[\[[^\]]+\]\])/g)
@@ -47,6 +53,23 @@ export function kbd(str: string) {
47
53
  )
48
54
  }
49
55
 
56
+ // Split a kbd string on commas, treating an empty entry produced by "x,," as a literal
57
+ // trailing comma on the previous entry.
58
+ /** @internal */
59
+ export function splitKbd(key: string) {
60
+ if (!key) return []
61
+ const keys = key.split(',')
62
+ let index = keys.lastIndexOf('')
63
+
64
+ for (; index >= 0; ) {
65
+ keys[index - 1] += ','
66
+ keys.splice(index, 1)
67
+ index = keys.lastIndexOf('')
68
+ }
69
+
70
+ return keys
71
+ }
72
+
50
73
  /** @public */
51
74
  export function kbdStr(str: string) {
52
75
  return '— ' + kbd(str).join(' ')
@@ -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 = '5.3.2'
4
+ export const version = '5.4.0-canary.042da660fa66'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-08-05T11:39:54.978Z',
8
- patch: '2026-08-18T09:59:40.172Z',
7
+ minor: '2026-08-19T12:47:12.111Z',
8
+ patch: '2026-08-19T12:47:12.111Z',
9
9
  }