tldraw 5.3.0 → 5.4.0-next.ef99ab47e5ce
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 +6 -2
- package/RELEASE_NOTES.md +52 -6
- package/dist-cjs/index.js +1 -1
- 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/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.mjs +1 -1
- 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/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/shapes/frame/frameHelpers.ts +10 -1
- 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/test/commands/alignShapes.test.tsx +19 -10
- package/src/test/modifiers.test.ts +104 -0
package/DOCS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tldraw Documentation
|
|
2
2
|
|
|
3
|
-
Version: `5.
|
|
3
|
+
Version: `5.4.0-next.ef99ab47e5ce`
|
|
4
4
|
|
|
5
5
|
This file is generated during package publishing from the tldraw docs content for this exact package version.
|
|
6
6
|
|
|
@@ -383,6 +383,7 @@ We ship an experimental `tldraw-migrate` agent skill that helps a coding agent u
|
|
|
383
383
|
|
|
384
384
|
#### v5.x
|
|
385
385
|
|
|
386
|
+
- [v5.3](https://tldraw.dev/releases/v5.3.0) - Commenting with anchored threads on the canvas, new collaboration packages, geo shape flipping, crop snapping, and faster viewport culling
|
|
386
387
|
- [v5.2](https://tldraw.dev/releases/v5.2.0) - Faster freehand ink, a frame selection action, SDK-wide performance improvements, and breaking changes to the collaborator user-id types and Node support
|
|
387
388
|
- [v5.1](https://tldraw.dev/releases/v5.1.0) - Page menu redesign, copy-styles shortcut, selectable locked shapes, public translation APIs, and performance improvements
|
|
388
389
|
- [v5.0](https://tldraw.dev/releases/v5.0.0) - Custom themes, overlays, performance, extensiblity, and attribution
|
|
@@ -16960,11 +16961,14 @@ Ungrouping reverses this process by moving the group's children back to the grou
|
|
|
16960
16961
|
|
|
16961
16962
|
##### Alignment
|
|
16962
16963
|
|
|
16963
|
-
Alignment moves shapes so they share a common edge or center line. The
|
|
16964
|
+
Alignment moves shapes so they share a common edge or center line. The six single-axis operations are `left`, `right`, `top`, `bottom`, `center-horizontal`, and `center-vertical`. Use `center` to align shapes by both their horizontal and vertical centers in one call.
|
|
16965
|
+
|
|
16966
|
+
When aligning shapes, the editor first calculates the common bounding box of all selected shapes. It then moves each shape to the appropriate edge or center of that common box.
|
|
16964
16967
|
|
|
16965
16968
|
```typescript
|
|
16966
16969
|
editor.alignShapes(editor.getSelectedShapeIds(), 'left')
|
|
16967
16970
|
editor.alignShapes([box1, box2], 'center-vertical')
|
|
16971
|
+
editor.alignShapes([box1, box2], 'center')
|
|
16968
16972
|
```
|
|
16969
16973
|
|
|
16970
16974
|
Selected shapes connected by arrow bindings move together as a cluster during alignment. If shapes A and B are connected by an arrow and you align them with shape C, A and B move together as a single unit.
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,27 +1,70 @@
|
|
|
1
1
|
# Releases
|
|
2
2
|
|
|
3
|
-
Version: `5.
|
|
3
|
+
Version: `5.4.0-next.ef99ab47e5ce`
|
|
4
4
|
|
|
5
5
|
This file is generated during package publishing from the tldraw release notes content for this exact package version.
|
|
6
6
|
|
|
7
7
|
## v5.3.0
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
[View on GitHub](https://github.com/tldraw/tldraw/releases/tag/v5.3.0)
|
|
10
|
+
|
|
11
|
+
This release adds commenting: a set of new packages for anchored comment threads on the canvas, backed by comment records in the schema and sync support for non-document data. Commenting is the first piece of our new collaboration package, a separately licensed section of the SDK for premium collaboration features. The release also adds flipping for geo shapes, snapping and symmetric resizing while cropping images, an `assets` prop for `TldrawImage`, and a reactive `Editor.getIsMounted()` with a new `unmount` event. Viewport culling is faster on large pages, and there's a batch of fixes for crashes and interaction bugs across arrows, groups, and text.
|
|
10
12
|
|
|
11
13
|
### What's new
|
|
12
14
|
|
|
13
|
-
####
|
|
15
|
+
#### Commenting ([#9471](https://github.com/tldraw/tldraw/pull/9471), [#9782](https://github.com/tldraw/tldraw/pull/9782), [#9850](https://github.com/tldraw/tldraw/pull/9850))
|
|
16
|
+
|
|
17
|
+
Comments are now a first-class primitive in the SDK. `@tldraw/commenting` ships a batteries-included canvas comments layer: threads anchor to a point, a shape (precisely or imprecisely), a text range, a region, or the page. You can reply, edit, react, resolve, and delete comments. The pins drag to re-anchor, and `?comment=<id>` deep links open a thread and bring the camera to its pin. Comment pins cluster together as you zoom out and separate back out as you zoom in.
|
|
18
|
+
|
|
19
|
+
Comment records live in `@tldraw/tlschema` as `TLCommentThread`, `TLComment`, and `TLCommentReaction`, and sync over a new generic object store in `@tldraw/sync-core`, so they ride alongside the document rather than inside it. On the server, `@tldraw/sync-collaboration` enforces write authorization, with a `canModifyComment` rule that lets a host widen the default owner-only permission. That way a workspace admin can moderate comments they don't own without granting anything beyond edit, delete, and resolve.
|
|
20
|
+
|
|
21
|
+
One rollout note: the comment record types ship with guard migrations, so once your server registers them, clients on an older SDK are rejected as too old and prompted to refresh. Roll out the upgraded client before or alongside the server change.
|
|
22
|
+
|
|
23
|
+
The layer is built to be taken apart. `ThreadRow` and `ThreadActions` are component slots, `CommentListItem` and `sortSidebarRows` are exported so a hand-built list can match the built-in one, and a host that supplies `getThreadHref` gets copy-link in the thread menu. On phones, the composer and the open thread stay above the software keyboard.
|
|
24
|
+
|
|
25
|
+
<details>
|
|
26
|
+
<summary>New packages</summary>
|
|
27
|
+
|
|
28
|
+
| Package | What it provides |
|
|
29
|
+
| ---------------------------- | ------------------------------------------------------------------------ |
|
|
30
|
+
| `@tldraw/commenting` | Canvas comments layer, comment UI components, and the comment tool |
|
|
31
|
+
| `@tldraw/mentions` | @-mention picker and TipTap node, shared by comments and shape rich text |
|
|
32
|
+
| `@tldraw/collaboration` | Umbrella package for the collaboration pieces |
|
|
33
|
+
| `@tldraw/sync-collaboration` | Server-side write authorization for collaboration features |
|
|
34
|
+
|
|
35
|
+
</details>
|
|
14
36
|
|
|
15
|
-
|
|
37
|
+
See the [commenting docs](https://tldraw.dev/docs/commenting) for setup.
|
|
16
38
|
|
|
17
39
|
### API changes
|
|
18
40
|
|
|
41
|
+
- 💥 Remove `usePassThroughMouseOverEvents` from `@tldraw/editor`. It re-dispatched `mouseover` at `.tl-canvas` so shapes under a floating panel stayed hovered, but the canvas only wires `onPointerEnter`, so the re-dispatched event arrived at an element with no listener for it. ([#9782](https://github.com/tldraw/tldraw/pull/9782))
|
|
42
|
+
- Add `TLCommentThread`, `TLComment`, and `TLCommentReaction` records to `@tldraw/tlschema`, with `createCommentThread` and `createComment` helpers, id helpers, validators, and `commentSchemaRecords` for registering them. ([#9471](https://github.com/tldraw/tldraw/pull/9471), [#9782](https://github.com/tldraw/tldraw/pull/9782))
|
|
43
|
+
- Add a generic object store to `@tldraw/sync-core` for syncing non-document records: `objectTypes` on the room and storage constructors, per-session `TLObjectStoreAccess`, `getObjectsSnapshot()`, `onCommittedChanges`, and an optional `TLSyncStorage.getObjectsByIds(ids)` for callers that need a few object-lane records rather than the whole lane. ([#9471](https://github.com/tldraw/tldraw/pull/9471), [#9782](https://github.com/tldraw/tldraw/pull/9782))
|
|
44
|
+
- Add `objectAccess` to the `synced-remote` member of `RemoteTLStoreWithStatus`, so a store from `useSync` reports whether the session can write object-lane records such as comments. If you construct that type yourself, in test mocks for example, you now need to supply `objectAccess`. ([#9471](https://github.com/tldraw/tldraw/pull/9471))
|
|
45
|
+
- Add `EditorPortal`, `EditorPortalProps`, and `useEditorPortalHost` to `@tldraw/editor`, for portalling canvas-anchored content into a host the editor renders last so its DOM order doesn't depend on mount timing. ([#9782](https://github.com/tldraw/tldraw/pull/9782))
|
|
46
|
+
- Add `renderHtmlFromRichTextWithExtensions(richText, extensions)` for rendering rich text outside a shape's editor config. `renderHtmlFromRichText` now fills _every_ empty paragraph with a `<br />` and keeps the paragraph's attributes, so an empty paragraph renders as `<p dir="auto"><br /></p>` — worth knowing if you snapshot that output. ([#9782](https://github.com/tldraw/tldraw/pull/9782))
|
|
47
|
+
- Add `flipX` and `flipY` to `TLGeoShapeProps`, with an `AddFlipProps` migration defaulting both to `false`, and add `PathBuilder.transform(mat)` for producing a transformed copy of a path. ([#9530](https://github.com/tldraw/tldraw/pull/9530))
|
|
48
|
+
- Add an optional `sizeToContentAspectRatio` to `EmbedDefinition` and a public `EmbedShapeUtil.resolveAspectRatio(shape)`. When set, an embed corrects its size after creation to its content's real aspect ratio, resolved from the URL's OpenGraph metadata via the editor's url asset handler. Existing custom embed definitions are unaffected. ([#9613](https://github.com/tldraw/tldraw/pull/9613))
|
|
49
|
+
- Add `ExportAsMenuGroup`, mirroring `CopyAsMenuGroup`, for the "Export as" submenu. ([#9589](https://github.com/tldraw/tldraw/pull/9589))
|
|
50
|
+
- Add `EditorManager`, a public abstract base class for editor managers that own a subscription or resource, so they share one teardown contract. `InputsManager`, `TickManager`, `FocusManager`, and `TextManager` now extend it. ([#9204](https://github.com/tldraw/tldraw/pull/9204))
|
|
51
|
+
- Populate `TLBaseExternalContent.sources` for file pastes, where it was previously always undefined, so custom `files` handlers and `onBeforePasteFromClipboard` can inspect the text, HTML, and url content that came alongside the files. ([#9763](https://github.com/tldraw/tldraw/pull/9763))
|
|
52
|
+
- Add an `assets` prop to `TldrawImage`, so a snapshot's images render from a custom asset store the same way they do in `Tldraw`. Previously `TldrawImage` always used the inline base64 asset store, so images kept anywhere else were missing. ([#9697](https://github.com/tldraw/tldraw/pull/9697)) (based on an implementation by [@jon-dez](https://github.com/jon-dez))
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<TldrawImage snapshot={snapshot} assets={assetStore} />
|
|
56
|
+
```
|
|
57
|
+
|
|
19
58
|
- Add an `unmount` event to `TLEventMap` and make `Editor.getIsMounted()` a reactive value that tracks whether the editor's component is currently mounted. ([#9472](https://github.com/tldraw/tldraw/pull/9472), [#9483](https://github.com/tldraw/tldraw/pull/9483))
|
|
20
59
|
- Make the shape hit-test margin pointer-aware and add a `coarseHitTestMargin` option: fine pointers (mouse and trackpad) use a tighter 3px default, coarse pointers (touch and pen) use 4px. ([#9492](https://github.com/tldraw/tldraw/pull/9492))
|
|
21
|
-
- Add a `ShapeUtil.onDuplicate(source, duplicate)` callback, invoked by `Editor.duplicateShapes` and `Editor.putContentOntoCurrentPage`,
|
|
60
|
+
- Add a `ShapeUtil.onDuplicate(source, duplicate)` callback, invoked by `Editor.duplicateShapes` and `Editor.putContentOntoCurrentPage`, so a shape can adjust its props when it is copied. Sticky notes now use it to attribute duplicated or pasted text to the current user instead of carrying over the original author. ([#9566](https://github.com/tldraw/tldraw/pull/9566))
|
|
22
61
|
|
|
23
62
|
### Improvements
|
|
24
63
|
|
|
64
|
+
- Geo shapes now flip. Previously flipping a selection left directional shapes such as the rhombus, trapezoid, and arrow geo shapes looking exactly the same on the other side of the mirror. `TLGeoShape` gains `flipX` and `flipY` props, matching the image shape, and a flip mirrors the shape's generated path. Geometry, hit-testing, the selection indicator, the canvas render, and SVG export all read from that path, so they stay in agreement. ([#9530](https://github.com/tldraw/tldraw/pull/9530))
|
|
65
|
+
- When cropping an image, crop edges now snap to other shapes and the grid, with the usual snapping guidelines. Holding Alt resizes the crop region symmetrically from its center. ([#9424](https://github.com/tldraw/tldraw/pull/9424))
|
|
66
|
+
- Size embeds from providers that report their content's dimensions (currently Vimeo) to that content's real aspect ratio, so a video is no longer letterboxed inside a 16:9 box. The corrected embed keeps the area of the original box, so a portrait video doesn't balloon over whatever sits above and below it. ([#9613](https://github.com/tldraw/tldraw/pull/9613), [#9756](https://github.com/tldraw/tldraw/pull/9756))
|
|
67
|
+
- Hide the "Copy as", "Export as", and "Export file" submenus, and the main menu's export group, when a consumer has removed all of their actions through `overrides`, instead of leaving an empty submenu trigger behind. ([#9589](https://github.com/tldraw/tldraw/pull/9589))
|
|
25
68
|
- Improve viewport culling performance on large pages: the spatial index now rechecks only the shapes affected by a change instead of every shape on the page. Custom shapes whose geometry depends on unrelated shapes (outside parent/child and binding relationships) are no longer automatically rechecked for culling when those shapes change; built-in shapes and standard patterns are unaffected. ([#9093](https://github.com/tldraw/tldraw/pull/9093))
|
|
26
69
|
- Improve performance when deleting many shapes at once by batching page state cleanup. ([#9456](https://github.com/tldraw/tldraw/pull/9456))
|
|
27
70
|
- Make long toast and dialog content wrap instead of overflowing, so long strings like URLs stay within the toast or dialog. ([#9546](https://github.com/tldraw/tldraw/pull/9546))
|
|
@@ -30,8 +73,11 @@ When cropping an image, crop edges now snap to other shapes and the grid, with t
|
|
|
30
73
|
|
|
31
74
|
### Bug fixes
|
|
32
75
|
|
|
76
|
+
- Fix zooming and panning not working while the cursor is over a non-scrollable UI overlay. The wheel pass-through guard treated any element whose content overflowed as scrollable, and a hover transform on a small overlay was enough to trigger that. Overlays now count as scrollable only when their computed `overflow` says so. ([#9698](https://github.com/tldraw/tldraw/pull/9698))
|
|
77
|
+
- Fix a crash when dragging content from another browser tab whose URL isn't a valid link, which happens when an ad blocker rewrites the dragged URL to something like `about:blank#blocked`. Dropping one now shows an error toast instead of failing validation on the bookmark shape. ([#9598](https://github.com/tldraw/tldraw/pull/9598))
|
|
78
|
+
- Fix the "URL" label wrapping onto two lines in the edit link and embed dialogs. ([#9663](https://github.com/tldraw/tldraw/pull/9663))
|
|
33
79
|
- Fix rich text headings (H1–H6) rendering with their line-height too tight, causing overlapping lines. ([#9543](https://github.com/tldraw/tldraw/pull/9543))
|
|
34
|
-
- Fix the style panel not highlighting the active fill correctly when the current fill lives in the overflow dropdown. ([#9509](https://github.com/tldraw/tldraw/pull/9509))
|
|
80
|
+
- Fix the style panel not highlighting the active fill correctly when the current fill lives in the overflow dropdown. ([#9509](https://github.com/tldraw/tldraw/pull/9509))
|
|
35
81
|
- Fix the style panel Shape, Line, and spline dropdown popovers so they open with the same gap as the other dropdowns. ([#9491](https://github.com/tldraw/tldraw/pull/9491))
|
|
36
82
|
- Fix double-clicking a shape inside an unfocused group immediately editing it; a double-click now drills one level into the group. ([#9488](https://github.com/tldraw/tldraw/pull/9488))
|
|
37
83
|
- Fix selected clipped arrows so they can be dragged when their geometry extends outside a clipping parent. ([#9479](https://github.com/tldraw/tldraw/pull/9479))
|
package/dist-cjs/index.js
CHANGED
|
@@ -653,7 +653,7 @@ var import_buildFromV1Document = require("./lib/utils/tldr/buildFromV1Document")
|
|
|
653
653
|
var import_file = require("./lib/utils/tldr/file");
|
|
654
654
|
(0, import_editor.registerTldrawLibraryVersion)(
|
|
655
655
|
"tldraw",
|
|
656
|
-
"5.
|
|
656
|
+
"5.4.0-next.ef99ab47e5ce",
|
|
657
657
|
"cjs"
|
|
658
658
|
);
|
|
659
659
|
//# sourceMappingURL=index.js.map
|
|
@@ -49,6 +49,9 @@ function getFrameHeadingSize(editor, shape, opts) {
|
|
|
49
49
|
const spans = editor.textMeasure.measureTextSpans(frameTitle, opts);
|
|
50
50
|
const firstSpan = spans[0];
|
|
51
51
|
const lastSpan = (0, import_editor.last)(spans);
|
|
52
|
+
if (!firstSpan || !lastSpan) {
|
|
53
|
+
return new import_editor.Box(0, -opts.height, 0, opts.height);
|
|
54
|
+
}
|
|
52
55
|
width = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x;
|
|
53
56
|
measurementWeakmap.set(shape.props, width);
|
|
54
57
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/lib/shapes/frame/frameHelpers.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n\tBox,\n\tEditor,\n\tTLFrameShape,\n\tcanonicalizeRotation,\n\tlast,\n\ttoDomPrecision,\n} from '@tldraw/editor'\nimport { TLCreateTextJsxFromSpansOpts } from '../shared/createTextJsxFromSpans'\n\nexport function defaultEmptyAs(str: string, dflt: string) {\n\tif (str.match(/^\\s*$/)) {\n\t\treturn dflt\n\t}\n\treturn str\n}\n\nexport function getFrameHeadingSide(editor: Editor, shape: TLFrameShape): 0 | 1 | 2 | 3 {\n\tconst pageRotation = canonicalizeRotation(editor.getShapePageTransform(shape.id)!.rotation())\n\tconst offsetRotation = pageRotation + Math.PI / 4\n\tconst scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4\n\treturn Math.floor(scaledRotation) as 0 | 1 | 2 | 3\n}\n\n/**\n * We use a weak map here to prevent re-measuring the text width of frames that haven't changed their names.\n * It's only really important for performance reasons while zooming in and out. The measured text size is\n * independent of the zoom level, so we can cache the expensive part (measurement) and apply those changes\n * using the zoom level.\n */\nconst measurementWeakmap = new WeakMap()\n\n/**\n * Get the frame heading info (size and text) for a frame shape.\n *\n * @param editor The editor instance.\n * @param shape The frame shape.\n * @param opts The text measurement options.\n *\n * @returns The frame heading's size (as a Box) and JSX text spans.\n */\nexport function getFrameHeadingSize(\n\teditor: Editor,\n\tshape: TLFrameShape,\n\topts: TLCreateTextJsxFromSpansOpts\n) {\n\tif (process.env.NODE_ENV === 'test') {\n\t\t// can't really measure text in tests\n\t\treturn new Box(0, -opts.height, shape.props.w, opts.height)\n\t}\n\n\tlet width = measurementWeakmap.get(shape.props)\n\tif (!width) {\n\t\tconst frameTitle = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203)\n\t\tconst spans = editor.textMeasure.measureTextSpans(frameTitle, opts)\n\t\tconst firstSpan = spans[0]\n\t\tconst lastSpan = last(spans)
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAOO;AAGA,SAAS,eAAe,KAAa,MAAc;AACzD,MAAI,IAAI,MAAM,OAAO,GAAG;AACvB,WAAO;AAAA,EACR;AACA,SAAO;AACR;AAEO,SAAS,oBAAoB,QAAgB,OAAoC;AACvF,QAAM,mBAAe,oCAAqB,OAAO,sBAAsB,MAAM,EAAE,EAAG,SAAS,CAAC;AAC5F,QAAM,iBAAiB,eAAe,KAAK,KAAK;AAChD,QAAM,kBAAkB,kBAAkB,IAAI,KAAK,MAAM,KAAK;AAC9D,SAAO,KAAK,MAAM,cAAc;AACjC;AAQA,MAAM,qBAAqB,oBAAI,QAAQ;AAWhC,SAAS,oBACf,QACA,OACA,MACC;AACD,MAAI,QAAQ,IAAI,aAAa,QAAQ;AAEpC,WAAO,IAAI,kBAAI,GAAG,CAAC,KAAK,QAAQ,MAAM,MAAM,GAAG,KAAK,MAAM;AAAA,EAC3D;AAEA,MAAI,QAAQ,mBAAmB,IAAI,MAAM,KAAK;AAC9C,MAAI,CAAC,OAAO;AACX,UAAM,aAAa,eAAe,MAAM,MAAM,MAAM,OAAO,IAAI,OAAO,aAAa,IAAI;AACvF,UAAM,QAAQ,OAAO,YAAY,iBAAiB,YAAY,IAAI;AAClE,UAAM,YAAY,MAAM,CAAC;AACzB,UAAM,eAAW,oBAAK,KAAK;AAE3B,YAAQ,SAAS,IAAI,IAAI,SAAS,IAAI,IAAI,UAAU,IAAI;AACxD,uBAAmB,IAAI,MAAM,OAAO,KAAK;AAAA,EAC1C;AAEA,SAAO,IAAI,kBAAI,GAAG,CAAC,KAAK,QAAQ,OAAO,KAAK,MAAM;AACnD;AAEO,SAAS,oBAAoB,OAAe,OAA8C;AAChG,SAAO;AAAA,IACN,UAAU;AAAA,IACV,YAAY,QAAQ,UAAU;AAAA,IAC9B,WAAW;AAAA,IACX;AAAA,IACA,QAAQ;AAAA;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,WAAW;AAAA,IACX,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,mBAAmB;AAAA,IACnB,SAAS,EAAE,KAAK;AAAA;AAAA,IAChB,SAAS;AAAA,EACV;AACD;AAEO,SAAS,2BACf,OACA,MACA,OACC;AACD,QAAM,IAAI,QAAQ,KAAK;AACvB,QAAM,IAAI,QAAQ,KAAK;AACvB,MAAI;AACJ,UAAQ,MAAM;AAAA,IACb,KAAK;AACJ,uBAAiB;AACjB;AAAA,IACD,KAAK;AACJ,uBAAiB,iBAAa,8BAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC;AACrF;AAAA,IACD,KAAK;AACJ,uBAAiB,iBAAa,8BAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,SAAK;AAAA,QACnE,MAAM,MAAM;AAAA,MACb,CAAC,GAAG,CAAC,eAAe,CAAC;AACrB;AAAA,IACD,KAAK;AACJ,uBAAiB,cAAc,CAAC,SAAK,8BAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC;AACtF;AAAA,IACD;AACC,YAAM,MAAM,yBAAyB;AAAA,EACvC;AAEA,SAAO;AACR;",
|
|
4
|
+
"sourcesContent": ["import {\n\tBox,\n\tEditor,\n\tTLFrameShape,\n\tcanonicalizeRotation,\n\tlast,\n\ttoDomPrecision,\n} from '@tldraw/editor'\nimport { TLCreateTextJsxFromSpansOpts } from '../shared/createTextJsxFromSpans'\n\nexport function defaultEmptyAs(str: string, dflt: string) {\n\tif (str.match(/^\\s*$/)) {\n\t\treturn dflt\n\t}\n\treturn str\n}\n\nexport function getFrameHeadingSide(editor: Editor, shape: TLFrameShape): 0 | 1 | 2 | 3 {\n\tconst pageRotation = canonicalizeRotation(editor.getShapePageTransform(shape.id)!.rotation())\n\tconst offsetRotation = pageRotation + Math.PI / 4\n\tconst scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4\n\treturn Math.floor(scaledRotation) as 0 | 1 | 2 | 3\n}\n\n/**\n * We use a weak map here to prevent re-measuring the text width of frames that haven't changed their names.\n * It's only really important for performance reasons while zooming in and out. The measured text size is\n * independent of the zoom level, so we can cache the expensive part (measurement) and apply those changes\n * using the zoom level.\n */\nconst measurementWeakmap = new WeakMap()\n\n/**\n * Get the frame heading info (size and text) for a frame shape.\n *\n * @param editor The editor instance.\n * @param shape The frame shape.\n * @param opts The text measurement options.\n *\n * @returns The frame heading's size (as a Box) and JSX text spans.\n */\nexport function getFrameHeadingSize(\n\teditor: Editor,\n\tshape: TLFrameShape,\n\topts: TLCreateTextJsxFromSpansOpts\n) {\n\tif (process.env.NODE_ENV === 'test') {\n\t\t// can't really measure text in tests\n\t\treturn new Box(0, -opts.height, shape.props.w, opts.height)\n\t}\n\n\tlet width = measurementWeakmap.get(shape.props)\n\tif (!width) {\n\t\tconst frameTitle = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203)\n\t\tconst spans = editor.textMeasure.measureTextSpans(frameTitle, opts)\n\t\tconst firstSpan = spans[0]\n\t\tconst lastSpan = last(spans)\n\n\t\tif (!firstSpan || !lastSpan) {\n\t\t\t// measureTextSpans can return no spans when the text can't be laid\n\t\t\t// out yet \u2014 e.g. the measurement element isn't attached/visible\n\t\t\t// during the initial editor mount, or every grapheme lacks a layout\n\t\t\t// rect. Fall back to a zero-width heading and don't cache it, so the\n\t\t\t// heading is remeasured once measurement is possible.\n\t\t\treturn new Box(0, -opts.height, 0, opts.height)\n\t\t}\n\n\t\twidth = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x\n\t\tmeasurementWeakmap.set(shape.props, width)\n\t}\n\n\treturn new Box(0, -opts.height, width, opts.height)\n}\n\nexport function getFrameHeadingOpts(width: number, isSvg: boolean): TLCreateTextJsxFromSpansOpts {\n\treturn {\n\t\tfontSize: 12,\n\t\tfontFamily: isSvg ? 'Arial' : 'Inter, sans-serif',\n\t\ttextAlign: 'start' as const,\n\t\twidth: width,\n\t\theight: 24, // --frame-height\n\t\tpadding: 0,\n\t\tlineHeight: 1,\n\t\tfontStyle: 'normal',\n\t\tfontWeight: 'normal',\n\t\toverflow: 'truncate-ellipsis' as const,\n\t\tverticalTextAlign: 'middle' as const,\n\t\toffsetY: -(32 + 2), // --frame-minimum-height + (border width * 2)\n\t\toffsetX: 0,\n\t}\n}\n\nexport function getFrameHeadingTranslation(\n\tshape: TLFrameShape,\n\tside: 0 | 1 | 2 | 3,\n\tisSvg: boolean\n) {\n\tconst u = isSvg ? '' : 'px'\n\tconst r = isSvg ? '' : 'deg'\n\tlet labelTranslate: string\n\tswitch (side) {\n\t\tcase 0: // top\n\t\t\tlabelTranslate = ``\n\t\t\tbreak\n\t\tcase 3: // right\n\t\t\tlabelTranslate = `translate(${toDomPrecision(shape.props.w)}${u}, 0${u}) rotate(90${r})`\n\t\t\tbreak\n\t\tcase 2: // bottom\n\t\t\tlabelTranslate = `translate(${toDomPrecision(shape.props.w)}${u}, ${toDomPrecision(\n\t\t\t\tshape.props.h\n\t\t\t)}${u}) rotate(180${r})`\n\t\t\tbreak\n\t\tcase 1: // left\n\t\t\tlabelTranslate = `translate(0${u}, ${toDomPrecision(shape.props.h)}${u}) rotate(270${r})`\n\t\t\tbreak\n\t\tdefault:\n\t\t\tthrow Error('labelSide out of bounds')\n\t}\n\n\treturn labelTranslate\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAOO;AAGA,SAAS,eAAe,KAAa,MAAc;AACzD,MAAI,IAAI,MAAM,OAAO,GAAG;AACvB,WAAO;AAAA,EACR;AACA,SAAO;AACR;AAEO,SAAS,oBAAoB,QAAgB,OAAoC;AACvF,QAAM,mBAAe,oCAAqB,OAAO,sBAAsB,MAAM,EAAE,EAAG,SAAS,CAAC;AAC5F,QAAM,iBAAiB,eAAe,KAAK,KAAK;AAChD,QAAM,kBAAkB,kBAAkB,IAAI,KAAK,MAAM,KAAK;AAC9D,SAAO,KAAK,MAAM,cAAc;AACjC;AAQA,MAAM,qBAAqB,oBAAI,QAAQ;AAWhC,SAAS,oBACf,QACA,OACA,MACC;AACD,MAAI,QAAQ,IAAI,aAAa,QAAQ;AAEpC,WAAO,IAAI,kBAAI,GAAG,CAAC,KAAK,QAAQ,MAAM,MAAM,GAAG,KAAK,MAAM;AAAA,EAC3D;AAEA,MAAI,QAAQ,mBAAmB,IAAI,MAAM,KAAK;AAC9C,MAAI,CAAC,OAAO;AACX,UAAM,aAAa,eAAe,MAAM,MAAM,MAAM,OAAO,IAAI,OAAO,aAAa,IAAI;AACvF,UAAM,QAAQ,OAAO,YAAY,iBAAiB,YAAY,IAAI;AAClE,UAAM,YAAY,MAAM,CAAC;AACzB,UAAM,eAAW,oBAAK,KAAK;AAE3B,QAAI,CAAC,aAAa,CAAC,UAAU;AAM5B,aAAO,IAAI,kBAAI,GAAG,CAAC,KAAK,QAAQ,GAAG,KAAK,MAAM;AAAA,IAC/C;AAEA,YAAQ,SAAS,IAAI,IAAI,SAAS,IAAI,IAAI,UAAU,IAAI;AACxD,uBAAmB,IAAI,MAAM,OAAO,KAAK;AAAA,EAC1C;AAEA,SAAO,IAAI,kBAAI,GAAG,CAAC,KAAK,QAAQ,OAAO,KAAK,MAAM;AACnD;AAEO,SAAS,oBAAoB,OAAe,OAA8C;AAChG,SAAO;AAAA,IACN,UAAU;AAAA,IACV,YAAY,QAAQ,UAAU;AAAA,IAC9B,WAAW;AAAA,IACX;AAAA,IACA,QAAQ;AAAA;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,WAAW;AAAA,IACX,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,mBAAmB;AAAA,IACnB,SAAS,EAAE,KAAK;AAAA;AAAA,IAChB,SAAS;AAAA,EACV;AACD;AAEO,SAAS,2BACf,OACA,MACA,OACC;AACD,QAAM,IAAI,QAAQ,KAAK;AACvB,QAAM,IAAI,QAAQ,KAAK;AACvB,MAAI;AACJ,UAAQ,MAAM;AAAA,IACb,KAAK;AACJ,uBAAiB;AACjB;AAAA,IACD,KAAK;AACJ,uBAAiB,iBAAa,8BAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC;AACrF;AAAA,IACD,KAAK;AACJ,uBAAiB,iBAAa,8BAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,SAAK;AAAA,QACnE,MAAM,MAAM;AAAA,MACb,CAAC,GAAG,CAAC,eAAe,CAAC;AACrB;AAAA,IACD,KAAK;AACJ,uBAAiB,cAAc,CAAC,SAAK,8BAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC;AACtF;AAAA,IACD;AACC,YAAM,MAAM,yBAAyB;AAAA,EACvC;AAEA,SAAO;AACR;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -27,13 +27,13 @@ module.exports = __toCommonJS(useKeyboardShortcuts_exports);
|
|
|
27
27
|
var import_editor = require("@tldraw/editor");
|
|
28
28
|
var import_react = require("react");
|
|
29
29
|
var import_actions = require("../context/actions");
|
|
30
|
+
var import_kbd_utils = require("../kbd-utils");
|
|
30
31
|
var import_useCommentingEnabled = require("./useCommentingEnabled");
|
|
31
32
|
var import_useReadonly = require("./useReadonly");
|
|
32
33
|
var import_useTools = require("./useTools");
|
|
33
34
|
/*!
|
|
34
|
-
* The
|
|
35
|
-
*
|
|
36
|
-
* previously depended on.
|
|
35
|
+
* The form-input filter pattern in `shouldSkipEvent` (including its list of non-text INPUT
|
|
36
|
+
* types) is adapted from hotkeys-js, which this hook previously depended on.
|
|
37
37
|
*
|
|
38
38
|
* MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
|
|
39
39
|
* Copyright (c) 2015-present, Kenny Wong
|
|
@@ -282,7 +282,7 @@ const PHYSICAL_KEY_MAP = {
|
|
|
282
282
|
};
|
|
283
283
|
function parseKbd(kbd) {
|
|
284
284
|
const out = [];
|
|
285
|
-
for (const shortcut of
|
|
285
|
+
for (const shortcut of (0, import_kbd_utils.splitKbd)(kbd.replace(/\s/g, ""))) {
|
|
286
286
|
const parsed = parseShortcut(shortcut);
|
|
287
287
|
if (parsed) out.push(parsed);
|
|
288
288
|
}
|
|
@@ -358,7 +358,7 @@ function shouldSkipEvent(e) {
|
|
|
358
358
|
return false;
|
|
359
359
|
}
|
|
360
360
|
function getHotkeysStringFromKbd(kbd) {
|
|
361
|
-
return
|
|
361
|
+
return (0, import_kbd_utils.splitKbd)(kbd.replace(/\s/g, "")).map((kbd2) => {
|
|
362
362
|
let str = "";
|
|
363
363
|
const shift = kbd2.includes("!");
|
|
364
364
|
const alt = kbd2.includes("?");
|
|
@@ -384,16 +384,4 @@ function getHotkeysStringFromKbd(kbd) {
|
|
|
384
384
|
return str;
|
|
385
385
|
}).join(",");
|
|
386
386
|
}
|
|
387
|
-
function getKeys(key) {
|
|
388
|
-
if (typeof key !== "string") key = "";
|
|
389
|
-
key = key.replace(/\s/g, "");
|
|
390
|
-
const keys = key.split(",");
|
|
391
|
-
let index = keys.lastIndexOf("");
|
|
392
|
-
for (; index >= 0; ) {
|
|
393
|
-
keys[index - 1] += ",";
|
|
394
|
-
keys.splice(index, 1);
|
|
395
|
-
index = keys.lastIndexOf("");
|
|
396
|
-
}
|
|
397
|
-
return keys;
|
|
398
|
-
}
|
|
399
387
|
//# sourceMappingURL=useKeyboardShortcuts.js.map
|
|
@@ -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": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;
|
|
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": "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",
|
|
6
6
|
"names": ["kbd"]
|
|
7
7
|
}
|
|
@@ -19,16 +19,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
var kbd_utils_exports = {};
|
|
20
20
|
__export(kbd_utils_exports, {
|
|
21
21
|
kbd: () => kbd,
|
|
22
|
-
kbdStr: () => kbdStr
|
|
22
|
+
kbdStr: () => kbdStr,
|
|
23
|
+
splitKbd: () => splitKbd
|
|
23
24
|
});
|
|
24
25
|
module.exports = __toCommonJS(kbd_utils_exports);
|
|
25
26
|
var import_editor = require("@tldraw/editor");
|
|
27
|
+
/*!
|
|
28
|
+
* `splitKbd` is adapted from hotkeys-js.
|
|
29
|
+
*
|
|
30
|
+
* MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
|
|
31
|
+
* Copyright (c) 2015-present, Kenny Wong
|
|
32
|
+
* Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)
|
|
33
|
+
* Source: https://github.com/jaywcjlove/hotkeys-js
|
|
34
|
+
*/
|
|
26
35
|
const cmdKey = import_editor.tlenv.isDarwin ? "\u2318" : "__CTRL__";
|
|
27
36
|
const ctrlKey = import_editor.tlenv.isDarwin ? "\u2303" : "__CTRL__";
|
|
28
37
|
const altKey = import_editor.tlenv.isDarwin ? "\u2325" : "__ALT__";
|
|
29
38
|
function kbd(str) {
|
|
30
|
-
|
|
31
|
-
return str.split(",")[0].split(/(\[\[[^\]]+\]\])/g).map(
|
|
39
|
+
return (splitKbd(str)[0] ?? "").split(/(\[\[[^\]]+\]\])/g).map(
|
|
32
40
|
(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) || []
|
|
33
41
|
).flat().map((sub, index) => {
|
|
34
42
|
if (sub[0] === "+") return [];
|
|
@@ -43,6 +51,17 @@ function kbd(str) {
|
|
|
43
51
|
return import_editor.tlenv.isDarwin || !index ? modifiedKey : ["+", modifiedKey];
|
|
44
52
|
}).flat();
|
|
45
53
|
}
|
|
54
|
+
function splitKbd(key) {
|
|
55
|
+
if (!key) return [];
|
|
56
|
+
const keys = key.split(",");
|
|
57
|
+
let index = keys.lastIndexOf("");
|
|
58
|
+
for (; index >= 0; ) {
|
|
59
|
+
keys[index - 1] += ",";
|
|
60
|
+
keys.splice(index, 1);
|
|
61
|
+
index = keys.lastIndexOf("");
|
|
62
|
+
}
|
|
63
|
+
return keys;
|
|
64
|
+
}
|
|
46
65
|
function kbdStr(str) {
|
|
47
66
|
return "\u2014 " + kbd(str).join("\u2009");
|
|
48
67
|
}
|
|
@@ -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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAAsB;
|
|
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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAAsB;AAEtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAUA,MAAM,SAAS,oBAAM,WAAW,WAAM;AACtC,MAAM,UAAU,oBAAM,WAAW,WAAM;AACvC,MAAM,SAAS,oBAAM,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,oBAAM,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
|
}
|
|
@@ -22,10 +22,10 @@ __export(version_exports, {
|
|
|
22
22
|
version: () => version
|
|
23
23
|
});
|
|
24
24
|
module.exports = __toCommonJS(version_exports);
|
|
25
|
-
const version = "5.
|
|
25
|
+
const version = "5.4.0-next.ef99ab47e5ce";
|
|
26
26
|
const publishDates = {
|
|
27
27
|
major: "2026-05-06T16:28:18.473Z",
|
|
28
|
-
minor: "2026-08-05T11:
|
|
29
|
-
patch: "2026-08-05T11:
|
|
28
|
+
minor: "2026-08-05T11:52:36.664Z",
|
|
29
|
+
patch: "2026-08-05T11:52:36.664Z"
|
|
30
30
|
};
|
|
31
31
|
//# sourceMappingURL=version.js.map
|
|
@@ -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-next.ef99ab47e5ce'\nexport const publishDates = {\n\tmajor: '2026-05-06T16:28:18.473Z',\n\tminor: '2026-08-05T11:52:36.664Z',\n\tpatch: '2026-08-05T11:52:36.664Z',\n}\n"],
|
|
5
5
|
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAGO,MAAM,UAAU;AAChB,MAAM,eAAe;AAAA,EAC3B,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AACR;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist-esm/index.mjs
CHANGED
|
@@ -27,6 +27,9 @@ function getFrameHeadingSize(editor, shape, opts) {
|
|
|
27
27
|
const spans = editor.textMeasure.measureTextSpans(frameTitle, opts);
|
|
28
28
|
const firstSpan = spans[0];
|
|
29
29
|
const lastSpan = last(spans);
|
|
30
|
+
if (!firstSpan || !lastSpan) {
|
|
31
|
+
return new Box(0, -opts.height, 0, opts.height);
|
|
32
|
+
}
|
|
30
33
|
width = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x;
|
|
31
34
|
measurementWeakmap.set(shape.props, width);
|
|
32
35
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/lib/shapes/frame/frameHelpers.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n\tBox,\n\tEditor,\n\tTLFrameShape,\n\tcanonicalizeRotation,\n\tlast,\n\ttoDomPrecision,\n} from '@tldraw/editor'\nimport { TLCreateTextJsxFromSpansOpts } from '../shared/createTextJsxFromSpans'\n\nexport function defaultEmptyAs(str: string, dflt: string) {\n\tif (str.match(/^\\s*$/)) {\n\t\treturn dflt\n\t}\n\treturn str\n}\n\nexport function getFrameHeadingSide(editor: Editor, shape: TLFrameShape): 0 | 1 | 2 | 3 {\n\tconst pageRotation = canonicalizeRotation(editor.getShapePageTransform(shape.id)!.rotation())\n\tconst offsetRotation = pageRotation + Math.PI / 4\n\tconst scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4\n\treturn Math.floor(scaledRotation) as 0 | 1 | 2 | 3\n}\n\n/**\n * We use a weak map here to prevent re-measuring the text width of frames that haven't changed their names.\n * It's only really important for performance reasons while zooming in and out. The measured text size is\n * independent of the zoom level, so we can cache the expensive part (measurement) and apply those changes\n * using the zoom level.\n */\nconst measurementWeakmap = new WeakMap()\n\n/**\n * Get the frame heading info (size and text) for a frame shape.\n *\n * @param editor The editor instance.\n * @param shape The frame shape.\n * @param opts The text measurement options.\n *\n * @returns The frame heading's size (as a Box) and JSX text spans.\n */\nexport function getFrameHeadingSize(\n\teditor: Editor,\n\tshape: TLFrameShape,\n\topts: TLCreateTextJsxFromSpansOpts\n) {\n\tif (process.env.NODE_ENV === 'test') {\n\t\t// can't really measure text in tests\n\t\treturn new Box(0, -opts.height, shape.props.w, opts.height)\n\t}\n\n\tlet width = measurementWeakmap.get(shape.props)\n\tif (!width) {\n\t\tconst frameTitle = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203)\n\t\tconst spans = editor.textMeasure.measureTextSpans(frameTitle, opts)\n\t\tconst firstSpan = spans[0]\n\t\tconst lastSpan = last(spans)
|
|
5
|
-
"mappings": "AAAA;AAAA,EACC;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA,OACM;AAGA,SAAS,eAAe,KAAa,MAAc;AACzD,MAAI,IAAI,MAAM,OAAO,GAAG;AACvB,WAAO;AAAA,EACR;AACA,SAAO;AACR;AAEO,SAAS,oBAAoB,QAAgB,OAAoC;AACvF,QAAM,eAAe,qBAAqB,OAAO,sBAAsB,MAAM,EAAE,EAAG,SAAS,CAAC;AAC5F,QAAM,iBAAiB,eAAe,KAAK,KAAK;AAChD,QAAM,kBAAkB,kBAAkB,IAAI,KAAK,MAAM,KAAK;AAC9D,SAAO,KAAK,MAAM,cAAc;AACjC;AAQA,MAAM,qBAAqB,oBAAI,QAAQ;AAWhC,SAAS,oBACf,QACA,OACA,MACC;AACD,MAAI,QAAQ,IAAI,aAAa,QAAQ;AAEpC,WAAO,IAAI,IAAI,GAAG,CAAC,KAAK,QAAQ,MAAM,MAAM,GAAG,KAAK,MAAM;AAAA,EAC3D;AAEA,MAAI,QAAQ,mBAAmB,IAAI,MAAM,KAAK;AAC9C,MAAI,CAAC,OAAO;AACX,UAAM,aAAa,eAAe,MAAM,MAAM,MAAM,OAAO,IAAI,OAAO,aAAa,IAAI;AACvF,UAAM,QAAQ,OAAO,YAAY,iBAAiB,YAAY,IAAI;AAClE,UAAM,YAAY,MAAM,CAAC;AACzB,UAAM,WAAW,KAAK,KAAK;AAE3B,YAAQ,SAAS,IAAI,IAAI,SAAS,IAAI,IAAI,UAAU,IAAI;AACxD,uBAAmB,IAAI,MAAM,OAAO,KAAK;AAAA,EAC1C;AAEA,SAAO,IAAI,IAAI,GAAG,CAAC,KAAK,QAAQ,OAAO,KAAK,MAAM;AACnD;AAEO,SAAS,oBAAoB,OAAe,OAA8C;AAChG,SAAO;AAAA,IACN,UAAU;AAAA,IACV,YAAY,QAAQ,UAAU;AAAA,IAC9B,WAAW;AAAA,IACX;AAAA,IACA,QAAQ;AAAA;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,WAAW;AAAA,IACX,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,mBAAmB;AAAA,IACnB,SAAS,EAAE,KAAK;AAAA;AAAA,IAChB,SAAS;AAAA,EACV;AACD;AAEO,SAAS,2BACf,OACA,MACA,OACC;AACD,QAAM,IAAI,QAAQ,KAAK;AACvB,QAAM,IAAI,QAAQ,KAAK;AACvB,MAAI;AACJ,UAAQ,MAAM;AAAA,IACb,KAAK;AACJ,uBAAiB;AACjB;AAAA,IACD,KAAK;AACJ,uBAAiB,aAAa,eAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC;AACrF;AAAA,IACD,KAAK;AACJ,uBAAiB,aAAa,eAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,KAAK;AAAA,QACnE,MAAM,MAAM;AAAA,MACb,CAAC,GAAG,CAAC,eAAe,CAAC;AACrB;AAAA,IACD,KAAK;AACJ,uBAAiB,cAAc,CAAC,KAAK,eAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC;AACtF;AAAA,IACD;AACC,YAAM,MAAM,yBAAyB;AAAA,EACvC;AAEA,SAAO;AACR;",
|
|
4
|
+
"sourcesContent": ["import {\n\tBox,\n\tEditor,\n\tTLFrameShape,\n\tcanonicalizeRotation,\n\tlast,\n\ttoDomPrecision,\n} from '@tldraw/editor'\nimport { TLCreateTextJsxFromSpansOpts } from '../shared/createTextJsxFromSpans'\n\nexport function defaultEmptyAs(str: string, dflt: string) {\n\tif (str.match(/^\\s*$/)) {\n\t\treturn dflt\n\t}\n\treturn str\n}\n\nexport function getFrameHeadingSide(editor: Editor, shape: TLFrameShape): 0 | 1 | 2 | 3 {\n\tconst pageRotation = canonicalizeRotation(editor.getShapePageTransform(shape.id)!.rotation())\n\tconst offsetRotation = pageRotation + Math.PI / 4\n\tconst scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4\n\treturn Math.floor(scaledRotation) as 0 | 1 | 2 | 3\n}\n\n/**\n * We use a weak map here to prevent re-measuring the text width of frames that haven't changed their names.\n * It's only really important for performance reasons while zooming in and out. The measured text size is\n * independent of the zoom level, so we can cache the expensive part (measurement) and apply those changes\n * using the zoom level.\n */\nconst measurementWeakmap = new WeakMap()\n\n/**\n * Get the frame heading info (size and text) for a frame shape.\n *\n * @param editor The editor instance.\n * @param shape The frame shape.\n * @param opts The text measurement options.\n *\n * @returns The frame heading's size (as a Box) and JSX text spans.\n */\nexport function getFrameHeadingSize(\n\teditor: Editor,\n\tshape: TLFrameShape,\n\topts: TLCreateTextJsxFromSpansOpts\n) {\n\tif (process.env.NODE_ENV === 'test') {\n\t\t// can't really measure text in tests\n\t\treturn new Box(0, -opts.height, shape.props.w, opts.height)\n\t}\n\n\tlet width = measurementWeakmap.get(shape.props)\n\tif (!width) {\n\t\tconst frameTitle = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203)\n\t\tconst spans = editor.textMeasure.measureTextSpans(frameTitle, opts)\n\t\tconst firstSpan = spans[0]\n\t\tconst lastSpan = last(spans)\n\n\t\tif (!firstSpan || !lastSpan) {\n\t\t\t// measureTextSpans can return no spans when the text can't be laid\n\t\t\t// out yet \u2014 e.g. the measurement element isn't attached/visible\n\t\t\t// during the initial editor mount, or every grapheme lacks a layout\n\t\t\t// rect. Fall back to a zero-width heading and don't cache it, so the\n\t\t\t// heading is remeasured once measurement is possible.\n\t\t\treturn new Box(0, -opts.height, 0, opts.height)\n\t\t}\n\n\t\twidth = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x\n\t\tmeasurementWeakmap.set(shape.props, width)\n\t}\n\n\treturn new Box(0, -opts.height, width, opts.height)\n}\n\nexport function getFrameHeadingOpts(width: number, isSvg: boolean): TLCreateTextJsxFromSpansOpts {\n\treturn {\n\t\tfontSize: 12,\n\t\tfontFamily: isSvg ? 'Arial' : 'Inter, sans-serif',\n\t\ttextAlign: 'start' as const,\n\t\twidth: width,\n\t\theight: 24, // --frame-height\n\t\tpadding: 0,\n\t\tlineHeight: 1,\n\t\tfontStyle: 'normal',\n\t\tfontWeight: 'normal',\n\t\toverflow: 'truncate-ellipsis' as const,\n\t\tverticalTextAlign: 'middle' as const,\n\t\toffsetY: -(32 + 2), // --frame-minimum-height + (border width * 2)\n\t\toffsetX: 0,\n\t}\n}\n\nexport function getFrameHeadingTranslation(\n\tshape: TLFrameShape,\n\tside: 0 | 1 | 2 | 3,\n\tisSvg: boolean\n) {\n\tconst u = isSvg ? '' : 'px'\n\tconst r = isSvg ? '' : 'deg'\n\tlet labelTranslate: string\n\tswitch (side) {\n\t\tcase 0: // top\n\t\t\tlabelTranslate = ``\n\t\t\tbreak\n\t\tcase 3: // right\n\t\t\tlabelTranslate = `translate(${toDomPrecision(shape.props.w)}${u}, 0${u}) rotate(90${r})`\n\t\t\tbreak\n\t\tcase 2: // bottom\n\t\t\tlabelTranslate = `translate(${toDomPrecision(shape.props.w)}${u}, ${toDomPrecision(\n\t\t\t\tshape.props.h\n\t\t\t)}${u}) rotate(180${r})`\n\t\t\tbreak\n\t\tcase 1: // left\n\t\t\tlabelTranslate = `translate(0${u}, ${toDomPrecision(shape.props.h)}${u}) rotate(270${r})`\n\t\t\tbreak\n\t\tdefault:\n\t\t\tthrow Error('labelSide out of bounds')\n\t}\n\n\treturn labelTranslate\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,EACC;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA,OACM;AAGA,SAAS,eAAe,KAAa,MAAc;AACzD,MAAI,IAAI,MAAM,OAAO,GAAG;AACvB,WAAO;AAAA,EACR;AACA,SAAO;AACR;AAEO,SAAS,oBAAoB,QAAgB,OAAoC;AACvF,QAAM,eAAe,qBAAqB,OAAO,sBAAsB,MAAM,EAAE,EAAG,SAAS,CAAC;AAC5F,QAAM,iBAAiB,eAAe,KAAK,KAAK;AAChD,QAAM,kBAAkB,kBAAkB,IAAI,KAAK,MAAM,KAAK;AAC9D,SAAO,KAAK,MAAM,cAAc;AACjC;AAQA,MAAM,qBAAqB,oBAAI,QAAQ;AAWhC,SAAS,oBACf,QACA,OACA,MACC;AACD,MAAI,QAAQ,IAAI,aAAa,QAAQ;AAEpC,WAAO,IAAI,IAAI,GAAG,CAAC,KAAK,QAAQ,MAAM,MAAM,GAAG,KAAK,MAAM;AAAA,EAC3D;AAEA,MAAI,QAAQ,mBAAmB,IAAI,MAAM,KAAK;AAC9C,MAAI,CAAC,OAAO;AACX,UAAM,aAAa,eAAe,MAAM,MAAM,MAAM,OAAO,IAAI,OAAO,aAAa,IAAI;AACvF,UAAM,QAAQ,OAAO,YAAY,iBAAiB,YAAY,IAAI;AAClE,UAAM,YAAY,MAAM,CAAC;AACzB,UAAM,WAAW,KAAK,KAAK;AAE3B,QAAI,CAAC,aAAa,CAAC,UAAU;AAM5B,aAAO,IAAI,IAAI,GAAG,CAAC,KAAK,QAAQ,GAAG,KAAK,MAAM;AAAA,IAC/C;AAEA,YAAQ,SAAS,IAAI,IAAI,SAAS,IAAI,IAAI,UAAU,IAAI;AACxD,uBAAmB,IAAI,MAAM,OAAO,KAAK;AAAA,EAC1C;AAEA,SAAO,IAAI,IAAI,GAAG,CAAC,KAAK,QAAQ,OAAO,KAAK,MAAM;AACnD;AAEO,SAAS,oBAAoB,OAAe,OAA8C;AAChG,SAAO;AAAA,IACN,UAAU;AAAA,IACV,YAAY,QAAQ,UAAU;AAAA,IAC9B,WAAW;AAAA,IACX;AAAA,IACA,QAAQ;AAAA;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,WAAW;AAAA,IACX,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,mBAAmB;AAAA,IACnB,SAAS,EAAE,KAAK;AAAA;AAAA,IAChB,SAAS;AAAA,EACV;AACD;AAEO,SAAS,2BACf,OACA,MACA,OACC;AACD,QAAM,IAAI,QAAQ,KAAK;AACvB,QAAM,IAAI,QAAQ,KAAK;AACvB,MAAI;AACJ,UAAQ,MAAM;AAAA,IACb,KAAK;AACJ,uBAAiB;AACjB;AAAA,IACD,KAAK;AACJ,uBAAiB,aAAa,eAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC;AACrF;AAAA,IACD,KAAK;AACJ,uBAAiB,aAAa,eAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,KAAK;AAAA,QACnE,MAAM,MAAM;AAAA,MACb,CAAC,GAAG,CAAC,eAAe,CAAC;AACrB;AAAA,IACD,KAAK;AACJ,uBAAiB,cAAc,CAAC,KAAK,eAAe,MAAM,MAAM,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC;AACtF;AAAA,IACD;AACC,YAAM,MAAM,yBAAyB;AAAA,EACvC;AAEA,SAAO;AACR;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -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
|
|
@@ -16,6 +15,7 @@ import {
|
|
|
16
15
|
} from "@tldraw/editor";
|
|
17
16
|
import { useEffect } from "react";
|
|
18
17
|
import { useActions } from "../context/actions.mjs";
|
|
18
|
+
import { splitKbd } from "../kbd-utils.mjs";
|
|
19
19
|
import { useCommentingEnabled } from "./useCommentingEnabled.mjs";
|
|
20
20
|
import { useReadonly } from "./useReadonly.mjs";
|
|
21
21
|
import { useTools } from "./useTools.mjs";
|
|
@@ -261,7 +261,7 @@ const PHYSICAL_KEY_MAP = {
|
|
|
261
261
|
};
|
|
262
262
|
function parseKbd(kbd) {
|
|
263
263
|
const out = [];
|
|
264
|
-
for (const shortcut of
|
|
264
|
+
for (const shortcut of splitKbd(kbd.replace(/\s/g, ""))) {
|
|
265
265
|
const parsed = parseShortcut(shortcut);
|
|
266
266
|
if (parsed) out.push(parsed);
|
|
267
267
|
}
|
|
@@ -337,7 +337,7 @@ function shouldSkipEvent(e) {
|
|
|
337
337
|
return false;
|
|
338
338
|
}
|
|
339
339
|
function getHotkeysStringFromKbd(kbd) {
|
|
340
|
-
return
|
|
340
|
+
return splitKbd(kbd.replace(/\s/g, "")).map((kbd2) => {
|
|
341
341
|
let str = "";
|
|
342
342
|
const shift = kbd2.includes("!");
|
|
343
343
|
const alt = kbd2.includes("?");
|
|
@@ -363,18 +363,6 @@ function getHotkeysStringFromKbd(kbd) {
|
|
|
363
363
|
return str;
|
|
364
364
|
}).join(",");
|
|
365
365
|
}
|
|
366
|
-
function getKeys(key) {
|
|
367
|
-
if (typeof key !== "string") key = "";
|
|
368
|
-
key = key.replace(/\s/g, "");
|
|
369
|
-
const keys = key.split(",");
|
|
370
|
-
let index = keys.lastIndexOf("");
|
|
371
|
-
for (; index >= 0; ) {
|
|
372
|
-
keys[index - 1] += ",";
|
|
373
|
-
keys.splice(index, 1);
|
|
374
|
-
index = keys.lastIndexOf("");
|
|
375
|
-
}
|
|
376
|
-
return keys;
|
|
377
|
-
}
|
|
378
366
|
export {
|
|
379
367
|
areShortcutsDisabled,
|
|
380
368
|
getHotkeysStringFromKbd,
|
|
@@ -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": "AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;
|
|
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": "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",
|
|
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
|
-
|
|
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-next.ef99ab47e5ce";
|
|
2
2
|
const publishDates = {
|
|
3
3
|
major: "2026-05-06T16:28:18.473Z",
|
|
4
|
-
minor: "2026-08-05T11:
|
|
5
|
-
patch: "2026-08-05T11:
|
|
4
|
+
minor: "2026-08-05T11:52:36.664Z",
|
|
5
|
+
patch: "2026-08-05T11:52:36.664Z"
|
|
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-next.ef99ab47e5ce'\nexport const publishDates = {\n\tmajor: '2026-05-06T16:28:18.473Z',\n\tminor: '2026-08-05T11:52:36.664Z',\n\tpatch: '2026-08-05T11:52:36.664Z',\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-next.ef99ab47e5ce",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -63,11 +63,11 @@
|
|
|
63
63
|
"@tiptap/pm": "^3.12.1",
|
|
64
64
|
"@tiptap/react": "^3.12.1",
|
|
65
65
|
"@tiptap/starter-kit": "^3.12.1",
|
|
66
|
-
"@tldraw/driver": "5.
|
|
67
|
-
"@tldraw/editor": "5.
|
|
68
|
-
"@tldraw/state": "5.
|
|
69
|
-
"@tldraw/store": "5.
|
|
70
|
-
"@tldraw/tlschema": "5.
|
|
66
|
+
"@tldraw/driver": "5.4.0-next.ef99ab47e5ce",
|
|
67
|
+
"@tldraw/editor": "5.4.0-next.ef99ab47e5ce",
|
|
68
|
+
"@tldraw/state": "5.4.0-next.ef99ab47e5ce",
|
|
69
|
+
"@tldraw/store": "5.4.0-next.ef99ab47e5ce",
|
|
70
|
+
"@tldraw/tlschema": "5.4.0-next.ef99ab47e5ce",
|
|
71
71
|
"classnames": "^2.5.1",
|
|
72
72
|
"idb": "^7.1.1",
|
|
73
73
|
"lz-string": "^1.5.0",
|
|
@@ -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)
|
|
@@ -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-next.ef99ab47e5ce'
|
|
5
5
|
export const publishDates = {
|
|
6
6
|
major: '2026-05-06T16:28:18.473Z',
|
|
7
|
-
minor: '2026-08-05T11:
|
|
8
|
-
patch: '2026-08-05T11:
|
|
7
|
+
minor: '2026-08-05T11:52:36.664Z',
|
|
8
|
+
patch: '2026-08-05T11:52:36.664Z',
|
|
9
9
|
}
|
|
@@ -38,14 +38,14 @@ describe('when less than two shapes are selected', () => {
|
|
|
38
38
|
describe('when multiple shapes are selected', () => {
|
|
39
39
|
it('does, undoes and redoes command', () => {
|
|
40
40
|
editor.markHistoryStoppingPoint('align')
|
|
41
|
-
editor.alignShapes(editor.getSelectedShapeIds(), '
|
|
41
|
+
editor.alignShapes(editor.getSelectedShapeIds(), 'center')
|
|
42
42
|
vi.advanceTimersByTime(1000)
|
|
43
43
|
|
|
44
|
-
editor.expectShapeToMatch({ id: ids.boxB, y:
|
|
44
|
+
editor.expectShapeToMatch({ id: ids.boxB, x: 225, y: 225 })
|
|
45
45
|
editor.undo()
|
|
46
|
-
editor.expectShapeToMatch({ id: ids.boxB, y: 100 })
|
|
46
|
+
editor.expectShapeToMatch({ id: ids.boxB, x: 100, y: 100 })
|
|
47
47
|
editor.redo()
|
|
48
|
-
editor.expectShapeToMatch({ id: ids.boxB, y:
|
|
48
|
+
editor.expectShapeToMatch({ id: ids.boxB, x: 225, y: 225 })
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
it('aligns top', () => {
|
|
@@ -114,28 +114,37 @@ describe('when multiple shapes are selected', () => {
|
|
|
114
114
|
)
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
+
it('aligns center on both axes', () => {
|
|
118
|
+
editor.alignShapes(editor.getSelectedShapeIds(), 'center')
|
|
119
|
+
vi.advanceTimersByTime(1000)
|
|
120
|
+
|
|
121
|
+
editor.expectShapeToMatch(
|
|
122
|
+
{ id: ids.boxA, x: 200, y: 200 },
|
|
123
|
+
{ id: ids.boxB, x: 225, y: 225 },
|
|
124
|
+
{ id: ids.boxC, x: 200, y: 200 }
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
117
128
|
it('aligns center, when shapes are rotated', () => {
|
|
118
129
|
editor.updateShapes([
|
|
119
130
|
{
|
|
120
131
|
id: ids.boxA,
|
|
121
132
|
type: 'geo',
|
|
122
|
-
rotation:
|
|
133
|
+
rotation: 0.2,
|
|
123
134
|
},
|
|
124
135
|
{
|
|
125
136
|
id: ids.boxB,
|
|
126
137
|
type: 'geo',
|
|
127
|
-
rotation:
|
|
138
|
+
rotation: 0.4,
|
|
128
139
|
},
|
|
129
140
|
{
|
|
130
141
|
id: ids.boxC,
|
|
131
142
|
type: 'geo',
|
|
132
|
-
rotation:
|
|
143
|
+
rotation: 0.6,
|
|
133
144
|
},
|
|
134
145
|
])
|
|
135
146
|
|
|
136
|
-
editor.alignShapes(editor.getSelectedShapeIds(), 'center
|
|
137
|
-
vi.advanceTimersByTime(1000)
|
|
138
|
-
editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal')
|
|
147
|
+
editor.alignShapes(editor.getSelectedShapeIds(), 'center')
|
|
139
148
|
vi.advanceTimersByTime(1000)
|
|
140
149
|
|
|
141
150
|
const commonBounds = Box.Common([
|
|
@@ -37,6 +37,110 @@ it('Ctrl Key', () => {
|
|
|
37
37
|
expect(editor.inputs.getCtrlKey()).toBe(false)
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
it('pointer down releases a modifier still in its debounce window', () => {
|
|
41
|
+
// Collect any synthetic key_up events fired when the release is flushed.
|
|
42
|
+
const keyUps: string[] = []
|
|
43
|
+
editor.on('event', (info) => {
|
|
44
|
+
if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info.key)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Hold then release meta via pointer events; the release is debounced for 150ms.
|
|
48
|
+
editor.pointerMove(100, 100, { metaKey: true })
|
|
49
|
+
editor.pointerMove(100, 100, { metaKey: false })
|
|
50
|
+
expect(editor.inputs.getMetaKey()).toBe(true) // still held during the debounce window
|
|
51
|
+
|
|
52
|
+
// A new interaction starts with the key physically up: the lingering modifier
|
|
53
|
+
// should be released immediately (same path as the timer), including its
|
|
54
|
+
// synthetic key_up, without waiting for the timeout.
|
|
55
|
+
editor.pointerDown(100, 100, { metaKey: false })
|
|
56
|
+
expect(editor.inputs.getMetaKey()).toBe(false)
|
|
57
|
+
expect(keyUps).toEqual(['Meta'])
|
|
58
|
+
|
|
59
|
+
// The pending timer must have been cancelled, so advancing past it does nothing
|
|
60
|
+
// and doesn't fire the synthetic key_up a second time.
|
|
61
|
+
vi.advanceTimersByTime(200)
|
|
62
|
+
expect(editor.inputs.getMetaKey()).toBe(false)
|
|
63
|
+
expect(keyUps).toEqual(['Meta'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('pointer down releases every modifier still in its debounce window', () => {
|
|
67
|
+
const keyUps: string[] = []
|
|
68
|
+
editor.on('event', (info) => {
|
|
69
|
+
if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info.key)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
editor.pointerMove(100, 100, { shiftKey: true, altKey: true, ctrlKey: true, metaKey: true })
|
|
73
|
+
editor.pointerMove(100, 100, { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false })
|
|
74
|
+
expect(editor.inputs.getShiftKey()).toBe(true)
|
|
75
|
+
expect(editor.inputs.getAltKey()).toBe(true)
|
|
76
|
+
expect(editor.inputs.getCtrlKey()).toBe(true)
|
|
77
|
+
expect(editor.inputs.getMetaKey()).toBe(true)
|
|
78
|
+
|
|
79
|
+
editor.pointerDown(100, 100, { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false })
|
|
80
|
+
expect(editor.inputs.getShiftKey()).toBe(false)
|
|
81
|
+
expect(editor.inputs.getAltKey()).toBe(false)
|
|
82
|
+
expect(editor.inputs.getCtrlKey()).toBe(false)
|
|
83
|
+
expect(editor.inputs.getMetaKey()).toBe(false)
|
|
84
|
+
|
|
85
|
+
// Each flushed modifier fires its synthetic key_up so tools can react.
|
|
86
|
+
expect(keyUps).toEqual(expect.arrayContaining(['Shift', 'Alt', 'Ctrl', 'Meta']))
|
|
87
|
+
expect(keyUps).toHaveLength(4)
|
|
88
|
+
|
|
89
|
+
// All four timers were cancelled, so nothing flips back or fires again.
|
|
90
|
+
vi.advanceTimersByTime(200)
|
|
91
|
+
expect(editor.inputs.getShiftKey()).toBe(false)
|
|
92
|
+
expect(editor.inputs.getAltKey()).toBe(false)
|
|
93
|
+
expect(editor.inputs.getCtrlKey()).toBe(false)
|
|
94
|
+
expect(editor.inputs.getMetaKey()).toBe(false)
|
|
95
|
+
expect(keyUps).toHaveLength(4)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('pointer down clears stale key codes when flushing a debounced modifier', () => {
|
|
99
|
+
// A real key_down records the code, then the release is only seen via pointer
|
|
100
|
+
// flags (no keyboard key_up), so the code lingers in inputs.keys until flushed.
|
|
101
|
+
editor.keyDown('Shift')
|
|
102
|
+
expect(editor.inputs.keys.has('ShiftLeft')).toBe(true)
|
|
103
|
+
|
|
104
|
+
editor.pointerMove(100, 100, { shiftKey: false })
|
|
105
|
+
expect(editor.inputs.getShiftKey()).toBe(true) // debounced, still counted as held
|
|
106
|
+
expect(editor.inputs.keys.has('ShiftLeft')).toBe(true) // code not yet cleared
|
|
107
|
+
|
|
108
|
+
// The pointer down flushes the debounce through the timer's path, which clears
|
|
109
|
+
// both the modifier atom and the stale code.
|
|
110
|
+
editor.pointerDown(100, 100, { shiftKey: false })
|
|
111
|
+
expect(editor.inputs.getShiftKey()).toBe(false)
|
|
112
|
+
expect(editor.inputs.keys.has('ShiftLeft')).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('does not re-release a modifier after its natural debounce timer fired', () => {
|
|
116
|
+
const keyUps: string[] = []
|
|
117
|
+
editor.on('event', (info) => {
|
|
118
|
+
if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info.key)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Hold then release shift via pointer events and let the 150ms timer fire on its
|
|
122
|
+
// own, which releases shift with a single synthetic key_up.
|
|
123
|
+
editor.pointerMove(100, 100, { shiftKey: true })
|
|
124
|
+
editor.pointerMove(100, 100, { shiftKey: false })
|
|
125
|
+
vi.advanceTimersByTime(200)
|
|
126
|
+
expect(editor.inputs.getShiftKey()).toBe(false)
|
|
127
|
+
expect(keyUps).toEqual(['Shift'])
|
|
128
|
+
|
|
129
|
+
// A later pointer down must not treat the already-fired timer as an active
|
|
130
|
+
// debounce and emit another synthetic key_up.
|
|
131
|
+
editor.pointerDown(100, 100, { shiftKey: false })
|
|
132
|
+
expect(keyUps).toEqual(['Shift'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('pointer down keeps a genuinely held modifier', () => {
|
|
136
|
+
// Shift is actually held during the pointer down (flag stays true), so it must
|
|
137
|
+
// not be cleared by the debounce-flush.
|
|
138
|
+
editor.pointerMove(100, 100, { shiftKey: true })
|
|
139
|
+
expect(editor.inputs.getShiftKey()).toBe(true)
|
|
140
|
+
editor.pointerDown(100, 100, { shiftKey: true })
|
|
141
|
+
expect(editor.inputs.getShiftKey()).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
40
144
|
it('keyboard events carry currently-held modifiers', () => {
|
|
41
145
|
const keyboardEvents: TLKeyboardEventInfo[] = []
|
|
42
146
|
editor.on('event', (info) => {
|