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.
- package/DOCS.md +2822 -3224
- package/RELEASE_NOTES.md +11 -39
- package/dist-cjs/index.d.ts +264 -275
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/defaultExternalContentHandlers.js +2 -2
- package/dist-cjs/lib/defaultExternalContentHandlers.js.map +2 -2
- package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js +2 -2
- package/dist-cjs/lib/shapes/draw/toolStates/Drawing.js.map +2 -2
- package/dist-cjs/lib/shapes/frame/frameHelpers.js +3 -0
- package/dist-cjs/lib/shapes/frame/frameHelpers.js.map +2 -2
- package/dist-cjs/lib/shapes/video/VideoShapeUtil.js +3 -2
- package/dist-cjs/lib/shapes/video/VideoShapeUtil.js.map +2 -2
- package/dist-cjs/lib/ui/TldrawUi.js +2 -3
- package/dist-cjs/lib/ui/TldrawUi.js.map +2 -2
- package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js +0 -3
- package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenu.js.map +2 -2
- package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js +1 -1
- package/dist-cjs/lib/ui/components/ContextMenu/DefaultContextMenuContent.js.map +2 -2
- package/dist-cjs/lib/ui/components/Dialogs.js +8 -0
- package/dist-cjs/lib/ui/components/Dialogs.js.map +2 -2
- package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js +1 -2
- package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuItem.js.map +2 -2
- package/dist-cjs/lib/ui/context/actions.js +0 -1
- package/dist-cjs/lib/ui/context/actions.js.map +2 -2
- package/dist-cjs/lib/ui/hooks/useClipboardEvents.js +2 -0
- package/dist-cjs/lib/ui/hooks/useClipboardEvents.js.map +2 -2
- package/dist-cjs/lib/ui/hooks/useFlatten.js +42 -23
- package/dist-cjs/lib/ui/hooks/useFlatten.js.map +2 -2
- package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js +5 -17
- package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js.map +2 -2
- package/dist-cjs/lib/ui/kbd-utils.js +22 -3
- package/dist-cjs/lib/ui/kbd-utils.js.map +2 -2
- package/dist-cjs/lib/ui/version.js +3 -3
- package/dist-cjs/lib/ui/version.js.map +1 -1
- package/dist-esm/index.d.mts +264 -275
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/defaultExternalContentHandlers.mjs +2 -2
- package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
- package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs +2 -2
- package/dist-esm/lib/shapes/draw/toolStates/Drawing.mjs.map +2 -2
- package/dist-esm/lib/shapes/frame/frameHelpers.mjs +3 -0
- package/dist-esm/lib/shapes/frame/frameHelpers.mjs.map +2 -2
- package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +3 -2
- package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/ui/TldrawUi.mjs +2 -3
- package/dist-esm/lib/ui/TldrawUi.mjs.map +2 -2
- package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs +2 -5
- package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenu.mjs.map +2 -2
- package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs +1 -1
- package/dist-esm/lib/ui/components/ContextMenu/DefaultContextMenuContent.mjs.map +2 -2
- package/dist-esm/lib/ui/components/Dialogs.mjs +9 -1
- package/dist-esm/lib/ui/components/Dialogs.mjs.map +2 -2
- package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs +1 -2
- package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuItem.mjs.map +2 -2
- package/dist-esm/lib/ui/context/actions.mjs +0 -1
- package/dist-esm/lib/ui/context/actions.mjs.map +2 -2
- package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +2 -0
- package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +2 -2
- package/dist-esm/lib/ui/hooks/useFlatten.mjs +42 -23
- package/dist-esm/lib/ui/hooks/useFlatten.mjs.map +2 -2
- package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs +5 -17
- package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs.map +2 -2
- package/dist-esm/lib/ui/kbd-utils.mjs +22 -3
- package/dist-esm/lib/ui/kbd-utils.mjs.map +2 -2
- package/dist-esm/lib/ui/version.mjs +3 -3
- package/dist-esm/lib/ui/version.mjs.map +1 -1
- package/package.json +6 -6
- package/src/lib/defaultExternalContentHandlers.ts +2 -2
- package/src/lib/shapes/draw/DrawShapeTool.test.ts +19 -0
- package/src/lib/shapes/draw/toolStates/Drawing.ts +2 -2
- package/src/lib/shapes/frame/frameHelpers.ts +10 -1
- package/src/lib/shapes/video/VideoShapeUtil.tsx +4 -2
- package/src/lib/ui/TldrawUi.tsx +1 -2
- package/src/lib/ui/components/ActionsMenu/DefaultActionsMenu.tsx +2 -6
- package/src/lib/ui/components/ContextMenu/DefaultContextMenuContent.tsx +5 -1
- package/src/lib/ui/components/Dialogs.tsx +17 -1
- package/src/lib/ui/components/SharePanel/DefaultPeopleMenuItem.tsx +2 -3
- package/src/lib/ui/context/actions.tsx +0 -1
- package/src/lib/ui/hooks/useClipboardEvents.ts +2 -0
- package/src/lib/ui/hooks/useFlatten.ts +56 -23
- package/src/lib/ui/hooks/useKeyboardShortcuts.ts +5 -23
- package/src/lib/ui/kbd-utils.test.ts +11 -0
- package/src/lib/ui/kbd-utils.ts +27 -4
- package/src/lib/ui/version.ts +3 -3
- package/src/lib/ui.css +7 -16
- package/src/test/TldrawEditor.test.tsx +3 -0
- package/src/test/VideoShapeUtil.test.tsx +126 -0
- package/src/test/bookmark-shapes.test.ts +110 -0
- package/src/test/commands/alignShapes.test.tsx +19 -10
- package/src/test/commands/clipboard.test.ts +36 -1
- package/src/test/flatten.test.ts +135 -0
- package/src/test/modifiers.test.ts +104 -0
- 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
|
-
|
|
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\
|
|
5
|
-
"mappings": "AAAA,SAAS,aAAa;
|
|
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.
|
|
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-
|
|
5
|
-
patch: "2026-08-
|
|
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.
|
|
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.
|
|
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.
|
|
73
|
-
"@tldraw/editor": "5.
|
|
74
|
-
"@tldraw/state": "5.
|
|
75
|
-
"@tldraw/store": "5.
|
|
76
|
-
"@tldraw/tlschema": "5.
|
|
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.
|
|
736
|
+
length += Vec.Dist(points[i], points[i + 1])
|
|
737
737
|
}
|
|
738
738
|
}
|
|
739
739
|
|
|
740
|
-
return
|
|
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:
|
|
185
|
-
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">
|
package/src/lib/ui/TldrawUi.tsx
CHANGED
|
@@ -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
|
|
91
|
+
{hideUi ? null : <TldrawUiContent />}
|
|
93
92
|
</>
|
|
94
93
|
)
|
|
95
94
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEditor,
|
|
2
|
-
import { ReactNode, memo
|
|
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 &&
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
|
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
|
|
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
|
+
})
|
package/src/lib/ui/kbd-utils.ts
CHANGED
|
@@ -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(' ')
|
package/src/lib/ui/version.ts
CHANGED
|
@@ -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.
|
|
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-
|
|
8
|
-
patch: '2026-08-
|
|
7
|
+
minor: '2026-08-19T12:47:12.111Z',
|
|
8
|
+
patch: '2026-08-19T12:47:12.111Z',
|
|
9
9
|
}
|