@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,3423 @@
|
|
|
1
|
+
// The stage's React tree: the working area, `ShotRuntime` inside it, and the
|
|
2
|
+
// overlay on top.
|
|
3
|
+
//
|
|
4
|
+
// ── THE WORKING AREA ────────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// The canvas fills the whole viewport and the CAMERA carries the pan and zoom,
|
|
7
|
+
// through `setViewOffset` (see view.ts). Two consequences worth stating:
|
|
8
|
+
//
|
|
9
|
+
// 1. You can work OFF-CANVAS. The render is wider than the exportable frame,
|
|
10
|
+
// and the frame's own rectangle is derived from the same numbers as the
|
|
11
|
+
// offset — so the bright border sits exactly on the pixels that will be
|
|
12
|
+
// exported rather than approximately on them.
|
|
13
|
+
//
|
|
14
|
+
// 2. Zooming RE-RENDERS instead of magnifying. A CSS `transform: scale()`
|
|
15
|
+
// would have been fewer lines and would have made every zoomed-in frame
|
|
16
|
+
// soft, in a tool whose entire job is judging pixels.
|
|
17
|
+
//
|
|
18
|
+
// The layer RASTERS follow the zoom too, so text is cut for the pixels it is
|
|
19
|
+
// actually shown at.
|
|
20
|
+
//
|
|
21
|
+
// ── TWO PANES, ONE CANVAS, ONE SET OF NUMBERS ───────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// `paneRects` divides the canvas and EVERY consumer reads that one answer: the
|
|
24
|
+
// renderer scissors to it, `viewOffsetFor` measures the camera pane rather than
|
|
25
|
+
// the canvas, the overlay translates into it, and `paneAt` routes the pointer
|
|
26
|
+
// by it. A divider the renderer drew to and the pointer tested against
|
|
27
|
+
// separately would be off by a column at some widths, and the symptom is
|
|
28
|
+
// "clicking near the middle selects the wrong thing" rather than anything that
|
|
29
|
+
// looks like a layout bug.
|
|
30
|
+
//
|
|
31
|
+
// COORDINATES CROSS AS PANE-LOCAL. `paneAt` returns the pane and the local
|
|
32
|
+
// point together, and that pair is passed straight through to the feed. There
|
|
33
|
+
// is no second place where a pane origin is subtracted, which is the only way
|
|
34
|
+
// to be sure it is never subtracted twice.
|
|
35
|
+
//
|
|
36
|
+
// THE STAGE TAKES OVER THE PRESENT ONLY WHEN IT HAS TO. In camera mode
|
|
37
|
+
// `ShotRuntime` renders itself exactly as it does in an export, untouched;
|
|
38
|
+
// `SplitRenderer` mounts and `renderControl` flips to "external" only when a
|
|
39
|
+
// second pane is on screen. That is deliberate: the single-pane path is the one
|
|
40
|
+
// the export shares, and the cheapest way to keep it identical is to not
|
|
41
|
+
// reimplement it.
|
|
42
|
+
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
43
|
+
import { ShotRuntime, hasPostChain, quantizeRasterScale } from "@dailies-studio/shot";
|
|
44
|
+
import {
|
|
45
|
+
DEFAULT_KEY_DIR,
|
|
46
|
+
MIN_INVERTIBLE_SCALE,
|
|
47
|
+
binsAtCached,
|
|
48
|
+
cameraRigAtCached,
|
|
49
|
+
componentDefsOfCached,
|
|
50
|
+
instanceComponentIdsOf,
|
|
51
|
+
masterToSceneCached,
|
|
52
|
+
sceneHasMeshCached,
|
|
53
|
+
transitionAtCached,
|
|
54
|
+
withDescendants,
|
|
55
|
+
} from "@dailies-studio/shot/doc";
|
|
56
|
+
import type { Doc3D, RenderBin, SnapGuide } from "@dailies-studio/shot/doc";
|
|
57
|
+
import { Vector3 } from "three";
|
|
58
|
+
import type { Plane, Ray } from "three";
|
|
59
|
+
import { Overlays, modalReadoutSink } from "./overlays/Overlays.js";
|
|
60
|
+
import type { OverlayProps, ScreenKeyLight, ScreenMarquee, ScreenModal } from "./overlays/Overlays.js";
|
|
61
|
+
import { FrustumGizmo } from "./FrustumGizmo.js";
|
|
62
|
+
import { GridPlane } from "./GridPlane.js";
|
|
63
|
+
import { MoveGizmo } from "./MoveGizmo.js";
|
|
64
|
+
import { SplitRenderer } from "./SplitRenderer.js";
|
|
65
|
+
import { StageTools } from "./StageTools.js";
|
|
66
|
+
import { createStageFeed } from "./feed.js";
|
|
67
|
+
import type { Grab, StageFeed } from "./feed.js";
|
|
68
|
+
import { PERF_HUD_ENABLED, PerfHud, PerfProbe, createPerfBox } from "./PerfHud.js";
|
|
69
|
+
import { resolveInstanceModules } from "./instanceModules.js";
|
|
70
|
+
import type { InstanceResolution } from "./instanceModules.js";
|
|
71
|
+
import { quadMoved, toScreen } from "./project.js";
|
|
72
|
+
import type { ScreenQuad, WorldQuad } from "./project.js";
|
|
73
|
+
import { quadCentre } from "./handles.js";
|
|
74
|
+
import { paneAt, paneRects } from "./panes.js";
|
|
75
|
+
import type { PaneRect, StagePane } from "./panes.js";
|
|
76
|
+
import { isolationExtent, isolationEye, isolationFrame } from "./isolation.js";
|
|
77
|
+
import {
|
|
78
|
+
DOLLY_FALLOFF_PX,
|
|
79
|
+
WORKING_FOV_DEG,
|
|
80
|
+
defaultWorkingView,
|
|
81
|
+
dollyBounds,
|
|
82
|
+
dollyBy,
|
|
83
|
+
orbitBy,
|
|
84
|
+
panWorkingBy,
|
|
85
|
+
workingScreenScale,
|
|
86
|
+
workingViewForIsolation,
|
|
87
|
+
} from "./workingView.js";
|
|
88
|
+
import type { WorkingView } from "./workingView.js";
|
|
89
|
+
import { axisParam, framingArmDelta, keyDirOnScreen, navBalls, rotateKeyDir, viewForBall } from "./gizmoMath.js";
|
|
90
|
+
import type { GizmoGrab, NavBall, Vec3 } from "./gizmoMath.js";
|
|
91
|
+
import { frustumShape } from "./frustumGeometry.js";
|
|
92
|
+
import { isPanGesture, panBy, wheelToView } from "./interactions/panZoom.js";
|
|
93
|
+
import { designDelta, designPoint, gesturePlane, unionScale } from "./interactions/move.js";
|
|
94
|
+
import type { DesignBox } from "./interactions/move.js";
|
|
95
|
+
import { clampZoom, designToScreen, fitView, frameRect, screenToDesign, viewOffsetFor } from "./view.js";
|
|
96
|
+
import type { FrameSize } from "./view.js";
|
|
97
|
+
import type { StageClient, StageClientState } from "./bridgeClient.js";
|
|
98
|
+
import { isDrawTool, specOf } from "../tools.js";
|
|
99
|
+
import type { DrawTool } from "../tools.js";
|
|
100
|
+
import { resolveKey } from "../state/keymap.js";
|
|
101
|
+
import {
|
|
102
|
+
PEN_CLOSE_SLOP_PX,
|
|
103
|
+
PEN_DRAG_THRESHOLD_PX,
|
|
104
|
+
PEN_EMPTY,
|
|
105
|
+
penCloses,
|
|
106
|
+
penData,
|
|
107
|
+
penReduce,
|
|
108
|
+
} from "./penState.js";
|
|
109
|
+
import type { PenState } from "./penState.js";
|
|
110
|
+
import {
|
|
111
|
+
TILT_DEG_PER_PX,
|
|
112
|
+
advanceVirtual,
|
|
113
|
+
axisRefusal,
|
|
114
|
+
framingDeltaFor,
|
|
115
|
+
initialGestureKind,
|
|
116
|
+
layerGestureFor,
|
|
117
|
+
modalAxisToggle,
|
|
118
|
+
modalLabel,
|
|
119
|
+
modalReadout,
|
|
120
|
+
modalTarget,
|
|
121
|
+
typeNumber,
|
|
122
|
+
typedValue,
|
|
123
|
+
PRECISION_FACTOR,
|
|
124
|
+
} from "./modalState.js";
|
|
125
|
+
import type { ModalMode, ModalState } from "./modalState.js";
|
|
126
|
+
import type { ShotView, WorkingViewOrigin } from "../bridgeTypes.js";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Below this a press-and-release is a CLICK, not a drag. Without it every click
|
|
130
|
+
* that moved one pixel would clear the selection instead of setting it — and,
|
|
131
|
+
* once the draw lane existed, would insert a two-pixel rectangle instead of one
|
|
132
|
+
* of a usable size.
|
|
133
|
+
*
|
|
134
|
+
* ONE threshold for both lanes on purpose: "did the pointer travel" is the same
|
|
135
|
+
* question, and two numbers would make a gesture a marquee and a click at the
|
|
136
|
+
* same time.
|
|
137
|
+
*/
|
|
138
|
+
const DRAG_THRESHOLD_PX = 3;
|
|
139
|
+
|
|
140
|
+
/** What the wheel lane actually reads off a WheelEvent — a plain shape, so the
|
|
141
|
+
* rAF coalescer can hand the handler one batch with summed deltas. */
|
|
142
|
+
interface WheelLike {
|
|
143
|
+
clientX: number;
|
|
144
|
+
clientY: number;
|
|
145
|
+
deltaX: number;
|
|
146
|
+
deltaY: number;
|
|
147
|
+
deltaMode: number;
|
|
148
|
+
ctrlKey: boolean;
|
|
149
|
+
metaKey: boolean;
|
|
150
|
+
/** Shift turns a vertical wheel into a horizontal one, which is how a mouse
|
|
151
|
+
* reaches the pan a trackpad gets from `deltaX`. */
|
|
152
|
+
shiftKey: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What an unisolated layer fades to. Low enough that the subject is obviously
|
|
156
|
+
* the subject, high enough that the surroundings are still context to align
|
|
157
|
+
* against — which is the whole reason they are drawn at all rather than
|
|
158
|
+
* hidden. */
|
|
159
|
+
const DIM_OPACITY = 0.18;
|
|
160
|
+
|
|
161
|
+
// TILT_DEG_PER_PX moved to modalState.ts — one definition for the drag lane
|
|
162
|
+
// and the modal lane; imported above.
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The design z = 0 plane, which is the plane the camera's framed rectangle sits
|
|
166
|
+
* in and therefore the one a frustum drag unprojects onto.
|
|
167
|
+
*
|
|
168
|
+
* Built through `gesturePlane` from an IDENTITY matrix rather than constructed
|
|
169
|
+
* here, so the frustum drag and a layer's "move it on the canvas" drag are the
|
|
170
|
+
* same plane maths from the same file. `gesturePlane` takes anything with an
|
|
171
|
+
* `elements` array, which is also what keeps this line free of a three import
|
|
172
|
+
* it would otherwise need for `Matrix4`.
|
|
173
|
+
*/
|
|
174
|
+
const Z0_PLANE = gesturePlane("frame", {
|
|
175
|
+
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** Nothing on screen. Hoisted so the prop is referentially stable. */
|
|
179
|
+
const EMPTY_BINS: readonly RenderBin[] = Object.freeze([]);
|
|
180
|
+
|
|
181
|
+
/** A live modal transform's imperative half — held in a REF, not state:
|
|
182
|
+
* mouse motion drives it per pointer event and must not re-render the
|
|
183
|
+
* stage. The pure half (what each mode+axis emits) is modalState.ts; the
|
|
184
|
+
* pill is the only thing React is told about. Coordinates are PANE-LOCAL
|
|
185
|
+
* CSS px, which is what `feed.rayAt` measures against. */
|
|
186
|
+
/**
|
|
187
|
+
* The four bidirectional resize cursors, in the order `resizeCursorFor` indexes
|
|
188
|
+
* them: east, south-east, south, south-west. Each is its own opposite, which is
|
|
189
|
+
* why four names cover eight handles.
|
|
190
|
+
*/
|
|
191
|
+
const RESIZE_CURSORS = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"] as const;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A handle's screen direction, as a cursor.
|
|
195
|
+
*
|
|
196
|
+
* ── WHY AN ANGLE AND NOT A NAME ─────────────────────────────────────────
|
|
197
|
+
*
|
|
198
|
+
* The selection box is a PROJECTED QUAD, not an axis-aligned rectangle. Roll a
|
|
199
|
+
* layer 45 degrees and its "nw" corner points due north; look at it from the
|
|
200
|
+
* working pane's orbit and it can point anywhere at all. A cursor chosen from
|
|
201
|
+
* the handle's NAME would sit at up to 45 degrees to the edge the author is
|
|
202
|
+
* about to drag, which is worse than no cursor because it is confidently wrong.
|
|
203
|
+
*
|
|
204
|
+
* ── AND WHY FOUR BUCKETS RATHER THAN A DRAWN CURSOR ─────────────────────
|
|
205
|
+
*
|
|
206
|
+
* CSS has exactly these four bidirectional resize cursors and no way to rotate
|
|
207
|
+
* one. Snapping to the nearest is correct to within 22.5 degrees, which is what
|
|
208
|
+
* Figma does and what nobody has ever noticed. A data-URI SVG cursor rotated to
|
|
209
|
+
* the exact angle is possible and is deliberately NOT built here: it is a
|
|
210
|
+
* per-frame image encode for an error nobody can see. The rotate ring is where
|
|
211
|
+
* that cost earns its keep, because CSS has no rotate cursor at all.
|
|
212
|
+
*
|
|
213
|
+
* `angle` is measured in the overlay's y-DOWN space (see `Grab.handleAngle`),
|
|
214
|
+
* so bucket 1 is down-and-right, which reads as the ↖↘ diagonal: `nwse`.
|
|
215
|
+
*/
|
|
216
|
+
/** Ctrl quantises a pointer roll, Blender's own 15 degrees. */
|
|
217
|
+
const ROLL_SNAP_DEG = 15;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The rotate cursor, as a data-URI SVG rotated to the ring's own angle.
|
|
221
|
+
*
|
|
222
|
+
* ── THE ONE PLACE A DRAWN CURSOR EARNS ITS KEEP ─────────────────────────
|
|
223
|
+
*
|
|
224
|
+
* CSS has four bidirectional resize cursors and NO rotate cursor at all, so
|
|
225
|
+
* unlike `resizeCursorFor` there is nothing to snap to. Figma and Framer both
|
|
226
|
+
* draw one, and without it the ring is a band of empty space that silently
|
|
227
|
+
* behaves differently from the space beside it.
|
|
228
|
+
*
|
|
229
|
+
* QUANTISED TO 15 DEGREES and cached, so a sweep round a box mints 24 strings
|
|
230
|
+
* over its whole life rather than one per pointermove. The eye cannot see the
|
|
231
|
+
* difference; the allocation is real.
|
|
232
|
+
*/
|
|
233
|
+
const ROTATE_CURSORS = new Map<number, string>();
|
|
234
|
+
export function rotateCursorFor(angle: number): string {
|
|
235
|
+
if (!Number.isFinite(angle)) return "pointer";
|
|
236
|
+
const step = Math.round((angle * 180) / Math.PI / ROLL_SNAP_DEG) * ROLL_SNAP_DEG;
|
|
237
|
+
const cached = ROTATE_CURSORS.get(step);
|
|
238
|
+
if (cached !== undefined) return cached;
|
|
239
|
+
// Two black-outlined white arrowheads on an arc, which is the shape every
|
|
240
|
+
// design tool uses and the only one that reads at 24px.
|
|
241
|
+
const svg =
|
|
242
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">` +
|
|
243
|
+
`<g transform="rotate(${step} 12 12)" fill="none" stroke="#000" stroke-width="3.2" ` +
|
|
244
|
+
`stroke-linecap="round" stroke-linejoin="round">` +
|
|
245
|
+
`<path d="M5 9a7.6 7.6 0 0 1 14 0"/><path d="M19 4v5h-5"/></g>` +
|
|
246
|
+
`<g transform="rotate(${step} 12 12)" fill="none" stroke="#fff" stroke-width="1.6" ` +
|
|
247
|
+
`stroke-linecap="round" stroke-linejoin="round">` +
|
|
248
|
+
`<path d="M5 9a7.6 7.6 0 0 1 14 0"/><path d="M19 4v5h-5"/></g></svg>`;
|
|
249
|
+
const value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, pointer`;
|
|
250
|
+
ROTATE_CURSORS.set(step, value);
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function resizeCursorFor(angle: number): string {
|
|
255
|
+
if (!Number.isFinite(angle)) return "pointer";
|
|
256
|
+
const k = Math.round(angle / (Math.PI / 4));
|
|
257
|
+
return RESIZE_CURSORS[((k % 4) + 4) % 4]!;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What a press at a point would do, as data.
|
|
262
|
+
*
|
|
263
|
+
* `"empty"` is what a marquee, an orbit or a deselecting click all start from,
|
|
264
|
+
* and the three are told apart by the BUTTON and the MODIFIERS, which this
|
|
265
|
+
* union deliberately knows nothing about — those belong to the press lane, and
|
|
266
|
+
* a hover has neither.
|
|
267
|
+
*/
|
|
268
|
+
type StageIntent =
|
|
269
|
+
| { kind: "arm"; arm: GizmoGrab }
|
|
270
|
+
| { kind: "grab"; grab: Grab }
|
|
271
|
+
| { kind: "frustum"; part: "apex" | "body" }
|
|
272
|
+
| { kind: "empty" };
|
|
273
|
+
|
|
274
|
+
interface ModalLive {
|
|
275
|
+
state: ModalState;
|
|
276
|
+
rect: PaneRect;
|
|
277
|
+
anchor: readonly [number, number];
|
|
278
|
+
virtual: readonly [number, number];
|
|
279
|
+
lastRaw: readonly [number, number];
|
|
280
|
+
/** The anchor ray and unprojection plane, captured at start — move modes
|
|
281
|
+
* only; rotate and scale are screen-space and need neither. */
|
|
282
|
+
ray: Ray | null;
|
|
283
|
+
plane: Plane | null;
|
|
284
|
+
screenScale: number;
|
|
285
|
+
/** The world DESIGN point a scale grows away from — the selection's median —
|
|
286
|
+
* or null for a single layer, which scales in place. Snapshotted at the
|
|
287
|
+
* start so the pivot cannot walk while the layers move around it. */
|
|
288
|
+
pivot: readonly [number, number, number] | null;
|
|
289
|
+
/**
|
|
290
|
+
* A one-shot message the pill shows INSTEAD of the numbers, or null.
|
|
291
|
+
*
|
|
292
|
+
* For the axis keys the mapping table claims and ignores (`S` then `Z`, any
|
|
293
|
+
* axis during a camera zoom, `Z` during a camera move). The listener has
|
|
294
|
+
* already called `preventDefault`, so without this the key is swallowed and
|
|
295
|
+
* nothing anywhere distinguishes that from a key that never registered.
|
|
296
|
+
*
|
|
297
|
+
* Cleared by the next axis press that IS honoured, AND by any real pointer
|
|
298
|
+
* motion — a refusal describes a KEY, and the author moving the mouse is the
|
|
299
|
+
* author moving on. Without the second clause the message sat over a
|
|
300
|
+
* transform that was still changing while the live numbers never came back.
|
|
301
|
+
*/
|
|
302
|
+
refusal: string | null;
|
|
303
|
+
/**
|
|
304
|
+
* The numeric buffer: `G X 200 Enter`.
|
|
305
|
+
*
|
|
306
|
+
* A STRING and not a number, because `"1."`, `"-"` and `""` are all real
|
|
307
|
+
* mid-typing states that no number can represent — and a bare `-` parsed as
|
|
308
|
+
* `-0` would snap the layer to the origin the moment the author pressed it.
|
|
309
|
+
* `typedValue` is the one place that decides when it means something.
|
|
310
|
+
*
|
|
311
|
+
* Emptying it hands the transform back to the mouse, which is what makes a
|
|
312
|
+
* mistyped digit recoverable without cancelling the whole gesture.
|
|
313
|
+
*/
|
|
314
|
+
typed: string;
|
|
315
|
+
/** CTRL is held: quantise. Shift's partner, tracked here for the same reason
|
|
316
|
+
* shift is read per event — it can change mid-gesture. */
|
|
317
|
+
snap: boolean;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Nothing snapping — the overwhelmingly common case, hoisted so "no guides"
|
|
321
|
+
* is the same array on every render and cannot break the overlay's memo. */
|
|
322
|
+
const EMPTY_GUIDES: OverlayProps["guides"] = [];
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The overlay, with its OWN repaint subscription.
|
|
326
|
+
*
|
|
327
|
+
* This is the leaf `feed.onOverlay` re-renders. When the bump lived on
|
|
328
|
+
* `StageApp` (`bumpOverlay`), a camera dolly with a selection cost two full
|
|
329
|
+
* stage commits per frame — one for the dolly, one when the quads moved — and
|
|
330
|
+
* each commit is a GL draw under `frameloop="never"`. Here, a moved quad
|
|
331
|
+
* repaints one SVG.
|
|
332
|
+
*/
|
|
333
|
+
function OverlaysHost({
|
|
334
|
+
feed,
|
|
335
|
+
designGuides,
|
|
336
|
+
size,
|
|
337
|
+
keyLightArm,
|
|
338
|
+
...rest
|
|
339
|
+
}: {
|
|
340
|
+
feed: StageFeed;
|
|
341
|
+
/** The guides as the STORE sends them: one design-frame coordinate on one
|
|
342
|
+
* axis, origin at the canvas centre. The camera pane's screen numbers are
|
|
343
|
+
* derived by the caller; the working pane's segments are derived here,
|
|
344
|
+
* because only this half is re-rendered when the camera moves. */
|
|
345
|
+
designGuides: readonly SnapGuide[];
|
|
346
|
+
size: FrameSize;
|
|
347
|
+
/**
|
|
348
|
+
* The key-light arm's DIRECTION and its grab handler, or null.
|
|
349
|
+
*
|
|
350
|
+
* Its screen ANCHOR is derived here rather than passed, for `guidesWorking`'s
|
|
351
|
+
* reason: the layer's quad is rewritten by the in-canvas half every frame and
|
|
352
|
+
* no dependency array can see it move, so it has to be read where the repaint
|
|
353
|
+
* already happens.
|
|
354
|
+
*/
|
|
355
|
+
keyLightArm: { layerId: string; dir: readonly [number, number] | null; onGrab: (e: React.PointerEvent) => void } | null;
|
|
356
|
+
} & Omit<
|
|
357
|
+
OverlayProps,
|
|
358
|
+
| "quadsCamera"
|
|
359
|
+
| "quadsWorking"
|
|
360
|
+
| "locked"
|
|
361
|
+
| "guidesWorking"
|
|
362
|
+
| "unionCamera"
|
|
363
|
+
| "unionWorking"
|
|
364
|
+
| "keyLight"
|
|
365
|
+
>) {
|
|
366
|
+
const [, bump] = useReducer((n: number) => n + 1, 0);
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
feed.onOverlay = bump;
|
|
369
|
+
return () => {
|
|
370
|
+
feed.onOverlay = null;
|
|
371
|
+
};
|
|
372
|
+
}, [feed]);
|
|
373
|
+
return (
|
|
374
|
+
<MemoOverlays
|
|
375
|
+
{...rest}
|
|
376
|
+
quadsCamera={feed.quadsCamera}
|
|
377
|
+
quadsWorking={feed.quadsWorking}
|
|
378
|
+
locked={feed.locked}
|
|
379
|
+
unionCamera={feed.union?.camera ?? null}
|
|
380
|
+
unionWorking={feed.union?.working ?? null}
|
|
381
|
+
keyLight={keyLightAnchor(keyLightArm, feed)}
|
|
382
|
+
guidesWorking={projectGuides(designGuides, feed, rest.panes.working, size)}
|
|
383
|
+
/>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The key-light handle, anchored on its layer's quad.
|
|
389
|
+
*
|
|
390
|
+
* Null when the layer has no quad in the working pane this frame — which is
|
|
391
|
+
* every frame outside its lifespan, and every frame it is behind the camera.
|
|
392
|
+
* Drawing a handle for a layer that is not on screen would put a sun in empty
|
|
393
|
+
* space and let the author drag the shading of something they cannot see.
|
|
394
|
+
*/
|
|
395
|
+
function keyLightAnchor(
|
|
396
|
+
arm: { layerId: string; dir: readonly [number, number] | null; onGrab: (e: React.PointerEvent) => void } | null,
|
|
397
|
+
feed: StageFeed,
|
|
398
|
+
): ScreenKeyLight | null {
|
|
399
|
+
if (arm === null) return null;
|
|
400
|
+
const index = feed.ids.indexOf(arm.layerId);
|
|
401
|
+
const quad = index < 0 ? undefined : feed.quadsWorking[index];
|
|
402
|
+
if (quad === undefined) return null;
|
|
403
|
+
return { at: quadCentre(quad), dir: arm.dir, onGrab: arm.onGrab };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A design-plane guide line, as a screen SEGMENT in the working pane.
|
|
408
|
+
*
|
|
409
|
+
* ── WHY IT IS HERE AND NOT IN A `useMemo` ON `StageApp` ─────────────────
|
|
410
|
+
*
|
|
411
|
+
* `feed.workingCamera` is a mutable object the in-canvas half rewrites every
|
|
412
|
+
* frame, so no dependency array can see it move. `OverlaysHost` re-renders on
|
|
413
|
+
* `feed.onOverlay`, which fires exactly when a projected quad moved — which is
|
|
414
|
+
* every frame of the drag these guides exist during. So this is computed where
|
|
415
|
+
* the repaint already happens, and nowhere else.
|
|
416
|
+
*
|
|
417
|
+
* Cheap by construction: guides are empty except mid-snap, and the common case
|
|
418
|
+
* returns the same frozen array every time so the memo below still bails.
|
|
419
|
+
*
|
|
420
|
+
* The endpoints span TWICE the canvas, so the line runs past the artwork the
|
|
421
|
+
* way the camera pane's full-height rule does rather than stopping at an edge
|
|
422
|
+
* that would read as part of the geometry.
|
|
423
|
+
*/
|
|
424
|
+
function projectGuides(
|
|
425
|
+
guides: readonly SnapGuide[],
|
|
426
|
+
feed: StageFeed,
|
|
427
|
+
rect: PaneRect | null,
|
|
428
|
+
size: FrameSize,
|
|
429
|
+
): readonly { a: readonly [number, number]; b: readonly [number, number] }[] {
|
|
430
|
+
const camera = feed.workingCamera;
|
|
431
|
+
if (guides.length === 0 || rect === null || camera === null) return EMPTY_SEGMENTS;
|
|
432
|
+
const out: { a: readonly [number, number]; b: readonly [number, number] }[] = [];
|
|
433
|
+
for (const g of guides) {
|
|
434
|
+
// Design (dx, dy) with the origin at the canvas centre, and the one mapping
|
|
435
|
+
// applied on the way out: world = (x, -y, -z).
|
|
436
|
+
const span = g.axis === "x" ? size.height : size.width;
|
|
437
|
+
const from = g.axis === "x" ? [g.at, span] : [-span, -g.at];
|
|
438
|
+
const to = g.axis === "x" ? [g.at, -span] : [span, -g.at];
|
|
439
|
+
out.push({
|
|
440
|
+
a: toScreen(_guideA.set(from[0]!, from[1]!, 0), camera, rect.width, rect.height),
|
|
441
|
+
b: toScreen(_guideB.set(to[0]!, to[1]!, 0), camera, rect.width, rect.height),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const EMPTY_SEGMENTS: readonly { a: readonly [number, number]; b: readonly [number, number] }[] = [];
|
|
448
|
+
const _guideA = new Vector3();
|
|
449
|
+
const _guideB = new Vector3();
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The SVG bails unless a prop actually changed. StageApp re-renders on every
|
|
453
|
+
* seek and every gesture frame, and the overlay's props are stable across a
|
|
454
|
+
* seek by construction: the quads arrive as FRESH ARRAYS from StageTools
|
|
455
|
+
* exactly when a quad moved (feed.ts), the frame rect and the guides are
|
|
456
|
+
* memoized by the caller, and everything else is state. So the memo turns
|
|
457
|
+
* "the overlay re-renders whenever the stage does" into "the overlay
|
|
458
|
+
* re-renders when something it draws moved", which is what the quad gate in
|
|
459
|
+
* StageTools was already promising.
|
|
460
|
+
*/
|
|
461
|
+
const MemoOverlays = memo(Overlays);
|
|
462
|
+
|
|
463
|
+
export function StageApp({ client }: { client: StageClient }) {
|
|
464
|
+
const state = useStageState(client);
|
|
465
|
+
const areaRef = useRef<HTMLDivElement | null>(null);
|
|
466
|
+
const feed = useMemo(createStageFeed, []);
|
|
467
|
+
const perfBox = useMemo(createPerfBox, []);
|
|
468
|
+
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
|
469
|
+
const [marquee, setMarquee] = useState<ScreenMarquee | null>(null);
|
|
470
|
+
/**
|
|
471
|
+
* The outline a click would select, in STATE because the overlay draws it.
|
|
472
|
+
*
|
|
473
|
+
* The one place in the hover lane that costs a render, so it is written only
|
|
474
|
+
* when the answer actually CHANGES — a pointermove inside one layer keeps the
|
|
475
|
+
* same quad object and commits nothing. `quadMoved`'s half-pixel gate is the
|
|
476
|
+
* same rule `StageTools` uses to decide the selection outline moved, so a
|
|
477
|
+
* stationary pointer over a stationary layer is free.
|
|
478
|
+
*/
|
|
479
|
+
const [hover, setHover] = useState<{ pane: StagePane; quad: ScreenQuad } | null>(null);
|
|
480
|
+
/**
|
|
481
|
+
* The pen's path so far, in DESIGN px from the frame's top-left.
|
|
482
|
+
*
|
|
483
|
+
* DESIGN and not screen, even though the preview is drawn in screen space and
|
|
484
|
+
* has to be converted on every render. A point held in screen coordinates
|
|
485
|
+
* would stay where it is on the display while the author pans or zooms
|
|
486
|
+
* between clicks, and land somewhere they never pointed at.
|
|
487
|
+
*
|
|
488
|
+
* The GEOMETRY is penState.ts's and none of it is here: this component owns
|
|
489
|
+
* the pointer plumbing and the conversion, and nothing about where a handle
|
|
490
|
+
* goes.
|
|
491
|
+
*/
|
|
492
|
+
const [pen, setPen] = useState<PenState>(PEN_EMPTY);
|
|
493
|
+
/** The space bar is DOWN, so a left drag pans. See `useKeyRelay`. */
|
|
494
|
+
const spaceRef = useRef(false);
|
|
495
|
+
/** …and a pan actually ran during this hold, so the release must not also
|
|
496
|
+
* play. A separate bit because key auto-repeat resurrects a shared one. */
|
|
497
|
+
const spacePanned = useRef(false);
|
|
498
|
+
/**
|
|
499
|
+
* Recompute the cursor with no pointer motion, filled in far below.
|
|
500
|
+
*
|
|
501
|
+
* Declared up HERE because the key relay is registered above everything
|
|
502
|
+
* `refreshCursor` needs (`intentAt`, the feed, the panes), and a ref is the
|
|
503
|
+
* one shape that lets a listener registered once reach a function rebuilt on
|
|
504
|
+
* every render — the pattern `endModalRef` and `wheelRef` already use.
|
|
505
|
+
*/
|
|
506
|
+
const refreshCursorRef = useRef<() => void>(() => {});
|
|
507
|
+
/**
|
|
508
|
+
* The cursor a live orbit or dolly is holding, or null.
|
|
509
|
+
*
|
|
510
|
+
* Navigation was the one gesture family with no feedback at all: a pan closed
|
|
511
|
+
* the hand and the orbit right beside it said nothing, so "am I turning the
|
|
512
|
+
* view or dragging a layer" was answered only by watching the picture. It is
|
|
513
|
+
* a REF and read by `cursorFor` rather than written at the press, because
|
|
514
|
+
* every move of these drags commits and the per-commit refresh would
|
|
515
|
+
* overwrite an imperative write immediately.
|
|
516
|
+
*/
|
|
517
|
+
const navCursorRef = useRef<string | null>(null);
|
|
518
|
+
/** `B` armed a one-shot box select (Blender's). Consumed by the next press,
|
|
519
|
+
* whether or not it marquees — an armed mode the author has to remember is
|
|
520
|
+
* worse than one that expires. */
|
|
521
|
+
const boxSelectRef = useRef(false);
|
|
522
|
+
/** The live modal transform's PILL state — the ref below is authoritative;
|
|
523
|
+
* this exists so the overlay re-renders on start/axis/end and on nothing
|
|
524
|
+
* else (mouse motion stays off React entirely). */
|
|
525
|
+
const [modal, setModal] = useState<ModalState | null>(null);
|
|
526
|
+
const modalRef = useRef<ModalLive | null>(null);
|
|
527
|
+
const modalRaf = useRef(0);
|
|
528
|
+
/** The cursor's last known canvas position and button state, fed by the
|
|
529
|
+
* pointer-move prop. The start lane needs an ANCHOR — a modal begins on a
|
|
530
|
+
* keydown, which carries no coordinates — and `buttons` gates starting
|
|
531
|
+
* while any drag lane may be live (a modal's end would close that drag's
|
|
532
|
+
* scrub).
|
|
533
|
+
*
|
|
534
|
+
* `inside` IS LOAD-BEARING, not bookkeeping. Without it the position alone
|
|
535
|
+
* answers "where is the cursor", and it lies in two ways that both open a
|
|
536
|
+
* modal nobody asked for: it starts at `[0, 0]`, which is INSIDE the working
|
|
537
|
+
* pane in every layout that has one, so `G`/`S` armed a camera modal before
|
|
538
|
+
* the pointer had ever entered the stage; and the stage is an iframe that
|
|
539
|
+
* keeps keyboard focus after the cursor moves onto the chrome, where no
|
|
540
|
+
* `pointermove` arrives to correct the stale position. Either way the modal
|
|
541
|
+
* opened invisibly, and its guards swallow the wheel, the first click in
|
|
542
|
+
* EITHER pane, and every non-modifier key including the space the pan gate
|
|
543
|
+
* reads — which is the whole of "I can't move or pan around". */
|
|
544
|
+
const lastPointer = useRef<{ at: readonly [number, number]; buttons: number; inside: boolean }>({
|
|
545
|
+
at: [0, 0],
|
|
546
|
+
buttons: 0,
|
|
547
|
+
inside: false,
|
|
548
|
+
});
|
|
549
|
+
/** The pane the cursor is over, so a crossing writes `activePane` ONCE. */
|
|
550
|
+
const hoverPane = useRef<StagePane | null>(null);
|
|
551
|
+
|
|
552
|
+
const doc = state.doc;
|
|
553
|
+
const size = doc?.size ?? { width: 1920, height: 1080 };
|
|
554
|
+
// How far the working pane may dolly, derived from THIS document's fit. Every
|
|
555
|
+
// navigation writer passes it; the derived-pose writers (fit, isolation seed)
|
|
556
|
+
// deliberately do not. Memoised on the two numbers because it runs a
|
|
557
|
+
// `defaultWorkingView` and every wheel event would otherwise repeat it.
|
|
558
|
+
const dolly = useMemo(() => dollyBounds(size), [size.width, size.height]);
|
|
559
|
+
// Stable for the stage's lifetime, so the runtime's mount effect can hold it
|
|
560
|
+
// in a ref and stay a mount-only effect. It bumps a plain counter on the feed
|
|
561
|
+
// and touches no React state: a layer arriving must not re-commit the stage.
|
|
562
|
+
const bumpSceneEpoch = useCallback(() => {
|
|
563
|
+
feed.sceneEpoch += 1;
|
|
564
|
+
}, [feed]);
|
|
565
|
+
// Straight across the bridge. Stable identity so the runtime's effect stays
|
|
566
|
+
// a mount-only one; `client` is the stage's own handle and never changes.
|
|
567
|
+
const pushMeshStats = useCallback(
|
|
568
|
+
(layerId: string, triangles: number | null) => {
|
|
569
|
+
client.handlers()?.onMeshStats(layerId, triangles);
|
|
570
|
+
},
|
|
571
|
+
[client],
|
|
572
|
+
);
|
|
573
|
+
const view = state.view;
|
|
574
|
+
const layout = state.layout;
|
|
575
|
+
const workingView = state.workingView;
|
|
576
|
+
const follows = state.cameraFollowsView;
|
|
577
|
+
|
|
578
|
+
// The area's client origin, maintained by the SAME ResizeObserver that
|
|
579
|
+
// measures the viewport — never read per event. `localPoint` runs on every
|
|
580
|
+
// pointermove of every drag, and a `getBoundingClientRect` there is a forced
|
|
581
|
+
// layout read against a layout the same move's render just invalidated. The
|
|
582
|
+
// area is `position: fixed; inset: 0` today (origin 0,0 always); the ref
|
|
583
|
+
// keeps this honest if that ever changes.
|
|
584
|
+
const areaOrigin = useRef<readonly [number, number]>([0, 0]);
|
|
585
|
+
useViewportSize(areaRef, setViewport, areaOrigin);
|
|
586
|
+
|
|
587
|
+
const panes = useMemo(() => paneRects(layout, viewport), [layout, viewport]);
|
|
588
|
+
|
|
589
|
+
// ── A MOVED DIVIDER INVALIDATES "WHICH PANE IS THE POINTER OVER" ─────────
|
|
590
|
+
//
|
|
591
|
+
// `onPointerHover` reports a pane crossing ONCE, deduped against
|
|
592
|
+
// `hoverPane`. But a panel collapse, a window resize or a divider drag moves
|
|
593
|
+
// the boundary under a STATIONARY cursor, with no pointermove to follow —
|
|
594
|
+
// and the dedupe then pins the disagreement indefinitely, so Home and the
|
|
595
|
+
// zoom keys keep addressing a pane the author is no longer pointing at.
|
|
596
|
+
//
|
|
597
|
+
// Keyed on `panes` and not on `layout.mode`, because all three of those
|
|
598
|
+
// change the rects without necessarily changing the mode.
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
hoverPane.current = null;
|
|
601
|
+
}, [panes]);
|
|
602
|
+
|
|
603
|
+
// ── THE RESOLVED POSITION, AND ISOLATION ─────────────────────────────────
|
|
604
|
+
//
|
|
605
|
+
// UP HERE, above the gesture lanes, because `paneScreenScale` below is read
|
|
606
|
+
// by `startModal`, `runTransformGesture` and `runGizmoGesture` — all of which
|
|
607
|
+
// are declared before the render half of this component. The rest of the
|
|
608
|
+
// frame's derivations (effects, bins, the camera rig) stay where they are.
|
|
609
|
+
//
|
|
610
|
+
// Where the playhead lands in the document, resolved ONCE. It rides the SAME
|
|
611
|
+
// size-1 caches ShotRuntime reads (frameCache.ts), so the stage and the
|
|
612
|
+
// runtime cannot compute the frame twice — the second caller replays the
|
|
613
|
+
// first one's answer.
|
|
614
|
+
const at = useMemo(
|
|
615
|
+
() => (doc === null ? null : masterToSceneCached(doc, state.frame)),
|
|
616
|
+
[doc, state.frame],
|
|
617
|
+
);
|
|
618
|
+
// Isolation is derived from ONE id and the document the stage already has,
|
|
619
|
+
// rather than pushed as a set: a set the chrome computed could disagree with
|
|
620
|
+
// the tree after any edit, and the two would then argue about what is
|
|
621
|
+
// pickable. Both memos are keyed on the resolved position, so the frame
|
|
622
|
+
// follows a parent's animation while the author is inside a card.
|
|
623
|
+
const isolated = useMemo(
|
|
624
|
+
() =>
|
|
625
|
+
// NOT IN THIS SCENE MEANS NOT ISOLATED. `withDescendants` answers a set
|
|
626
|
+
// holding the seed whether or not the seed exists, so an id from another
|
|
627
|
+
// document — or from another scene, once the playhead has moved on —
|
|
628
|
+
// produces a "keep" set that no layer matches: every layer dims and
|
|
629
|
+
// `pickable` filters to empty. A uniformly dead pane with nothing to
|
|
630
|
+
// click is worse than no isolation, and this one guard is what makes the
|
|
631
|
+
// dim, the picking and the badge agree by construction.
|
|
632
|
+
state.isolation === null || at === null || at.scene.layersById[state.isolation] === undefined
|
|
633
|
+
? null
|
|
634
|
+
: withDescendants(at.scene, [state.isolation]),
|
|
635
|
+
[state.isolation, at],
|
|
636
|
+
);
|
|
637
|
+
const isolationView = useMemo(
|
|
638
|
+
() =>
|
|
639
|
+
state.isolation === null || at === null || isolated === null || doc === null
|
|
640
|
+
? null
|
|
641
|
+
: isolationFrame(at.scene, state.isolation, isolated, at.sceneFrame, doc.fps),
|
|
642
|
+
[state.isolation, at, isolated, doc],
|
|
643
|
+
);
|
|
644
|
+
/**
|
|
645
|
+
* The derived head-on rig, or null once the AUTHOR owns the camera.
|
|
646
|
+
*
|
|
647
|
+
* Every consumer that means "which camera is drawing this pane" reads this
|
|
648
|
+
* and not `isolationView`: the ortho rig in `StageTools`, the screen scale
|
|
649
|
+
* below, and the nav gizmo. `isolationView` itself stays the geometry, which
|
|
650
|
+
* the seeding effect still needs after the hand-over.
|
|
651
|
+
*/
|
|
652
|
+
const headOnRig = state.isolationOrbit ? null : isolationView;
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* CSS px per design px in a pane, asked of the camera ACTUALLY IN THE SLOT.
|
|
656
|
+
*
|
|
657
|
+
* `workingScreenScale` derives it from the orbit rig's distance and lens —
|
|
658
|
+
* correct, until isolation swaps an orthographic camera in and stops reading
|
|
659
|
+
* `workingView` at all. Every consumer then measured against a pose that was
|
|
660
|
+
* drawing no pixels: a 400px card isolated in a 1080x1920 document gives a
|
|
661
|
+
* true scale near 4.1 against a frozen 0.37, so a depth drag ran an order of
|
|
662
|
+
* magnitude fast and the snap threshold bit at a distance with no relation to
|
|
663
|
+
* anything on screen.
|
|
664
|
+
*
|
|
665
|
+
* NOT ON THE FEED, tempting as that is. An `OrthographicCamera` carries its
|
|
666
|
+
* own extents but a `PerspectiveCamera` does not carry its target distance,
|
|
667
|
+
* so the perspective branch would still have to come back here for
|
|
668
|
+
* `workingView`. This is the one place that knows both.
|
|
669
|
+
*
|
|
670
|
+
* The aspect expression is character-for-character `StageTools`' own, so the
|
|
671
|
+
* two derivations of the ortho extent cannot drift apart.
|
|
672
|
+
*/
|
|
673
|
+
const paneScreenScale = (rect: PaneRect): number =>
|
|
674
|
+
headOnRig === null
|
|
675
|
+
? workingScreenScale(workingView, rect.height, WORKING_FOV_DEG)
|
|
676
|
+
: rect.height /
|
|
677
|
+
(2 * isolationExtent(headOnRig, rect.height === 0 ? 1 : rect.width / rect.height).halfHeight);
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* SEED THE ORBIT RIG WHERE THE HEAD-ON RIG STANDS, once per entry.
|
|
681
|
+
*
|
|
682
|
+
* So the author's first orbit continues from what is on screen instead of
|
|
683
|
+
* from wherever the camera was before they entered the subtree. It runs while
|
|
684
|
+
* the derived rig is still drawing, so it changes no pixel — it only makes
|
|
685
|
+
* the hand-over continuous.
|
|
686
|
+
*
|
|
687
|
+
* ORIGIN "fit", and that is load-bearing: this is a pose the stage derived
|
|
688
|
+
* ITSELF, so under follow mode it must not reach `writeFramingFromView` and
|
|
689
|
+
* rewrite the shot camera of a document the author merely opened a card in.
|
|
690
|
+
* The bridge is written directly for the same reason the document-open fit
|
|
691
|
+
* does it — the local `setWorkingView` is the gesture path.
|
|
692
|
+
*/
|
|
693
|
+
const seededFor = useRef<string | null>(null);
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
if (state.isolation === null) {
|
|
696
|
+
seededFor.current = null;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (state.isolationOrbit || isolationView === null || panes.working === null) return;
|
|
700
|
+
if (seededFor.current === state.isolation) return;
|
|
701
|
+
seededFor.current = state.isolation;
|
|
702
|
+
const rect = panes.working;
|
|
703
|
+
const { halfHeight } = isolationExtent(isolationView, rect.height === 0 ? 1 : rect.width / rect.height);
|
|
704
|
+
const seeded = workingViewForIsolation(isolationEye(isolationView).eye, isolationView.target, halfHeight);
|
|
705
|
+
client.bridge.setWorkingView(seeded);
|
|
706
|
+
client.handlers()?.onWorkingViewChange(seeded, "fit");
|
|
707
|
+
}, [client, state.isolation, state.isolationOrbit, isolationView, panes.working]);
|
|
708
|
+
|
|
709
|
+
/** What the in-pane badge says. Off `isolationView` and not off `isolation`
|
|
710
|
+
* alone, so a stale id — one the scene no longer holds — announces no mode
|
|
711
|
+
* the pane is not actually in. */
|
|
712
|
+
const isolationLabel = useMemo(() => {
|
|
713
|
+
if (state.isolation === null || at === null || isolationView === null) return null;
|
|
714
|
+
const label = at.scene.layersById[state.isolation]?.label ?? state.isolation;
|
|
715
|
+
// ── AND WHETHER IT IS ACTUALLY ON SCREEN ──────────────────────────────
|
|
716
|
+
//
|
|
717
|
+
// A layer outside its own lifespan at this frame is dimmed away with
|
|
718
|
+
// everything else, so the pane is uniformly dark with nothing to click and
|
|
719
|
+
// the badge said only "INSIDE CARD" — which reads as the mode being broken
|
|
720
|
+
// rather than as the playhead being somewhere the card is not.
|
|
721
|
+
//
|
|
722
|
+
// `isolated` is the subtree, so this is true when NO member is drawing:
|
|
723
|
+
// a group whose children are all off-lifespan is just as blank.
|
|
724
|
+
const drawing = (feed.frames ?? []).some((f) => isolated?.has(f.id) === true);
|
|
725
|
+
return drawing ? label : `${label} — NOT ON SCREEN AT THIS FRAME`;
|
|
726
|
+
// `feed.frames` is a mutable field, so this cannot be keyed on it. The
|
|
727
|
+
// playhead is what changes the answer, and `at` carries it.
|
|
728
|
+
}, [state.isolation, at, isolationView, isolated, feed]);
|
|
729
|
+
// The camera pane is what the view offset, the fit and the zoom anchor are
|
|
730
|
+
// all measured against — `view.ts` still knows nothing about panes, it is
|
|
731
|
+
// simply handed a smaller viewport. Falling back to the whole canvas keeps
|
|
732
|
+
// those three total while the working pane is the only one on screen.
|
|
733
|
+
const cameraViewport = panes.camera ?? viewport;
|
|
734
|
+
// WHENEVER THE WORKING PANE IS ON SCREEN, not only in a split. A working-only
|
|
735
|
+
// layout has no camera pane at all, so leaving `ShotRuntime` to present would
|
|
736
|
+
// draw the shot camera across a viewport the author asked to see from
|
|
737
|
+
// somewhere else. The camera-only layout is the one — and the only one — that
|
|
738
|
+
// stays on the runtime's own path, which is the path an export shares.
|
|
739
|
+
const ownsPresent = panes.working !== null;
|
|
740
|
+
|
|
741
|
+
const fit = useCallback(
|
|
742
|
+
(): ShotView => fitView(cameraViewport, size),
|
|
743
|
+
[cameraViewport, size.width, size.height],
|
|
744
|
+
);
|
|
745
|
+
/**
|
|
746
|
+
* Frame the whole canvas, KEEPING where the author is standing.
|
|
747
|
+
*
|
|
748
|
+
* Blender's Frame All moves the target and the distance and never the
|
|
749
|
+
* angles, and numpad-dot in this editor already worked that way
|
|
750
|
+
* (`fitWorkingViewToSelection`). Home did not: it went back to the canonical
|
|
751
|
+
* three-quarter pose, so an author who had lined up an elevation lost it to
|
|
752
|
+
* a key they pressed to see everything.
|
|
753
|
+
*
|
|
754
|
+
* DONE IN THE FITTER and not in `defaultWorkingView`, because that function
|
|
755
|
+
* is also `INITIAL_WORKING_VIEW` and the document-open fit — both of which
|
|
756
|
+
* genuinely want the canonical pose. Read at CALL time, so the angles are
|
|
757
|
+
* whatever they are when Home is pressed rather than whatever they were when
|
|
758
|
+
* this callback was built.
|
|
759
|
+
*/
|
|
760
|
+
const fitWorking = useCallback((): WorkingView => {
|
|
761
|
+
const at = client.getState().workingView;
|
|
762
|
+
return {
|
|
763
|
+
...defaultWorkingView(size, WORKING_FOV_DEG),
|
|
764
|
+
theta: at.theta,
|
|
765
|
+
phi: at.phi,
|
|
766
|
+
// Spread conditionally: `exactOptionalPropertyTypes` is on, so writing
|
|
767
|
+
// `ortho: undefined` is not the same as omitting it.
|
|
768
|
+
...(at.ortho === undefined ? {} : { ortho: at.ortho }),
|
|
769
|
+
};
|
|
770
|
+
}, [client, size.width, size.height]);
|
|
771
|
+
useEffect(() => {
|
|
772
|
+
client.setFitter(fit);
|
|
773
|
+
client.setWorkingFitter(fitWorking);
|
|
774
|
+
return () => {
|
|
775
|
+
client.setFitter(null);
|
|
776
|
+
client.setWorkingFitter(null);
|
|
777
|
+
};
|
|
778
|
+
}, [client, fit, fitWorking]);
|
|
779
|
+
|
|
780
|
+
// The chrome's data seam onto the overlay geometry. Rendering the outline is
|
|
781
|
+
// the stage's job (see Overlays), but a panel that wants to sit beside the
|
|
782
|
+
// selection needs the numbers.
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
client.setQuadSource((pane) => {
|
|
785
|
+
const quads = pane === "working" ? feed.quadsWorking : feed.quadsCamera;
|
|
786
|
+
// Length, not `feed.ids.length`: a pane that is off screen holds no quads
|
|
787
|
+
// even though the selection is not empty, and answering with a shorter
|
|
788
|
+
// array zipped against the ids would pair a layer with another's outline.
|
|
789
|
+
return quads.length === 0 ? null : feed.ids.slice(0, quads.length).map((id, i) => ({ id, pts: quads[i]! }));
|
|
790
|
+
});
|
|
791
|
+
return () => client.setQuadSource(null);
|
|
792
|
+
}, [client, feed]);
|
|
793
|
+
|
|
794
|
+
// Fit once, when a document first arrives at a laid-out viewport. Re-fitting
|
|
795
|
+
// on every doc push would fight the author's own zoom on every autosave.
|
|
796
|
+
const fittedFor = useRef<string | null>(null);
|
|
797
|
+
/** The exact view the last auto-fit produced, so a later refit can tell "the
|
|
798
|
+
* author has not touched it" from "the author framed something". */
|
|
799
|
+
const lastFit = useRef<ShotView | null>(null);
|
|
800
|
+
useEffect(() => {
|
|
801
|
+
if (doc === null || viewport.width === 0 || fittedFor.current === doc.id) return;
|
|
802
|
+
fittedFor.current = doc.id;
|
|
803
|
+
const next = fit();
|
|
804
|
+
lastFit.current = next;
|
|
805
|
+
client.bridge.setView(next);
|
|
806
|
+
client.handlers()?.onViewChange(next);
|
|
807
|
+
// Both panes get framed by the same event: opening a document into a split
|
|
808
|
+
// and finding the working pane looking at the previous document's canvas
|
|
809
|
+
// would be the same bug the camera fit exists to prevent.
|
|
810
|
+
//
|
|
811
|
+
// ORIGIN "fit", and that is not decoration. This line is a MIRROR of a view
|
|
812
|
+
// the stage derived itself; follow mode listens on the same handler an
|
|
813
|
+
// orbit uses, so without the discriminator opening a shot while follow is
|
|
814
|
+
// armed silently overwrites its camera and autosaves it.
|
|
815
|
+
const working = fitWorking();
|
|
816
|
+
client.bridge.setWorkingView(working);
|
|
817
|
+
client.handlers()?.onWorkingViewChange(working, "fit");
|
|
818
|
+
}, [client, doc, fit, fitWorking, viewport.width]);
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* THE CAMERA PANE REFITS WHEN IT CHANGES SIZE, but only if the author never
|
|
822
|
+
* framed anything themselves.
|
|
823
|
+
*
|
|
824
|
+
* Entering a split halves the pane's width, and the frame the author was
|
|
825
|
+
* looking at is then cut off with no key pressed to explain it. Refitting is
|
|
826
|
+
* what every design tool does; what none of them do is overwrite a zoom the
|
|
827
|
+
* author chose, which is why this compares against the LAST AUTO-FIT rather
|
|
828
|
+
* than refitting unconditionally.
|
|
829
|
+
*
|
|
830
|
+
* Reference equality is the right test: `setView` only ever passes fresh
|
|
831
|
+
* objects, so `view === lastFit.current` means nothing has written since.
|
|
832
|
+
*/
|
|
833
|
+
useEffect(() => {
|
|
834
|
+
if (doc === null || panes.camera === null || lastFit.current === null) return;
|
|
835
|
+
if (view !== lastFit.current) return;
|
|
836
|
+
const next = fit();
|
|
837
|
+
if (next.zoom === view.zoom && next.panX === view.panX && next.panY === view.panY) return;
|
|
838
|
+
lastFit.current = next;
|
|
839
|
+
client.bridge.setView(next);
|
|
840
|
+
client.handlers()?.onViewChange(next);
|
|
841
|
+
}, [client, doc, fit, panes.camera, view]);
|
|
842
|
+
|
|
843
|
+
const setView = useCallback(
|
|
844
|
+
(next: ShotView) => {
|
|
845
|
+
client.bridge.setView(next);
|
|
846
|
+
client.handlers()?.onViewChange(next);
|
|
847
|
+
},
|
|
848
|
+
[client],
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
const setWorkingView = useCallback(
|
|
852
|
+
(next: WorkingView, origin: WorkingViewOrigin = "gesture") => {
|
|
853
|
+
const s = client.getState();
|
|
854
|
+
// ── INSIDE ISOLATION, THE FIRST GESTURE TAKES THE CAMERA ────────────
|
|
855
|
+
//
|
|
856
|
+
// The derived orthographic head-on rig is the ENTRY pose, not a cage.
|
|
857
|
+
// While it owns the slot nothing reads `workingView`, so this used to
|
|
858
|
+
// swallow the write — which is correct as far as it goes (under follow,
|
|
859
|
+
// projecting an unseen pose onto the shot camera rewrites and autosaves
|
|
860
|
+
// the document) but leaves an author who orbits with a pane that does
|
|
861
|
+
// nothing and says nothing. Blender's local view scopes what you see and
|
|
862
|
+
// leaves the camera free; so does this now.
|
|
863
|
+
//
|
|
864
|
+
// The chrome owns the mode, flips its bit and pushes it back down, so the
|
|
865
|
+
// ordinary orbit rig takes over from the NEXT commit. This gesture's own
|
|
866
|
+
// pose still lands, because `workingView` was seeded to the head-on rig's
|
|
867
|
+
// own pose on entry (see the seeding effect) — so the first orbit
|
|
868
|
+
// continues from what is on screen rather than jumping to wherever the
|
|
869
|
+
// camera happened to be before the author entered.
|
|
870
|
+
//
|
|
871
|
+
// ORIGIN MATTERS. Only a real navigation gesture takes the camera. A
|
|
872
|
+
// "fit" is the stage mirroring a pose it derived itself, which must not
|
|
873
|
+
// silently leave head-on mode.
|
|
874
|
+
if (s.isolation !== null && !s.isolationOrbit) {
|
|
875
|
+
if (origin === "fit") return;
|
|
876
|
+
client.handlers()?.onIsolationOrbitTaken();
|
|
877
|
+
}
|
|
878
|
+
client.bridge.setWorkingView(next);
|
|
879
|
+
client.handlers()?.onWorkingViewChange(next, origin);
|
|
880
|
+
},
|
|
881
|
+
[client],
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
const onReady = useCallback(
|
|
885
|
+
(info: { backend: string }) => {
|
|
886
|
+
if (doc === null) return;
|
|
887
|
+
client.handlers()?.onReady({
|
|
888
|
+
width: doc.size.width,
|
|
889
|
+
height: doc.size.height,
|
|
890
|
+
durationFrames: durationOf(doc),
|
|
891
|
+
backend: info.backend,
|
|
892
|
+
});
|
|
893
|
+
},
|
|
894
|
+
[client, doc],
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
// The project's own modules for every `instance` layer. Resolved HERE
|
|
898
|
+
// because this file is the only one served through the project's Vite; the
|
|
899
|
+
// chrome is a prebuilt bundle with no view of the project graph.
|
|
900
|
+
const instances = useInstanceModules(client, doc);
|
|
901
|
+
|
|
902
|
+
useKeyRelay(client, spaceRef, spacePanned, refreshCursorRef);
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The pen's terminators: Enter or a double-click commits, Escape abandons.
|
|
906
|
+
*
|
|
907
|
+
* ── WHY THE KEY LISTENER IS IN THE CAPTURE PHASE ────────────────────────
|
|
908
|
+
*
|
|
909
|
+
* `useKeyRelay` forwards every keystroke to the chrome, where Escape CLEARS
|
|
910
|
+
* THE SELECTION. Both are correct and only one of them may happen: an author
|
|
911
|
+
* abandoning a half-drawn path must not also lose what they had selected. A
|
|
912
|
+
* capture-phase listener on window runs before the relay's bubble-phase one,
|
|
913
|
+
* and `stopPropagation` there means the event never reaches it.
|
|
914
|
+
*
|
|
915
|
+
* Mounted only while the pen HOLDS POINTS, so with no path in progress
|
|
916
|
+
* Escape is the chrome's as it always was.
|
|
917
|
+
*/
|
|
918
|
+
/**
|
|
919
|
+
* Hand the path to the chrome and clear the pen.
|
|
920
|
+
*
|
|
921
|
+
* The anchors AND both handles move to canvas-centre origin together: a
|
|
922
|
+
* handle is an absolute point in the same space as the anchor it belongs to,
|
|
923
|
+
* not an offset from it, so converting the anchor alone would leave every
|
|
924
|
+
* curve pointing at where the old origin used to be.
|
|
925
|
+
*
|
|
926
|
+
* Under two anchors `penData` answers null and this sends nothing — one
|
|
927
|
+
* anchor is a layer with no geometry and no way to select it, and refusing
|
|
928
|
+
* IS the behaviour (the same posture `onDraw` documents for an empty pen).
|
|
929
|
+
*/
|
|
930
|
+
const commitPen = useCallback(
|
|
931
|
+
(next: PenState): void => {
|
|
932
|
+
const data = penData(next);
|
|
933
|
+
setPen(PEN_EMPTY);
|
|
934
|
+
if (data === null) return;
|
|
935
|
+
client.handlers()?.onPenCommit({
|
|
936
|
+
closed: data.closed,
|
|
937
|
+
points: data.points.map((p) => ({
|
|
938
|
+
p: centreOrigin(p.p, size),
|
|
939
|
+
...(p.in === undefined ? {} : { in: centreOrigin(p.in, size) }),
|
|
940
|
+
...(p.out === undefined ? {} : { out: centreOrigin(p.out, size) }),
|
|
941
|
+
})),
|
|
942
|
+
});
|
|
943
|
+
},
|
|
944
|
+
[client, size.width, size.height],
|
|
945
|
+
);
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The pen in CAMERA-PANE px, for the preview.
|
|
949
|
+
*
|
|
950
|
+
* Every anchor and both its handles go through the same projection, for the
|
|
951
|
+
* reason `commitPen` gives about the origin shift: a handle is a point in the
|
|
952
|
+
* anchor's space, not an offset from it. `closeHot` is answered by
|
|
953
|
+
* `penCloses` rather than by a second distance test here, so the highlight
|
|
954
|
+
* and the click agree by construction.
|
|
955
|
+
*/
|
|
956
|
+
const penScreen = useMemo(() => {
|
|
957
|
+
if (pen.points.length === 0) return null;
|
|
958
|
+
const project = (p: readonly [number, number]): [number, number] =>
|
|
959
|
+
designToScreen([p[0], p[1]], view, cameraViewport, size);
|
|
960
|
+
return {
|
|
961
|
+
closed: pen.closed,
|
|
962
|
+
cursor: pen.cursor === null ? null : project(pen.cursor),
|
|
963
|
+
closeHot:
|
|
964
|
+
pen.cursor !== null &&
|
|
965
|
+
penCloses(pen, pen.cursor, PEN_CLOSE_SLOP_PX / Math.max(view.zoom, MIN_INVERTIBLE_SCALE)),
|
|
966
|
+
points: pen.points.map((p) => ({
|
|
967
|
+
p: project(p.p),
|
|
968
|
+
...(p.in === undefined ? {} : { in: project(p.in) }),
|
|
969
|
+
...(p.out === undefined ? {} : { out: project(p.out) }),
|
|
970
|
+
})),
|
|
971
|
+
};
|
|
972
|
+
}, [pen, view, cameraViewport, size.width, size.height]);
|
|
973
|
+
|
|
974
|
+
useEffect(() => {
|
|
975
|
+
if (state.tool !== "path") {
|
|
976
|
+
// Arming another tool abandons the pen. The points are the pen's, and a
|
|
977
|
+
// path that committed later out of a tool the author had left would
|
|
978
|
+
// appear from nowhere.
|
|
979
|
+
if (pen.points.length > 0) setPen(PEN_EMPTY);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (pen.points.length === 0) return;
|
|
983
|
+
const area = areaRef.current;
|
|
984
|
+
const commit = (): void => commitPen(pen);
|
|
985
|
+
const onKey = (ev: KeyboardEvent): void => {
|
|
986
|
+
// Resolved through the KEYMAP, not by comparing key names here. The
|
|
987
|
+
// chrome's keymap is the one table that knows Escape means deselect
|
|
988
|
+
// when no path is open, and `penActive` is what tells it a path is —
|
|
989
|
+
// so the precedence lives in one readable place instead of being an
|
|
990
|
+
// accident of which listener happened to be in the capture phase.
|
|
991
|
+
const action = resolveKey(ev, { editing: false, hasSelection: false, penActive: true });
|
|
992
|
+
if (action?.kind !== "penCommit" && action?.kind !== "penAbandon") return;
|
|
993
|
+
ev.preventDefault();
|
|
994
|
+
ev.stopPropagation();
|
|
995
|
+
if (action.kind === "penCommit") commit();
|
|
996
|
+
else setPen(PEN_EMPTY);
|
|
997
|
+
};
|
|
998
|
+
window.addEventListener("keydown", onKey, true);
|
|
999
|
+
area?.addEventListener("dblclick", commit);
|
|
1000
|
+
return () => {
|
|
1001
|
+
window.removeEventListener("keydown", onKey, true);
|
|
1002
|
+
area?.removeEventListener("dblclick", commit);
|
|
1003
|
+
};
|
|
1004
|
+
}, [client, pen, size.width, size.height, state.tool]);
|
|
1005
|
+
|
|
1006
|
+
const localPoint = (e: { clientX: number; clientY: number }): [number, number] => {
|
|
1007
|
+
return [e.clientX - areaOrigin.current[0], e.clientY - areaOrigin.current[1]];
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// ── THE MODAL TRANSFORM CONTROLLER (G / R / S) ──────────────────────────
|
|
1011
|
+
//
|
|
1012
|
+
// A modal transform is the EXISTING gesture lane re-driven: it opens the
|
|
1013
|
+
// same onGestureStart / onGesture / onGestureEnd (or onFramingGesture*)
|
|
1014
|
+
// scrub a pointer drag opens, so animate mode, snapping, refusals and
|
|
1015
|
+
// one-undo-per-gesture all fall out unchanged. Only the input differs —
|
|
1016
|
+
// mouse MOTION with no button held, terminated by click/Enter (confirm) or
|
|
1017
|
+
// Escape/right-click (cancel). It registers ZERO pointer listeners: motion
|
|
1018
|
+
// rides the pointer-move prop below, termination rides the pointerdown
|
|
1019
|
+
// lane's first branch, and keys ride one capture-phase listener (the pen's
|
|
1020
|
+
// pattern). The mapping tables live in modalState.ts.
|
|
1021
|
+
|
|
1022
|
+
const emitModal = (live: ModalLive): void => {
|
|
1023
|
+
const handlers = client.handlers();
|
|
1024
|
+
if (handlers === undefined || handlers === null) return;
|
|
1025
|
+
const travel = { x: live.virtual[0] - live.anchor[0], y: live.virtual[1] - live.anchor[1] };
|
|
1026
|
+
// ── A TYPED VALUE OUTRANKS THE POINTER ────────────────────────────────
|
|
1027
|
+
//
|
|
1028
|
+
// `G X 200 Enter`. While the buffer holds something, the mouse stops
|
|
1029
|
+
// driving the transform entirely — Blender's own behaviour, and the reason
|
|
1030
|
+
// backspacing it empty hands the gesture back rather than cancelling it.
|
|
1031
|
+
//
|
|
1032
|
+
// A PLANAR MOVE HAS NO SCALAR to replace: `designDelta` answers two
|
|
1033
|
+
// components from two rays, and picking one for the author would guess.
|
|
1034
|
+
// Blender requires an axis for a numeric move too, and the refusal says so
|
|
1035
|
+
// in the pill rather than silently ignoring the digits.
|
|
1036
|
+
const exact = typedValue(live.typed);
|
|
1037
|
+
if (exact !== null && live.state.mode === "move" && live.state.axis === null) {
|
|
1038
|
+
live.refusal = "TYPE AN AXIS FIRST · X Y Z";
|
|
1039
|
+
modalReadoutSink.write?.(live.refusal);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
// ── A SCALE IS A FACTOR, AND ZERO IS NOT ONE ──────────────────────────
|
|
1043
|
+
//
|
|
1044
|
+
// `size` has no sign and no zero to come back from, so `applyGesture`
|
|
1045
|
+
// refuses a non-positive ratio by name. The modal used to clamp the typed
|
|
1046
|
+
// value to 0.1 before it ever got there, so `S 0 Enter` shrank the layer to
|
|
1047
|
+
// a tenth of itself rather than being refused — the number the author typed
|
|
1048
|
+
// silently replaced by another one. The table returns null; this names it.
|
|
1049
|
+
if (exact !== null && live.state.mode === "scale" && !(exact > 0)) {
|
|
1050
|
+
live.refusal = "A SCALE NEEDS A FACTOR ABOVE ZERO";
|
|
1051
|
+
modalReadoutSink.write?.(live.refusal);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
let planar: { dx: number; dy: number; dz: number } | null = null;
|
|
1055
|
+
if (live.state.mode === "move" && live.ray !== null && live.plane !== null) {
|
|
1056
|
+
const to = feed.rayAt?.("working", live.virtual[0], live.virtual[1]) ?? null;
|
|
1057
|
+
planar = to === null ? null : designDelta(live.ray, to, live.plane);
|
|
1058
|
+
}
|
|
1059
|
+
// A LIVE BUFFER IS SHOWN AS TYPED, not as the number it parses to, so the
|
|
1060
|
+
// author can see `1.` while they are half way through `1.5`.
|
|
1061
|
+
const echo = live.typed === "" ? null : `${live.typed}_`;
|
|
1062
|
+
if (live.state.target === "camera") {
|
|
1063
|
+
const delta = framingDeltaFor(live.state, travel, planar, DOLLY_FALLOFF_PX, exact);
|
|
1064
|
+
// THE PILL'S NUMBERS COME OFF THE EMITTED DELTA, never re-derived from
|
|
1065
|
+
// the travel: a readout computed a second way is one that can disagree
|
|
1066
|
+
// with the thing moving under it. Written imperatively, because this runs
|
|
1067
|
+
// per pointermove and the pill is memoised on state alone.
|
|
1068
|
+
modalReadoutSink.write?.(live.refusal ?? echo ?? modalReadout(delta));
|
|
1069
|
+
if (delta !== null) handlers.onFramingGesture(delta);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// THE PANE'S SCALE IS RE-READ, not the one snapshotted at start. A window
|
|
1073
|
+
// resize or a panel collapse mid-transform moves the divider, and a scale
|
|
1074
|
+
// captured before it describes a pane that no longer exists — so the layer
|
|
1075
|
+
// would suddenly grow at the wrong rate for the rest of the gesture.
|
|
1076
|
+
// `travel` is measured in the virtual cursor's own accumulated px and is
|
|
1077
|
+
// unaffected; only the px-to-design conversion has to stay current.
|
|
1078
|
+
// Always the WORKING pane: modals only start over it (the keymap's
|
|
1079
|
+
// `workingPane` gate), and the camera branch above returned already.
|
|
1080
|
+
const scale = panes.working === null ? live.screenScale : paneScreenScale(panes.working);
|
|
1081
|
+
const delta = layerGestureFor(live.state, travel, planar, scale, live.pivot, exact, live.snap);
|
|
1082
|
+
modalReadoutSink.write?.(live.refusal ?? echo ?? modalReadout(delta));
|
|
1083
|
+
if (delta !== null) handlers.onGesture(delta);
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const flushModal = (): void => {
|
|
1087
|
+
modalRaf.current = 0;
|
|
1088
|
+
const live = modalRef.current;
|
|
1089
|
+
if (live !== null) emitModal(live);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const endModal = (
|
|
1093
|
+
commit: boolean,
|
|
1094
|
+
/**
|
|
1095
|
+
* Hand the cursor back? False ONLY on a live switch (`G` then `R`), which
|
|
1096
|
+
* discards one modal and starts another inside the same keystroke.
|
|
1097
|
+
*
|
|
1098
|
+
* Chrome refuses a `requestPointerLock` that follows an exit too closely,
|
|
1099
|
+
* so the exit-then-request pair a switch used to make left the new modal
|
|
1100
|
+
* UNLOCKED for the rest of the gesture — and an unlocked modal cancels the
|
|
1101
|
+
* moment the pointer leaves the stage, which on a long rotate is most of
|
|
1102
|
+
* the time. Keeping the lock across the seam is the whole fix; every other
|
|
1103
|
+
* caller really is ending the gesture and still releases.
|
|
1104
|
+
*/
|
|
1105
|
+
release = true,
|
|
1106
|
+
): void => {
|
|
1107
|
+
const live = modalRef.current;
|
|
1108
|
+
if (live === null) return;
|
|
1109
|
+
modalRef.current = null;
|
|
1110
|
+
if (modalRaf.current !== 0) {
|
|
1111
|
+
cancelAnimationFrame(modalRaf.current);
|
|
1112
|
+
modalRaf.current = 0;
|
|
1113
|
+
}
|
|
1114
|
+
// Released FIRST, so the cursor is back under the author's control before
|
|
1115
|
+
// anything else runs — and unconditionally for a real end, because a lock
|
|
1116
|
+
// that outlived its modal would freeze the pointer with nothing left to
|
|
1117
|
+
// drive.
|
|
1118
|
+
if (release && document.pointerLockElement === areaRef.current) document.exitPointerLock?.();
|
|
1119
|
+
// The final position lands before the end on a commit — dragGesture's own
|
|
1120
|
+
// rule, so the layer cannot land a frame behind the pointer.
|
|
1121
|
+
if (commit) emitModal(live);
|
|
1122
|
+
const handlers = client.handlers();
|
|
1123
|
+
if (live.state.target === "camera") handlers?.onFramingGestureEnd(commit);
|
|
1124
|
+
else handlers?.onGestureEnd(commit);
|
|
1125
|
+
setModal(null);
|
|
1126
|
+
};
|
|
1127
|
+
// Stable identity for the mount-once effects below; the function itself
|
|
1128
|
+
// only touches refs and the (stable) client, so a stale closure is safe.
|
|
1129
|
+
const endModalRef = useRef(endModal);
|
|
1130
|
+
endModalRef.current = endModal;
|
|
1131
|
+
|
|
1132
|
+
const startModal = (
|
|
1133
|
+
mode: ModalMode,
|
|
1134
|
+
local: readonly [number, number],
|
|
1135
|
+
rect: PaneRect,
|
|
1136
|
+
/** The outgoing modal's `lastRaw` on a LIVE SWITCH (`G` then `R`), so the
|
|
1137
|
+
* first move after it measures one step and not the whole prior travel.
|
|
1138
|
+
* Absent on a fresh start, where the press point is both. */
|
|
1139
|
+
lastRaw: readonly [number, number] = local,
|
|
1140
|
+
): void => {
|
|
1141
|
+
const handlers = client.handlers();
|
|
1142
|
+
if (handlers === undefined || handlers === null) return;
|
|
1143
|
+
const target = modalTarget(state.selection.length > 0);
|
|
1144
|
+
// ── NOTHING ON SCREEN, NOTHING TO TRANSFORM ─────────────────────────
|
|
1145
|
+
//
|
|
1146
|
+
// Hoisted ABOVE the mode branch, because it used to live inside the move
|
|
1147
|
+
// arm only: `G` returned silently when no selected layer had a frame at
|
|
1148
|
+
// this playhead, while `R` and `S` armed happily and transformed a layer
|
|
1149
|
+
// nobody could see. All three want the same answer.
|
|
1150
|
+
const onScreen = feed.frames?.find((f) => state.selection.includes(f.id)) ?? null;
|
|
1151
|
+
if (target === "layers" && onScreen === null) return;
|
|
1152
|
+
let ray: Ray | null = null;
|
|
1153
|
+
let plane: Plane | null = null;
|
|
1154
|
+
if (mode === "move") {
|
|
1155
|
+
// The anchor ray and the plane, captured at start. Layers unproject on
|
|
1156
|
+
// the FRAME plane through the first selected layer — the same plane
|
|
1157
|
+
// the letter G already means in this editor — and the camera on z = 0,
|
|
1158
|
+
// the plane its rectangle sits in. No ray means no camera on screen:
|
|
1159
|
+
// nothing to start, better than a scrub that can never be fed.
|
|
1160
|
+
ray = feed.rayAt?.("working", local[0], local[1]) ?? null;
|
|
1161
|
+
if (ray === null) return;
|
|
1162
|
+
if (target === "layers") {
|
|
1163
|
+
// ANY selected layer that is on screen this frame, not `selection[0]`.
|
|
1164
|
+
// The selection is DOCUMENT-ordered (see the store's `select`), so its
|
|
1165
|
+
// first entry is whichever id sorts lowest — not the one the author
|
|
1166
|
+
// grabbed, and not necessarily one that is alive at this frame. Taking
|
|
1167
|
+
// the first that has a frame means a multi-select whose lowest member
|
|
1168
|
+
// is outside its lifespan still transforms, instead of refusing the
|
|
1169
|
+
// whole gesture with no explanation. Found once, above the branch.
|
|
1170
|
+
plane = gesturePlane("frame", onScreen!.matrixWorld);
|
|
1171
|
+
} else {
|
|
1172
|
+
plane = Z0_PLANE;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
const screenScale = paneScreenScale(rect);
|
|
1176
|
+
// THE MEDIAN OF WHAT IS SELECTED, which is Blender's default pivot, and
|
|
1177
|
+
// null for one layer — a single layer scaling about the median of itself is
|
|
1178
|
+
// the same thing as scaling in place, and null says so without arithmetic.
|
|
1179
|
+
// Off `feed.frames`, whose `matrixWorld` is the pose actually on screen at
|
|
1180
|
+
// this frame, so an animated parent is already accounted for.
|
|
1181
|
+
// ROTATE JOINS SCALE. The rotate ring passes a pivot and `R` did not, so a
|
|
1182
|
+
// multi-selection orbited about its median from the ring and spun in place
|
|
1183
|
+
// from the key. `layerGestureFor` puts it on the ROLL row only; a tilt about
|
|
1184
|
+
// a shared point is not expressible (see there).
|
|
1185
|
+
const pivot = mode === "scale" || mode === "rotate" ? selectionPivot(feed, state.selection) : null;
|
|
1186
|
+
const modalState: ModalState = { mode, target, axis: null };
|
|
1187
|
+
// ARMED ONLY IF THE SCRUB ACTUALLY OPENED. A pointer drag can absorb a
|
|
1188
|
+
// refusal silently — the author releases and tries again — but a modal
|
|
1189
|
+
// stays armed, so one that opened over another lane's scrub would drive
|
|
1190
|
+
// THAT scrub with its own deltas and commit it. `false` here means a
|
|
1191
|
+
// gesture or a wheel flick still holds the history.
|
|
1192
|
+
const opened =
|
|
1193
|
+
target === "camera"
|
|
1194
|
+
? handlers.onFramingGestureStart()
|
|
1195
|
+
: handlers.onGestureStart(initialGestureKind(modalState), state.selection, screenScale);
|
|
1196
|
+
if (opened === false) return;
|
|
1197
|
+
// ── THE CURSOR IS PINNED FOR THE DURATION ─────────────────────────────
|
|
1198
|
+
//
|
|
1199
|
+
// A modal is driven by motion with no button held, so the pointer is free
|
|
1200
|
+
// to leave the stage — and `onPointerLeave` then cancelled the transform.
|
|
1201
|
+
// On a laptop trackpad that is a large scale or a long move most of the
|
|
1202
|
+
// time. Locking removes the edge entirely; the emit integrates
|
|
1203
|
+
// `movementX/Y` instead of reading an absolute point.
|
|
1204
|
+
//
|
|
1205
|
+
// ALLOWED HERE because a keydown carries transient user activation, which
|
|
1206
|
+
// is what `requestPointerLock` requires. A browser that refuses (or an
|
|
1207
|
+
// iframe without the permission) rejects, and the modal runs exactly as it
|
|
1208
|
+
// did before — the fallback is today's behaviour, not a broken one.
|
|
1209
|
+
//
|
|
1210
|
+
// NOT RE-REQUESTED WHEN IT IS ALREADY HELD, which is the state a live
|
|
1211
|
+
// switch leaves (see `endModal`'s `release`). Asking again would be a
|
|
1212
|
+
// second promise to swallow for no change of state.
|
|
1213
|
+
if (document.pointerLockElement !== areaRef.current) {
|
|
1214
|
+
void areaRef.current?.requestPointerLock?.()?.catch?.(() => {});
|
|
1215
|
+
}
|
|
1216
|
+
modalRef.current = {
|
|
1217
|
+
state: modalState,
|
|
1218
|
+
rect,
|
|
1219
|
+
anchor: local,
|
|
1220
|
+
virtual: local,
|
|
1221
|
+
lastRaw,
|
|
1222
|
+
ray,
|
|
1223
|
+
plane,
|
|
1224
|
+
screenScale,
|
|
1225
|
+
pivot,
|
|
1226
|
+
refusal: null,
|
|
1227
|
+
typed: "",
|
|
1228
|
+
snap: false,
|
|
1229
|
+
};
|
|
1230
|
+
setModal(modalState);
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
const onModalKey = (ev: KeyboardEvent): void => {
|
|
1234
|
+
const live = modalRef.current;
|
|
1235
|
+
if (live !== null) {
|
|
1236
|
+
// A live modal owns EVERYTHING except the modifier keys themselves
|
|
1237
|
+
// (shift is its precision). Swallowing ⌘Z and Delete here is
|
|
1238
|
+
// deliberate: a relayed transaction while the modal's scrub is open is
|
|
1239
|
+
// the one thing the history model cannot absorb. The keymap's own lane
|
|
1240
|
+
// stays honest about what those combos MEAN — the suppression is this
|
|
1241
|
+
// listener's to own, not the table's to hide.
|
|
1242
|
+
if (ev.key === "Shift" || ev.key === "Control" || ev.key === "Alt" || ev.key === "Meta") return;
|
|
1243
|
+
ev.preventDefault();
|
|
1244
|
+
ev.stopPropagation();
|
|
1245
|
+
// AUTO-REPEAT IS SWALLOWED, not acted on. The axis keys TOGGLE, so a
|
|
1246
|
+
// held X flips the constraint on and off at the OS repeat rate and the
|
|
1247
|
+
// one the author ends up with is decided by how long they held the key
|
|
1248
|
+
// — release on an even count and the axis they pressed to constrain is
|
|
1249
|
+
// cleared instead. Nothing in this lane wants a repeat: the terminators
|
|
1250
|
+
// are one-shot and the starters cannot reach here.
|
|
1251
|
+
if (ev.repeat) return;
|
|
1252
|
+
// `hasSelection` IS THE MODAL'S OWN SUBJECT, not a constant. It was
|
|
1253
|
+
// hard-coded true, so `R` re-armed a ROTATE against the camera — which
|
|
1254
|
+
// has no roll, so `emitModal` could never produce a delta, the pill still
|
|
1255
|
+
// read CAMERA MOVE, and a typed `X 100 Enter` fell past the move-only
|
|
1256
|
+
// axis refusal into `framingDeltaFor` and wrote a real 100px camera pan.
|
|
1257
|
+
const action = resolveKey(keyOf(ev), {
|
|
1258
|
+
editing: false,
|
|
1259
|
+
hasSelection: live.state.target !== "camera",
|
|
1260
|
+
transformModal: true,
|
|
1261
|
+
});
|
|
1262
|
+
if (action === null) return;
|
|
1263
|
+
if (action.kind === "modalConfirm") endModal(true);
|
|
1264
|
+
else if (action.kind === "modalCancel") endModal(false);
|
|
1265
|
+
else if (action.kind === "modalStart") {
|
|
1266
|
+
// ANOTHER TRANSFORM LETTER SWITCHES LIVE. Discarded rather than
|
|
1267
|
+
// committed, so `G` then `R` rotates instead of leaving a move behind
|
|
1268
|
+
// — and restarted at the ORIGINAL ANCHOR, so the rotation is measured
|
|
1269
|
+
// from where the author pressed rather than from wherever the pointer
|
|
1270
|
+
// had wandered while they were moving.
|
|
1271
|
+
//
|
|
1272
|
+
// ── BUT `lastRaw` CARRIES OVER ──────────────────────────────────
|
|
1273
|
+
//
|
|
1274
|
+
// The anchor is where the transform is measured FROM; `lastRaw` is
|
|
1275
|
+
// where the POINTER last was. Re-seeding both to the anchor made the
|
|
1276
|
+
// next pointermove read a step of "everything travelled during the
|
|
1277
|
+
// move" and apply it in one jump. Under pointer lock the deltas are
|
|
1278
|
+
// integrated from `movementX/Y` so it never showed; on the unlocked
|
|
1279
|
+
// fallback path (an iframe with no permission, a browser that refused)
|
|
1280
|
+
// it was the whole prior travel at once.
|
|
1281
|
+
const anchor = live.anchor;
|
|
1282
|
+
const rect = live.rect;
|
|
1283
|
+
const lastRaw = live.lastRaw;
|
|
1284
|
+
endModal(false, false);
|
|
1285
|
+
startModal(action.mode, anchor, rect, lastRaw);
|
|
1286
|
+
}
|
|
1287
|
+
else if (action.kind === "modalAxis") {
|
|
1288
|
+
const next = modalAxisToggle(live.state, action.axis);
|
|
1289
|
+
if (next === live.state) {
|
|
1290
|
+
// CLAIMED AND IGNORED. The key was already swallowed by the
|
|
1291
|
+
// `preventDefault` above, so saying nothing here is
|
|
1292
|
+
// indistinguishable from a key that never arrived.
|
|
1293
|
+
live.refusal = axisRefusal(live.state, action.axis);
|
|
1294
|
+
modalReadoutSink.write?.(live.refusal ?? "");
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
live.state = next;
|
|
1298
|
+
live.refusal = null;
|
|
1299
|
+
setModal(next);
|
|
1300
|
+
// Re-project what is already travelled under the new constraint.
|
|
1301
|
+
if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
|
|
1302
|
+
} else if (action.kind === "modalNumber") {
|
|
1303
|
+
const next = typeNumber(live.typed, action.ch);
|
|
1304
|
+
// A second decimal point is rejected rather than swallowed, so `1.2.3`
|
|
1305
|
+
// cannot exist. Nothing to redraw when the buffer did not change.
|
|
1306
|
+
if (next === null) return;
|
|
1307
|
+
live.typed = next;
|
|
1308
|
+
live.refusal = null;
|
|
1309
|
+
if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
|
|
1310
|
+
} else if (action.kind === "modalNumberBack") {
|
|
1311
|
+
// Emptying it hands the transform back to the MOUSE rather than
|
|
1312
|
+
// cancelling, which is what makes a mistyped digit recoverable.
|
|
1313
|
+
live.typed = live.typed.slice(0, -1);
|
|
1314
|
+
live.refusal = null;
|
|
1315
|
+
if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
|
|
1316
|
+
}
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
if (doc === null) return;
|
|
1320
|
+
const at = lastPointer.current;
|
|
1321
|
+
// `inside` before the position is read at all: off the stage there is no
|
|
1322
|
+
// cursor to anchor to and the coordinate below is a leftover (see the ref).
|
|
1323
|
+
// `buttons !== 0` means a drag lane may be live; the pen's emptiness
|
|
1324
|
+
// keeps its own capture listener and this one from fighting over a key.
|
|
1325
|
+
if (!at.inside || at.buttons !== 0 || pen.points.length > 0) return;
|
|
1326
|
+
const hit = paneAt(panes, at.at[0], at.at[1]);
|
|
1327
|
+
if (hit === null || hit.pane !== "working") return;
|
|
1328
|
+
const action = resolveKey(keyOf(ev), {
|
|
1329
|
+
editing: isEditable(ev.target),
|
|
1330
|
+
hasSelection: state.selection.length > 0,
|
|
1331
|
+
workingPane: true,
|
|
1332
|
+
});
|
|
1333
|
+
// `B` ARMS A BOX SELECT for the next press, and it is consumed HERE rather
|
|
1334
|
+
// than in the chrome: the marquee is the stage's own lane, and a bit that
|
|
1335
|
+
// crossed the bridge would be one round trip behind the press it arms.
|
|
1336
|
+
if (action?.kind === "boxSelect") {
|
|
1337
|
+
boxSelectRef.current = true;
|
|
1338
|
+
ev.preventDefault();
|
|
1339
|
+
ev.stopPropagation();
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (action?.kind !== "modalStart") return;
|
|
1343
|
+
ev.preventDefault();
|
|
1344
|
+
ev.stopPropagation();
|
|
1345
|
+
startModal(action.mode, hit.local, panes.working as PaneRect);
|
|
1346
|
+
};
|
|
1347
|
+
const onModalKeyRef = useRef(onModalKey);
|
|
1348
|
+
onModalKeyRef.current = onModalKey;
|
|
1349
|
+
|
|
1350
|
+
useEffect(() => {
|
|
1351
|
+
// Registered ONCE, reading the current handler through a ref (the wheel
|
|
1352
|
+
// lane's pattern). Capture phase so a consumed start/terminator never
|
|
1353
|
+
// reaches useKeyRelay's bubble listener — same reason the pen's is.
|
|
1354
|
+
const down = (ev: KeyboardEvent): void => onModalKeyRef.current(ev);
|
|
1355
|
+
// A blur mid-modal (clicked into the chrome, alt-tabbed) cancels: keys
|
|
1356
|
+
// and motion stop arriving, and an abandoned open scrub outlives them.
|
|
1357
|
+
const blur = (): void => endModalRef.current(false);
|
|
1358
|
+
window.addEventListener("keydown", down, true);
|
|
1359
|
+
window.addEventListener("blur", blur);
|
|
1360
|
+
return () => {
|
|
1361
|
+
window.removeEventListener("keydown", down, true);
|
|
1362
|
+
window.removeEventListener("blur", blur);
|
|
1363
|
+
endModalRef.current(false);
|
|
1364
|
+
};
|
|
1365
|
+
}, []);
|
|
1366
|
+
|
|
1367
|
+
// A document switch mid-modal cancels — the store's doc-swap discard is
|
|
1368
|
+
// the backstop, not the plan. The `B` arm goes with it: an armed one-shot is
|
|
1369
|
+
// a mode the author is in, and modes do not survive the document they were
|
|
1370
|
+
// armed over.
|
|
1371
|
+
useEffect(
|
|
1372
|
+
() => () => {
|
|
1373
|
+
endModalRef.current(false);
|
|
1374
|
+
boxSelectRef.current = false;
|
|
1375
|
+
},
|
|
1376
|
+
[doc?.id],
|
|
1377
|
+
);
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* The pen's rubber band and close highlight follow the cursor between
|
|
1381
|
+
* presses, and only then.
|
|
1382
|
+
*
|
|
1383
|
+
* Cheap by construction: it returns before touching state unless the pen
|
|
1384
|
+
* actually holds a point, so with any other tool armed this is one comparison
|
|
1385
|
+
* per pointer event. `penReduce`'s `move` with no drag open advances `cursor`
|
|
1386
|
+
* alone, so hover needs no branch of its own in the model.
|
|
1387
|
+
*/
|
|
1388
|
+
/** Every pointer event updates the cursor box, not just moves. `buttons` is
|
|
1389
|
+
* a GATE on starting a modal, and a move-only writer leaves it stale in
|
|
1390
|
+
* both directions: no `pointermove` is emitted on release, so one stuck at
|
|
1391
|
+
* 1 makes G/R/S silently arm tools forever after; and a press with no
|
|
1392
|
+
* motion leaves it at 0, which would let a modal open on top of a live
|
|
1393
|
+
* drag's scrub. */
|
|
1394
|
+
const onPointerTrack = (e: React.PointerEvent<HTMLDivElement>): void => {
|
|
1395
|
+
lastPointer.current = { at: localPoint(e), buttons: e.buttons, inside: true };
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* The cursor left the stage: no modal can be started, and a live one is
|
|
1400
|
+
* cancelled.
|
|
1401
|
+
*
|
|
1402
|
+
* A modal is driven by MOTION over the pane. Off the stage none arrives, so
|
|
1403
|
+
* it can neither be aimed nor confirmed — while its guards go on eating the
|
|
1404
|
+
* wheel, the first press in either pane and the whole keyboard, until the
|
|
1405
|
+
* author guesses Escape. `window`'s blur cancel is not this: blur needs a
|
|
1406
|
+
* CLICK into the chrome, and hovering it is enough to strand one.
|
|
1407
|
+
*/
|
|
1408
|
+
/**
|
|
1409
|
+
* The container's cursor, written IMPERATIVELY and exactly once.
|
|
1410
|
+
*
|
|
1411
|
+
* ── WHY NOT REACT ───────────────────────────────────────────────────────
|
|
1412
|
+
*
|
|
1413
|
+
* It changes on every pointermove, and a move already costs a stage commit
|
|
1414
|
+
* and a GL draw when a gesture is live. Routing the cursor through state
|
|
1415
|
+
* would add a full re-render per move to change one string. The last-written
|
|
1416
|
+
* guard makes the common case (the cursor did not change) a string compare.
|
|
1417
|
+
*
|
|
1418
|
+
* ── AND WHY THERE MUST BE ONLY ONE WRITER ───────────────────────────────
|
|
1419
|
+
*
|
|
1420
|
+
* The JSX style object used to carry `cursor: spaceRef.current ? ... `, read
|
|
1421
|
+
* from a REF during render — so it only reached the DOM when something
|
|
1422
|
+
* unrelated happened to commit, which is why the stage appeared to have no
|
|
1423
|
+
* cursor behaviour at all. Leaving that in place alongside this would be
|
|
1424
|
+
* worse than the original bug: React would win on its next commit and the
|
|
1425
|
+
* cursor would be intermittently right, which nobody can debug.
|
|
1426
|
+
*/
|
|
1427
|
+
// Seeded EMPTY, mirroring the element's own inline style rather than the
|
|
1428
|
+
// effective value. Seeding it "default" makes the first write to "default" a
|
|
1429
|
+
// no-op, so a stage whose first answer is the default never writes at all and
|
|
1430
|
+
// the ref stops describing the DOM.
|
|
1431
|
+
const cursorRef = useRef("");
|
|
1432
|
+
const setCursor = useCallback((next: string): void => {
|
|
1433
|
+
if (next === cursorRef.current) return;
|
|
1434
|
+
cursorRef.current = next;
|
|
1435
|
+
const el = areaRef.current;
|
|
1436
|
+
if (el !== null) el.style.cursor = next;
|
|
1437
|
+
}, []);
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Arm or disarm the navigation cursor.
|
|
1441
|
+
*
|
|
1442
|
+
* Two writes, and both are needed. The IMPERATIVE one is what puts the shape
|
|
1443
|
+
* on screen at the press, the way the pan lane already does it. The REF is
|
|
1444
|
+
* what makes it survive: an orbit calls `setWorkingView` on every move, every
|
|
1445
|
+
* one of those is a commit, and while a real browser reports a held button
|
|
1446
|
+
* (so `refreshCursor` bails) nothing guarantees that — a synthetic move, or a
|
|
1447
|
+
* commit from anywhere else in the stage, would otherwise re-answer the
|
|
1448
|
+
* cursor from what is UNDER the pointer, which during a navigation drag is
|
|
1449
|
+
* not the subject.
|
|
1450
|
+
*/
|
|
1451
|
+
const setNavCursor = useCallback(
|
|
1452
|
+
(next: string | null): void => {
|
|
1453
|
+
navCursorRef.current = next;
|
|
1454
|
+
if (next !== null) {
|
|
1455
|
+
setCursor(next);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
// Disarming drops to the default BEFORE asking for the real answer,
|
|
1459
|
+
// because `refreshCursor` bails while a button is still reported down —
|
|
1460
|
+
// and at a pointerup the tracked `buttons` is still the drag's. Without
|
|
1461
|
+
// this the closed hand would outlive the gesture until the next move.
|
|
1462
|
+
setCursor("default");
|
|
1463
|
+
refreshCursorRef.current();
|
|
1464
|
+
},
|
|
1465
|
+
[setCursor],
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* The cursor for what is under the pointer, in precedence order.
|
|
1470
|
+
*
|
|
1471
|
+
* The order matters and is not the same as the press lane's: a held space bar
|
|
1472
|
+
* outranks everything because the NEXT press pans whatever it is over, and a
|
|
1473
|
+
* live gesture holds whatever shape it started with because the thing being
|
|
1474
|
+
* dragged has already left the pointer's own position behind.
|
|
1475
|
+
*/
|
|
1476
|
+
const cursorFor = useCallback(
|
|
1477
|
+
(pane: StagePane | null, intent: StageIntent | null): string => {
|
|
1478
|
+
// A LIVE NAVIGATION DRAG OUTRANKS EVERYTHING, and it has to be answered
|
|
1479
|
+
// here rather than written imperatively at the press. An orbit or a dolly
|
|
1480
|
+
// calls `setWorkingView` on every move, every one of those is a commit,
|
|
1481
|
+
// and the per-commit effect below re-answers the cursor from what is
|
|
1482
|
+
// UNDER the pointer — which during a navigation drag is not the subject.
|
|
1483
|
+
// An imperative write would therefore survive exactly until the first
|
|
1484
|
+
// move, which is the moment it starts mattering.
|
|
1485
|
+
if (navCursorRef.current !== null) return navCursorRef.current;
|
|
1486
|
+
if (spaceRef.current) return "grab";
|
|
1487
|
+
if (pane === null || intent === null) return "default";
|
|
1488
|
+
switch (intent.kind) {
|
|
1489
|
+
case "grab":
|
|
1490
|
+
if (intent.grab.handleAngle === undefined) return "move";
|
|
1491
|
+
// A UNION HANDLE GETS THE RESIZE CURSOR TOO. What the drag writes
|
|
1492
|
+
// differs (a ratio about a pivot, not one layer's edge), but what the
|
|
1493
|
+
// POINTER does is identical — the corner follows it outward — and a
|
|
1494
|
+
// cursor that said otherwise would be describing the implementation
|
|
1495
|
+
// rather than the gesture.
|
|
1496
|
+
return intent.grab.kind === "roll"
|
|
1497
|
+
? rotateCursorFor(intent.grab.handleAngle)
|
|
1498
|
+
: intent.grab.kind === "resize" || intent.grab.kind === "scale"
|
|
1499
|
+
? resizeCursorFor(intent.grab.handleAngle)
|
|
1500
|
+
: "move";
|
|
1501
|
+
case "arm":
|
|
1502
|
+
case "frustum":
|
|
1503
|
+
return "crosshair";
|
|
1504
|
+
case "empty":
|
|
1505
|
+
// The armed TOOL, at last: `ToolSpec.cursor` has carried a column for
|
|
1506
|
+
// all 13 rows, and been tested, with no reader in the stage at all.
|
|
1507
|
+
// Forced back to the default in the working pane, because the draw
|
|
1508
|
+
// tools only act in the camera one (a free orbit has no screen
|
|
1509
|
+
// rectangle that corresponds to a drawn box) and a crosshair over a
|
|
1510
|
+
// pane that will answer with an orbit is a promise the lane breaks.
|
|
1511
|
+
return pane === "working" && isDrawTool(state.tool) ? "default" : specOf(state.tool).cursor;
|
|
1512
|
+
}
|
|
1513
|
+
},
|
|
1514
|
+
[state.tool],
|
|
1515
|
+
);
|
|
1516
|
+
|
|
1517
|
+
const onPointerLeave = (): void => {
|
|
1518
|
+
lastPointer.current = { ...lastPointer.current, inside: false };
|
|
1519
|
+
setCursor("default");
|
|
1520
|
+
setHover(null);
|
|
1521
|
+
// A LIVE MODAL UNDER POINTER LOCK DOES NOT LEAVE. The lock freezes the
|
|
1522
|
+
// cursor, so a `pointerleave` while it holds is the browser's doing and
|
|
1523
|
+
// not the author's — cancelling there would throw away a transform for a
|
|
1524
|
+
// reason they never caused.
|
|
1525
|
+
if (document.pointerLockElement === areaRef.current) return;
|
|
1526
|
+
endModalRef.current(false);
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
const onPointerHover = (e: React.PointerEvent<HTMLDivElement>): void => {
|
|
1530
|
+
const at = localPoint(e);
|
|
1531
|
+
lastPointer.current = { at, buttons: e.buttons, inside: true };
|
|
1532
|
+
const live = modalRef.current;
|
|
1533
|
+
if (live !== null) {
|
|
1534
|
+
// The modal is driven by MOTION with no button held. Raw steps
|
|
1535
|
+
// accumulate into a virtual cursor (shift scales them — precision) and
|
|
1536
|
+
// the emit coalesces to the frame, dragGesture's own pattern.
|
|
1537
|
+
//
|
|
1538
|
+
// UNDER POINTER LOCK the absolute position is frozen and only
|
|
1539
|
+
// `movementX/Y` is real, so the raw point is integrated from the deltas.
|
|
1540
|
+
// That is what lets a transform run past the edge of the window without
|
|
1541
|
+
// the pointer leaving and cancelling it — Blender wraps the cursor for
|
|
1542
|
+
// the same reason.
|
|
1543
|
+
const locked = document.pointerLockElement === areaRef.current;
|
|
1544
|
+
const raw: readonly [number, number] = locked
|
|
1545
|
+
? [live.lastRaw[0] + e.movementX, live.lastRaw[1] + e.movementY]
|
|
1546
|
+
: [at[0] - live.rect.x, at[1] - live.rect.y];
|
|
1547
|
+
const moved = raw[0] !== live.lastRaw[0] || raw[1] !== live.lastRaw[1];
|
|
1548
|
+
live.virtual = advanceVirtual(live.virtual, live.lastRaw, raw, e.shiftKey);
|
|
1549
|
+
live.lastRaw = raw;
|
|
1550
|
+
// ── A REFUSAL DESCRIBES A KEY, NOT THE TRANSFORM ────────────────────
|
|
1551
|
+
//
|
|
1552
|
+
// It was only ever cleared by another KEY, so "SIZE HAS NO Z" sat in the
|
|
1553
|
+
// pill while the author kept moving the mouse and the size kept changing
|
|
1554
|
+
// — the live numbers never came back and the message described nothing
|
|
1555
|
+
// that was still happening. Any real motion is the author moving on.
|
|
1556
|
+
if (moved) live.refusal = null;
|
|
1557
|
+
// Read PER EVENT, like shift's precision: the author holds and releases
|
|
1558
|
+
// ctrl mid-gesture to nudge onto a round number and off again.
|
|
1559
|
+
live.snap = e.ctrlKey || e.metaKey;
|
|
1560
|
+
if (modalRaf.current === 0) modalRaf.current = requestAnimationFrame(flushModal);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
const hit = paneAt(panes, at[0], at[1]);
|
|
1564
|
+
// ── THE PANE UNDER THE CURSOR IS THE ACTIVE ONE ───────────────────────
|
|
1565
|
+
//
|
|
1566
|
+
// `activePane` decides what Home fits and what the zoom keys and the
|
|
1567
|
+
// header's zoom buttons address, and until now only a PRESS or a WHEEL
|
|
1568
|
+
// wrote it. In split that leaves it wherever it was: entering split from
|
|
1569
|
+
// camera-only keeps `"camera"` (the store forces the pane to the mode in
|
|
1570
|
+
// the single-pane layouts, so this is split's alone), and Home then fits a
|
|
1571
|
+
// pane the author is not pointing at while the one they are pointing at
|
|
1572
|
+
// does nothing. Hover is the answer the author already believes, and it is
|
|
1573
|
+
// the same rule the modal starters and the shading bar read.
|
|
1574
|
+
//
|
|
1575
|
+
// Once per CROSSING, not once per move: `onActivePane` is a store write
|
|
1576
|
+
// and the chrome re-renders on it.
|
|
1577
|
+
if (hit !== null && hit.pane !== hoverPane.current) {
|
|
1578
|
+
hoverPane.current = hit.pane;
|
|
1579
|
+
client.handlers()?.onActivePane(hit.pane);
|
|
1580
|
+
}
|
|
1581
|
+
// THE CURSOR SAYS WHAT THE PRESS WOULD DO, off the same `intentAt` the
|
|
1582
|
+
// press itself switches on. Not behind the `onActivePane` gate above: that
|
|
1583
|
+
// one fires once per pane CROSSING, and the cursor has to change every time
|
|
1584
|
+
// the pointer crosses a handle.
|
|
1585
|
+
//
|
|
1586
|
+
// No React work: `setCursor` is a string compare and, on a change, one
|
|
1587
|
+
// style write. `intentAt` is three feed calls, the same three the press
|
|
1588
|
+
// makes, and the press lane already runs them per press.
|
|
1589
|
+
if (e.buttons === 0) {
|
|
1590
|
+
const intent = hit === null ? null : intentAt(hit.pane, hit.local[0], hit.local[1]);
|
|
1591
|
+
setCursor(hit === null ? cursorFor(null, null) : cursorFor(hit.pane, intent));
|
|
1592
|
+
// The arm under the pointer lights up. A plain field write, read by
|
|
1593
|
+
// `MoveGizmo` inside the frame loop, so a hover costs no React work at
|
|
1594
|
+
// all — see `feed.hoverArm`.
|
|
1595
|
+
feed.hoverArm = intent?.kind === "arm" ? intent.arm.axis : null;
|
|
1596
|
+
// THE HIGHLIGHT, only over empty space. Over a handle or an already
|
|
1597
|
+
// selected body the press is a transform, not a selection change, so
|
|
1598
|
+
// previewing a selection there would promise something the click will
|
|
1599
|
+
// not do.
|
|
1600
|
+
const over =
|
|
1601
|
+
hit === null || intent === null || intent.kind !== "empty"
|
|
1602
|
+
? null
|
|
1603
|
+
: hit;
|
|
1604
|
+
const quad = over === null ? null : (feed.hoverQuad?.(over.pane, over.local[0], over.local[1]) ?? null);
|
|
1605
|
+
setHover((prev) => {
|
|
1606
|
+
if (quad === null || over === null) return prev === null ? prev : null;
|
|
1607
|
+
if (prev !== null && prev.pane === over.pane && !quadMoved(prev.quad, quad)) return prev;
|
|
1608
|
+
return { pane: over.pane, quad };
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
if (state.tool !== "path" || pen.points.length === 0 || pen.drag !== null) return;
|
|
1612
|
+
if (hit === null || hit.pane !== "camera") return;
|
|
1613
|
+
const design = screenToDesign(hit.local, view, cameraViewport, size);
|
|
1614
|
+
setPen((prev) => penReduce(prev, { kind: "move", at: design, dragThreshold: PEN_DRAG_THRESHOLD_PX }));
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Double-click: go inside what is under the cursor.
|
|
1619
|
+
*
|
|
1620
|
+
* Reads the pick list fresh rather than trusting the selection the
|
|
1621
|
+
* pointerdown lane just made — a double-click whose first press landed on a
|
|
1622
|
+
* layer and whose second landed a few pixels away, on another, should enter
|
|
1623
|
+
* what the author is pointing at now.
|
|
1624
|
+
*/
|
|
1625
|
+
const onDoubleClick = (e: React.MouseEvent<HTMLDivElement>): void => {
|
|
1626
|
+
if (doc === null || isDrawTool(state.tool)) return;
|
|
1627
|
+
const hit = paneAt(panes, ...localPoint(e));
|
|
1628
|
+
if (hit === null) return;
|
|
1629
|
+
const hits = feed.pick?.(hit.pane, hit.local[0], hit.local[1]) ?? [];
|
|
1630
|
+
if (hits.length > 0) client.handlers()?.onEnterLayer(hits, hit.pane);
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* WHAT A PRESS AT THIS POINT WOULD DO. Position only, no buttons, no keys.
|
|
1635
|
+
*
|
|
1636
|
+
* ── WHY THIS IS ONE FUNCTION AND NOT A SEQUENCE OF `return`s ────────────
|
|
1637
|
+
*
|
|
1638
|
+
* It used to be the tail of `onPointerDown`, and it had exactly one caller,
|
|
1639
|
+
* so nothing could ask "what would happen here" without actually making it
|
|
1640
|
+
* happen. That is why the stage had no hover cursor: the answer existed only
|
|
1641
|
+
* as a side effect. Now the press lane switches on it and the hover lane maps
|
|
1642
|
+
* it to a cursor, so the shape under the pointer IS the thing the press does.
|
|
1643
|
+
*
|
|
1644
|
+
* ── THE ORDERING, WHICH IS FORCED ───────────────────────────────────────
|
|
1645
|
+
*
|
|
1646
|
+
* ARMS FIRST. An arm is drawn ON TOP OF the thing it belongs to and overlaps
|
|
1647
|
+
* it, so a gizmo hit ranked below `grabAt` could never be reached: the body
|
|
1648
|
+
* grab would take every press the arm sits over, which is most of the arm at
|
|
1649
|
+
* a shallow orbit. The arms are small screen-space targets nothing else
|
|
1650
|
+
* claims, exactly like the frustum's apex.
|
|
1651
|
+
*
|
|
1652
|
+
* AND THE APEX STILL WORKS UNDERNEATH, because the arms start 30 screen px
|
|
1653
|
+
* out — more than this test's slop plus the apex's own — so a press aimed at
|
|
1654
|
+
* the apex misses all three and falls through to the frustum lane. That
|
|
1655
|
+
* inequality is the whole reason ARM_START_PX has a value rather than a look.
|
|
1656
|
+
*
|
|
1657
|
+
* THEN THE SELECTION. A press on it is a transform, not a selection change: a
|
|
1658
|
+
* handle resizes, the body moves. Pressing an UNselected layer falls through
|
|
1659
|
+
* to the click lane, because dragging something the author had not selected
|
|
1660
|
+
* is the classic way to move the wrong thing.
|
|
1661
|
+
*
|
|
1662
|
+
* THEN THE CAMERA GIZMO, and its rank is the subtlety. The frustum's base
|
|
1663
|
+
* quad is COPLANAR WITH EVERY LAYER — it is the rectangle the layers live in
|
|
1664
|
+
* — so a frustum hit that outranked a layer would make layers unselectable in
|
|
1665
|
+
* this pane, and one that outranked the orbit everywhere inside the frame
|
|
1666
|
+
* would take away the pane's primary gesture. Behind the layers and in front
|
|
1667
|
+
* of empty space is the only ordering in which all three stay reachable. The
|
|
1668
|
+
* APEX is exempt from that veto: it is a small target floating away from the
|
|
1669
|
+
* plane, and nothing else claims it.
|
|
1670
|
+
*
|
|
1671
|
+
* NO SIDE EFFECTS. It is called on every hover, so it may not write state,
|
|
1672
|
+
* open a scrub or notify the chrome.
|
|
1673
|
+
*/
|
|
1674
|
+
const intentAt = (pane: StagePane, x: number, y: number): StageIntent => {
|
|
1675
|
+
if (pane === "working") {
|
|
1676
|
+
const arm = feed.gizmoHit?.(x, y) ?? null;
|
|
1677
|
+
if (arm !== null) return { kind: "arm", arm };
|
|
1678
|
+
}
|
|
1679
|
+
const grab = feed.grabAt?.(pane, x, y, state.selection) ?? null;
|
|
1680
|
+
if (grab !== null) return { kind: "grab", grab };
|
|
1681
|
+
if (pane === "working") {
|
|
1682
|
+
const part = feed.frustumHit?.(x, y) ?? null;
|
|
1683
|
+
const overLayer = part === "body" && (feed.pick?.(pane, x, y) ?? []).length > 0;
|
|
1684
|
+
if (part !== null && !overLayer) return { kind: "frustum", part };
|
|
1685
|
+
}
|
|
1686
|
+
return { kind: "empty" };
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Recompute the cursor from the LAST KNOWN pointer position.
|
|
1691
|
+
*
|
|
1692
|
+
* For everything that changes the answer without moving the pointer: arming a
|
|
1693
|
+
* tool, pressing or releasing the space bar, a layer becoming selected from a
|
|
1694
|
+
* panel. Read through a ref by the listeners that are registered once (the
|
|
1695
|
+
* key relay), which is the same shape `endModalRef` and `wheelRef` use.
|
|
1696
|
+
*/
|
|
1697
|
+
const refreshCursor = (): void => {
|
|
1698
|
+
const { at, inside, buttons } = lastPointer.current;
|
|
1699
|
+
// A live modal owns the pointer's meaning, and a DRAG has already left the
|
|
1700
|
+
// pointer's own position behind — a pan's closed hand must survive every
|
|
1701
|
+
// commit the drag itself causes, and this runs on all of them.
|
|
1702
|
+
if (!inside || buttons !== 0 || modalRef.current !== null) return;
|
|
1703
|
+
const hit = paneAt(panes, at[0], at[1]);
|
|
1704
|
+
setCursor(
|
|
1705
|
+
hit === null
|
|
1706
|
+
? cursorFor(null, null)
|
|
1707
|
+
: cursorFor(hit.pane, intentAt(hit.pane, hit.local[0], hit.local[1])),
|
|
1708
|
+
);
|
|
1709
|
+
};
|
|
1710
|
+
refreshCursorRef.current = refreshCursor;
|
|
1711
|
+
|
|
1712
|
+
// The armed tool changes the cursor with no pointer motion at all, and a
|
|
1713
|
+
// selection arriving from the outliner adds handles under a stationary
|
|
1714
|
+
// pointer. Both are commits, so one effect per commit is the whole cost.
|
|
1715
|
+
useEffect(() => {
|
|
1716
|
+
refreshCursorRef.current();
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>): void => {
|
|
1720
|
+
// A press with no motion still moves the gate: without this, holding the
|
|
1721
|
+
// button still still reads `buttons: 0` and a modal could open on top of
|
|
1722
|
+
// the drag's own scrub.
|
|
1723
|
+
onPointerTrack(e);
|
|
1724
|
+
// MIDDLE PRESS: kill the default before anything else looks at it.
|
|
1725
|
+
// Windows and Linux Chrome open AUTOSCROLL on an unprevented middle
|
|
1726
|
+
// mousedown — the anchor puck appears, the cursor changes, and every
|
|
1727
|
+
// subsequent move scrolls instead of reaching this pane. That is both
|
|
1728
|
+
// Blender bindings this stage added, middle-drag orbit and shift+middle
|
|
1729
|
+
// pan, dead on the platform they were reported broken on.
|
|
1730
|
+
// `touchAction: "none"` does not cover it: autoscroll rides the
|
|
1731
|
+
// compatibility MOUSE event, and preventing the pointerdown's default is
|
|
1732
|
+
// what suppresses that.
|
|
1733
|
+
if (e.button === 1) e.preventDefault();
|
|
1734
|
+
// A press terminates a live modal — LEFT confirms, right cancels — and does
|
|
1735
|
+
// NOTHING ELSE. First branch, so a confirm click can never fall through to
|
|
1736
|
+
// pick / marquee / orbit.
|
|
1737
|
+
//
|
|
1738
|
+
// MIDDLE IS IGNORED rather than treated as a confirm. Blender binds it to
|
|
1739
|
+
// auto-constrain, so a Blender user reaches for it MID-TRANSFORM expecting
|
|
1740
|
+
// an axis — and committing the transform instead is worse than doing
|
|
1741
|
+
// nothing, because it is silent and it costs an undo to get back. The
|
|
1742
|
+
// `preventDefault` above still stands: Windows autoscroll must not open
|
|
1743
|
+
// over a live modal either.
|
|
1744
|
+
if (modalRef.current !== null) {
|
|
1745
|
+
if (e.button === 1) return;
|
|
1746
|
+
endModal(e.button !== 2);
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
if (doc === null) return;
|
|
1750
|
+
const canvasStart = localPoint(e);
|
|
1751
|
+
const hit = paneAt(panes, canvasStart[0], canvasStart[1]);
|
|
1752
|
+
// A press in no pane is a press on a gutter the layout left over. Doing
|
|
1753
|
+
// nothing is the only honest answer; picking a pane for it would answer a
|
|
1754
|
+
// click with a camera the author was not pointing at.
|
|
1755
|
+
if (hit === null) return;
|
|
1756
|
+
const { pane, local: start } = hit;
|
|
1757
|
+
const rect = (pane === "camera" ? panes.camera : panes.working) as PaneRect;
|
|
1758
|
+
const el = e.currentTarget;
|
|
1759
|
+
el.setPointerCapture(e.pointerId);
|
|
1760
|
+
client.handlers()?.onActivePane(pane);
|
|
1761
|
+
// ── THE `B` ARM IS SPENT BY *THIS* PRESS, WHATEVER THE PRESS DOES ─────
|
|
1762
|
+
//
|
|
1763
|
+
// It used to be read and cleared down in the empty-space lane, below every
|
|
1764
|
+
// `return` above it — so a press that landed on a layer, a handle, a gizmo
|
|
1765
|
+
// arm or the frustum left the bit SET, and a working-pane drag some time
|
|
1766
|
+
// later marqueed instead of orbiting, with nothing on screen to say why.
|
|
1767
|
+
// Reading it here, where the press is first known to be a press, is what
|
|
1768
|
+
// makes "armed, and spent by the next press" true rather than nearly true.
|
|
1769
|
+
const boxArmed = boxSelectRef.current;
|
|
1770
|
+
boxSelectRef.current = false;
|
|
1771
|
+
|
|
1772
|
+
// ── A RIGHT PRESS OPENS THE MENU, AND DOES NOTHING ELSE ───────────────
|
|
1773
|
+
//
|
|
1774
|
+
// ABOVE the draw and pen lanes on purpose. This is the change the gate
|
|
1775
|
+
// further down reserved for itself: `e.button !== 0` sits BELOW those
|
|
1776
|
+
// lanes, so a right DRAG with the rect tool armed still inserted a layer —
|
|
1777
|
+
// under the browser's own context menu, which is a layer the author did not
|
|
1778
|
+
// ask for and did not see arrive.
|
|
1779
|
+
//
|
|
1780
|
+
// Below the modal terminator above, so a right-click that cancels a
|
|
1781
|
+
// transform cancels it and stops; opening a menu on the way out of a
|
|
1782
|
+
// gesture would be answering a question nobody asked.
|
|
1783
|
+
//
|
|
1784
|
+
// On the PRESS rather than the release, so this opens no gesture of its
|
|
1785
|
+
// own: no move/up pair to register, and sourceHygiene's one-registration
|
|
1786
|
+
// rule for hand-rolled lanes is untouched.
|
|
1787
|
+
if (e.button === 2) {
|
|
1788
|
+
const hits = feed.pick?.(pane, start[0], start[1]) ?? [];
|
|
1789
|
+
client.handlers()?.onContextMenu(hits, pane, { x: e.clientX, y: e.clientY });
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Deltas are the same in canvas space and in pane space (the two differ by
|
|
1794
|
+
// a constant), so every drag below measures from the CANVAS press point and
|
|
1795
|
+
// only the hit tests use the pane-local one.
|
|
1796
|
+
const dragFrom = (ev: PointerEvent): [number, number] => {
|
|
1797
|
+
const at = localPoint(ev);
|
|
1798
|
+
return [at[0] - canvasStart[0], at[1] - canvasStart[1]];
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
// The pan gate is PANE-DEPENDENT. The camera pane keeps the designer
|
|
1802
|
+
// convention (middle or space = pan). The working pane speaks Blender:
|
|
1803
|
+
// middle-drag ORBITS (the branch below), shift+middle pans, and space
|
|
1804
|
+
// still pans — both muscle memories stay valid at once.
|
|
1805
|
+
// ── BLENDER'S EMULATE-3-BUTTON, so a trackpad can orbit ───────────────
|
|
1806
|
+
//
|
|
1807
|
+
// alt + left stands in for the middle button, which a laptop does not
|
|
1808
|
+
// have — and without it the working pane's orbit was reachable only by
|
|
1809
|
+
// dragging EMPTY SPACE, so an author with a full-bleed background layer
|
|
1810
|
+
// could not turn the view at all.
|
|
1811
|
+
//
|
|
1812
|
+
// Alt is free on a working-pane press: it is read as `aboutCentre` inside a
|
|
1813
|
+
// resize drag and nowhere else. Shift+alt+left therefore pans, which costs
|
|
1814
|
+
// that one combo its additive marquee — named here rather than discovered.
|
|
1815
|
+
//
|
|
1816
|
+
// THE WORKING PANE ONLY, and that gate is the fix. Folding it into the
|
|
1817
|
+
// camera pane's `pans` made alt+left a PAN there, which ate the two
|
|
1818
|
+
// gestures alt already owns on that side: `aboutCentre` on a resize, and
|
|
1819
|
+
// every draw tool that an author holds alt through. The camera pane has a
|
|
1820
|
+
// middle button's worth of pan already (middle, and space), so it needs no
|
|
1821
|
+
// stand-in for a button a laptop lacks.
|
|
1822
|
+
const emulatesMiddle = e.button === 0 && e.altKey && pane === "working";
|
|
1823
|
+
const middle = e.button === 1 || emulatesMiddle;
|
|
1824
|
+
const pans =
|
|
1825
|
+
pane === "camera"
|
|
1826
|
+
? isPanGesture(e, spaceRef.current)
|
|
1827
|
+
: spaceRef.current || (middle && e.shiftKey);
|
|
1828
|
+
// ctrl/meta + middle DOLLIES, at the wheel's own falloff so a drag and a
|
|
1829
|
+
// notch are the same step. Blender's third mouse binding, and the only one
|
|
1830
|
+
// this pane was missing.
|
|
1831
|
+
if (!pans && pane === "working" && middle && (e.ctrlKey || e.metaKey)) {
|
|
1832
|
+
const fromWorking = workingView;
|
|
1833
|
+
// BRACKETED LIKE EVERY OTHER NAVIGATION LANE. Under follow mode a view
|
|
1834
|
+
// change IS a document write, and an unbracketed one goes through
|
|
1835
|
+
// `store.edit` -> `transaction()` per rAF-flushed move — an undo entry
|
|
1836
|
+
// every frame, with the redo stack cleared each time, and the Escape
|
|
1837
|
+
// restore itself another edit. The travel gate is what keeps a middle
|
|
1838
|
+
// CLICK from opening a scrub it never feeds, which would then block
|
|
1839
|
+
// `edit`/`undo`/`redo` until something else closed it.
|
|
1840
|
+
let dragged = false;
|
|
1841
|
+
const following = follows;
|
|
1842
|
+
// ns-resize, not a zoom cursor: the gesture is a VERTICAL DRAG and that is
|
|
1843
|
+
// what the arrows say. zoom-in/zoom-out are click affordances and would
|
|
1844
|
+
// promise an action a drag does not have.
|
|
1845
|
+
setNavCursor("ns-resize");
|
|
1846
|
+
dragGesture(
|
|
1847
|
+
el,
|
|
1848
|
+
e.pointerId,
|
|
1849
|
+
(ev) => {
|
|
1850
|
+
const dy = dragFrom(ev)[1];
|
|
1851
|
+
if (!dragged && Math.abs(dy) < DRAG_THRESHOLD_PX) return;
|
|
1852
|
+
if (!dragged && following) client.handlers()?.onFramingGestureStart();
|
|
1853
|
+
dragged = true;
|
|
1854
|
+
setWorkingView(dollyBy(fromWorking, -dy, dolly));
|
|
1855
|
+
},
|
|
1856
|
+
(commit) => {
|
|
1857
|
+
setNavCursor(null);
|
|
1858
|
+
if (!dragged) return;
|
|
1859
|
+
if (!commit) setWorkingView(fromWorking);
|
|
1860
|
+
if (following) client.handlers()?.onFramingGestureEnd(commit);
|
|
1861
|
+
},
|
|
1862
|
+
);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if (pans) {
|
|
1866
|
+
// A closed hand while the drag runs, and back to whatever the pointer is
|
|
1867
|
+
// over when it ends. This is the one cursor a live gesture is allowed to
|
|
1868
|
+
// change, because a pan is the one gesture whose subject IS the pointer.
|
|
1869
|
+
setCursor("grabbing");
|
|
1870
|
+
const fromView = view;
|
|
1871
|
+
const fromWorking = workingView;
|
|
1872
|
+
// With follow armed, a working-pane pan moves the CAMERA too, so it is
|
|
1873
|
+
// bracketed exactly as an orbit is and the whole hold is one undo entry.
|
|
1874
|
+
const following = follows && pane === "working";
|
|
1875
|
+
if (following) client.handlers()?.onFramingGestureStart();
|
|
1876
|
+
dragGesture(
|
|
1877
|
+
el,
|
|
1878
|
+
e.pointerId,
|
|
1879
|
+
(ev) => {
|
|
1880
|
+
const [dx, dy] = dragFrom(ev);
|
|
1881
|
+
if (pane === "camera") setView(panBy(fromView, dx, dy));
|
|
1882
|
+
// The working pane pans its TARGET at the target's own depth, which
|
|
1883
|
+
// is what keeps the point under the cursor under the cursor. It needs
|
|
1884
|
+
// the pane height because that is what the fov spans, not the canvas.
|
|
1885
|
+
else setWorkingView(panWorkingBy(fromWorking, dx, dy, rect.height, WORKING_FOV_DEG));
|
|
1886
|
+
},
|
|
1887
|
+
(commit) => {
|
|
1888
|
+
// THE VIEW GOES BACK TOO, not only the framing the scrub restores.
|
|
1889
|
+
// Escape means the pan never happened, and a moved view left under a
|
|
1890
|
+
// restored camera is exactly the disagreement follow mode exists to
|
|
1891
|
+
// prevent — the next orbit would cross the gap in one jump.
|
|
1892
|
+
if (!commit) {
|
|
1893
|
+
if (pane === "camera") setView(fromView);
|
|
1894
|
+
else setWorkingView(fromWorking);
|
|
1895
|
+
}
|
|
1896
|
+
if (following) client.handlers()?.onFramingGestureEnd(commit);
|
|
1897
|
+
// The hold PANNED, so releasing space must not also play. Marked on
|
|
1898
|
+
// its OWN bit and not by clearing `spaceRef`: the bar is still down,
|
|
1899
|
+
// and the next auto-repeat keydown would set a shared flag straight
|
|
1900
|
+
// back and play the shot anyway.
|
|
1901
|
+
spacePanned.current = true;
|
|
1902
|
+
refreshCursorRef.current();
|
|
1903
|
+
},
|
|
1904
|
+
);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// ── MIDDLE-DRAG ORBITS THE WORKING PANE (Blender's muscle memory) ─────
|
|
1909
|
+
//
|
|
1910
|
+
// Before the grab and the frustum lanes on purpose: middle-drag OVER a
|
|
1911
|
+
// layer must orbit, not move it. The same travel gate, follow bracketing
|
|
1912
|
+
// and Escape restore as the empty-space orbit below — a middle CLICK does
|
|
1913
|
+
// nothing and costs nothing.
|
|
1914
|
+
if (pane === "working" && middle) {
|
|
1915
|
+
const fromWorking = workingView;
|
|
1916
|
+
const following = follows;
|
|
1917
|
+
let dragged = false;
|
|
1918
|
+
// A closed hand, from the press, exactly as the pan lane does it. Not
|
|
1919
|
+
// gated on the travel threshold like the gesture start is: an undo entry
|
|
1920
|
+
// for a click would be a real cost and a cursor for one is not, and
|
|
1921
|
+
// waiting would mean the affordance appears after the author has already
|
|
1922
|
+
// committed to the drag.
|
|
1923
|
+
setNavCursor("grabbing");
|
|
1924
|
+
dragGesture(
|
|
1925
|
+
el,
|
|
1926
|
+
e.pointerId,
|
|
1927
|
+
(ev) => {
|
|
1928
|
+
const [dx, dy] = dragFrom(ev);
|
|
1929
|
+
if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
1930
|
+
if (!dragged && following) client.handlers()?.onFramingGestureStart();
|
|
1931
|
+
dragged = true;
|
|
1932
|
+
setWorkingView(orbitBy(fromWorking, dx, dy));
|
|
1933
|
+
},
|
|
1934
|
+
(commit) => {
|
|
1935
|
+
setNavCursor(null);
|
|
1936
|
+
if (!dragged) return;
|
|
1937
|
+
if (!commit) setWorkingView(fromWorking);
|
|
1938
|
+
if (following) client.handlers()?.onFramingGestureEnd(commit);
|
|
1939
|
+
},
|
|
1940
|
+
);
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// ── THE INSERT LANES, ABOVE THE GRAB AND THE PICK ─────────────────────
|
|
1945
|
+
//
|
|
1946
|
+
// An armed insert tool outranks everything the canvas already holds. With
|
|
1947
|
+
// the rect tool armed, a drag that happens to start over the selection must
|
|
1948
|
+
// draw a rectangle rather than move the layer under it — that is what
|
|
1949
|
+
// "armed" means, and every design tool behaves this way.
|
|
1950
|
+
//
|
|
1951
|
+
// CAMERA PANE ONLY. A box is drawn on the design plane, and in a free orbit
|
|
1952
|
+
// there is no screen rectangle that corresponds to one: the same drag would
|
|
1953
|
+
// describe a different quadrilateral from every angle. The working pane
|
|
1954
|
+
// keeps its orbit, which is the gesture it has nowhere else to put.
|
|
1955
|
+
if (pane === "camera") {
|
|
1956
|
+
if (isDrawTool(state.tool)) {
|
|
1957
|
+
runDrawGesture(state.tool, start, dragFrom, el, e.pointerId, rect);
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (state.tool === "path") {
|
|
1961
|
+
// A press places an anchor; HOLDING AND DRAGGING pulls its bezier
|
|
1962
|
+
// handles. The drag rides `dragGesture` like every other lane in this
|
|
1963
|
+
// file rather than hand-rolling a move/up pair: that helper is the one
|
|
1964
|
+
// place pointercancel and Escape are handled, and a lane without them
|
|
1965
|
+
// leaks its listener when the browser takes the pointer away.
|
|
1966
|
+
//
|
|
1967
|
+
// The two thresholds are SCREEN px (they are about the author's
|
|
1968
|
+
// finger), so they cross into the reducer divided by the zoom.
|
|
1969
|
+
const at = screenToDesign(start, view, cameraViewport, size);
|
|
1970
|
+
const zoom = Math.max(view.zoom, MIN_INVERTIBLE_SCALE);
|
|
1971
|
+
const next = penReduce(pen, { kind: "press", at, closeSlop: PEN_CLOSE_SLOP_PX / zoom });
|
|
1972
|
+
if (next.closed) {
|
|
1973
|
+
commitPen(next);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
setPen(next);
|
|
1977
|
+
dragGesture(
|
|
1978
|
+
el,
|
|
1979
|
+
e.pointerId,
|
|
1980
|
+
(ev) => {
|
|
1981
|
+
// PANE-LOCAL, which is what `screenToDesign` measures against:
|
|
1982
|
+
// `localPoint` is canvas-local and the two differ by the pane's
|
|
1983
|
+
// origin, so the press point plus the delta is the conversion
|
|
1984
|
+
// (deltas being equal in both spaces is exactly why `dragFrom`
|
|
1985
|
+
// exists).
|
|
1986
|
+
const [dx, dy] = dragFrom(ev);
|
|
1987
|
+
const to = screenToDesign([start[0] + dx, start[1] + dy], view, cameraViewport, size);
|
|
1988
|
+
setPen((prev) => penReduce(prev, { kind: "move", at: to, dragThreshold: PEN_DRAG_THRESHOLD_PX / zoom }));
|
|
1989
|
+
},
|
|
1990
|
+
() => setPen((prev) => penReduce(prev, { kind: "release" })),
|
|
1991
|
+
);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// ── ONLY THE PRIMARY BUTTON DRIVES THE LANES BELOW ────────────────────
|
|
1997
|
+
//
|
|
1998
|
+
// Everything from here down assumed a left press. It never checked, so a
|
|
1999
|
+
// right press on a selected layer opened a transform scrub the author had
|
|
2000
|
+
// no idea was running, and a right drag in empty space marqueed. The middle
|
|
2001
|
+
// button's own lanes (orbit, pan, dolly) have all had their say above.
|
|
2002
|
+
//
|
|
2003
|
+
// It sits below the draw and pen lanes, which used to mean a right drag
|
|
2004
|
+
// with the rect tool armed still inserted a layer. That is FIXED: the
|
|
2005
|
+
// context-menu branch above returns on every `button === 2` press, so no
|
|
2006
|
+
// right press reaches this far and the gate now only turns away buttons
|
|
2007
|
+
// three and up. Left where it is because a lane that assumes a left press
|
|
2008
|
+
// should say so, whether or not something upstream already guaranteed it.
|
|
2009
|
+
if (e.button !== 0) return;
|
|
2010
|
+
|
|
2011
|
+
// ── WHAT IS UNDER THE POINTER, ASKED ONCE ─────────────────────────────
|
|
2012
|
+
//
|
|
2013
|
+
// `intentAt` is the arms / grab / frustum / empty-space ordering, and the
|
|
2014
|
+
// hover lane asks it the identical question to choose a cursor. One
|
|
2015
|
+
// function, so the shape the pointer takes and the thing the press does
|
|
2016
|
+
// cannot disagree — which is the whole failure a hover cursor exists to
|
|
2017
|
+
// prevent.
|
|
2018
|
+
const intent = intentAt(pane, start[0], start[1]);
|
|
2019
|
+
if (intent.kind === "arm") {
|
|
2020
|
+
runGizmoGesture(intent.arm, rect, start, canvasStart, el, e.pointerId);
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
if (intent.kind === "grab") {
|
|
2024
|
+
runTransformGesture(intent.grab, pane, rect, start, canvasStart, el, e.pointerId);
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
if (intent.kind === "frustum") {
|
|
2028
|
+
runFramingGesture(intent.part, start, canvasStart, el, e.pointerId);
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// ── EMPTY SPACE: A CLICK UNTIL IT TRAVELS ─────────────────────────────
|
|
2033
|
+
//
|
|
2034
|
+
// What it becomes then depends on the pane.
|
|
2035
|
+
//
|
|
2036
|
+
// CAMERA: a marquee, which is what a flat design surface means by
|
|
2037
|
+
// dragging nothing.
|
|
2038
|
+
//
|
|
2039
|
+
// WORKING: an ORBIT, because that is the only gesture with nowhere else
|
|
2040
|
+
// to live — pan is already space/middle and dolly is already the wheel,
|
|
2041
|
+
// and a 3D view whose primary drag was a marquee is a 3D view you
|
|
2042
|
+
// cannot turn. SHIFT makes it a marquee instead: shift already means
|
|
2043
|
+
// "extend the selection" in the pick policy, so the one marquee this
|
|
2044
|
+
// pane offers is the additive one, which is the one still worth having
|
|
2045
|
+
// once the plain drag is spent.
|
|
2046
|
+
//
|
|
2047
|
+
// THE TRAVEL GATE IS SHARED, and that is the load-bearing part. An orbit
|
|
2048
|
+
// that started on the pointerdown would swallow every click in the working
|
|
2049
|
+
// pane, and clicking a layer there would silently do nothing.
|
|
2050
|
+
// `B` ARMED A ONE-SHOT BOX SELECT, so a Blender user gets the marquee they
|
|
2051
|
+
// reached for without also having to know that shift means it here. Read
|
|
2052
|
+
// and cleared up at `paneAt`, so a press that never reaches this lane still
|
|
2053
|
+
// spends it.
|
|
2054
|
+
const orbits = pane === "working" && !e.shiftKey && !boxArmed;
|
|
2055
|
+
const following = orbits && follows;
|
|
2056
|
+
const fromWorking = workingView;
|
|
2057
|
+
let dragged = false;
|
|
2058
|
+
// The orbit branch only: a marquee draws its own band, and a closed hand
|
|
2059
|
+
// over a rubber band would say the wrong thing about what is moving.
|
|
2060
|
+
if (orbits) setNavCursor("grabbing");
|
|
2061
|
+
dragGesture(
|
|
2062
|
+
el,
|
|
2063
|
+
e.pointerId,
|
|
2064
|
+
(ev) => {
|
|
2065
|
+
const [dx, dy] = dragFrom(ev);
|
|
2066
|
+
if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
2067
|
+
// The travel gate is also where a follow scrub OPENS, and for the same
|
|
2068
|
+
// reason it exists at all: a click must not cost an undo entry.
|
|
2069
|
+
if (!dragged && following) client.handlers()?.onFramingGestureStart();
|
|
2070
|
+
dragged = true;
|
|
2071
|
+
if (orbits) {
|
|
2072
|
+
setWorkingView(orbitBy(fromWorking, dx, dy));
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
// CLAMPED TO ITS OWN PANE, at the source. The band stays in the pane
|
|
2076
|
+
// the drag started in (the overlay clips it there), so a marquee
|
|
2077
|
+
// dragged across the divider used to be a clipped VIEW of a larger
|
|
2078
|
+
// invisible rectangle — and `pickRect` selected against the invisible
|
|
2079
|
+
// one. Clamping here makes the drawn band and the selection the same
|
|
2080
|
+
// rectangle by construction rather than by two agreeing clips.
|
|
2081
|
+
const [mx, my] = clampInPane(rect, start[0] + dx, start[1] + dy);
|
|
2082
|
+
setMarquee({ pane, x0: start[0], y0: start[1], x1: mx, y1: my });
|
|
2083
|
+
},
|
|
2084
|
+
(commit, ev) => {
|
|
2085
|
+
setNavCursor(null);
|
|
2086
|
+
// An orbit moved the author, not the selection.
|
|
2087
|
+
if (dragged && orbits) {
|
|
2088
|
+
if (!commit) setWorkingView(fromWorking);
|
|
2089
|
+
if (following) client.handlers()?.onFramingGestureEnd(commit);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
setMarquee(null);
|
|
2093
|
+
// NO TERMINATING EVENT, NO SELECTION CHANGE. Escape and a cancelled
|
|
2094
|
+
// pointer both end here with `ev: null`, and the final point and the
|
|
2095
|
+
// modifiers are only readable off the event that ended the drag —
|
|
2096
|
+
// answering from the press point would pick something the author had
|
|
2097
|
+
// already dragged away from.
|
|
2098
|
+
if (!commit || ev === null) return;
|
|
2099
|
+
const [dx, dy] = dragFrom(ev);
|
|
2100
|
+
const modifiers = { shift: ev.shiftKey, meta: ev.metaKey || ev.ctrlKey, alt: ev.altKey };
|
|
2101
|
+
if (dragged) {
|
|
2102
|
+
// The SAME clamp the drawn band uses, so the selection is the
|
|
2103
|
+
// rectangle the author actually saw.
|
|
2104
|
+
const [mx, my] = clampInPane(rect, start[0] + dx, start[1] + dy);
|
|
2105
|
+
const hits = feed.pickRect?.(pane, { x0: start[0], y0: start[1], x1: mx, y1: my }) ?? [];
|
|
2106
|
+
// A marquee REPLACES the selection unless shift is held — the same
|
|
2107
|
+
// rule every design tool has, and the reason the pick handler takes
|
|
2108
|
+
// the modifiers rather than deciding for itself.
|
|
2109
|
+
//
|
|
2110
|
+
// `marquee: true` is what tells the chrome this list is a SET rather
|
|
2111
|
+
// than a hit stack. Without it the click policy took `hits[0]` and a
|
|
2112
|
+
// box over five layers selected one.
|
|
2113
|
+
client.handlers()?.onPick(hits, { ...modifiers, meta: false, marquee: true }, pane);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
client.handlers()?.onPick(feed.pick?.(pane, start[0] + dx, start[1] + dy) ?? [], modifiers, pane);
|
|
2117
|
+
},
|
|
2118
|
+
);
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* Rubber-band a new layer's box, and hand its two corners to the chrome.
|
|
2123
|
+
*
|
|
2124
|
+
* THE PREVIEW IS THE MARQUEE'S OWN RECTANGLE, reused rather than re-drawn:
|
|
2125
|
+
* both are "the box between the press and the cursor", and a second dashed
|
|
2126
|
+
* rect drawn a different way is a second thing to keep in step with the pane
|
|
2127
|
+
* translation and the clip.
|
|
2128
|
+
*
|
|
2129
|
+
* ponytail: a line or an arrow therefore previews as the BOX it spans rather
|
|
2130
|
+
* than as the stroke it will become. Give the overlay a second preview shape
|
|
2131
|
+
* when someone is drawing enough arrows to care.
|
|
2132
|
+
*
|
|
2133
|
+
* The corners cross as DESIGN COORDINATES because only this half can convert
|
|
2134
|
+
* (see `onDraw`), and a sub-threshold drag crosses as two IDENTICAL corners
|
|
2135
|
+
* because only this half knows how far the pointer travelled in screen px.
|
|
2136
|
+
*/
|
|
2137
|
+
const runDrawGesture = (
|
|
2138
|
+
tool: DrawTool,
|
|
2139
|
+
start: readonly [number, number],
|
|
2140
|
+
dragFrom: (ev: PointerEvent) => [number, number],
|
|
2141
|
+
el: HTMLDivElement,
|
|
2142
|
+
pointerId: number,
|
|
2143
|
+
rect: PaneRect,
|
|
2144
|
+
): void => {
|
|
2145
|
+
let dragged = false;
|
|
2146
|
+
dragGesture(
|
|
2147
|
+
el,
|
|
2148
|
+
pointerId,
|
|
2149
|
+
(ev) => {
|
|
2150
|
+
const [dx, dy] = dragFrom(ev);
|
|
2151
|
+
if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
2152
|
+
dragged = true;
|
|
2153
|
+
// CLAMPED, exactly as the marquee is and for the same reason. The
|
|
2154
|
+
// marquee lane got this and its sibling here did not: a draw dragged
|
|
2155
|
+
// across the divider previewed clipped (the overlay clips to the pane)
|
|
2156
|
+
// and then INSERTED a layer sized to the full invisible drag, so the
|
|
2157
|
+
// shape the author got was not the shape they saw.
|
|
2158
|
+
const [mx, my] = clampInPane(rect, start[0] + dx, start[1] + dy);
|
|
2159
|
+
setMarquee({ pane: "camera", x0: start[0], y0: start[1], x1: mx, y1: my });
|
|
2160
|
+
},
|
|
2161
|
+
(commit, ev) => {
|
|
2162
|
+
setMarquee(null);
|
|
2163
|
+
// Escape and a cancelled pointer insert NOTHING. Same rule the marquee
|
|
2164
|
+
// lane keeps for the same reason: the final point only exists on the
|
|
2165
|
+
// event that ended the drag, and there is none.
|
|
2166
|
+
if (!commit || ev === null) return;
|
|
2167
|
+
const [dx, dy] = dragFrom(ev);
|
|
2168
|
+
const from = screenToDesign(start, view, cameraViewport, size);
|
|
2169
|
+
const at = clampInPane(rect, start[0] + dx, start[1] + dy);
|
|
2170
|
+
const to = dragged ? screenToDesign(at, view, cameraViewport, size) : from;
|
|
2171
|
+
client.handlers()?.onDraw(tool, [centreOrigin(from, size), centreOrigin(to, size)]);
|
|
2172
|
+
},
|
|
2173
|
+
);
|
|
2174
|
+
};
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* Run a move / resize / tilt / depth drag.
|
|
2178
|
+
*
|
|
2179
|
+
* THREE THINGS MAKE THIS ONE UNDO ENTRY AND EXACTLY REVERSIBLE, and none of
|
|
2180
|
+
* them is in this function: `onGestureStart` opens a history scrub, every
|
|
2181
|
+
* delta is measured FROM THE PRESS so the scrub can re-derive from its base,
|
|
2182
|
+
* and Escape ends it with `commit: false`, which restores that base exactly
|
|
2183
|
+
* because the document never left it.
|
|
2184
|
+
*
|
|
2185
|
+
* The delta is computed by unprojecting BOTH rays onto one plane — which
|
|
2186
|
+
* plane is the V-vs-G rule, and it lives in interactions/move.ts.
|
|
2187
|
+
*
|
|
2188
|
+
* IT WORKS UNCHANGED IN THE WORKING PANE. `designDelta` takes two RAYS and a
|
|
2189
|
+
* PLANE and no camera, so which camera cast the rays is not a question it can
|
|
2190
|
+
* ask; a move dragged from an orbit view is the same arithmetic on rays that
|
|
2191
|
+
* happen to come in at an angle. Only the SCREEN-space kinds below are
|
|
2192
|
+
* camera-bound, and `grabAt` never returns one in the working pane.
|
|
2193
|
+
*/
|
|
2194
|
+
const runTransformGesture = (
|
|
2195
|
+
grab: Grab,
|
|
2196
|
+
pane: StagePane,
|
|
2197
|
+
rect: PaneRect,
|
|
2198
|
+
start: readonly [number, number],
|
|
2199
|
+
canvasStart: readonly [number, number],
|
|
2200
|
+
el: HTMLDivElement,
|
|
2201
|
+
pointerId: number,
|
|
2202
|
+
): void => {
|
|
2203
|
+
const handlers = client.handlers();
|
|
2204
|
+
if (handlers === undefined || handlers === null || feed.rayAt === null) return;
|
|
2205
|
+
// ── THE ROTATE RING: AN ANGLE SWEPT, NOT A DISTANCE DRAGGED ──────────
|
|
2206
|
+
//
|
|
2207
|
+
// Its own lane, above the ray work, because it needs neither a ray nor a
|
|
2208
|
+
// plane: a roll is measured on screen, about the projected centre of the
|
|
2209
|
+
// thing being turned. A linear px scale (which is what `R` uses, and what
|
|
2210
|
+
// it should use — it has no drawn handle to follow) would make the box slip
|
|
2211
|
+
// out from under the cursor the moment the author swept past a corner.
|
|
2212
|
+
//
|
|
2213
|
+
// TOTAL FROM THE PRESS, like every other gesture here, so a coalesced move
|
|
2214
|
+
// cannot drift and Escape restores the base exactly.
|
|
2215
|
+
if (grab.kind === "roll") {
|
|
2216
|
+
// BOTH CARRIED ON THE GRAB, from the quad that was actually pressed. They
|
|
2217
|
+
// used to be re-derived from `selection[0]`, which is the lowest id in
|
|
2218
|
+
// document order rather than the one under the cursor.
|
|
2219
|
+
const from = grab.handleAngle;
|
|
2220
|
+
const about = grab.centre;
|
|
2221
|
+
if (from === undefined || about === undefined) return;
|
|
2222
|
+
const [cx, cy] = about;
|
|
2223
|
+
const pivot = selectionPivot(feed, state.selection);
|
|
2224
|
+
handlers.onGestureStart("roll", state.selection, clampZoom(view.zoom));
|
|
2225
|
+
dragGesture(
|
|
2226
|
+
el,
|
|
2227
|
+
pointerId,
|
|
2228
|
+
(ev) => {
|
|
2229
|
+
const at = localPoint(ev);
|
|
2230
|
+
// Unwrapped through `atan2`'s branch cut, so a sweep past due west
|
|
2231
|
+
// does not jump by a full turn.
|
|
2232
|
+
let deg = ((Math.atan2(at[1] - rect.y - cy, at[0] - rect.x - cx) - from) * 180) / Math.PI;
|
|
2233
|
+
deg = ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
2234
|
+
// SHIFT is the same precision the modal lane gives, and CTRL the same
|
|
2235
|
+
// 15 degree quantise Blender uses.
|
|
2236
|
+
if (ev.shiftKey) deg *= PRECISION_FACTOR;
|
|
2237
|
+
if (ev.ctrlKey || ev.metaKey) deg = Math.round(deg / ROLL_SNAP_DEG) * ROLL_SNAP_DEG;
|
|
2238
|
+
// Copied out of the stage's own snapshot: `GestureDelta` is the wire
|
|
2239
|
+
// shape the store re-derives from, and sharing an alias lets one
|
|
2240
|
+
// mutate the other.
|
|
2241
|
+
handlers.onGesture({ kind: "roll", dx: deg, dy: 0, pivot: pivot === null ? null : [...pivot] });
|
|
2242
|
+
},
|
|
2243
|
+
(commit) => handlers.onGestureEnd(commit),
|
|
2244
|
+
);
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
// A RESIZE IS ON THE LAYER'S OWN PLANE, always. The handle is drawn on that
|
|
2248
|
+
// plane and the edge it moves lives in it, so the frame plane would measure
|
|
2249
|
+
// a foreshortened distance on any tilted layer. Select's move joins it (the
|
|
2250
|
+
// V-vs-G rule, interactions/move.ts); G's move is deliberately flat.
|
|
2251
|
+
//
|
|
2252
|
+
// A UNION SCALE joins them for the same reason and with a stronger claim:
|
|
2253
|
+
// its box IS a plane the members all lie in, by `unionQuad`'s own predicate.
|
|
2254
|
+
const plane = gesturePlane(
|
|
2255
|
+
grab.kind === "resize" ||
|
|
2256
|
+
grab.kind === "scale" ||
|
|
2257
|
+
(grab.kind === "move" && state.tool === "select")
|
|
2258
|
+
? "layer"
|
|
2259
|
+
: "frame",
|
|
2260
|
+
grab.matrixWorld,
|
|
2261
|
+
);
|
|
2262
|
+
// No ray means the pane has no camera on screen, so there is no gesture to
|
|
2263
|
+
// start — better than opening a history scrub that can never be fed.
|
|
2264
|
+
const from = feed.rayAt(pane, start[0], start[1]);
|
|
2265
|
+
if (from === null) return;
|
|
2266
|
+
// ── THE UNION SCALE'S TWO SNAPSHOTS ───────────────────────────────────
|
|
2267
|
+
//
|
|
2268
|
+
// The box and the press point, both frozen at the press and both in DESIGN
|
|
2269
|
+
// px. The box is rebuilt from its members every frame, so reading it live
|
|
2270
|
+
// would move the pivot with the layers the scale is moving; the anchor is
|
|
2271
|
+
// where the ratio is measured from, and re-deriving it per move is how a
|
|
2272
|
+
// gesture stops being a total from the press.
|
|
2273
|
+
//
|
|
2274
|
+
// A press with no honest point on the plane (edge-on) opens no scrub at
|
|
2275
|
+
// all, the same refusal `designDelta` makes mid-drag.
|
|
2276
|
+
const box = grab.kind === "scale" ? designBox(grab.union) : null;
|
|
2277
|
+
const anchor = grab.kind === "scale" ? designPoint(from, plane) : null;
|
|
2278
|
+
if (grab.kind === "scale" && (box === null || anchor === null)) return;
|
|
2279
|
+
// THE PANE'S OWN SCREEN SCALE, which only this half can answer. The chrome
|
|
2280
|
+
// divides its snap threshold by it, and the two panes derive it from
|
|
2281
|
+
// different things entirely: a flat zoom here, an orbit distance there.
|
|
2282
|
+
handlers.onGestureStart(
|
|
2283
|
+
grab.kind,
|
|
2284
|
+
state.selection,
|
|
2285
|
+
pane === "camera" ? clampZoom(view.zoom) : paneScreenScale(rect),
|
|
2286
|
+
);
|
|
2287
|
+
|
|
2288
|
+
dragGesture(
|
|
2289
|
+
el,
|
|
2290
|
+
pointerId,
|
|
2291
|
+
(ev) => {
|
|
2292
|
+
const at = localPoint(ev);
|
|
2293
|
+
const dx = at[0] - canvasStart[0];
|
|
2294
|
+
const dy = at[1] - canvasStart[1];
|
|
2295
|
+
const to = feed.rayAt?.(pane, start[0] + dx, start[1] + dy) ?? null;
|
|
2296
|
+
const delta = to === null ? null : designDelta(from, to, plane);
|
|
2297
|
+
// A null delta is an EDGE-ON layer, which has no answer — skipping the
|
|
2298
|
+
// gesture is the only honest response, and writing NaN is the
|
|
2299
|
+
// alternative.
|
|
2300
|
+
if (delta === null) return;
|
|
2301
|
+
// ── A RESIZE IS THE SAME UNPROJECTION AS A MOVE ───────────────────
|
|
2302
|
+
//
|
|
2303
|
+
// It used to be a screen-px drag divided by `view.zoom`, and that was
|
|
2304
|
+
// wrong in two independent ways at once. `view.zoom` is screen px per
|
|
2305
|
+
// design px AT z = 0 (view.ts), but the camera's distance is
|
|
2306
|
+
// `planeDistance(H, fov, framing.zoom)` (resolve.ts) and nothing
|
|
2307
|
+
// multiplied the two — so every resize was off by the framing's own
|
|
2308
|
+
// zoom even on the canvas plane, and off by the perspective divide at
|
|
2309
|
+
// any depth. Neither error was visible as a jump; the handle just did
|
|
2310
|
+
// not keep up with the cursor, which reads as "resize feels wrong".
|
|
2311
|
+
//
|
|
2312
|
+
// Two rays on the layer's own plane have no zoom term at all, so this
|
|
2313
|
+
// is exact for BOTH panes and for a perspective or an orthographic
|
|
2314
|
+
// camera alike. That is what lets the working pane have handles.
|
|
2315
|
+
if (grab.kind === "resize") {
|
|
2316
|
+
handlers.onGesture({
|
|
2317
|
+
kind: "resize",
|
|
2318
|
+
dx: delta.dx,
|
|
2319
|
+
dy: delta.dy,
|
|
2320
|
+
dz: delta.dz,
|
|
2321
|
+
...(grab.handle === undefined ? {} : { handle: grab.handle }),
|
|
2322
|
+
keepAspect: ev.shiftKey,
|
|
2323
|
+
aboutCentre: ev.altKey,
|
|
2324
|
+
});
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
// ── A UNION HANDLE IS A RATIO, NOT AN EDGE ────────────────────────
|
|
2328
|
+
//
|
|
2329
|
+
// Same two rays and the same plane as a resize; only the arithmetic on
|
|
2330
|
+
// the far side differs. ALT re-pivots to the box's centre mid-drag,
|
|
2331
|
+
// which is the modifier a single-layer resize spends on `aboutCentre`
|
|
2332
|
+
// and means the same thing here.
|
|
2333
|
+
if (grab.kind === "scale") {
|
|
2334
|
+
if (box === null || anchor === null || grab.handle === undefined) return;
|
|
2335
|
+
const scale = unionScale(box, grab.handle, [anchor[0], anchor[1]], delta, ev.altKey);
|
|
2336
|
+
// A degenerate arm has no ratio — see `unionScale`. Skipping the emit
|
|
2337
|
+
// holds the last good one rather than writing a NaN size.
|
|
2338
|
+
if (scale === null) return;
|
|
2339
|
+
handlers.onGesture({
|
|
2340
|
+
kind: "scale",
|
|
2341
|
+
dx: 0,
|
|
2342
|
+
dy: 0,
|
|
2343
|
+
ratio: scale.ratio,
|
|
2344
|
+
axis: scale.axis,
|
|
2345
|
+
// The design z the whole box lies in, so a member's own depth
|
|
2346
|
+
// offset from the pivot is zero and a uniform scale cannot drift
|
|
2347
|
+
// the selection towards or away from the camera.
|
|
2348
|
+
pivot: [scale.pivot[0], scale.pivot[1], anchor[2]],
|
|
2349
|
+
});
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
handlers.onGesture({ kind: "move", ...delta });
|
|
2353
|
+
},
|
|
2354
|
+
(commit) => handlers.onGestureEnd(commit),
|
|
2355
|
+
);
|
|
2356
|
+
};
|
|
2357
|
+
|
|
2358
|
+
/**
|
|
2359
|
+
* Drag one arm of the move gizmo: a translation along ONE design axis, of
|
|
2360
|
+
* whichever subject the gizmo is hanging on.
|
|
2361
|
+
*
|
|
2362
|
+
* ── IT IS THE SAME SCRUB EVERY OTHER TRANSFORM OPENS ────────────────────
|
|
2363
|
+
*
|
|
2364
|
+
* For layers: `onGestureStart("move", …)` / N× `onGesture` / `onGestureEnd`.
|
|
2365
|
+
* For the camera: the framing trio the frustum's own two grips already use.
|
|
2366
|
+
* Either way every emit is a TOTAL FROM THE PRESS — the two rules that make a
|
|
2367
|
+
* drag one undo entry and exactly reversible (see `dragGesture`). So an arm
|
|
2368
|
+
* drag, a body drag and `G X` all land in the store the same way, and the
|
|
2369
|
+
* store's base/block/refusal fork sees one kind of thing.
|
|
2370
|
+
*
|
|
2371
|
+
* THE SUBJECT COMES OFF THE GRAB, snapshotted at the press, and is never
|
|
2372
|
+
* re-read from the selection mid-drag. A selection arriving from the chrome
|
|
2373
|
+
* while an arm is held would otherwise turn a camera dolly into a layer move
|
|
2374
|
+
* halfway through, inside one history scrub.
|
|
2375
|
+
*
|
|
2376
|
+
* ── WHY IT SOLVES A LINE RATHER THAN A PLANE ────────────────────────────
|
|
2377
|
+
*
|
|
2378
|
+
* `axisParam` is the closest point on the axis line to the pointer ray, so
|
|
2379
|
+
* the grabbed arm stays under the cursor at every orbit. Unprojecting onto a
|
|
2380
|
+
* plane and zeroing the other two components — what the MODAL lane does,
|
|
2381
|
+
* because it has no drawn arm to stay under — drifts as the view turns and
|
|
2382
|
+
* runs away entirely at a grazing angle.
|
|
2383
|
+
*
|
|
2384
|
+
* The axis parameter is already in DESIGN units, and needs no `-y` flip: a
|
|
2385
|
+
* translation of `s` along the arm's world direction is `s` along the design
|
|
2386
|
+
* axis it was built from, because `DESIGN_AXES` carries unit vectors and the
|
|
2387
|
+
* one mapping has no scale in it.
|
|
2388
|
+
*
|
|
2389
|
+
* Null from `axisParam` is a ray PARALLEL to the arm — every point on it is
|
|
2390
|
+
* equally close — and skips the emit rather than writing NaN, the same
|
|
2391
|
+
* refusal `designDelta` makes for an edge-on layer.
|
|
2392
|
+
*/
|
|
2393
|
+
const runGizmoGesture = (
|
|
2394
|
+
arm: GizmoGrab,
|
|
2395
|
+
rect: PaneRect,
|
|
2396
|
+
start: readonly [number, number],
|
|
2397
|
+
canvasStart: readonly [number, number],
|
|
2398
|
+
el: HTMLDivElement,
|
|
2399
|
+
pointerId: number,
|
|
2400
|
+
): void => {
|
|
2401
|
+
const handlers = client.handlers();
|
|
2402
|
+
if (handlers === undefined || handlers === null) return;
|
|
2403
|
+
const from = feed.rayAt?.("working", start[0], start[1]) ?? null;
|
|
2404
|
+
const anchor = from === null ? null : paramAt(arm, from);
|
|
2405
|
+
// No ray, or a press that grazed the arm end-on: there is nothing to
|
|
2406
|
+
// measure against, and opening a scrub that can never be fed is worse than
|
|
2407
|
+
// letting the press fall through to nothing.
|
|
2408
|
+
if (anchor === null) return;
|
|
2409
|
+
const camera = arm.subject === "camera";
|
|
2410
|
+
// The apex's own world z IS the camera's distance to the canvas — the
|
|
2411
|
+
// gizmo hangs on `[x, -y, distance]` (frustumGeometry) — so the dolly's
|
|
2412
|
+
// ratio needs no second lookup and cannot go stale against the pose the
|
|
2413
|
+
// arm was drawn at.
|
|
2414
|
+
const distance = arm.origin[2];
|
|
2415
|
+
if (camera) handlers.onFramingGestureStart();
|
|
2416
|
+
else {
|
|
2417
|
+
handlers.onGestureStart("move", state.selection, paneScreenScale(rect));
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
dragGesture(
|
|
2421
|
+
el,
|
|
2422
|
+
pointerId,
|
|
2423
|
+
(ev) => {
|
|
2424
|
+
const at = localPoint(ev);
|
|
2425
|
+
const to = feed.rayAt?.(
|
|
2426
|
+
"working",
|
|
2427
|
+
start[0] + at[0] - canvasStart[0],
|
|
2428
|
+
start[1] + at[1] - canvasStart[1],
|
|
2429
|
+
);
|
|
2430
|
+
const now = to === null || to === undefined ? null : paramAt(arm, to);
|
|
2431
|
+
if (now === null) return;
|
|
2432
|
+
const d = now - anchor;
|
|
2433
|
+
if (camera) {
|
|
2434
|
+
const delta = framingArmDelta(arm.axis, d, distance);
|
|
2435
|
+
if (delta !== null) handlers.onFramingGesture(delta);
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
handlers.onGesture({
|
|
2439
|
+
kind: "move",
|
|
2440
|
+
dx: arm.axis === "x" ? d : 0,
|
|
2441
|
+
dy: arm.axis === "y" ? d : 0,
|
|
2442
|
+
dz: arm.axis === "z" ? d : 0,
|
|
2443
|
+
});
|
|
2444
|
+
},
|
|
2445
|
+
(commit) => {
|
|
2446
|
+
if (camera) handlers.onFramingGestureEnd(commit);
|
|
2447
|
+
else handlers.onGestureEnd(commit);
|
|
2448
|
+
},
|
|
2449
|
+
);
|
|
2450
|
+
};
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* Drag the CAMERA: the body pans its framing, the apex zooms it.
|
|
2454
|
+
*
|
|
2455
|
+
* Working pane only, because the gizmo only exists there — it is the camera
|
|
2456
|
+
* pane's camera drawn as an object, and it cannot appear inside the view it
|
|
2457
|
+
* represents.
|
|
2458
|
+
*
|
|
2459
|
+
* The two halves take their delta from different places on purpose. The BODY
|
|
2460
|
+
* unprojects both rays onto z = 0, the plane its rectangle sits in, so the
|
|
2461
|
+
* grabbed point stays under the cursor whatever angle the author is orbiting
|
|
2462
|
+
* from — the same unprojection a layer move uses, and `designDelta` needs no
|
|
2463
|
+
* camera to do it. The APEX is a screen-space drag: its distance from the
|
|
2464
|
+
* plane IS the zoom (`planeDistance`), the pointer has no depth, and there is
|
|
2465
|
+
* no projection that would make dragging a point along the view axis feel
|
|
2466
|
+
* like anything. Exponential at the wheel's own falloff, so an apex drag and
|
|
2467
|
+
* a wheel notch are the same step.
|
|
2468
|
+
*
|
|
2469
|
+
* What the deltas MEAN — a base write, a refusal, or a block — is the store's
|
|
2470
|
+
* fork, not the stage's. See camera.ts.
|
|
2471
|
+
*/
|
|
2472
|
+
const runFramingGesture = (
|
|
2473
|
+
part: "apex" | "body",
|
|
2474
|
+
start: readonly [number, number],
|
|
2475
|
+
canvasStart: readonly [number, number],
|
|
2476
|
+
el: HTMLDivElement,
|
|
2477
|
+
pointerId: number,
|
|
2478
|
+
): void => {
|
|
2479
|
+
const handlers = client.handlers();
|
|
2480
|
+
if (handlers === undefined || handlers === null) return;
|
|
2481
|
+
const from = part === "body" ? (feed.rayAt?.("working", start[0], start[1]) ?? null) : null;
|
|
2482
|
+
// No ray means no camera on screen for this pane, so there is nothing to
|
|
2483
|
+
// measure against — better than a scrub that can never be fed.
|
|
2484
|
+
if (part === "body" && from === null) return;
|
|
2485
|
+
handlers.onFramingGestureStart();
|
|
2486
|
+
|
|
2487
|
+
dragGesture(
|
|
2488
|
+
el,
|
|
2489
|
+
pointerId,
|
|
2490
|
+
(ev) => {
|
|
2491
|
+
const at = localPoint(ev);
|
|
2492
|
+
const dx = at[0] - canvasStart[0];
|
|
2493
|
+
const dy = at[1] - canvasStart[1];
|
|
2494
|
+
if (part === "apex") {
|
|
2495
|
+
handlers.onFramingGesture({ dx: 0, dy: 0, dzoom: Math.exp(-dy / DOLLY_FALLOFF_PX) });
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
const to = feed.rayAt?.("working", start[0] + dx, start[1] + dy) ?? null;
|
|
2499
|
+
const delta = to === null || from === null ? null : designDelta(from, to, Z0_PLANE);
|
|
2500
|
+
if (delta !== null) handlers.onFramingGesture({ dx: delta.dx, dy: delta.dy, dzoom: 1 });
|
|
2501
|
+
},
|
|
2502
|
+
(commit) => handlers.onFramingGestureEnd(commit),
|
|
2503
|
+
);
|
|
2504
|
+
};
|
|
2505
|
+
|
|
2506
|
+
const onWheel = (e: WheelLike): void => {
|
|
2507
|
+
if (doc === null) return;
|
|
2508
|
+
// A LIVE MODAL OWNS THE VIEW, the way it owns the keyboard. Its anchor ray
|
|
2509
|
+
// and screen scale are captured once at `startModal`; a dolly re-poses the
|
|
2510
|
+
// working camera under them, so the next emit differences a post-dolly ray
|
|
2511
|
+
// against a pre-dolly anchor and commits the camera's motion as the
|
|
2512
|
+
// layer's. Ignoring the wheel keeps the transform anchored to where the
|
|
2513
|
+
// author started it — Blender's own answer is the same, and the author can
|
|
2514
|
+
// confirm or cancel in one keystroke if they wanted to dolly first.
|
|
2515
|
+
if (modalRef.current !== null) return;
|
|
2516
|
+
const at = localPoint(e);
|
|
2517
|
+
const hit = paneAt(panes, at[0], at[1]);
|
|
2518
|
+
if (hit === null) return;
|
|
2519
|
+
client.handlers()?.onActivePane(hit.pane);
|
|
2520
|
+
if (hit.pane === "working") {
|
|
2521
|
+
// `deltaMode` scaled exactly as `wheelToView` scales it — Firefox reports
|
|
2522
|
+
// lines where Chrome reports pixels — so one notch is the same step in
|
|
2523
|
+
// whichever pane it lands in.
|
|
2524
|
+
const rect = panes.working as PaneRect;
|
|
2525
|
+
const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? rect.height : 1;
|
|
2526
|
+
// ── A TRACKPAD CAN PAN THIS PANE AT LAST ────────────────────────────
|
|
2527
|
+
//
|
|
2528
|
+
// A two-finger sideways scroll reports `deltaX`, and a shifted vertical
|
|
2529
|
+
// one is the same gesture on a mouse wheel. Both used to fall through to
|
|
2530
|
+
// the dolly, so the working pane had NO pan for anyone without a middle
|
|
2531
|
+
// button: the camera pane panned on a plain wheel and this one zoomed,
|
|
2532
|
+
// which is also why the two felt inconsistent.
|
|
2533
|
+
//
|
|
2534
|
+
// ctrl/meta STAYS ON THE DOLLY, deliberately. `panZoom.ts` is explicit
|
|
2535
|
+
// that a trackpad reports a PINCH as ctrl + wheel, so that binding is
|
|
2536
|
+
// what makes two-finger zoom work at all.
|
|
2537
|
+
const pans = !e.ctrlKey && !e.metaKey && (e.deltaX !== 0 || e.shiftKey);
|
|
2538
|
+
if (pans) {
|
|
2539
|
+
// NEGATED, because a wheel delta is the opposite sense from a drag
|
|
2540
|
+
// delta — the same reason `wheelToView` subtracts.
|
|
2541
|
+
const dx = (e.shiftKey ? e.deltaY : e.deltaX) * scale;
|
|
2542
|
+
const dy = (e.shiftKey ? 0 : e.deltaY) * scale;
|
|
2543
|
+
setWorkingView(panWorkingBy(workingView, -dx, -dy, rect.height, WORKING_FOV_DEG), "wheel");
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
// ORIGIN "wheel": under follow this is a document write with no press and
|
|
2547
|
+
// no release to bracket it, so the chrome closes it over silence instead.
|
|
2548
|
+
setWorkingView(dollyBy(workingView, e.deltaY * scale, dolly), "wheel");
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
setView(wheelToView(e, hit.local, view, cameraViewport, size));
|
|
2552
|
+
};
|
|
2553
|
+
|
|
2554
|
+
/**
|
|
2555
|
+
* The wheel is registered BY HAND, non-passive, and it is the only way this
|
|
2556
|
+
* works.
|
|
2557
|
+
*
|
|
2558
|
+
* React attaches `onWheel` as a PASSIVE listener on the root container
|
|
2559
|
+
* (react-dom does this for wheel, touchstart and touchmove), and a passive
|
|
2560
|
+
* listener cannot `preventDefault`. So ctrl/meta + wheel — which is exactly
|
|
2561
|
+
* the gesture a trackpad pinch reports, and the one `wheelToView` reads as
|
|
2562
|
+
* zoom — reached the BROWSER as a page-zoom request at the same time as it
|
|
2563
|
+
* reached us. Both fired: the canvas zoomed and the entire chrome scaled
|
|
2564
|
+
* with it. Preventing the default on a listener we own is the whole fix.
|
|
2565
|
+
*
|
|
2566
|
+
* Every wheel event is prevented, not only the zooming ones: a plain wheel
|
|
2567
|
+
* pans this canvas, and letting it also scroll whatever the browser thinks
|
|
2568
|
+
* is scrollable behind us is the same class of double-handling.
|
|
2569
|
+
*
|
|
2570
|
+
* The handler is read through a ref so the listener registers ONCE. Re-adding
|
|
2571
|
+
* it whenever `view` changed would mean removing and re-adding a listener on
|
|
2572
|
+
* every frame of a zoom gesture.
|
|
2573
|
+
*
|
|
2574
|
+
* EVENTS ARE COALESCED TO THE FRAME, deltas summed. A trackpad fires wheel
|
|
2575
|
+
* far above display rate and each application costs a stage commit and a GL
|
|
2576
|
+
* draw. Summing is exact for every consumer: the pan is linear in the
|
|
2577
|
+
* deltas, and the zoom and the dolly are exponential (`exp(-(a+b)/k) =
|
|
2578
|
+
* exp(-a/k)·exp(-b/k)`), so one flush of a summed delta lands on the same
|
|
2579
|
+
* view as applying each event. The default is still prevented PER EVENT —
|
|
2580
|
+
* only the application defers. A modifier or deltaMode change flushes what
|
|
2581
|
+
* is pending first, so a pinch starting mid-scroll cannot fold into a pan.
|
|
2582
|
+
*/
|
|
2583
|
+
const wheelRef = useRef(onWheel);
|
|
2584
|
+
wheelRef.current = onWheel;
|
|
2585
|
+
useEffect(() => {
|
|
2586
|
+
const el = areaRef.current;
|
|
2587
|
+
if (el === null) return;
|
|
2588
|
+
let pending: WheelLike | null = null;
|
|
2589
|
+
let raf = 0;
|
|
2590
|
+
const flush = (): void => {
|
|
2591
|
+
raf = 0;
|
|
2592
|
+
if (pending === null) return;
|
|
2593
|
+
const batch = pending;
|
|
2594
|
+
pending = null;
|
|
2595
|
+
wheelRef.current(batch);
|
|
2596
|
+
};
|
|
2597
|
+
const onWheelNative = (e: WheelEvent): void => {
|
|
2598
|
+
e.preventDefault();
|
|
2599
|
+
// SHIFT joins the flush set: it decides whether a vertical wheel pans or
|
|
2600
|
+
// dollies the working pane, so folding a shifted notch into an unshifted
|
|
2601
|
+
// batch would apply both to whichever the batch ended up claiming.
|
|
2602
|
+
if (
|
|
2603
|
+
pending !== null &&
|
|
2604
|
+
(pending.ctrlKey !== e.ctrlKey ||
|
|
2605
|
+
pending.metaKey !== e.metaKey ||
|
|
2606
|
+
pending.shiftKey !== e.shiftKey ||
|
|
2607
|
+
pending.deltaMode !== e.deltaMode)
|
|
2608
|
+
) {
|
|
2609
|
+
flush();
|
|
2610
|
+
}
|
|
2611
|
+
if (pending === null) {
|
|
2612
|
+
pending = {
|
|
2613
|
+
clientX: e.clientX,
|
|
2614
|
+
clientY: e.clientY,
|
|
2615
|
+
deltaX: e.deltaX,
|
|
2616
|
+
deltaY: e.deltaY,
|
|
2617
|
+
deltaMode: e.deltaMode,
|
|
2618
|
+
ctrlKey: e.ctrlKey,
|
|
2619
|
+
metaKey: e.metaKey,
|
|
2620
|
+
shiftKey: e.shiftKey,
|
|
2621
|
+
};
|
|
2622
|
+
} else {
|
|
2623
|
+
pending.deltaX += e.deltaX;
|
|
2624
|
+
pending.deltaY += e.deltaY;
|
|
2625
|
+
pending.clientX = e.clientX;
|
|
2626
|
+
pending.clientY = e.clientY;
|
|
2627
|
+
}
|
|
2628
|
+
if (raf === 0) raf = requestAnimationFrame(flush);
|
|
2629
|
+
};
|
|
2630
|
+
el.addEventListener("wheel", onWheelNative, { passive: false });
|
|
2631
|
+
return () => {
|
|
2632
|
+
if (raf !== 0) cancelAnimationFrame(raf);
|
|
2633
|
+
el.removeEventListener("wheel", onWheelNative);
|
|
2634
|
+
};
|
|
2635
|
+
}, []);
|
|
2636
|
+
|
|
2637
|
+
const laidOut = viewport.width > 0 && viewport.height > 0;
|
|
2638
|
+
const offset =
|
|
2639
|
+
laidOut && panes.camera !== null ? viewOffsetFor(view, panes.camera, size) : null;
|
|
2640
|
+
|
|
2641
|
+
// The overlay's derived props, IDENTITY-STABLE across everything that does
|
|
2642
|
+
// not move them — a seek re-renders this component, and fresh objects here
|
|
2643
|
+
// would defeat MemoOverlays entirely. The guides are almost always empty,
|
|
2644
|
+
// and empty must be the SAME empty every render.
|
|
2645
|
+
const overlayFrame = useMemo(
|
|
2646
|
+
() => frameRect(view, cameraViewport, size),
|
|
2647
|
+
[view, cameraViewport, size.width, size.height],
|
|
2648
|
+
);
|
|
2649
|
+
const overlayGuides = useMemo(
|
|
2650
|
+
() =>
|
|
2651
|
+
state.guides.length === 0
|
|
2652
|
+
? EMPTY_GUIDES
|
|
2653
|
+
: state.guides.map((g) => ({
|
|
2654
|
+
axis: g.axis,
|
|
2655
|
+
// Design FRAME coordinates arrive with the origin at the canvas
|
|
2656
|
+
// centre, the way the schema measures; `designToScreen` wants them
|
|
2657
|
+
// from the frame's top-left. Measured against the CAMERA PANE,
|
|
2658
|
+
// because that is the viewport its projection was computed for.
|
|
2659
|
+
at:
|
|
2660
|
+
g.axis === "x"
|
|
2661
|
+
? designToScreen([size.width / 2 + g.at, 0], view, cameraViewport, size)[0]
|
|
2662
|
+
: designToScreen([0, size.height / 2 + g.at], view, cameraViewport, size)[1],
|
|
2663
|
+
})),
|
|
2664
|
+
[state.guides, view, cameraViewport, size.width, size.height],
|
|
2665
|
+
);
|
|
2666
|
+
// Where the playhead lands in the document, resolved ONCE. The post stack and
|
|
2667
|
+
// the camera rig both hang off it, and both are read out here rather than
|
|
2668
|
+
// inside the canvas because the playhead and the document live out here —
|
|
2669
|
+
// neither consumer may hold its own copy of either.
|
|
2670
|
+
// Every derivation below rides the SAME size-1 caches ShotRuntime reads
|
|
2671
|
+
// (frameCache.ts), so the stage and the runtime cannot compute the frame
|
|
2672
|
+
// twice — the second caller replays the first one's answer.
|
|
2673
|
+
const effects = at?.scene.effects ?? null;
|
|
2674
|
+
const defs = useMemo(() => (doc === null ? {} : componentDefsOfCached(doc)), [doc]);
|
|
2675
|
+
const hasMesh = at === null ? false : sceneHasMeshCached(at.scene, defs);
|
|
2676
|
+
// The bin partition, from the same resolved position as the post stack. Both
|
|
2677
|
+
// panes draw through it (SplitRenderer), so the editor cannot disagree with
|
|
2678
|
+
// the export about what a model covers. Empty when nothing is on screen AND
|
|
2679
|
+
// for every scene without a live mesh layer — `renderBins` treats empty and
|
|
2680
|
+
// single identically (one plain draw), and gating on `hasMesh` here matches
|
|
2681
|
+
// exactly what ShotScene computes, which is what lets the cache be shared.
|
|
2682
|
+
// The definitions ride into both: a def-blind partition leaves the mesh bin
|
|
2683
|
+
// closed for a scene whose only model is inside a component, and the two
|
|
2684
|
+
// panes would then disagree with the export about what a model covers.
|
|
2685
|
+
const bins = useMemo(
|
|
2686
|
+
() => (at === null || !hasMesh ? EMPTY_BINS : binsAtCached(at.scene, at.sceneFrame, defs)),
|
|
2687
|
+
[at, defs, hasMesh],
|
|
2688
|
+
);
|
|
2689
|
+
// The pill's stable prop — built from the modal STATE alone, so MemoOverlays
|
|
2690
|
+
// re-renders on start/axis/end and never on mouse motion.
|
|
2691
|
+
const modalBadge = useMemo<ScreenModal | null>(
|
|
2692
|
+
() =>
|
|
2693
|
+
modal === null
|
|
2694
|
+
? null
|
|
2695
|
+
: {
|
|
2696
|
+
label: modalLabel(modal),
|
|
2697
|
+
camera: modal.target === "camera",
|
|
2698
|
+
// The glyph and its axis tint. Both ride the same memo as the label
|
|
2699
|
+
// because both are modal STATE — they change when the author arms a
|
|
2700
|
+
// mode or presses an axis key, never when the pointer moves.
|
|
2701
|
+
mode: modal.mode,
|
|
2702
|
+
axis: modal.axis,
|
|
2703
|
+
},
|
|
2704
|
+
[modal],
|
|
2705
|
+
);
|
|
2706
|
+
|
|
2707
|
+
/**
|
|
2708
|
+
* The corner axis indicator, which is a pure function of the view's two
|
|
2709
|
+
* angles — so it re-derives exactly when the working pane's picture does,
|
|
2710
|
+
* and never on a seek or a selection change.
|
|
2711
|
+
*
|
|
2712
|
+
* A click SNAPS, through the same `presetWorkingView` the numpad keys go
|
|
2713
|
+
* through and with the same origin an orbit uses: with follow armed, snapping
|
|
2714
|
+
* the view is a camera write, which is what follow means.
|
|
2715
|
+
*/
|
|
2716
|
+
/**
|
|
2717
|
+
* A press on the nav gizmo: DRAG TO ORBIT, click to snap.
|
|
2718
|
+
*
|
|
2719
|
+
* The identical lane an orbit on empty space runs — same `dragGesture`, same
|
|
2720
|
+
* `DRAG_THRESHOLD_PX`, same follow bracketing, same Escape restore — so the
|
|
2721
|
+
* widget cannot develop its own idea of what turning the view means. It only
|
|
2722
|
+
* SNAPS when the travel never crossed the threshold, which is what makes one
|
|
2723
|
+
* gesture serve both.
|
|
2724
|
+
*
|
|
2725
|
+
* The event is stopped HERE and not in the overlay: the overlay would have to
|
|
2726
|
+
* stop it to keep the container's own lane out, and then there would be no
|
|
2727
|
+
* event left to open a drag with.
|
|
2728
|
+
*/
|
|
2729
|
+
const grabNav = useCallback(
|
|
2730
|
+
(e: React.PointerEvent, ball: NavBall | null) => {
|
|
2731
|
+
e.stopPropagation();
|
|
2732
|
+
e.preventDefault();
|
|
2733
|
+
const el = areaRef.current;
|
|
2734
|
+
if (el === null) return;
|
|
2735
|
+
const start = localPoint(e);
|
|
2736
|
+
const from = client.getState().workingView;
|
|
2737
|
+
const following = client.getState().cameraFollowsView;
|
|
2738
|
+
let dragged = false;
|
|
2739
|
+
el.setPointerCapture(e.pointerId);
|
|
2740
|
+
dragGesture(
|
|
2741
|
+
el,
|
|
2742
|
+
e.pointerId,
|
|
2743
|
+
(ev) => {
|
|
2744
|
+
const at = localPoint(ev);
|
|
2745
|
+
const dx = at[0] - start[0];
|
|
2746
|
+
const dy = at[1] - start[1];
|
|
2747
|
+
if (!dragged && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
2748
|
+
// The travel gate is where a follow scrub opens, for the reason it
|
|
2749
|
+
// exists at all: a click must not cost an undo entry.
|
|
2750
|
+
if (!dragged && following) client.handlers()?.onFramingGestureStart();
|
|
2751
|
+
dragged = true;
|
|
2752
|
+
setWorkingView(orbitBy(from, dx, dy));
|
|
2753
|
+
},
|
|
2754
|
+
(commit) => {
|
|
2755
|
+
if (!dragged) {
|
|
2756
|
+
// A CLICK. The disc has no axis to snap to, so it does nothing —
|
|
2757
|
+
// which is right: its whole job is the drag.
|
|
2758
|
+
if (commit && ball !== null) setWorkingView(viewForBall(from, ball));
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
if (!commit) setWorkingView(from);
|
|
2762
|
+
if (following) client.handlers()?.onFramingGestureEnd(commit);
|
|
2763
|
+
},
|
|
2764
|
+
);
|
|
2765
|
+
},
|
|
2766
|
+
[client, setWorkingView],
|
|
2767
|
+
);
|
|
2768
|
+
/**
|
|
2769
|
+
* The selected toon mesh, or null. The whole gate for the key-light handle.
|
|
2770
|
+
*
|
|
2771
|
+
* ONE layer, because a key direction is one value and a handle over a
|
|
2772
|
+
* multi-selection would silently write it to whichever member it felt like.
|
|
2773
|
+
* TOON only, because `keyDir` is inert under `unlit` — a handle that moved a
|
|
2774
|
+
* number nothing reads is worse than no handle. And never while the derived
|
|
2775
|
+
* isolation rig is drawing, for the nav gizmo's reason: the arm is projected
|
|
2776
|
+
* through `workingView`'s basis, which nothing is using then.
|
|
2777
|
+
*/
|
|
2778
|
+
const keyLightLayer = useMemo(() => {
|
|
2779
|
+
if (panes.working === null || headOnRig !== null || state.selection.length !== 1) return null;
|
|
2780
|
+
const id = state.selection[0]!;
|
|
2781
|
+
const layer = at?.scene.layersById[id];
|
|
2782
|
+
if (layer === undefined || layer.kind !== "mesh") return null;
|
|
2783
|
+
if (layer.mesh.shading?.mode !== "toon") return null;
|
|
2784
|
+
return { id, dir: layer.mesh.shading.keyDir ?? DEFAULT_KEY_DIR };
|
|
2785
|
+
}, [panes.working, headOnRig, state.selection, at]);
|
|
2786
|
+
|
|
2787
|
+
/**
|
|
2788
|
+
* Drag the key. The gesture trio's shape, and the nav gizmo's plumbing.
|
|
2789
|
+
*
|
|
2790
|
+
* Deltas are measured FROM THE PRESS against the direction at the press, so a
|
|
2791
|
+
* coalesced drag lands exactly where an uncoalesced one does and Escape
|
|
2792
|
+
* restores by discarding rather than by replaying.
|
|
2793
|
+
*/
|
|
2794
|
+
const grabKeyLight = useCallback(
|
|
2795
|
+
(e: React.PointerEvent) => {
|
|
2796
|
+
// Stopped HERE and not in the overlay, for `grabNav`'s reason: the
|
|
2797
|
+
// overlay would have to stop it to keep the container's lane out, and
|
|
2798
|
+
// then there would be no event left to open a drag with.
|
|
2799
|
+
e.stopPropagation();
|
|
2800
|
+
e.preventDefault();
|
|
2801
|
+
const el = areaRef.current;
|
|
2802
|
+
const light = keyLightLayer;
|
|
2803
|
+
if (el === null || light === null) return;
|
|
2804
|
+
const start = localPoint(e);
|
|
2805
|
+
const from = light.dir;
|
|
2806
|
+
const view = client.getState().workingView;
|
|
2807
|
+
let opened = false;
|
|
2808
|
+
el.setPointerCapture(e.pointerId);
|
|
2809
|
+
dragGesture(
|
|
2810
|
+
el,
|
|
2811
|
+
e.pointerId,
|
|
2812
|
+
(ev) => {
|
|
2813
|
+
const at2 = localPoint(ev);
|
|
2814
|
+
const dx = at2[0] - start[0];
|
|
2815
|
+
const dy = at2[1] - start[1];
|
|
2816
|
+
if (!opened && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
2817
|
+
if (!opened) {
|
|
2818
|
+
// The travel gate is where the scrub opens, for the reason it
|
|
2819
|
+
// exists at all: a click must not cost an undo entry. A refusal
|
|
2820
|
+
// here abandons the drag rather than emitting into nothing.
|
|
2821
|
+
if (client.handlers()?.onKeyLightStart(light.id) !== true) return;
|
|
2822
|
+
opened = true;
|
|
2823
|
+
}
|
|
2824
|
+
client.handlers()?.onKeyLight(rotateKeyDir(from, view, dx, dy));
|
|
2825
|
+
},
|
|
2826
|
+
(commit) => {
|
|
2827
|
+
if (!opened) return;
|
|
2828
|
+
client.handlers()?.onKeyLightEnd(commit);
|
|
2829
|
+
},
|
|
2830
|
+
);
|
|
2831
|
+
},
|
|
2832
|
+
[client, keyLightLayer],
|
|
2833
|
+
);
|
|
2834
|
+
|
|
2835
|
+
const keyLightArm = useMemo(
|
|
2836
|
+
() =>
|
|
2837
|
+
keyLightLayer === null
|
|
2838
|
+
? null
|
|
2839
|
+
: { layerId: keyLightLayer.id, dir: keyDirOnScreen(keyLightLayer.dir, workingView), onGrab: grabKeyLight },
|
|
2840
|
+
[keyLightLayer, workingView, grabKeyLight],
|
|
2841
|
+
);
|
|
2842
|
+
|
|
2843
|
+
const nav = useMemo(
|
|
2844
|
+
// HIDDEN WHILE THE DERIVED RIG DRAWS. The balls are a projection of
|
|
2845
|
+
// `workingView`'s basis, and nothing is using that basis then — so the
|
|
2846
|
+
// widget drew one camera's axes over another camera's picture, offered
|
|
2847
|
+
// `cursor: pointer`, and did nothing when clicked. Once the author has
|
|
2848
|
+
// taken the camera it is correct again and its clicks land.
|
|
2849
|
+
() =>
|
|
2850
|
+
panes.working === null || headOnRig !== null
|
|
2851
|
+
? null
|
|
2852
|
+
: { balls: navBalls(workingView), onGrab: grabNav },
|
|
2853
|
+
[panes.working, headOnRig, workingView, grabNav],
|
|
2854
|
+
);
|
|
2855
|
+
|
|
2856
|
+
// A transition window mounts its own priority-1 compositor inside
|
|
2857
|
+
// ShotRuntime, so the probe has to count it too — the comment below promises
|
|
2858
|
+
// this expression mirrors EXACTLY when a priority-1 renderer exists, and a
|
|
2859
|
+
// probe that claims priority when one already has it is the bug it guards.
|
|
2860
|
+
const inTransition = doc !== null && transitionAtCached(doc, state.frame) !== null;
|
|
2861
|
+
// The gizmo's pose. `cameraRigAt` is the ONE answer to "where is the camera
|
|
2862
|
+
// at this frame", including the dolly-vs-zoom distinction — deriving it a
|
|
2863
|
+
// second time inside the gizmo would be a second camera model.
|
|
2864
|
+
const rig = useMemo(
|
|
2865
|
+
() => (at === null || doc === null ? null : cameraRigAtCached(at.scene, doc.size.height, at.sceneFrame, doc.fps)),
|
|
2866
|
+
[at, doc],
|
|
2867
|
+
);
|
|
2868
|
+
|
|
2869
|
+
/** Where the camera IS, for the arms to hang on when nothing is selected.
|
|
2870
|
+
* Off `rig` through the same `frustumShape` the gizmo's own vertices come
|
|
2871
|
+
* from, so the arms cannot sit anywhere but on the drawn apex. */
|
|
2872
|
+
const cameraApex = useMemo<Vec3 | null>(
|
|
2873
|
+
() => (rig === null ? null : frustumShape(rig, size).apex),
|
|
2874
|
+
[rig, size],
|
|
2875
|
+
);
|
|
2876
|
+
|
|
2877
|
+
return (
|
|
2878
|
+
<div
|
|
2879
|
+
ref={areaRef}
|
|
2880
|
+
data-shot-area
|
|
2881
|
+
// No `onWheel` prop: React would register it passive and the browser
|
|
2882
|
+
// would page-zoom alongside us. It is added by hand above.
|
|
2883
|
+
onPointerDown={onPointerDown}
|
|
2884
|
+
// GO INSIDE whatever is under the cursor. A React prop, like the hover
|
|
2885
|
+
// below and for the same reason: it opens no gesture and holds no state,
|
|
2886
|
+
// so it is not one of the hand-rolled lanes `sourceHygiene.test.ts`
|
|
2887
|
+
// holds to one registration each.
|
|
2888
|
+
//
|
|
2889
|
+
// The pointerdown lane has already run and selected, which is the
|
|
2890
|
+
// intended compound — the author sees what they are about to enter
|
|
2891
|
+
// highlighted before they enter it.
|
|
2892
|
+
onDoubleClick={onDoubleClick}
|
|
2893
|
+
// THE PEN'S HOVER, and nothing else's. A React prop rather than an
|
|
2894
|
+
// `addEventListener`, deliberately: the hand-rolled pointer lanes in this
|
|
2895
|
+
// file are held to one registration each (`sourceHygiene.test.ts`)
|
|
2896
|
+
// because a lane that rolls its own move/up pair has no `pointercancel`
|
|
2897
|
+
// and leaks a history scrub. This listener opens no gesture and holds no
|
|
2898
|
+
// state, so React's own teardown is the whole story.
|
|
2899
|
+
//
|
|
2900
|
+
// Without it the pen's rubber band and its close highlight have no input
|
|
2901
|
+
// at all: `cursor` would only advance while a button was held, so the
|
|
2902
|
+
// author would never see that the next click closes the loop.
|
|
2903
|
+
onPointerMove={onPointerHover}
|
|
2904
|
+
// The cursor box, kept honest on RELEASE too. React props rather than
|
|
2905
|
+
// `addEventListener` for the reason the hover prop states: they open no
|
|
2906
|
+
// gesture and hold no state, so React's own teardown is the whole story
|
|
2907
|
+
// and the hand-rolled-lane rule in sourceHygiene.test.ts is untouched.
|
|
2908
|
+
onPointerUp={onPointerTrack}
|
|
2909
|
+
onPointerCancel={onPointerTrack}
|
|
2910
|
+
onLostPointerCapture={onPointerTrack}
|
|
2911
|
+
// The cursor LEFT. Same posture as the props above — no gesture, no
|
|
2912
|
+
// state of its own — and it is what keeps a modal from being armed by a
|
|
2913
|
+
// keystroke aimed at the chrome, or stranded there once it is.
|
|
2914
|
+
onPointerLeave={onPointerLeave}
|
|
2915
|
+
// ALWAYS. The browser's own menu over a 3D canvas offers "save image"
|
|
2916
|
+
// and "reload" and nothing an author of a shot could want, and every
|
|
2917
|
+
// right press now has a meaning of its own: it opens the stage's menu,
|
|
2918
|
+
// or it cancels a modal. The one-shot suppressor this replaced existed
|
|
2919
|
+
// only for the cancel case; a rule with no exceptions needs no flag.
|
|
2920
|
+
onContextMenu={(e) => {
|
|
2921
|
+
e.preventDefault();
|
|
2922
|
+
}}
|
|
2923
|
+
style={{
|
|
2924
|
+
position: "fixed",
|
|
2925
|
+
inset: 0,
|
|
2926
|
+
overflow: "hidden",
|
|
2927
|
+
background: "var(--stage-backdrop, #0e0e10)",
|
|
2928
|
+
// NO `cursor` HERE. It is written imperatively by `setCursor`, and a
|
|
2929
|
+
// second writer React re-applies on every commit makes the right cursor
|
|
2930
|
+
// intermittent. See `setCursor`.
|
|
2931
|
+
touchAction: "none",
|
|
2932
|
+
}}
|
|
2933
|
+
>
|
|
2934
|
+
{doc === null || !laidOut ? null : (
|
|
2935
|
+
<>
|
|
2936
|
+
<ShotRuntime
|
|
2937
|
+
doc={doc}
|
|
2938
|
+
frame={state.frame}
|
|
2939
|
+
viewport={viewport}
|
|
2940
|
+
viewOffset={offset}
|
|
2941
|
+
// Only while the working pane is on screen. In camera mode the
|
|
2942
|
+
// runtime presents itself exactly as an export does — header.
|
|
2943
|
+
renderControl={ownsPresent ? "external" : "default"}
|
|
2944
|
+
// Quantised to the ladder, never the raw zoom: the raster cache
|
|
2945
|
+
// keys on this value, so a continuous zoom would re-raster the
|
|
2946
|
+
// whole document per wheel event. The ladder's top rung is the
|
|
2947
|
+
// same 4x cap this line used to spell out by hand.
|
|
2948
|
+
rasterScale={quantizeRasterScale(clampZoom(view.zoom))}
|
|
2949
|
+
// A far layer costs what it looks like, not what it was authored
|
|
2950
|
+
// at. The editor only: an export names its own scale and never
|
|
2951
|
+
// reaches the branch, which is what keeps preview === export a
|
|
2952
|
+
// property of the code rather than a rule someone remembers.
|
|
2953
|
+
adaptiveRaster
|
|
2954
|
+
instances={instances.instances}
|
|
2955
|
+
instanceContracts={instances.contracts}
|
|
2956
|
+
{...(isolated === null ? {} : { dim: { keep: isolated, opacity: DIM_OPACITY } })}
|
|
2957
|
+
onLayerGraph={bumpSceneEpoch}
|
|
2958
|
+
onMeshStats={pushMeshStats}
|
|
2959
|
+
onReady={onReady}
|
|
2960
|
+
>
|
|
2961
|
+
<StageTools
|
|
2962
|
+
feed={feed}
|
|
2963
|
+
panes={panes}
|
|
2964
|
+
workingView={workingView}
|
|
2965
|
+
selection={state.selection}
|
|
2966
|
+
layers={at?.scene ?? null}
|
|
2967
|
+
sceneFrame={at?.sceneFrame ?? 0}
|
|
2968
|
+
isolationRoot={isolated === null ? null : state.isolation}
|
|
2969
|
+
isolated={isolated}
|
|
2970
|
+
// The DERIVED rig, so a null here is the author having taken the
|
|
2971
|
+
// camera and `StageTools` falling back to the orbit one with no
|
|
2972
|
+
// branch of its own to keep in step.
|
|
2973
|
+
isolationView={headOnRig}
|
|
2974
|
+
/>
|
|
2975
|
+
{/* presenterMounted mirrors exactly the conditions under which a
|
|
2976
|
+
priority-1 renderer exists on this canvas: SplitRenderer when
|
|
2977
|
+
a working pane owns the present, or ShotRuntime's own
|
|
2978
|
+
ScenePresenter (post chain or mesh bin). See PerfProbe for why
|
|
2979
|
+
the probe's priority must not be positive when neither is. */}
|
|
2980
|
+
{PERF_HUD_ENABLED ? (
|
|
2981
|
+
<PerfProbe
|
|
2982
|
+
box={perfBox}
|
|
2983
|
+
presenterMounted={
|
|
2984
|
+
ownsPresent ||
|
|
2985
|
+
hasMesh ||
|
|
2986
|
+
(effects !== null && hasPostChain(effects)) ||
|
|
2987
|
+
inTransition
|
|
2988
|
+
}
|
|
2989
|
+
/>
|
|
2990
|
+
) : null}
|
|
2991
|
+
{ownsPresent ? (
|
|
2992
|
+
<>
|
|
2993
|
+
{/* The working pane's placement aids. Mounted with the
|
|
2994
|
+
renderer that draws them and nowhere else: all three are
|
|
2995
|
+
EDITOR_LAYER objects the shot camera cannot see, so in
|
|
2996
|
+
camera-only mode they would be scene-graph nodes nothing
|
|
2997
|
+
ever renders. */}
|
|
2998
|
+
{/* ── BOTH CAMERA AIDS ARE SCOPED OUT WHILE ISOLATED ──────
|
|
2999
|
+
The grid IS the design z = 0 plane, and a card at z = 400
|
|
3000
|
+
seen head-on has it running across the pane as a wall at an
|
|
3001
|
+
unrelated angle. The frustum is the SHOT camera drawn as an
|
|
3002
|
+
object, and inside a subtree it frames something the pane
|
|
3003
|
+
cannot show. Worse, with an empty selection `modalTarget`
|
|
3004
|
+
retargets the move arms at that camera, so a drag would
|
|
3005
|
+
write `framing` from a view with no relation to it. */}
|
|
3006
|
+
{isolated === null ? (
|
|
3007
|
+
<GridPlane frame={size} target={workingView.target} distance={workingView.distance} />
|
|
3008
|
+
) : null}
|
|
3009
|
+
<MoveGizmo
|
|
3010
|
+
feed={feed}
|
|
3011
|
+
panes={panes}
|
|
3012
|
+
selection={state.selection}
|
|
3013
|
+
cameraApex={isolated === null ? cameraApex : null}
|
|
3014
|
+
/>
|
|
3015
|
+
{isolated === null ? (
|
|
3016
|
+
<FrustumGizmo feed={feed} panes={panes} rig={rig} frame={size} />
|
|
3017
|
+
) : null}
|
|
3018
|
+
<SplitRenderer
|
|
3019
|
+
feed={feed}
|
|
3020
|
+
panes={panes}
|
|
3021
|
+
effects={effects}
|
|
3022
|
+
bins={bins}
|
|
3023
|
+
hasMesh={hasMesh}
|
|
3024
|
+
frame={state.frame}
|
|
3025
|
+
shading={state.shading}
|
|
3026
|
+
/>
|
|
3027
|
+
</>
|
|
3028
|
+
) : null}
|
|
3029
|
+
</ShotRuntime>
|
|
3030
|
+
<OverlaysHost
|
|
3031
|
+
feed={feed}
|
|
3032
|
+
width={viewport.width}
|
|
3033
|
+
height={viewport.height}
|
|
3034
|
+
panes={panes}
|
|
3035
|
+
frame={overlayFrame}
|
|
3036
|
+
hover={hover}
|
|
3037
|
+
followsView={follows}
|
|
3038
|
+
isolation={isolationLabel}
|
|
3039
|
+
marquee={marquee}
|
|
3040
|
+
pen={penScreen}
|
|
3041
|
+
guides={overlayGuides}
|
|
3042
|
+
designGuides={state.guides}
|
|
3043
|
+
size={size}
|
|
3044
|
+
modal={modalBadge}
|
|
3045
|
+
nav={nav}
|
|
3046
|
+
keyLightArm={keyLightArm}
|
|
3047
|
+
inset={state.inset}
|
|
3048
|
+
/>
|
|
3049
|
+
{PERF_HUD_ENABLED ? (
|
|
3050
|
+
<PerfHud box={perfBox} layers={at === null ? 0 : Object.keys(at.scene.layersById).length} />
|
|
3051
|
+
) : null}
|
|
3052
|
+
</>
|
|
3053
|
+
)}
|
|
3054
|
+
</div>
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
/**
|
|
3059
|
+
* The world DESIGN point a multi-selection scales away from: the mean of its
|
|
3060
|
+
* members' origins, at the frame on screen.
|
|
3061
|
+
*
|
|
3062
|
+
* NULL FOR ONE LAYER, and that is not an optimisation. One layer's median is
|
|
3063
|
+
* its own origin, so the offset from it is zero and the position never moves —
|
|
3064
|
+
* which is exactly what `pivot: null` means downstream, said without arithmetic
|
|
3065
|
+
* that could round.
|
|
3066
|
+
*
|
|
3067
|
+
* The MEAN and not the bounding box's centre: `project.ts` and `Overlays` both
|
|
3068
|
+
* state that a screen bounding box lies about tilt, and the same is true in the
|
|
3069
|
+
* world. A mean of origins is a real point that needs no box.
|
|
3070
|
+
*
|
|
3071
|
+
* Off `matrixWorld`, whose 13th to 15th elements are the translation column, so
|
|
3072
|
+
* the one mapping (design y down, z away) is undone on the way out.
|
|
3073
|
+
*/
|
|
3074
|
+
/**
|
|
3075
|
+
* A pane-local point, held inside its pane.
|
|
3076
|
+
*
|
|
3077
|
+
* ── AT THE SOURCE, IN ONE PLACE ─────────────────────────────────────────
|
|
3078
|
+
*
|
|
3079
|
+
* The overlay CLIPS its bands to the pane, so a drag that crosses the divider
|
|
3080
|
+
* draws a clipped view of a larger invisible rectangle — and the lane behind it
|
|
3081
|
+
* then acts on the invisible one. The marquee selected against it; the draw
|
|
3082
|
+
* tools inserted a layer sized to it. Both are the same bug and now share the
|
|
3083
|
+
* same clamp, so the rectangle drawn and the rectangle acted on are one
|
|
3084
|
+
* rectangle by construction rather than by two clips agreeing.
|
|
3085
|
+
*/
|
|
3086
|
+
function clampInPane(rect: PaneRect, x: number, y: number): [number, number] {
|
|
3087
|
+
return [Math.max(0, Math.min(rect.width, x)), Math.max(0, Math.min(rect.height, y))];
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
/**
|
|
3091
|
+
* The union box's world corners, as the DESIGN rectangle the scale measures in.
|
|
3092
|
+
*
|
|
3093
|
+
* `unionQuad` only answers for a box whose edges run along the world axes, so
|
|
3094
|
+
* the two opposite corners carry the whole rectangle and `(x, -y)` is the only
|
|
3095
|
+
* conversion needed. TL is `quad[0]` and BR is `quad[2]`, and world +y is up, so
|
|
3096
|
+
* the flip is what turns the world's TOP corner into design's SMALLEST y.
|
|
3097
|
+
*/
|
|
3098
|
+
function designBox(union: WorldQuad | undefined): DesignBox | null {
|
|
3099
|
+
if (union === undefined) return null;
|
|
3100
|
+
return { x0: union[0].x, y0: -union[0].y, x1: union[2].x, y1: -union[2].y };
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
function selectionPivot(feed: StageFeed, selection: readonly string[]): Vec3 | null {
|
|
3104
|
+
if (selection.length < 2) return null;
|
|
3105
|
+
let n = 0;
|
|
3106
|
+
let x = 0;
|
|
3107
|
+
let y = 0;
|
|
3108
|
+
let z = 0;
|
|
3109
|
+
// One frame per id, the FIRST — the same rule `StageTools`' `byId` follows. A
|
|
3110
|
+
// cloner stamps its children N times under one id, and averaging every clone
|
|
3111
|
+
// would put the pivot in the empty space between them.
|
|
3112
|
+
const seen = new Set<string>();
|
|
3113
|
+
for (const frame of feed.frames ?? []) {
|
|
3114
|
+
if (!selection.includes(frame.id) || seen.has(frame.id)) continue;
|
|
3115
|
+
seen.add(frame.id);
|
|
3116
|
+
const e = frame.matrixWorld.elements;
|
|
3117
|
+
x += e[12]!;
|
|
3118
|
+
y += e[13]!;
|
|
3119
|
+
z += e[14]!;
|
|
3120
|
+
n += 1;
|
|
3121
|
+
}
|
|
3122
|
+
// Fewer than two ON SCREEN is the same case as fewer than two selected: a
|
|
3123
|
+
// layer outside its lifespan at this frame has no position to average.
|
|
3124
|
+
return n < 2 ? null : [x / n, -(y / n), -(z / n)];
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
/** A three `Ray` into the plain numbers `axisParam` takes. The one place the
|
|
3128
|
+
* gizmo lane crosses from three into the three-free maths module, so nothing
|
|
3129
|
+
* in gizmoMath.ts has to know what a Ray is. `Raycaster` hands out a
|
|
3130
|
+
* normalized direction, which is what the unit-direction solve assumes. */
|
|
3131
|
+
function paramAt(arm: GizmoGrab, ray: Ray): number | null {
|
|
3132
|
+
return axisParam(
|
|
3133
|
+
arm.origin,
|
|
3134
|
+
arm.direction,
|
|
3135
|
+
[ray.origin.x, ray.origin.y, ray.origin.z],
|
|
3136
|
+
[ray.direction.x, ray.direction.y, ray.direction.z],
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
/**
|
|
3141
|
+
* The pointer plumbing every committing drag in this file shares.
|
|
3142
|
+
*
|
|
3143
|
+
* THREE THINGS MAKE A DRAG ONE UNDO ENTRY AND EXACTLY REVERSIBLE, and two of
|
|
3144
|
+
* them are here: the gesture ends exactly once however it ends, and Escape ends
|
|
3145
|
+
* it with `commit: false`. The third is the caller's, and is the reason every
|
|
3146
|
+
* `move` above measures FROM THE PRESS: the scrub re-derives from its base.
|
|
3147
|
+
*
|
|
3148
|
+
* Module scope, so it closes over nothing and EVERY caller in this file gets
|
|
3149
|
+
* the same teardown — which is how one of them ends up leaking a `keydown`
|
|
3150
|
+
* listener that fires against a finished gesture, or registering `pointerup`
|
|
3151
|
+
* alone and leaving a history scrub open forever when the browser cancels the
|
|
3152
|
+
* pointer instead of releasing it.
|
|
3153
|
+
*
|
|
3154
|
+
* `end` is handed the event that ENDED the drag, or null when nothing did
|
|
3155
|
+
* (Escape, `pointercancel`). The marquee lane needs it: the final point and the
|
|
3156
|
+
* modifiers exist only on that event, and a `commit` boolean cannot carry them.
|
|
3157
|
+
*/
|
|
3158
|
+
function dragGesture(
|
|
3159
|
+
el: HTMLDivElement,
|
|
3160
|
+
pointerId: number,
|
|
3161
|
+
move: (ev: PointerEvent) => void,
|
|
3162
|
+
end: (commit: boolean, ev: PointerEvent | null) => void,
|
|
3163
|
+
): void {
|
|
3164
|
+
let live = true;
|
|
3165
|
+
// MOVES ARE COALESCED TO THE FRAME, last event wins. A pointermove costs a
|
|
3166
|
+
// full stage commit and a GL draw (and, for a document-writing gesture, a
|
|
3167
|
+
// fresh Doc3D and a chrome-wide notification), while the display can only
|
|
3168
|
+
// show one result per frame — so applying every event buys nothing and
|
|
3169
|
+
// stalls the thread exactly when the author is watching most closely.
|
|
3170
|
+
// Deltas are measured FROM THE PRESS by every caller, so dropping
|
|
3171
|
+
// intermediate events cannot make a drag drift; the LAST event always
|
|
3172
|
+
// applies, on the frame or at the release below.
|
|
3173
|
+
let pending: PointerEvent | null = null;
|
|
3174
|
+
let raf = 0;
|
|
3175
|
+
const flush = (): void => {
|
|
3176
|
+
raf = 0;
|
|
3177
|
+
if (!live || pending === null) return;
|
|
3178
|
+
const ev = pending;
|
|
3179
|
+
pending = null;
|
|
3180
|
+
move(ev);
|
|
3181
|
+
};
|
|
3182
|
+
const onMove = (ev: PointerEvent): void => {
|
|
3183
|
+
pending = ev;
|
|
3184
|
+
if (raf === 0) raf = requestAnimationFrame(flush);
|
|
3185
|
+
};
|
|
3186
|
+
const finish = (commit: boolean, ev: PointerEvent | null): void => {
|
|
3187
|
+
if (!live) return;
|
|
3188
|
+
live = false;
|
|
3189
|
+
if (raf !== 0) cancelAnimationFrame(raf);
|
|
3190
|
+
// The FINAL position always lands before the end: a move still pending at
|
|
3191
|
+
// release would otherwise drop the last frame of travel and the layer
|
|
3192
|
+
// would land where the pointer was a frame ago. A discard skips it — the
|
|
3193
|
+
// gesture is being thrown away.
|
|
3194
|
+
if (commit && pending !== null) {
|
|
3195
|
+
const last = pending;
|
|
3196
|
+
pending = null;
|
|
3197
|
+
move(last);
|
|
3198
|
+
}
|
|
3199
|
+
pending = null;
|
|
3200
|
+
el.removeEventListener("pointermove", onMove);
|
|
3201
|
+
el.removeEventListener("pointerup", up);
|
|
3202
|
+
el.removeEventListener("pointercancel", cancel);
|
|
3203
|
+
window.removeEventListener("keydown", onKey);
|
|
3204
|
+
try {
|
|
3205
|
+
el.releasePointerCapture(pointerId);
|
|
3206
|
+
} catch {
|
|
3207
|
+
/* already released */
|
|
3208
|
+
}
|
|
3209
|
+
end(commit, ev);
|
|
3210
|
+
};
|
|
3211
|
+
const up = (ev: PointerEvent): void => finish(true, ev);
|
|
3212
|
+
const cancel = (): void => finish(false, null);
|
|
3213
|
+
const onKey = (ev: KeyboardEvent): void => {
|
|
3214
|
+
if (ev.key !== "Escape") return;
|
|
3215
|
+
ev.preventDefault();
|
|
3216
|
+
finish(false, null);
|
|
3217
|
+
};
|
|
3218
|
+
el.addEventListener("pointermove", onMove);
|
|
3219
|
+
el.addEventListener("pointerup", up);
|
|
3220
|
+
el.addEventListener("pointercancel", cancel);
|
|
3221
|
+
window.addEventListener("keydown", onKey);
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
/**
|
|
3225
|
+
* Design px from the frame's TOP-LEFT (what `screenToDesign` answers) into
|
|
3226
|
+
* design px from the CANVAS CENTRE (what the schema stores).
|
|
3227
|
+
*
|
|
3228
|
+
* One conversion, at the boundary, in the direction the guides already cross in
|
|
3229
|
+
* the other — see the `designToScreen` call the overlay makes for them. Doing
|
|
3230
|
+
* it in the chrome instead would mean the chrome deriving canvas geometry it
|
|
3231
|
+
* only half knows.
|
|
3232
|
+
*/
|
|
3233
|
+
function centreOrigin(
|
|
3234
|
+
point: readonly [number, number],
|
|
3235
|
+
frame: { width: number; height: number },
|
|
3236
|
+
): [number, number] {
|
|
3237
|
+
return [point[0] - frame.width / 2, point[1] - frame.height / 2];
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
function durationOf(doc: {
|
|
3241
|
+
scenes: readonly string[];
|
|
3242
|
+
scenesById: Record<string, { durationFrames: number }>;
|
|
3243
|
+
}): number {
|
|
3244
|
+
let total = 0;
|
|
3245
|
+
for (const id of doc.scenes) total += Math.max(1, Math.trunc(doc.scenesById[id]?.durationFrames ?? 0));
|
|
3246
|
+
return total;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
/**
|
|
3250
|
+
* Every instance layer's component and declared contract, re-resolved when the
|
|
3251
|
+
* set of component IDS changes and not on every keystroke.
|
|
3252
|
+
*
|
|
3253
|
+
* KEYED ON THE IDS, not on the document: an inspector edit mints a new `Doc3D`
|
|
3254
|
+
* on every character typed, and re-running the resolver there would re-await a
|
|
3255
|
+
* cached promise per keystroke and hand `ShotRuntime` a fresh object identity
|
|
3256
|
+
* each time — remounting every instanced component, which for a module with
|
|
3257
|
+
* internal state is visible as a flicker and for one behind Suspense is a
|
|
3258
|
+
* re-fetch. The module cache in instanceModules.ts makes the awaits free; the
|
|
3259
|
+
* identity is what has to stay stable.
|
|
3260
|
+
*/
|
|
3261
|
+
function useInstanceModules(client: StageClient, doc: Doc3D | null): InstanceResolution {
|
|
3262
|
+
const [resolved, setResolved] = useState<InstanceResolution>(EMPTY_INSTANCES);
|
|
3263
|
+
// Joined on a character no registry id can contain (an id is
|
|
3264
|
+
// "<module>#<Export>", and a module path holds no newline), so two different
|
|
3265
|
+
// id sets can never collapse to the same key.
|
|
3266
|
+
const key = doc === null ? "" : instanceComponentIdsOf(doc).join("\n");
|
|
3267
|
+
useEffect(() => {
|
|
3268
|
+
if (doc === null || key === "") {
|
|
3269
|
+
setResolved(EMPTY_INSTANCES);
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
let live = true;
|
|
3273
|
+
void resolveInstanceModules(doc).then((next) => {
|
|
3274
|
+
if (!live) return;
|
|
3275
|
+
setResolved(next);
|
|
3276
|
+
// Reported, never thrown: a module that will not resolve leaves its layer
|
|
3277
|
+
// empty, and the author needs to know WHY rather than losing the editor.
|
|
3278
|
+
for (const message of next.errors) client.handlers()?.onError(message);
|
|
3279
|
+
});
|
|
3280
|
+
return () => {
|
|
3281
|
+
live = false;
|
|
3282
|
+
};
|
|
3283
|
+
// `doc` is deliberately not a dependency — see the header. It is read
|
|
3284
|
+
// through the closure at the moment the id set changes, which is the only
|
|
3285
|
+
// moment its instance layers can differ.
|
|
3286
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3287
|
+
}, [client, key]);
|
|
3288
|
+
return resolved;
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
const EMPTY_INSTANCES: InstanceResolution = { instances: {}, contracts: {}, errors: [] };
|
|
3292
|
+
|
|
3293
|
+
function useStageState(client: StageClient): StageClientState {
|
|
3294
|
+
const [state, setState] = useState(client.getState);
|
|
3295
|
+
useEffect(() => client.subscribe(() => setState(client.getState())), [client]);
|
|
3296
|
+
return state;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
/** The container's CSS size, which is the canvas's size and therefore the unit
|
|
3300
|
+
* every screen coordinate in this lane is measured in. `origin` receives the
|
|
3301
|
+
* container's client origin from the same measurement, so the pointer lanes
|
|
3302
|
+
* can convert client coordinates without a per-event layout read. */
|
|
3303
|
+
function useViewportSize(
|
|
3304
|
+
ref: React.RefObject<HTMLDivElement | null>,
|
|
3305
|
+
set: (size: { width: number; height: number }) => void,
|
|
3306
|
+
origin?: React.RefObject<readonly [number, number]>,
|
|
3307
|
+
): void {
|
|
3308
|
+
useEffect(() => {
|
|
3309
|
+
const el = ref.current;
|
|
3310
|
+
if (el === null) return;
|
|
3311
|
+
const measure = (): void => {
|
|
3312
|
+
const rect = el.getBoundingClientRect();
|
|
3313
|
+
if (origin !== undefined) origin.current = [rect.left, rect.top];
|
|
3314
|
+
set({ width: Math.round(rect.width), height: Math.round(rect.height) });
|
|
3315
|
+
};
|
|
3316
|
+
measure();
|
|
3317
|
+
const observer = new ResizeObserver(measure);
|
|
3318
|
+
observer.observe(el);
|
|
3319
|
+
return () => observer.disconnect();
|
|
3320
|
+
}, [ref, set, origin]);
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
/**
|
|
3324
|
+
* Forward the stage's own key events to the chrome, and hold the space bar's
|
|
3325
|
+
* two bits.
|
|
3326
|
+
*
|
|
3327
|
+
* KEYBOARD EVENTS GO TO ONE DOCUMENT. The stage is an iframe, so once the
|
|
3328
|
+
* author has moved the pointer onto the canvas — which is most of the time,
|
|
3329
|
+
* since StagePanel focuses the frame on pointer entry — the chrome's window
|
|
3330
|
+
* listener never sees a keystroke. Without this relay every shortcut would work
|
|
3331
|
+
* only while a panel had focus, which is the version of this bug that reads as
|
|
3332
|
+
* "the shortcuts are flaky".
|
|
3333
|
+
*
|
|
3334
|
+
* ── WHY THE RELAY AND THE SPACE FLAG ARE ONE HOOK ───────────────────────
|
|
3335
|
+
*
|
|
3336
|
+
* They were two, and the split was a silent bug: both registered a `keyup` on
|
|
3337
|
+
* `window`, and DOM listeners fire in registration order, so the flag hook —
|
|
3338
|
+
* called one line earlier — cleared the bit before the relay could read it.
|
|
3339
|
+
* Space therefore NEVER reached the chrome from a focused stage, so it never
|
|
3340
|
+
* played or paused. One hook, one listener pair, and the order is a statement
|
|
3341
|
+
* rather than an accident of which line came first.
|
|
3342
|
+
*
|
|
3343
|
+
* ── AND WHY THE BAR NEEDS TWO BITS, NOT ONE ─────────────────────────────
|
|
3344
|
+
*
|
|
3345
|
+
* `held` gates the pan. `panned` says a pan actually ran, and is what makes the
|
|
3346
|
+
* release swallow the play/pause. One flag carrying both could not survive
|
|
3347
|
+
* KEY AUTO-REPEAT: the pan lane cleared it at the end of the drag, the OS fired
|
|
3348
|
+
* the next repeat keydown milliseconds later and set it straight back, and the
|
|
3349
|
+
* release played the shot anyway. Which happened depended on repeat timing,
|
|
3350
|
+
* which is the definition of flaky.
|
|
3351
|
+
*
|
|
3352
|
+
* A fresh press clears `panned`; a repeat does not, so a hold that panned stays
|
|
3353
|
+
* marked for as long as the author keeps the bar down.
|
|
3354
|
+
*
|
|
3355
|
+
* Refs, not state: both are read at pointerdown and neither changes a pixel, so
|
|
3356
|
+
* state would re-render the whole stage twice per press of the space bar.
|
|
3357
|
+
* `blur` clears them too, because `keyup` never arrives if the author alt-tabs
|
|
3358
|
+
* mid-hold and the stage would be stuck in pan mode.
|
|
3359
|
+
*/
|
|
3360
|
+
function useKeyRelay(
|
|
3361
|
+
client: StageClient,
|
|
3362
|
+
held: React.RefObject<boolean>,
|
|
3363
|
+
panned: React.RefObject<boolean>,
|
|
3364
|
+
refreshCursor: React.RefObject<() => void>,
|
|
3365
|
+
): void {
|
|
3366
|
+
useEffect(() => {
|
|
3367
|
+
const down = (e: KeyboardEvent): void => {
|
|
3368
|
+
if (e.key === " ") {
|
|
3369
|
+
if (!e.repeat) panned.current = false;
|
|
3370
|
+
// The bar changes the cursor to a grab hand with no pointer motion at
|
|
3371
|
+
// all, so the refresh rides the same listener that owns the flag. AFTER
|
|
3372
|
+
// the flag is set, because `cursorFor` reads it — refreshing first
|
|
3373
|
+
// recomputes the answer the bar was pressed to change. Only on the
|
|
3374
|
+
// FIRST press: an auto-repeat re-deriving the same answer at the OS
|
|
3375
|
+
// repeat rate buys nothing.
|
|
3376
|
+
const first = !held.current;
|
|
3377
|
+
held.current = true;
|
|
3378
|
+
if (first) refreshCursor.current();
|
|
3379
|
+
return; // the chrome hears space on KEYUP, and only if no pan ran
|
|
3380
|
+
}
|
|
3381
|
+
if (client.handlers()?.onKey(keyOf(e), isEditable(e.target)) === true) e.preventDefault();
|
|
3382
|
+
};
|
|
3383
|
+
const up = (e: KeyboardEvent): void => {
|
|
3384
|
+
if (e.key !== " ") return;
|
|
3385
|
+
held.current = false;
|
|
3386
|
+
refreshCursor.current();
|
|
3387
|
+
if (!panned.current) client.handlers()?.onKey(keyOf(e), isEditable(e.target));
|
|
3388
|
+
panned.current = false;
|
|
3389
|
+
};
|
|
3390
|
+
const clear = (): void => {
|
|
3391
|
+
held.current = false;
|
|
3392
|
+
panned.current = false;
|
|
3393
|
+
refreshCursor.current();
|
|
3394
|
+
};
|
|
3395
|
+
window.addEventListener("keydown", down);
|
|
3396
|
+
window.addEventListener("keyup", up);
|
|
3397
|
+
window.addEventListener("blur", clear);
|
|
3398
|
+
return () => {
|
|
3399
|
+
window.removeEventListener("keydown", down);
|
|
3400
|
+
window.removeEventListener("keyup", up);
|
|
3401
|
+
window.removeEventListener("blur", clear);
|
|
3402
|
+
};
|
|
3403
|
+
}, [client, held, panned, refreshCursor]);
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
function keyOf(e: KeyboardEvent) {
|
|
3407
|
+
return {
|
|
3408
|
+
key: e.key,
|
|
3409
|
+
shiftKey: e.shiftKey,
|
|
3410
|
+
altKey: e.altKey,
|
|
3411
|
+
ctrlKey: e.ctrlKey,
|
|
3412
|
+
metaKey: e.metaKey,
|
|
3413
|
+
code: e.code,
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
function isEditable(target: EventTarget | null): boolean {
|
|
3418
|
+
if (target === null || typeof (target as HTMLElement).tagName !== "string") return false;
|
|
3419
|
+
const el = target as HTMLElement;
|
|
3420
|
+
return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA";
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
|