@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.
- package/dist/assets/{hyperframes-player-DUB-xtR5.js → hyperframes-player-Jx07OQgw.js} +1 -1
- package/dist/assets/{index-DHc-xHLb.js → index-CryZ0PGi.js} +1 -1
- package/dist/assets/{index-Cez_yvk5.js → index-Dqi2GmOp.js} +1 -1
- package/dist/assets/{index-CxC2vn2-.js → index-DuxKGsHZ.js} +214 -214
- package/dist/index.html +1 -1
- package/dist/index.js +779 -294
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -2
- package/src/captions/hooks/useCaptionSync.ts +2 -1
- package/src/components/editor/DomEditOverlay.test.ts +89 -2
- package/src/components/editor/DomEditOverlay.tsx +40 -23
- package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
- package/src/components/editor/domEditOverlayGeometry.ts +59 -2
- package/src/components/editor/domEditOverlayGestures.ts +42 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
- package/src/components/editor/groupDragMove.ts +110 -0
- package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
- package/src/components/editor/manualEditsDom.ts +15 -13
- package/src/components/editor/manualOffsetDrag.test.ts +92 -5
- package/src/components/editor/manualOffsetDrag.ts +22 -17
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
- package/src/components/editor/snapEngageTravel.test.ts +65 -0
- package/src/components/editor/snapEngine.ts +21 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
- package/src/components/editor/useDomEditOverlayRects.ts +4 -2
- package/src/hooks/domSelectionTimelineMirror.ts +73 -0
- package/src/hooks/gestureTransaction.test.ts +7 -12
- package/src/hooks/gestureTransaction.ts +7 -20
- package/src/hooks/gsapRuntimePatch.test.ts +43 -0
- package/src/hooks/gsapRuntimePatch.ts +11 -1
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
- package/src/hooks/keyframeCacheAstLoad.ts +29 -1
- package/src/hooks/timelineTimingSync.ts +4 -1
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
- package/src/hooks/useDomEditPreviewSync.ts +23 -0
- package/src/hooks/useDomEditSession.test.tsx +43 -62
- package/src/hooks/useDomEditSession.ts +10 -2
- package/src/hooks/useDomEditWiring.ts +6 -0
- package/src/hooks/useDomSelection.test.ts +133 -3
- package/src/hooks/useDomSelection.ts +67 -40
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
- package/src/hooks/useFileManager.ts +2 -8
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
- package/src/hooks/useGsapAwareEditing.ts +88 -15
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
- package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
- package/src/hooks/useGsapScriptCommits.ts +52 -15
- package/src/hooks/useStudioUrlState.ts +130 -7
- package/src/hooks/useTimelineEditing.ts +2 -1
- package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +2 -0
- package/src/utils/dragDebug.ts +98 -0
- package/src/utils/razorSplitTransaction.test.ts +15 -3
- package/src/utils/razorSplitTransaction.ts +2 -0
- package/src/utils/reloadDebug.ts +10 -0
- package/src/utils/resizeDebug.ts +19 -29
- package/src/utils/selectDebug.ts +9 -0
- package/src/utils/studioDebug.test.ts +32 -0
- package/src/utils/studioDebug.ts +30 -0
- package/src/utils/studioFileVersion.ts +16 -1
- package/src/utils/studioUrlState.test.ts +163 -1
- package/src/utils/studioUrlState.ts +60 -3
- 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(() =>
|
|
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({
|
|
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");
|
package/src/utils/resizeDebug.ts
CHANGED
|
@@ -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
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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,
|