@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,1046 @@
1
+ // The stage's overlay layer: one SVG over the canvas.
2
+ //
3
+ // ── WHY THE OVERLAY IS IN THE STAGE AND NOT IN THE CHROME ───────────────
4
+ //
5
+ // The chrome is three-free and receives screen-space numbers as data — that
6
+ // invariant is unchanged and `sourceHygiene.test.ts` still enforces it. What
7
+ // changed is WHERE those numbers are drawn.
8
+ //
9
+ // Drawing them in the stage removes a cross-document synchronisation problem
10
+ // outright. The quads are recomputed inside the render loop; the chrome is
11
+ // another document with its own commit schedule, so pushing them across would
12
+ // paint the outline one frame behind the pixels it outlines — visible on every
13
+ // dolly, as handles that lag the layer. The chrome can still READ the quads
14
+ // (`bridge.selectionQuads(pane)`) for anything that belongs in a panel.
15
+ //
16
+ // It is also where the pointer already is: gestures land on the stage's own
17
+ // DOM, so the thing being dragged and the thing being drawn are one document.
18
+ //
19
+ // ── ONE SVG, TWO PANES ──────────────────────────────────────────────────
20
+ //
21
+ // Each pane is a `<g>` translated to its rect and clipped to it. Translating
22
+ // is what lets every number that crosses this boundary be PANE-LOCAL — the
23
+ // same coordinates `paneAt` hands the pointer lane and the same ones
24
+ // `StageTools` projects into. A single overlay in canvas coordinates would
25
+ // need every quad offset at the draw site, which is a second place for a pane
26
+ // origin to be added or forgotten.
27
+ //
28
+ // Clipping is not decoration. A layer that sits off the left edge of the
29
+ // camera pane projects to a negative x, and an unclipped outline would draw it
30
+ // across the working pane's picture as a stray rectangle.
31
+ //
32
+ // ── THE OUTLINE IS A POLYGON ────────────────────────────────────────────
33
+ //
34
+ // Never an axis-aligned rect. A tilted layer's outline is a trapezoid, and a
35
+ // bounding box would lie about the tilt in every frame — and would put the
36
+ // resize handles somewhere the layer is not.
37
+ import { useEffect, useRef } from "react";
38
+ import { pathToD } from "@dailies-studio/shot/doc";
39
+ import type { PathPoint } from "@dailies-studio/shot/doc";
40
+ import type { NavBall } from "../gizmoMath.js";
41
+ import type { ModalAxis, ModalMode } from "../modalState.js";
42
+ import { HANDLE_SITES, handlePoint } from "../handles.js";
43
+ import type { StageInset } from "../../bridgeTypes.js";
44
+ import type { PaneRect, StagePane } from "../panes.js";
45
+ import type { ScreenQuad } from "../project.js";
46
+
47
+ /** A smart guide, already converted to CSS px by the caller — the overlay does
48
+ * no coordinate maths of its own, so there is one conversion in the lane. */
49
+ export interface ScreenGuide {
50
+ axis: "x" | "y";
51
+ at: number;
52
+ }
53
+
54
+ /** A live marquee, in the coordinates of the pane the drag STARTED in. It
55
+ * stays in that pane even when the cursor leaves it, which is what makes a
56
+ * marquee that overshoots the divider still mean what the author meant. */
57
+ export interface ScreenMarquee {
58
+ pane: StagePane;
59
+ x0: number;
60
+ y0: number;
61
+ x1: number;
62
+ y1: number;
63
+ }
64
+
65
+ export interface OverlayProps {
66
+ width: number;
67
+ height: number;
68
+ panes: { working: PaneRect | null; camera: PaneRect | null };
69
+ /** The exportable frame, in CSS px from the CAMERA PANE's top-left. */
70
+ frame: { x: number; y: number; width: number; height: number };
71
+ /** Selection outlines, pane-local. Empty for a pane that is off screen. */
72
+ quadsCamera: readonly ScreenQuad[];
73
+ quadsWorking: readonly ScreenQuad[];
74
+ /** Parallel to both quad arrays: is this selected layer locked? ONE array for
75
+ * both panes, because the selection is one selection and only its projection
76
+ * differs. A locked layer keeps its outline and loses its handles. */
77
+ locked: readonly boolean[];
78
+ /**
79
+ * The multi-select's ONE box, per pane, or null.
80
+ *
81
+ * When it is drawn it OWNS the handles and every member outline gives them up
82
+ * — because `grabAt` gives the box first refusal on any press near a handle,
83
+ * so a member's own would be drawn and never answered. When it is null (one
84
+ * layer, or a selection that is not coplanar and axis-parallel) the members
85
+ * keep theirs and nothing else changes.
86
+ */
87
+ unionCamera: ScreenQuad | null;
88
+ unionWorking: ScreenQuad | null;
89
+ /**
90
+ * What a click WOULD select, and in which pane, or null.
91
+ *
92
+ * Drawn thinner and dimmer than the selection so the two never read as the
93
+ * same state. It is the affordance Figma and Blender both have and this stage
94
+ * did not: without it the author learns what a click selects by clicking,
95
+ * which in a group tree means undoing a selection they did not want.
96
+ */
97
+ hover: { pane: StagePane; quad: ScreenQuad } | null;
98
+ /** "Camera follows this view" is armed, so orbiting writes the document. */
99
+ followsView: boolean;
100
+ /** The isolated layer's label, or null when nothing is isolated. Isolation
101
+ * REPLACES the working pane's camera with a derived head-on orthographic rig
102
+ * (isolation.ts), so orbit, pan and dolly all stop answering — the badge is
103
+ * what turns that from "the view froze" into a mode with a way out. */
104
+ isolation: string | null;
105
+ marquee: ScreenMarquee | null;
106
+ guides: readonly ScreenGuide[];
107
+ /** The pen's path so far, in CAMERA-PANE CSS px, or null when no path is
108
+ * being drawn. Converted by the caller, like the guides — the overlay does
109
+ * no coordinate maths of its own.
110
+ *
111
+ * `cursor` is where the rubber band ends; `closeHot` is true when a press
112
+ * there would CLOSE the loop, which the preview says by filling the first
113
+ * anchor rather than by moving anything. */
114
+ /**
115
+ * The same guides, PROJECTED, for the working pane.
116
+ *
117
+ * A guide is a line in the design plane. In the flat camera pane that is one
118
+ * screen coordinate, which is what `guides` above carries. Under a free orbit
119
+ * it is a segment at an arbitrary angle, so it needs two projected endpoints
120
+ * and cannot be expressed the same way.
121
+ *
122
+ * It used to be neither: `snapFor` runs for a move in BOTH panes, so a
123
+ * working-pane drag snapped onto another layer's edge and nothing was drawn
124
+ * to say why — the layer simply jumped. Silent behaviour is the whole class
125
+ * this lane keeps removing.
126
+ */
127
+ guidesWorking: readonly { a: readonly [number, number]; b: readonly [number, number] }[];
128
+ pen: ScreenPen | null;
129
+ /** A live modal transform's pill, or null. In-pane for the FollowBadge's
130
+ * reason: a mode the author is inside belongs over the picture it is
131
+ * changing, not in a panel they are not looking at. */
132
+ modal: ScreenModal | null;
133
+ /** The working pane's axis indicator: six projected axis ends, and what a
134
+ * click on one should do. Null when there is no working pane. */
135
+ nav: ScreenNav | null;
136
+ /** The selected toon mesh's key-direction handle, or null when there is no
137
+ * single toon mesh selected. Working pane only: it is a 3D direction, and
138
+ * the camera pane has no way to show one. */
139
+ keyLight: ScreenKeyLight | null;
140
+ /**
141
+ * How far the chrome's floating panels reach into the stage, per side.
142
+ *
143
+ * EVERY piece of overlay furniture lays out inside it — the mode bars, the
144
+ * modal pill, the nav gizmo — because the panes are the whole window and the
145
+ * chrome sits on top of them. Before this, two mode bars drew entirely behind
146
+ * the opaque shell header and the modal pill drew behind the left rail, so
147
+ * the only thing that said a G / R / S transform was live had never been
148
+ * visible in the default layout.
149
+ *
150
+ * The pane RECTS are untouched: insetting those would move the scissor and
151
+ * shrink the rendered picture. This is paint only.
152
+ */
153
+ inset: StageInset;
154
+ }
155
+
156
+ /**
157
+ * The key-light handle: where the selected mesh is on screen, and which way its
158
+ * fake key points.
159
+ *
160
+ * ── IT IS NOT A LIGHT, AND THE DRAWING SHOULD NOT PRETEND ─────────────────
161
+ *
162
+ * This lane has no lights and is not getting any (`MeshShading`: "no lights,
163
+ * ever"). What a toon mesh has is `keyDir`, a direction evaluated in the
164
+ * shader — and until now the only way to set it was three number fields in the
165
+ * inspector, which is a poor way to answer a question the author is looking
166
+ * straight at. So the sun disc is an affordance for a uniform, and it wears the
167
+ * frustum's yellow: the colour this stage already uses for "scene rig drawn as
168
+ * furniture", not the accent that means "your selection".
169
+ */
170
+ export interface ScreenKeyLight {
171
+ /** The selected layer's quad centre, pane-local CSS px. */
172
+ at: readonly [number, number];
173
+ /** The key direction projected into the pane, normalised, or null when it
174
+ * points nearly along the view axis and has no readable 2D direction. */
175
+ dir: readonly [number, number] | null;
176
+ /** A press on the disc or its arm. The overlay is paint: it hands the whole
177
+ * event to the stage, exactly as `ScreenNav.onGrab` does. */
178
+ onGrab: (event: React.PointerEvent) => void;
179
+ }
180
+
181
+ export interface ScreenNav {
182
+ balls: readonly NavBall[];
183
+ /**
184
+ * A press ON the widget: a ball, or `null` for its backing disc.
185
+ *
186
+ * ── WHY THE OVERLAY HANDS THE WHOLE EVENT OVER ──────────────────────────
187
+ *
188
+ * It used to be `onSnap(ball)`, fired straight from `pointerdown` — so the
189
+ * gizmo could only be CLICKED, and dragging it did nothing. Blender's turns
190
+ * the view. Deciding between the two needs a travel threshold and a drag
191
+ * lane, and this file is paint: it owns no gesture vocabulary and registers
192
+ * no move listeners. The stage runs `dragGesture` exactly as it does for an
193
+ * orbit on empty space, and snaps only when the travel never crossed.
194
+ */
195
+ onGrab: (event: React.PointerEvent, ball: NavBall | null) => void;
196
+ }
197
+
198
+ /** What the modal pill says, and whose colour it wears: the accent for layer
199
+ * transforms, the frustum's yellow for the camera — the same subject-colour
200
+ * rule the FollowBadge states. */
201
+ export interface ScreenModal {
202
+ label: string;
203
+ camera: boolean;
204
+ /**
205
+ * Which transform is live, drawn as a glyph before the label.
206
+ *
207
+ * A modal takes POINTER LOCK, so there is no cursor on screen at all while
208
+ * one runs — the usual place a tool says what it is doing. The pill is the
209
+ * only surface left, and a word alone is something the author has to read;
210
+ * a shape is something they see. Same three modes the keymap arms.
211
+ */
212
+ mode: ModalMode;
213
+ /** The axis lock, or null. Tints the glyph with that axis's own colour, so
214
+ * "S then X" says which direction is about to move without a second look at
215
+ * the label. */
216
+ axis: ModalAxis;
217
+ }
218
+
219
+ /**
220
+ * Where the pill's live numbers are written, OUTSIDE React.
221
+ *
222
+ * The readout changes on every pointermove, and `modalBadge` is memoised on the
223
+ * modal STATE alone precisely so motion costs no render. Routing the numbers
224
+ * through props would undo that — a full stage commit per mouse move to change
225
+ * a string. So the pill registers a setter here and `flushModal` calls it.
226
+ *
227
+ * A module-level box rather than a ref chain, because exactly one modal can be
228
+ * live at a time (the pointerdown lane's first branch guarantees it) and the
229
+ * two ends are in different files.
230
+ */
231
+ export const modalReadoutSink: { write: ((text: string) => void) | null } = { write: null };
232
+
233
+ export interface ScreenPen {
234
+ points: readonly PathPoint[];
235
+ closed: boolean;
236
+ cursor: readonly [number, number] | null;
237
+ closeHot: boolean;
238
+ }
239
+
240
+ export function Overlays({
241
+ width,
242
+ height,
243
+ panes,
244
+ frame,
245
+ quadsCamera,
246
+ quadsWorking,
247
+ locked,
248
+ unionCamera,
249
+ unionWorking,
250
+ hover,
251
+ followsView,
252
+ isolation,
253
+ marquee,
254
+ guides,
255
+ guidesWorking,
256
+ pen,
257
+ modal,
258
+ nav,
259
+ keyLight,
260
+ inset,
261
+ }: OverlayProps) {
262
+ const { working, camera } = panes;
263
+ return (
264
+ <svg
265
+ className="stage-overlay"
266
+ width={width}
267
+ height={height}
268
+ viewBox={`0 0 ${width} ${height}`}
269
+ // The pointer lane owns the container; the overlay is paint only. Without
270
+ // this every drag would end the moment the cursor crossed a handle.
271
+ style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
272
+ >
273
+ <defs>
274
+ {working === null ? null : <PaneClip id="shot-pane-working" rect={working} />}
275
+ {camera === null ? null : <PaneClip id="shot-pane-camera" rect={camera} />}
276
+ </defs>
277
+
278
+ {working === null ? null : (
279
+ <g clipPath="url(#shot-pane-working)" transform={`translate(${working.x} ${working.y})`}>
280
+ {/* NO FRAME BORDER. The exportable frame is a property of the shot
281
+ camera's projection; drawn in an orbit view it would be a
282
+ rectangle floating in front of a scene it does not bound. */}
283
+ {/* UNDER the selection: a layer that is both hovered and selected
284
+ must read as selected, not as a heavier version of hovered. */}
285
+ {hover?.pane === "working" ? <HoverQuad quad={hover.quad} /> : null}
286
+ {quadsWorking.map((q, i) => (
287
+ // HANDLES HERE TOO, since `runTransformGesture` learned to measure
288
+ // a resize by unprojecting two rays onto the layer's own plane. The
289
+ // old note here said this pane had "no zoom, only a distance" and
290
+ // therefore no resize; the ray form has no zoom term at all, so the
291
+ // reason is gone and the author can resize what they are looking at.
292
+ <SelectionQuad
293
+ key={i}
294
+ quad={q}
295
+ handles={locked[i] !== true && unionWorking === null}
296
+ />
297
+ ))}
298
+ {unionWorking === null ? null : <UnionBox quad={unionWorking} />}
299
+ {/* The snap guides, as the SEGMENTS they are once the plane is seen
300
+ at an angle. Same colour and weight as the camera pane's, because
301
+ they are the same guides. */}
302
+ {guidesWorking.map((g, i) => (
303
+ <line
304
+ key={i}
305
+ x1={g.a[0]}
306
+ y1={g.a[1]}
307
+ x2={g.b[0]}
308
+ y2={g.b[1]}
309
+ stroke="var(--guide, #f43f5e)"
310
+ strokeWidth={1}
311
+ />
312
+ ))}
313
+ {marquee?.pane === "working" ? <Marquee rect={marquee} /> : null}
314
+ {/* The mode bars STACK DOWN FROM the chrome's bottom edge, so they
315
+ are the first thing under the header rather than behind it. */}
316
+ {isolation === null ? null : (
317
+ <IsolationBadge
318
+ width={working.width}
319
+ y={inset.top}
320
+ left={inset.left}
321
+ label={isolation}
322
+ />
323
+ )}
324
+ {/* Under the isolation bar when both are on. Two bars at the same y
325
+ would hide one mode behind the other, and isolation is the one
326
+ that changed the camera. */}
327
+ {followsView ? (
328
+ <FollowBadge
329
+ width={working.width}
330
+ y={inset.top + (isolation === null ? 0 : BAR_H)}
331
+ left={inset.left}
332
+ />
333
+ ) : null}
334
+ {keyLight === null ? null : <KeyLightGizmo light={keyLight} />}
335
+ {modal === null ? null : <ModalBadge modal={modal} rect={working} inset={inset} />}
336
+ {nav === null ? null : (
337
+ <NavGizmo nav={nav} width={working.width} top={inset.top} right={inset.right} />
338
+ )}
339
+ </g>
340
+ )}
341
+
342
+ {camera === null ? null : (
343
+ <g clipPath="url(#shot-pane-camera)" transform={`translate(${camera.x} ${camera.y})`}>
344
+ <FrameBorder rect={frame} width={camera.width} height={camera.height} />
345
+ {hover?.pane === "camera" ? <HoverQuad quad={hover.quad} /> : null}
346
+ {quadsCamera.map((q, i) => (
347
+ // A LOCKED LAYER DRAWS NO HANDLES, matching `grabAt`, which refuses
348
+ // to answer one. A handle that cannot be grabbed is a handle the
349
+ // author will try to grab, and then wonder what is broken.
350
+ //
351
+ // NOR DOES A MEMBER OF A UNION. The box has them, and `grabAt` gives
352
+ // the box first refusal on every press near one — so a member's own
353
+ // handle would be drawn and never answered.
354
+ <SelectionQuad key={i} quad={q} handles={locked[i] !== true && unionCamera === null} />
355
+ ))}
356
+ {unionCamera === null ? null : <UnionBox quad={unionCamera} />}
357
+ {guides.map((g, i) =>
358
+ g.axis === "x" ? (
359
+ <line
360
+ key={i}
361
+ x1={g.at}
362
+ y1={0}
363
+ x2={g.at}
364
+ y2={camera.height}
365
+ stroke="var(--guide, #f43f5e)"
366
+ strokeWidth={1}
367
+ />
368
+ ) : (
369
+ <line
370
+ key={i}
371
+ x1={0}
372
+ y1={g.at}
373
+ x2={camera.width}
374
+ y2={g.at}
375
+ stroke="var(--guide, #f43f5e)"
376
+ strokeWidth={1}
377
+ />
378
+ ),
379
+ )}
380
+ {marquee?.pane === "camera" ? <Marquee rect={marquee} /> : null}
381
+ {pen === null || pen.points.length === 0 ? null : <PenPreview pen={pen} />}
382
+ {/* THE BADGE FOLLOWS THE MODE, NOT THE PANE. Camera-only plus
383
+ isolated is a legitimate state — the author can switch layout
384
+ while inside a subtree — and it had zero indicators while the dim
385
+ and the pick scope were both fully in force, so every click
386
+ outside the card did nothing with nothing to say why. Drawn here
387
+ only when the working pane is off screen, or the two would both
388
+ render it. */}
389
+ {isolation === null || working !== null ? null : (
390
+ <IsolationBadge
391
+ width={camera.width}
392
+ y={inset.top}
393
+ left={inset.left}
394
+ label={isolation}
395
+ />
396
+ )}
397
+ </g>
398
+ )}
399
+
400
+ {working === null || camera === null ? null : <Divider x={camera.x} height={height} />}
401
+ </svg>
402
+ );
403
+ }
404
+
405
+ function PaneClip({ id, rect }: { id: string; rect: PaneRect }) {
406
+ // Authored at the ORIGIN because the group that references it carries the
407
+ // translate, and a clip path resolves in the referencing element's own user
408
+ // space. Repeating the pane's x here would offset it twice.
409
+ return (
410
+ <clipPath id={id}>
411
+ <rect x={0} y={0} width={rect.width} height={rect.height} />
412
+ </clipPath>
413
+ );
414
+ }
415
+
416
+ /**
417
+ * "Camera follows this view", said INSIDE the pane it is true of.
418
+ *
419
+ * Blender's decade-old complaint about the equivalent toggle is that it is easy
420
+ * to forget it is on — and it is easy because the only thing that says so is a
421
+ * checkbox in a panel nobody is looking at while they orbit. So this is drawn
422
+ * over the picture, in the gizmo's own yellow so it reads as the same subject,
423
+ * and it is a full-width bar rather than a dot because the state it announces
424
+ * is that ORBITING EDITS THE DOCUMENT.
425
+ *
426
+ * A bar and not a border: a border here would be one more rectangle in a pane
427
+ * that already draws a frustum, a selection and a divider.
428
+ */
429
+ /** Height of a mode bar. Shared so isolation can stack follow under itself
430
+ * without either one restating the other's geometry. */
431
+ const BAR_H = 22;
432
+
433
+ function FollowBadge({ width, y, left }: { width: number; y: number; left: number }) {
434
+ return (
435
+ <ModeBar
436
+ width={width}
437
+ y={y}
438
+ left={left}
439
+ color="var(--frustum, #eab308)"
440
+ text="CAMERA FOLLOWS THIS VIEW"
441
+ />
442
+ );
443
+ }
444
+
445
+ /**
446
+ * "You are inside this layer", said INSIDE the pane it is true of.
447
+ *
448
+ * Isolation is the largest mode this pane has: `StageTools` swaps a derived
449
+ * orthographic head-on rig into the working camera's slot, so orbit, pan, dolly
450
+ * and the nav gizmo all stop answering, and picking is scoped to the subtree.
451
+ * With no in-pane sign, that reads as the view having frozen on whatever was
452
+ * double-clicked — and the only thing that said otherwise was a breadcrumb in
453
+ * the left panel, which `[` and `.` can hide entirely.
454
+ *
455
+ * THE WAY OUT IS IN THE BADGE, for the reason the badge exists at all: a mode
456
+ * the author entered by accident has to be leavable without finding the panel
457
+ * that documents it.
458
+ *
459
+ * The accent, not the frustum's yellow: the subject is a LAYER, which is the
460
+ * same subject-colour rule ModalBadge follows.
461
+ */
462
+ function IsolationBadge({
463
+ width,
464
+ y,
465
+ label,
466
+ left,
467
+ }: {
468
+ width: number;
469
+ y: number;
470
+ label: string;
471
+ left: number;
472
+ }) {
473
+ return (
474
+ <ModeBar
475
+ width={width}
476
+ y={y}
477
+ left={left}
478
+ color="var(--accent, #3b82f6)"
479
+ text={`INSIDE ${label.toUpperCase()} — ESC TO LEAVE`}
480
+ />
481
+ );
482
+ }
483
+
484
+ function ModeBar({
485
+ width,
486
+ y,
487
+ color,
488
+ text,
489
+ /** How far the chrome's left rail reaches into this pane. THE TEXT IS THE
490
+ * ONLY PART THAT MOVES: the tinted band is a full-width mode indicator and
491
+ * reads correctly under a panel, but the words did not — they drew at x = 10,
492
+ * behind an opaque rail, so the only thing naming isolation or follow mode
493
+ * was invisible at every default panel width. */
494
+ left,
495
+ }: {
496
+ width: number;
497
+ y: number;
498
+ color: string;
499
+ text: string;
500
+ left: number;
501
+ }) {
502
+ return (
503
+ <g pointerEvents="none">
504
+ <rect x={0} y={y} width={width} height={BAR_H} fill={color} fillOpacity={0.16} />
505
+ <rect x={0} y={y} width={width} height={2} fill={color} />
506
+ <text
507
+ x={left + 10}
508
+ y={y + 16}
509
+ fill={color}
510
+ fontFamily="var(--font-ui, system-ui)"
511
+ fontSize={11}
512
+ fontWeight={600}
513
+ letterSpacing={0.4}
514
+ >
515
+ {text}
516
+ </text>
517
+ </g>
518
+ );
519
+ }
520
+
521
+ /**
522
+ * The live modal transform's pill: which transform, which axis, whose
523
+ * subject. Bottom-left of the working pane — over the picture being changed
524
+ * (the FollowBadge's argument), and at the bottom so it never collides with
525
+ * the follow bar at the top. Width from the label's length at the pill's own
526
+ * monospace-ish tracking; an SVG has no text measurement without layout, and
527
+ * a few px of slack is invisible on a translucent plate.
528
+ */
529
+ /**
530
+ * The three mode glyphs, as SVG path data on a 16x16 box centred at (8, 8).
531
+ *
532
+ * Stroked, not filled, so one `stroke` attribute colours the whole thing and
533
+ * the shapes read at 14px against a translucent plate. They are the shapes the
534
+ * tools already imply: a four-way arrow for move, an arc with a head for
535
+ * rotate, and a diagonal with a box at each end for scale.
536
+ */
537
+ const MODAL_GLYPHS: Record<ModalMode, string> = {
538
+ move: "M8 2v12M2 8h12M8 2 6 4.5M8 2l2 2.5M8 14l-2-2.5M8 14l2-2.5M2 8l2.5-2M2 8l2.5 2M14 8l-2.5-2M14 8l-2.5 2",
539
+ rotate: "M3.2 9.6a5 5 0 1 0 1.5-4.4M4.7 2.2v3.2h3.2",
540
+ scale: "M4 12 12 4M2.5 10.5h3v3h-3zM10.5 2.5h3v3h-3z",
541
+ };
542
+
543
+ /** Glyph size and the gap to the label, in CSS px. The pill's own width grows
544
+ * by exactly this much so nothing that fitted before starts clipping. */
545
+ const GLYPH_PX = 14;
546
+ const GLYPH_GAP_PX = 6;
547
+
548
+ /** The axis palette, the same tokens the nav gizmo and the move arms wear. */
549
+ const AXIS_COLORS: Record<"x" | "y" | "z", string> = {
550
+ x: "var(--axis-x, #ef4444)",
551
+ y: "var(--axis-y, #22c55e)",
552
+ z: "var(--axis-z, #3b82f6)",
553
+ };
554
+
555
+ function ModalBadge({ modal, rect, inset }: { modal: ScreenModal; rect: PaneRect; inset: StageInset }) {
556
+ const color = modal.camera ? "var(--frustum, #eab308)" : "var(--accent, #3b82f6)";
557
+ // The glyph wears the AXIS's colour when one is locked, and the pill's own
558
+ // otherwise. The lock is the thing most worth seeing at a glance, and it is
559
+ // the one piece of modal state that changes mid-gesture.
560
+ const glyphColor = modal.axis === null ? color : AXIS_COLORS[modal.axis];
561
+ const shift = GLYPH_PX + GLYPH_GAP_PX;
562
+ // INSIDE the chrome on both axes. It sat at x = 10 and y = height - 34, which
563
+ // is behind the 232px left rail and behind the timeline dock — so the only
564
+ // thing that says a transform is live has never been visible in the default
565
+ // layout.
566
+ const x = inset.left + 10;
567
+ const y = rect.height - inset.bottom - 34;
568
+ // The live numbers, written straight into the node. See `modalReadoutSink`:
569
+ // the readout changes per pointermove and the pill is memoised on state, so
570
+ // the two have to meet outside React or motion costs a stage commit.
571
+ const readout = useRef<SVGTextElement | null>(null);
572
+ useEffect(() => {
573
+ modalReadoutSink.write = (text: string): void => {
574
+ if (readout.current !== null) readout.current.textContent = text;
575
+ };
576
+ return () => {
577
+ modalReadoutSink.write = null;
578
+ };
579
+ }, []);
580
+ return (
581
+ <g pointerEvents="none">
582
+ <rect
583
+ x={x}
584
+ y={y}
585
+ rx={4}
586
+ // Room for the label AND the numbers. An SVG has no text measurement
587
+ // without layout, and a few px of slack is invisible on a translucent
588
+ // plate; a pill too NARROW would clip the figure the author is reading.
589
+ width={modal.label.length * 7.5 + 96 + shift}
590
+ height={24}
591
+ fill="var(--panel, #1c1c20)"
592
+ fillOpacity={0.88}
593
+ stroke={color}
594
+ strokeWidth={1}
595
+ />
596
+ {/* Scaled onto the pill from the 16x16 box the paths are authored in, so
597
+ the shapes stay editable as plain coordinates. */}
598
+ <g
599
+ transform={`translate(${x + 10} ${y + 5}) scale(${GLYPH_PX / 16})`}
600
+ fill="none"
601
+ stroke={glyphColor}
602
+ strokeWidth={1.6}
603
+ strokeLinecap="round"
604
+ strokeLinejoin="round"
605
+ >
606
+ <path d={MODAL_GLYPHS[modal.mode]} />
607
+ </g>
608
+ <text
609
+ x={x + 10 + shift}
610
+ y={y + 16}
611
+ fill={color}
612
+ fontFamily="var(--font-ui, system-ui)"
613
+ fontSize={11}
614
+ fontWeight={600}
615
+ letterSpacing={0.6}
616
+ >
617
+ {modal.label}
618
+ </text>
619
+ <text
620
+ ref={readout}
621
+ x={x + modal.label.length * 7.5 + 22 + shift}
622
+ y={y + 16}
623
+ fill="var(--text, #e7e7ea)"
624
+ fontFamily="var(--font-mono, ui-monospace, monospace)"
625
+ fontSize={11}
626
+ fontWeight={500}
627
+ />
628
+ </g>
629
+ );
630
+ }
631
+
632
+ /** Arm length and disc radius, CSS px. The arm is long enough to aim with and
633
+ * short enough not to cross the layer it belongs to. */
634
+ const KEY_ARM_PX = 48;
635
+ const KEY_DISC_PX = 9;
636
+
637
+ function KeyLightGizmo({ light }: { light: ScreenKeyLight }) {
638
+ const color = "var(--frustum, #eab308)";
639
+ const [cx, cy] = light.at;
640
+ // The arm points where the KEY COMES FROM, which is the direction the author
641
+ // is aiming: a sun in the sky, not a ray leaving it. `dir` is already the
642
+ // design direction projected into the pane, so this is a sign, not a maths.
643
+ const tip =
644
+ light.dir === null ? null : ([cx - light.dir[0] * KEY_ARM_PX, cy - light.dir[1] * KEY_ARM_PX] as const);
645
+ return (
646
+ <g>
647
+ {tip === null ? null : (
648
+ <line x1={cx} y1={cy} x2={tip[0]} y2={tip[1]} stroke={color} strokeWidth={1.5} strokeDasharray="3 3" />
649
+ )}
650
+ {/* The grabbable end. When the key points nearly along the view axis
651
+ there is no readable 2D direction, so the disc sits ON the layer
652
+ centre — still draggable, which is what pulls it back out of the
653
+ degenerate pose. */}
654
+ <g
655
+ transform={`translate(${tip === null ? cx : tip[0]} ${tip === null ? cy : tip[1]})`}
656
+ // The one other piece of overlay furniture that takes pointer events.
657
+ // Paint hands the event over; the stage owns the drag.
658
+ pointerEvents="auto"
659
+ style={{ cursor: "grab" }}
660
+ onPointerDown={light.onGrab}
661
+ >
662
+ <circle r={KEY_DISC_PX} fill="var(--panel, #1c1c20)" fillOpacity={0.9} stroke={color} strokeWidth={1.5} />
663
+ {/* Four short rays, so the disc reads as a key rather than as another
664
+ handle. Drawn at the diagonals to stay clear of the arm. */}
665
+ {[45, 135, 225, 315].map((deg) => {
666
+ const r = (deg * Math.PI) / 180;
667
+ return (
668
+ <line
669
+ key={deg}
670
+ x1={Math.cos(r) * (KEY_DISC_PX + 2)}
671
+ y1={Math.sin(r) * (KEY_DISC_PX + 2)}
672
+ x2={Math.cos(r) * (KEY_DISC_PX + 5)}
673
+ y2={Math.sin(r) * (KEY_DISC_PX + 5)}
674
+ stroke={color}
675
+ strokeWidth={1.5}
676
+ strokeLinecap="round"
677
+ />
678
+ );
679
+ })}
680
+ </g>
681
+ </g>
682
+ );
683
+ }
684
+
685
+ /** The nav gizmo's radius and its ball, in CSS px. Small enough to be furniture
686
+ * over the corner of a picture, large enough that a ball is a real click
687
+ * target at the size a cursor actually is. */
688
+ const NAV_RADIUS = 30;
689
+ const NAV_BALL = 8;
690
+ const NAV_MARGIN = 16;
691
+
692
+ /**
693
+ * Which way is which, in the corner of the working pane.
694
+ *
695
+ * ── WHY THIS IS SVG AND NOT A SECOND VIEWPORT ───────────────────────────
696
+ *
697
+ * The usual build of this widget is a small scene rendered into its own corner
698
+ * viewport. That is exactly the construction SplitRenderer's header forbids:
699
+ * a second render into a scissored canvas, whose failure on the WebGPU backend
700
+ * is a whole-attachment MSAA resolve that wipes the neighbouring pane — silent,
701
+ * and invisible to CI because the pinned Chromium cannot make a WebGPU device.
702
+ *
703
+ * A nav gizmo shows DIRECTIONS, and the directions are three dot products
704
+ * against the camera basis (`navBalls`). So it costs no GL at all.
705
+ *
706
+ * ── THE AXES ARE THE DESIGN AXES ────────────────────────────────────────
707
+ *
708
+ * X right, Y DOWN, Z away — the same three the inspector's fields and the
709
+ * modal's `G` `Y` mean. Labelling them with world axes instead would put the
710
+ * letter Y on the arm that moves a layer the opposite way from the number the
711
+ * author is watching.
712
+ *
713
+ * Top-right, which is where every 3D tool puts it — but pushed down past the
714
+ * chrome that floats over this pane's own top edge. `top` is the chrome's own
715
+ * measurement, sent across the bridge, and it replaced a hardcoded 116 that a
716
+ * wrapping control strip on a narrow window grew straight past.
717
+ */
718
+ function NavGizmo({
719
+ nav,
720
+ width,
721
+ top,
722
+ /** How far the inspector reaches into this pane from the right. Unlike the
723
+ * mode bars', this inset is not cosmetic: the gizmo is the only piece of
724
+ * overlay furniture that TAKES POINTER EVENTS, so drawing it under an opaque
725
+ * panel makes it unclickable rather than merely hidden. */
726
+ right,
727
+ }: {
728
+ nav: ScreenNav;
729
+ width: number;
730
+ top: number;
731
+ right: number;
732
+ }) {
733
+ const gap = NAV_RADIUS + NAV_BALL + NAV_MARGIN;
734
+ const cx = width - right - gap;
735
+ const cy = top + gap;
736
+ // FAR FIRST. A flat drawing has no depth buffer, so paint order is the only
737
+ // cue for which end of an axis is pointing at the author — and without it a
738
+ // gizmo seen from behind reads exactly like one seen from the front.
739
+ const balls = [...nav.balls].sort((a, b) => a.toward - b.toward);
740
+ return (
741
+ <g data-testid="nav-gizmo">
742
+ {/* THE DISC TURNS THE VIEW. Blender's gizmo drags to orbit, and the body
743
+ is the part with nothing else to do — a press here can only ever have
744
+ meant "turn me". `grab` is the cursor because that is what it is. */}
745
+ <circle
746
+ cx={cx}
747
+ cy={cy}
748
+ r={NAV_RADIUS + NAV_BALL}
749
+ fill="var(--panel, #0c0c0e)"
750
+ fillOpacity={0.6}
751
+ pointerEvents="auto"
752
+ style={{ cursor: "grab" }}
753
+ onPointerDown={(e) => nav.onGrab(e, null)}
754
+ />
755
+ {balls.map((ball) => {
756
+ const x = cx + ball.x * NAV_RADIUS;
757
+ const y = cy + ball.y * NAV_RADIUS;
758
+ const color = `var(--axis-${ball.axis}, ${NAV_FALLBACK[ball.axis]})`;
759
+ return (
760
+ <g
761
+ key={`${ball.axis}${ball.positive ? "+" : "-"}`}
762
+ // The SVG root is `pointerEvents: none` so drags pass through it;
763
+ // these groups opt back in. The press is stopped by the STAGE (see
764
+ // `onGrab`), which needs the event to open its own drag lane —
765
+ // stopping it here would leave a ball that could be clicked and
766
+ // never dragged, which is the behaviour this replaced.
767
+ pointerEvents="auto"
768
+ style={{ cursor: "pointer" }}
769
+ onPointerDown={(e) => nav.onGrab(e, ball)}
770
+ >
771
+ {/* The spoke, positive ends only: drawing both makes six lines
772
+ through one point and reads as a star rather than as an axis. */}
773
+ {ball.positive ? (
774
+ <line x1={cx} y1={cy} x2={x} y2={y} stroke={color} strokeWidth={2} />
775
+ ) : null}
776
+ <circle
777
+ cx={x}
778
+ cy={y}
779
+ r={NAV_BALL}
780
+ // FILLED positive, HOLLOW negative — the one thing that tells the
781
+ // author whether they are looking at +Z or at -Z when the two
782
+ // project to the same place.
783
+ fill={ball.positive ? color : "var(--panel, #0c0c0e)"}
784
+ stroke={color}
785
+ strokeWidth={1.5}
786
+ />
787
+ {ball.positive ? (
788
+ <text
789
+ x={x}
790
+ y={y + 3.5}
791
+ textAnchor="middle"
792
+ fill="var(--bg, #000)"
793
+ fontFamily="var(--font-ui, system-ui)"
794
+ fontSize={10}
795
+ fontWeight={700}
796
+ >
797
+ {ball.axis.toUpperCase()}
798
+ </text>
799
+ ) : null}
800
+ </g>
801
+ );
802
+ })}
803
+ </g>
804
+ );
805
+ }
806
+
807
+ const NAV_FALLBACK: Readonly<Record<"x" | "y" | "z", string>> = {
808
+ x: "#ef4444",
809
+ y: "#22c55e",
810
+ z: "#3b82f6",
811
+ };
812
+
813
+ /** The seam between the panes. It belongs to the CAMERA pane's first column,
814
+ * which is what `paneAt` decides, so it is drawn ON that column rather than
815
+ * straddling it — a half-pixel straddle would put the drawn edge and the
816
+ * hit-tested edge in different places. */
817
+ function Divider({ x, height }: { x: number; height: number }) {
818
+ return (
819
+ <rect
820
+ x={x}
821
+ y={0}
822
+ width={1}
823
+ height={height}
824
+ fill="var(--stage-divider, #232328)"
825
+ shapeRendering="crispEdges"
826
+ />
827
+ );
828
+ }
829
+
830
+ /**
831
+ * The exportable frame.
832
+ *
833
+ * Its rect comes from `frameRect`, which is derived from the SAME numbers as
834
+ * the camera's view offset — so the border sits exactly on the pixels that will
835
+ * be exported, at every zoom and every playhead, rather than approximately on
836
+ * them. The dimmed surround is what makes "you are working off-canvas" legible
837
+ * without a second camera.
838
+ */
839
+ function FrameBorder({
840
+ rect,
841
+ width,
842
+ height,
843
+ }: {
844
+ rect: OverlayProps["frame"];
845
+ width: number;
846
+ height: number;
847
+ }) {
848
+ return (
849
+ <>
850
+ {/* Even-odd fill: one path, outer rect minus the frame, so the scrim can
851
+ never disagree with the border about where the frame is. */}
852
+ <path
853
+ d={`M0 0H${width}V${height}H0Z M${rect.x} ${rect.y}H${rect.x + rect.width}V${
854
+ rect.y + rect.height
855
+ }H${rect.x}Z`}
856
+ fillRule="evenodd"
857
+ fill="rgba(0,0,0,0.45)"
858
+ />
859
+ <rect
860
+ x={rect.x}
861
+ y={rect.y}
862
+ width={rect.width}
863
+ height={rect.height}
864
+ fill="none"
865
+ stroke="var(--frame-border, #6b7280)"
866
+ strokeWidth={1}
867
+ shapeRendering="crispEdges"
868
+ />
869
+ </>
870
+ );
871
+ }
872
+
873
+ /** What a click would select. One px and half opacity against the selection's
874
+ * 1.5 px solid, so the two states are told apart at a glance and never
875
+ * mistaken for each other on a layer that is both. */
876
+ function HoverQuad({ quad }: { quad: ScreenQuad }) {
877
+ return (
878
+ <polygon
879
+ points={pointsOf(quad)}
880
+ fill="none"
881
+ stroke="var(--accent, #3b82f6)"
882
+ strokeOpacity={0.5}
883
+ strokeWidth={1}
884
+ />
885
+ );
886
+ }
887
+
888
+ function SelectionQuad({ quad, handles }: { quad: ScreenQuad; handles?: boolean }) {
889
+ const points = pointsOf(quad);
890
+ return (
891
+ <>
892
+ <polygon points={points} fill="none" stroke="var(--accent, #3b82f6)" strokeWidth={1.5} />
893
+ {/* THE SAME TABLE `grabAt` HIT-TESTS, imported rather than restated: a
894
+ drawn handle the hit test does not answer, or an answered one nobody
895
+ drew, is the exact bug the hover cursor exists to make impossible. */}
896
+ {handles !== true
897
+ ? null
898
+ : HANDLE_SITES.map((site, i) => {
899
+ const [x, y] = handlePoint(quad, site);
900
+ return (
901
+ <rect
902
+ key={i}
903
+ x={x - 3.5}
904
+ y={y - 3.5}
905
+ width={7}
906
+ height={7}
907
+ fill="var(--panel, #1c1c20)"
908
+ stroke="var(--accent, #3b82f6)"
909
+ strokeWidth={1.5}
910
+ />
911
+ );
912
+ })}
913
+ </>
914
+ );
915
+ }
916
+
917
+ /**
918
+ * The one box around a multi-select.
919
+ *
920
+ * ── IT LOOKS DIFFERENT FROM A SELECTION OUTLINE ON PURPOSE ──────────────
921
+ *
922
+ * A dashed edge and solid handles, against the members' solid edges and (now)
923
+ * no handles. The two are different objects and offer different gestures: a
924
+ * member's outline says "this layer is selected", the box says "these grow
925
+ * together, about the corner opposite the one you grab". Drawing them alike
926
+ * would leave the author guessing which of nine outlines the handles belong to.
927
+ *
928
+ * The handles come off `HANDLE_SITES` like every other handle in the lane, so
929
+ * the box the hit test answers and the box the author sees are one table.
930
+ */
931
+ function UnionBox({ quad }: { quad: ScreenQuad }) {
932
+ return (
933
+ <>
934
+ <polygon
935
+ points={pointsOf(quad)}
936
+ fill="none"
937
+ stroke="var(--accent, #3b82f6)"
938
+ strokeWidth={1}
939
+ strokeDasharray="4 3"
940
+ />
941
+ {HANDLE_SITES.map((site, i) => {
942
+ const [x, y] = handlePoint(quad, site);
943
+ return (
944
+ <rect
945
+ key={i}
946
+ x={x - 3.5}
947
+ y={y - 3.5}
948
+ width={7}
949
+ height={7}
950
+ fill="var(--panel, #1c1c20)"
951
+ stroke="var(--accent, #3b82f6)"
952
+ strokeWidth={1.5}
953
+ />
954
+ );
955
+ })}
956
+ </>
957
+ );
958
+ }
959
+
960
+ /**
961
+ * The path as it is being drawn, through THE SAME PRINTER the committed layer
962
+ * uses.
963
+ *
964
+ * `pathToD` and not a hand-rolled polyline: the preview has to show the curve
965
+ * the anchors actually describe, and a second printer here would be a second
966
+ * answer to "what does this path look like" — visibly wrong the moment a
967
+ * handle is pulled, which is exactly when the author is looking at it.
968
+ *
969
+ * The DOTS are an affordance rather than decoration. One anchor draws no
970
+ * segment, so without them the author cannot tell a pen that registered their
971
+ * click from one that did not — and the FIRST dot fills when a press there
972
+ * would close the loop, which is the only signal that the path can be ended
973
+ * without reaching for Enter. The tether to the cursor is paid for by a hover
974
+ * listener in StageApp; the earlier note here said it was not worth a
975
+ * per-move re-render, and drawing curves rather than a polyline changed that.
976
+ */
977
+ function PenPreview({ pen }: { pen: ScreenPen }) {
978
+ const { points, cursor } = pen;
979
+ const last = points[points.length - 1];
980
+ return (
981
+ <g pointerEvents="none">
982
+ {points.length >= 2 ? (
983
+ <path d={pathToD({ points: [...points], closed: pen.closed })} fill="none" stroke="var(--accent, #3b82f6)" strokeWidth={1.5} />
984
+ ) : null}
985
+ {/* The rubber band to the cursor, drawn as the segment it WOULD become:
986
+ curved when the anchor behind it carries an outgoing handle. */}
987
+ {last !== undefined && cursor !== null && !pen.closed ? (
988
+ <path
989
+ d={pathToD({ points: [last, { p: [cursor[0], cursor[1]] }], closed: false })}
990
+ fill="none"
991
+ stroke="var(--accent, #3b82f6)"
992
+ strokeWidth={1}
993
+ strokeDasharray="4 3"
994
+ opacity={0.7}
995
+ />
996
+ ) : null}
997
+ {/* Handles, so a pulled curve is legible as two arms and not just a bend. */}
998
+ {points.map((p, i) => (
999
+ <g key={`h${i}`}>
1000
+ {p.out === undefined ? null : (
1001
+ <line x1={p.p[0]} y1={p.p[1]} x2={p.out[0]} y2={p.out[1]} stroke="var(--accent, #3b82f6)" strokeWidth={1} opacity={0.6} />
1002
+ )}
1003
+ {p.in === undefined ? null : (
1004
+ <line x1={p.p[0]} y1={p.p[1]} x2={p.in[0]} y2={p.in[1]} stroke="var(--accent, #3b82f6)" strokeWidth={1} opacity={0.6} />
1005
+ )}
1006
+ </g>
1007
+ ))}
1008
+ {points.map((p, i) => (
1009
+ <circle
1010
+ key={i}
1011
+ cx={p.p[0]}
1012
+ cy={p.p[1]}
1013
+ r={i === 0 && pen.closeHot ? 5 : 3}
1014
+ // A FILLED first anchor is the whole close affordance: the author
1015
+ // needs to know the next click ends the path before they make it.
1016
+ fill={i === 0 && pen.closeHot ? "var(--accent, #3b82f6)" : "var(--panel, #1c1c20)"}
1017
+ stroke="var(--accent, #3b82f6)"
1018
+ strokeWidth={1.5}
1019
+ />
1020
+ ))}
1021
+ </g>
1022
+ );
1023
+ }
1024
+
1025
+ function Marquee({ rect }: { rect: ScreenMarquee }) {
1026
+ const x = Math.min(rect.x0, rect.x1);
1027
+ const y = Math.min(rect.y0, rect.y1);
1028
+ return (
1029
+ <rect
1030
+ x={x}
1031
+ y={y}
1032
+ width={Math.abs(rect.x1 - rect.x0)}
1033
+ height={Math.abs(rect.y1 - rect.y0)}
1034
+ fill="var(--accent, #3b82f6)"
1035
+ fillOpacity={0.12}
1036
+ stroke="var(--accent, #3b82f6)"
1037
+ strokeWidth={1}
1038
+ />
1039
+ );
1040
+ }
1041
+
1042
+ function pointsOf(quad: ScreenQuad): string {
1043
+ const out: string[] = [];
1044
+ for (let i = 0; i < 8; i += 2) out.push(`${quad[i]},${quad[i + 1]}`);
1045
+ return out.join(" ");
1046
+ }