@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.
- package/LICENSE +201 -0
- package/README.md +20 -0
- package/dist/asset-store.d.ts +40 -0
- package/dist/asset-store.d.ts.map +1 -0
- package/dist/asset-store.js +181 -0
- package/dist/asset-store.js.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +23 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/node-graph.d.ts +42 -0
- package/dist/node-graph.d.ts.map +1 -0
- package/dist/node-graph.js +123 -0
- package/dist/node-graph.js.map +1 -0
- package/dist/registry/build.d.ts +3 -0
- package/dist/registry/build.d.ts.map +1 -0
- package/dist/registry/build.js +84 -0
- package/dist/registry/build.js.map +1 -0
- package/dist/registry/configuration.d.ts +51 -0
- package/dist/registry/configuration.d.ts.map +1 -0
- package/dist/registry/configuration.js +92 -0
- package/dist/registry/configuration.js.map +1 -0
- package/dist/registry/derive.d.ts +30 -0
- package/dist/registry/derive.d.ts.map +1 -0
- package/dist/registry/derive.js +212 -0
- package/dist/registry/derive.js.map +1 -0
- package/dist/registry/overrides.d.ts +7 -0
- package/dist/registry/overrides.d.ts.map +1 -0
- package/dist/registry/overrides.js +322 -0
- package/dist/registry/overrides.js.map +1 -0
- package/dist/registry/types.d.ts +58 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +7 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/source-diff.d.ts +8 -0
- package/dist/source-diff.d.ts.map +1 -0
- package/dist/source-diff.js +148 -0
- package/dist/source-diff.js.map +1 -0
- package/dist/stage-utils.d.ts +170 -0
- package/dist/stage-utils.d.ts.map +1 -0
- package/dist/stage-utils.js +476 -0
- package/dist/stage-utils.js.map +1 -0
- package/dist/store.d.ts +123 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +234 -0
- package/dist/store.js.map +1 -0
- package/dist/timeline-utils.d.ts +69 -0
- package/dist/timeline-utils.d.ts.map +1 -0
- package/dist/timeline-utils.js +211 -0
- package/dist/timeline-utils.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/useEditor.d.ts +77 -0
- package/dist/useEditor.d.ts.map +1 -0
- package/dist/useEditor.js +74 -0
- package/dist/useEditor.js.map +1 -0
- package/dist/useEditorStore.d.ts +3 -0
- package/dist/useEditorStore.d.ts.map +1 -0
- package/dist/useEditorStore.js +15 -0
- package/dist/useEditorStore.js.map +1 -0
- package/dist/usePlaybackSession.d.ts +20 -0
- package/dist/usePlaybackSession.d.ts.map +1 -0
- package/dist/usePlaybackSession.js +120 -0
- package/dist/usePlaybackSession.js.map +1 -0
- 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
|