@dailies-studio/shot-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,560 @@
1
+ // The keymap, as a PURE function from a key event to an action.
2
+ //
3
+ // Pure so it can be walked as a table by a test, and so "what does this key do"
4
+ // has exactly one answer that a reader can find. The store turns actions into
5
+ // document edits; nothing else in this chrome may bind a key.
6
+ //
7
+ // ── THE ONE ORDERED RULE ─────────────────────────────────────────────────
8
+ //
9
+ // 1. A field being typed into wins. Always, before anything else is even
10
+ // considered.
11
+ // 1b. A pen path in progress owns Enter and Escape, and only those two.
12
+ // 1c. A LIVE MODAL TRANSFORM owns its terminators and axis keys, pen-style,
13
+ // and blanks every other bare key so a tool cannot arm mid-modal.
14
+ // 1d. NUMPAD codes resolve as working-view verbs, BEFORE the modifier lanes
15
+ // because ⌘Numpad1 is "back view", not a browser combo.
16
+ // 2. MODIFIER COMBOS resolve next: ctrl/meta first, then alt.
17
+ // 3. Bare and shifted letters resolve LAST, and `toolForKey` is consulted
18
+ // only when `!ctrl && !meta && !alt` — with the working pane's modal
19
+ // starters (G/R/S) checked immediately before it.
20
+ //
21
+ // That order is what lets `Z`, `⇧Z`, `⌘Z`, `⌘⇧Z` and z-as-axis mean five
22
+ // different things without any of them shadowing another. Reverse it — check
23
+ // the letter first — and `⌘Z` arms whatever tool `Z` is bound to before undo
24
+ // is ever reached.
25
+ //
26
+ // ── THREE AMBIGUITIES IN RAYLIGHT'S PUBLISHED LIST, RESOLVED ────────────
27
+ //
28
+ // ARROWS are listed as both "nudge the playhead" and "nudge the layer".
29
+ // SELECTION WINS; an empty selection falls through to the playhead. That is
30
+ // the only reading where both are reachable without a mode.
31
+ //
32
+ // `C` was listed as both the cut tool and split-at-playhead. THE CUT TOOL IS
33
+ // GONE — it was a fourteenth row in the tool table that split a scene under
34
+ // the cursor, which `⌘B` already does from the playhead with no mode to
35
+ // arm and no mode to leave. `⌘B` is now the only splitter, and `⇧C` is
36
+ // Blender's "frame all" rather than the camera-move it used to be (the
37
+ // `+ shot` button owns that, via `store.addShotAtPlayhead`).
38
+ //
39
+ // SPACE is both play/pause and hold-to-pan. It fires on KEYUP, and only when
40
+ // no pan drag happened during the hold — which is why the stage owns the
41
+ // suppression (it is the half that knows) and the chrome, which cannot pan,
42
+ // always fires.
43
+ import { toolForKey } from "../tools.js";
44
+ import type { ShotTool } from "../tools.js";
45
+ import type { StageLayout } from "../stage/panes.js";
46
+ import { ORBIT_STEP_DEG } from "../stage/workingView.js";
47
+
48
+ export type KeyAction =
49
+ | { kind: "tool"; tool: ShotTool }
50
+ | { kind: "undo" }
51
+ | { kind: "redo" }
52
+ | { kind: "save" }
53
+ | { kind: "playToggle" }
54
+ /** Move the playhead by whole frames. */
55
+ | { kind: "step"; delta: number }
56
+ /** Move the selection, in design px. */
57
+ | { kind: "nudge"; dx: number; dy: number }
58
+ | { kind: "fit" }
59
+ /** Fit the whole document across the timeline dock. A SECOND fit and not an
60
+ * arm of the first: `⇧Z` addresses the active stage pane, and the dock is
61
+ * never a pane. */
62
+ | { kind: "fitTimeline" }
63
+ | { kind: "zoom"; direction: 1 | -1 }
64
+ | { kind: "zoomReset" }
65
+ /** How many panes the stage shows. `toggle` means "…and back again if that
66
+ * is where we already are", which is what numpad-0 needs and what the
67
+ * 1 / 2 / 3 row deliberately does NOT: pressing 2 twice must not leave the
68
+ * split. */
69
+ | { kind: "stageLayout"; mode: StageLayout["mode"]; toggle?: boolean }
70
+ /** Point the shot camera where the working pane is looking. */
71
+ | { kind: "alignCameraToView" }
72
+ /** Drop a marker at the playhead. */
73
+ | { kind: "addMarker" }
74
+ /** Hide a side rail, or every panel at once. */
75
+ | { kind: "togglePanel"; panel: "rail" | "inspector" | "all" }
76
+ | { kind: "animateToggle" }
77
+ | { kind: "openExport" }
78
+ | { kind: "deleteSelection" }
79
+ | { kind: "duplicate" }
80
+ | { kind: "selectAll" }
81
+ | { kind: "deselect" }
82
+ | { kind: "splitAtPlayhead" }
83
+ | { kind: "group" }
84
+ | { kind: "ungroup" }
85
+ | { kind: "toggleLock" }
86
+ | { kind: "toggleHidden" }
87
+ /** Finish the path the pen is drawing, keeping it. */
88
+ | { kind: "penCommit" }
89
+ /** Throw away the path the pen is drawing. */
90
+ | { kind: "penAbandon" }
91
+ /** Edit the selected layer's subtree alone. */
92
+ | { kind: "enterIsolation" }
93
+ /** Come back up ONE level. */
94
+ | { kind: "exitIsolation" }
95
+ /** Begin a Blender-style modal transform in the working pane. The stage
96
+ * decides the TARGET (selection = layers, empty = the camera); the action
97
+ * only carries which transform the letter named. */
98
+ | { kind: "modalStart"; mode: "move" | "rotate" | "scale" }
99
+ /** Constrain the live modal transform to one axis; the same axis again
100
+ * clears it (the reducer owns that toggle, not this table). */
101
+ | { kind: "modalAxis"; axis: "x" | "y" | "z" }
102
+ /** One character typed into the live transform's numeric buffer: a digit, a
103
+ * decimal point, or `-` to flip the sign (Blender's own rule). `G X 200
104
+ * Enter` is what this exists for. */
105
+ | { kind: "modalNumber"; ch: string }
106
+ /** Backspace over that buffer; emptying it hands the transform back to the
107
+ * mouse, which is what makes a mistyped digit recoverable. */
108
+ | { kind: "modalNumberBack" }
109
+ | { kind: "modalConfirm" }
110
+ | { kind: "modalCancel" }
111
+ /** Cycle the working pane's shading (solid → wireframe → material →
112
+ * rendered). The camera pane is always the graded render. */
113
+ | { kind: "shadingCycle" }
114
+ /** Snap the working camera to an axis view, Blender numpad style. */
115
+ | {
116
+ kind: "workingViewPreset";
117
+ preset: "front" | "back" | "right" | "left" | "top" | "bottom";
118
+ }
119
+ /** Frame the selection in the working view (numpad-dot). */
120
+ | { kind: "fitSelection" }
121
+ /** Orbit the working view by a fixed number of DEGREES, numpad 2/4/6/8 and
122
+ * the half turn on 9. Degrees rather than pixels, so the step means the same
123
+ * thing whatever `ORBIT_DEG_PER_PX` is. */
124
+ | { kind: "orbitStep"; dx: number; dy: number }
125
+ /** Numpad-slash: Blender's local view, which is this editor's isolation. In
126
+ * or out, decided by where the author already is. */
127
+ | { kind: "toggleIsolation" }
128
+ /** Numpad-5: the working camera's own projection. Editor state, no schema. */
129
+ | { kind: "toggleWorkingOrtho" }
130
+ /** Bare B: arm a one-shot box select, Blender's own. Consumed by the STAGE,
131
+ * which reads it beside `e.shiftKey` in its marquee lane. */
132
+ | { kind: "boxSelect" }
133
+ /**
134
+ * Hide explicitly, rather than toggling `selection[0]`'s own flag.
135
+ *
136
+ * `"selection"` hides what is selected, `"others"` hides everything else
137
+ * (⇧H), `"reveal"` unhides the whole scene (⌥H). The toggle had no way back
138
+ * once the hidden layers were deselected, which is most of the time.
139
+ */
140
+ | { kind: "setHidden"; scope: "selection" | "others" | "reveal" }
141
+ /** ⌥G / ⌥R / ⌥S: back to the layer's own zero on one channel. */
142
+ | { kind: "clearTransform"; channel: "offset" | "rotation" | "scale" }
143
+ /** ⇧Z: wireframe on, and back to whatever the shading was. Blender's. */
144
+ | { kind: "wireframeToggle" };
145
+
146
+ /** What the key event lands in. Only what actually changes the ANSWER — a
147
+ * keymap that took the whole store would be a keymap nobody could test. */
148
+ export interface KeyContext {
149
+ /** The event target is a field, a textarea or a contenteditable. */
150
+ editing: boolean;
151
+ hasSelection: boolean;
152
+ /**
153
+ * The pen HOLDS AT LEAST ONE POINT — not "the path tool is armed".
154
+ *
155
+ * The distinction is the whole reason this field exists. Escape with a
156
+ * half-drawn path must abandon the path and nothing else; Escape with the
157
+ * pen armed but empty must still mean deselect, exactly as it did before
158
+ * the pen had a keymap entry. Defining this as "tool === path" would take
159
+ * Escape away from the chrome for as long as the tool was armed, which is
160
+ * a regression a keymap test would then happily pin as intended.
161
+ *
162
+ * Absent counts as false, so every caller that has no pen keeps today's
163
+ * answers.
164
+ */
165
+ penActive?: boolean;
166
+ /** The author is inside an isolated subtree. Only Escape's precedence reads
167
+ * it, and only once nothing is selected — see the lane. Absent = false. */
168
+ isolation?: boolean;
169
+ /**
170
+ * The CURSOR IS OVER THE WORKING PANE and a modal transform could start.
171
+ *
172
+ * Stage-side callers only: the chrome has no cursor-over-pane answer, and
173
+ * passing nothing keeps G arming the move tool from a panel exactly as it
174
+ * always has. Cursor position and not `activePane`, because a modal needs an
175
+ * anchor point and activePane is only written on press/wheel — the mouse can
176
+ * hover one pane while the other is active. Absent = false.
177
+ */
178
+ workingPane?: boolean;
179
+ /** A modal transform is LIVE. Owns its terminator and axis keys, the way
180
+ * `penActive` owns Enter/Escape. Absent = false. */
181
+ transformModal?: boolean;
182
+ }
183
+
184
+ export interface KeyEventLike {
185
+ key: string;
186
+ shiftKey: boolean;
187
+ altKey: boolean;
188
+ ctrlKey: boolean;
189
+ metaKey: boolean;
190
+ /** `KeyboardEvent.code`, for the numpad lane. Optional so every caller and
191
+ * probe that predates it keeps today's answers; absent = no numpad. */
192
+ code?: string;
193
+ }
194
+
195
+ /** How far a plain arrow moves a layer, and how far a shifted one does. Design
196
+ * px, so the nudge is the same size at every zoom — a nudge that scaled with
197
+ * the view would be a different edit depending on how far you were zoomed in. */
198
+ export const NUDGE_PX = 1;
199
+ export const NUDGE_COARSE_PX = 10;
200
+
201
+ /**
202
+ * The view-mode digits, LEFT TO RIGHT as the toolbar draws them.
203
+ *
204
+ * Keyed on `event.key` and not on a folded letter, which is what keeps them
205
+ * bare-only for free: shift-1 reports "!" on every layout that has one, so a
206
+ * shifted digit falls through to the tool lane and finds nothing rather than
207
+ * needing a guard. The modifier lanes above have already returned by here, so
208
+ * ⌘1 stays the browser's tab switch.
209
+ */
210
+ const LAYOUT_KEYS: Readonly<Record<string, StageLayout["mode"]>> = {
211
+ "1": "working",
212
+ "2": "split",
213
+ "3": "camera",
214
+ };
215
+
216
+ const ARROWS: Readonly<Record<string, readonly [number, number]>> = {
217
+ ArrowLeft: [-1, 0],
218
+ ArrowRight: [1, 0],
219
+ ArrowUp: [0, -1],
220
+ ArrowDown: [0, 1],
221
+ };
222
+
223
+ export function resolveKey(event: KeyEventLike, context: KeyContext): KeyAction | null {
224
+ // 1. A field owns its own keystrokes. Every editor that has ever swallowed
225
+ // Space while the author was naming something shipped that bug by checking
226
+ // the KEY before the TARGET.
227
+ if (context.editing) return null;
228
+
229
+ const key = event.key;
230
+ const lower = key.length === 1 ? key.toLowerCase() : key;
231
+ const mod = event.metaKey || event.ctrlKey;
232
+
233
+ // 1b. A PATH IN PROGRESS owns Enter and Escape, and only those two.
234
+ //
235
+ // Above every other lane because both keys already mean something here
236
+ // (Escape deselects, Enter is unclaimed) and a half-drawn path is the
237
+ // more local subject: an author pressing Escape mid-path is abandoning
238
+ // the path, not their selection. Two keys and no more, so everything
239
+ // else still reaches the chrome while the pen is up.
240
+ if (context.penActive === true && !mod) {
241
+ if (key === "Enter") return { kind: "penCommit" };
242
+ if (key === "Escape") return { kind: "penAbandon" };
243
+ }
244
+
245
+ // 1c. A LIVE MODAL TRANSFORM owns everything bare.
246
+ //
247
+ // Enter/Escape terminate it and x/y/z constrain it (shift allowed —
248
+ // shift is the live precision modifier, so ⇧Z mid-modal is still the z
249
+ // axis, not fit). Every OTHER bare key answers null so no tool can arm
250
+ // and no panel can toggle under an open scrub. Modifier combos fall
251
+ // through on purpose: the table stays honest about what ⌘Z means — the
252
+ // stage's capture listener is the one that swallows it while the modal
253
+ // is live, and that suppression is the listener's to own, not this
254
+ // table's to hide.
255
+ //
256
+ // ALT FALLS THROUGH, and `mod` alone does not cover it: `mod` is
257
+ // ctrl/meta, so ⌥G reached this lane and RESTARTED the modal instead of
258
+ // reaching lane 2b's `clearTransform` (Blender's ⌥G / ⌥R / ⌥S, "clear
259
+ // this channel"), and ⌥X set an axis. The alt bindings are a different
260
+ // verb that happens to share a letter, so the lane has to let them past.
261
+ if (context.transformModal === true && !mod && !event.altKey) {
262
+ if (key === "Enter") return { kind: "modalConfirm" };
263
+ if (key === "Escape") return { kind: "modalCancel" };
264
+ if (lower === "x") return { kind: "modalAxis", axis: "x" };
265
+ if (lower === "y") return { kind: "modalAxis", axis: "y" };
266
+ if (lower === "z") return { kind: "modalAxis", axis: "z" };
267
+ // SWITCHING TRANSFORM MID-GESTURE, which in Blender feels like a mode
268
+ // change rather than a restart: `G` then `R` keeps the anchor the author
269
+ // started from, so the rotation is measured from where they pressed and not
270
+ // from wherever the pointer had wandered to.
271
+ if (lower === "g") return { kind: "modalStart", mode: "move" };
272
+ // `R` NEEDS A SELECTION, exactly as the starter lane below does and for the
273
+ // same reason: the CAMERA has no modal roll. Without the guard, switching
274
+ // to rotate on a camera modal armed a transform that could never emit,
275
+ // under a pill that still said CAMERA MOVE.
276
+ if (lower === "r" && context.hasSelection) return { kind: "modalStart", mode: "rotate" };
277
+ if (lower === "s") return { kind: "modalStart", mode: "scale" };
278
+ // NUMERIC ENTRY: `G X 200 Enter`, which is the single biggest thing a
279
+ // Blender user's fingers reach for and finds missing. It goes through the
280
+ // TABLE rather than being intercepted inside the modal's own listener,
281
+ // because this file's header says nothing else in this chrome may bind a
282
+ // key — and a lane that quietly claimed the digits would make that false.
283
+ //
284
+ // `-` toggles the sign the way Blender's does, and `.` starts a decimal.
285
+ if (/^[0-9.-]$/.test(key)) return { kind: "modalNumber", ch: key };
286
+ if (key === "Backspace") return { kind: "modalNumberBack" };
287
+ return null;
288
+ }
289
+
290
+ // 1d. NUMPAD, by `code` and not by `key`, which is what makes it NumLock-
291
+ // agnostic the way Blender's is: NumLock-off Numpad7 reports key "Home"
292
+ // but code "Numpad7", and it should mean "top view" either way. Before
293
+ // the modifier lanes because ⌘Numpad1 is "back view", not a browser
294
+ // combo.
295
+ if (event.code !== undefined && event.code.startsWith("Numpad")) {
296
+ switch (event.code) {
297
+ case "Numpad1":
298
+ return { kind: "workingViewPreset", preset: mod ? "back" : "front" };
299
+ case "Numpad3":
300
+ return { kind: "workingViewPreset", preset: mod ? "left" : "right" };
301
+ case "Numpad7":
302
+ return { kind: "workingViewPreset", preset: mod ? "bottom" : "top" };
303
+ case "Numpad9":
304
+ // Blender's "look from the opposite side": a half turn of the azimuth,
305
+ // which is the one preset that is relative rather than absolute.
306
+ if (!mod) return { kind: "orbitStep", dx: 180, dy: 0 };
307
+ break;
308
+ case "Numpad2":
309
+ case "Numpad4":
310
+ case "Numpad6":
311
+ case "Numpad8": {
312
+ // ORBIT BY A FIXED STEP, Blender's own 15 degrees. The arrows around
313
+ // the 5 turn the view the way dragging would, which is what makes the
314
+ // numpad usable for a precise angle without a mouse at all.
315
+ //
316
+ // The magnitude lives in `workingView.ts` beside `ORBIT_DEG_PER_PX`,
317
+ // never here: a keymap that invented its own step would be a second
318
+ // opinion on how far a turn is.
319
+ // NUMLOCK ON ONLY, which `key.length === 1` is this file's test for.
320
+ // With NumLock off these four report "ArrowLeft" / "ArrowUp" and so on,
321
+ // and the lane below deliberately lets those FALL THROUGH to keep their
322
+ // `key` meanings — an arrow nudges the selection, and stealing that for
323
+ // an orbit would take the nudge away from anyone using the numpad as a
324
+ // cursor pad.
325
+ if (mod || key.length !== 1) break;
326
+ const step: Record<string, { dx: number; dy: number }> = {
327
+ Numpad4: { dx: -1, dy: 0 },
328
+ Numpad6: { dx: 1, dy: 0 },
329
+ Numpad8: { dx: 0, dy: -1 },
330
+ Numpad2: { dx: 0, dy: 1 },
331
+ };
332
+ const one = step[event.code]!;
333
+ return { kind: "orbitStep", dx: one.dx * ORBIT_STEP_DEG, dy: one.dy * ORBIT_STEP_DEG };
334
+ }
335
+ case "Numpad0":
336
+ // A TOGGLE, matching Blender: numpad-0 goes to the camera and numpad-0
337
+ // again comes back. Straight to `camera` meant an author who pressed it
338
+ // twice was stuck there with no way back but the mouse.
339
+ if (!mod) return { kind: "stageLayout", mode: "camera", toggle: true };
340
+ break;
341
+ case "NumpadAdd":
342
+ case "NumpadSubtract":
343
+ // The existing zoom verb, which already routes to the active pane —
344
+ // so it dollies the working camera and zooms the flat one, correctly,
345
+ // with no new action.
346
+ if (!mod) return { kind: "zoom", direction: event.code === "NumpadAdd" ? 1 : -1 };
347
+ break;
348
+ case "NumpadDivide":
349
+ // LITERALLY Blender's local view. Ours is isolation, and the two mean
350
+ // the same thing now that the camera is handed over on the first
351
+ // gesture.
352
+ if (!mod) return { kind: "toggleIsolation" };
353
+ break;
354
+ case "NumpadDecimal":
355
+ if (!mod) return { kind: "fitSelection" };
356
+ break;
357
+ case "Numpad5":
358
+ if (!mod) return { kind: "toggleWorkingOrtho" };
359
+ break;
360
+ }
361
+ // An UNMODIFIED NumLock-on digit or operator (key length 1) stops here —
362
+ // otherwise Numpad2 would switch the stage layout because its key is "2".
363
+ // NumLock-off navigation keys (arrows, End, PageUp) and NumpadEnter have
364
+ // longer keys and FALL THROUGH to keep their `key` meanings.
365
+ //
366
+ // THE `!mod` IS LOAD-BEARING, and it is the whole reason this lane sits
367
+ // above the modifier lanes rather than being folded into them: ⌘Numpad+,
368
+ // ⌘Numpad− and ⌘Numpad0 are the zoom verbs, and swallowing them here left
369
+ // the browser to page-zoom the entire chrome instead. Only the numpad
370
+ // combos this lane actually CLAIMS (the view presets) may stop here.
371
+ if (!mod && key.length === 1) return null;
372
+ }
373
+
374
+ // 2a. ctrl / meta.
375
+ if (mod) {
376
+ switch (lower) {
377
+ case "z":
378
+ return event.shiftKey ? { kind: "redo" } : { kind: "undo" };
379
+ case "y":
380
+ return { kind: "redo" };
381
+ case "s":
382
+ return { kind: "save" };
383
+ case "d":
384
+ return { kind: "duplicate" };
385
+ case "e":
386
+ // Export. The one destination-producing verb in the whole chrome, so
387
+ // it gets the letter rather than a chord.
388
+ return { kind: "openExport" };
389
+ case "a":
390
+ return { kind: "selectAll" };
391
+ case "b":
392
+ // Split at the playhead, and the ONLY splitter now that the cut tool is
393
+ // gone — see the header.
394
+ return { kind: "splitAtPlayhead" };
395
+ case "g":
396
+ return event.shiftKey ? { kind: "ungroup" } : { kind: "group" };
397
+ case "0":
398
+ return { kind: "zoomReset" };
399
+ case "=":
400
+ case "+":
401
+ return { kind: "zoom", direction: 1 };
402
+ case "-":
403
+ return { kind: "zoom", direction: -1 };
404
+ default:
405
+ // An unclaimed modifier combo belongs to the BROWSER (⌘T, ⌘W, ⌘R).
406
+ // Falling through to the letter lane here would arm a tool on ⌘R and
407
+ // reload the page at the same time.
408
+ return null;
409
+ }
410
+ }
411
+
412
+ // 2b. alt.
413
+ if (event.altKey) {
414
+ switch (lower) {
415
+ case "l":
416
+ return { kind: "toggleLock" };
417
+ case "h":
418
+ // REVEAL EVERYTHING, Blender's own ⌥H, replacing a toggle that read
419
+ // `selection[0]`'s flag — so five layers hidden one at a time had no
420
+ // key that brought them back.
421
+ return { kind: "setHidden", scope: "reveal" };
422
+ case "a":
423
+ // Blender's deselect-all. Its own action and NOT Escape's lane, which
424
+ // also leaves a subtree.
425
+ return { kind: "deselect" };
426
+ case "c":
427
+ // ⌥C and not bare C, because the alt lane is where this chrome keeps
428
+ // the verbs whose letter is already spoken for.
429
+ return { kind: "alignCameraToView" };
430
+ // ── CLEAR TRANSFORM, Blender's ⌥G / ⌥R / ⌥S ────────────────────────
431
+ //
432
+ // Back to the layer's own zero: position, rotation, or size. It is the
433
+ // fastest way out of a transform that went wrong several edits ago, and
434
+ // undo cannot reach it once anything else has happened since.
435
+ case "g":
436
+ return { kind: "clearTransform", channel: "offset" };
437
+ case "r":
438
+ return { kind: "clearTransform", channel: "rotation" };
439
+ case "s":
440
+ return { kind: "clearTransform", channel: "scale" };
441
+ default:
442
+ return null;
443
+ }
444
+ }
445
+
446
+ // 3. Bare and shifted.
447
+ const arrow = ARROWS[key];
448
+ if (arrow !== undefined) {
449
+ const step = event.shiftKey ? NUDGE_COARSE_PX : NUDGE_PX;
450
+ // SELECTION WINS. With nothing selected the same keys move the playhead,
451
+ // which is the only reading where both meanings are reachable without a
452
+ // mode to switch between them.
453
+ return context.hasSelection
454
+ ? { kind: "nudge", dx: arrow[0] * step, dy: arrow[1] * step }
455
+ : { kind: "step", delta: arrow[0] !== 0 ? arrow[0] : arrow[1] };
456
+ }
457
+ const mode = LAYOUT_KEYS[key];
458
+ if (mode !== undefined) return { kind: "stageLayout", mode };
459
+ if (key === " ") return { kind: "playToggle" };
460
+ // `\` fits the timeline. It is the NLE convention (Premiere and Resolve both
461
+ // bind "zoom to sequence" to it) and it is a key no letter lane can reach, so
462
+ // the dock's fit costs the tool table nothing.
463
+ if (key === "\\") return { kind: "fitTimeline" };
464
+ // The bracket keys hide their own side, and `.` hides everything. Bare, and
465
+ // safe to be bare: none of the three is a tool letter, and this lane's
466
+ // complaint was that the panels leave too little stage — a chord to see your
467
+ // own work is a chord too many. Every one has a button too (the header's
468
+ // panel toggles), because a keyboard-only affordance is undiscoverable.
469
+ if (key === "[") return { kind: "togglePanel", panel: "rail" };
470
+ if (key === "]") return { kind: "togglePanel", panel: "inspector" };
471
+ if (key === ".") return { kind: "togglePanel", panel: "all" };
472
+ // ESCAPE, IN PRECEDENCE ORDER: abandon the pen (lane 1b, above), then
473
+ // deselect, then leave one level of isolation. Innermost subject first, and
474
+ // the two are ordered rather than exclusive because an author inside a card
475
+ // with a child selected presses Escape twice — once to drop the selection,
476
+ // once to come out. Collapsing them would make the second press do nothing
477
+ // and the first do too much.
478
+ if (key === "Escape") {
479
+ if (context.hasSelection) return { kind: "deselect" };
480
+ if (context.isolation === true) return { kind: "exitIsolation" };
481
+ return { kind: "deselect" };
482
+ }
483
+ // ENTER goes the other way: into the selected layer. Unclaimed until now, and
484
+ // the pen already owns it while a path is open (lane 1b).
485
+ if (key === "Enter" && context.hasSelection) return { kind: "enterIsolation" };
486
+ if (key === "Delete" || key === "Backspace") return { kind: "deleteSelection" };
487
+ // HOME frames the active pane, the Blender "frame all". A key no field
488
+ // shares (lane 1 already returned for a field) and no tool can reach.
489
+ if (key === "Home") return { kind: "fit" };
490
+ if (lower === "a" && event.shiftKey) return { kind: "animateToggle" };
491
+ // ── BLENDER'S SELECTION KEYS ──────────────────────────────────────────
492
+ //
493
+ // Bare `A` selects all and `⌥A` deselects, which is muscle memory a Blender
494
+ // user brings with them. `A` was free; the alt lane handles its partner.
495
+ // `⌥A` must be its own action rather than reusing Escape's lane, because
496
+ // Escape also LEAVES A SUBTREE and deselecting must not.
497
+ if (lower === "a" && !event.shiftKey) return { kind: "selectAll" };
498
+ // `B` arms a one-shot box select. Stage-local: the letter still has one owner
499
+ // here, and the stage reads the armed bit beside `e.shiftKey` in its own
500
+ // marquee lane — no bridge message and no store verb.
501
+ if (lower === "b" && !event.shiftKey) return { kind: "boxSelect" };
502
+ // `⇧D` duplicates, Blender's own. A plain alias: `duplicateSelection` already
503
+ // offsets the copy and selects it, so Blender's duplicate-then-grab has no
504
+ // job here.
505
+ if (lower === "d" && event.shiftKey) return { kind: "duplicate" };
506
+ // ── THE HIDE TRIPLE ───────────────────────────────────────────────────
507
+ //
508
+ // `toggleFlag` reads `selection[0]`'s own flag, so after hiding five layers
509
+ // one at a time there was NO key that brought them back — the selection was
510
+ // gone with them. Explicit verbs fix that: H hides, ⌥H reveals everything,
511
+ // ⇧H hides everything else. The outliner's eye button keeps the toggle.
512
+ if (lower === "h" && !event.shiftKey && !event.altKey) return { kind: "setHidden", scope: "selection" };
513
+ if (lower === "h" && event.shiftKey) return { kind: "setHidden", scope: "others" };
514
+ // ⇧Z IS THE WIREFRAME TOGGLE, matching Blender. It used to fit the active
515
+ // pane, which `Home` already does and which is the Blender-correct key for
516
+ // it — so the letter was spent on a duplicate.
517
+ if (lower === "z" && event.shiftKey) return { kind: "wireframeToggle" };
518
+ // BARE Z cycles the working pane's shading — Blender's viewport-shading key.
519
+ // Fifth meaning on the letter: `⇧Z` fit, `⌘Z` undo, `⌘⇧Z` redo, z-as-axis
520
+ // in lane 1c, and this one — none shadowing another, by lane order alone.
521
+ if (lower === "z" && !event.shiftKey) return { kind: "shadingCycle" };
522
+ // ⇧C FRAMES EVERYTHING, matching Blender. It used to add a camera move at the
523
+ // playhead — so a Blender user reaching for "show me everything" WROTE THE
524
+ // DOCUMENT and autosaved it, which is the worst possible answer to a view
525
+ // key. The `+ shot` button in the dock is where an author looks for a move,
526
+ // and it is still there.
527
+ if (lower === "c" && event.shiftKey) return { kind: "fit" };
528
+ // BARE M drops a marker, which is the binding Premiere, Resolve and Final Cut
529
+ // all ship. Guarded to bare rather than left open, because ⇧M is "go to the
530
+ // next marker" in the same three tools and this chrome does not have that
531
+ // verb yet — claiming the shifted letter now would make it unavailable later
532
+ // without breaking somebody's muscle memory twice.
533
+ if (lower === "m" && !event.shiftKey) return { kind: "addMarker" };
534
+
535
+ // THE MODAL STARTERS, checked immediately before the tool table so bare G
536
+ // and R shadow the move/rect tools ONLY with the cursor over the working
537
+ // pane. Blender's letters, Blender's scope: a transform needs an anchor
538
+ // under the cursor, so from a panel or the camera pane the letters keep
539
+ // arming tools exactly as before. `R` additionally requires a selection —
540
+ // with nothing selected G/S address the CAMERA (the stage decides that from
541
+ // the same selection), but the camera has no modal roll, so R falls through
542
+ // to the rect tool rather than claiming a dead key.
543
+ if (context.workingPane === true && !event.shiftKey) {
544
+ if (lower === "g") return { kind: "modalStart", mode: "move" };
545
+ if (lower === "s") return { kind: "modalStart", mode: "scale" };
546
+ if (lower === "r" && context.hasSelection) return { kind: "modalStart", mode: "rotate" };
547
+ }
548
+
549
+ const tool = toolForKey(lower, event.shiftKey);
550
+ return tool === null ? null : { kind: "tool", tool };
551
+ }
552
+
553
+ /** Is the event target something the author is typing into? Exported because
554
+ * both documents ask it and neither should re-derive it. */
555
+ export function isEditable(target: EventTarget | null): boolean {
556
+ if (target === null || typeof (target as HTMLElement).tagName !== "string") return false;
557
+ const el = target as HTMLElement;
558
+ if (el.isContentEditable) return true;
559
+ return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
560
+ }