@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.
- package/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- 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
|
+
}
|