@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.
Files changed (107) hide show
  1. package/dist/assets/hyperframes-player-BkDxgSKG.js +464 -0
  2. package/dist/assets/{index-BiFKcNjs.js → index-COX0scwJ.js} +1 -1
  3. package/dist/assets/{index-BlUYw1MB.js → index-DeHir1up.js} +156 -156
  4. package/dist/assets/{index-HjexPsny.js → index-FmKUmYhC.js} +1 -1
  5. package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
  6. package/dist/chunk-IYDE66BY.js.map +1 -0
  7. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
  8. package/dist/index.d.ts +34 -69
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +4147 -4253
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/components/StudioRightPanels.types.ts +0 -2
  14. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  15. package/src/components/editor/LayersPanel.tsx +3 -14
  16. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  17. package/src/components/editor/domEditing.ts +1 -0
  18. package/src/components/editor/domEditingLayers.test.ts +27 -0
  19. package/src/components/editor/domEditingLayers.ts +9 -0
  20. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  21. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  22. package/src/components/editor/overlayFrameLoop.ts +11 -0
  23. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  24. package/src/components/nle/NLEPreview.test.ts +94 -17
  25. package/src/components/nle/NLEPreview.tsx +14 -0
  26. package/src/components/nle/PreviewPoster.tsx +99 -0
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  28. package/src/components/panels/VariablesPanel.tsx +0 -2
  29. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  30. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  31. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  32. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  33. package/src/components/studioColorGradingScope.ts +0 -3
  34. package/src/hooks/domEditDeleteMembers.ts +0 -2
  35. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  36. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  37. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  38. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  39. package/src/hooks/timelineEditingHelpers.ts +0 -3
  40. package/src/hooks/timelineTimingSync.ts +0 -1
  41. package/src/hooks/timelineTrackVisibility.ts +0 -1
  42. package/src/hooks/useBlockHandlers.ts +0 -2
  43. package/src/hooks/useClipboard.test.ts +69 -10
  44. package/src/hooks/useClipboard.ts +28 -19
  45. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  48. package/src/hooks/useEditHistoryActions.ts +12 -7
  49. package/src/hooks/useEditorSave.ts +0 -3
  50. package/src/hooks/useElementLifecycleOps.ts +0 -1
  51. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  52. package/src/hooks/useFileManager.ts +0 -2
  53. package/src/hooks/useGroupCommits.ts +0 -1
  54. package/src/hooks/useGsapScriptCommits.ts +0 -1
  55. package/src/hooks/useLintModal.test.tsx +34 -1
  56. package/src/hooks/useLintModal.ts +32 -30
  57. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  58. package/src/hooks/usePersistentEditHistory.ts +155 -342
  59. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  60. package/src/hooks/usePreviewPersistence.ts +0 -2
  61. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  62. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  63. package/src/hooks/useRazorSplit.test.ts +17 -47
  64. package/src/hooks/useRenderClipContent.test.ts +29 -0
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  67. package/src/hooks/useSdkSession.ts +8 -2
  68. package/src/hooks/useSlideshowPersist.ts +0 -2
  69. package/src/hooks/useStudioContextValue.ts +6 -1
  70. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  71. package/src/hooks/useTimelineDeleteOps.ts +0 -1
  72. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  73. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  74. package/src/hostMount.test.tsx +53 -22
  75. package/src/main.tsx +3 -0
  76. package/src/player/components/CompositionThumbnail.tsx +14 -0
  77. package/src/player/components/Player.test.ts +49 -0
  78. package/src/player/components/Player.tsx +21 -7
  79. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  80. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  81. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  82. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  83. package/src/player/hooks/useShadowPreviewReload.ts +1 -0
  84. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  85. package/src/player/hooks/useTimelinePlayer.ts +1 -0
  86. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  87. package/src/player/lib/timelineElementHelpers.ts +45 -9
  88. package/src/player/store/playerStore.test.ts +35 -1
  89. package/src/player/store/playerStore.ts +29 -0
  90. package/src/player/store/readinessSlice.ts +34 -6
  91. package/src/utils/blockInstaller.ts +0 -3
  92. package/src/utils/previewPrefetch.test.ts +22 -0
  93. package/src/utils/previewPrefetch.ts +14 -0
  94. package/src/utils/razorSplitTransaction.test.ts +0 -1
  95. package/src/utils/razorSplitTransaction.ts +1 -1
  96. package/src/utils/sdkEditTransaction.ts +0 -3
  97. package/src/utils/studioFileHistory.test.ts +0 -9
  98. package/src/utils/studioFileHistory.ts +1 -5
  99. package/src/utils/timelineCompositionInsert.ts +0 -1
  100. package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
  101. package/dist/chunk-7BP32G62.js.map +0 -1
  102. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  103. package/src/utils/editHistory.test.ts +0 -534
  104. package/src/utils/editHistory.ts +0 -236
  105. package/src/utils/editHistoryStorage.test.ts +0 -37
  106. package/src/utils/editHistoryStorage.ts +0 -99
  107. /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 { pasteTimelineClips, resolveFreeTrack, type PlacedClip } from "./useClipboard";
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("strips the copied data-hf-id so a fresh one mints on next save", () => {
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("data-hf-id");
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("strips a nested descendant's data-hf-id too, not just the root's", () => {
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).not.toContain("data-hf-id");
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("strips a single-quoted data-hf-id, not just a double-quoted one", () => {
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("data-hf-id");
199
+ expect(content).not.toContain("hf-75o7");
200
+ expect(hfIdsIn(content)).toHaveLength(1);
157
201
  });
158
202
 
159
- it("strips data-hf-id even when an earlier attribute value contains a literal >", () => {
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("data-hf-id");
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 stripHfIds's DOM walk sees
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
- /** Strips data-hf-id from the root and every descendant so a clone re-mints
104
- * its own. DOMParser, not a bracket-scoped regex, so a `>` inside an earlier
105
- * attribute value or a single-quoted id can't defeat the strip. */
106
- function stripHfIds(html: string, parser: DOMParser): string {
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.querySelectorAll("[data-hf-id]").forEach((el) => el.removeAttribute("data-hf-id"));
116
- root.removeAttribute("data-hf-id");
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 stripped = stripHfIds(clip.html, domParser);
143
- const deduped = deduplicateIds(stripped, existingIds);
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
- const deduped = deduplicateIds(payload.html, collectHtmlIds(originalContent));
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: { ok: boolean; reason?: string; label?: string; paths?: string[] }) {
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 (cb) => {
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 writes through the host writer, resyncs the preview and toasts the label", async () => {
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({ ok: true, label: "Split clip", paths: ["other.html"] });
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("explains a refused undo when the file changed on disk", async () => {
64
- const { deps, actions } = mount({ ok: false, reason: "content-mismatch" });
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
- "File changed outside Studio. Undo history was not applied.",
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
- /** Applies one persisted file-history step: the single owner of undo/redo over project files. */
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 [noun, verb] = direction === "undo" ? ["Undo", "Undid"] : ["Redo", "Redid"];
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(`File changed outside Studio. ${noun} history was not applied.`, "info");
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(`${verb} ${result.label}`, "info");
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
+ });