@hyperframes/studio 0.8.75 → 0.8.76
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-BkDxgSKG.js +464 -0
- package/dist/assets/{index-BiFKcNjs.js → index-COX0scwJ.js} +1 -1
- package/dist/assets/{index-BlUYw1MB.js → index-DeHir1up.js} +156 -156
- package/dist/assets/{index-HjexPsny.js → index-FmKUmYhC.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
- package/dist/chunk-IYDE66BY.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
- package/dist/index.d.ts +34 -69
- package/dist/index.html +1 -1
- package/dist/index.js +4147 -4253
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +94 -17
- package/src/components/nle/NLEPreview.tsx +14 -0
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +0 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.ts +0 -1
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/timelineElementHelpers.ts +45 -9
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
+
import { ensureHfIds } from "@hyperframes/parsers";
|
|
2
3
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
pasteElementHtml,
|
|
6
|
+
pasteTimelineClips,
|
|
7
|
+
resolveFreeTrack,
|
|
8
|
+
type PlacedClip,
|
|
9
|
+
} from "./useClipboard";
|
|
4
10
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
5
11
|
import type { TimelineClipboardClip } from "../utils/clipboardPayload";
|
|
6
12
|
import type { TimelineElement } from "../player";
|
|
@@ -8,6 +14,9 @@ import type { TimelineElement } from "../player";
|
|
|
8
14
|
const ROOT =
|
|
9
15
|
'<div id="claude-paper" data-composition-id="claude-paper" data-start="0" data-duration="10"></div>';
|
|
10
16
|
|
|
17
|
+
const hfIdsIn = (html: string) =>
|
|
18
|
+
Array.from(html.matchAll(/data-hf-id=["']([^"']+)/g), (m) => m[1]);
|
|
19
|
+
|
|
11
20
|
function clip(id: string, start: number, duration: number, track: number): TimelineClipboardClip {
|
|
12
21
|
return {
|
|
13
22
|
html: `<audio id="${id}" src="typenew.mp3" data-start="${start}" data-duration="${duration}" data-track-index="${track}"></audio>`,
|
|
@@ -77,7 +86,7 @@ describe("pasteTimelineClips", () => {
|
|
|
77
86
|
expect(content).toMatch(/data-track-index="133"/);
|
|
78
87
|
});
|
|
79
88
|
|
|
80
|
-
it("
|
|
89
|
+
it("gives a pasted clip a fresh data-hf-id, never the copied one", () => {
|
|
81
90
|
const clipWithHfId: TimelineClipboardClip = {
|
|
82
91
|
html: '<audio data-hf-id="hf-75o7" id="sfx-type-0" src="typenew.mp3" data-start="7.8" data-duration="0.57" data-track-index="109"></audio>',
|
|
83
92
|
start: 7.8,
|
|
@@ -85,10 +94,44 @@ describe("pasteTimelineClips", () => {
|
|
|
85
94
|
track: 109,
|
|
86
95
|
};
|
|
87
96
|
const { content } = pasteTimelineClips(ROOT, [clipWithHfId], 40, []);
|
|
88
|
-
expect(content).not.toContain("
|
|
97
|
+
expect(content).not.toContain("hf-75o7");
|
|
98
|
+
expect(hfIdsIn(content)).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("leaves nothing for the server to stamp, so the file undo recorded is what stays on disk", () => {
|
|
102
|
+
const stamped = ensureHfIds(
|
|
103
|
+
ROOT.replace("</div>", `${clip("sfx-1", 1, 2, 0).html}${clip("sfx-2", 3, 2, 0).html}</div>`),
|
|
104
|
+
);
|
|
105
|
+
const copies = hfIdsIn(stamped).length;
|
|
106
|
+
const original = (id: string) => ({
|
|
107
|
+
...clip(id, 1, 2, 0),
|
|
108
|
+
html: stamped.match(new RegExp(`<audio[^>]*id="${id}"[^>]*></audio>`))![0],
|
|
109
|
+
});
|
|
110
|
+
const { content } = pasteTimelineClips(stamped, [original("sfx-1"), original("sfx-2")], 11, []);
|
|
111
|
+
const ids = hfIdsIn(content);
|
|
112
|
+
expect(ids).toHaveLength(copies + 2);
|
|
113
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
114
|
+
expect(hfIdsIn(ensureHfIds(content))).toHaveLength(ids.length);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("remints a nested clip's descendants clear of their originals already in the file", () => {
|
|
118
|
+
const nested =
|
|
119
|
+
'<div id="comp-1" data-start="1" data-duration="2" data-track-index="0"><span>a</span><span>b</span></div>';
|
|
120
|
+
const stamped = ensureHfIds(ROOT.replace("</div>", `${nested}</div>`));
|
|
121
|
+
const copied = stamped.match(/<div[^>]*id="comp-1"[^>]*>.*?<\/div>/)![0];
|
|
122
|
+
const { content } = pasteTimelineClips(
|
|
123
|
+
stamped,
|
|
124
|
+
[{ html: copied, start: 1, duration: 2, track: 0 }],
|
|
125
|
+
11,
|
|
126
|
+
[],
|
|
127
|
+
);
|
|
128
|
+
const ids = hfIdsIn(content);
|
|
129
|
+
expect(ids).toHaveLength(hfIdsIn(stamped).length + 3);
|
|
130
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
131
|
+
expect(hfIdsIn(ensureHfIds(content))).toHaveLength(ids.length);
|
|
89
132
|
});
|
|
90
133
|
|
|
91
|
-
it("
|
|
134
|
+
it("remints a nested descendant's data-hf-id too, not just the root's", () => {
|
|
92
135
|
// A composition-instance clip's descendants carry their own hf-ids; a
|
|
93
136
|
// clone must remint all of them, not just the root, or the descendants
|
|
94
137
|
// collide with their originals the same way the root would.
|
|
@@ -101,7 +144,7 @@ describe("pasteTimelineClips", () => {
|
|
|
101
144
|
const { content } = pasteTimelineClips(ROOT, [nested], 40, []);
|
|
102
145
|
expect(content).not.toContain("hf-root");
|
|
103
146
|
expect(content).not.toContain("hf-child");
|
|
104
|
-
expect(content).
|
|
147
|
+
expect(hfIdsIn(content)).toHaveLength(2);
|
|
105
148
|
});
|
|
106
149
|
|
|
107
150
|
it("does not strip text content that happens to contain the data-hf-id string", () => {
|
|
@@ -145,7 +188,7 @@ describe("pasteTimelineClips", () => {
|
|
|
145
188
|
expect(content).toContain(`id="${ids[0]}"`);
|
|
146
189
|
});
|
|
147
190
|
|
|
148
|
-
it("
|
|
191
|
+
it("remints a single-quoted data-hf-id, not just a double-quoted one", () => {
|
|
149
192
|
const singleQuoted: TimelineClipboardClip = {
|
|
150
193
|
html: '<audio data-hf-id=\'hf-75o7\' id="sfx-type-0" src="typenew.mp3" data-start="7.8" data-duration="0.57" data-track-index="109"></audio>',
|
|
151
194
|
start: 7.8,
|
|
@@ -153,10 +196,11 @@ describe("pasteTimelineClips", () => {
|
|
|
153
196
|
track: 109,
|
|
154
197
|
};
|
|
155
198
|
const { content } = pasteTimelineClips(ROOT, [singleQuoted], 40, []);
|
|
156
|
-
expect(content).not.toContain("
|
|
199
|
+
expect(content).not.toContain("hf-75o7");
|
|
200
|
+
expect(hfIdsIn(content)).toHaveLength(1);
|
|
157
201
|
});
|
|
158
202
|
|
|
159
|
-
it("
|
|
203
|
+
it("remints data-hf-id even when an earlier attribute value contains a literal >", () => {
|
|
160
204
|
// A `>` inside title's value would close a bracket-scoped tag regex
|
|
161
205
|
// early, leaving data-hf-id (which comes after it) outside the matched
|
|
162
206
|
// span and unstripped. A real parser doesn't have this failure mode.
|
|
@@ -167,7 +211,8 @@ describe("pasteTimelineClips", () => {
|
|
|
167
211
|
track: 109,
|
|
168
212
|
};
|
|
169
213
|
const { content } = pasteTimelineClips(ROOT, [gtInAttr], 40, []);
|
|
170
|
-
expect(content).not.toContain("
|
|
214
|
+
expect(content).not.toContain("hf-xyz");
|
|
215
|
+
expect(hfIdsIn(content)).toHaveLength(1);
|
|
171
216
|
});
|
|
172
217
|
|
|
173
218
|
it("still strips data-hf-id from bare text with no element root", () => {
|
|
@@ -182,7 +227,7 @@ describe("pasteTimelineClips", () => {
|
|
|
182
227
|
});
|
|
183
228
|
|
|
184
229
|
it("still strips data-hf-id from a tag the HTML parser hoists out of body (e.g. title)", () => {
|
|
185
|
-
// A <title> never reaches DOMParser's body, so
|
|
230
|
+
// A <title> never reaches DOMParser's body, so remintHfIds's DOM walk sees
|
|
186
231
|
// no root element here -- this exercises the regex fallback, not the walk.
|
|
187
232
|
const hoisted: TimelineClipboardClip = {
|
|
188
233
|
html: '<title data-hf-id="hf-leak">x</title>',
|
|
@@ -232,3 +277,17 @@ describe("pasteTimelineClips + extendRootDurationInSource", () => {
|
|
|
232
277
|
expect(extended).toContain('data-duration="10"');
|
|
233
278
|
});
|
|
234
279
|
});
|
|
280
|
+
|
|
281
|
+
describe("pasteElementHtml", () => {
|
|
282
|
+
it("gives a pasted element and its children ids of their own, so an edit to the copy stays on it", () => {
|
|
283
|
+
const stamped = ensureHfIds(
|
|
284
|
+
ROOT.replace("</div>", '<p id="title" class="t">Hello <b>there</b></p></div>'),
|
|
285
|
+
);
|
|
286
|
+
const copied = stamped.match(/<p[^>]*id="title"[^>]*>.*?<\/p>/)![0];
|
|
287
|
+
const content = pasteElementHtml(stamped, { html: copied, originSelector: "#title" });
|
|
288
|
+
const ids = hfIdsIn(content);
|
|
289
|
+
expect(ids).toHaveLength(hfIdsIn(stamped).length + 2);
|
|
290
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
291
|
+
expect(hfIdsIn(ensureHfIds(content))).toHaveLength(ids.length);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
+
import { EXCLUDED_TAGS, mintHfId, walkCompositionDescendants } from "@hyperframes/parsers/hf-ids";
|
|
2
3
|
import type { TimelineElement } from "../player";
|
|
3
4
|
import { usePlayerStore } from "../player";
|
|
4
5
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
@@ -13,7 +14,6 @@ import { collectHtmlIds } from "../utils/studioHelpers";
|
|
|
13
14
|
import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
|
|
14
15
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
15
16
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
16
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
17
17
|
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
18
18
|
import { findElementForSelection } from "../components/editor/domEditingElement";
|
|
19
19
|
import { findTimelineElementInIframe, readFileContent } from "./timelineEditingHelpers";
|
|
@@ -22,7 +22,6 @@ import { timeRangesOverlap } from "../player/components/timelineCollision";
|
|
|
22
22
|
|
|
23
23
|
interface RecordEditInput {
|
|
24
24
|
label: string;
|
|
25
|
-
kind: EditHistoryKind;
|
|
26
25
|
coalesceKey?: string;
|
|
27
26
|
files: Record<string, { before: string; after: string }>;
|
|
28
27
|
}
|
|
@@ -100,10 +99,10 @@ export function resolveFreeTrack(preferred: PlacedClip, taken: readonly PlacedCl
|
|
|
100
99
|
return maxTrack + 1;
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
function
|
|
102
|
+
/** Gives a clone's root and every descendant a fresh data-hf-id, clear of `taken`. Minted here,
|
|
103
|
+
* inside the edit, or the server stamps them on the next preview load, behind undo's back.
|
|
104
|
+
* DOMParser, not a regex, so a `>` inside an attribute value can't defeat it. */
|
|
105
|
+
function remintHfIds(html: string, parser: DOMParser, taken: Set<string>): string {
|
|
107
106
|
const root = parser.parseFromString(html, "text/html").body.firstElementChild;
|
|
108
107
|
if (!root) {
|
|
109
108
|
// A tag the HTML parser hoists out of <body> (title/meta/style/base/link)
|
|
@@ -112,11 +111,28 @@ function stripHfIds(html: string, parser: DOMParser): string {
|
|
|
112
111
|
// of these tags today.
|
|
113
112
|
return html.replace(/\sdata-hf-id=("[^"]*"|'[^']*')/g, "");
|
|
114
113
|
}
|
|
115
|
-
root
|
|
116
|
-
root.
|
|
114
|
+
const elements = [root];
|
|
115
|
+
walkCompositionDescendants(root, (el) => elements.push(el));
|
|
116
|
+
for (const el of elements) {
|
|
117
|
+
if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) el.removeAttribute("data-hf-id");
|
|
118
|
+
else el.setAttribute("data-hf-id", mintHfId(el, taken));
|
|
119
|
+
}
|
|
117
120
|
return root.outerHTML;
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
const HF_ID_ATTR_RE = /\bdata-hf-id\s*=\s*["']?([^"'\s>]+)/gi;
|
|
124
|
+
const hfIdsInFile = (content: string) =>
|
|
125
|
+
new Set(Array.from(content.matchAll(HF_ID_ATTR_RE), (match) => match[1]));
|
|
126
|
+
|
|
127
|
+
export function pasteElementHtml(
|
|
128
|
+
content: string,
|
|
129
|
+
payload: { html: string; originSelector?: string; originSelectorIndex?: number },
|
|
130
|
+
): string {
|
|
131
|
+
const reminted = remintHfIds(payload.html, new DOMParser(), hfIdsInFile(content));
|
|
132
|
+
const deduped = deduplicateIds(reminted, collectHtmlIds(content));
|
|
133
|
+
return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
|
|
134
|
+
}
|
|
135
|
+
|
|
120
136
|
/** Shared insertion path for paste and duplicate, anchored at the playhead or
|
|
121
137
|
* the selection's end respectively. Returns the final ids so the caller can
|
|
122
138
|
* select what it just placed, and the furthest end any clip lands at so the
|
|
@@ -138,9 +154,10 @@ export function pasteTimelineClips(
|
|
|
138
154
|
let result = content;
|
|
139
155
|
let requiredEnd = 0;
|
|
140
156
|
const domParser = new DOMParser();
|
|
157
|
+
const takenHfIds = hfIdsInFile(content);
|
|
141
158
|
for (const clip of clips) {
|
|
142
|
-
const
|
|
143
|
-
const deduped = deduplicateIds(
|
|
159
|
+
const reminted = remintHfIds(clip.html, domParser, takenHfIds);
|
|
160
|
+
const deduped = deduplicateIds(reminted, existingIds);
|
|
144
161
|
existingIds = existingIds.concat(collectHtmlIds(deduped));
|
|
145
162
|
const newStart = anchorTime + (clip.start - groupMinStart);
|
|
146
163
|
const newTrack = resolveFreeTrack(
|
|
@@ -280,13 +297,7 @@ export function useClipboard({
|
|
|
280
297
|
patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
281
298
|
pastedIds = pasted.ids;
|
|
282
299
|
} else {
|
|
283
|
-
|
|
284
|
-
patchedContent = insertAsSibling(
|
|
285
|
-
originalContent,
|
|
286
|
-
deduped,
|
|
287
|
-
payload.originSelector,
|
|
288
|
-
payload.originSelectorIndex,
|
|
289
|
-
);
|
|
300
|
+
patchedContent = pasteElementHtml(originalContent, payload);
|
|
290
301
|
}
|
|
291
302
|
|
|
292
303
|
const label =
|
|
@@ -297,7 +308,6 @@ export function useClipboard({
|
|
|
297
308
|
await saveProjectFilesWithHistory({
|
|
298
309
|
projectId: pid,
|
|
299
310
|
label,
|
|
300
|
-
kind: "timeline" as EditHistoryKind,
|
|
301
311
|
files: { [targetPath]: patchedContent },
|
|
302
312
|
readFile: async () => originalContent,
|
|
303
313
|
writeFile: writeProjectFile,
|
|
@@ -348,7 +358,6 @@ export function useClipboard({
|
|
|
348
358
|
await saveProjectFilesWithHistory({
|
|
349
359
|
projectId: pid,
|
|
350
360
|
label: clipLabel("Duplicate", clips.length),
|
|
351
|
-
kind: "timeline" as EditHistoryKind,
|
|
352
361
|
files: { [targetPath]: patchedContent },
|
|
353
362
|
readFile: async () => originalContent,
|
|
354
363
|
writeFile: writeProjectFile,
|
|
@@ -340,7 +340,6 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
340
340
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
341
341
|
expect(rendered.recordEdit).toHaveBeenCalledWith({
|
|
342
342
|
label: "Reorder layers",
|
|
343
|
-
kind: "manual",
|
|
344
343
|
coalesceKey: "z-reorder:test",
|
|
345
344
|
// Unbounded per-gesture fold window (keys are unique per gesture):
|
|
346
345
|
// the z entry and its mirror/lane counterpart fold across the server
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
} from "../utils/studioSaveDiagnostics";
|
|
13
13
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
14
14
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
15
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
16
15
|
import type { CommitDomEditPatchBatches, PersistDomEditOperations } from "./domEditCommitTypes";
|
|
17
16
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
18
17
|
import {
|
|
@@ -38,7 +37,6 @@ import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
|
38
37
|
import { reseekPreviewRuntime } from "./timelineTrackVisibility";
|
|
39
38
|
interface RecordEditInput {
|
|
40
39
|
label: string;
|
|
41
|
-
kind: EditHistoryKind;
|
|
42
40
|
coalesceKey?: string;
|
|
43
41
|
coalesceMs?: number;
|
|
44
42
|
files: Record<string, { before: string; after: string }>;
|
|
@@ -286,7 +284,6 @@ export function useDomEditCommits({
|
|
|
286
284
|
|
|
287
285
|
await editHistory.recordEdit({
|
|
288
286
|
label: options?.label ?? "Edit layer",
|
|
289
|
-
kind: "manual",
|
|
290
287
|
coalesceKey: options?.coalesceKey,
|
|
291
288
|
coalesceMs: options?.coalesceMs,
|
|
292
289
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
@@ -365,7 +362,6 @@ export function useDomEditCommits({
|
|
|
365
362
|
if (changed) {
|
|
366
363
|
await editHistory.recordEdit({
|
|
367
364
|
label: options.label,
|
|
368
|
-
kind: "manual",
|
|
369
365
|
coalesceKey: options.coalesceKey,
|
|
370
366
|
coalesceMs: options.coalesceMs,
|
|
371
367
|
files,
|
|
@@ -11,12 +11,15 @@ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryA
|
|
|
11
11
|
let root: Root | null = null;
|
|
12
12
|
afterEach(() => act(() => root?.unmount()));
|
|
13
13
|
|
|
14
|
-
function mount(result: {
|
|
14
|
+
function mount(result: {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
reason?: string;
|
|
17
|
+
message?: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
paths?: string[];
|
|
20
|
+
}) {
|
|
15
21
|
const editHistory = {
|
|
16
|
-
undo: vi.fn<EditHistoryHandle["undo"]>(async (
|
|
17
|
-
if (result.ok) await cb.writeFile("index.html", "before");
|
|
18
|
-
return result;
|
|
19
|
-
}),
|
|
22
|
+
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
|
|
20
23
|
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
21
24
|
};
|
|
22
25
|
const deps = {
|
|
@@ -42,34 +45,48 @@ function mount(result: { ok: boolean; reason?: string; label?: string; paths?: s
|
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
describe("useEditHistoryActions", () => {
|
|
45
|
-
it("undo
|
|
46
|
-
const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
|
|
48
|
+
it("undo resyncs the preview and toasts the step as the history names it", async () => {
|
|
49
|
+
const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] });
|
|
47
50
|
await act(() => actions.undo());
|
|
48
51
|
expect(deps.waitForPendingDomEditSaves).toHaveBeenCalled();
|
|
49
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith("index.html", "before");
|
|
50
52
|
expect(deps.onAfterUndoRedo).toHaveBeenCalled();
|
|
51
53
|
expect(deps.forceReloadSdkSession).toHaveBeenCalled();
|
|
52
54
|
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalled();
|
|
53
|
-
expect(deps.showToast).toHaveBeenCalledWith("Undid Move clip", "info");
|
|
55
|
+
expect(deps.showToast).toHaveBeenCalledWith("Undid: Move clip", "info");
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
it("redo reports the redone label and skips the SDK reload for other files", async () => {
|
|
57
|
-
const { deps, actions } = mount({
|
|
59
|
+
const { deps, actions } = mount({
|
|
60
|
+
ok: true,
|
|
61
|
+
label: "Redid: Split clip",
|
|
62
|
+
paths: ["other.html"],
|
|
63
|
+
});
|
|
58
64
|
await act(() => actions.redo());
|
|
59
65
|
expect(deps.forceReloadSdkSession).not.toHaveBeenCalled();
|
|
60
|
-
expect(deps.showToast).toHaveBeenCalledWith("Redid Split clip", "info");
|
|
66
|
+
expect(deps.showToast).toHaveBeenCalledWith("Redid: Split clip", "info");
|
|
61
67
|
});
|
|
62
68
|
|
|
63
|
-
it("
|
|
64
|
-
const { deps, actions } = mount({
|
|
69
|
+
it("names the files that changed since the edit when an undo is refused", async () => {
|
|
70
|
+
const { deps, actions } = mount({
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "content-mismatch",
|
|
73
|
+
paths: ["index.html"],
|
|
74
|
+
});
|
|
65
75
|
await act(() => actions.undo());
|
|
66
76
|
expect(deps.showToast).toHaveBeenCalledWith(
|
|
67
|
-
"
|
|
77
|
+
"Can't undo: index.html changed since that edit.",
|
|
68
78
|
"info",
|
|
69
79
|
);
|
|
70
80
|
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
71
81
|
});
|
|
72
82
|
|
|
83
|
+
it("says why when the history could not take the step", async () => {
|
|
84
|
+
const { deps, actions } = mount({ ok: false, reason: "failed", message: "disk full" });
|
|
85
|
+
await act(() => actions.undo());
|
|
86
|
+
expect(deps.showToast).toHaveBeenCalledWith("Undo failed: disk full", "error");
|
|
87
|
+
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
73
90
|
it("waits for pending saves first and reads the motion file through the optional reader", async () => {
|
|
74
91
|
const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
|
|
75
92
|
const order: string[] = [];
|
|
@@ -6,6 +6,7 @@ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordin
|
|
|
6
6
|
interface HistoryResult {
|
|
7
7
|
ok: boolean;
|
|
8
8
|
reason?: string;
|
|
9
|
+
message?: string;
|
|
9
10
|
label?: string;
|
|
10
11
|
paths?: string[];
|
|
11
12
|
/** Per-file restored/previous content, used to soft-apply the preview. */
|
|
@@ -13,7 +14,6 @@ interface HistoryResult {
|
|
|
13
14
|
}
|
|
14
15
|
interface HistoryFileCallbacks {
|
|
15
16
|
readFile: (path: string) => Promise<string>;
|
|
16
|
-
writeFile: (path: string, content: string) => Promise<void>;
|
|
17
17
|
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
18
18
|
}
|
|
19
19
|
export interface EditHistoryHandle {
|
|
@@ -40,7 +40,7 @@ export interface UseEditHistoryActionsOptions {
|
|
|
40
40
|
forceReloadSdkSession?: () => void;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/** Takes one step of the project's history: the single owner of undo/redo over project files. */
|
|
44
44
|
export function useEditHistoryActions({
|
|
45
45
|
editHistory,
|
|
46
46
|
readOptionalProjectFile,
|
|
@@ -66,15 +66,21 @@ export function useEditHistoryActions({
|
|
|
66
66
|
|
|
67
67
|
const apply = useCallback(
|
|
68
68
|
async (direction: "undo" | "redo") => {
|
|
69
|
-
const
|
|
69
|
+
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
70
70
|
await waitForPendingDomEditSaves();
|
|
71
71
|
const result = await editHistory[direction]({
|
|
72
72
|
readFile: readHistoryFile,
|
|
73
|
-
writeFile: writeProjectFile,
|
|
74
73
|
serialize: serializeHistoryFiles,
|
|
75
74
|
});
|
|
76
75
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
77
|
-
showToast(
|
|
76
|
+
showToast(
|
|
77
|
+
`Can't ${direction}: ${result.paths?.join(", ")} changed since that edit.`,
|
|
78
|
+
"info",
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!result.ok && result.reason === "failed") {
|
|
83
|
+
showToast(`${noun} failed: ${result.message}`, "error");
|
|
78
84
|
return;
|
|
79
85
|
}
|
|
80
86
|
if (result.ok && result.label) {
|
|
@@ -83,7 +89,7 @@ export function useEditHistoryActions({
|
|
|
83
89
|
forceReloadSdkSession?.();
|
|
84
90
|
}
|
|
85
91
|
await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
|
|
86
|
-
showToast(
|
|
92
|
+
showToast(result.label, "info");
|
|
87
93
|
}
|
|
88
94
|
},
|
|
89
95
|
[
|
|
@@ -92,7 +98,6 @@ export function useEditHistoryActions({
|
|
|
92
98
|
showToast,
|
|
93
99
|
syncHistoryPreviewAfterApply,
|
|
94
100
|
waitForPendingDomEditSaves,
|
|
95
|
-
writeProjectFile,
|
|
96
101
|
serializeHistoryFiles,
|
|
97
102
|
onAfterUndoRedo,
|
|
98
103
|
activeCompPath,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
4
3
|
import {
|
|
5
4
|
StudioFileConflictError,
|
|
6
5
|
buildStudioSaveFailureProperties,
|
|
@@ -12,7 +11,6 @@ const FAILURE_BURST_MS = 5_000;
|
|
|
12
11
|
|
|
13
12
|
interface RecordEditInput {
|
|
14
13
|
label: string;
|
|
15
|
-
kind: EditHistoryKind;
|
|
16
14
|
coalesceKey?: string;
|
|
17
15
|
files: Record<string, { before: string; after: string }>;
|
|
18
16
|
}
|
|
@@ -108,7 +106,6 @@ export function useEditorSave({
|
|
|
108
106
|
const task = saveProjectFilesWithHistory({
|
|
109
107
|
projectId: candidate.projectId,
|
|
110
108
|
label: "Edit source",
|
|
111
|
-
kind: "source",
|
|
112
109
|
coalesceKey: `source:${candidate.path}`,
|
|
113
110
|
files: { [candidate.path]: candidate.content },
|
|
114
111
|
readFile: readProjectFile,
|
|
@@ -185,7 +185,6 @@ export function useElementLifecycleOps({
|
|
|
185
185
|
await saveProjectFilesWithHistory({
|
|
186
186
|
projectId: pid,
|
|
187
187
|
label: "Delete element",
|
|
188
|
-
kind: "timeline",
|
|
189
188
|
files: { [targetPath]: patchedContent },
|
|
190
189
|
readFile: async () => originalContent,
|
|
191
190
|
// remove-element already wrote the removal, so disk holds THAT — not
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { act, createElement, useRef } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import {
|
|
@@ -400,6 +400,10 @@ function stubFlatTweenConversion(id: string): {
|
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
describe("useEnableKeyframes — flat tween transaction", () => {
|
|
403
|
+
beforeEach(() => {
|
|
404
|
+
usePlayerStore.setState({ timelineProjectId: "test-project", previewBooted: true });
|
|
405
|
+
});
|
|
406
|
+
|
|
403
407
|
it("skips the convert reload and coalesces an outside-range terminal soft reload", async () => {
|
|
404
408
|
window.location.hash = "#/project/test-project";
|
|
405
409
|
usePlayerStore.setState({ currentTime: 3 });
|
|
@@ -468,6 +472,10 @@ describe("useEnableKeyframes — flat tween transaction", () => {
|
|
|
468
472
|
});
|
|
469
473
|
|
|
470
474
|
describe("useEnableKeyframes — new tween on a class-only element", () => {
|
|
475
|
+
beforeEach(() => {
|
|
476
|
+
usePlayerStore.setState({ timelineProjectId: "test-project", previewBooted: true });
|
|
477
|
+
});
|
|
478
|
+
|
|
471
479
|
it("targets the selected sibling alone, not every element sharing its class", async () => {
|
|
472
480
|
window.location.hash = "#/project/test-project";
|
|
473
481
|
usePlayerStore.setState({ currentTime: 1 });
|
|
@@ -2,7 +2,6 @@ import { useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import type { EditingFile } from "../utils/studioHelpers";
|
|
3
3
|
import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
|
|
4
4
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
5
|
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
|
7
6
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
8
7
|
import { useFileTree } from "./useFileTree";
|
|
@@ -13,7 +12,6 @@ import { useProjectFileWriter } from "./useProjectFileWriter";
|
|
|
13
12
|
|
|
14
13
|
interface RecordEditInput {
|
|
15
14
|
label: string;
|
|
16
|
-
kind: EditHistoryKind;
|
|
17
15
|
coalesceKey?: string;
|
|
18
16
|
files: Record<string, { before: string; after: string }>;
|
|
19
17
|
}
|
|
@@ -146,7 +146,6 @@ async function commitStructuralMutation(
|
|
|
146
146
|
await saveProjectFilesWithHistory({
|
|
147
147
|
projectId: pid,
|
|
148
148
|
label,
|
|
149
|
-
kind: "manual",
|
|
150
149
|
files: { [targetPath]: patchedContent },
|
|
151
150
|
readFile: async () => originalContent,
|
|
152
151
|
writeFile: deps.writeProjectFile,
|
|
@@ -326,7 +326,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
326
326
|
if (result.before == null || result.after == null) return;
|
|
327
327
|
await editHistory.recordEdit({
|
|
328
328
|
label: options.label,
|
|
329
|
-
kind: "manual",
|
|
330
329
|
coalesceKey: options.coalesceKey,
|
|
331
330
|
coalesceMs: options.coalesceMs,
|
|
332
331
|
files: { [targetPath]: { before: result.before, after: result.after } },
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { useLintModal } from "./useLintModal";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
10
|
|
|
@@ -15,6 +16,10 @@ function Probe() {
|
|
|
15
16
|
return null;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
usePlayerStore.setState({ timelineProjectId: "demo", previewBooted: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
18
23
|
afterEach(() => {
|
|
19
24
|
if (root) act(() => root?.unmount());
|
|
20
25
|
root = null;
|
|
@@ -63,3 +68,31 @@ describe("useLintModal hasLintError", () => {
|
|
|
63
68
|
expect(latest?.hasLintError).toBe(true);
|
|
64
69
|
});
|
|
65
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("useLintModal background lint", () => {
|
|
73
|
+
it("waits for the live preview to boot before asking the server to lint", async () => {
|
|
74
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
75
|
+
const fetchStub = vi.fn(lintResponse([]));
|
|
76
|
+
vi.stubGlobal("fetch", fetchStub);
|
|
77
|
+
root = createRoot(document.createElement("div"));
|
|
78
|
+
await act(async () => root?.render(<Probe />));
|
|
79
|
+
expect(fetchStub).not.toHaveBeenCalled();
|
|
80
|
+
|
|
81
|
+
await act(async () => usePlayerStore.getState().markPreviewBooted());
|
|
82
|
+
expect(fetchStub).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("never lints a project the user left before its preview booted", async () => {
|
|
86
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
87
|
+
const fetchStub = vi.fn(lintResponse([]));
|
|
88
|
+
vi.stubGlobal("fetch", fetchStub);
|
|
89
|
+
root = createRoot(document.createElement("div"));
|
|
90
|
+
await act(async () => root?.render(<Probe />));
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
usePlayerStore.getState().beginTimelineSession("another-project");
|
|
94
|
+
usePlayerStore.getState().markPreviewBooted();
|
|
95
|
+
});
|
|
96
|
+
expect(fetchStub).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|