@hyperframes/studio 0.7.103 → 0.7.105

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-DUB-xtR5.js → hyperframes-player-Jx07OQgw.js} +1 -1
  2. package/dist/assets/{index-DHc-xHLb.js → index-CryZ0PGi.js} +1 -1
  3. package/dist/assets/{index-Cez_yvk5.js → index-Dqi2GmOp.js} +1 -1
  4. package/dist/assets/{index-CxC2vn2-.js → index-DuxKGsHZ.js} +214 -214
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +779 -294
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +6 -2
  10. package/src/captions/hooks/useCaptionSync.ts +2 -1
  11. package/src/components/editor/DomEditOverlay.test.ts +89 -2
  12. package/src/components/editor/DomEditOverlay.tsx +40 -23
  13. package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
  14. package/src/components/editor/domEditOverlayGeometry.ts +59 -2
  15. package/src/components/editor/domEditOverlayGestures.ts +42 -0
  16. package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
  17. package/src/components/editor/groupDragMove.ts +110 -0
  18. package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
  19. package/src/components/editor/manualEditsDom.ts +15 -13
  20. package/src/components/editor/manualOffsetDrag.test.ts +92 -5
  21. package/src/components/editor/manualOffsetDrag.ts +22 -17
  22. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
  23. package/src/components/editor/snapEngageTravel.test.ts +65 -0
  24. package/src/components/editor/snapEngine.ts +21 -1
  25. package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
  26. package/src/components/editor/useDomEditOverlayRects.ts +4 -2
  27. package/src/hooks/domSelectionTimelineMirror.ts +73 -0
  28. package/src/hooks/gestureTransaction.test.ts +7 -12
  29. package/src/hooks/gestureTransaction.ts +7 -20
  30. package/src/hooks/gsapRuntimePatch.test.ts +43 -0
  31. package/src/hooks/gsapRuntimePatch.ts +11 -1
  32. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  33. package/src/hooks/gsapScriptCommitTypes.ts +20 -0
  34. package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
  35. package/src/hooks/keyframeCacheAstLoad.ts +29 -1
  36. package/src/hooks/timelineTimingSync.ts +4 -1
  37. package/src/hooks/useDomEditCommits.ts +2 -1
  38. package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
  39. package/src/hooks/useDomEditPreviewSync.ts +23 -0
  40. package/src/hooks/useDomEditSession.test.tsx +43 -62
  41. package/src/hooks/useDomEditSession.ts +10 -2
  42. package/src/hooks/useDomEditWiring.ts +6 -0
  43. package/src/hooks/useDomSelection.test.ts +133 -3
  44. package/src/hooks/useDomSelection.ts +67 -40
  45. package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
  46. package/src/hooks/useElementLifecycleOps.ts +2 -1
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
  48. package/src/hooks/useFileManager.ts +2 -8
  49. package/src/hooks/useGroupCommits.ts +2 -1
  50. package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
  52. package/src/hooks/useGsapAwareEditing.ts +88 -15
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
  54. package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
  55. package/src/hooks/useGsapScriptCommits.ts +52 -15
  56. package/src/hooks/useStudioUrlState.ts +130 -7
  57. package/src/hooks/useTimelineEditing.ts +2 -1
  58. package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
  59. package/src/player/hooks/useTimelinePlayer.ts +2 -0
  60. package/src/utils/dragDebug.ts +98 -0
  61. package/src/utils/razorSplitTransaction.test.ts +15 -3
  62. package/src/utils/razorSplitTransaction.ts +2 -0
  63. package/src/utils/reloadDebug.ts +10 -0
  64. package/src/utils/resizeDebug.ts +19 -29
  65. package/src/utils/selectDebug.ts +9 -0
  66. package/src/utils/studioDebug.test.ts +32 -0
  67. package/src/utils/studioDebug.ts +30 -0
  68. package/src/utils/studioFileVersion.ts +16 -1
  69. package/src/utils/studioUrlState.test.ts +163 -1
  70. package/src/utils/studioUrlState.ts +60 -3
  71. package/src/utils/timelineCompositionInsert.ts +2 -1
@@ -0,0 +1,98 @@
1
+ // Canvas drag diagnostics — grep [hf-drag]. Off by default; opt in per session
2
+ // with `localStorage.setItem("hf-drag-debug", "1")` (then reload).
3
+ //
4
+ // A drag that "jumps" is a position that changed without the pointer asking. The
5
+ // pointer delta, what snapping did to it, what each member was told to move, and
6
+ // where each member actually ended up are logged at every stage, so the frame the
7
+ // position diverges from the pointer is visible rather than inferred.
8
+ import { makeStudioDebugLogger } from "./studioDebug";
9
+
10
+ export const logDrag = makeStudioDebugLogger("drag");
11
+
12
+ let moveN = 0;
13
+
14
+ /** Per-pointermove logging, throttled: the first move then every 8th. */
15
+ export function logDragMove(data: Record<string, unknown>): void {
16
+ logDrag("move", () => {
17
+ moveN += 1;
18
+ return moveN % 8 === 1 ? { n: moveN, ...data } : null;
19
+ });
20
+ }
21
+
22
+ export function resetDragMoveLog(): void {
23
+ moveN = 0;
24
+ }
25
+
26
+ /** Where these elements are rendered right now, in preview-document pixels. */
27
+ export function readDragPositions(
28
+ elements: Array<{ key: string; element: HTMLElement }>,
29
+ ): Record<string, string> {
30
+ const positions: Record<string, string> = {};
31
+ for (const { key, element } of elements) {
32
+ const rect = element.getBoundingClientRect();
33
+ positions[key] = `${Math.round(rect.left)},${Math.round(rect.top)}`;
34
+ }
35
+ return positions;
36
+ }
37
+
38
+ /**
39
+ * Members whose screen movement disagrees with the rest of the group this frame.
40
+ *
41
+ * A group moves as one object, so every member travels the same distance; one
42
+ * that does not is the whole bug, and averaged-looking samples hide it. Compares
43
+ * each member's movement against the group's median and names the outliers, so a
44
+ * single element drifting shows up as itself rather than as "the group jumped".
45
+ */
46
+ export function findNonRigidMembers(
47
+ before: Record<string, string>,
48
+ after: Record<string, string>,
49
+ ): string[] {
50
+ const moves = new Map<string, string>();
51
+ for (const key of Object.keys(after)) {
52
+ const from = before[key]?.split(",").map(Number);
53
+ const to = after[key]?.split(",").map(Number);
54
+ if (!from || !to || from.length !== 2 || to.length !== 2) continue;
55
+ moves.set(key, `${Math.round(to[0]! - from[0]!)},${Math.round(to[1]! - from[1]!)}`);
56
+ }
57
+ const counts = new Map<string, number>();
58
+ for (const move of moves.values()) counts.set(move, (counts.get(move) ?? 0) + 1);
59
+ let common = "";
60
+ let best = 0;
61
+ for (const [move, count] of counts) {
62
+ if (count > best) [common, best] = [move, count];
63
+ }
64
+ return [...moves]
65
+ .filter(([, move]) => move !== common)
66
+ .map(([key, move]) => `${key.split("|")[2] ?? key} moved ${move}, group moved ${common}`);
67
+ }
68
+
69
+ /**
70
+ * Sample the group now and again after the commit has had time to land. The drop
71
+ * is the one moment a jump can hide: the source write, the preview reload and the
72
+ * timeline resume all happen within a few frames of each other, and any of them
73
+ * can put the elements back where they started before the new position arrives.
74
+ */
75
+ export function logDragSettle(
76
+ stage: string,
77
+ elements: Array<{ key: string; element: HTMLElement }>,
78
+ ): void {
79
+ logDrag(stage, () => {
80
+ const at = readDragPositions(elements);
81
+ const win = elements[0]?.element.ownerDocument.defaultView;
82
+ if (win) {
83
+ win.setTimeout(
84
+ () => logDrag(`${stage}+120ms`, () => ({ at: readDragPositions(elements) })),
85
+ 120,
86
+ );
87
+ win.setTimeout(
88
+ () => logDrag(`${stage}+400ms`, () => ({ at: readDragPositions(elements) })),
89
+ 400,
90
+ );
91
+ win.setTimeout(
92
+ () => logDrag(`${stage}+900ms`, () => ({ at: readDragPositions(elements) })),
93
+ 900,
94
+ );
95
+ }
96
+ return { at };
97
+ });
98
+ }
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction";
4
+ import { consumeStudioWriteToken, resetStudioWriteTokens } from "./studioFileVersion";
4
5
 
5
6
  const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
6
7
  id: "clip",
@@ -14,7 +15,10 @@ const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
14
15
  ...over,
15
16
  });
16
17
 
17
- afterEach(() => vi.unstubAllGlobals());
18
+ afterEach(() => {
19
+ resetStudioWriteTokens();
20
+ vi.unstubAllGlobals();
21
+ });
18
22
 
19
23
  describe("buildAtomicCutIntents", () => {
20
24
  it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => {
@@ -45,12 +49,16 @@ describe("buildAtomicCutIntents", () => {
45
49
  });
46
50
 
47
51
  function installCutServer(options: { status?: number } = {}) {
48
- const requests: Array<{ url: string; body?: unknown }> = [];
52
+ const requests: Array<{ url: string; body?: unknown; headers?: HeadersInit }> = [];
49
53
  vi.stubGlobal(
50
54
  "fetch",
51
55
  vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
52
56
  const url = String(input);
53
- requests.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
57
+ requests.push({
58
+ url,
59
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
60
+ headers: init?.headers,
61
+ });
54
62
  if (url.includes("/files/")) {
55
63
  return new Response(JSON.stringify({ content: "before", version: '"v0"' }), {
56
64
  status: 200,
@@ -109,6 +117,10 @@ describe("runAtomicCutTransaction", () => {
109
117
  "/api/projects/launch%2Fdemo/files/index.html",
110
118
  "/api/projects/launch%2Fdemo/file-mutations/split-batch",
111
119
  ]);
120
+ const splitRequest = requests.find((request) => request.url.includes("split-batch"));
121
+ const writeToken = new Headers(splitRequest?.headers).get("X-Hyperframes-Write-Token");
122
+ expect(writeToken).toMatch(/^cut:/);
123
+ expect(consumeStudioWriteToken(writeToken)).toBe(true);
112
124
  expect(writeProjectFile).not.toHaveBeenCalled();
113
125
  expect(recordEdit).toHaveBeenCalledWith({
114
126
  label: "Split timeline clip",
@@ -3,6 +3,7 @@ import type { RecordEditInput } from "../hooks/timelineEditingHelpers";
3
3
  import { buildPatchTarget } from "./timelineElementSplit";
4
4
  import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
5
5
  import { buildProjectApiPath } from "./projectRouting";
6
+ import { markStudioWriteToken } from "./studioFileVersion";
6
7
 
7
8
  type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
8
9
 
@@ -117,6 +118,7 @@ async function requestAtomicCut(
117
118
  });
118
119
  }
119
120
  const transactionToken = `cut:${crypto.randomUUID()}`;
121
+ markStudioWriteToken(transactionToken);
120
122
  const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
121
123
  method: "POST",
122
124
  headers: {
@@ -0,0 +1,10 @@
1
+ // Preview full-reload diagnostics — grep [hf-reload]. Off by default; opt in per
2
+ // session with `localStorage.setItem("hf-reload-debug", "1")` (then reload).
3
+ //
4
+ // A full reload blanks the stage for ~100-300ms, so any reload the user did not
5
+ // ask for reads as a flash. These lines answer the only question that matters
6
+ // when one appears: who asked for it, and why the write that triggered it was
7
+ // not recognised as Studio's own.
8
+ import { makeStudioDebugLogger } from "./studioDebug";
9
+
10
+ export const logReload = makeStudioDebugLogger("reload");
@@ -1,33 +1,18 @@
1
1
  // Resize/gesture diagnostics — grep [hf-resize]. Off by default; opt in per
2
2
  // session with `localStorage.setItem("hf-resize-debug", "1")` (then reload).
3
- // Granular per-move/per-gesture tracing that complements the always-on
4
- // [hf-commit] transaction telemetry in gestureTransaction.ts.
5
- let moveN = 0;
6
- let enabled: boolean | null = null;
3
+ // Granular per-move/per-gesture tracing for resize investigation.
4
+ import { makeStudioDebugLogger } from "./studioDebug";
7
5
 
8
- function isEnabled(): boolean {
9
- if (enabled === null) {
10
- try {
11
- enabled = localStorage.getItem("hf-resize-debug") === "1";
12
- } catch {
13
- enabled = false;
14
- }
15
- }
16
- return enabled;
17
- }
6
+ export const logResize = makeStudioDebugLogger("resize");
18
7
 
19
- export function logResize(stage: string, data: Record<string, unknown>): void {
20
- if (!isEnabled()) return;
21
- console.log(
22
- `[hf-resize] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...data })}`,
23
- );
24
- }
8
+ let moveN = 0;
25
9
 
26
10
  /** Per-pointermove logging, throttled: first move then every 8th. */
27
11
  export function logResizeMove(data: Record<string, unknown>): void {
28
- if (!isEnabled()) return;
29
- moveN += 1;
30
- if (moveN % 8 === 1) logResize("move", { n: moveN, ...data });
12
+ logResize("move", () => {
13
+ moveN += 1;
14
+ return moveN % 8 === 1 ? { n: moveN, ...data } : null;
15
+ });
31
16
  }
32
17
 
33
18
  export function resetResizeMoveLog(): void {
@@ -36,11 +21,10 @@ export function resetResizeMoveLog(): void {
36
21
 
37
22
  /** Snapshot the element's live geometry now and again after 200ms (jump detector). */
38
23
  export function logResizeSettle(el: HTMLElement, tag: string): void {
39
- if (!isEnabled()) return;
40
- const snap = (phase: string) => {
24
+ const snapshot = (phase: string) => {
41
25
  const r = el.getBoundingClientRect();
42
26
  const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
43
- logResize("settle", {
27
+ return {
44
28
  tag,
45
29
  phase,
46
30
  rect: { x: r.x, y: r.y, w: r.width, h: r.height },
@@ -48,8 +32,14 @@ export function logResizeSettle(el: HTMLElement, tag: string): void {
48
32
  cssH: cs?.height,
49
33
  transform: cs?.transform,
50
34
  inlineStyle: el.getAttribute("style"),
51
- });
35
+ };
52
36
  };
53
- snap("t0");
54
- setTimeout(() => snap("t200"), 200);
37
+ logResize("settle", () => {
38
+ const current = snapshot("t0");
39
+ el.ownerDocument.defaultView?.setTimeout(
40
+ () => logResize("settle", () => snapshot("t200")),
41
+ 200,
42
+ );
43
+ return current;
44
+ });
55
45
  }
@@ -0,0 +1,9 @@
1
+ // Canvas selection diagnostics — grep [hf-select]. Off by default; opt in with
2
+ // `localStorage.setItem("hf-select-debug", "1")` (then reload).
3
+ //
4
+ // Selection failures are silent by nature: a handler returns early and nothing
5
+ // happens, which looks identical to a click that never landed. These lines say
6
+ // which branch ran and what it decided.
7
+ import { makeStudioDebugLogger } from "./studioDebug";
8
+
9
+ export const logSelect = makeStudioDebugLogger("select");
@@ -0,0 +1,32 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { makeStudioDebugLogger } from "./studioDebug";
5
+
6
+ describe("makeStudioDebugLogger", () => {
7
+ beforeEach(() => {
8
+ localStorage.clear();
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("does no console work while its channel is disabled", () => {
13
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
14
+ const buildDetails = vi.fn(() => ({ expensive: true }));
15
+
16
+ makeStudioDebugLogger("commit")("persisted", buildDetails);
17
+
18
+ expect(buildDetails).not.toHaveBeenCalled();
19
+ expect(log).not.toHaveBeenCalled();
20
+ });
21
+
22
+ it("logs only after its exact channel is enabled", () => {
23
+ localStorage.setItem("hf-commit-debug", "1");
24
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
25
+
26
+ makeStudioDebugLogger("commit")("persisted", { mutations: 2 });
27
+
28
+ expect(log).toHaveBeenCalledOnce();
29
+ expect(log.mock.calls[0]?.[0]).toContain('[hf-commit] {"stage":"persisted"');
30
+ expect(log.mock.calls[0]?.[0]).toContain('"mutations":2');
31
+ });
32
+ });
@@ -0,0 +1,30 @@
1
+ // Opt-in diagnostic channels — one per question worth tracing, all off by
2
+ // default. Turn one on for the session with `localStorage.setItem("hf-<name>-debug",
3
+ // "1")` and reload, then grep the console for `[hf-<name>]`.
4
+ // Live channels: reload, select, drag, resize, commit.
5
+ //
6
+ // These exist because the interesting failures here are decisions, not crashes:
7
+ // a preview that reloads when it should not, a shift-click that selects nothing.
8
+ // Nothing is thrown and nothing is logged by default, so without a trace of the
9
+ // decision the only way to find the cause is to guess.
10
+ type DebugDetails = Record<string, unknown> | (() => Record<string, unknown> | null);
11
+ type DebugLogger = (stage: string, data?: DebugDetails) => void;
12
+
13
+ export function makeStudioDebugLogger(name: string): DebugLogger {
14
+ let enabled: boolean | null = null;
15
+ return (stage, data = {}) => {
16
+ if (enabled === null) {
17
+ try {
18
+ enabled = localStorage.getItem(`hf-${name}-debug`) === "1";
19
+ } catch {
20
+ enabled = false;
21
+ }
22
+ }
23
+ if (!enabled) return;
24
+ const details = typeof data === "function" ? data() : data;
25
+ if (!details) return;
26
+ console.log(
27
+ `[hf-${name}] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...details })}`,
28
+ );
29
+ };
30
+ }
@@ -48,6 +48,21 @@ export async function studioExpectedFileVersion(
48
48
  return versions.get(path);
49
49
  }
50
50
 
51
- export function createStudioWriteToken(): string {
51
+ function createStudioWriteToken(): string {
52
52
  return globalThis.crypto.randomUUID();
53
53
  }
54
+
55
+ /**
56
+ * Headers that claim the write a mutation request is about to make as our own.
57
+ *
58
+ * The token is marked BEFORE the request goes out on purpose: the server writes
59
+ * the file and the watcher broadcasts it while the request is still in flight, so
60
+ * a token marked from the response can arrive after the echo it was meant to
61
+ * match. An unmatched echo reads as an external change and costs a full preview
62
+ * reload, which the user sees as a flash right after their own edit.
63
+ */
64
+ export function studioWriteHeaders(): Record<string, string> {
65
+ const token = createStudioWriteToken();
66
+ markStudioWriteToken(token);
67
+ return { "X-Hyperframes-Write-Token": token };
68
+ }
@@ -77,8 +77,11 @@ function renderStudioUrlStateHarness(
77
77
  rightCollapsed: true,
78
78
  activeCompPathHydrated: true,
79
79
  domEditSelection: null,
80
+ domEditGroupSelections: [],
81
+ applyMarqueeSelection: () => {},
80
82
  buildDomSelectionFromTarget: () => Promise.resolve(null),
81
83
  applyDomSelection: () => {},
84
+ setRightPanelTab: () => {},
82
85
  initialState: {
83
86
  activeCompPath: null,
84
87
  currentTime: 4.2,
@@ -116,6 +119,12 @@ function StudioUrlStateHarness(props: Parameters<typeof useStudioUrlState>[0]) {
116
119
  return null;
117
120
  }
118
121
 
122
+ function previewIframeFor(contentDocument: Document): HTMLIFrameElement {
123
+ const iframe = document.createElement("iframe");
124
+ Object.defineProperty(iframe, "contentDocument", { value: contentDocument });
125
+ return iframe;
126
+ }
127
+
119
128
  describe("studio url state", () => {
120
129
  it("parses persisted studio state from project hash", () => {
121
130
  const state = parseStudioUrlStateFromHash(
@@ -132,7 +141,135 @@ describe("studio url state", () => {
132
141
  id: "hero",
133
142
  selector: undefined,
134
143
  selectorIndex: undefined,
144
+ group: undefined,
145
+ });
146
+ });
147
+
148
+ /**
149
+ * A link to a bug hit while several elements were selected has to carry the
150
+ * whole selection. Without the group the URL reopens one element, the report
151
+ * cannot be reproduced from it, and it reads as "works for me".
152
+ */
153
+ it("round-trips a multi-selection through the hash", () => {
154
+ const hash = buildStudioHash("demo", {
155
+ activeCompPath: null,
156
+ currentTime: null,
157
+ rightPanelTab: null,
158
+ rightCollapsed: null,
159
+ timelineVisible: null,
160
+ selection: {
161
+ sourceFile: "index.html",
162
+ id: "chip",
163
+ group: [
164
+ { sourceFile: "index.html", id: "card" },
165
+ { sourceFile: "index.html", selector: ".dot", selectorIndex: 1 },
166
+ ],
167
+ },
168
+ });
169
+
170
+ expect(parseStudioUrlStateFromHash(hash).selection?.group).toEqual([
171
+ { sourceFile: "index.html", id: "card" },
172
+ { sourceFile: "index.html", selector: ".dot", selectorIndex: 1 },
173
+ ]);
174
+ });
175
+
176
+ it("reads a single selection as having no group", () => {
177
+ const hash = parseStudioUrlStateFromHash("#project/demo?v=1&selFile=index.html&selId=hero");
178
+ expect(hash.selection?.group).toBeUndefined();
179
+ });
180
+
181
+ it("restores selector-based multi-selection members from the hash", async () => {
182
+ const previewDoc = document.implementation.createHTMLDocument("preview");
183
+ const primaryElement = previewDoc.createElement("div");
184
+ primaryElement.id = "hero";
185
+ const memberElement = previewDoc.createElement("div");
186
+ memberElement.className = "dot";
187
+ previewDoc.body.append(primaryElement, memberElement);
188
+ const primary = { element: primaryElement, id: "hero", sourceFile: "index.html" };
189
+ const member = {
190
+ element: memberElement,
191
+ selector: ".dot",
192
+ selectorIndex: 0,
193
+ sourceFile: "index.html",
194
+ };
195
+ const applyMarqueeSelection = vi.fn();
196
+
197
+ const harness = renderStudioUrlStateHarness({
198
+ previewIframeRef: {
199
+ current: previewIframeFor(previewDoc),
200
+ },
201
+ applyMarqueeSelection,
202
+ buildDomSelectionFromTarget: (target) =>
203
+ Promise.resolve(target === primaryElement ? primary : member),
204
+ initialState: {
205
+ activeCompPath: null,
206
+ currentTime: null,
207
+ rightPanelTab: null,
208
+ rightCollapsed: null,
209
+ timelineVisible: null,
210
+ selection: {
211
+ sourceFile: "index.html",
212
+ id: "hero",
213
+ group: [{ sourceFile: "index.html", selector: ".dot", selectorIndex: 0 }],
214
+ },
215
+ },
216
+ });
217
+
218
+ await act(async () => {
219
+ await Promise.resolve();
220
+ });
221
+ expect(applyMarqueeSelection).toHaveBeenCalledWith([primary, member], false);
222
+ harness.unmount();
223
+ });
224
+
225
+ it("does not let an older async URL selection overwrite a newer hash", async () => {
226
+ const previewDoc = document.implementation.createHTMLDocument("preview");
227
+ const firstElement = previewDoc.createElement("div");
228
+ firstElement.id = "first";
229
+ const secondElement = previewDoc.createElement("div");
230
+ secondElement.id = "second";
231
+ previewDoc.body.append(firstElement, secondElement);
232
+ const first = { element: firstElement, id: "first", sourceFile: "index.html" };
233
+ const second = { element: secondElement, id: "second", sourceFile: "index.html" };
234
+ let resolveFirst = (_selection: typeof first) => undefined;
235
+ const firstResolution = new Promise<typeof first>((resolve) => {
236
+ resolveFirst = resolve;
237
+ });
238
+ const applyDomSelection = vi.fn();
239
+ const harness = renderStudioUrlStateHarness({
240
+ previewIframeRef: { current: previewIframeFor(previewDoc) },
241
+ applyDomSelection,
242
+ buildDomSelectionFromTarget: (target) =>
243
+ target === firstElement ? firstResolution : Promise.resolve(second),
244
+ initialState: {
245
+ activeCompPath: null,
246
+ currentTime: null,
247
+ rightPanelTab: null,
248
+ rightCollapsed: null,
249
+ timelineVisible: null,
250
+ selection: null,
251
+ },
252
+ });
253
+
254
+ act(() => {
255
+ window.history.replaceState(null, "", "#project/demo?v=1&selId=first");
256
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
257
+ window.history.replaceState(null, "", "#project/demo?v=1&selId=second");
258
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
259
+ });
260
+ await act(async () => {
261
+ await Promise.resolve();
135
262
  });
263
+ expect(applyDomSelection).toHaveBeenCalled();
264
+ expect(applyDomSelection.mock.calls.every(([selection]) => selection === second)).toBe(true);
265
+ const appliedBeforeOlderResolution = applyDomSelection.mock.calls.length;
266
+
267
+ await act(async () => {
268
+ resolveFirst(first);
269
+ await firstResolution;
270
+ });
271
+ expect(applyDomSelection).toHaveBeenCalledTimes(appliedBeforeOlderResolution);
272
+ harness.unmount();
136
273
  });
137
274
 
138
275
  it("builds a project hash with persisted studio state", () => {
@@ -228,7 +365,7 @@ describe("studio url state", () => {
228
365
 
229
366
  const harness = renderStudioUrlStateHarness({
230
367
  previewIframeRef: {
231
- current: { contentDocument: previewDoc } as HTMLIFrameElement,
368
+ current: previewIframeFor(previewDoc),
232
369
  },
233
370
  rightPanelTab: "design",
234
371
  rightCollapsed: false,
@@ -279,6 +416,31 @@ describe("studio url state", () => {
279
416
  expect(window.location.hash).toContain("t=4.2");
280
417
  expect(window.location.hash).toContain("selId=hero");
281
418
 
419
+ const selectorMember = {
420
+ ...restoredSelection,
421
+ element: document.createElement("div"),
422
+ id: "",
423
+ selector: ".dot",
424
+ selectorIndex: 1,
425
+ label: "Dot",
426
+ };
427
+ harness.rerender({
428
+ currentTime: 4.2,
429
+ domEditSelection: restoredSelection,
430
+ domEditGroupSelections: [restoredSelection, selectorMember],
431
+ });
432
+ act(() => {
433
+ vi.advanceTimersByTime(250);
434
+ });
435
+ expect(parseStudioUrlStateFromHash(window.location.hash).selection?.group).toEqual([
436
+ {
437
+ sourceFile: "index.html",
438
+ id: undefined,
439
+ selector: ".dot",
440
+ selectorIndex: 1,
441
+ },
442
+ ]);
443
+
282
444
  harness.unmount();
283
445
  });
284
446
  });
@@ -2,13 +2,23 @@ import type { RightPanelTab } from "./studioHelpers";
2
2
  import { buildProjectHash, parseProjectHashRoute } from "./projectRouting";
3
3
  import { roundTo3 } from "./rounding";
4
4
 
5
- export interface StudioUrlSelectionState {
5
+ export interface StudioUrlSelectionTarget {
6
6
  sourceFile?: string;
7
7
  id?: string;
8
8
  selector?: string;
9
9
  selectorIndex?: number;
10
10
  }
11
11
 
12
+ export interface StudioUrlSelectionState extends StudioUrlSelectionTarget {
13
+ /**
14
+ * The other members of a multi-selection, primary excluded.
15
+ * A link to a bug in a group edit is only reproducible if it carries the group;
16
+ * without this, opening the URL lands on one element and the report reads as
17
+ * "works for me".
18
+ */
19
+ group?: StudioUrlSelectionTarget[];
20
+ }
21
+
12
22
  export interface StudioUrlState {
13
23
  activeCompPath: string | null;
14
24
  currentTime: number | null;
@@ -63,19 +73,63 @@ function parseTab(value: string | null): RightPanelTab | null {
63
73
  return VALID_TABS.includes(value as RightPanelTab) ? (value as RightPanelTab) : null;
64
74
  }
65
75
 
76
+ function optionalString(value: unknown): string | undefined {
77
+ return typeof value === "string" ? value : undefined;
78
+ }
79
+
80
+ function normalizedIndex(value: unknown): number | undefined {
81
+ return typeof value === "number" && Number.isFinite(value)
82
+ ? Math.max(0, Math.floor(value))
83
+ : undefined;
84
+ }
85
+
86
+ function parseSelectionTarget(value: unknown): StudioUrlSelectionTarget | null {
87
+ if (!value || typeof value !== "object") return null;
88
+ const sourceFile = optionalString(Reflect.get(value, "sourceFile"));
89
+ const id = optionalString(Reflect.get(value, "id"));
90
+ const selector = optionalString(Reflect.get(value, "selector"));
91
+ if (!id && !selector) return null;
92
+ return {
93
+ sourceFile,
94
+ id,
95
+ selector,
96
+ selectorIndex: normalizedIndex(Reflect.get(value, "selectorIndex")),
97
+ };
98
+ }
99
+
100
+ /** The other members of a multi-selection, dropping invalid hand-edited entries. */
101
+ function parseGroup(value: string | null): StudioUrlSelectionTarget[] | undefined {
102
+ if (!value) return undefined;
103
+ let parsed: unknown;
104
+ try {
105
+ parsed = JSON.parse(value);
106
+ } catch {
107
+ // Compatibility with links produced by the first id-only implementation.
108
+ const legacy = value
109
+ .split(",")
110
+ .map((id) => id.trim())
111
+ .filter(Boolean)
112
+ .map((id) => ({ id }));
113
+ return legacy.length > 0 ? legacy : undefined;
114
+ }
115
+ if (!Array.isArray(parsed)) return undefined;
116
+ const targets = parsed.map(parseSelectionTarget).filter((target) => target !== null);
117
+ return targets.length > 0 ? targets : undefined;
118
+ }
119
+
66
120
  function normalizeSelection(params: URLSearchParams): StudioUrlSelectionState | null {
67
121
  const sourceFile = params.get("selFile") || undefined;
68
122
  const id = params.get("selId") || undefined;
69
123
  const selector = params.get("selSelector") || undefined;
70
- const selectorIndex = parseNumber(params.get("selIndex"));
71
-
72
124
  if (!sourceFile && !id && !selector) return null;
73
125
 
126
+ const selectorIndex = parseNumber(params.get("selIndex"));
74
127
  return {
75
128
  sourceFile,
76
129
  id,
77
130
  selector,
78
131
  selectorIndex: selectorIndex != null ? Math.max(0, Math.floor(selectorIndex)) : undefined,
132
+ group: parseGroup(params.get("selGroup")),
79
133
  };
80
134
  }
81
135
 
@@ -130,6 +184,9 @@ export function buildStudioHash(projectId: string, state: StudioUrlState): strin
130
184
  if (typeof state.selection.selectorIndex === "number") {
131
185
  params.set("selIndex", String(Math.max(0, Math.floor(state.selection.selectorIndex))));
132
186
  }
187
+ if (state.selection.group?.length) {
188
+ params.set("selGroup", JSON.stringify(state.selection.group));
189
+ }
133
190
  }
134
191
 
135
192
  return buildProjectHash(projectId, params);
@@ -2,6 +2,7 @@ import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
2
2
  import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
3
3
  import type { RecordEditInput } from "./studioFileHistory";
4
4
  import { buildProjectApiPath } from "./projectRouting";
5
+ import { studioWriteHeaders } from "./studioFileVersion";
5
6
 
6
7
  interface TimelineCompositionInsertionResult {
7
8
  path: string;
@@ -34,7 +35,7 @@ async function insertTimelineComposition(input: {
34
35
  ),
35
36
  {
36
37
  method: "POST",
37
- headers: { "Content-Type": "application/json" },
38
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
38
39
  body: JSON.stringify({
39
40
  sourcePath: input.sourcePath,
40
41
  start: input.start,