@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,151 @@
|
|
|
1
|
+
// The selection box's eight resize handles, as pure screen geometry.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY THIS IS ITS OWN MODULE ──────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// THREE READERS, and they must never disagree. `Overlays` DRAWS the handles,
|
|
6
|
+
// `StageTools.grabAt` HIT-TESTS them, and the cursor lane asks which way one
|
|
7
|
+
// points. A drawn handle the hit test does not answer, or an answered one
|
|
8
|
+
// nobody drew, is precisely the bug a hover cursor exists to make impossible —
|
|
9
|
+
// and the two used to be separate tables in separate files, one of which only
|
|
10
|
+
// knew about four of the eight.
|
|
11
|
+
//
|
|
12
|
+
// It is also the only shape here that can be checked in node: `StageTools`
|
|
13
|
+
// pulls in three's WebGPU node materials through its neighbours, so a test that
|
|
14
|
+
// wanted to ask "what does a press at this point grab" had to mount a canvas.
|
|
15
|
+
//
|
|
16
|
+
// ── AND WHY THE QUAD IS A TRAPEZOID, NOT A RECTANGLE ────────────────────
|
|
17
|
+
//
|
|
18
|
+
// `screenQuad` projects a layer's four corners through whichever camera the
|
|
19
|
+
// pane is using. Under the camera pane's perspective lens a tilted layer is
|
|
20
|
+
// already a trapezoid; under the working pane's free orbit it can be any convex
|
|
21
|
+
// quad at all. So nothing here may assume axis alignment, and in particular a
|
|
22
|
+
// handle's NAME says nothing about which way it points on screen.
|
|
23
|
+
import { RESIZE_HANDLES } from "@dailies-studio/shot/doc";
|
|
24
|
+
import type { ResizeHandle } from "@dailies-studio/shot/doc";
|
|
25
|
+
import type { ScreenQuad } from "./project.js";
|
|
26
|
+
|
|
27
|
+
/** How close a press has to land to a handle, in CSS px. Larger than the drawn
|
|
28
|
+
* 7px handle, because a 7px target is a 7px target and everyone misses it. */
|
|
29
|
+
export const HANDLE_GRAB_PX = 10;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The eight handle sites, in HIT-TEST ORDER: four corners, then four edge
|
|
33
|
+
* midpoints.
|
|
34
|
+
*
|
|
35
|
+
* Corners first is load-bearing rather than cosmetic. The grab radius is larger
|
|
36
|
+
* than the drawn handle, so a corner's target overlaps both of its neighbouring
|
|
37
|
+
* edge targets; testing edges first would answer a corner press with an edge,
|
|
38
|
+
* and the author would resize one axis when they asked for two.
|
|
39
|
+
*
|
|
40
|
+
* `a` and `b` are quad corner indices (TL, TR, BR, BL) and the site is their
|
|
41
|
+
* MIDPOINT — which is the corner itself when they are equal. The midpoint of
|
|
42
|
+
* two projected corners is exactly the projection of the edge's midpoint,
|
|
43
|
+
* because a projective map takes lines to lines, so an edge handle sits on the
|
|
44
|
+
* drawn edge at every angle with no extra maths.
|
|
45
|
+
*
|
|
46
|
+
* The handle's two signs ARE the resize arithmetic (packages/shot's
|
|
47
|
+
* `resizeTransform`). `RESIZE_HANDLES` has carried all eight since the document
|
|
48
|
+
* model was written; four of them were unreachable from the pointer until this
|
|
49
|
+
* table existed.
|
|
50
|
+
*/
|
|
51
|
+
export const HANDLE_SITES: ReadonlyArray<{ a: number; b: number; handle: ResizeHandle }> = [
|
|
52
|
+
{ a: 0, b: 0, handle: RESIZE_HANDLES["nw"]! },
|
|
53
|
+
{ a: 1, b: 1, handle: RESIZE_HANDLES["ne"]! },
|
|
54
|
+
{ a: 2, b: 2, handle: RESIZE_HANDLES["se"]! },
|
|
55
|
+
{ a: 3, b: 3, handle: RESIZE_HANDLES["sw"]! },
|
|
56
|
+
{ a: 0, b: 1, handle: RESIZE_HANDLES["n"]! },
|
|
57
|
+
{ a: 1, b: 2, handle: RESIZE_HANDLES["e"]! },
|
|
58
|
+
{ a: 2, b: 3, handle: RESIZE_HANDLES["s"]! },
|
|
59
|
+
{ a: 3, b: 0, handle: RESIZE_HANDLES["w"]! },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Where a site sits on screen. */
|
|
63
|
+
export function handlePoint(quad: ScreenQuad, site: { a: number; b: number }): [number, number] {
|
|
64
|
+
return [
|
|
65
|
+
(quad[site.a * 2]! + quad[site.b * 2]!) / 2,
|
|
66
|
+
(quad[site.a * 2 + 1]! + quad[site.b * 2 + 1]!) / 2,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The mean of the four projected corners.
|
|
72
|
+
*
|
|
73
|
+
* NOT the diagonals' intersection: under a perspective camera the quad is a
|
|
74
|
+
* trapezoid whose diagonals do not bisect each other, and the two answers
|
|
75
|
+
* disagree by enough at a steep angle to flip a cursor across a 45 degree
|
|
76
|
+
* bucket boundary.
|
|
77
|
+
*/
|
|
78
|
+
export function quadCentre(quad: ScreenQuad): [number, number] {
|
|
79
|
+
let cx = 0;
|
|
80
|
+
let cy = 0;
|
|
81
|
+
for (let i = 0; i < 4; i++) {
|
|
82
|
+
cx += quad[i * 2]!;
|
|
83
|
+
cy += quad[i * 2 + 1]!;
|
|
84
|
+
}
|
|
85
|
+
return [cx / 4, cy / 4];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Which of the eight handles a press grabbed, with the direction it points.
|
|
90
|
+
*
|
|
91
|
+
* `angle` is the OUTWARD screen direction from the quad's centre, in radians in
|
|
92
|
+
* the overlay's y-DOWN space. It is here rather than at the cursor's end
|
|
93
|
+
* because this is where the quad is, and deriving it twice is how the drawn
|
|
94
|
+
* handle and the cursor end up a rotation apart.
|
|
95
|
+
*/
|
|
96
|
+
export function nearestHandle(
|
|
97
|
+
quad: ScreenQuad | undefined,
|
|
98
|
+
x: number,
|
|
99
|
+
y: number,
|
|
100
|
+
): { handle: ResizeHandle; angle: number } | null {
|
|
101
|
+
if (quad === undefined) return null;
|
|
102
|
+
for (const site of HANDLE_SITES) {
|
|
103
|
+
const [hx, hy] = handlePoint(quad, site);
|
|
104
|
+
if (Math.hypot(hx - x, hy - y) > HANDLE_GRAB_PX) continue;
|
|
105
|
+
const [cx, cy] = quadCentre(quad);
|
|
106
|
+
return { handle: site.handle, angle: Math.atan2(hy - cy, hx - cx) };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The rotate ring: a band JUST OUTSIDE each corner.
|
|
113
|
+
*
|
|
114
|
+
* ── WHY OUTSIDE, AND WHY ONLY AT THE CORNERS ────────────────────────────
|
|
115
|
+
*
|
|
116
|
+
* Figma and Framer both put rotation in the empty space beyond a corner, and
|
|
117
|
+
* that placement is not arbitrary: it is the one region around a selection box
|
|
118
|
+
* that no other gesture claims. Inside is a body move, on the corner is a
|
|
119
|
+
* resize, along an edge is a one-axis resize, and further out is empty space.
|
|
120
|
+
*
|
|
121
|
+
* Corners only, not the whole perimeter, for the same reason: the band beside
|
|
122
|
+
* an EDGE is a few px from that edge's own resize handle, so a ring there would
|
|
123
|
+
* make one-axis resizes hard to hit for a gesture that has somewhere else to
|
|
124
|
+
* live.
|
|
125
|
+
*
|
|
126
|
+
* It starts where the resize slop ends, so the two never both answer, and the
|
|
127
|
+
* annulus is wide enough to hit without being a trap the pointer falls into on
|
|
128
|
+
* the way out of the box.
|
|
129
|
+
*/
|
|
130
|
+
export const RING_INNER_PX = HANDLE_GRAB_PX;
|
|
131
|
+
export const RING_OUTER_PX = 26;
|
|
132
|
+
|
|
133
|
+
/** Is this press in the rotate ring, and which way does that corner point? */
|
|
134
|
+
export function rotateRingAt(
|
|
135
|
+
quad: ScreenQuad | undefined,
|
|
136
|
+
x: number,
|
|
137
|
+
y: number,
|
|
138
|
+
): { angle: number } | null {
|
|
139
|
+
if (quad === undefined) return null;
|
|
140
|
+
const [cx, cy] = quadCentre(quad);
|
|
141
|
+
for (let i = 0; i < 4; i++) {
|
|
142
|
+
const d = Math.hypot(quad[i * 2]! - x, quad[i * 2 + 1]! - y);
|
|
143
|
+
if (d <= RING_INNER_PX || d > RING_OUTER_PX) continue;
|
|
144
|
+
// OUTSIDE THE BOX ONLY. The annulus around a corner reaches inwards too,
|
|
145
|
+
// and that half belongs to the body move — a press an author aimed at the
|
|
146
|
+
// layer must not rotate it.
|
|
147
|
+
if (Math.hypot(x - cx, y - cy) < Math.hypot(quad[i * 2]! - cx, quad[i * 2 + 1]! - cy)) continue;
|
|
148
|
+
return { angle: Math.atan2(y - cy, x - cx) };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Resolving a shot's INSTANCE targets to real modules, on the stage.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT BELONGS HERE AND NOWHERE ELSE. The shot stage is served through the
|
|
4
|
+
// PROJECT's Vite (`/shot-stage.html` + `/@fs`, shotRoutes.ts) precisely so the
|
|
5
|
+
// three stack resolves to the studio's pinned copies and the project's own
|
|
6
|
+
// modules are reachable. The chrome cannot do it (it is a prebuilt static
|
|
7
|
+
// bundle with no view of the project graph) and @dailies-studio/shot must not
|
|
8
|
+
// (codegen supplies static imports there). This is a faithful sibling of the
|
|
9
|
+
// canvas lane's `embedModules.ts`, and it is deliberately a sibling rather than
|
|
10
|
+
// a shared module: the two chromes depend on neither each other nor a third
|
|
11
|
+
// package, and the whole file is forty lines of dynamic import.
|
|
12
|
+
//
|
|
13
|
+
// TWO LOADS PER COMPONENT, mirroring codegen exactly (shot codegen emit.ts):
|
|
14
|
+
// the COMPONENT through `virtual:studio-components`, whose `load()` already
|
|
15
|
+
// resolves the named export and handles BUILTINS (bare
|
|
16
|
+
// package specifiers, not project paths) — hand-rolling a URL
|
|
17
|
+
// here would get exactly that case wrong.
|
|
18
|
+
// the CONTRACT a DECLARED contract is a module, imported from the same
|
|
19
|
+
// specifier codegen writes. A SNAPSHOT comes off the document
|
|
20
|
+
// (`snapshotInstanceContracts`, folded in by `ShotRuntime`)
|
|
21
|
+
// and needs no network at all.
|
|
22
|
+
//
|
|
23
|
+
// FAILURE IS NEVER FATAL. Every load is caught: a component that will not
|
|
24
|
+
// resolve is simply absent from the map and its layer draws nothing, with the
|
|
25
|
+
// reason travelling up the stage's existing error channel. A stage that threw
|
|
26
|
+
// here would blank the whole editor over one broken import.
|
|
27
|
+
import {
|
|
28
|
+
controlsModuleOf,
|
|
29
|
+
instanceComponentIdsOf,
|
|
30
|
+
instanceContractBindings,
|
|
31
|
+
instanceModuleOf,
|
|
32
|
+
isBareModuleSpecifier,
|
|
33
|
+
} from "@dailies-studio/shot/doc";
|
|
34
|
+
import type { Doc3D } from "@dailies-studio/shot/doc";
|
|
35
|
+
import type { Contract } from "@dailies-studio/contract";
|
|
36
|
+
import type { ComponentType } from "react";
|
|
37
|
+
|
|
38
|
+
/** The `virtual:studio-components` row shape (cli registry.ts). */
|
|
39
|
+
interface RegistryEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
host: string;
|
|
42
|
+
load(): Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
export type InstanceModules = Record<string, ComponentType<any>>;
|
|
47
|
+
|
|
48
|
+
export interface InstanceResolution {
|
|
49
|
+
instances: InstanceModules;
|
|
50
|
+
contracts: Record<string, Contract>;
|
|
51
|
+
/** One line per component that could not be resolved. Empty when everything
|
|
52
|
+
* loaded (or the document has no instances). */
|
|
53
|
+
errors: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const EMPTY: InstanceResolution = { instances: {}, contracts: {}, errors: [] };
|
|
57
|
+
|
|
58
|
+
/** Module-level caches: the stage re-resolves on every document identity change
|
|
59
|
+
* (an inspector keystroke), and a dynamic import is idempotent but a REJECTED
|
|
60
|
+
* one is not cheap to retry in a loop. */
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
const componentCache = new Map<string, Promise<ComponentType<any> | null>>();
|
|
63
|
+
const contractCache = new Map<string, Promise<Contract | null>>();
|
|
64
|
+
|
|
65
|
+
/** The registry's URL, not its module ID. `virtual:studio-components` is
|
|
66
|
+
* resolvable only by the studio server's own Vite plugin, so a bare specifier
|
|
67
|
+
* would be a hard build error in every OTHER Vite that sees this file. Held in
|
|
68
|
+
* a const so Vite's import analysis cannot see a literal and try. */
|
|
69
|
+
const COMPONENT_REGISTRY_URL = "/@id/virtual:studio-components";
|
|
70
|
+
|
|
71
|
+
async function registry(): Promise<Record<string, RegistryEntry>> {
|
|
72
|
+
const mod = (await import(/* @vite-ignore */ COMPONENT_REGISTRY_URL)) as {
|
|
73
|
+
components?: Record<string, RegistryEntry>;
|
|
74
|
+
};
|
|
75
|
+
return mod.components ?? {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
function loadComponent(id: string): Promise<ComponentType<any> | null> {
|
|
80
|
+
const hit = componentCache.get(id);
|
|
81
|
+
if (hit !== undefined) return hit;
|
|
82
|
+
const p = (async () => {
|
|
83
|
+
const entry = (await registry())[id];
|
|
84
|
+
if (entry === undefined) return null;
|
|
85
|
+
const value = await entry.load();
|
|
86
|
+
// A vanished export resolves to undefined; treat it as "not resolvable"
|
|
87
|
+
// rather than rendering `undefined` into the tree.
|
|
88
|
+
return typeof value === "function" || (typeof value === "object" && value !== null)
|
|
89
|
+
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
|
+
(value as ComponentType<any>)
|
|
91
|
+
: null;
|
|
92
|
+
})().catch(() => null);
|
|
93
|
+
componentCache.set(id, p);
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadDeclaredContract(specifier: string): Promise<Contract | null> {
|
|
98
|
+
const hit = contractCache.get(specifier);
|
|
99
|
+
if (hit !== undefined) return hit;
|
|
100
|
+
const url = isBareModuleSpecifier(specifier) ? specifier : `/${specifier}`;
|
|
101
|
+
const p = (async () => {
|
|
102
|
+
const mod = (await import(/* @vite-ignore */ url)) as { controls?: unknown };
|
|
103
|
+
const controls = mod.controls;
|
|
104
|
+
return controls !== null && typeof controls === "object" && "keys" in controls
|
|
105
|
+
? (controls as Contract)
|
|
106
|
+
: null;
|
|
107
|
+
})().catch(() => null);
|
|
108
|
+
contractCache.set(specifier, p);
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Resolve every instance target in `doc`. Pure I/O — no React, no state — so
|
|
113
|
+
* the caller owns when it runs and what it does with a partial answer. */
|
|
114
|
+
export async function resolveInstanceModules(doc: Doc3D): Promise<InstanceResolution> {
|
|
115
|
+
const ids = instanceComponentIdsOf(doc);
|
|
116
|
+
if (ids.length === 0) return EMPTY;
|
|
117
|
+
|
|
118
|
+
const instances: InstanceModules = {};
|
|
119
|
+
const errors: string[] = [];
|
|
120
|
+
await Promise.all(
|
|
121
|
+
ids.map(async (id) => {
|
|
122
|
+
const comp = await loadComponent(id);
|
|
123
|
+
if (comp === null) {
|
|
124
|
+
errors.push(`instance "${id}" could not be loaded from the project — its layer is empty.`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
instances[id] = comp;
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Only the DECLARED ones. `ShotRuntime` folds the document's own snapshots in
|
|
132
|
+
// underneath, so there is nothing to merge here and no chance of this half
|
|
133
|
+
// shadowing a snapshot with an undefined.
|
|
134
|
+
const contracts: Record<string, Contract> = {};
|
|
135
|
+
await Promise.all(
|
|
136
|
+
instanceContractBindings(doc).map(async ({ component, ref }) => {
|
|
137
|
+
if (ref.source === "snapshot") return;
|
|
138
|
+
const specifier = controlsModuleOf(instanceModuleOf(component), ref.source);
|
|
139
|
+
const contract = await loadDeclaredContract(specifier);
|
|
140
|
+
if (contract === null) {
|
|
141
|
+
errors.push(
|
|
142
|
+
`instance "${component}" declares its contract in ${JSON.stringify(specifier)}, which ` +
|
|
143
|
+
`did not load — its props render unmapped here.`,
|
|
144
|
+
);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
contracts[component] = contract;
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return { instances, contracts, errors };
|
|
152
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Turning a pointer into a DESIGN-SPACE delta, which is the one thing the
|
|
2
|
+
// chrome's document math cannot do for itself.
|
|
3
|
+
//
|
|
4
|
+
// ── THE V-vs-G RULE, WHICH IS THE WHOLE FILE ────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// The 2D lane could say "screen delta ÷ zoom" because everything was coplanar.
|
|
7
|
+
// Here each layer sits at its OWN depth under a perspective camera, so a screen
|
|
8
|
+
// delta maps to a different world delta per layer — and there is no single
|
|
9
|
+
// correct answer, because two different gestures are being asked for:
|
|
10
|
+
//
|
|
11
|
+
// SELECT (V) unprojects onto the LAYER'S OWN tilted plane. Dragging a tilted
|
|
12
|
+
// layer slides it along its own surface, which is what "move this thing"
|
|
13
|
+
// means once the thing is a plane in space.
|
|
14
|
+
//
|
|
15
|
+
// MOVE (G) unprojects onto the plane through the layer's origin PARALLEL to
|
|
16
|
+
// the export frame. Flat screen space: z never changes and tilt is ignored,
|
|
17
|
+
// which is what "move it on the canvas" means.
|
|
18
|
+
//
|
|
19
|
+
// Two `Ray.intersectPlane` calls. One plane constructor different. That is the
|
|
20
|
+
// entire distinction, and stating it as two planes rather than as two code
|
|
21
|
+
// paths is what keeps it from drifting.
|
|
22
|
+
import { Plane, Vector3 } from "three";
|
|
23
|
+
import type { Ray } from "three";
|
|
24
|
+
|
|
25
|
+
export type MovePlane = "layer" | "frame";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The plane a gesture unprojects onto.
|
|
29
|
+
*
|
|
30
|
+
* `matrixWorld` is the layer group's, so its third basis column is the layer's
|
|
31
|
+
* own normal — no separate rotation bookkeeping, and a layer flipped or tilted
|
|
32
|
+
* past 90 degrees still gets its own plane rather than a mirrored one.
|
|
33
|
+
*/
|
|
34
|
+
export function gesturePlane(kind: MovePlane, matrixWorld: { elements: ArrayLike<number> }): Plane {
|
|
35
|
+
const e = matrixWorld.elements;
|
|
36
|
+
const origin = new Vector3(e[12]!, e[13]!, e[14]!);
|
|
37
|
+
if (kind === "frame") {
|
|
38
|
+
// Parallel to the export frame, through the layer's own depth: the design
|
|
39
|
+
// z = 0 plane's normal is world +z, and moving the plane to the layer keeps
|
|
40
|
+
// the drag anchored to the thing being dragged rather than to the canvas.
|
|
41
|
+
return new Plane().setFromNormalAndCoplanarPoint(new Vector3(0, 0, 1), origin);
|
|
42
|
+
}
|
|
43
|
+
const normal = new Vector3(e[8]!, e[9]!, e[10]!).normalize();
|
|
44
|
+
return new Plane().setFromNormalAndCoplanarPoint(normal, origin);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where a ray meets a plane, or null when it is parallel to it (an edge-on
|
|
48
|
+
* layer) — which is a real case and must not become a NaN delta. */
|
|
49
|
+
export function unproject(ray: Ray, plane: Plane): Vector3 | null {
|
|
50
|
+
const hit = ray.intersectPlane(plane, new Vector3());
|
|
51
|
+
return hit === null || !Number.isFinite(hit.x) ? null : hit;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The world-space displacement between two rays on one plane, expressed in the
|
|
56
|
+
* DESIGN coordinates the schema uses.
|
|
57
|
+
*
|
|
58
|
+
* The one mapping is undone here and nowhere else: design +y is DOWN and +z is
|
|
59
|
+
* AWAY, so world (x, y, z) reads back as design (x, -y, -z).
|
|
60
|
+
*/
|
|
61
|
+
export function designDelta(
|
|
62
|
+
from: Ray,
|
|
63
|
+
to: Ray,
|
|
64
|
+
plane: Plane,
|
|
65
|
+
): { dx: number; dy: number; dz: number } | null {
|
|
66
|
+
const a = unproject(from, plane);
|
|
67
|
+
const b = unproject(to, plane);
|
|
68
|
+
if (a === null || b === null) return null;
|
|
69
|
+
return { dx: b.x - a.x, dy: -(b.y - a.y), dz: -(b.z - a.z) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A point on the gesture's plane, in DESIGN coordinates. The same one mapping
|
|
73
|
+
* `designDelta` undoes, for the absolute point rather than the difference —
|
|
74
|
+
* the union scale needs both, because a ratio is a quotient of distances and
|
|
75
|
+
* not a displacement. */
|
|
76
|
+
export function designPoint(ray: Ray, plane: Plane): [number, number, number] | null {
|
|
77
|
+
const p = unproject(ray, plane);
|
|
78
|
+
return p === null ? null : [p.x, -p.y, -p.z];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A multi-select's box, in DESIGN px. `x0,y0` is the top-left corner.
|
|
83
|
+
*
|
|
84
|
+
* It is a rectangle rather than a quad because `unionQuad` only hands one back
|
|
85
|
+
* for a selection that makes a rectangle honest — coplanar and axis-parallel.
|
|
86
|
+
*/
|
|
87
|
+
export interface DesignBox {
|
|
88
|
+
x0: number;
|
|
89
|
+
y0: number;
|
|
90
|
+
x1: number;
|
|
91
|
+
y1: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Below this the anchor is ON the pivot and every ratio is a division by
|
|
95
|
+
* nothing. In design px, so it is the same threshold at every zoom. */
|
|
96
|
+
const MIN_SCALE_ARM = 1;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The smallest ratio a drag may report, matching `MIN_INVERTIBLE_SCALE`.
|
|
100
|
+
*
|
|
101
|
+
* Dragging PAST the pivot asks for a negative factor, which `applyGesture`
|
|
102
|
+
* refuses by name — correctly, since `size` has no sign. Clamping here rather
|
|
103
|
+
* than letting the refusal fire means the box stops at the pivot instead of
|
|
104
|
+
* filling the error line on every pointermove of an overshoot, and nothing is
|
|
105
|
+
* lost on the way back out: every emit is a total from the press, so the clamp
|
|
106
|
+
* is re-evaluated from the base and never compounds.
|
|
107
|
+
*/
|
|
108
|
+
const MIN_RATIO = 1e-3;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A drag on the union box's handle, as the ratio-about-a-pivot the document
|
|
112
|
+
* model actually has.
|
|
113
|
+
*
|
|
114
|
+
* ── WHY A RATIO AND NOT EIGHT EDGE DISTANCES ────────────────────────────
|
|
115
|
+
*
|
|
116
|
+
* A `resize` moves ONE layer's edge to the pointer, which is exact for one
|
|
117
|
+
* layer and meaningless for eight: each member sits a different distance from
|
|
118
|
+
* the box's edge, so "the edge travelled 40px" is a different size change for
|
|
119
|
+
* every one of them. What a group resize means is that the whole arrangement
|
|
120
|
+
* grows about a fixed point — one factor, applied to every member's size AND to
|
|
121
|
+
* its offset from that point. That is `GestureDelta`'s `scale`.
|
|
122
|
+
*
|
|
123
|
+
* ── THE ANCHOR IS THE PRESS, NOT THE HANDLE ─────────────────────────────
|
|
124
|
+
*
|
|
125
|
+
* The grab radius is 10px, so a press is near the handle and not on it.
|
|
126
|
+
* Measuring from the handle's own corner would make the box jump by up to that
|
|
127
|
+
* distance on the first pointermove; measuring from where the author actually
|
|
128
|
+
* pressed starts every drag at a ratio of exactly 1.
|
|
129
|
+
*
|
|
130
|
+
* Returns null when the anchor sits on the pivot along the axis being scaled —
|
|
131
|
+
* a division by nothing, which is a real case for a press dead-centre on an
|
|
132
|
+
* edge handle with ALT held, and NaN is the alternative.
|
|
133
|
+
*/
|
|
134
|
+
export function unionScale(
|
|
135
|
+
box: DesignBox,
|
|
136
|
+
handle: { sx: number; sy: number },
|
|
137
|
+
anchor: readonly [number, number],
|
|
138
|
+
travel: { dx: number; dy: number },
|
|
139
|
+
aboutCentre: boolean,
|
|
140
|
+
): { ratio: number; axis: "x" | "y" | "both"; pivot: [number, number] } | null {
|
|
141
|
+
const midX = (box.x0 + box.x1) / 2;
|
|
142
|
+
const midY = (box.y0 + box.y1) / 2;
|
|
143
|
+
// The corner OPPOSITE the one grabbed, which is what stays put. An edge
|
|
144
|
+
// handle has one sign of zero, and negating zero gives the box's own middle
|
|
145
|
+
// on that axis — exactly the "opposite edge" pivot, with no second rule.
|
|
146
|
+
const pivot: [number, number] = aboutCentre
|
|
147
|
+
? [midX, midY]
|
|
148
|
+
: [
|
|
149
|
+
handle.sx === 0 ? midX : handle.sx < 0 ? box.x1 : box.x0,
|
|
150
|
+
handle.sy === 0 ? midY : handle.sy < 0 ? box.y1 : box.y0,
|
|
151
|
+
];
|
|
152
|
+
const ax = anchor[0] - pivot[0];
|
|
153
|
+
const ay = anchor[1] - pivot[1];
|
|
154
|
+
const bx = ax + travel.dx;
|
|
155
|
+
const by = ay + travel.dy;
|
|
156
|
+
// A corner scales UNIFORMLY, by the ratio of the two distances from the
|
|
157
|
+
// pivot. One `ratio` cannot express the independent x and y a single-layer
|
|
158
|
+
// corner resize gives, and a uniform group scale is both the honest reading
|
|
159
|
+
// of one number and what Blender's `S` does.
|
|
160
|
+
if (handle.sx !== 0 && handle.sy !== 0) {
|
|
161
|
+
const a = Math.hypot(ax, ay);
|
|
162
|
+
if (a < MIN_SCALE_ARM) return null;
|
|
163
|
+
return { ratio: Math.max(Math.hypot(bx, by) / a, MIN_RATIO), axis: "both", pivot };
|
|
164
|
+
}
|
|
165
|
+
// An edge handle names ONE of the box's axes, and the box's axes are the
|
|
166
|
+
// design axes — `unionQuad` refuses any selection where they are not.
|
|
167
|
+
const axis = handle.sx !== 0 ? "x" : "y";
|
|
168
|
+
const a = axis === "x" ? ax : ay;
|
|
169
|
+
const b = axis === "x" ? bx : by;
|
|
170
|
+
if (Math.abs(a) < MIN_SCALE_ARM) return null;
|
|
171
|
+
return { ratio: Math.max(b / a, MIN_RATIO), axis, pivot };
|
|
172
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Pan and zoom of the working area. Pure: (event, view) -> view.
|
|
2
|
+
//
|
|
3
|
+
// It writes only `ShotView`, never the camera, because `view.ts` owns the one
|
|
4
|
+
// conversion into `setViewOffset`. Two places computing a camera from a gesture
|
|
5
|
+
// is how a lane ends up with a frame border that disagrees with the render.
|
|
6
|
+
import { clampZoom, zoomAt } from "../view.js";
|
|
7
|
+
import type { FrameSize, ShotView, Viewport } from "../view.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A wheel event.
|
|
11
|
+
*
|
|
12
|
+
* ctrl/meta + wheel is a browser pinch-zoom gesture — trackpads report a pinch
|
|
13
|
+
* as exactly that, so honouring it is what makes two-finger zoom work at all.
|
|
14
|
+
* A plain wheel PANS, which is what every design tool does and what makes a
|
|
15
|
+
* trackpad's two-finger scroll usable.
|
|
16
|
+
*
|
|
17
|
+
* `deltaMode` is honoured because Firefox reports lines (1) and pages (2) where
|
|
18
|
+
* Chrome reports pixels (0); ignoring it makes the same gesture ~16x weaker on
|
|
19
|
+
* one browser.
|
|
20
|
+
*/
|
|
21
|
+
export function wheelToView(
|
|
22
|
+
event: {
|
|
23
|
+
deltaX: number;
|
|
24
|
+
deltaY: number;
|
|
25
|
+
deltaMode: number;
|
|
26
|
+
ctrlKey: boolean;
|
|
27
|
+
metaKey: boolean;
|
|
28
|
+
},
|
|
29
|
+
point: readonly [number, number],
|
|
30
|
+
view: ShotView,
|
|
31
|
+
viewport: Viewport,
|
|
32
|
+
frame: FrameSize,
|
|
33
|
+
): ShotView {
|
|
34
|
+
const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.height : 1;
|
|
35
|
+
const dx = event.deltaX * scale;
|
|
36
|
+
const dy = event.deltaY * scale;
|
|
37
|
+
if (event.ctrlKey || event.metaKey) {
|
|
38
|
+
// Exponential, so a fixed wheel notch is a fixed RATIO at every zoom —
|
|
39
|
+
// linear steps crawl when zoomed out and jump when zoomed in.
|
|
40
|
+
return zoomAt(view, viewport, frame, point, clampZoom(view.zoom * Math.exp(-dy / 320)));
|
|
41
|
+
}
|
|
42
|
+
return { ...view, panX: view.panX - dx, panY: view.panY - dy };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A pan drag: the view at press, plus the pointer's total displacement. Always
|
|
46
|
+
* measured from the PRESS, never accumulated per move, so a coalesced move or
|
|
47
|
+
* a clamp cannot make the canvas drift away from the cursor. */
|
|
48
|
+
export function panBy(start: ShotView, deltaX: number, deltaY: number): ShotView {
|
|
49
|
+
return { ...start, panX: start.panX + deltaX, panY: start.panY + deltaY };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Does this pointer event start a PAN rather than a selection?
|
|
54
|
+
*
|
|
55
|
+
* Middle button, or space held. Both are the conventions a designer already
|
|
56
|
+
* has, and neither collides with a left-drag marquee.
|
|
57
|
+
*/
|
|
58
|
+
export function isPanGesture(event: { button: number }, spaceHeld: boolean): boolean {
|
|
59
|
+
return event.button === 1 || spaceHeld;
|
|
60
|
+
}
|