@clipkit/editor-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +20 -0
  3. package/dist/asset-store.d.ts +40 -0
  4. package/dist/asset-store.d.ts.map +1 -0
  5. package/dist/asset-store.js +181 -0
  6. package/dist/asset-store.js.map +1 -0
  7. package/dist/context.d.ts +17 -0
  8. package/dist/context.d.ts.map +1 -0
  9. package/dist/context.js +23 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/index.d.ts +20 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +17 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/node-graph.d.ts +42 -0
  16. package/dist/node-graph.d.ts.map +1 -0
  17. package/dist/node-graph.js +123 -0
  18. package/dist/node-graph.js.map +1 -0
  19. package/dist/registry/build.d.ts +3 -0
  20. package/dist/registry/build.d.ts.map +1 -0
  21. package/dist/registry/build.js +84 -0
  22. package/dist/registry/build.js.map +1 -0
  23. package/dist/registry/configuration.d.ts +51 -0
  24. package/dist/registry/configuration.d.ts.map +1 -0
  25. package/dist/registry/configuration.js +92 -0
  26. package/dist/registry/configuration.js.map +1 -0
  27. package/dist/registry/derive.d.ts +30 -0
  28. package/dist/registry/derive.d.ts.map +1 -0
  29. package/dist/registry/derive.js +212 -0
  30. package/dist/registry/derive.js.map +1 -0
  31. package/dist/registry/overrides.d.ts +7 -0
  32. package/dist/registry/overrides.d.ts.map +1 -0
  33. package/dist/registry/overrides.js +322 -0
  34. package/dist/registry/overrides.js.map +1 -0
  35. package/dist/registry/types.d.ts +58 -0
  36. package/dist/registry/types.d.ts.map +1 -0
  37. package/dist/registry/types.js +7 -0
  38. package/dist/registry/types.js.map +1 -0
  39. package/dist/source-diff.d.ts +8 -0
  40. package/dist/source-diff.d.ts.map +1 -0
  41. package/dist/source-diff.js +148 -0
  42. package/dist/source-diff.js.map +1 -0
  43. package/dist/stage-utils.d.ts +170 -0
  44. package/dist/stage-utils.d.ts.map +1 -0
  45. package/dist/stage-utils.js +476 -0
  46. package/dist/stage-utils.js.map +1 -0
  47. package/dist/store.d.ts +123 -0
  48. package/dist/store.d.ts.map +1 -0
  49. package/dist/store.js +234 -0
  50. package/dist/store.js.map +1 -0
  51. package/dist/timeline-utils.d.ts +69 -0
  52. package/dist/timeline-utils.d.ts.map +1 -0
  53. package/dist/timeline-utils.js +211 -0
  54. package/dist/timeline-utils.js.map +1 -0
  55. package/dist/types.d.ts +6 -0
  56. package/dist/types.d.ts.map +1 -0
  57. package/dist/types.js +4 -0
  58. package/dist/types.js.map +1 -0
  59. package/dist/useEditor.d.ts +77 -0
  60. package/dist/useEditor.d.ts.map +1 -0
  61. package/dist/useEditor.js +74 -0
  62. package/dist/useEditor.js.map +1 -0
  63. package/dist/useEditorStore.d.ts +3 -0
  64. package/dist/useEditorStore.d.ts.map +1 -0
  65. package/dist/useEditorStore.js +15 -0
  66. package/dist/useEditorStore.js.map +1 -0
  67. package/dist/usePlaybackSession.d.ts +20 -0
  68. package/dist/usePlaybackSession.d.ts.map +1 -0
  69. package/dist/usePlaybackSession.js +120 -0
  70. package/dist/usePlaybackSession.js.map +1 -0
  71. package/package.json +39 -0
@@ -0,0 +1,170 @@
1
+ import type { Element, Source } from '@clipkit/protocol';
2
+ export declare function isVisualElement(el: Element): boolean;
3
+ /**
4
+ * Parse a Clipkit anchor value (number 0..1, or `"50%"` style string).
5
+ * Returns the fallback if the value isn't parseable. The fallback defaults
6
+ * to 0 (top-left) to match the runtime's anchor default — see resolveAnchor
7
+ * in @clipkit/runtime. Pass an explicit fallback for pivot math (centre).
8
+ */
9
+ export declare function parseAnchor(v: unknown, fallback?: number): number;
10
+ export interface SourceBox {
11
+ /** Top-left x in source coordinates. */
12
+ x: number;
13
+ /** Top-left y in source coordinates. */
14
+ y: number;
15
+ /** Width in source pixels. */
16
+ w: number;
17
+ /** Height in source pixels. */
18
+ h: number;
19
+ }
20
+ /**
21
+ * Hook for resolving an element's animated/expression-driven box at the
22
+ * playhead. editor-core stays renderer-free, so the editor package injects
23
+ * `evalExpr` (from `@clipkit/runtime`) and the global playhead `time`; the
24
+ * box then tracks the evaluated expression frame-for-frame, exactly like
25
+ * the render. Without it, expression values fall back to their leading
26
+ * constant (`parseFloat(expr)`) so the box is at least in the right place.
27
+ */
28
+ export interface BoxResolveOpts {
29
+ /** Global playhead time in seconds (element-local time is derived from `el.time`). */
30
+ time?: number;
31
+ /** Tier-A expression evaluator, injected from `@clipkit/runtime`. */
32
+ evalExpr?: (value: {
33
+ expr: string;
34
+ }, scope: {
35
+ t: number;
36
+ dur: number;
37
+ i: number;
38
+ n: number;
39
+ value: number;
40
+ }) => number;
41
+ }
42
+ /**
43
+ * Resolve an element's bounding box in source space, accounting for
44
+ * x/y_anchor + percent / vw / vh / vmin / vmax / "auto" sizing, and
45
+ * Tier-A expressions (evaluated at the playhead when `opts.evalExpr` is
46
+ * supplied — see {@link BoxResolveOpts}).
47
+ *
48
+ * For numeric sizes this is exact. For percent-based sizes we resolve
49
+ * against the canvas dimensions. For `"auto"` on text/caption we
50
+ * measure via Canvas2D to estimate; on other element types we fall
51
+ * back to the canvas size so at least *some* clickable box renders
52
+ * (better than `null` → no selection box at all).
53
+ */
54
+ export declare function elementSourceBox(el: Element, source: Source, opts?: BoxResolveOpts): SourceBox | null;
55
+ /** Check whether `time` falls inside an element's [time, time+duration). */
56
+ export declare function isElementActive(el: Element, time: number, sourceDuration: number): boolean;
57
+ /** Element rotation in degrees, defaulting to 0. */
58
+ export declare function elementRotation(el: Element): number;
59
+ /**
60
+ * Find the topmost element under a source-space point. Walks elements
61
+ * in ascending layer order (layer 1 = rendered last = on top). Filters
62
+ * to active + visual elements. For rotated elements, the hit point is
63
+ * inverse-rotated into the element's local frame before the bounds
64
+ * test. Composition recursion deferred to a later phase.
65
+ */
66
+ export declare function hitTest(elements: readonly Element[], source: Source, point: {
67
+ x: number;
68
+ y: number;
69
+ }, playhead: number, sourceDuration: number): Element | null;
70
+ /**
71
+ * Marquee box-select: ids of every visual, active element whose source-space
72
+ * bounding box intersects the given source-space rectangle. Rotation is ignored
73
+ * for the test (the un-rotated AABB is a good-enough selection bound).
74
+ */
75
+ export declare function boxSelect(elements: readonly Element[], source: Source, rect: {
76
+ x0: number;
77
+ y0: number;
78
+ x1: number;
79
+ y1: number;
80
+ }, playhead: number, sourceDuration: number): string[];
81
+ /**
82
+ * Walk a group drill-down path. Returns the scoped child elements (the deepest
83
+ * entered group's `elements`, or the root when the path is empty) plus the group
84
+ * elements crossed (for breadcrumbs). A stale id stops the walk early.
85
+ */
86
+ export declare function resolveGroupPath(rootElements: readonly Element[], groupPath: readonly string[]): {
87
+ elements: readonly Element[];
88
+ crumbs: Element[];
89
+ offset: {
90
+ x: number;
91
+ y: number;
92
+ };
93
+ timeOffset: number;
94
+ };
95
+ /** Apply a rotation (degrees) around (0,0). */
96
+ export declare function rotateVec(v: {
97
+ x: number;
98
+ y: number;
99
+ }, degrees: number): {
100
+ x: number;
101
+ y: number;
102
+ };
103
+ /** Apply the inverse rotation (degrees) around (0,0). */
104
+ export declare function inverseRotate(v: {
105
+ x: number;
106
+ y: number;
107
+ }, degrees: number): {
108
+ x: number;
109
+ y: number;
110
+ };
111
+ /**
112
+ * Convert client (screen) coordinates to source-space coordinates,
113
+ * using the viewport's bounding rect and the current zoom + pan.
114
+ */
115
+ export declare function screenToSource(clientX: number, clientY: number, viewportRect: DOMRect, zoom: number, pan: {
116
+ x: number;
117
+ y: number;
118
+ }): {
119
+ x: number;
120
+ y: number;
121
+ };
122
+ /** The 8 resize handles around the bounding box. */
123
+ export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
124
+ export declare const RESIZE_HANDLES: readonly ResizeHandle[];
125
+ export declare const HANDLE_CURSOR: Record<ResizeHandle, string>;
126
+ /** Position (as % of bounding box) where each handle sits. */
127
+ export declare const HANDLE_POSITION: Record<ResizeHandle, {
128
+ left: string;
129
+ top: string;
130
+ }>;
131
+ /** Initial element state captured at the start of a resize drag. */
132
+ export interface ResizeInitial {
133
+ x: number;
134
+ y: number;
135
+ width: number;
136
+ height: number;
137
+ xAnchor: number;
138
+ yAnchor: number;
139
+ }
140
+ /**
141
+ * Given an element's initial bounds + the cursor's current source
142
+ * position, compute the new element fields after a resize drag.
143
+ * Honors x/y_anchor (so the "fixed" edge stays put in source space)
144
+ * and a Shift-key aspect-ratio lock on corner handles.
145
+ */
146
+ export declare function computeResize(init: ResizeInitial, handle: ResizeHandle, cursorSourceX: number, cursorSourceY: number, shiftKey: boolean): {
147
+ x: number;
148
+ y: number;
149
+ width: number;
150
+ height: number;
151
+ };
152
+ /**
153
+ * Compute the cursor angle relative to the element's anchor, measured
154
+ * clockwise from "up" (12 o'clock), in degrees.
155
+ *
156
+ * straight up → 0°
157
+ * straight right → 90°
158
+ * straight down → 180°
159
+ * straight left → 270°
160
+ *
161
+ * Matches the convention used by `rotation` in the Clipkit schema.
162
+ */
163
+ export declare function angleFromAnchor(anchorX: number, anchorY: number, cursorX: number, cursorY: number): number;
164
+ /**
165
+ * Given the initial element rotation + initial cursor angle + current
166
+ * cursor angle, compute the new rotation. Shift snaps to 15°
167
+ * increments (standard editor convention).
168
+ */
169
+ export declare function computeRotation(initialRotation: number, initialCursorAngle: number, cursorAngle: number, shiftKey: boolean): number;
170
+ //# sourceMappingURL=stage-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stage-utils.d.ts","sourceRoot":"","sources":["../src/stage-utils.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,476 @@
1
+ // Pure helpers for the Stage / StageOverlay. No React, no DOM beyond
2
+ // `DOMRect` for coordinate conversion. Hit-testing, anchor parsing,
3
+ // and source ↔ screen transforms.
4
+ const VISUAL_ELEMENT_TYPES = new Set([
5
+ 'text',
6
+ 'shape',
7
+ 'image',
8
+ 'video',
9
+ 'caption',
10
+ 'particles',
11
+ 'group',
12
+ ]);
13
+ export function isVisualElement(el) {
14
+ return VISUAL_ELEMENT_TYPES.has(el.type);
15
+ }
16
+ /**
17
+ * Parse a Clipkit anchor value (number 0..1, or `"50%"` style string).
18
+ * Returns the fallback if the value isn't parseable. The fallback defaults
19
+ * to 0 (top-left) to match the runtime's anchor default — see resolveAnchor
20
+ * in @clipkit/runtime. Pass an explicit fallback for pivot math (centre).
21
+ */
22
+ export function parseAnchor(v, fallback = 0) {
23
+ if (typeof v === 'number')
24
+ return v;
25
+ if (typeof v === 'string') {
26
+ const m = /^(-?\d+(?:\.\d+)?)%$/.exec(v);
27
+ if (m && m[1])
28
+ return parseFloat(m[1]) / 100;
29
+ }
30
+ return fallback;
31
+ }
32
+ /**
33
+ * Resolve a Clipkit length value (number, "Npx", "N%", "Nvw|vh|vmin|vmax")
34
+ * to a numeric pixel value. Returns null when the value is missing,
35
+ * `"auto"`, or otherwise needs rendered context to resolve.
36
+ *
37
+ * Mirrors `resolveLength` in @clipkit/runtime — duplicated here so the
38
+ * editor doesn't have to depend on the renderer package for layout
39
+ * math.
40
+ */
41
+ function resolveLength(value, ref, canvasW, canvasH) {
42
+ if (value == null)
43
+ return null;
44
+ if (typeof value === 'number')
45
+ return Number.isFinite(value) ? value : null;
46
+ if (typeof value !== 'string')
47
+ return null;
48
+ const s = value.trim();
49
+ if (s === '' || s === 'auto' || s === 'end')
50
+ return null;
51
+ const m = s.match(/^(-?\d*\.?\d+)\s*(px|%|vw|vh|vmin|vmax)?$/i);
52
+ if (!m)
53
+ return null;
54
+ const num = parseFloat(m[1]);
55
+ if (!Number.isFinite(num))
56
+ return null;
57
+ const unit = (m[2] || 'px').toLowerCase();
58
+ switch (unit) {
59
+ case 'px':
60
+ return num;
61
+ case '%':
62
+ return (num / 100) * ref;
63
+ case 'vw':
64
+ return (num / 100) * canvasW;
65
+ case 'vh':
66
+ return (num / 100) * canvasH;
67
+ case 'vmin':
68
+ return (num / 100) * Math.min(canvasW, canvasH);
69
+ case 'vmax':
70
+ return (num / 100) * Math.max(canvasW, canvasH);
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Rough text-bounds estimate via Canvas2D `measureText`. Good enough
77
+ * for a selection box — won't match the renderer's exact metrics (the
78
+ * runtime uses an SDF font atlas with its own kerning), but the box
79
+ * size + position lands close enough to click on. Used when a text or
80
+ * caption element has `width: "auto"` / `height: "auto"`.
81
+ */
82
+ function measureTextBounds(el) {
83
+ if (typeof document === 'undefined')
84
+ return null;
85
+ let text = '';
86
+ if (el.type === 'text') {
87
+ text = typeof el.text === 'string' ? el.text : '';
88
+ }
89
+ else if (el.type === 'caption') {
90
+ const words = el.words;
91
+ if (Array.isArray(words))
92
+ text = words.map((w) => w.text).join(' ');
93
+ }
94
+ else {
95
+ return null;
96
+ }
97
+ if (text === '')
98
+ text = ' ';
99
+ const fontFamily = typeof el.font_family === 'string' && el.font_family
100
+ ? el.font_family
101
+ : 'sans-serif';
102
+ const fontSize = typeof el.font_size === 'number' && Number.isFinite(el.font_size)
103
+ ? el.font_size
104
+ : 48;
105
+ const fontWeight = typeof el.font_weight === 'number' || typeof el.font_weight === 'string'
106
+ ? el.font_weight
107
+ : 400;
108
+ const lineHeight = typeof el.line_height === 'number' && el.line_height > 0
109
+ ? el.line_height
110
+ : 1.2;
111
+ const canvas = document.createElement('canvas');
112
+ const ctx = canvas.getContext('2d');
113
+ if (!ctx)
114
+ return null;
115
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
116
+ const lines = text.split('\n');
117
+ let maxW = 0;
118
+ for (const line of lines) {
119
+ const m = ctx.measureText(line);
120
+ if (m.width > maxW)
121
+ maxW = m.width;
122
+ }
123
+ return {
124
+ w: Math.ceil(maxW),
125
+ h: Math.ceil(lines.length * fontSize * lineHeight),
126
+ };
127
+ }
128
+ /** A Tier-A expression value: `{ expr: "300 + sin(t)" }`. */
129
+ function isExprValue(v) {
130
+ return typeof v === 'object' && v !== null
131
+ && typeof v.expr === 'string';
132
+ }
133
+ /** Evaluate an `{ expr }` length to a number, or null if it isn't one. */
134
+ function resolveExprLength(value, el, source, opts) {
135
+ if (!isExprValue(value))
136
+ return null;
137
+ if (opts?.evalExpr) {
138
+ const elTime = typeof el.time === 'number' ? el.time : 0;
139
+ const elDur = typeof el.duration === 'number'
140
+ ? el.duration
141
+ : (typeof source.duration === 'number' ? source.duration : 0) - elTime;
142
+ const t = (opts.time ?? 0) - elTime;
143
+ const n = opts.evalExpr(value, { t, dur: elDur, i: 0, n: 1, value: 0 });
144
+ if (Number.isFinite(n))
145
+ return n;
146
+ }
147
+ // Renderer-free fallback: the expression's leading constant.
148
+ const n = parseFloat(value.expr);
149
+ return Number.isFinite(n) ? n : null;
150
+ }
151
+ /**
152
+ * Resolve an element's bounding box in source space, accounting for
153
+ * x/y_anchor + percent / vw / vh / vmin / vmax / "auto" sizing, and
154
+ * Tier-A expressions (evaluated at the playhead when `opts.evalExpr` is
155
+ * supplied — see {@link BoxResolveOpts}).
156
+ *
157
+ * For numeric sizes this is exact. For percent-based sizes we resolve
158
+ * against the canvas dimensions. For `"auto"` on text/caption we
159
+ * measure via Canvas2D to estimate; on other element types we fall
160
+ * back to the canvas size so at least *some* clickable box renders
161
+ * (better than `null` → no selection box at all).
162
+ */
163
+ export function elementSourceBox(el, source, opts) {
164
+ const sw = source.width ?? 1920;
165
+ const sh = source.height ?? 1080;
166
+ let w = resolveLength(el.width, sw, sw, sh) ?? resolveExprLength(el.width, el, source, opts);
167
+ let h = resolveLength(el.height, sh, sw, sh) ?? resolveExprLength(el.height, el, source, opts);
168
+ if (w == null || h == null) {
169
+ const measured = el.type === 'text' || el.type === 'caption'
170
+ ? measureTextBounds(el)
171
+ : null;
172
+ if (measured) {
173
+ if (w == null)
174
+ w = measured.w;
175
+ if (h == null)
176
+ h = measured.h;
177
+ }
178
+ // Last-resort fallback for elements with no numeric size and no
179
+ // measurable text (image/video with `width: "auto"`, etc.). Use
180
+ // the full canvas so the box is at least selectable.
181
+ if (w == null)
182
+ w = sw;
183
+ if (h == null)
184
+ h = sh;
185
+ }
186
+ const x = resolveLength(el.x, sw, sw, sh) ??
187
+ resolveExprLength(el.x, el, source, opts) ??
188
+ (typeof el.x === 'number' ? el.x : sw / 2);
189
+ const y = resolveLength(el.y, sh, sw, sh) ??
190
+ resolveExprLength(el.y, el, source, opts) ??
191
+ (typeof el.y === 'number' ? el.y : sh / 2);
192
+ const ax = parseAnchor(el.x_anchor);
193
+ const ay = parseAnchor(el.y_anchor);
194
+ return { x: x - w * ax, y: y - h * ay, w, h };
195
+ }
196
+ /** Check whether `time` falls inside an element's [time, time+duration). */
197
+ export function isElementActive(el, time, sourceDuration) {
198
+ const elTime = typeof el.time === 'number' ? el.time : 0;
199
+ const elDur = typeof el.duration === 'number'
200
+ ? el.duration
201
+ : sourceDuration - elTime;
202
+ return time >= elTime && time < elTime + elDur;
203
+ }
204
+ /** Element rotation in degrees, defaulting to 0. */
205
+ export function elementRotation(el) {
206
+ return typeof el.rotation === 'number' ? el.rotation : 0;
207
+ }
208
+ /**
209
+ * Find the topmost element under a source-space point. Walks elements
210
+ * in ascending layer order (layer 1 = rendered last = on top). Filters
211
+ * to active + visual elements. For rotated elements, the hit point is
212
+ * inverse-rotated into the element's local frame before the bounds
213
+ * test. Composition recursion deferred to a later phase.
214
+ */
215
+ export function hitTest(elements, source, point, playhead, sourceDuration) {
216
+ const candidates = elements
217
+ .filter(isVisualElement)
218
+ .filter((el) => isElementActive(el, playhead, sourceDuration))
219
+ .slice()
220
+ .sort((a, b) => {
221
+ const la = typeof a.layer === 'number' ? a.layer : 1;
222
+ const lb = typeof b.layer === 'number' ? b.layer : 1;
223
+ return la - lb;
224
+ });
225
+ for (const el of candidates) {
226
+ const box = elementSourceBox(el, source);
227
+ if (!box)
228
+ continue;
229
+ const rotation = elementRotation(el);
230
+ if (rotation === 0) {
231
+ if (point.x >= box.x &&
232
+ point.x <= box.x + box.w &&
233
+ point.y >= box.y &&
234
+ point.y <= box.y + box.h) {
235
+ return el;
236
+ }
237
+ continue;
238
+ }
239
+ // Rotated: inverse-rotate the hit point around the box CENTRE. The
240
+ // runtime pivots rotation/scale at the geometric centre regardless of
241
+ // anchor (see resolveAnchor → anchorToCenter), so hit-testing must too.
242
+ const cx = box.x + box.w * 0.5;
243
+ const cy = box.y + box.h * 0.5;
244
+ const local = inverseRotate({ x: point.x - cx, y: point.y - cy }, rotation);
245
+ const localLeft = -box.w * 0.5;
246
+ const localRight = box.w * 0.5;
247
+ const localTop = -box.h * 0.5;
248
+ const localBottom = box.h * 0.5;
249
+ if (local.x >= localLeft &&
250
+ local.x <= localRight &&
251
+ local.y >= localTop &&
252
+ local.y <= localBottom) {
253
+ return el;
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+ /**
259
+ * Marquee box-select: ids of every visual, active element whose source-space
260
+ * bounding box intersects the given source-space rectangle. Rotation is ignored
261
+ * for the test (the un-rotated AABB is a good-enough selection bound).
262
+ */
263
+ export function boxSelect(elements, source, rect, playhead, sourceDuration) {
264
+ const ml = Math.min(rect.x0, rect.x1), mr = Math.max(rect.x0, rect.x1);
265
+ const mt = Math.min(rect.y0, rect.y1), mb = Math.max(rect.y0, rect.y1);
266
+ const out = [];
267
+ for (const el of elements) {
268
+ if (!isVisualElement(el) || !isElementActive(el, playhead, sourceDuration))
269
+ continue;
270
+ if (typeof el.id !== 'string')
271
+ continue;
272
+ const box = elementSourceBox(el, source);
273
+ if (!box)
274
+ continue;
275
+ if (box.x < mr && box.x + box.w > ml && box.y < mb && box.y + box.h > mt)
276
+ out.push(el.id);
277
+ }
278
+ return out;
279
+ }
280
+ /**
281
+ * Walk a group drill-down path. Returns the scoped child elements (the deepest
282
+ * entered group's `elements`, or the root when the path is empty) plus the group
283
+ * elements crossed (for breadcrumbs). A stale id stops the walk early.
284
+ */
285
+ export function resolveGroupPath(rootElements, groupPath) {
286
+ let els = rootElements;
287
+ const crumbs = [];
288
+ // Cumulative child transform of the entered groups. A group translates its
289
+ // children by its center, and its children's time is local to its start — so
290
+ // child boxes/hit-tests/playhead map through `offset` + `timeOffset`. Pure
291
+ // translate composition (scale/rotation on a group aren't baked here yet).
292
+ let ox = 0, oy = 0, timeOffset = 0;
293
+ for (const id of groupPath) {
294
+ const g = els.find((e) => e.id === id && e.type === 'group');
295
+ if (!g || !Array.isArray(g.elements))
296
+ break;
297
+ crumbs.push(g);
298
+ const gx = typeof g.x === 'number' ? g.x : 0;
299
+ const gy = typeof g.y === 'number' ? g.y : 0;
300
+ const gw = typeof g.width === 'number' ? g.width : 0;
301
+ const gh = typeof g.height === 'number' ? g.height : 0;
302
+ // A group's children sit relative to its TOP-LEFT corner (verified against
303
+ // the runtime), i.e. anchor-point minus the anchored fraction of the box.
304
+ ox += gx - parseAnchor(g.x_anchor) * gw;
305
+ oy += gy - parseAnchor(g.y_anchor) * gh;
306
+ timeOffset += typeof g.time === 'number' ? g.time : 0;
307
+ els = g.elements;
308
+ }
309
+ return { elements: els, crumbs, offset: { x: ox, y: oy }, timeOffset };
310
+ }
311
+ /** Apply a rotation (degrees) around (0,0). */
312
+ export function rotateVec(v, degrees) {
313
+ const rad = (degrees * Math.PI) / 180;
314
+ const c = Math.cos(rad);
315
+ const s = Math.sin(rad);
316
+ return { x: v.x * c - v.y * s, y: v.x * s + v.y * c };
317
+ }
318
+ /** Apply the inverse rotation (degrees) around (0,0). */
319
+ export function inverseRotate(v, degrees) {
320
+ return rotateVec(v, -degrees);
321
+ }
322
+ /**
323
+ * Convert client (screen) coordinates to source-space coordinates,
324
+ * using the viewport's bounding rect and the current zoom + pan.
325
+ */
326
+ export function screenToSource(clientX, clientY, viewportRect, zoom, pan) {
327
+ return {
328
+ x: (clientX - viewportRect.left - pan.x) / zoom,
329
+ y: (clientY - viewportRect.top - pan.y) / zoom,
330
+ };
331
+ }
332
+ export const RESIZE_HANDLES = [
333
+ 'nw',
334
+ 'n',
335
+ 'ne',
336
+ 'e',
337
+ 'se',
338
+ 's',
339
+ 'sw',
340
+ 'w',
341
+ ];
342
+ /** Which edges of the bounding box each handle controls. */
343
+ const HANDLE_EDGES = {
344
+ nw: { left: true, top: true },
345
+ n: { top: true },
346
+ ne: { right: true, top: true },
347
+ e: { right: true },
348
+ se: { right: true, bottom: true },
349
+ s: { bottom: true },
350
+ sw: { left: true, bottom: true },
351
+ w: { left: true },
352
+ };
353
+ export const HANDLE_CURSOR = {
354
+ nw: 'nwse-resize',
355
+ n: 'ns-resize',
356
+ ne: 'nesw-resize',
357
+ e: 'ew-resize',
358
+ se: 'nwse-resize',
359
+ s: 'ns-resize',
360
+ sw: 'nesw-resize',
361
+ w: 'ew-resize',
362
+ };
363
+ /** Position (as % of bounding box) where each handle sits. */
364
+ export const HANDLE_POSITION = {
365
+ nw: { left: '0%', top: '0%' },
366
+ n: { left: '50%', top: '0%' },
367
+ ne: { left: '100%', top: '0%' },
368
+ e: { left: '100%', top: '50%' },
369
+ se: { left: '100%', top: '100%' },
370
+ s: { left: '50%', top: '100%' },
371
+ sw: { left: '0%', top: '100%' },
372
+ w: { left: '0%', top: '50%' },
373
+ };
374
+ const MIN_DIMENSION = 8;
375
+ /**
376
+ * Given an element's initial bounds + the cursor's current source
377
+ * position, compute the new element fields after a resize drag.
378
+ * Honors x/y_anchor (so the "fixed" edge stays put in source space)
379
+ * and a Shift-key aspect-ratio lock on corner handles.
380
+ */
381
+ export function computeResize(init, handle, cursorSourceX, cursorSourceY, shiftKey) {
382
+ const edges = HANDLE_EDGES[handle];
383
+ // Initial bounding box in source space (top-left + bottom-right).
384
+ const initLeft = init.x - init.width * init.xAnchor;
385
+ const initTop = init.y - init.height * init.yAnchor;
386
+ const initRight = initLeft + init.width;
387
+ const initBottom = initTop + init.height;
388
+ let newLeft = initLeft;
389
+ let newTop = initTop;
390
+ let newRight = initRight;
391
+ let newBottom = initBottom;
392
+ if (edges.left)
393
+ newLeft = cursorSourceX;
394
+ if (edges.right)
395
+ newRight = cursorSourceX;
396
+ if (edges.top)
397
+ newTop = cursorSourceY;
398
+ if (edges.bottom)
399
+ newBottom = cursorSourceY;
400
+ // Enforce minimum size — clamp the moving edge so the box never
401
+ // collapses to zero (or flips inside-out).
402
+ if (newRight - newLeft < MIN_DIMENSION) {
403
+ if (edges.left)
404
+ newLeft = newRight - MIN_DIMENSION;
405
+ else
406
+ newRight = newLeft + MIN_DIMENSION;
407
+ }
408
+ if (newBottom - newTop < MIN_DIMENSION) {
409
+ if (edges.top)
410
+ newTop = newBottom - MIN_DIMENSION;
411
+ else
412
+ newBottom = newTop + MIN_DIMENSION;
413
+ }
414
+ // Aspect-ratio lock on corner handles when Shift is held.
415
+ const isCorner = (edges.left || edges.right) && (edges.top || edges.bottom);
416
+ if (shiftKey && isCorner) {
417
+ const aspect = init.width / init.height;
418
+ const proposedW = newRight - newLeft;
419
+ const proposedH = newBottom - newTop;
420
+ // Pick the axis that moved further (relative to original).
421
+ const ratioW = proposedW / init.width;
422
+ const ratioH = proposedH / init.height;
423
+ if (ratioW > ratioH) {
424
+ const targetH = proposedW / aspect;
425
+ if (edges.top)
426
+ newTop = newBottom - targetH;
427
+ else
428
+ newBottom = newTop + targetH;
429
+ }
430
+ else {
431
+ const targetW = proposedH * aspect;
432
+ if (edges.left)
433
+ newLeft = newRight - targetW;
434
+ else
435
+ newRight = newLeft + targetW;
436
+ }
437
+ }
438
+ const newWidth = newRight - newLeft;
439
+ const newHeight = newBottom - newTop;
440
+ const newX = newLeft + newWidth * init.xAnchor;
441
+ const newY = newTop + newHeight * init.yAnchor;
442
+ return { x: newX, y: newY, width: newWidth, height: newHeight };
443
+ }
444
+ // ── Rotation handle ────────────────────────────────────────────────
445
+ const ROTATION_SNAP_DEG = 15;
446
+ /**
447
+ * Compute the cursor angle relative to the element's anchor, measured
448
+ * clockwise from "up" (12 o'clock), in degrees.
449
+ *
450
+ * straight up → 0°
451
+ * straight right → 90°
452
+ * straight down → 180°
453
+ * straight left → 270°
454
+ *
455
+ * Matches the convention used by `rotation` in the Clipkit schema.
456
+ */
457
+ export function angleFromAnchor(anchorX, anchorY, cursorX, cursorY) {
458
+ const dx = cursorX - anchorX;
459
+ const dy = cursorY - anchorY;
460
+ // atan2(dx, -dy): the negation on dy flips Y so "up" is 0 and the
461
+ // angle increases clockwise (canvas Y is down).
462
+ return (Math.atan2(dx, -dy) * 180) / Math.PI;
463
+ }
464
+ /**
465
+ * Given the initial element rotation + initial cursor angle + current
466
+ * cursor angle, compute the new rotation. Shift snaps to 15°
467
+ * increments (standard editor convention).
468
+ */
469
+ export function computeRotation(initialRotation, initialCursorAngle, cursorAngle, shiftKey) {
470
+ const delta = cursorAngle - initialCursorAngle;
471
+ const raw = initialRotation + delta;
472
+ if (!shiftKey)
473
+ return raw;
474
+ return Math.round(raw / ROTATION_SNAP_DEG) * ROTATION_SNAP_DEG;
475
+ }
476
+ //# sourceMappingURL=stage-utils.js.map