@hyperframes/studio 0.7.105 → 0.7.107

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-Jx07OQgw.js → hyperframes-player-BLrfwq5o.js} +1 -1
  2. package/dist/assets/{index-CryZ0PGi.js → index-BA9yhzfR.js} +1 -1
  3. package/dist/assets/{index-Dqi2GmOp.js → index-BSBk5srs.js} +1 -1
  4. package/dist/assets/{index-DuxKGsHZ.js → index-dF-CmLZu.js} +218 -218
  5. package/dist/assets/index-zQ4JFwwB.css +1 -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 +1 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5335 -3954
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -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/editor/DomEditOverlay.tsx +36 -18
  19. package/src/components/editor/DomEditSelectionChrome.test.tsx +119 -31
  20. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  21. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  22. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  23. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  24. package/src/components/editor/domEditInlineText.test.ts +86 -0
  25. package/src/components/editor/domEditInlineText.ts +99 -0
  26. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  27. package/src/components/editor/domEditOverlayCrop.test.ts +136 -2
  28. package/src/components/editor/domEditOverlayCrop.ts +99 -15
  29. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  30. package/src/components/editor/domEditOverlayGeometry.ts +13 -10
  31. package/src/components/editor/domEditOverlayTransform.ts +88 -0
  32. package/src/components/editor/domEditing.ts +1 -0
  33. package/src/components/editor/domEditingLayers.ts +7 -6
  34. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  35. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  36. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  37. package/src/components/editor/useDomEditNudge.ts +2 -2
  38. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  39. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  40. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  41. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  42. package/src/components/feedback/feedbackTrigger.ts +269 -0
  43. package/src/components/feedback/projectProvenance.test.ts +120 -0
  44. package/src/components/feedback/projectProvenance.ts +83 -0
  45. package/src/components/renders/useRenderQueue.ts +62 -4
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  47. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  48. package/src/contexts/DomEditContext.tsx +4 -0
  49. package/src/hooks/domEditPersistFailure.ts +0 -7
  50. package/src/hooks/useAppHotkeys.ts +4 -4
  51. package/src/hooks/useDomEditCommits.test.tsx +103 -21
  52. package/src/hooks/useDomEditCommits.ts +2 -0
  53. package/src/hooks/useDomEditSession.ts +2 -0
  54. package/src/hooks/useDomEditTextCommits.ts +110 -11
  55. package/src/hooks/useFileTree.ts +8 -3
  56. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  57. package/src/hooks/useInlineTextEdit.ts +320 -0
  58. package/src/player/lib/playbackShortcuts.ts +5 -4
  59. package/src/telemetry/breadcrumbs.test.ts +68 -0
  60. package/src/telemetry/breadcrumbs.ts +70 -0
  61. package/src/telemetry/client.ts +5 -0
  62. package/src/telemetry/events.ts +84 -3
  63. package/src/utils/sourcePatcher.ts +5 -1
  64. package/src/utils/studioHelpers.ts +2 -5
  65. package/src/utils/timelineDiscovery.ts +3 -24
  66. package/src/utils/typingTarget.test.ts +54 -0
  67. package/src/utils/typingTarget.ts +43 -0
  68. package/dist/assets/index-tBPidglp.css +0 -1
  69. package/dist/chunk-OBAG3GWK.js.map +0 -1
  70. package/src/components/StudioFeedbackBar.tsx +0 -217
  71. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -24,6 +24,7 @@ export interface DomEditActionsValue extends Pick<
24
24
  | "handleDomRotationCommit"
25
25
  | "handleDomManualEditsReset"
26
26
  | "handleDomTextCommit"
27
+ | "handleDomRichTextCommit"
27
28
  | "handleDomTextFieldStyleCommit"
28
29
  | "handleDomAddTextField"
29
30
  | "handleDomRemoveTextField"
@@ -149,6 +150,7 @@ export function DomEditProvider({
149
150
  handleDomManualEditsReset,
150
151
 
151
152
  handleDomTextCommit,
153
+ handleDomRichTextCommit,
152
154
  handleDomTextFieldStyleCommit,
153
155
  handleDomAddTextField,
154
156
  handleDomRemoveTextField,
@@ -236,6 +238,7 @@ export function DomEditProvider({
236
238
  handleDomRotationCommit,
237
239
  handleDomManualEditsReset,
238
240
  handleDomTextCommit,
241
+ handleDomRichTextCommit,
239
242
  handleDomTextFieldStyleCommit,
240
243
  handleDomAddTextField,
241
244
  handleDomRemoveTextField,
@@ -305,6 +308,7 @@ export function DomEditProvider({
305
308
  handleDomRotationCommit,
306
309
  handleDomManualEditsReset,
307
310
  handleDomTextCommit,
311
+ handleDomRichTextCommit,
308
312
  handleDomTextFieldStyleCommit,
309
313
  handleDomAddTextField,
310
314
  handleDomRemoveTextField,
@@ -19,13 +19,6 @@ export class DomEditPersistUnsafeValueError extends Error {
19
19
  }
20
20
  }
21
21
 
22
- export class DomEditPersistUnsupportedTextStructureError extends Error {
23
- constructor() {
24
- super("Couldn't save this text structure change");
25
- this.name = "DomEditPersistUnsupportedTextStructureError";
26
- }
27
- }
28
-
29
22
  export type DomEditPersistFailureSelection = Pick<
30
23
  DomEditSelection,
31
24
  "label" | "hfId" | "id" | "selector" | "selectorIndex" | "sourceFile"
@@ -4,7 +4,7 @@ import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
- import { isEditableTarget } from "../utils/timelineDiscovery";
7
+ import { isTypingTarget } from "../utils/typingTarget";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -188,14 +188,14 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
188
188
  return true;
189
189
  }
190
190
 
191
- if (key === "g" && !event.altKey && !isEditableTarget(event.target)) {
191
+ if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
192
192
  event.preventDefault();
193
193
  if (event.shiftKey) cb.onUngroupSelection?.();
194
194
  else cb.onGroupSelection?.();
195
195
  return true;
196
196
  }
197
197
 
198
- if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
198
+ if (!event.shiftKey && !event.altKey && !isTypingTarget(event.target)) {
199
199
  if (key === "c") {
200
200
  if (cb.handleCopy()) {
201
201
  event.preventDefault();
@@ -454,7 +454,7 @@ export function useAppHotkeys({
454
454
  dispatchModifierKey(event, key, cb);
455
455
  return;
456
456
  }
457
- if (!isEditableTarget(event.target)) dispatchPlainKey(event, key, cb);
457
+ if (!isTypingTarget(event.target)) dispatchPlainKey(event, key, cb);
458
458
  }, []);
459
459
 
460
460
  // eslint-disable-next-line no-restricted-syntax
@@ -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 () => {
@@ -378,6 +378,7 @@ export function useDomEditCommits({
378
378
  handleDomHtmlAttributeCommit,
379
379
  handleDomAttributesCommit,
380
380
  handleDomTextCommit,
381
+ handleDomRichTextCommit,
381
382
  commitDomTextFields,
382
383
  handleDomTextFieldStyleCommit,
383
384
  handleDomAddTextField,
@@ -441,6 +442,7 @@ export function useDomEditCommits({
441
442
  handleDomHtmlAttributeCommit,
442
443
  handleDomAttributesCommit,
443
444
  handleDomTextCommit,
445
+ handleDomRichTextCommit,
444
446
  commitDomTextFields,
445
447
  handleDomTextFieldStyleCommit,
446
448
  handleDomAddTextField,
@@ -231,6 +231,7 @@ export function useDomEditSession({
231
231
  handleDomHtmlAttributeCommit,
232
232
  handleDomAttributesCommit,
233
233
  handleDomTextCommit,
234
+ handleDomRichTextCommit,
234
235
  handleDomTextFieldStyleCommit,
235
236
  handleDomAddTextField,
236
237
  handleDomRemoveTextField,
@@ -501,6 +502,7 @@ export function useDomEditSession({
501
502
  handleDomRotationCommit: handleGsapAwareRotationCommit,
502
503
  handleDomManualEditsReset,
503
504
  handleDomTextCommit,
505
+ handleDomRichTextCommit,
504
506
  handleDomTextFieldStyleCommit,
505
507
  handleDomAddTextField,
506
508
  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,
@@ -269,9 +294,6 @@ export function useDomEditTextCommits({
269
294
  }
270
295
  },
271
296
  persist: async () => {
272
- if (textCommit.usesSerializedTextFields && textCommit.childOperations === null) {
273
- throw new DomEditPersistUnsupportedTextStructureError();
274
- }
275
297
  await persistDomEditOperations(domEditSelection, textCommit.operations, {
276
298
  label: "Edit text",
277
299
  skipRefresh: true,
@@ -307,6 +329,85 @@ export function useDomEditTextCommits({
307
329
  ],
308
330
  );
309
331
 
332
+ /**
333
+ * Persist an element's own markup, for a text edit that styled part of it.
334
+ *
335
+ * Its own commit rather than a mode of the one above: that one plans a change
336
+ * to the text-field model, which escapes markup on the way out and refuses a
337
+ * change in child structure, and both of those are correct for the design
338
+ * panel. Styling a run of characters is neither of those things. The element
339
+ * already holds what the user typed, so there is nothing to apply, only
340
+ * something to save and something to put back if saving fails.
341
+ */
342
+ const handleDomRichTextCommit = useCallback(
343
+ async ({ element, html, previousHtml }: InlineTextEditCommit) => {
344
+ if (!domEditSelection) return;
345
+ // The same gate that let the edit open, not the design panel's.
346
+ //
347
+ // The panel's rule is about its text fields, and it has none for an
348
+ // element whose text contains a line break: a `<span>` holding `<br>`s
349
+ // is not a leaf, so nothing inside is a field and the element reports no
350
+ // editable text at all. Editing in place does not use fields — it
351
+ // rewrites the element's own markup — so refusing on that rule refused
352
+ // elements the caret had just been opened in, and every colour the user
353
+ // chose was dropped on the way out with nothing said about it.
354
+ if (!canCommitInlineTextSelection(domEditSelection, element)) return;
355
+ const iframe = previewIframeRef.current;
356
+ const doc = iframe?.contentDocument;
357
+ // A preview reload replaces the document. Never resolve this commit onto
358
+ // the replacement node: it did not own the edit or its rollback snapshot.
359
+ if (!ownsCurrentPreviewElement(domEditSelection, element, doc)) return;
360
+ const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
361
+ const operations = [buildDomEditRichTextPatchOperation(html)];
362
+ let appliedHtml = "";
363
+
364
+ await runDomEditCommit({
365
+ capture: () => {},
366
+ apply: () => {
367
+ // Idempotent: the caret put this there. Assigned anyway so a commit
368
+ // raised from anywhere but the element itself still lands.
369
+ element.innerHTML = html;
370
+ appliedHtml = element.innerHTML;
371
+ },
372
+ persist: async () => {
373
+ await persistDomEditOperations(domEditSelection, operations, {
374
+ label: "Edit text",
375
+ skipRefresh: true,
376
+ shouldSave: isLatestTextCommit,
377
+ });
378
+ },
379
+ shouldRevert: () => isLatestTextCommit(),
380
+ revert: () => {
381
+ // An external actor that changed the live node while the request was
382
+ // in flight owns its new value; only roll back the value we submitted.
383
+ if (element.isConnected && element.innerHTML === appliedHtml) {
384
+ element.innerHTML = previousHtml;
385
+ }
386
+ },
387
+ onError: (error) =>
388
+ reportDomEditPersistFailure(domEditSelection, operations, error, showToast),
389
+ shouldResync: isLatestTextCommit,
390
+ resync: () =>
391
+ resyncDomTextSelectionFromPreview(
392
+ doc,
393
+ domEditSelection,
394
+ activeCompPath,
395
+ buildDomSelectionFromTarget,
396
+ applyDomSelection,
397
+ ),
398
+ });
399
+ },
400
+ [
401
+ activeCompPath,
402
+ applyDomSelection,
403
+ buildDomSelectionFromTarget,
404
+ domEditSelection,
405
+ persistDomEditOperations,
406
+ previewIframeRef,
407
+ showToast,
408
+ ],
409
+ );
410
+
310
411
  const commitDomTextFields = useCallback(
311
412
  async (
312
413
  selection: DomEditSelection,
@@ -342,9 +443,6 @@ export function useDomEditTextCommits({
342
443
  }
343
444
  },
344
445
  persist: async () => {
345
- if (textCommit.usesSerializedTextFields && textCommit.childOperations === null) {
346
- throw new DomEditPersistUnsupportedTextStructureError();
347
- }
348
446
  await persistDomEditOperations(selection, textCommit.operations, {
349
447
  label: "Edit text",
350
448
  skipRefresh: true,
@@ -477,6 +575,7 @@ export function useDomEditTextCommits({
477
575
  handleDomHtmlAttributeCommit,
478
576
  handleDomAttributesCommit,
479
577
  handleDomTextCommit,
578
+ handleDomRichTextCommit,
480
579
  commitDomTextFields,
481
580
  handleDomTextFieldStyleCommit,
482
581
  handleDomAddTextField,
@@ -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);