@hyperframes/studio 0.8.32 → 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.
Files changed (85) hide show
  1. package/dist/assets/{hyperframes-player-CroojEG1.js → hyperframes-player-D4aSRr_M.js} +1 -1
  2. package/dist/assets/{index-PVOehm7h.js → index-CkxPQxLJ.js} +182 -172
  3. package/dist/assets/{index-CSyaU1m2.js → index-aNyQBoJv.js} +1 -1
  4. package/dist/assets/{index-D0AjUHNT.js → index-hch4ktcC.js} +1 -1
  5. package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
  6. package/dist/chunk-7BP32G62.js.map +1 -0
  7. package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +354 -206
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -4
  13. package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
  14. package/src/captions/components/CaptionOverlay.tsx +1 -0
  15. package/src/captions/generator.test.ts +30 -0
  16. package/src/captions/generator.ts +5 -3
  17. package/src/captions/hooks/useCaptionSync.ts +3 -2
  18. package/src/components/MediaPreview.tsx +2 -1
  19. package/src/components/editor/AnimationCard.test.tsx +3 -3
  20. package/src/components/editor/PropertyPanel.test.tsx +18 -14
  21. package/src/components/editor/SnapGuideOverlay.tsx +2 -6
  22. package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
  23. package/src/components/editor/domEditLayerWalkCache.ts +327 -0
  24. package/src/components/editor/domEditOverlayBasis.ts +72 -0
  25. package/src/components/editor/domEditOverlayGeometry.ts +78 -83
  26. package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
  27. package/src/components/editor/domEditOverlayTransform.ts +47 -9
  28. package/src/components/editor/domEditingDom.ts +63 -14
  29. package/src/components/editor/domEditingElement.ts +56 -14
  30. package/src/components/editor/domEditingLayers.test.ts +48 -0
  31. package/src/components/editor/domEditingLayers.ts +22 -35
  32. package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
  33. package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
  34. package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
  35. package/src/components/editor/overlayFrameLoop.test.ts +246 -0
  36. package/src/components/editor/overlayFrameLoop.ts +204 -0
  37. package/src/components/editor/probeSourceElement.ts +29 -0
  38. package/src/components/editor/propertyPanelFill.tsx +2 -1
  39. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
  40. package/src/components/editor/useDomEditCompositionRect.ts +2 -4
  41. package/src/components/editor/useDomEditOverlayRects.ts +15 -9
  42. package/src/components/editor/useMotionPathData.ts +2 -4
  43. package/src/components/feedback/projectProvenance.ts +4 -1
  44. package/src/components/nle/NLEContext.tsx +2 -1
  45. package/src/components/nle/PreviewOverlays.tsx +1 -1
  46. package/src/components/nle/useCompositionStack.ts +11 -4
  47. package/src/components/renders/RenderQueue.test.tsx +2 -2
  48. package/src/components/renders/RenderQueueItem.tsx +2 -1
  49. package/src/components/renders/useRenderQueue.ts +3 -2
  50. package/src/components/sidebar/AssetsTab.tsx +2 -1
  51. package/src/components/sidebar/CompositionsTab.tsx +2 -1
  52. package/src/components/storyboard/FramePoster.tsx +2 -1
  53. package/src/hooks/useAskAgentModal.ts +2 -1
  54. package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
  55. package/src/hooks/useCaptionDetection.ts +3 -1
  56. package/src/hooks/useCompositionContentLoader.ts +3 -1
  57. package/src/hooks/useCompositionDimensions.test.tsx +47 -0
  58. package/src/hooks/useCompositionDimensions.ts +3 -2
  59. package/src/hooks/useDomEditCommits.ts +5 -4
  60. package/src/hooks/useElementLifecycleOps.ts +5 -1
  61. package/src/hooks/useFileTree.ts +5 -4
  62. package/src/hooks/useGroupCommits.ts +2 -1
  63. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
  64. package/src/hooks/useLintModal.ts +2 -1
  65. package/src/hooks/useRenderClipContent.ts +6 -5
  66. package/src/hooks/useTimelineDeleteOps.ts +5 -1
  67. package/src/player/components/Player.tsx +6 -4
  68. package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
  69. package/src/player/components/thumbnailUtils.ts +6 -2
  70. package/src/player/components/timelineDragDrop.test.tsx +3 -3
  71. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  72. package/src/player/lib/timelineIframeHelpers.ts +17 -1
  73. package/src/test-setup.ts +8 -0
  74. package/src/utils/blockInstaller.ts +2 -1
  75. package/src/utils/projectRouting.test.ts +9 -0
  76. package/src/utils/projectRouting.ts +18 -1
  77. package/src/utils/razorSplitTransaction.test.ts +3 -3
  78. package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
  79. package/src/utils/sourceScopedSelectorIndex.ts +64 -6
  80. package/src/utils/studioFileHistory.ts +2 -1
  81. package/src/utils/studioHelpers.test.ts +5 -1
  82. package/src/utils/studioHelpers.ts +3 -2
  83. package/src/utils/timelineCompositionInsert.test.ts +3 -3
  84. package/dist/chunk-A7S5SGNA.js.map +0 -1
  85. /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: ReturnType<typeof vi.fn>,
40
+ onAssetDrop: Mock,
41
41
  sessionEpoch = 1,
42
- options: { onBlockDrop?: ReturnType<typeof vi.fn>; strict?: boolean } = {},
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 instanceof HTMLAudioElement) return 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(`/api/projects/${projectId}/registry/install`, {
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: decodeHashProjectId(encodedProjectId),
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/demo",
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%2Fdemo/files/index.html",
118
- "/api/projects/launch%2Fdemo/file-mutations/split-batch",
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 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;
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(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
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(() => probe),
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 = `/api/projects/${projectId}/preview/${assetPath}`;
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 = `/api/projects/${projectId}/preview/${assetPath}`;
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/demo",
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%2Fdemo/files/index.html",
52
- "/api/projects/launch%2Fdemo/file-mutations/insert-composition/index.html",
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();