@hyperframes/studio 0.8.23 → 0.8.25
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/{hyperframes-player-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
- package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
- package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
- package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
- package/dist/chunk-A7S5SGNA.js.map +1 -0
- package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6206 -5011
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/editor/TopologyLens.test.tsx +294 -0
- package/src/components/editor/TopologyLens.tsx +206 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
- package/src/components/editor/domEditingLayers.test.ts +9 -0
- package/src/components/editor/domEditingLayers.ts +8 -8
- package/src/components/editor/topologyLensGeometry.test.ts +44 -0
- package/src/components/editor/topologyLensGeometry.ts +119 -0
- package/src/components/editor/topologyLensState.test.ts +116 -0
- package/src/components/editor/topologyLensState.ts +72 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
- package/src/components/nle/PreviewOverlays.tsx +24 -18
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
- package/src/components/sidebar/CompositionsTab.tsx +10 -3
- package/src/components/ui/HyperframesLoader.tsx +3 -48
- package/src/components/ui/HyperframesMark.tsx +53 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/domEditCommitRunner.test.ts +59 -0
- package/src/hooks/domEditCommitRunner.ts +55 -20
- package/src/hooks/domEditCommitTypes.ts +8 -1
- package/src/hooks/domEditTextCommitPlan.ts +47 -0
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +207 -8
- package/src/hooks/useDomEditCommits.ts +54 -14
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
- package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
- package/src/hooks/useDomEditSession.ts +6 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
- package/src/hooks/useDomEditTextCommits.ts +64 -86
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomSelection.ts +2 -0
- package/src/hooks/useDomSelectionTypes.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
- package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
- package/src/hooks/useGsapAnimationOps.ts +4 -4
- package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
- package/src/hooks/useGsapSelectionHandlers.ts +21 -11
- package/src/hooks/useRenderClipContent.test.ts +27 -0
- package/src/hooks/useRenderClipContent.ts +5 -0
- package/src/hooks/useStudioExternalFileChanges.ts +5 -0
- package/src/player/components/CompositionThumbnail.test.ts +66 -1
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/store/playerStore.test.ts +24 -0
- package/src/player/store/thumbnailSlice.ts +6 -0
- package/src/styles/studio.css +179 -0
- package/src/utils/domEditSaveQueue.test.ts +5 -3
- package/src/utils/domEditSaveQueue.ts +6 -1
- package/src/webmcp/StudioAgentTools.test.ts +70 -0
- package/src/webmcp/StudioAgentTools.tsx +89 -21
- package/src/webmcp/handles.test.ts +172 -15
- package/src/webmcp/handles.ts +152 -35
- package/src/webmcp/tools/animationTools.test.ts +241 -60
- package/src/webmcp/tools/animationTools.ts +191 -104
- package/src/webmcp/tools/contentTools.test.ts +103 -41
- package/src/webmcp/tools/contentTools.ts +149 -105
- package/src/webmcp/tools/inspectTools.test.ts +47 -5
- package/src/webmcp/tools/inspectTools.ts +59 -19
- package/src/webmcp/tools/lookTools.test.ts +185 -38
- package/src/webmcp/tools/lookTools.ts +134 -21
- package/src/webmcp/tools/selectionTools.test.ts +105 -9
- package/src/webmcp/tools/selectionTools.ts +34 -19
- package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
- package/src/webmcp/tools/transformTools.test.ts +140 -54
- package/src/webmcp/tools/transformTools.ts +215 -56
- package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
- package/src/webmcp/useStudioAgentTools.ts +27 -15
- package/src/webmcp/webmcpTestUtils.ts +37 -0
- package/src/webmcp/writeCoordinator.test.ts +375 -0
- package/src/webmcp/writeCoordinator.ts +473 -0
- package/dist/assets/index-yGhfxxoL.css +0 -1
- package/dist/chunk-6BT6DTB4.js.map +0 -1
- /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { resolveElementHandle } from "../../webmcp/handles";
|
|
2
|
+
import { collectStudioLookScene } from "../../webmcp/tools/lookTools";
|
|
3
|
+
import type { DomEditLayerItem } from "./domEditingTypes";
|
|
4
|
+
import { toVisibleOverlayRects, type OverlayRect } from "./domEditOverlayGeometry";
|
|
5
|
+
|
|
6
|
+
export interface TopologyLensNode {
|
|
7
|
+
label: string;
|
|
8
|
+
rect: OverlayRect;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TopologyLensGeometry {
|
|
12
|
+
field: TopologyLensNode;
|
|
13
|
+
target: TopologyLensNode;
|
|
14
|
+
contours: TopologyLensNode[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findSourceContextIndex(items: readonly DomEditLayerItem[], targetIndex: number): number {
|
|
18
|
+
const target = items[targetIndex]!;
|
|
19
|
+
let contextIndex = targetIndex;
|
|
20
|
+
let parentDepth = target.depth - 1;
|
|
21
|
+
for (let index = targetIndex - 1; index >= 0 && parentDepth >= 0; index--) {
|
|
22
|
+
const item = items[index]!;
|
|
23
|
+
if (item.depth !== parentDepth) continue;
|
|
24
|
+
if (item.sourceFile !== target.sourceFile) break;
|
|
25
|
+
contextIndex = index;
|
|
26
|
+
parentDepth -= 1;
|
|
27
|
+
}
|
|
28
|
+
return contextIndex;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectSourceContours(
|
|
32
|
+
items: readonly DomEditLayerItem[],
|
|
33
|
+
contextIndex: number,
|
|
34
|
+
target: DomEditLayerItem,
|
|
35
|
+
): HTMLElement[] {
|
|
36
|
+
const context = items[contextIndex]!;
|
|
37
|
+
const contours: HTMLElement[] = [];
|
|
38
|
+
for (let index = contextIndex + 1; index < items.length; index++) {
|
|
39
|
+
const item = items[index]!;
|
|
40
|
+
if (item.depth <= context.depth) break;
|
|
41
|
+
if (item.sourceFile === target.sourceFile && item.element !== target.element) {
|
|
42
|
+
contours.push(item.element);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return contours;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the same nested preorder used by studio_look. This keeps the lens on
|
|
50
|
+
* the agent's source-safe scene model instead of guessing hierarchy from an
|
|
51
|
+
* unrelated presentation selector.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveTopologyLensElements(
|
|
54
|
+
doc: Document,
|
|
55
|
+
activeCompositionPath: string | null,
|
|
56
|
+
handle: string,
|
|
57
|
+
): { context: HTMLElement; target: HTMLElement; contours: HTMLElement[] } | null {
|
|
58
|
+
const target = resolveElementHandle(doc, handle);
|
|
59
|
+
if (!target) return null;
|
|
60
|
+
const scene = collectStudioLookScene(doc, activeCompositionPath, null);
|
|
61
|
+
if (scene.status === "loading") return null;
|
|
62
|
+
const targetIndex = scene.items.findIndex((item) => item.element === target);
|
|
63
|
+
if (targetIndex < 0) return { context: target, target, contours: [] };
|
|
64
|
+
|
|
65
|
+
const targetItem = scene.items[targetIndex]!;
|
|
66
|
+
const contextIndex = findSourceContextIndex(scene.items, targetIndex);
|
|
67
|
+
const contextItem = scene.items[contextIndex]!;
|
|
68
|
+
const sourceContext = target.closest<HTMLElement>(
|
|
69
|
+
"[data-composition-file], [data-composition-src]",
|
|
70
|
+
);
|
|
71
|
+
return {
|
|
72
|
+
context: sourceContext ?? contextItem.element,
|
|
73
|
+
target,
|
|
74
|
+
contours: collectSourceContours(scene.items, contextIndex, targetItem),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isRenderableRect(rect: OverlayRect | null | undefined): rect is OverlayRect {
|
|
79
|
+
return Boolean(rect && rect.width > 0 && rect.height > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function measureTopologyLensGeometry(input: {
|
|
83
|
+
overlay: HTMLDivElement;
|
|
84
|
+
iframe: HTMLIFrameElement;
|
|
85
|
+
activeCompositionPath: string | null;
|
|
86
|
+
handle: string;
|
|
87
|
+
}): TopologyLensGeometry | null {
|
|
88
|
+
const doc = input.iframe.contentDocument;
|
|
89
|
+
if (!doc) return null;
|
|
90
|
+
const elements = resolveTopologyLensElements(doc, input.activeCompositionPath, input.handle);
|
|
91
|
+
if (!elements) return null;
|
|
92
|
+
const sharedField = elements.context === elements.target;
|
|
93
|
+
const measured = toVisibleOverlayRects(
|
|
94
|
+
input.overlay,
|
|
95
|
+
input.iframe,
|
|
96
|
+
sharedField
|
|
97
|
+
? [elements.target, ...elements.contours]
|
|
98
|
+
: [elements.context, elements.target, ...elements.contours],
|
|
99
|
+
);
|
|
100
|
+
const target = measured[sharedField ? 0 : 1];
|
|
101
|
+
if (!isRenderableRect(target)) return null;
|
|
102
|
+
const measuredField = measured[0];
|
|
103
|
+
const fieldUsesContext = !sharedField && isRenderableRect(measuredField);
|
|
104
|
+
const contourOffset = sharedField ? 1 : 2;
|
|
105
|
+
return {
|
|
106
|
+
field: {
|
|
107
|
+
label: (fieldUsesContext ? elements.context : elements.target).tagName.toLowerCase(),
|
|
108
|
+
rect: fieldUsesContext ? measuredField : target,
|
|
109
|
+
},
|
|
110
|
+
target: { label: elements.target.tagName.toLowerCase(), rect: target },
|
|
111
|
+
contours: measured
|
|
112
|
+
.slice(contourOffset)
|
|
113
|
+
.map((rect, index) => ({
|
|
114
|
+
label: elements.contours[index]!.tagName.toLowerCase(),
|
|
115
|
+
rect,
|
|
116
|
+
}))
|
|
117
|
+
.filter((item): item is TopologyLensNode => isRenderableRect(item.rect)),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { reduceTopologyLens, type TopologyLensState } from "./topologyLensState";
|
|
3
|
+
import type { StudioEditLifecycleState } from "../../webmcp/writeCoordinator";
|
|
4
|
+
|
|
5
|
+
const targetA = { handle: "dom:v1:index.html:index.html:target-a", sourceFile: "index.html" };
|
|
6
|
+
const targetB = { handle: "dom:v1:index.html:index.html:target-b", sourceFile: "index.html" };
|
|
7
|
+
|
|
8
|
+
function lifecycle(
|
|
9
|
+
overrides: Partial<Exclude<StudioEditLifecycleState, { phase: "idle" }>> = {},
|
|
10
|
+
): Exclude<StudioEditLifecycleState, { phase: "idle" }> {
|
|
11
|
+
return {
|
|
12
|
+
callId: "call-a",
|
|
13
|
+
projectId: "project-a",
|
|
14
|
+
target: targetA,
|
|
15
|
+
operation: "set-text",
|
|
16
|
+
targetChanged: true,
|
|
17
|
+
phase: "dispatching",
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hidden(): TopologyLensState {
|
|
23
|
+
return { phase: "hidden" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("Topology Lens state", () => {
|
|
27
|
+
it("reveals a new target but localizes a repeated target immediately", () => {
|
|
28
|
+
expect(reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() })).toMatchObject({
|
|
29
|
+
phase: "acquiring",
|
|
30
|
+
callId: "call-a",
|
|
31
|
+
});
|
|
32
|
+
expect(
|
|
33
|
+
reduceTopologyLens(hidden(), {
|
|
34
|
+
type: "lifecycle",
|
|
35
|
+
value: lifecycle({ callId: "call-repeat", targetChanged: false }),
|
|
36
|
+
}),
|
|
37
|
+
).toMatchObject({ phase: "localizing", callId: "call-repeat" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("lets a fast durable receipt interrupt acquisition and seal once", () => {
|
|
41
|
+
const acquiring = reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() });
|
|
42
|
+
const sealing = reduceTopologyLens(acquiring, {
|
|
43
|
+
type: "lifecycle",
|
|
44
|
+
value: lifecycle({ phase: "verified" }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(sealing).toMatchObject({ phase: "sealing", receiptStage: "verified" });
|
|
48
|
+
expect(reduceTopologyLens(sealing, { type: "lifecycle", value: { phase: "idle" } })).toEqual(
|
|
49
|
+
hidden(),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each(["dispatched", "failed"] as const)(
|
|
54
|
+
"retracts %s without manufacturing a success seal",
|
|
55
|
+
(phase) => {
|
|
56
|
+
const localizing = reduceTopologyLens(hidden(), {
|
|
57
|
+
type: "lifecycle",
|
|
58
|
+
value: lifecycle({ phase }),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(localizing).toMatchObject({ phase: "localizing", terminal: phase });
|
|
62
|
+
expect(
|
|
63
|
+
reduceTopologyLens(localizing, { type: "lifecycle", value: { phase: "idle" } }),
|
|
64
|
+
).toEqual(hidden());
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
it.each(["saved", "verified"] as const)(
|
|
69
|
+
"retracts a %s no-op without manufacturing a success seal",
|
|
70
|
+
(phase) => {
|
|
71
|
+
const noChange = reduceTopologyLens(hidden(), {
|
|
72
|
+
type: "lifecycle",
|
|
73
|
+
value: lifecycle({
|
|
74
|
+
phase,
|
|
75
|
+
receipt: {
|
|
76
|
+
ok: true,
|
|
77
|
+
stage: phase,
|
|
78
|
+
target: targetA,
|
|
79
|
+
operation: "set-text",
|
|
80
|
+
changed: false,
|
|
81
|
+
evidence: { kind: "content-version", sourceFile: "index.html", version: "v1" },
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(noChange).toMatchObject({ phase: "localizing", terminal: "no-change" });
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
it("ignores elapsed events owned by an older invocation", () => {
|
|
91
|
+
const newer = reduceTopologyLens(hidden(), {
|
|
92
|
+
type: "lifecycle",
|
|
93
|
+
value: lifecycle({ callId: "call-b", target: targetB }),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(reduceTopologyLens(newer, { type: "acquisition-elapsed", callId: "call-a" })).toBe(
|
|
97
|
+
newer,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("clears when the coordinator publishes idle", () => {
|
|
102
|
+
const acquiring = reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() });
|
|
103
|
+
|
|
104
|
+
expect(reduceTopologyLens(acquiring, { type: "lifecycle", value: { phase: "idle" } })).toEqual(
|
|
105
|
+
hidden(),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("advances acquisition to localization only for the same visual invocation", () => {
|
|
110
|
+
const acquiring = reduceTopologyLens(hidden(), { type: "lifecycle", value: lifecycle() });
|
|
111
|
+
|
|
112
|
+
expect(
|
|
113
|
+
reduceTopologyLens(acquiring, { type: "acquisition-elapsed", callId: "call-a" }),
|
|
114
|
+
).toMatchObject({ phase: "localizing", terminal: null });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StudioEditLifecycleState,
|
|
3
|
+
StudioWriteOperation,
|
|
4
|
+
StudioWriteTarget,
|
|
5
|
+
} from "../../webmcp/writeCoordinator";
|
|
6
|
+
|
|
7
|
+
type TopologyLensBase = {
|
|
8
|
+
callId: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
target: StudioWriteTarget;
|
|
11
|
+
operation: StudioWriteOperation;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type TopologyLensState =
|
|
15
|
+
| { phase: "hidden" }
|
|
16
|
+
| (TopologyLensBase & { phase: "acquiring" })
|
|
17
|
+
| (TopologyLensBase & {
|
|
18
|
+
phase: "localizing";
|
|
19
|
+
terminal: "dispatched" | "failed" | "no-change" | null;
|
|
20
|
+
})
|
|
21
|
+
| (TopologyLensBase & {
|
|
22
|
+
phase: "sealing";
|
|
23
|
+
receiptStage: "saved" | "verified";
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type TopologyLensEvent =
|
|
27
|
+
| { type: "lifecycle"; value: StudioEditLifecycleState }
|
|
28
|
+
| { type: "acquisition-elapsed"; callId: string };
|
|
29
|
+
|
|
30
|
+
function visibleBase(
|
|
31
|
+
value: Exclude<StudioEditLifecycleState, { phase: "idle" }>,
|
|
32
|
+
): TopologyLensBase {
|
|
33
|
+
return {
|
|
34
|
+
callId: value.callId,
|
|
35
|
+
projectId: value.projectId,
|
|
36
|
+
target: value.target,
|
|
37
|
+
operation: value.operation,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Presentation follows transaction facts. Elapsed events may finish a visual
|
|
43
|
+
* transition, but cannot promote a receipt or invent persistence.
|
|
44
|
+
*/
|
|
45
|
+
export function reduceTopologyLens(
|
|
46
|
+
state: TopologyLensState,
|
|
47
|
+
event: TopologyLensEvent,
|
|
48
|
+
): TopologyLensState {
|
|
49
|
+
if (event.type === "lifecycle") {
|
|
50
|
+
const lifecycle = event.value;
|
|
51
|
+
if (lifecycle.phase === "idle") return { phase: "hidden" };
|
|
52
|
+
const base = visibleBase(lifecycle);
|
|
53
|
+
switch (lifecycle.phase) {
|
|
54
|
+
case "dispatching":
|
|
55
|
+
return lifecycle.targetChanged
|
|
56
|
+
? { ...base, phase: "acquiring" }
|
|
57
|
+
: { ...base, phase: "localizing", terminal: null };
|
|
58
|
+
case "dispatched":
|
|
59
|
+
case "failed":
|
|
60
|
+
return { ...base, phase: "localizing", terminal: lifecycle.phase };
|
|
61
|
+
case "saved":
|
|
62
|
+
case "verified":
|
|
63
|
+
if (lifecycle.receipt?.ok && lifecycle.receipt.changed === false) {
|
|
64
|
+
return { ...base, phase: "localizing", terminal: "no-change" };
|
|
65
|
+
}
|
|
66
|
+
return { ...base, phase: "sealing", receiptStage: lifecycle.phase };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (state.phase === "hidden" || state.callId !== event.callId) return state;
|
|
71
|
+
return state.phase === "acquiring" ? { ...state, phase: "localizing", terminal: null } : state;
|
|
72
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { PreviewOverlays } from "./PreviewOverlays";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
const previewState = vi.hoisted(() => ({ captionEditMode: false }));
|
|
11
|
+
const iframeRef = { current: null as HTMLIFrameElement | null };
|
|
12
|
+
|
|
13
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
14
|
+
useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
|
|
15
|
+
useStudioPlaybackContext: () => ({
|
|
16
|
+
captionEditMode: previewState.captionEditMode,
|
|
17
|
+
compositionLoading: false,
|
|
18
|
+
isPlaying: false,
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
22
|
+
useDomEditSelectionContext: () => ({
|
|
23
|
+
domEditHoverSelection: null,
|
|
24
|
+
domEditSelection: null,
|
|
25
|
+
domEditGroupSelections: [],
|
|
26
|
+
}),
|
|
27
|
+
useDomEditActionsContext: () => ({
|
|
28
|
+
handlePreviewCanvasMouseDown: vi.fn(),
|
|
29
|
+
handlePreviewCanvasPointerMove: vi.fn(),
|
|
30
|
+
handlePreviewCanvasPointerLeave: vi.fn(),
|
|
31
|
+
applyDomSelection: vi.fn(),
|
|
32
|
+
handleBlockedDomMove: vi.fn(),
|
|
33
|
+
handleDomManualDragStart: vi.fn(),
|
|
34
|
+
handleDomPathOffsetCommit: vi.fn(),
|
|
35
|
+
handleDomGroupPathOffsetCommit: vi.fn(),
|
|
36
|
+
handleDomBoxSizeCommit: vi.fn(),
|
|
37
|
+
handleDomRotationCommit: vi.fn(),
|
|
38
|
+
handleDomStyleCommit: vi.fn(),
|
|
39
|
+
applyMarqueeSelection: vi.fn(),
|
|
40
|
+
handleDomEditElementDelete: vi.fn(),
|
|
41
|
+
handleDomZIndexReorderCommit: vi.fn(),
|
|
42
|
+
}),
|
|
43
|
+
}));
|
|
44
|
+
vi.mock("../../captions/store", () => {
|
|
45
|
+
const state = {
|
|
46
|
+
model: null,
|
|
47
|
+
dismissed: false,
|
|
48
|
+
syncError: null,
|
|
49
|
+
clearSelection: vi.fn(),
|
|
50
|
+
setDismissed: vi.fn(),
|
|
51
|
+
setEditMode: vi.fn(),
|
|
52
|
+
setSyncError: vi.fn(),
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
useCaptionStore: Object.assign(
|
|
56
|
+
(selector: (value: typeof state) => unknown) => selector(state),
|
|
57
|
+
{ getState: () => state },
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
vi.mock("../../hooks/useCompositionDimensions", () => ({
|
|
62
|
+
useCompositionDimensions: () => null,
|
|
63
|
+
}));
|
|
64
|
+
vi.mock("../../utils/studioUiPreferences", () => ({ readStudioUiPreferences: () => ({}) }));
|
|
65
|
+
vi.mock("./useCanvasZOrderTimelineMirror", () => ({
|
|
66
|
+
useCanvasZOrderTimelineMirror: () => vi.fn(),
|
|
67
|
+
}));
|
|
68
|
+
vi.mock("../editor/TopologyLens", async () => {
|
|
69
|
+
const { createElement } = await import("react");
|
|
70
|
+
return {
|
|
71
|
+
TopologyLens: () => createElement("div", { "data-topology-host": "true" }),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
vi.mock("../../captions/components/CaptionOverlay", () => ({ CaptionOverlay: () => null }));
|
|
75
|
+
vi.mock("../editor/DomEditOverlay", () => ({ DomEditOverlay: () => null }));
|
|
76
|
+
vi.mock("../editor/MotionPathOverlay", () => ({ MotionPathOverlay: () => null }));
|
|
77
|
+
vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
|
|
78
|
+
|
|
79
|
+
let root: Root | null = null;
|
|
80
|
+
let host: HTMLDivElement | null = null;
|
|
81
|
+
|
|
82
|
+
function render(props: Partial<React.ComponentProps<typeof PreviewOverlays>> = {}): void {
|
|
83
|
+
host = document.createElement("div");
|
|
84
|
+
iframeRef.current = document.createElement("iframe");
|
|
85
|
+
document.body.append(host, iframeRef.current);
|
|
86
|
+
root = createRoot(host);
|
|
87
|
+
act(() => {
|
|
88
|
+
root?.render(
|
|
89
|
+
<PreviewOverlays
|
|
90
|
+
shouldShowMotionPath={false}
|
|
91
|
+
shouldShowSelectedDomBounds={false}
|
|
92
|
+
{...props}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
if (root) act(() => root?.unmount());
|
|
100
|
+
root = null;
|
|
101
|
+
host = null;
|
|
102
|
+
iframeRef.current = null;
|
|
103
|
+
previewState.captionEditMode = false;
|
|
104
|
+
document.body.replaceChildren();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("PreviewOverlays Topology Lens host", () => {
|
|
108
|
+
it("stays in Studio chrome while a block preview covers the composition", () => {
|
|
109
|
+
render({
|
|
110
|
+
blockPreview: {
|
|
111
|
+
id: "block-a",
|
|
112
|
+
title: "Block",
|
|
113
|
+
posterUrl: "poster.png",
|
|
114
|
+
} as React.ComponentProps<typeof PreviewOverlays>["blockPreview"],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(host?.querySelector('[data-topology-host="true"]')).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("stays independent of caption editing chrome", () => {
|
|
121
|
+
previewState.captionEditMode = true;
|
|
122
|
+
render();
|
|
123
|
+
|
|
124
|
+
expect(host?.querySelector('[data-topology-host="true"]')).not.toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
|
|
2
2
|
import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
|
|
3
3
|
import { useCaptionStore } from "../../captions/store";
|
|
4
4
|
import { DomEditOverlay } from "../editor/DomEditOverlay";
|
|
5
|
+
import { TopologyLens } from "../editor/TopologyLens";
|
|
5
6
|
import { MotionPathOverlay } from "../editor/MotionPathOverlay";
|
|
6
7
|
import { SnapToolbar } from "../editor/SnapToolbar";
|
|
7
8
|
import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
|
|
@@ -191,30 +192,34 @@ export function PreviewOverlays({
|
|
|
191
192
|
|
|
192
193
|
if (blockPreview) {
|
|
193
194
|
return (
|
|
194
|
-
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
195
|
+
<>
|
|
196
|
+
<TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
|
|
197
|
+
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
|
|
198
|
+
{blockPreview.videoUrl ? (
|
|
199
|
+
<video
|
|
200
|
+
src={blockPreview.videoUrl}
|
|
201
|
+
autoPlay
|
|
202
|
+
muted
|
|
203
|
+
loop
|
|
204
|
+
playsInline
|
|
205
|
+
className="w-full h-full object-contain"
|
|
206
|
+
/>
|
|
207
|
+
) : blockPreview.posterUrl ? (
|
|
208
|
+
<img
|
|
209
|
+
src={blockPreview.posterUrl}
|
|
210
|
+
alt={blockPreview.title}
|
|
211
|
+
className="w-full h-full object-contain"
|
|
212
|
+
/>
|
|
213
|
+
) : null}
|
|
214
|
+
</div>
|
|
215
|
+
</>
|
|
212
216
|
);
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
if (captionEditMode) {
|
|
216
220
|
return (
|
|
217
221
|
<>
|
|
222
|
+
<TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
|
|
218
223
|
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
219
224
|
{/* Mode indicator + explicit exit */}
|
|
220
225
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
|
|
@@ -257,6 +262,7 @@ export function PreviewOverlays({
|
|
|
257
262
|
|
|
258
263
|
return (
|
|
259
264
|
<>
|
|
265
|
+
<TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
|
|
260
266
|
<DomEditOverlay
|
|
261
267
|
iframeRef={previewIframeRef}
|
|
262
268
|
activeCompositionPath={activeCompPath}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
7
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
7
8
|
import { CompositionsTab } from "./CompositionsTab";
|
|
8
9
|
|
|
@@ -17,6 +18,7 @@ afterEach(() => {
|
|
|
17
18
|
if (root) act(() => root?.unmount());
|
|
18
19
|
root = null;
|
|
19
20
|
document.body.innerHTML = "";
|
|
21
|
+
usePlayerStore.setState({ thumbnailContentRevision: 0 });
|
|
20
22
|
});
|
|
21
23
|
|
|
22
24
|
function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
|
@@ -59,6 +61,20 @@ describe("composition card drag", () => {
|
|
|
59
61
|
expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
|
|
60
62
|
});
|
|
61
63
|
|
|
64
|
+
it("retries a failed thumbnail at the next persisted content revision", () => {
|
|
65
|
+
const { host } = mount();
|
|
66
|
+
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
67
|
+
if (!thumbnail) throw new Error("composition thumbnail did not render");
|
|
68
|
+
act(() => thumbnail.dispatchEvent(new Event("error")));
|
|
69
|
+
expect(host.textContent).toContain("Preview unavailable");
|
|
70
|
+
|
|
71
|
+
act(() => usePlayerStore.getState().bumpThumbnailContentRevision());
|
|
72
|
+
|
|
73
|
+
const retry = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
74
|
+
expect(retry).not.toBeNull();
|
|
75
|
+
expect(new URL(retry?.src ?? "").searchParams.get("revision")).toBe("1");
|
|
76
|
+
});
|
|
77
|
+
|
|
62
78
|
it("mounts one live preview only after sustained hover and removes it on leave", () => {
|
|
63
79
|
vi.useFakeTimers();
|
|
64
80
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
3
2
|
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
|
3
|
+
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
4
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
4
5
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
5
6
|
import { Tooltip } from "../ui/Tooltip";
|
|
6
7
|
|
|
@@ -120,6 +121,7 @@ function CompCard({
|
|
|
120
121
|
isRendering,
|
|
121
122
|
lintInfo,
|
|
122
123
|
onAddToTimeline,
|
|
124
|
+
contentRevision,
|
|
123
125
|
}: {
|
|
124
126
|
projectId: string;
|
|
125
127
|
comp: string;
|
|
@@ -129,11 +131,12 @@ function CompCard({
|
|
|
129
131
|
isRendering?: boolean;
|
|
130
132
|
lintInfo?: { count: number; messages: string[] };
|
|
131
133
|
onAddToTimeline?: () => void;
|
|
134
|
+
contentRevision: number;
|
|
132
135
|
}) {
|
|
133
136
|
const [hovered, setHovered] = useState(false);
|
|
134
137
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
135
138
|
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
|
136
|
-
const [
|
|
139
|
+
const [failedThumbnailUrl, setFailedThumbnailUrl] = useState<string | null>(null);
|
|
137
140
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
138
141
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
139
142
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -176,7 +179,9 @@ function CompCard({
|
|
|
176
179
|
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
|
177
180
|
duration: 0,
|
|
178
181
|
origin: window.location.origin,
|
|
182
|
+
contentRevision,
|
|
179
183
|
});
|
|
184
|
+
const thumbnailFailed = failedThumbnailUrl === thumbnailUrl;
|
|
180
185
|
const previewScale = resolveCompositionPreviewScale({
|
|
181
186
|
cardWidth: CARD_W,
|
|
182
187
|
cardHeight: CARD_H,
|
|
@@ -246,7 +251,7 @@ function CompCard({
|
|
|
246
251
|
draggable={false}
|
|
247
252
|
loading="lazy"
|
|
248
253
|
decoding="async"
|
|
249
|
-
onError={() =>
|
|
254
|
+
onError={() => setFailedThumbnailUrl(thumbnailUrl)}
|
|
250
255
|
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
|
251
256
|
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
|
252
257
|
}`}
|
|
@@ -368,6 +373,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
368
373
|
isRendering,
|
|
369
374
|
lintFindingsByFile,
|
|
370
375
|
}: CompositionsTabProps) {
|
|
376
|
+
const contentRevision = usePlayerStore((state) => state.thumbnailContentRevision);
|
|
371
377
|
if (compositions.length === 0) {
|
|
372
378
|
return (
|
|
373
379
|
<div className="flex-1 flex items-center justify-center px-4">
|
|
@@ -389,6 +395,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
389
395
|
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
|
|
390
396
|
isRendering={isRendering}
|
|
391
397
|
lintInfo={lintFindingsByFile?.get(comp)}
|
|
398
|
+
contentRevision={contentRevision}
|
|
392
399
|
/>
|
|
393
400
|
))}
|
|
394
401
|
</div>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { HyperframesMark } from "./HyperframesMark";
|
|
2
|
+
|
|
1
3
|
export interface HyperframesLoaderProps {
|
|
2
4
|
/** Status text shown below the mark. */
|
|
3
5
|
title: string;
|
|
@@ -31,54 +33,7 @@ export function HyperframesLoader({
|
|
|
31
33
|
style={{ width: markFrameSize, height: markFrameSize }}
|
|
32
34
|
draggable={false}
|
|
33
35
|
>
|
|
34
|
-
<
|
|
35
|
-
className="hf-loader-mark"
|
|
36
|
-
width={size}
|
|
37
|
-
height={size}
|
|
38
|
-
viewBox="0 0 100 100"
|
|
39
|
-
fill="none"
|
|
40
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
-
aria-hidden="true"
|
|
42
|
-
>
|
|
43
|
-
<g className="hf-loader-mark__mark" transform="translate(50 50)">
|
|
44
|
-
<g className="hf-loader-mark__core" transform="scale(1)" opacity=".92">
|
|
45
|
-
<g transform="translate(-50 -50)">
|
|
46
|
-
<path
|
|
47
|
-
d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z"
|
|
48
|
-
fill="url(#hf-loader-grad-left)"
|
|
49
|
-
/>
|
|
50
|
-
<path
|
|
51
|
-
d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"
|
|
52
|
-
fill="url(#hf-loader-grad-right)"
|
|
53
|
-
/>
|
|
54
|
-
</g>
|
|
55
|
-
</g>
|
|
56
|
-
</g>
|
|
57
|
-
<defs>
|
|
58
|
-
<linearGradient
|
|
59
|
-
id="hf-loader-grad-left"
|
|
60
|
-
x1="48.5676"
|
|
61
|
-
y1="25"
|
|
62
|
-
x2="44.7804"
|
|
63
|
-
y2="71.9384"
|
|
64
|
-
gradientUnits="userSpaceOnUse"
|
|
65
|
-
>
|
|
66
|
-
<stop stopColor="#06E3FA" />
|
|
67
|
-
<stop offset="1" stopColor="#4FDB5E" />
|
|
68
|
-
</linearGradient>
|
|
69
|
-
<linearGradient
|
|
70
|
-
id="hf-loader-grad-right"
|
|
71
|
-
x1="54.8282"
|
|
72
|
-
y1="73.8392"
|
|
73
|
-
x2="72.0989"
|
|
74
|
-
y2="32.8932"
|
|
75
|
-
gradientUnits="userSpaceOnUse"
|
|
76
|
-
>
|
|
77
|
-
<stop stopColor="#06E3FA" />
|
|
78
|
-
<stop offset="1" stopColor="#4FDB5E" />
|
|
79
|
-
</linearGradient>
|
|
80
|
-
</defs>
|
|
81
|
-
</svg>
|
|
36
|
+
<HyperframesMark className="hf-loader-mark" width={size} height={size} />
|
|
82
37
|
</div>
|
|
83
38
|
<div className="hf-loader-title">{title}</div>
|
|
84
39
|
{detail && <div className="hf-loader-detail">{detail}</div>}
|