@hyperframes/studio 0.8.81 → 0.8.83

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
  4. package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +834 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +26378 -28802
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  29. package/src/components/TimelineHistoryButtons.tsx +26 -14
  30. package/src/components/TimelineToolbar.test.tsx +9 -1
  31. package/src/components/TimelineToolbar.tsx +13 -5
  32. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  33. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  34. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  35. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  36. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  37. package/src/components/editor/DomEditOverlay.tsx +36 -21
  38. package/src/components/editor/LayersPanel.tsx +1 -1
  39. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  40. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  41. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  42. package/src/components/editor/clipAudioClock.test.ts +61 -0
  43. package/src/components/editor/clipAudioClock.ts +78 -0
  44. package/src/components/editor/manualEdits.test.ts +24 -0
  45. package/src/components/editor/manualEdits.ts +6 -2
  46. package/src/components/editor/manualEditsDom.ts +0 -65
  47. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  48. package/src/components/editor/marqueeCommit.ts +100 -37
  49. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
  51. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  52. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  53. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  54. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  55. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  56. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  57. package/src/components/editor/useFxCarve.ts +11 -15
  58. package/src/components/editor/useFxCarveNodes.ts +27 -15
  59. package/src/components/editor/useFxLevelling.ts +20 -31
  60. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  61. package/src/components/feedback/projectProvenance.ts +2 -3
  62. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  63. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  64. package/src/components/nle/PreviewOverlays.tsx +7 -188
  65. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  66. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  67. package/src/components/pointerTargetSize.test.tsx +1 -1
  68. package/src/components/studioColorGradingScope.ts +12 -36
  69. package/src/components/ui/HyperframesLogo.tsx +120 -0
  70. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  71. package/src/contexts/TimelineEditContext.tsx +27 -23
  72. package/src/domEditExports.test.tsx +69 -0
  73. package/src/editorGesturesExports.test.tsx +236 -0
  74. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  75. package/src/hooks/gsapMutationClient.ts +116 -0
  76. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  77. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  78. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  79. package/src/hooks/timelineEditingHelpers.ts +66 -61
  80. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  81. package/src/hooks/timelineTimingSync.test.ts +12 -0
  82. package/src/hooks/timelineTimingSync.ts +31 -123
  83. package/src/hooks/timelineTrackVisibility.ts +15 -29
  84. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  85. package/src/hooks/useAskAgentModal.ts +3 -1
  86. package/src/hooks/useClipboard.source.test.ts +32 -0
  87. package/src/hooks/useClipboard.ts +21 -20
  88. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  89. package/src/hooks/useDomEditCommits.ts +88 -97
  90. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  91. package/src/hooks/useEditorSave.ts +1 -1
  92. package/src/hooks/useElementLifecycleOps.ts +37 -38
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  94. package/src/hooks/useFileManager.ts +21 -12
  95. package/src/hooks/useGroupCommits.ts +23 -24
  96. package/src/hooks/useGsapAnimationOps.ts +26 -8
  97. package/src/hooks/useGsapScriptCommits.ts +1 -0
  98. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  99. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  100. package/src/hooks/usePreviewPersistence.ts +1 -1
  101. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  102. package/src/hooks/useThumbnailStripSize.ts +31 -0
  103. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  104. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  105. package/src/hooks/useTimelineEditing.ts +3 -1
  106. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  107. package/src/index.ts +53 -32
  108. package/src/lightEntries.test.ts +32 -0
  109. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  110. package/src/player/components/CompositionThumbnail.tsx +6 -21
  111. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  112. package/src/player/components/ImageThumbnail.tsx +4 -18
  113. package/src/player/components/PlayerControls.test.tsx +52 -0
  114. package/src/player/components/PlayerControls.tsx +2 -1
  115. package/src/player/components/Timeline.test.ts +28 -3
  116. package/src/player/components/TimelineCanvas.tsx +5 -3
  117. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  118. package/src/player/components/TimelineClipFades.tsx +2 -1
  119. package/src/player/components/TimelineTypes.ts +4 -2
  120. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  121. package/src/player/components/VideoThumbnail.tsx +7 -29
  122. package/src/player/components/studioShortcuts.ts +8 -5
  123. package/src/player/components/thumbnailUtils.test.ts +16 -20
  124. package/src/player/components/thumbnailUtils.ts +6 -7
  125. package/src/player/components/timelineClipChildren.test.ts +34 -1
  126. package/src/player/components/timelineClipChildren.tsx +7 -4
  127. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  128. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  129. package/src/player/components/timelineEditing.test.ts +55 -0
  130. package/src/player/components/timelineGroupEditing.ts +13 -6
  131. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  132. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  133. package/src/player/components/timelinePointerRelease.ts +4 -0
  134. package/src/player/components/useTimelinePlayhead.ts +26 -8
  135. package/src/player/components/useTimelineProviderState.tsx +4 -0
  136. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  137. package/src/player/components/useTimelineStackingSync.ts +18 -11
  138. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  139. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  140. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  141. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  142. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  143. package/src/player/lib/playbackShortcuts.ts +18 -0
  144. package/src/player/lib/playbackTypes.ts +1 -0
  145. package/src/player/lib/timelineDOM.test.ts +92 -0
  146. package/src/player/lib/timelineDOM.ts +0 -2
  147. package/src/player/lib/timelineElementHelpers.ts +47 -10
  148. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  149. package/src/player/store/timelineElement.ts +2 -1
  150. package/src/playerEntry.ts +1 -0
  151. package/src/timelineChromeExports.test.tsx +79 -0
  152. package/src/timelineStackingSyncExport.test.tsx +67 -0
  153. package/src/ui.ts +31 -0
  154. package/src/utils/authoredSource.ts +15 -0
  155. package/src/utils/blockInstaller.ts +32 -27
  156. package/src/utils/mediaTypes.test.ts +33 -0
  157. package/src/utils/mediaTypes.ts +12 -5
  158. package/src/utils/studioFileHistory.test.ts +14 -14
  159. package/src/utils/studioFileHistory.ts +31 -2
  160. package/dist/chunk-I7XNAIOF.js.map +0 -1
  161. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -7,10 +7,18 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR
7
7
  import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
8
8
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
9
  import { patchDocumentRootDuration } from "./timelineEditingGsap";
10
- import { studioWriteHeaders } from "../utils/studioFileVersion";
11
-
12
- class GsapPreviewConvergenceError extends Error {}
13
- class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
10
+ import {
11
+ GsapOwnershipProtocolError,
12
+ GsapPreviewConvergenceError,
13
+ postGsapMutation,
14
+ requireGsapOwnershipProtocol,
15
+ rollbackOwnedMutation,
16
+ type GsapMutationStatus,
17
+ } from "./gsapMutationClient";
18
+ import {
19
+ serializeStudioFileMutations,
20
+ type StudioProjectFileWriter,
21
+ } from "../utils/studioFileMutationCoordinator";
14
22
 
15
23
  export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
16
24
  if (targetPath.includes("\0") || targetPath.includes("..")) {
@@ -29,51 +37,6 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
29
37
  return data.content;
30
38
  }
31
39
 
32
- /** Verify rollback ownership support before any GSAP mutation can land. */
33
- async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
34
- const response = await fetch(
35
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
36
- );
37
- if (!response.ok) {
38
- throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
39
- }
40
- const body = await response.json().catch(() => null);
41
- if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
42
- throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
43
- }
44
- }
45
-
46
- /** Atomically restore one GSAP mutation only while its exact output still owns
47
- * the file. The server performs compare + write synchronously, eliminating the
48
- * client GET→PUT window that could overwrite a successor edit. */
49
- async function rollbackOwnedMutation(
50
- projectId: string,
51
- targetPath: string,
52
- expected: string,
53
- restore: string,
54
- ): Promise<"restored" | "conflict"> {
55
- if (targetPath.includes("\0") || targetPath.includes("..")) {
56
- throw new Error(`Unsafe path: ${targetPath}`);
57
- }
58
- const response = await fetch(
59
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
60
- {
61
- method: "POST",
62
- // Deliberately unclaimed: a rollback runs because a mutation did not
63
- // converge, so let the restored file reload the preview.
64
- headers: { "Content-Type": "application/json" },
65
- body: JSON.stringify({ expected, restore }),
66
- },
67
- );
68
- if (!response.ok) {
69
- throw new Error(`Failed to restore ${targetPath}`);
70
- }
71
- const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
72
- if (result.restored === true && result.conflict === false) return "restored";
73
- if (result.restored === false && result.conflict === true) return "conflict";
74
- throw new Error(`Invalid restore response for ${targetPath}`);
75
- }
76
-
77
40
  /** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
78
41
  function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
79
42
  try {
@@ -102,79 +65,6 @@ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () =>
102
65
  };
103
66
  }
104
67
 
105
- /**
106
- * The bits of the server GSAP-mutation response the timeline edit path needs.
107
- * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
108
- * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
109
- * when the endpoint didn't return one (older server, or a multi-script comp the
110
- * soft path can't scope) — the caller then full-reloads when `mutated`, or
111
- * rebinds the runtime timing in place when nothing was rewritten (see
112
- * syncTimingEditPreview).
113
- */
114
- export type GsapMutationStatus = {
115
- mutated: boolean;
116
- scriptText: string | null;
117
- /** Atomic whole-file ownership pair returned by the mutation endpoint. */
118
- before?: string;
119
- after?: string;
120
- };
121
-
122
- function isRecord(value: unknown): value is Record<string, unknown> {
123
- return typeof value === "object" && value !== null;
124
- }
125
-
126
- function readMutationStatus(value: unknown): GsapMutationStatus {
127
- if (
128
- !isRecord(value) ||
129
- typeof value.mutated !== "boolean" ||
130
- typeof value.before !== "string" ||
131
- typeof value.after !== "string" ||
132
- value.mutated !== (value.before !== value.after) ||
133
- ("changed" in value && value.changed !== value.mutated)
134
- ) {
135
- throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
136
- }
137
- return {
138
- mutated: value.mutated,
139
- scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
140
- before: value.before,
141
- after: value.after,
142
- };
143
- }
144
-
145
- function readMutationError(value: unknown, fallback: string): string {
146
- if (isRecord(value) && typeof value.error === "string") return value.error;
147
- return fallback;
148
- }
149
-
150
- async function postGsapMutation(
151
- projectId: string,
152
- filePath: string,
153
- mutation: Record<string, unknown>,
154
- fallback: string,
155
- ): Promise<GsapMutationStatus> {
156
- let response: Response;
157
- try {
158
- response = await fetch(
159
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
160
- {
161
- method: "POST",
162
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
163
- body: JSON.stringify(mutation),
164
- },
165
- );
166
- } catch (error) {
167
- throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
168
- cause: error,
169
- });
170
- }
171
- const body: unknown = await response.json().catch(() => null);
172
- if (!response.ok) {
173
- throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
174
- }
175
- return readMutationStatus(body);
176
- }
177
-
178
68
  /** Re-derive live timing windows without re-executing composition scripts.
179
69
  * Works for zero-GSAP compositions; false asks the caller to full-reload. */
180
70
  function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean {
@@ -341,7 +231,7 @@ async function rollbackAfterFailure(
341
231
  // The ledger, reverse rollback, final ownership check, and history fold are one
342
232
  // transaction; extracting phases would obscure which function owns convergence.
343
233
  // fallow-ignore-next-line complexity
344
- async function foldGsapMutationIntoHistory(input: {
234
+ async function foldGsapMutationInQueue(input: {
345
235
  projectId: string;
346
236
  label: string;
347
237
  coalesceKey?: string;
@@ -411,6 +301,18 @@ async function foldGsapMutationIntoHistory(input: {
411
301
  return status;
412
302
  }
413
303
 
304
+ // The mutations, the ownership read and any rollback hold every touched file's queue.
305
+ function foldGsapMutationIntoHistory(
306
+ input: Parameters<typeof foldGsapMutationInQueue>[0] & {
307
+ writeFile: StudioProjectFileWriter;
308
+ paths: readonly string[];
309
+ },
310
+ ): Promise<GsapMutationStatus> {
311
+ return serializeStudioFileMutations(input.writeFile, input.paths, () =>
312
+ foldGsapMutationInQueue(input),
313
+ );
314
+ }
315
+
414
316
  /**
415
317
  * Shift all GSAP animation positions targeting a given element by a time delta.
416
318
  * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
@@ -491,6 +393,7 @@ export function finishClipTimingFallback(input: {
491
393
  label: string;
492
394
  coalesceKey?: string;
493
395
  recordEdit: (edit: RecordEditInput) => Promise<void>;
396
+ writeProjectFile: StudioProjectFileWriter;
494
397
  edit: SingleClipGsapEdit;
495
398
  }): Promise<void> {
496
399
  const { projectId, targetPath, domId, edit } = input;
@@ -519,6 +422,8 @@ export function finishClipTimingFallback(input: {
519
422
  timingChanged && domId && projectId
520
423
  ? () =>
521
424
  foldGsapMutationIntoHistory({
425
+ writeFile: input.writeProjectFile,
426
+ paths: [targetPath],
522
427
  projectId,
523
428
  label: input.label,
524
429
  coalesceKey: input.coalesceKey,
@@ -552,6 +457,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
552
457
  errorLabel: string;
553
458
  coalesceKey?: string;
554
459
  recordEdit: (edit: RecordEditInput) => Promise<void>;
460
+ writeProjectFile: StudioProjectFileWriter;
555
461
  activeCompPath: string | null;
556
462
  changes: readonly C[];
557
463
  resolveChangePath: (element: TimelineElement) => string;
@@ -568,6 +474,8 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
568
474
  reloadPreview: input.reloadPreview,
569
475
  gsapMutation: () =>
570
476
  foldGsapMutationIntoHistory({
477
+ writeFile: input.writeProjectFile,
478
+ paths: input.changes.map((change) => input.resolveChangePath(change.element)),
571
479
  projectId: input.projectId,
572
480
  label: input.label,
573
481
  coalesceKey: input.coalesceKey,
@@ -9,11 +9,11 @@ import {
9
9
  } from "../player/components/timelineTrackDisplay";
10
10
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
11
11
  import { isAudioTimelineElement } from "../utils/timelineInspector";
12
- import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
12
+ import type { PatchOperation } from "../utils/sourcePatcher";
13
13
  import {
14
- applyPatchByTarget,
15
- buildPatchTarget,
16
14
  findTimelineElementInIframe,
15
+ operationChanges,
16
+ patchTimelineChangesInSource,
17
17
  readFileContent,
18
18
  type RecordEditInput,
19
19
  } from "./timelineEditingHelpers";
@@ -149,38 +149,24 @@ async function setElementsHidden({
149
149
  property: "hidden",
150
150
  value: hidden ? "" : null,
151
151
  };
152
- const originalByPath = new Map<string, string>();
153
- const files: Record<string, string> = {};
154
-
155
- try {
156
- for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
157
- let patchedContent = await readFileContent(projectId, targetPath);
158
- originalByPath.set(targetPath, patchedContent);
159
-
160
- for (const element of fileElements) {
161
- const patchTarget = buildPatchTarget(element);
162
- if (!patchTarget) {
163
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
164
- }
165
- if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
166
- throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
167
- }
168
- patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
169
- }
170
-
171
- files[targetPath] = patchedContent;
152
+ const files: Record<string, (current: string) => string> = {};
153
+ for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
154
+ files[targetPath] = (current) => {
172
155
  pendingTimelineEditPathRef.current.add(targetPath);
173
- }
156
+ return patchTimelineChangesInSource(
157
+ current,
158
+ targetPath,
159
+ operationChanges(fileElements, hiddenOperation),
160
+ );
161
+ };
162
+ }
174
163
 
164
+ try {
175
165
  const changedPaths = await saveProjectFilesWithHistory({
176
166
  projectId,
177
167
  label,
178
168
  files,
179
- readFile: async (path) => {
180
- const original = originalByPath.get(path);
181
- if (original !== undefined) return original;
182
- return readFileContent(projectId, path);
183
- },
169
+ readFile: (path) => readFileContent(projectId, path),
184
170
  writeFile: writeProjectFile,
185
171
  recordEdit,
186
172
  });
@@ -0,0 +1,78 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { DomEditSelection } from "../components/editor/domEditing";
7
+ import { useAskAgentModal } from "./useAskAgentModal";
8
+
9
+ const copied = vi.hoisted(() => [] as string[]);
10
+ vi.mock("../utils/clipboard", () => ({
11
+ copyTextToClipboard: async (text: string) => copied.push(text) > 0,
12
+ }));
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ afterEach(() => {
17
+ document.body.innerHTML = "";
18
+ copied.length = 0;
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ describe("useAskAgentModal", () => {
23
+ it("sends the live element without the preview's lazy loading or look-ahead mark, keeping authored loading", async () => {
24
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
25
+ const element = document.createElement("div");
26
+ element.setAttribute("data-hf-upcoming", "");
27
+ element.innerHTML =
28
+ '<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="eager">';
29
+ const selection: DomEditSelection = {
30
+ element,
31
+ label: "Plate",
32
+ tagName: "div",
33
+ sourceFile: "index.html",
34
+ compositionPath: "index.html",
35
+ isCompositionHost: false,
36
+ isInsideLockedComposition: false,
37
+ selector: "div",
38
+ selectorIndex: 0,
39
+ boundingBox: { x: 0, y: 0, width: 10, height: 10 },
40
+ textContent: "",
41
+ dataAttributes: {},
42
+ inlineStyles: {},
43
+ computedStyles: {},
44
+ textFields: [],
45
+ capabilities: {
46
+ canSelect: true,
47
+ canEditStyles: true,
48
+ canCrop: false,
49
+ canMove: true,
50
+ canResize: true,
51
+ canApplyManualOffset: false,
52
+ canApplyManualSize: false,
53
+ canApplyManualRotation: false,
54
+ },
55
+ };
56
+ let hook: ReturnType<typeof useAskAgentModal> | null = null;
57
+ function Harness() {
58
+ hook = useAskAgentModal({
59
+ projectId: "demo",
60
+ activeCompPath: "index.html",
61
+ projectDir: "/demo",
62
+ projectIdRef: { current: "demo" },
63
+ showToast: () => {},
64
+ domEditSelectionRef: { current: selection },
65
+ domEditSelection: selection,
66
+ });
67
+ return null;
68
+ }
69
+ const host = document.body.appendChild(document.createElement("div"));
70
+ await act(async () => createRoot(host).render(<Harness />));
71
+
72
+ await act(async () => hook!.handleAgentModalSubmit("Make it bigger"));
73
+
74
+ expect(copied[0]).toContain('<img src="plate.png"><img src="own.png" loading="eager">');
75
+ expect(copied[0]).not.toContain("data-hf-preview-lazy");
76
+ expect(copied[0]).not.toContain("data-hf-upcoming");
77
+ });
78
+ });
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
4
4
  import { copyTextToClipboard } from "../utils/clipboard";
5
5
  import { readTagSnippetByTarget } from "../utils/sourcePatcher";
6
6
  import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
7
+ import { liveMarkupWithoutPreviewMarks } from "../utils/authoredSource";
7
8
  import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
8
9
  import { usePlayerStore } from "../player";
9
10
 
@@ -89,7 +90,8 @@ export function useAskAgentModal({
89
90
  if (!domEditSelection) return;
90
91
 
91
92
  const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
92
- const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
93
+ const tagSnippet =
94
+ agentPromptTagSnippet ?? liveMarkupWithoutPreviewMarks(domEditSelection.element);
93
95
  const prompt = buildElementAgentPrompt({
94
96
  selection: domEditSelection,
95
97
  currentTime: usePlayerStore.getState().currentTime,
@@ -220,6 +220,27 @@ describe("copy takes a clip's saved markup, not the runtime's live styling", ()
220
220
  });
221
221
  });
222
222
 
223
+ describe("copy of a clip missing from its saved file", () => {
224
+ it("takes the preview's markup without its lazy loading or look-ahead mark, keeping authored loading", async () => {
225
+ selectTitle();
226
+ const { clipboard, writes, files, iframe } = mountClipboard();
227
+ const title = iframe.contentDocument?.querySelector("h1") as Element;
228
+ title.setAttribute("data-hf-upcoming", "");
229
+ title.insertAdjacentHTML(
230
+ "beforeend",
231
+ '<img src="plate.png" loading="lazy" data-hf-preview-lazy><img src="own.png" loading="lazy">',
232
+ );
233
+ files["index.html"] = SAVED.replace(/<h1[\s\S]*<\/h1>/, "");
234
+ clipboard().handleCopy();
235
+ await clipboard().handlePaste();
236
+ expect(writes[0]).toMatch(
237
+ /<img src="plate.png" data-hf-id="[^"]+"><img src="own.png" loading="lazy"/,
238
+ );
239
+ expect(writes[0]).not.toContain("data-hf-preview-lazy");
240
+ expect(writes[0]).not.toContain("data-hf-upcoming");
241
+ });
242
+ });
243
+
223
244
  const SUB_SELECTION = {
224
245
  hfId: SUB_HF_ID,
225
246
  selector: "h2",
@@ -338,3 +359,14 @@ describe("a copy that fails", () => {
338
359
  expect(deleted).toEqual([]);
339
360
  });
340
361
  });
362
+
363
+ describe("with no DOM edit session", () => {
364
+ it("refuses copy, cut and duplicate when nothing is selected", async () => {
365
+ clearSelection();
366
+ const { clipboard, deleted } = mountClipboard();
367
+ expect(clipboard().handleCopy()).toBe(false);
368
+ expect(await clipboard().handleCut()).toBe(false);
369
+ expect(await clipboard().handleDuplicate()).toBe(false);
370
+ expect(deleted).toEqual([]);
371
+ });
372
+ });
@@ -23,6 +23,7 @@ import {
23
23
  authoredMarkup,
24
24
  findAuthoredElement,
25
25
  findAuthoredElementById,
26
+ liveMarkupWithoutPreviewMarks,
26
27
  parseSavedSource,
27
28
  } from "../utils/authoredSource";
28
29
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
@@ -33,7 +34,7 @@ interface RecordEditInput {
33
34
  files: Record<string, { before: string; after: string }>;
34
35
  }
35
36
 
36
- interface UseClipboardOptions {
37
+ export interface UseClipboardOptions {
37
38
  projectId: string | null;
38
39
  activeCompPath: string | null;
39
40
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
@@ -88,7 +89,9 @@ function getSelectedDomElement(
88
89
 
89
90
  function savedMarkupElseLive(saved: Document, live: Element, sourceFile: string): string {
90
91
  const authored = findAuthoredElement(saved, live) ?? findAuthoredElementById(saved, live);
91
- return authored ? authoredMarkup(authored, live, sourceFile) : live.outerHTML;
92
+ return authored
93
+ ? authoredMarkup(authored, live, sourceFile)
94
+ : liveMarkupWithoutPreviewMarks(live);
92
95
  }
93
96
 
94
97
  async function readSavedMarkup(
@@ -356,21 +359,17 @@ export function useClipboard({
356
359
 
357
360
  const targetPath = activeCompPath || "index.html";
358
361
  try {
359
- const originalContent = await readFileContent(pid, targetPath);
360
- let patchedContent: string;
361
362
  let pastedIds: string[] = [];
362
-
363
- if (payload.kind === "timeline-clip") {
363
+ const paste = (originalContent: string) => {
364
+ if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
364
365
  const { currentTime, elements } = usePlayerStore.getState();
365
366
  const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
367
+ pastedIds = pasted.ids;
366
368
  // A clip pasted past the current composition end would exist in the
367
369
  // file but never appear on the timeline or in playback/export (the
368
370
  // root's data-duration is what actually bounds the render).
369
- patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
370
- pastedIds = pasted.ids;
371
- } else {
372
- patchedContent = pasteElementHtml(originalContent, payload);
373
- }
371
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
372
+ };
374
373
 
375
374
  const label =
376
375
  payload.kind === "timeline-clip"
@@ -380,8 +379,8 @@ export function useClipboard({
380
379
  await saveProjectFilesWithHistory({
381
380
  projectId: pid,
382
381
  label,
383
- files: { [targetPath]: patchedContent },
384
- readFile: async () => originalContent,
382
+ files: { [targetPath]: paste },
383
+ readFile: (path) => readFileContent(pid, path),
385
384
  writeFile: writeProjectFile,
386
385
  recordEdit,
387
386
  });
@@ -422,17 +421,19 @@ export function useClipboard({
422
421
 
423
422
  try {
424
423
  const clips = await readClips(targets);
425
- const originalContent = await readFileContent(pid, targetPath);
426
- const liveElements = usePlayerStore.getState().elements;
427
- const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
428
- const patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
429
- const ids = pasted.ids;
424
+ let ids: string[] = [];
425
+ const duplicate = (originalContent: string) => {
426
+ const liveElements = usePlayerStore.getState().elements;
427
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
428
+ ids = pasted.ids;
429
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
430
+ };
430
431
 
431
432
  await saveProjectFilesWithHistory({
432
433
  projectId: pid,
433
434
  label: clipLabel("Duplicate", clips.length),
434
- files: { [targetPath]: patchedContent },
435
- readFile: async () => originalContent,
435
+ files: { [targetPath]: duplicate },
436
+ readFile: (path) => readFileContent(pid, path),
436
437
  writeFile: writeProjectFile,
437
438
  recordEdit,
438
439
  });
@@ -1252,8 +1252,15 @@ describe("useDomEditCommits style persist handling", () => {
1252
1252
 
1253
1253
  const secondCommit = rendered.hook.handleDomStyleCommit("color", "green");
1254
1254
  await flushAsyncWork();
1255
- expect(patchCount).toBe(2);
1255
+ // The newer commit waits for the older one in the file's queue.
1256
+ expect(patchCount).toBe(1);
1256
1257
 
1258
+ firstPatch.reject(new Error("server rejected blue"));
1259
+ await firstCommit;
1260
+ expect(element.style.getPropertyValue("color")).toBe("green");
1261
+
1262
+ await flushAsyncWork();
1263
+ expect(patchCount).toBe(2);
1257
1264
  secondPatch.resolve(
1258
1265
  jsonResponse({
1259
1266
  ok: true,
@@ -1263,10 +1270,6 @@ describe("useDomEditCommits style persist handling", () => {
1263
1270
  }),
1264
1271
  );
1265
1272
  await secondCommit;
1266
- expect(element.style.getPropertyValue("color")).toBe("green");
1267
-
1268
- firstPatch.reject(new Error("server rejected blue"));
1269
- await firstCommit;
1270
1273
 
1271
1274
  expect(element.style.getPropertyValue("color")).toBe("green");
1272
1275
  } finally {
@@ -1698,15 +1701,16 @@ describe("useDomEditCommits attribute persist handling", () => {
1698
1701
  try {
1699
1702
  // Older commit's persist stays pending (captures previousValue=null); the
1700
1703
  // newer commit captures previousValue="first-value" (the older commit's
1701
- // optimistic apply) and succeeds before the older one rejects. Without the
1704
+ // optimistic apply) and is still queued when the older one rejects. Without the
1702
1705
  // per-key version guard, the stale rejection would revert to the older
1703
1706
  // commit's own previousValue (null) and stomp the newer commit's value.
1704
1707
  const firstCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
1705
1708
  await act(async () => {
1706
- await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
1709
+ const secondCommit = rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
1710
+ first.reject(new Error("stale request failed"));
1711
+ await firstCommit;
1712
+ await secondCommit;
1707
1713
  });
1708
- first.reject(new Error("stale request failed"));
1709
- await firstCommit;
1710
1714
 
1711
1715
  expect(element.getAttribute("muted")).toBe("second-value");
1712
1716
  } finally {