@hyperframes/studio 0.8.33 → 0.8.34
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-CNhkujVy.js → hyperframes-player-D4aSRr_M.js} +1 -1
- package/dist/assets/{index-DOoo96Tb.js → index-CkxPQxLJ.js} +182 -172
- package/dist/assets/{index-DiStlnx7.js → index-aNyQBoJv.js} +1 -1
- package/dist/assets/{index-CslAQ3JV.js → index-hch4ktcC.js} +1 -1
- package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
- package/dist/chunk-7BP32G62.js.map +1 -0
- package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +354 -206
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
- package/src/captions/components/CaptionOverlay.tsx +1 -0
- package/src/captions/generator.test.ts +30 -0
- package/src/captions/generator.ts +5 -3
- package/src/captions/hooks/useCaptionSync.ts +3 -2
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +3 -3
- package/src/components/editor/PropertyPanel.test.tsx +18 -14
- package/src/components/editor/SnapGuideOverlay.tsx +2 -6
- package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
- package/src/components/editor/domEditLayerWalkCache.ts +327 -0
- package/src/components/editor/domEditOverlayBasis.ts +72 -0
- package/src/components/editor/domEditOverlayGeometry.ts +78 -83
- package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
- package/src/components/editor/domEditOverlayTransform.ts +47 -9
- package/src/components/editor/domEditingDom.ts +63 -14
- package/src/components/editor/domEditingElement.ts +56 -14
- package/src/components/editor/domEditingLayers.test.ts +48 -0
- package/src/components/editor/domEditingLayers.ts +22 -35
- package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
- package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
- package/src/components/editor/overlayFrameLoop.test.ts +246 -0
- package/src/components/editor/overlayFrameLoop.ts +204 -0
- package/src/components/editor/probeSourceElement.ts +29 -0
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
- package/src/components/editor/useDomEditCompositionRect.ts +2 -4
- package/src/components/editor/useDomEditOverlayRects.ts +15 -9
- package/src/components/editor/useMotionPathData.ts +2 -4
- package/src/components/feedback/projectProvenance.ts +4 -1
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/PreviewOverlays.tsx +1 -1
- package/src/components/nle/useCompositionStack.ts +11 -4
- package/src/components/renders/RenderQueue.test.tsx +2 -2
- package/src/components/renders/RenderQueueItem.tsx +2 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/CompositionsTab.tsx +2 -1
- package/src/components/storyboard/FramePoster.tsx +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useCompositionContentLoader.ts +3 -1
- package/src/hooks/useCompositionDimensions.test.tsx +47 -0
- package/src/hooks/useCompositionDimensions.ts +3 -2
- package/src/hooks/useDomEditCommits.ts +5 -4
- package/src/hooks/useElementLifecycleOps.ts +5 -1
- package/src/hooks/useFileTree.ts +5 -4
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +6 -5
- package/src/hooks/useTimelineDeleteOps.ts +5 -1
- package/src/player/components/Player.tsx +6 -4
- package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
- package/src/player/components/thumbnailUtils.ts +6 -2
- package/src/player/components/timelineDragDrop.test.tsx +3 -3
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -1
- package/src/test-setup.ts +8 -0
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +18 -1
- package/src/utils/razorSplitTransaction.test.ts +3 -3
- package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
- package/src/utils/sourceScopedSelectorIndex.ts +64 -6
- package/src/utils/studioFileHistory.ts +2 -1
- package/src/utils/studioHelpers.test.ts +5 -1
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/timelineCompositionInsert.test.ts +3 -3
- package/dist/chunk-A7S5SGNA.js.map +0 -1
- /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
6
6
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
7
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
8
8
|
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
@@ -37,9 +37,9 @@ function assetTransfer(payload: string): DropTransfer {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function renderHarness(
|
|
40
|
-
onAssetDrop:
|
|
40
|
+
onAssetDrop: Mock,
|
|
41
41
|
sessionEpoch = 1,
|
|
42
|
-
options: { onBlockDrop?:
|
|
42
|
+
options: { onBlockDrop?: Mock; strict?: boolean } = {},
|
|
43
43
|
) {
|
|
44
44
|
const tracks = Array.from({ length: 100 }, (_, index) => index);
|
|
45
45
|
const geometry = createTimelineRowGeometry(
|
|
@@ -87,6 +87,42 @@ describe("scrubPreviewAudio", () => {
|
|
|
87
87
|
expect(audio.volume).toBeCloseTo(0.1);
|
|
88
88
|
stopScrubPreviewAudio();
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The preview document is a different realm, so `instanceof HTMLAudioElement`
|
|
93
|
+
* is false for every node in it. That threw the `musicId` hint away and left
|
|
94
|
+
* the first `<audio>` in the document as the only route — and the first
|
|
95
|
+
* `<audio>` is often the voiceover, so scrubbing previewed the wrong track.
|
|
96
|
+
* Two elements, music second, is what tells the two paths apart: with one
|
|
97
|
+
* element the fallback reaches the right node by accident.
|
|
98
|
+
*/
|
|
99
|
+
it("previews the track named by musicId, not the first audio in the document", () => {
|
|
100
|
+
const iframe = document.createElement("iframe");
|
|
101
|
+
document.body.append(iframe);
|
|
102
|
+
const previewDoc = iframe.contentDocument;
|
|
103
|
+
if (!previewDoc?.body) throw new Error("expected an iframe document");
|
|
104
|
+
|
|
105
|
+
const voiceover = previewDoc.createElement("audio");
|
|
106
|
+
voiceover.id = "voiceover";
|
|
107
|
+
voiceover.play = vi.fn(async () => {});
|
|
108
|
+
voiceover.pause = vi.fn();
|
|
109
|
+
|
|
110
|
+
const music = previewDoc.createElement("audio");
|
|
111
|
+
music.id = "music-bed";
|
|
112
|
+
music.play = vi.fn(async () => {});
|
|
113
|
+
music.pause = vi.fn();
|
|
114
|
+
|
|
115
|
+
previewDoc.body.append(voiceover, music);
|
|
116
|
+
|
|
117
|
+
// The node really is cross-realm; this is the condition, not a contrivance.
|
|
118
|
+
expect(music instanceof HTMLAudioElement).toBe(false);
|
|
119
|
+
|
|
120
|
+
scrubPreviewAudio(iframe, 0.5, "music-bed", 1);
|
|
121
|
+
|
|
122
|
+
expect(music.play).toHaveBeenCalled();
|
|
123
|
+
expect(voiceover.play).not.toHaveBeenCalled();
|
|
124
|
+
stopScrubPreviewAudio();
|
|
125
|
+
});
|
|
90
126
|
});
|
|
91
127
|
|
|
92
128
|
describe("applyPreviewAudioFlags", () => {
|
|
@@ -218,10 +218,26 @@ let scrubPrevVolume: number | null = null;
|
|
|
218
218
|
// Resolve the SAME element the store identified as music: prefer its id, then
|
|
219
219
|
// the role attribute, and only fall back to the first <audio> (which could be a
|
|
220
220
|
// voiceover, so the id hint matters).
|
|
221
|
+
/**
|
|
222
|
+
* `doc` is the preview iframe's document, so its `<audio>` nodes are instances of
|
|
223
|
+
* the IFRAME's `HTMLAudioElement`, never this module's. `instanceof
|
|
224
|
+
* HTMLAudioElement` here is false for every one of them, which silently threw the
|
|
225
|
+
* `musicId` hint away and fell through to "first `<audio>` in the document" — the
|
|
226
|
+
* very thing the comment above warns can be a voiceover. Ask what the node IS.
|
|
227
|
+
* Same rule and same reasoning as packages/core/src/runtime/domRealm.ts.
|
|
228
|
+
*/
|
|
229
|
+
function isAudioNode(node: Element | null): node is HTMLAudioElement {
|
|
230
|
+
return (
|
|
231
|
+
node !== null &&
|
|
232
|
+
node.namespaceURI === "http://www.w3.org/1999/xhtml" &&
|
|
233
|
+
node.localName === "audio"
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
221
237
|
function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioElement | null {
|
|
222
238
|
if (musicId) {
|
|
223
239
|
const byId = doc.getElementById(musicId);
|
|
224
|
-
if (byId
|
|
240
|
+
if (isAudioNode(byId)) return byId;
|
|
225
241
|
}
|
|
226
242
|
return (
|
|
227
243
|
doc.querySelector<HTMLAudioElement>("audio[data-timeline-role='music']") ??
|
package/src/test-setup.ts
CHANGED
|
@@ -4,3 +4,11 @@ if (typeof globalThis.CSS === "undefined") {
|
|
|
4
4
|
if (typeof CSS.escape !== "function") {
|
|
5
5
|
CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
// happy-dom does not implement `window.confirm`. Vitest 3 let `vi.spyOn` invent
|
|
9
|
+
// a missing property; Vitest 4 refuses with "can only spy on a function", so the
|
|
10
|
+
// environment has to supply a real one for the guard tests to replace. Declining
|
|
11
|
+
// is the safe default: no test calls it without stubbing a return value first.
|
|
12
|
+
if (typeof globalThis.confirm !== "function") {
|
|
13
|
+
(globalThis as Record<string, unknown>).confirm = () => false;
|
|
14
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./projectRouting";
|
|
1
2
|
import type { RegistryItem } from "@hyperframes/core/registry";
|
|
2
3
|
import type { TimelineElement } from "../player";
|
|
3
4
|
import {
|
|
@@ -64,7 +65,7 @@ async function installRegistryItem({
|
|
|
64
65
|
block: RegistryItem;
|
|
65
66
|
compositionFile: string;
|
|
66
67
|
} | null> {
|
|
67
|
-
const response = await fetch(`/
|
|
68
|
+
const response = await fetch(buildProjectApiPath(projectId, `/registry/install`), {
|
|
68
69
|
method: "POST",
|
|
69
70
|
headers: { "Content-Type": "application/json" },
|
|
70
71
|
body: JSON.stringify({ blockName }),
|
|
@@ -9,6 +9,15 @@ import {
|
|
|
9
9
|
} from "./projectRouting";
|
|
10
10
|
|
|
11
11
|
describe("project routing utilities", () => {
|
|
12
|
+
it.each(["C:", "C:demo", ".", "..", "../sessions", "a/b", "a\\b", "a\u0000b", "a\nb"])(
|
|
13
|
+
"rejects unsafe decoded project IDs: %s",
|
|
14
|
+
(id) => {
|
|
15
|
+
expect(parseProjectIdFromHash(`#project/${encodeURIComponent(id)}`)).toBeNull();
|
|
16
|
+
expect(() => buildProjectApiPath(id, "/files/index.html")).toThrow("Invalid project ID");
|
|
17
|
+
expect(() => buildProjectHash(id)).toThrow("Invalid project ID");
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
12
21
|
it("decodes project ids from hash routes before building capture URLs", () => {
|
|
13
22
|
vi.useFakeTimers();
|
|
14
23
|
vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
|
|
@@ -5,6 +5,19 @@ export interface ProjectHashRoute {
|
|
|
5
5
|
params: URLSearchParams;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Project names are single path segments, including when received from a hash or server. */
|
|
9
|
+
export function isValidProjectId(value: string): boolean {
|
|
10
|
+
return (
|
|
11
|
+
value.length > 0 &&
|
|
12
|
+
value !== "." &&
|
|
13
|
+
value !== ".." &&
|
|
14
|
+
!value.includes(":") &&
|
|
15
|
+
!value.includes("/") &&
|
|
16
|
+
!value.includes("\\") &&
|
|
17
|
+
!Array.from(value).some((char) => char.charCodeAt(0) < 32)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
function decodeHashProjectId(value: string): string {
|
|
9
22
|
try {
|
|
10
23
|
return decodeURIComponent(value);
|
|
@@ -28,6 +41,7 @@ function normalizeHashParams(
|
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
export function encodeProjectId(projectId: string): string {
|
|
44
|
+
if (!isValidProjectId(projectId)) throw new Error("Invalid project ID");
|
|
31
45
|
return encodeURIComponent(projectId);
|
|
32
46
|
}
|
|
33
47
|
|
|
@@ -47,9 +61,12 @@ export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
|
|
|
47
61
|
const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;
|
|
48
62
|
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
|
|
49
63
|
|
|
64
|
+
const projectId = decodeHashProjectId(encodedProjectId);
|
|
65
|
+
if (!isValidProjectId(projectId)) return null;
|
|
66
|
+
|
|
50
67
|
const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : "";
|
|
51
68
|
return {
|
|
52
|
-
projectId
|
|
69
|
+
projectId,
|
|
53
70
|
params: new URLSearchParams(rawParams),
|
|
54
71
|
};
|
|
55
72
|
}
|
|
@@ -103,7 +103,7 @@ describe("runAtomicCutTransaction", () => {
|
|
|
103
103
|
const synchronize = vi.fn();
|
|
104
104
|
|
|
105
105
|
const result = await runAtomicCutTransaction({
|
|
106
|
-
projectId: "launch
|
|
106
|
+
projectId: "launch#demo",
|
|
107
107
|
intents: buildAtomicCutIntents([element()], 2, "index.html"),
|
|
108
108
|
label: "Split timeline clip",
|
|
109
109
|
writeProjectFile,
|
|
@@ -114,8 +114,8 @@ describe("runAtomicCutTransaction", () => {
|
|
|
114
114
|
|
|
115
115
|
expect(requests.filter((request) => request.url.includes("split-batch"))).toHaveLength(1);
|
|
116
116
|
expect(requests.map((request) => request.url)).toEqual([
|
|
117
|
-
"/api/projects/launch%
|
|
118
|
-
"/api/projects/launch%
|
|
117
|
+
"/api/projects/launch%23demo/files/index.html",
|
|
118
|
+
"/api/projects/launch%23demo/file-mutations/split-batch",
|
|
119
119
|
]);
|
|
120
120
|
const splitRequest = requests.find((request) => request.url.includes("split-batch"));
|
|
121
121
|
const writeToken = new Headers(splitRequest?.headers).get("X-Hyperframes-Write-Token");
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { getSourceScopedSelectorIndex, withSelectorIndexPass } from "./sourceScopedSelectorIndex";
|
|
5
|
+
|
|
6
|
+
// The per-element algorithm this helper used before it grew a per-pass index,
|
|
7
|
+
// transcribed verbatim. It is the independent source the memoized results are
|
|
8
|
+
// checked against — including the misses, which are `indexOf` returning -1.
|
|
9
|
+
function referenceIndex(
|
|
10
|
+
doc: Document,
|
|
11
|
+
el: Element,
|
|
12
|
+
selector: string | undefined,
|
|
13
|
+
sourceFile: string | undefined,
|
|
14
|
+
resolveSourceFile: (candidate: Element) => string | undefined,
|
|
15
|
+
): number | undefined {
|
|
16
|
+
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const scope = sourceFile ?? "index.html";
|
|
21
|
+
const matches = Array.from(doc.querySelectorAll(selector)).filter(
|
|
22
|
+
(candidate) => (resolveSourceFile(candidate) ?? "index.html") === scope,
|
|
23
|
+
);
|
|
24
|
+
const matchIndex = matches.indexOf(el);
|
|
25
|
+
return matchIndex >= 0 ? matchIndex : undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bySourceFile = (candidate: Element): string | undefined =>
|
|
32
|
+
candidate.closest("[data-composition-file]")?.getAttribute("data-composition-file") ?? undefined;
|
|
33
|
+
|
|
34
|
+
// Two source files sharing one class, plus an unscoped element, is the case
|
|
35
|
+
// source-file scoping exists for: raw document order would number these 0..4.
|
|
36
|
+
function mixedSourceDocument(): Document {
|
|
37
|
+
const doc = document.implementation.createHTMLDocument("mixed");
|
|
38
|
+
doc.body.innerHTML = `
|
|
39
|
+
<div data-composition-id="root">
|
|
40
|
+
<section data-composition-file="a.html">
|
|
41
|
+
<p class="box" id="a0">a0</p>
|
|
42
|
+
<p class="box" id="a1">a1</p>
|
|
43
|
+
</section>
|
|
44
|
+
<section data-composition-file="b.html">
|
|
45
|
+
<p class="box" id="b0">b0</p>
|
|
46
|
+
<p class="box" id="b1">b1</p>
|
|
47
|
+
</section>
|
|
48
|
+
<p class="box" id="root0">root0</p>
|
|
49
|
+
</div>
|
|
50
|
+
`;
|
|
51
|
+
return doc;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("getSourceScopedSelectorIndex", () => {
|
|
55
|
+
it("numbers occurrences within each source file, not across the flattened document", () => {
|
|
56
|
+
const doc = mixedSourceDocument();
|
|
57
|
+
const read = (id: string, sourceFile: string | undefined) =>
|
|
58
|
+
getSourceScopedSelectorIndex(doc, doc.getElementById(id)!, ".box", sourceFile, bySourceFile);
|
|
59
|
+
|
|
60
|
+
expect(read("a0", "a.html")).toBe(0);
|
|
61
|
+
expect(read("a1", "a.html")).toBe(1);
|
|
62
|
+
expect(read("b0", "b.html")).toBe(0);
|
|
63
|
+
expect(read("b1", "b.html")).toBe(1);
|
|
64
|
+
expect(read("root0", undefined)).toBe(0);
|
|
65
|
+
// Asking for an element under the wrong scope is a miss, not a neighbour's index.
|
|
66
|
+
expect(read("b0", "a.html")).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("matches the per-element reference for every element, inside a pass and outside it", () => {
|
|
70
|
+
const doc = mixedSourceDocument();
|
|
71
|
+
const boxes = Array.from(doc.querySelectorAll(".box"));
|
|
72
|
+
const scopes = [undefined, "index.html", "a.html", "b.html", "missing.html"];
|
|
73
|
+
const cases = boxes.flatMap((el) => scopes.map((scope) => ({ el, scope })));
|
|
74
|
+
|
|
75
|
+
const expected = cases.map(({ el, scope }) =>
|
|
76
|
+
referenceIndex(doc, el, ".box", scope, bySourceFile),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const outsidePass = cases.map(({ el, scope }) =>
|
|
80
|
+
getSourceScopedSelectorIndex(doc, el, ".box", scope, bySourceFile),
|
|
81
|
+
);
|
|
82
|
+
const insidePass = withSelectorIndexPass(doc, () =>
|
|
83
|
+
cases.map(({ el, scope }) =>
|
|
84
|
+
getSourceScopedSelectorIndex(doc, el, ".box", scope, bySourceFile),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(outsidePass).toEqual(expected);
|
|
89
|
+
expect(insidePass).toEqual(expected);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns undefined for the selectors that carry their own identity", () => {
|
|
93
|
+
const doc = mixedSourceDocument();
|
|
94
|
+
const el = doc.getElementById("a0")!;
|
|
95
|
+
for (const selector of [undefined, "#a0", '[data-composition-id="root"]']) {
|
|
96
|
+
expect(
|
|
97
|
+
getSourceScopedSelectorIndex(doc, el, selector, "a.html", bySourceFile),
|
|
98
|
+
).toBeUndefined();
|
|
99
|
+
expect(
|
|
100
|
+
withSelectorIndexPass(doc, () =>
|
|
101
|
+
getSourceScopedSelectorIndex(doc, el, selector, "a.html", bySourceFile),
|
|
102
|
+
),
|
|
103
|
+
).toBeUndefined();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns undefined for an invalid selector rather than throwing", () => {
|
|
108
|
+
const doc = mixedSourceDocument();
|
|
109
|
+
const el = doc.getElementById("a0")!;
|
|
110
|
+
expect(getSourceScopedSelectorIndex(doc, el, ".((", "a.html", bySourceFile)).toBeUndefined();
|
|
111
|
+
expect(
|
|
112
|
+
withSelectorIndexPass(doc, () =>
|
|
113
|
+
getSourceScopedSelectorIndex(doc, el, ".((", "a.html", bySourceFile),
|
|
114
|
+
),
|
|
115
|
+
).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not reuse one document's index for another, and restores the outer pass", () => {
|
|
119
|
+
const inner = mixedSourceDocument();
|
|
120
|
+
const outer = mixedSourceDocument();
|
|
121
|
+
// A nested pass over a different document must not answer from, or poison,
|
|
122
|
+
// the outer document's index.
|
|
123
|
+
const nested = withSelectorIndexPass(outer, () =>
|
|
124
|
+
withSelectorIndexPass(inner, () =>
|
|
125
|
+
getSourceScopedSelectorIndex(
|
|
126
|
+
inner,
|
|
127
|
+
inner.getElementById("b1")!,
|
|
128
|
+
".box",
|
|
129
|
+
"b.html",
|
|
130
|
+
bySourceFile,
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
expect(nested).toBe(1);
|
|
135
|
+
|
|
136
|
+
const afterNested = withSelectorIndexPass(outer, () => {
|
|
137
|
+
withSelectorIndexPass(inner, () => undefined);
|
|
138
|
+
return getSourceScopedSelectorIndex(
|
|
139
|
+
outer,
|
|
140
|
+
outer.getElementById("a1")!,
|
|
141
|
+
".box",
|
|
142
|
+
"a.html",
|
|
143
|
+
bySourceFile,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
expect(afterNested).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -5,6 +5,60 @@
|
|
|
5
5
|
* `querySelectorAll` index is not a stable source-file identity. Callers supply
|
|
6
6
|
* their existing source resolver; this helper alone owns occurrence scoping.
|
|
7
7
|
*/
|
|
8
|
+
|
|
9
|
+
/** Every element matching one selector, mapped to the source file it belongs to
|
|
10
|
+
* and its occurrence index within that file. One document query fills it. */
|
|
11
|
+
type SelectorOccurrences = Map<Element, { scope: string; index: number }>;
|
|
12
|
+
|
|
13
|
+
interface SelectorIndexPass {
|
|
14
|
+
doc: Document;
|
|
15
|
+
bySelector: Map<string, SelectorOccurrences>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let activePass: SelectorIndexPass | null = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Share one occurrence index per selector across every
|
|
22
|
+
* `getSourceScopedSelectorIndex` call made synchronously inside `run`.
|
|
23
|
+
*
|
|
24
|
+
* Unshared, each call runs its own whole-document `querySelectorAll(selector)`
|
|
25
|
+
* and resolves the source file of every match. A rebuild that walks n elements
|
|
26
|
+
* sharing a selector therefore does n whole-document queries and n² resolver
|
|
27
|
+
* calls — a composition of repeated cards or tiles is exactly that shape. One
|
|
28
|
+
* index per selector makes the same pass linear.
|
|
29
|
+
*
|
|
30
|
+
* Correctness rests on the pass being ONE synchronous walk of a document that
|
|
31
|
+
* does not change under it: nothing invalidates the index, so `run` must not
|
|
32
|
+
* mutate `doc`, and every call inside it must resolve source files the same way
|
|
33
|
+
* (the resolver may be a fresh closure per call, as long as it answers the
|
|
34
|
+
* same). Outside a pass the index is per-call, exactly as before.
|
|
35
|
+
*/
|
|
36
|
+
export function withSelectorIndexPass<T>(doc: Document, run: () => T): T {
|
|
37
|
+
const previous = activePass;
|
|
38
|
+
activePass = { doc, bySelector: new Map() };
|
|
39
|
+
try {
|
|
40
|
+
return run();
|
|
41
|
+
} finally {
|
|
42
|
+
activePass = previous;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildOccurrences(
|
|
47
|
+
doc: Document,
|
|
48
|
+
selector: string,
|
|
49
|
+
resolveSourceFile: (candidate: Element) => string | undefined,
|
|
50
|
+
): SelectorOccurrences {
|
|
51
|
+
const occurrences: SelectorOccurrences = new Map();
|
|
52
|
+
const counts = new Map<string, number>();
|
|
53
|
+
for (const candidate of doc.querySelectorAll(selector)) {
|
|
54
|
+
const scope = resolveSourceFile(candidate) ?? "index.html";
|
|
55
|
+
const index = counts.get(scope) ?? 0;
|
|
56
|
+
counts.set(scope, index + 1);
|
|
57
|
+
occurrences.set(candidate, { scope, index });
|
|
58
|
+
}
|
|
59
|
+
return occurrences;
|
|
60
|
+
}
|
|
61
|
+
|
|
8
62
|
export function getSourceScopedSelectorIndex(
|
|
9
63
|
doc: Document,
|
|
10
64
|
el: Element,
|
|
@@ -17,12 +71,16 @@ export function getSourceScopedSelectorIndex(
|
|
|
17
71
|
}
|
|
18
72
|
|
|
19
73
|
try {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
74
|
+
const pass = activePass?.doc === doc ? activePass : null;
|
|
75
|
+
let occurrences = pass?.bySelector.get(selector);
|
|
76
|
+
if (!occurrences) {
|
|
77
|
+
occurrences = buildOccurrences(doc, selector, resolveSourceFile);
|
|
78
|
+
pass?.bySelector.set(selector, occurrences);
|
|
79
|
+
}
|
|
80
|
+
// An element outside the requested scope is not in that scope's occurrence
|
|
81
|
+
// run at all, which is the `indexOf` miss the filtered form returned before.
|
|
82
|
+
const hit = occurrences.get(el);
|
|
83
|
+
return hit && hit.scope === (sourceFile ?? "index.html") ? hit.index : undefined;
|
|
26
84
|
} catch {
|
|
27
85
|
return undefined;
|
|
28
86
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./projectRouting";
|
|
1
2
|
import type { MutableRefObject } from "react";
|
|
2
3
|
import type { EditHistoryKind } from "./editHistory";
|
|
3
4
|
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
@@ -52,7 +53,7 @@ interface SaveProjectFilesWithHistoryInput {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export async function readProjectFileContent(pid: string, path: string): Promise<string> {
|
|
55
|
-
const response = await fetch(`/
|
|
56
|
+
const response = await fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent(path)}`));
|
|
56
57
|
if (!response.ok) {
|
|
57
58
|
throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
|
|
58
59
|
}
|
|
@@ -165,9 +165,13 @@ describe("resolveDroppedAssetDimensions", () => {
|
|
|
165
165
|
naturalWidth: 0,
|
|
166
166
|
src: "",
|
|
167
167
|
};
|
|
168
|
+
// Vitest 4 refuses to `new` an arrow-function mock, and the code under test
|
|
169
|
+
// constructs the probe with `new Image()`.
|
|
168
170
|
vi.stubGlobal(
|
|
169
171
|
"Image",
|
|
170
|
-
vi.fn(()
|
|
172
|
+
vi.fn(function () {
|
|
173
|
+
return probe;
|
|
174
|
+
}),
|
|
171
175
|
);
|
|
172
176
|
|
|
173
177
|
const result = resolveDroppedAssetDimensions("demo", "assets/hung.png", "image");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./projectRouting";
|
|
1
2
|
import { isTypingTarget } from "./typingTarget";
|
|
2
3
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
3
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
@@ -306,7 +307,7 @@ export async function resolveDroppedAssetDuration(
|
|
|
306
307
|
|
|
307
308
|
const media = document.createElement(kind === "video" ? "video" : "audio");
|
|
308
309
|
media.preload = "metadata";
|
|
309
|
-
media.src = `/
|
|
310
|
+
media.src = buildProjectApiPath(projectId, `/preview/${assetPath}`);
|
|
310
311
|
|
|
311
312
|
const duration = await new Promise<number>((resolve) => {
|
|
312
313
|
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
|
@@ -343,7 +344,7 @@ export async function resolveDroppedAssetDimensions(
|
|
|
343
344
|
kind: TimelineAssetKind,
|
|
344
345
|
): Promise<{ width: number; height: number } | null> {
|
|
345
346
|
if (kind === "audio") return null;
|
|
346
|
-
const src = `/
|
|
347
|
+
const src = buildProjectApiPath(projectId, `/preview/${assetPath}`);
|
|
347
348
|
|
|
348
349
|
if (kind === "image") {
|
|
349
350
|
return new Promise((resolve) => {
|
|
@@ -33,7 +33,7 @@ describe("commitTimelineCompositionInsertion", () => {
|
|
|
33
33
|
const refresh = vi.fn();
|
|
34
34
|
|
|
35
35
|
await commitTimelineCompositionInsertion({
|
|
36
|
-
projectId: "launch
|
|
36
|
+
projectId: "launch#demo",
|
|
37
37
|
targetPath: "index.html",
|
|
38
38
|
sourcePath: "headline.html",
|
|
39
39
|
start: 4,
|
|
@@ -48,8 +48,8 @@ describe("commitTimelineCompositionInsertion", () => {
|
|
|
48
48
|
|
|
49
49
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
50
50
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
51
|
-
"/api/projects/launch%
|
|
52
|
-
"/api/projects/launch%
|
|
51
|
+
"/api/projects/launch%23demo/files/index.html",
|
|
52
|
+
"/api/projects/launch%23demo/file-mutations/insert-composition/index.html",
|
|
53
53
|
]);
|
|
54
54
|
expect(recordEdit).toHaveBeenCalledOnce();
|
|
55
55
|
expect(writeFile).not.toHaveBeenCalled();
|