@hyperframes/studio 0.5.0-alpha.5 → 0.5.0-alpha.7
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-0Zt0t13W.css +1 -0
- package/dist/assets/index-CDSQavT7.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +6 -2
- package/src/components/editor/DomEditOverlay.tsx +36 -4
- package/src/components/editor/domEditing.test.ts +73 -0
- package/src/components/editor/domEditing.ts +29 -3
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/hooks/useTimelinePlayer.test.ts +59 -0
- package/src/player/hooks/useTimelinePlayer.ts +70 -27
- package/dist/assets/index-Ba6SZOXW.js +0 -105
- package/dist/assets/index-BpcIkyVP.css +0 -1
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CDSQavT7.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-0Zt0t13W.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.7",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.5.0-alpha.
|
|
36
|
-
"@hyperframes/player": "0.5.0-alpha.
|
|
35
|
+
"@hyperframes/core": "0.5.0-alpha.7",
|
|
36
|
+
"@hyperframes/player": "0.5.0-alpha.7"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.5.0-alpha.
|
|
50
|
+
"@hyperframes/producer": "0.5.0-alpha.7"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -841,6 +841,8 @@ export function StudioApp() {
|
|
|
841
841
|
label={el.id || el.tag}
|
|
842
842
|
labelColor={style.label}
|
|
843
843
|
accentColor={style.clip}
|
|
844
|
+
selector={el.selector}
|
|
845
|
+
selectorIndex={el.selectorIndex}
|
|
844
846
|
seekTime={el.start}
|
|
845
847
|
duration={el.duration}
|
|
846
848
|
/>
|
|
@@ -901,6 +903,8 @@ export function StudioApp() {
|
|
|
901
903
|
label={el.id || el.tag}
|
|
902
904
|
labelColor={style.label}
|
|
903
905
|
accentColor={style.clip}
|
|
906
|
+
selector={el.selector}
|
|
907
|
+
selectorIndex={el.selectorIndex}
|
|
904
908
|
seekTime={el.start}
|
|
905
909
|
duration={el.duration}
|
|
906
910
|
/>
|
|
@@ -1947,7 +1951,7 @@ export function StudioApp() {
|
|
|
1947
1951
|
);
|
|
1948
1952
|
|
|
1949
1953
|
const handlePreviewCanvasMouseDown = useCallback(
|
|
1950
|
-
(e: React.MouseEvent<HTMLDivElement
|
|
1954
|
+
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
1951
1955
|
const iframe = previewIframeRef.current;
|
|
1952
1956
|
if (!iframe || captionEditMode) return;
|
|
1953
1957
|
const target = getPreviewTargetFromPointer(iframe, e.clientX, e.clientY);
|
|
@@ -1959,7 +1963,7 @@ export function StudioApp() {
|
|
|
1959
1963
|
e.preventDefault();
|
|
1960
1964
|
e.stopPropagation();
|
|
1961
1965
|
const nextSelection = buildDomSelectionFromTarget(target, {
|
|
1962
|
-
preferClipAncestor: true,
|
|
1966
|
+
preferClipAncestor: options?.preferClipAncestor ?? true,
|
|
1963
1967
|
});
|
|
1964
1968
|
if (!nextSelection) {
|
|
1965
1969
|
lastPreviewClickRef.current = null;
|
|
@@ -14,7 +14,10 @@ interface OverlayRect {
|
|
|
14
14
|
interface DomEditOverlayProps {
|
|
15
15
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
16
16
|
selection: DomEditSelection | null;
|
|
17
|
-
onCanvasMouseDown: (
|
|
17
|
+
onCanvasMouseDown: (
|
|
18
|
+
event: React.MouseEvent<HTMLDivElement>,
|
|
19
|
+
options?: { preferClipAncestor?: boolean },
|
|
20
|
+
) => void;
|
|
18
21
|
onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
19
22
|
onSelectedDoubleClick: () => void;
|
|
20
23
|
onBlockedMove: (selection: DomEditSelection) => void;
|
|
@@ -85,10 +88,21 @@ function selectionCacheKey(
|
|
|
85
88
|
].join("|");
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
function restoreInlineStyle(
|
|
92
|
+
element: HTMLElement,
|
|
93
|
+
property: "left" | "top" | "width" | "height",
|
|
94
|
+
value: string,
|
|
95
|
+
) {
|
|
96
|
+
if (value) element.style.setProperty(property, value);
|
|
97
|
+
else element.style.removeProperty(property);
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
interface GestureState {
|
|
89
101
|
kind: GestureKind;
|
|
90
102
|
startX: number;
|
|
91
103
|
startY: number;
|
|
104
|
+
initialStyleLeft: string;
|
|
105
|
+
initialStyleTop: string;
|
|
92
106
|
originLeft: number;
|
|
93
107
|
originTop: number;
|
|
94
108
|
originWidth: number;
|
|
@@ -226,6 +240,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
226
240
|
kind,
|
|
227
241
|
startX: e.clientX,
|
|
228
242
|
startY: e.clientY,
|
|
243
|
+
initialStyleLeft: sel.element.style.left,
|
|
244
|
+
initialStyleTop: sel.element.style.top,
|
|
229
245
|
originLeft: rect.left,
|
|
230
246
|
originTop: rect.top,
|
|
231
247
|
originWidth: rect.width,
|
|
@@ -277,9 +293,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
277
293
|
}
|
|
278
294
|
};
|
|
279
295
|
|
|
280
|
-
const onPointerUp = () => {
|
|
296
|
+
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
281
297
|
const g = gestureRef.current;
|
|
282
298
|
const sel = selectionRef.current;
|
|
299
|
+
const box = boxRef.current;
|
|
283
300
|
blockedMoveRef.current = null;
|
|
284
301
|
if (!g || !sel) {
|
|
285
302
|
gestureRef.current = null;
|
|
@@ -290,6 +307,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
290
307
|
gestureRef.current = null;
|
|
291
308
|
rafPausedRef.current = false;
|
|
292
309
|
|
|
310
|
+
const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
|
|
311
|
+
if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
|
|
312
|
+
restoreInlineStyle(sel.element, "left", g.initialStyleLeft);
|
|
313
|
+
restoreInlineStyle(sel.element, "top", g.initialStyleTop);
|
|
314
|
+
if (box) {
|
|
315
|
+
box.style.left = `${g.originLeft}px`;
|
|
316
|
+
box.style.top = `${g.originTop}px`;
|
|
317
|
+
}
|
|
318
|
+
suppressNextBoxClickRef.current = true;
|
|
319
|
+
onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
|
|
320
|
+
preferClipAncestor: false,
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
293
325
|
if (g.kind === "drag") {
|
|
294
326
|
const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
|
|
295
327
|
const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
|
|
@@ -320,7 +352,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
320
352
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
321
353
|
const target = event.target as HTMLElement | null;
|
|
322
354
|
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
323
|
-
onCanvasMouseDown(event);
|
|
355
|
+
onCanvasMouseDown(event, { preferClipAncestor: false });
|
|
324
356
|
};
|
|
325
357
|
|
|
326
358
|
const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
@@ -339,7 +371,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
339
371
|
event.stopPropagation();
|
|
340
372
|
return;
|
|
341
373
|
}
|
|
342
|
-
onCanvasMouseDown(event);
|
|
374
|
+
onCanvasMouseDown(event, { preferClipAncestor: false });
|
|
343
375
|
};
|
|
344
376
|
|
|
345
377
|
const clearPointerState = () => {
|
|
@@ -108,6 +108,61 @@ describe("resolveDomEditCapabilities", () => {
|
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it("treats identity transforms left behind by animation libraries as movable", () => {
|
|
112
|
+
expect(
|
|
113
|
+
resolveDomEditCapabilities({
|
|
114
|
+
selector: "#card",
|
|
115
|
+
inlineStyles: {
|
|
116
|
+
left: "120px",
|
|
117
|
+
top: "80px",
|
|
118
|
+
width: "240px",
|
|
119
|
+
height: "140px",
|
|
120
|
+
},
|
|
121
|
+
computedStyles: {
|
|
122
|
+
position: "absolute",
|
|
123
|
+
left: "120px",
|
|
124
|
+
top: "80px",
|
|
125
|
+
width: "240px",
|
|
126
|
+
height: "140px",
|
|
127
|
+
transform: "matrix(1, 0, 0, 1, 0, 0)",
|
|
128
|
+
},
|
|
129
|
+
isCompositionHost: false,
|
|
130
|
+
isMasterView: false,
|
|
131
|
+
}),
|
|
132
|
+
).toMatchObject({
|
|
133
|
+
canMove: true,
|
|
134
|
+
canResize: true,
|
|
135
|
+
canDetachFromLayout: false,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats identity matrix3d transforms as movable", () => {
|
|
140
|
+
expect(
|
|
141
|
+
resolveDomEditCapabilities({
|
|
142
|
+
selector: "#card",
|
|
143
|
+
inlineStyles: {
|
|
144
|
+
left: "120px",
|
|
145
|
+
top: "80px",
|
|
146
|
+
width: "240px",
|
|
147
|
+
height: "140px",
|
|
148
|
+
},
|
|
149
|
+
computedStyles: {
|
|
150
|
+
position: "absolute",
|
|
151
|
+
left: "120px",
|
|
152
|
+
top: "80px",
|
|
153
|
+
width: "240px",
|
|
154
|
+
height: "140px",
|
|
155
|
+
transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
|
|
156
|
+
},
|
|
157
|
+
isCompositionHost: false,
|
|
158
|
+
isMasterView: false,
|
|
159
|
+
}),
|
|
160
|
+
).toMatchObject({
|
|
161
|
+
canMove: true,
|
|
162
|
+
canResize: true,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
111
166
|
it("allows imported absolute media to resize from computed px geometry", () => {
|
|
112
167
|
expect(
|
|
113
168
|
resolveDomEditCapabilities({
|
|
@@ -228,6 +283,24 @@ describe("resolveDomEditSelection", () => {
|
|
|
228
283
|
expect(selection?.selector).toBe("#card");
|
|
229
284
|
});
|
|
230
285
|
|
|
286
|
+
it("can resolve the exact child when clip-ancestor preference is disabled", () => {
|
|
287
|
+
const document = createDocument(`
|
|
288
|
+
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
|
289
|
+
<p id="copy">Hello</p>
|
|
290
|
+
</section>
|
|
291
|
+
`);
|
|
292
|
+
|
|
293
|
+
const child = document.getElementById("copy") as HTMLElement;
|
|
294
|
+
const selection = resolveDomEditSelection(child, {
|
|
295
|
+
activeCompositionPath: null,
|
|
296
|
+
isMasterView: false,
|
|
297
|
+
preferClipAncestor: false,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(selection?.id).toBe("copy");
|
|
301
|
+
expect(selection?.selector).toBe("#copy");
|
|
302
|
+
});
|
|
303
|
+
|
|
231
304
|
it("collects simple child text blocks as separate editable fields", () => {
|
|
232
305
|
const document = createDocument(`
|
|
233
306
|
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
|
@@ -93,6 +93,32 @@ function parsePx(value: string | undefined): number | null {
|
|
|
93
93
|
return Number.isFinite(parsed) ? parsed : null;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function isIdentityTransform(value: string | undefined): boolean {
|
|
97
|
+
const transform = (value ?? "none").trim();
|
|
98
|
+
if (!transform || transform === "none") return true;
|
|
99
|
+
|
|
100
|
+
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
|
|
101
|
+
if (matrix) {
|
|
102
|
+
const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
103
|
+
if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
|
|
104
|
+
return (
|
|
105
|
+
Math.abs(values[0] - 1) < 0.0001 &&
|
|
106
|
+
Math.abs(values[1]) < 0.0001 &&
|
|
107
|
+
Math.abs(values[2]) < 0.0001 &&
|
|
108
|
+
Math.abs(values[3] - 1) < 0.0001 &&
|
|
109
|
+
Math.abs(values[4]) < 0.0001 &&
|
|
110
|
+
Math.abs(values[5]) < 0.0001
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
|
|
115
|
+
if (!matrix3d) return false;
|
|
116
|
+
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
117
|
+
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
|
|
118
|
+
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
119
|
+
return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
|
|
120
|
+
}
|
|
121
|
+
|
|
96
122
|
function isClipClassName(className: string | undefined): boolean {
|
|
97
123
|
return Boolean(className?.split(/\s+/).includes("clip"));
|
|
98
124
|
}
|
|
@@ -426,13 +452,13 @@ export function resolveDomEditCapabilities(args: {
|
|
|
426
452
|
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
|
|
427
453
|
const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
|
|
428
454
|
const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
|
|
429
|
-
const
|
|
455
|
+
const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
|
|
430
456
|
|
|
431
457
|
const canMove =
|
|
432
458
|
(position === "absolute" || position === "fixed") &&
|
|
433
459
|
left != null &&
|
|
434
460
|
top != null &&
|
|
435
|
-
|
|
461
|
+
!hasTransformDrivenGeometry;
|
|
436
462
|
|
|
437
463
|
const canResize = canMove && (width != null || height != null);
|
|
438
464
|
const isBlockishLayer =
|
|
@@ -442,7 +468,7 @@ export function resolveDomEditCapabilities(args: {
|
|
|
442
468
|
isBlockishDisplay(args.computedStyles.display);
|
|
443
469
|
const canDetachFromLayout =
|
|
444
470
|
!canMove &&
|
|
445
|
-
|
|
471
|
+
!hasTransformDrivenGeometry &&
|
|
446
472
|
isBlockishLayer &&
|
|
447
473
|
(!isInlineTextTag(args.tagName) || isClipClassName(args.className));
|
|
448
474
|
const reasonIfDisabled = !canMove
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
|
|
3
|
+
|
|
4
|
+
describe("buildCompositionThumbnailUrl", () => {
|
|
5
|
+
it("includes selector and occurrence index for precise element thumbnails", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCompositionThumbnailUrl({
|
|
8
|
+
previewUrl: "/api/projects/demo/preview",
|
|
9
|
+
seekTime: 1,
|
|
10
|
+
duration: 2,
|
|
11
|
+
selector: ".card",
|
|
12
|
+
selectorIndex: 2,
|
|
13
|
+
origin: "http://localhost:3000",
|
|
14
|
+
}),
|
|
15
|
+
).toBe(
|
|
16
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v2&selector=.card&selectorIndex=2",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
|
|
|
7
7
|
labelColor: string;
|
|
8
8
|
accentColor?: string;
|
|
9
9
|
selector?: string;
|
|
10
|
+
selectorIndex?: number;
|
|
10
11
|
seekTime?: number;
|
|
11
12
|
duration?: number;
|
|
12
13
|
width?: number;
|
|
@@ -16,12 +17,44 @@ interface CompositionThumbnailProps {
|
|
|
16
17
|
const CLIP_HEIGHT = 66;
|
|
17
18
|
const THUMBNAIL_URL_VERSION = "v2";
|
|
18
19
|
|
|
20
|
+
export function buildCompositionThumbnailUrl({
|
|
21
|
+
previewUrl,
|
|
22
|
+
seekTime = 2,
|
|
23
|
+
duration = 5,
|
|
24
|
+
selector,
|
|
25
|
+
selectorIndex,
|
|
26
|
+
origin,
|
|
27
|
+
}: {
|
|
28
|
+
previewUrl: string;
|
|
29
|
+
seekTime?: number;
|
|
30
|
+
duration?: number;
|
|
31
|
+
selector?: string;
|
|
32
|
+
selectorIndex?: number;
|
|
33
|
+
origin: string;
|
|
34
|
+
}): string {
|
|
35
|
+
const thumbnailBase = previewUrl
|
|
36
|
+
.replace("/preview/comp/", "/thumbnail/")
|
|
37
|
+
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
38
|
+
const midTime = seekTime + duration / 2;
|
|
39
|
+
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
40
|
+
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
41
|
+
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
42
|
+
if (selector) {
|
|
43
|
+
thumbnailUrl.searchParams.set("selector", selector);
|
|
44
|
+
if (selectorIndex != null && selectorIndex > 0) {
|
|
45
|
+
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return thumbnailUrl.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
20
52
|
previewUrl,
|
|
21
53
|
label,
|
|
22
54
|
labelColor,
|
|
23
55
|
accentColor = "#6B7280",
|
|
24
56
|
selector,
|
|
57
|
+
selectorIndex,
|
|
25
58
|
seekTime = 2,
|
|
26
59
|
duration = 5,
|
|
27
60
|
}: CompositionThumbnailProps) {
|
|
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
48
81
|
roRef.current?.disconnect();
|
|
49
82
|
});
|
|
50
83
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const url = thumbnailUrl.toString();
|
|
84
|
+
const url = buildCompositionThumbnailUrl({
|
|
85
|
+
previewUrl,
|
|
86
|
+
seekTime,
|
|
87
|
+
duration,
|
|
88
|
+
selector,
|
|
89
|
+
selectorIndex,
|
|
90
|
+
origin: window.location.origin,
|
|
91
|
+
});
|
|
60
92
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
61
93
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
62
94
|
|
|
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
66
98
|
src={url}
|
|
67
99
|
alt=""
|
|
68
100
|
draggable={false}
|
|
69
|
-
loading="
|
|
101
|
+
loading="eager"
|
|
70
102
|
onLoad={(e) => {
|
|
71
103
|
const img = e.currentTarget;
|
|
72
104
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
@@ -248,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("allows moving generic motion clips while keeping trims blocked", () => {
|
|
252
252
|
expect(
|
|
253
253
|
getTimelineEditCapabilities({
|
|
254
254
|
tag: "section",
|
|
@@ -256,7 +256,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
256
256
|
selector: ".feature-card",
|
|
257
257
|
}),
|
|
258
258
|
).toEqual({
|
|
259
|
-
canMove:
|
|
259
|
+
canMove: true,
|
|
260
260
|
canTrimStart: false,
|
|
261
261
|
canTrimEnd: false,
|
|
262
262
|
});
|
|
@@ -233,7 +233,7 @@ export function getTimelineEditCapabilities(input: {
|
|
|
233
233
|
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
|
|
234
234
|
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
235
235
|
return {
|
|
236
|
-
canMove: canPatch && hasDeterministicWindow,
|
|
236
|
+
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
237
237
|
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
|
238
238
|
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
|
239
239
|
};
|
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
2
3
|
import {
|
|
3
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
findTimelineDomNodeForClip,
|
|
6
|
+
getTimelineElementSelector,
|
|
7
|
+
type ClipManifestClip,
|
|
4
8
|
mergeTimelineElementsPreservingDowngrades,
|
|
5
9
|
resolveStandaloneRootCompositionSrc,
|
|
6
10
|
} from "./useTimelinePlayer";
|
|
7
11
|
|
|
12
|
+
function createDocument(markup: string): Document {
|
|
13
|
+
const window = new Window();
|
|
14
|
+
window.document.body.innerHTML = markup;
|
|
15
|
+
return window.document;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
19
|
+
return {
|
|
20
|
+
id: null,
|
|
21
|
+
label: "",
|
|
22
|
+
start: 0,
|
|
23
|
+
duration: 4,
|
|
24
|
+
track: 0,
|
|
25
|
+
kind: "element",
|
|
26
|
+
tagName: "div",
|
|
27
|
+
compositionId: null,
|
|
28
|
+
parentCompositionId: null,
|
|
29
|
+
compositionSrc: null,
|
|
30
|
+
assetUrl: null,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
describe("buildStandaloneRootTimelineElement", () => {
|
|
9
36
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
|
10
37
|
expect(
|
|
@@ -65,6 +92,38 @@ describe("resolveStandaloneRootCompositionSrc", () => {
|
|
|
65
92
|
});
|
|
66
93
|
});
|
|
67
94
|
|
|
95
|
+
describe("findTimelineDomNodeForClip", () => {
|
|
96
|
+
it("matches anonymous manifest clips back to repeated DOM nodes in timeline order", () => {
|
|
97
|
+
const doc = createDocument(`
|
|
98
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
99
|
+
<section id="identity-card" class="clip identity-card" data-start="0" data-duration="4" data-track-index="0"></section>
|
|
100
|
+
<div class="clip duplicate-card first" data-start="0" data-duration="4" data-track-index="1"></div>
|
|
101
|
+
<div class="clip duplicate-card second" data-start="0" data-duration="4" data-track-index="2"></div>
|
|
102
|
+
</div>
|
|
103
|
+
`);
|
|
104
|
+
const used = new Set<Element>();
|
|
105
|
+
|
|
106
|
+
const first = findTimelineDomNodeForClip(
|
|
107
|
+
doc,
|
|
108
|
+
createClip({ id: "__node__index_2", track: 1 }),
|
|
109
|
+
1,
|
|
110
|
+
used,
|
|
111
|
+
) as HTMLElement;
|
|
112
|
+
used.add(first);
|
|
113
|
+
const second = findTimelineDomNodeForClip(
|
|
114
|
+
doc,
|
|
115
|
+
createClip({ id: "__node__index_3", track: 2 }),
|
|
116
|
+
2,
|
|
117
|
+
used,
|
|
118
|
+
) as HTMLElement;
|
|
119
|
+
|
|
120
|
+
expect(first.className).toBe("clip duplicate-card first");
|
|
121
|
+
expect(second.className).toBe("clip duplicate-card second");
|
|
122
|
+
expect(getTimelineElementSelector(first)).toBe(".duplicate-card");
|
|
123
|
+
expect(getTimelineElementSelector(second)).toBe(".duplicate-card");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
68
127
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
69
128
|
it("preserves missing current elements when a shorter manifest arrives", () => {
|
|
70
129
|
expect(
|
|
@@ -20,7 +20,7 @@ interface TimelineLike {
|
|
|
20
20
|
isActive: () => boolean;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
interface ClipManifestClip {
|
|
23
|
+
export interface ClipManifestClip {
|
|
24
24
|
id: string | null;
|
|
25
25
|
label: string;
|
|
26
26
|
start: number;
|
|
@@ -193,12 +193,18 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|
|
193
193
|
return els;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
function
|
|
197
|
-
|
|
196
|
+
function isHtmlElement(el: Element): el is HTMLElement {
|
|
197
|
+
const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
|
|
198
|
+
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function getTimelineElementSelector(el: Element): string | undefined {
|
|
202
|
+
if (isHtmlElement(el) && el.id) return `#${el.id}`;
|
|
198
203
|
const compId = el.getAttribute("data-composition-id");
|
|
199
204
|
if (compId) return `[data-composition-id="${compId}"]`;
|
|
200
|
-
if (el
|
|
201
|
-
const
|
|
205
|
+
if (isHtmlElement(el)) {
|
|
206
|
+
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
207
|
+
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
|
|
202
208
|
if (firstClass) return `.${firstClass}`;
|
|
203
209
|
}
|
|
204
210
|
return undefined;
|
|
@@ -244,6 +250,48 @@ function buildTimelineElementKey(params: {
|
|
|
244
250
|
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
|
|
245
251
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
246
252
|
}
|
|
253
|
+
|
|
254
|
+
function getTimelineDomNodes(doc: Document): Element[] {
|
|
255
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
256
|
+
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function numbersNearlyEqual(a: number, b: number): boolean {
|
|
260
|
+
return Math.abs(a - b) < 0.001;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
|
|
264
|
+
const tagName = clip.tagName?.toLowerCase();
|
|
265
|
+
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
|
|
266
|
+
|
|
267
|
+
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
|
268
|
+
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
|
|
269
|
+
|
|
270
|
+
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
|
|
271
|
+
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
|
|
272
|
+
|
|
273
|
+
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
|
|
274
|
+
if (Number.isFinite(track) && track !== clip.track) return false;
|
|
275
|
+
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function findTimelineDomNodeForClip(
|
|
280
|
+
doc: Document,
|
|
281
|
+
clip: ClipManifestClip,
|
|
282
|
+
fallbackIndex: number,
|
|
283
|
+
usedNodes = new Set<Element>(),
|
|
284
|
+
): Element | null {
|
|
285
|
+
const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
|
|
286
|
+
if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
|
|
287
|
+
|
|
288
|
+
const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
|
|
289
|
+
const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
|
|
290
|
+
if (exact) return exact;
|
|
291
|
+
|
|
292
|
+
return candidates[fallbackIndex] ?? null;
|
|
293
|
+
}
|
|
294
|
+
|
|
247
295
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
248
296
|
return (
|
|
249
297
|
doc.getElementById(id) ??
|
|
@@ -571,8 +619,18 @@ export function useTimelinePlayer() {
|
|
|
571
619
|
const filtered = data.clips.filter(
|
|
572
620
|
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
|
573
621
|
);
|
|
622
|
+
let iframeDoc: Document | null = null;
|
|
623
|
+
try {
|
|
624
|
+
iframeDoc = iframeRef.current?.contentDocument ?? null;
|
|
625
|
+
} catch {
|
|
626
|
+
iframeDoc = null;
|
|
627
|
+
}
|
|
628
|
+
const usedHostEls = new Set<Element>();
|
|
574
629
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
575
|
-
let hostEl
|
|
630
|
+
let hostEl = iframeDoc
|
|
631
|
+
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
632
|
+
: null;
|
|
633
|
+
if (hostEl) usedHostEls.add(hostEl);
|
|
576
634
|
const id = clip.id || clip.label || clip.tagName || "element";
|
|
577
635
|
const entry: TimelineElement = {
|
|
578
636
|
id,
|
|
@@ -581,16 +639,7 @@ export function useTimelinePlayer() {
|
|
|
581
639
|
duration: clip.duration,
|
|
582
640
|
track: clip.track,
|
|
583
641
|
};
|
|
584
|
-
try {
|
|
585
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
586
|
-
if (iframeDoc && entry.id) {
|
|
587
|
-
hostEl = findTimelineDomNode(iframeDoc, entry.id);
|
|
588
|
-
}
|
|
589
|
-
} catch {
|
|
590
|
-
/* cross-origin */
|
|
591
|
-
}
|
|
592
642
|
if (hostEl) {
|
|
593
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
594
643
|
entry.domId = hostEl.id || undefined;
|
|
595
644
|
entry.selector = getTimelineElementSelector(hostEl);
|
|
596
645
|
entry.selectorIndex =
|
|
@@ -606,19 +655,13 @@ export function useTimelinePlayer() {
|
|
|
606
655
|
// after inlining, so the clip manifest may not have compositionSrc.
|
|
607
656
|
// Fall back to reading data-composition-file from the DOM.
|
|
608
657
|
let resolvedSrc = clip.compositionSrc;
|
|
609
|
-
let hostEl: Element | null = null;
|
|
610
658
|
if (!resolvedSrc) {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
hostEl?.getAttribute("data-composition-file") ??
|
|
618
|
-
null;
|
|
619
|
-
} catch {
|
|
620
|
-
/* cross-origin */
|
|
621
|
-
}
|
|
659
|
+
hostEl =
|
|
660
|
+
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
661
|
+
resolvedSrc =
|
|
662
|
+
hostEl?.getAttribute("data-composition-src") ??
|
|
663
|
+
hostEl?.getAttribute("data-composition-file") ??
|
|
664
|
+
null;
|
|
622
665
|
}
|
|
623
666
|
if (resolvedSrc) {
|
|
624
667
|
entry.compositionSrc = resolvedSrc;
|