@hyperframes/studio 0.7.57 → 0.7.58

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 (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -28,7 +28,6 @@ import { useDomEditTextCommits } from "./useDomEditTextCommits";
28
28
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
29
29
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
30
30
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
31
- import { readProjectFileContent } from "../utils/studioFileHistory";
32
31
 
33
32
  // ── Helpers ──
34
33
 
@@ -67,9 +66,9 @@ function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]):
67
66
  }
68
67
 
69
68
  /**
70
- * Surface server-reported unmatched patches. The matched subset already
71
- * persisted, so this must NOT throw (a throw would roll back applied state) —
72
- * warn and emit save-failure telemetry with a distinct reason instead.
69
+ * Surface server-reported unmatched patches. The server atomically refuses the
70
+ * whole multi-file gesture; the caller uses `durable: false` to roll back and
71
+ * reload, so report the refusal without turning it into a second failure.
73
72
  */
74
73
  function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
75
74
  const unmatchedIds = batch.patches
@@ -78,7 +77,7 @@ function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[
78
77
  if (unmatchedIds.length === 0) return;
79
78
  console.warn(
80
79
  `[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
81
- `${batch.sourceFile} (their z-order will revert on reload):`,
80
+ `${batch.sourceFile} (the whole z-order gesture will revert on reload):`,
82
81
  unmatchedIds.join(", "),
83
82
  );
84
83
  trackStudioSaveFailure({
@@ -89,40 +88,95 @@ function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[
89
88
  });
90
89
  }
91
90
 
92
- async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
93
- const before = await readProjectFileContent(projectId, batch.sourceFile);
94
- const response = await fetch(
95
- `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-elements-batch/${encodeURIComponent(batch.sourceFile)}`,
96
- {
97
- method: "POST",
98
- headers: { "Content-Type": "application/json" },
99
- body: JSON.stringify({ patches: batch.patches }),
100
- },
91
+ interface AtomicElementPatchFile {
92
+ sourceFile: string;
93
+ changed: boolean;
94
+ matched?: boolean[];
95
+ before: string;
96
+ after: string;
97
+ }
98
+
99
+ class AtomicElementPatchConvergenceError extends Error {
100
+ constructor(message: string, options?: { cause?: unknown }) {
101
+ super(message, options);
102
+ this.name = "AtomicElementPatchConvergenceError";
103
+ }
104
+ }
105
+
106
+ // Keep the atomic response contract in one guard so callers do not compose validity.
107
+ // fallow-ignore-next-line complexity
108
+ function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFile {
109
+ return (
110
+ typeof value === "object" &&
111
+ value !== null &&
112
+ "sourceFile" in value &&
113
+ typeof value.sourceFile === "string" &&
114
+ "changed" in value &&
115
+ typeof value.changed === "boolean" &&
116
+ (!("matched" in value) ||
117
+ (Array.isArray(value.matched) &&
118
+ value.matched.every((matched) => typeof matched === "boolean"))) &&
119
+ "before" in value &&
120
+ typeof value.before === "string" &&
121
+ "after" in value &&
122
+ typeof value.after === "string" &&
123
+ value.changed === (value.before !== value.after)
101
124
  );
102
- if (!response.ok) {
103
- const rejection = await readErrorResponseBody(response);
104
- throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
125
+ }
126
+
127
+ // This is the single client owner for dispatching and validating the aggregate
128
+ // atomic endpoint. Splitting validation from the request would weaken that wire contract.
129
+ // fallow-ignore-next-line complexity
130
+ async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
131
+ const body = JSON.stringify({ batches });
132
+ try {
133
+ const response = await fetch(
134
+ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
135
+ {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body,
139
+ },
140
+ );
141
+ if (!response.ok) {
142
+ const rejection = await readErrorResponseBody(response);
143
+ throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
144
+ }
145
+ const result: unknown = await response.json().catch(() => null);
146
+ if (
147
+ typeof result !== "object" ||
148
+ result === null ||
149
+ !("durable" in result) ||
150
+ typeof result.durable !== "boolean" ||
151
+ !("files" in result) ||
152
+ !Array.isArray(result.files) ||
153
+ result.files.length !== batches.length ||
154
+ !result.files.every(isAtomicElementPatchFile) ||
155
+ (!result.durable && result.files.some((file) => file.changed))
156
+ ) {
157
+ throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
158
+ }
159
+ const files = result.files.map((file, index) => {
160
+ const batch = batches[index];
161
+ const matched = file.matched ?? [];
162
+ if (
163
+ !batch ||
164
+ file.sourceFile !== batch.sourceFile ||
165
+ (matched.length !== 0 && matched.length !== batch.patches.length)
166
+ ) {
167
+ throw new StudioSaveHttpError("Invalid atomic element patch response", 502);
168
+ }
169
+ reportUnmatchedBatchPatches(batch, matched);
170
+ return {
171
+ ...file,
172
+ matched,
173
+ allMatched: matched.length === batch.patches.length && matched.every(Boolean),
174
+ };
175
+ });
176
+ return { durable: result.durable, files };
177
+ } catch (error) {
178
+ throw new AtomicElementPatchConvergenceError(getErrorDetail(error), { cause: error });
105
179
  }
106
- const result = (await response.json()) as {
107
- changed?: boolean;
108
- matched?: boolean[];
109
- content?: string;
110
- };
111
- if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
112
- return {
113
- sourceFile: batch.sourceFile,
114
- changed: result.changed === true,
115
- // Skip-reload safety: the persist is only provably in sync with the live
116
- // DOM when the server confirmed EVERY patch target matched. A missing /
117
- // short matched[] is treated as unknown (false) so the caller falls back
118
- // to reloading rather than silently diverging from disk.
119
- allMatched:
120
- Array.isArray(result.matched) &&
121
- result.matched.length === batch.patches.length &&
122
- result.matched.every(Boolean),
123
- before,
124
- after: typeof result.content === "string" ? result.content : before,
125
- };
126
180
  }
127
181
 
128
182
  /**
@@ -142,7 +196,7 @@ export interface UseDomEditCommitsParams {
142
196
  activeCompPath: string | null;
143
197
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
144
198
  showToast: (message: string, tone?: "error" | "info") => void;
145
- queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
199
+ queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
146
200
  writeProjectFile: (path: string, content: string) => Promise<void>;
147
201
  domEditSaveTimestampRef: React.MutableRefObject<number>;
148
202
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
@@ -382,48 +436,59 @@ export function useDomEditCommits({
382
436
 
383
437
  const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
384
438
  (batches, options) =>
385
- queueDomEditSave(async () => {
386
- const pid = projectIdRef.current;
387
- if (!pid) throw new Error("No active project");
388
- const unsafeFields = batches.flatMap((batch) =>
389
- batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
390
- );
391
- if (unsafeFields.length > 0) {
392
- showToast("Couldn't save edit because it contains invalid layout values", "error");
393
- throw new DomEditPersistUnsafeValueError(
394
- `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
395
- { alreadyToasted: true },
439
+ queueDomEditSave(
440
+ // One queued transaction owns validation, persistence, history, reload,
441
+ // and its durable result; splitting those phases risks partial commits.
442
+ // fallow-ignore-next-line complexity
443
+ async () => {
444
+ const pid = projectIdRef.current;
445
+ if (!pid) throw new Error("No active project");
446
+ const unsafeFields = batches.flatMap((batch) =>
447
+ batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
396
448
  );
397
- }
449
+ if (unsafeFields.length > 0) {
450
+ showToast("Couldn't save edit because it contains invalid layout values", "error");
451
+ throw new DomEditPersistUnsafeValueError(
452
+ `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
453
+ { alreadyToasted: true },
454
+ );
455
+ }
398
456
 
399
- domEditSaveTimestampRef.current = Date.now();
400
- const results = await Promise.all(batches.map((batch) => patchElementBatch(pid, batch)));
401
- const files = Object.fromEntries(
402
- results
403
- .filter((result) => result.changed)
404
- .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
405
- );
406
- if (Object.keys(files).length === 0) return;
407
- await editHistory.recordEdit({
408
- label: options.label,
409
- kind: "manual",
410
- coalesceKey: options.coalesceKey,
411
- files,
412
- });
413
- forceReloadSdkSession?.();
414
- // A z-only reorder already applied its inline styles to the live iframe
415
- // DOM (and the store) synchronously, so remounting the iframe here only
416
- // produces a visible blink. Skip the reload when the caller asked for it
417
- // AND the persist is provably in sync: style-only ops, every target
418
- // matched. Any unmatched patch means the live DOM now shows state disk
419
- // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
420
- // reload is independently suppressed by domEditSaveTimestampRef above.)
421
- const skipSafe =
422
- options.skipReload === true &&
423
- batchesAreInlineStyleOnly(batches) &&
424
- results.every((result) => result.allMatched);
425
- if (!skipSafe) reloadPreview();
426
- }).catch((error) => {
457
+ domEditSaveTimestampRef.current = Date.now();
458
+ const atomicResult = await patchElementBatches(pid, batches);
459
+ const allMatched =
460
+ atomicResult.durable && atomicResult.files.every((result) => result.allMatched);
461
+ const files = Object.fromEntries(
462
+ atomicResult.files
463
+ .filter((result) => result.changed)
464
+ .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
465
+ );
466
+ const changed = Object.keys(files).length > 0;
467
+ if (changed) {
468
+ await editHistory.recordEdit({
469
+ label: options.label,
470
+ kind: "manual",
471
+ coalesceKey: options.coalesceKey,
472
+ coalesceMs: options.coalesceMs,
473
+ files,
474
+ });
475
+ forceReloadSdkSession?.();
476
+ }
477
+ const durable = allMatched;
478
+ // A z-only reorder already applied its inline styles to the live iframe
479
+ // DOM (and the store) synchronously, so remounting the iframe here only
480
+ // produces a visible blink. Skip the reload when the caller asked for it
481
+ // AND the persist is provably in sync: style-only ops, every target
482
+ // matched. Any unmatched patch means the live DOM now shows state disk
483
+ // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
484
+ // reload is independently suppressed by domEditSaveTimestampRef above.)
485
+ const skipSafe =
486
+ options.skipReload === true && batchesAreInlineStyleOnly(batches) && durable;
487
+ if (!durable || (changed && !skipSafe)) reloadPreview();
488
+ return { durable, allMatched, changed };
489
+ },
490
+ ).catch((error) => {
491
+ if (error instanceof AtomicElementPatchConvergenceError) reloadPreview();
427
492
  const alreadyToasted =
428
493
  (error instanceof StudioSaveHttpError ||
429
494
  error instanceof DomEditPersistUnsafeValueError) &&
@@ -231,7 +231,9 @@ describe("onReorderShadow source filter", () => {
231
231
  setRightPanelTab: vi.fn(),
232
232
  showToast: vi.fn(),
233
233
  refreshPreviewDocumentVersion: vi.fn(),
234
- queueDomEditSave: vi.fn(async (save: () => Promise<void>) => save()),
234
+ queueDomEditSave: vi.fn(async <T,>(save: () => Promise<T>) => save()) as <T>(
235
+ save: () => Promise<T>,
236
+ ) => Promise<T>,
235
237
  readProjectFile,
236
238
  writeProjectFile: vi.fn(async () => {}),
237
239
  updateEditingFileContent: vi.fn(),
@@ -43,7 +43,7 @@ export interface UseDomEditSessionParams {
43
43
  setRightPanelTab: (tab: RightPanelTab) => void;
44
44
  showToast: (message: string, tone?: "error" | "info") => void;
45
45
  refreshPreviewDocumentVersion: () => void;
46
- queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
46
+ queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
47
47
  readProjectFile: (path: string) => Promise<string>;
48
48
  writeProjectFile: (path: string, content: string) => Promise<void>;
49
49
  updateEditingFileContent: (path: string, content: string) => void;