@hyperframes/studio 0.7.106 → 0.7.108

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 (175) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +86 -3
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +24325 -17831
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/StudioRightPanel.tsx +18 -1
  19. package/src/components/TimelineToolbar.test.tsx +46 -0
  20. package/src/components/TimelineToolbar.tsx +17 -0
  21. package/src/components/editor/DomEditOverlay.tsx +36 -18
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  23. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  24. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  25. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  26. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  27. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  28. package/src/components/editor/PropertyPanel.tsx +7 -23
  29. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  30. package/src/components/editor/audioFxSummary.test.ts +75 -0
  31. package/src/components/editor/audioFxSummary.ts +36 -0
  32. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  33. package/src/components/editor/audioFxTelemetry.ts +230 -0
  34. package/src/components/editor/domEditInlineText.test.ts +86 -0
  35. package/src/components/editor/domEditInlineText.ts +99 -0
  36. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  37. package/src/components/editor/domEditing.ts +1 -0
  38. package/src/components/editor/domEditingLayers.ts +7 -6
  39. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  40. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  41. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  42. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  43. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  44. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  45. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  46. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  47. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  48. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  49. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  50. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  51. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  52. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  53. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  54. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  55. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  57. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  58. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  59. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  60. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  61. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  62. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  63. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  64. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  65. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  66. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  67. package/src/components/editor/propertyPanelTypes.ts +5 -0
  68. package/src/components/editor/useDomEditNudge.ts +2 -2
  69. package/src/components/editor/useFxAudition.ts +95 -0
  70. package/src/components/editor/useFxCarve.ts +0 -0
  71. package/src/components/editor/useFxChainObserved.ts +83 -0
  72. package/src/components/editor/useFxLevelling.ts +235 -0
  73. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  74. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  75. package/src/components/editor/useVolumeAutomation.ts +54 -0
  76. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  77. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  78. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  79. package/src/components/feedback/feedbackTrigger.ts +269 -0
  80. package/src/components/feedback/projectProvenance.test.ts +120 -0
  81. package/src/components/feedback/projectProvenance.ts +83 -0
  82. package/src/components/renders/useRenderQueue.ts +62 -4
  83. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  84. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  85. package/src/contexts/DomEditContext.tsx +15 -0
  86. package/src/hooks/domEditPersistFailure.ts +0 -7
  87. package/src/hooks/useAppHotkeys.test.ts +198 -0
  88. package/src/hooks/useAppHotkeys.ts +31 -4
  89. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  90. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  91. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  92. package/src/hooks/useDomEditCommits.test.tsx +141 -21
  93. package/src/hooks/useDomEditCommits.ts +4 -0
  94. package/src/hooks/useDomEditSession.ts +4 -0
  95. package/src/hooks/useDomEditTextCommits.ts +112 -11
  96. package/src/hooks/useElementLifecycleOps.ts +3 -0
  97. package/src/hooks/useFileTree.ts +8 -3
  98. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  99. package/src/hooks/useInlineTextEdit.ts +320 -0
  100. package/src/hooks/useLivePlayheadTime.ts +49 -0
  101. package/src/hooks/usePreviewPersistence.ts +7 -0
  102. package/src/hooks/useTimelineEditing.ts +3 -0
  103. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  104. package/src/player/components/AutomationValueInput.tsx +51 -0
  105. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  106. package/src/player/components/Timeline.test.ts +84 -0
  107. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  108. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  109. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  110. package/src/player/components/TimelineLanes.test.tsx +24 -5
  111. package/src/player/components/TimelineLanes.tsx +142 -154
  112. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  113. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  114. package/src/player/components/TimelineTrackRow.tsx +8 -1
  115. package/src/player/components/automationClipboard.test.ts +94 -0
  116. package/src/player/components/automationClipboard.ts +136 -0
  117. package/src/player/components/automationGestureKeys.test.ts +49 -0
  118. package/src/player/components/automationGestureKeys.ts +49 -0
  119. package/src/player/components/automationLaneData.test.ts +270 -0
  120. package/src/player/components/automationLaneData.ts +258 -0
  121. package/src/player/components/automationLaneDragMath.ts +194 -0
  122. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  123. package/src/player/components/automationLaneGeometry.ts +287 -0
  124. package/src/player/components/automationLaneHeight.ts +15 -0
  125. package/src/player/components/automationLanePointer.ts +20 -0
  126. package/src/player/components/automationLaneSelection.test.ts +183 -0
  127. package/src/player/components/automationLaneSelection.ts +135 -0
  128. package/src/player/components/automationShapes.test.ts +82 -0
  129. package/src/player/components/automationShapes.ts +70 -0
  130. package/src/player/components/automationSimplify.test.ts +48 -0
  131. package/src/player/components/automationSimplify.ts +51 -0
  132. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  133. package/src/player/components/timelineLaneProps.ts +19 -0
  134. package/src/player/components/timelineLayout.ts +11 -5
  135. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  136. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  137. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  138. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  139. package/src/player/components/useAutomationLanes.ts +170 -0
  140. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  141. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  142. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  143. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  145. package/src/player/lib/automationStoreSync.test.ts +83 -0
  146. package/src/player/lib/automationStoreSync.ts +56 -0
  147. package/src/player/lib/playbackShortcuts.ts +5 -4
  148. package/src/player/lib/timelineDOM.test.ts +33 -0
  149. package/src/player/lib/timelineDOM.ts +12 -0
  150. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  151. package/src/player/store/automationSelectionSlice.ts +54 -0
  152. package/src/player/store/playerStore.test.ts +15 -0
  153. package/src/player/store/playerStore.ts +54 -65
  154. package/src/player/store/timelineElement.ts +78 -0
  155. package/src/styles/studio.css +89 -0
  156. package/src/telemetry/agentRuntime.test.ts +45 -0
  157. package/src/telemetry/agentRuntime.ts +62 -0
  158. package/src/telemetry/breadcrumbs.test.ts +68 -0
  159. package/src/telemetry/breadcrumbs.ts +70 -0
  160. package/src/telemetry/client.ts +5 -0
  161. package/src/telemetry/events.ts +84 -3
  162. package/src/telemetry/system.ts +8 -0
  163. package/src/utils/sourcePatcher.ts +5 -1
  164. package/src/utils/studioFileHistory.test.ts +49 -0
  165. package/src/utils/studioFileHistory.ts +17 -1
  166. package/src/utils/studioHelpers.ts +2 -5
  167. package/src/utils/studioTelemetry.ts +16 -10
  168. package/src/utils/timelineDiscovery.ts +3 -24
  169. package/src/utils/typingTarget.test.ts +54 -0
  170. package/src/utils/typingTarget.ts +43 -0
  171. package/dist/assets/index-DerI0ikN.js +0 -428
  172. package/dist/assets/index-tBPidglp.css +0 -1
  173. package/dist/chunk-OBAG3GWK.js.map +0 -1
  174. package/src/components/StudioFeedbackBar.tsx +0 -217
  175. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -117,14 +117,6 @@ function stubPatchFetch(
117
117
  return fetchMock;
118
118
  }
119
119
 
120
- function stubUnexpectedPersistFetch() {
121
- const fetchMock = vi.fn(async (): Promise<Response> => {
122
- throw new Error("persist should not run");
123
- });
124
- vi.stubGlobal("fetch", fetchMock);
125
- return fetchMock;
126
- }
127
-
128
120
  async function flushAsyncWork(): Promise<void> {
129
121
  for (let i = 0; i < 8; i += 1) {
130
122
  await Promise.resolve();
@@ -812,14 +804,26 @@ function renderStyleCommitWithFetch(fetchHandler: FetchHandler) {
812
804
  };
813
805
  }
814
806
 
815
- async function expectRejectedTextStructureEdit(
807
+ /**
808
+ * Adding or removing a text layer, which no per-child operation can express.
809
+ *
810
+ * Both used to be refused outright — the panel offered the buttons and neither
811
+ * could ever save — so this asserts the opposite of what it used to: one
812
+ * `rich-text` operation carrying the element's new markup, and no complaint.
813
+ */
814
+ async function expectPersistedTextStructureEdit(
816
815
  commit: (hook: ReturnType<typeof useDomEditCommits>) => Promise<unknown>,
816
+ expectedMarkup: (markup: string) => void,
817
817
  ): Promise<void> {
818
- const fetchMock = stubUnexpectedPersistFetch();
818
+ const fetchMock = stubPatchFetch({
819
+ ok: true,
820
+ changed: true,
821
+ matched: true,
822
+ content: '<div data-hf-id="hf-card"><span>First</span></div>',
823
+ });
819
824
  const { iframe, element } = createPreviewElement(
820
825
  '<div data-hf-id="hf-card"><span>First</span><span>Second</span></div>',
821
826
  );
822
- const originalInnerHtml = element.innerHTML;
823
827
  const selection = createSelection(element, {
824
828
  textFields: [
825
829
  textField({ key: "first", value: "First", source: "child" }),
@@ -833,18 +837,82 @@ async function expectRejectedTextStructureEdit(
833
837
  await commit(rendered.hook);
834
838
  });
835
839
 
836
- expect(fetchMock).not.toHaveBeenCalled();
837
- expect(rendered.showToast).toHaveBeenCalledWith(
838
- expect.stringContaining("text structure change"),
839
- "error",
840
+ const patchPost = fetchMock.mock.calls.find((call) =>
841
+ requestUrl(call[0]).includes("/file-mutations/patch-element/"),
840
842
  );
841
- expect(element.innerHTML).toBe(originalInnerHtml);
842
- expect(rendered.recordEdit).not.toHaveBeenCalled();
843
+ expect(patchPost).toBeDefined();
844
+ const body = JSON.parse(String(patchPost?.[1]?.body)) as {
845
+ operations: Array<{ type: string; value?: string }>;
846
+ };
847
+ expect(body.operations).toHaveLength(1);
848
+ expect(body.operations[0]?.type).toBe("rich-text");
849
+ expectedMarkup(body.operations[0]?.value ?? "");
850
+ expect(rendered.showToast).not.toHaveBeenCalled();
843
851
  } finally {
844
852
  rendered.cleanup();
845
853
  }
846
854
  }
847
855
 
856
+ describe("useDomEditCommits rich-text persist handling", () => {
857
+ beforeEach(() => {
858
+ ensureCssEscape();
859
+ vi.clearAllMocks();
860
+ vi.unstubAllGlobals();
861
+ document.body.replaceChildren();
862
+ });
863
+
864
+ it("restores the session snapshot when rich-text persistence fails", async () => {
865
+ stubPatchFetch({ ok: true, changed: false, matched: false });
866
+ const previousHtml = '<span style="color: red">Before</span>';
867
+ const html = '<span style="color: blue">After</span>';
868
+ const { iframe, element } = createPreviewElement(`<div data-hf-id="hf-card">${html}</div>`);
869
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
870
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
871
+
872
+ try {
873
+ await act(async () => {
874
+ await rendered.hook.handleDomRichTextCommit({ element, html, previousHtml });
875
+ });
876
+
877
+ expect(element.innerHTML).toBe(previousHtml);
878
+ expect(rendered.showToast).toHaveBeenCalledWith(
879
+ expect.stringMatching(/Couldn't save "Hero title"/),
880
+ "error",
881
+ );
882
+ } finally {
883
+ warnSpy.mockRestore();
884
+ rendered.cleanup();
885
+ }
886
+ });
887
+
888
+ it("does not retarget a commit onto a replacement preview node", async () => {
889
+ const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
890
+ const html = '<span style="color: blue">After</span>';
891
+ const { iframe, element } = createPreviewElement(`<div data-hf-id="hf-card">${html}</div>`);
892
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
893
+ const replacement = document.createElement("div");
894
+ replacement.dataset.hfId = "hf-card";
895
+ replacement.innerHTML = "Reloaded elsewhere";
896
+ element.replaceWith(replacement);
897
+
898
+ try {
899
+ await act(async () => {
900
+ await rendered.hook.handleDomRichTextCommit({
901
+ element,
902
+ html,
903
+ previousHtml: "Before",
904
+ });
905
+ });
906
+
907
+ expect(replacement.innerHTML).toBe("Reloaded elsewhere");
908
+ expect(fetchMock).not.toHaveBeenCalled();
909
+ expect(rendered.showToast).not.toHaveBeenCalled();
910
+ } finally {
911
+ rendered.cleanup();
912
+ }
913
+ });
914
+ });
915
+
848
916
  describe("useDomEditCommits style persist handling", () => {
849
917
  beforeEach(() => {
850
918
  ensureCssEscape();
@@ -1146,12 +1214,26 @@ describe("useDomEditCommits style persist handling", () => {
1146
1214
  }
1147
1215
  });
1148
1216
 
1149
- it("refuses added child text fields without persisting serialized markup", async () => {
1150
- await expectRejectedTextStructureEdit((hook) => hook.handleDomAddTextField("first"));
1217
+ it("persists an added child text field as the element's new markup", async () => {
1218
+ await expectPersistedTextStructureEdit(
1219
+ (hook) => hook.handleDomAddTextField("first"),
1220
+ (markup) => {
1221
+ expect(markup).toContain("First");
1222
+ expect(markup).toContain("Second");
1223
+ // The layer that was added, between the two that were there.
1224
+ expect(markup.match(/<span/g) ?? []).toHaveLength(3);
1225
+ },
1226
+ );
1151
1227
  });
1152
1228
 
1153
- it("refuses removed child text fields without persisting serialized markup", async () => {
1154
- await expectRejectedTextStructureEdit((hook) => hook.handleDomRemoveTextField("first"));
1229
+ it("persists a removed child text field as the element's new markup", async () => {
1230
+ await expectPersistedTextStructureEdit(
1231
+ (hook) => hook.handleDomRemoveTextField("first"),
1232
+ (markup) => {
1233
+ expect(markup).not.toContain("First");
1234
+ expect(markup).toContain("Second");
1235
+ },
1236
+ );
1155
1237
  });
1156
1238
 
1157
1239
  it("keeps single self text commits on the text-content path", async () => {
@@ -1256,6 +1338,44 @@ describe("useDomEditCommits attribute persist handling", () => {
1256
1338
  }
1257
1339
  });
1258
1340
 
1341
+ it("applies a preview-only write without persisting it", async () => {
1342
+ // What a drag needs from every pointermove: the preview and the audio graph
1343
+ // follow, the file does not. Persisting each move filled the undo stack with
1344
+ // fragments of one gesture — and because those writes race, a follow-up's
1345
+ // "before" often was not the previous entry's "after", so history refused to
1346
+ // coalesce them and undo took back a few milliseconds of the drag.
1347
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
1348
+ const { iframe, element } = createPreviewElement();
1349
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
1350
+
1351
+ try {
1352
+ await act(async () => {
1353
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7", undefined, {
1354
+ previewOnly: true,
1355
+ });
1356
+ });
1357
+ expect(element.getAttribute("data-volume")).toBe("0.7");
1358
+ expect(fetchSpy).not.toHaveBeenCalled();
1359
+ } finally {
1360
+ rendered.cleanup();
1361
+ }
1362
+ });
1363
+
1364
+ it("still persists a live write that does not ask to be preview-only", async () => {
1365
+ const fetchSpy = stubPatchFetch({ ok: true, changed: true, matched: true });
1366
+ const { iframe, element } = createPreviewElement();
1367
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
1368
+
1369
+ try {
1370
+ await act(async () => {
1371
+ await rendered.hook.handleDomAttributeLiveCommit("volume", "0.7");
1372
+ });
1373
+ expect(fetchSpy).toHaveBeenCalled();
1374
+ } finally {
1375
+ rendered.cleanup();
1376
+ }
1377
+ });
1378
+
1259
1379
  it("keeps a data-attribute commit on success", async () => {
1260
1380
  stubPatchFetch({
1261
1381
  ok: true,
@@ -375,9 +375,11 @@ export function useDomEditCommits({
375
375
  handleDomStyleCommit,
376
376
  handleDomAttributeCommit,
377
377
  handleDomAttributeLiveCommit,
378
+ handleDomAttributeQuietCommit,
378
379
  handleDomHtmlAttributeCommit,
379
380
  handleDomAttributesCommit,
380
381
  handleDomTextCommit,
382
+ handleDomRichTextCommit,
381
383
  commitDomTextFields,
382
384
  handleDomTextFieldStyleCommit,
383
385
  handleDomAddTextField,
@@ -438,9 +440,11 @@ export function useDomEditCommits({
438
440
  handleDomStyleCommit,
439
441
  handleDomAttributeCommit,
440
442
  handleDomAttributeLiveCommit,
443
+ handleDomAttributeQuietCommit,
441
444
  handleDomHtmlAttributeCommit,
442
445
  handleDomAttributesCommit,
443
446
  handleDomTextCommit,
447
+ handleDomRichTextCommit,
444
448
  commitDomTextFields,
445
449
  handleDomTextFieldStyleCommit,
446
450
  handleDomAddTextField,
@@ -228,9 +228,11 @@ export function useDomEditSession({
228
228
  handleDomStyleCommit,
229
229
  handleDomAttributeCommit,
230
230
  handleDomAttributeLiveCommit,
231
+ handleDomAttributeQuietCommit,
231
232
  handleDomHtmlAttributeCommit,
232
233
  handleDomAttributesCommit,
233
234
  handleDomTextCommit,
235
+ handleDomRichTextCommit,
234
236
  handleDomTextFieldStyleCommit,
235
237
  handleDomAddTextField,
236
238
  handleDomRemoveTextField,
@@ -492,6 +494,7 @@ export function useDomEditSession({
492
494
  handleDomStyleCommit,
493
495
  handleDomAttributeCommit,
494
496
  handleDomAttributeLiveCommit,
497
+ handleDomAttributeQuietCommit,
495
498
  handleDomHtmlAttributeCommit,
496
499
  handleDomAttributesCommit,
497
500
  handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
@@ -501,6 +504,7 @@ export function useDomEditSession({
501
504
  handleDomRotationCommit: handleGsapAwareRotationCommit,
502
505
  handleDomManualEditsReset,
503
506
  handleDomTextCommit,
507
+ handleDomRichTextCommit,
504
508
  handleDomTextFieldStyleCommit,
505
509
  handleDomAddTextField,
506
510
  handleDomRemoveTextField,
@@ -11,6 +11,7 @@ import {
11
11
  ensureImportedFontFace,
12
12
  } from "../utils/studioFontHelpers";
13
13
  import {
14
+ buildDomEditRichTextPatchOperation,
14
15
  buildDomEditStylePatchOperation,
15
16
  buildDomEditTextPatchOperation,
16
17
  findElementForSelection,
@@ -23,17 +24,16 @@ import {
23
24
  } from "../components/editor/domEditing";
24
25
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
25
26
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
27
+ import { canEditElementTextInline } from "../components/editor/domEditInlineText";
26
28
  import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps";
27
- import {
28
- DomEditPersistUnsupportedTextStructureError,
29
- reportDomEditPersistFailure,
30
- } from "./domEditPersistFailure";
29
+ import { reportDomEditPersistFailure } from "./domEditPersistFailure";
31
30
  import {
32
31
  bumpDomEditCommitMapVersion,
33
32
  bumpDomEditCommitVersion,
34
33
  runDomEditCommit,
35
34
  } from "./domEditCommitRunner";
36
35
  import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
36
+ import type { InlineTextEditCommit } from "./useInlineTextEdit";
37
37
 
38
38
  // ── Types ──
39
39
 
@@ -62,6 +62,20 @@ interface DomTextCommitPlan {
62
62
  operations: PatchOperation[];
63
63
  }
64
64
 
65
+ function canCommitInlineTextSelection(selection: DomEditSelection, element: HTMLElement): boolean {
66
+ if (selection.isCompositionHost || selection.isInsideLockedComposition) return false;
67
+ return canEditElementTextInline(element);
68
+ }
69
+
70
+ function ownsCurrentPreviewElement(
71
+ selection: DomEditSelection,
72
+ element: HTMLElement,
73
+ document: Document | null | undefined,
74
+ ): document is Document {
75
+ if (!document || !element.isConnected) return false;
76
+ return element === selection.element && element.ownerDocument === document;
77
+ }
78
+
65
79
  function buildDomStyleCommitOperations(
66
80
  property: string,
67
81
  value: string,
@@ -102,9 +116,20 @@ function planDomTextCommit(
102
116
  const childOperations = usesSerializedTextFields
103
117
  ? buildTextFieldChildOperations(originalTextFields, nextTextFields)
104
118
  : null;
119
+ // Per-child operations when the layers still line up one-for-one, and the
120
+ // element's whole markup when they do not.
121
+ //
122
+ // `buildTextFieldChildOperations` can only address children that already
123
+ // exist, so it returns null for any change in how many there are — which is
124
+ // every delete and every add. That used to end here with "Couldn't save this
125
+ // text structure change": the panel offered a remove button and an Add text
126
+ // field row, and neither could ever save. A structure change has one honest
127
+ // operation, which is to write the structure.
105
128
  const operations =
106
129
  childOperations ??
107
- (usesSerializedTextFields ? [] : [buildDomEditTextPatchOperation(nextContent)]);
130
+ (usesSerializedTextFields
131
+ ? [buildDomEditRichTextPatchOperation(nextContent)]
132
+ : [buildDomEditTextPatchOperation(nextContent)]);
108
133
 
109
134
  return {
110
135
  usesSerializedTextFields,
@@ -148,6 +173,7 @@ export function useDomEditTextCommits({
148
173
  const {
149
174
  handleDomAttributeCommit,
150
175
  handleDomAttributeLiveCommit,
176
+ handleDomAttributeQuietCommit,
151
177
  handleDomHtmlAttributeCommit,
152
178
  handleDomAttributesCommit,
153
179
  } = useDomEditAttributeCommits({
@@ -269,9 +295,6 @@ export function useDomEditTextCommits({
269
295
  }
270
296
  },
271
297
  persist: async () => {
272
- if (textCommit.usesSerializedTextFields && textCommit.childOperations === null) {
273
- throw new DomEditPersistUnsupportedTextStructureError();
274
- }
275
298
  await persistDomEditOperations(domEditSelection, textCommit.operations, {
276
299
  label: "Edit text",
277
300
  skipRefresh: true,
@@ -307,6 +330,85 @@ export function useDomEditTextCommits({
307
330
  ],
308
331
  );
309
332
 
333
+ /**
334
+ * Persist an element's own markup, for a text edit that styled part of it.
335
+ *
336
+ * Its own commit rather than a mode of the one above: that one plans a change
337
+ * to the text-field model, which escapes markup on the way out and refuses a
338
+ * change in child structure, and both of those are correct for the design
339
+ * panel. Styling a run of characters is neither of those things. The element
340
+ * already holds what the user typed, so there is nothing to apply, only
341
+ * something to save and something to put back if saving fails.
342
+ */
343
+ const handleDomRichTextCommit = useCallback(
344
+ async ({ element, html, previousHtml }: InlineTextEditCommit) => {
345
+ if (!domEditSelection) return;
346
+ // The same gate that let the edit open, not the design panel's.
347
+ //
348
+ // The panel's rule is about its text fields, and it has none for an
349
+ // element whose text contains a line break: a `<span>` holding `<br>`s
350
+ // is not a leaf, so nothing inside is a field and the element reports no
351
+ // editable text at all. Editing in place does not use fields — it
352
+ // rewrites the element's own markup — so refusing on that rule refused
353
+ // elements the caret had just been opened in, and every colour the user
354
+ // chose was dropped on the way out with nothing said about it.
355
+ if (!canCommitInlineTextSelection(domEditSelection, element)) return;
356
+ const iframe = previewIframeRef.current;
357
+ const doc = iframe?.contentDocument;
358
+ // A preview reload replaces the document. Never resolve this commit onto
359
+ // the replacement node: it did not own the edit or its rollback snapshot.
360
+ if (!ownsCurrentPreviewElement(domEditSelection, element, doc)) return;
361
+ const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
362
+ const operations = [buildDomEditRichTextPatchOperation(html)];
363
+ let appliedHtml = "";
364
+
365
+ await runDomEditCommit({
366
+ capture: () => {},
367
+ apply: () => {
368
+ // Idempotent: the caret put this there. Assigned anyway so a commit
369
+ // raised from anywhere but the element itself still lands.
370
+ element.innerHTML = html;
371
+ appliedHtml = element.innerHTML;
372
+ },
373
+ persist: async () => {
374
+ await persistDomEditOperations(domEditSelection, operations, {
375
+ label: "Edit text",
376
+ skipRefresh: true,
377
+ shouldSave: isLatestTextCommit,
378
+ });
379
+ },
380
+ shouldRevert: () => isLatestTextCommit(),
381
+ revert: () => {
382
+ // An external actor that changed the live node while the request was
383
+ // in flight owns its new value; only roll back the value we submitted.
384
+ if (element.isConnected && element.innerHTML === appliedHtml) {
385
+ element.innerHTML = previousHtml;
386
+ }
387
+ },
388
+ onError: (error) =>
389
+ reportDomEditPersistFailure(domEditSelection, operations, error, showToast),
390
+ shouldResync: isLatestTextCommit,
391
+ resync: () =>
392
+ resyncDomTextSelectionFromPreview(
393
+ doc,
394
+ domEditSelection,
395
+ activeCompPath,
396
+ buildDomSelectionFromTarget,
397
+ applyDomSelection,
398
+ ),
399
+ });
400
+ },
401
+ [
402
+ activeCompPath,
403
+ applyDomSelection,
404
+ buildDomSelectionFromTarget,
405
+ domEditSelection,
406
+ persistDomEditOperations,
407
+ previewIframeRef,
408
+ showToast,
409
+ ],
410
+ );
411
+
310
412
  const commitDomTextFields = useCallback(
311
413
  async (
312
414
  selection: DomEditSelection,
@@ -342,9 +444,6 @@ export function useDomEditTextCommits({
342
444
  }
343
445
  },
344
446
  persist: async () => {
345
- if (textCommit.usesSerializedTextFields && textCommit.childOperations === null) {
346
- throw new DomEditPersistUnsupportedTextStructureError();
347
- }
348
447
  await persistDomEditOperations(selection, textCommit.operations, {
349
448
  label: "Edit text",
350
449
  skipRefresh: true,
@@ -474,9 +573,11 @@ export function useDomEditTextCommits({
474
573
  handleDomStyleCommit,
475
574
  handleDomAttributeCommit,
476
575
  handleDomAttributeLiveCommit,
576
+ handleDomAttributeQuietCommit,
477
577
  handleDomHtmlAttributeCommit,
478
578
  handleDomAttributesCommit,
479
579
  handleDomTextCommit,
580
+ handleDomRichTextCommit,
480
581
  commitDomTextFields,
481
582
  handleDomTextFieldStyleCommit,
482
583
  handleDomAddTextField,
@@ -142,6 +142,9 @@ export function useElementLifecycleOps({
142
142
  kind: "timeline",
143
143
  files: { [targetPath]: patchedContent },
144
144
  readFile: async () => originalContent,
145
+ // remove-element already wrote the removal, so disk holds THAT — not
146
+ // the content read at the top. Undo still goes back to the original.
147
+ diskContent: { [targetPath]: patchedContent },
145
148
  writeFile: writeProjectFile,
146
149
  recordEdit: editHistory.recordEdit,
147
150
  });
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useEffect, useMemo } from "react";
2
2
  import { FONT_EXT } from "../utils/mediaTypes";
3
3
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
4
+ import { captureProjectProvenance } from "../components/feedback/projectProvenance";
4
5
 
5
6
  interface UseFileTreeOptions {
6
7
  projectId: string | null;
@@ -24,9 +25,13 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
24
25
  fetch(`/api/projects/${projectId}`)
25
26
  .then((r) => r.json())
26
27
  .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
27
- if (!cancelled && data.files) setFileTree(data.files);
28
- if (!cancelled && data.compositions) setCompositionPaths(data.compositions);
29
- if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
28
+ if (cancelled) return;
29
+ if (data.files) setFileTree(data.files);
30
+ if (data.compositions) setCompositionPaths(data.compositions);
31
+ setProjectDir(typeof data.dir === "string" ? data.dir : null);
32
+ // Snapshot how this project was made, while the listing is in hand and
33
+ // the app is still alive. A crash later has no other way to learn it.
34
+ void captureProjectProvenance(projectId, data.files ?? [], data.compositions ?? []);
30
35
  })
31
36
  .catch(() => {
32
37
  if (!cancelled) setProjectDir(null);