@hyperframes/studio 0.8.106 → 0.8.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.
- package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
- package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +367 -11
- package/dist/index.html +1 -1
- package/dist/index.js +1064 -714
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +41 -23
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +7 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +63 -0
- package/src/utils/studioPendingEdits.ts +16 -7
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
|
@@ -7,7 +7,6 @@ import type { MutableRefObject } from "react";
|
|
|
7
7
|
import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
|
|
8
8
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
9
9
|
import { usePlayerStore } from "../player";
|
|
10
|
-
import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
11
10
|
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
|
12
11
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
13
12
|
import type { CutoverResult } from "../utils/sdkCutover";
|
|
@@ -1318,101 +1317,57 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1318
1317
|
}
|
|
1319
1318
|
});
|
|
1320
1319
|
|
|
1321
|
-
it("
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
content:
|
|
1328
|
-
|
|
1329
|
-
},
|
|
1330
|
-
'<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>',
|
|
1331
|
-
);
|
|
1332
|
-
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1333
|
-
const { iframe, element } = createPreviewElement();
|
|
1334
|
-
const selection = createSelection(element, {
|
|
1335
|
-
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
1336
|
-
});
|
|
1337
|
-
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
1338
|
-
writeProjectFile: async () => {
|
|
1339
|
-
throw new StudioSaveHttpError("Failed to save index.html (500)", 500);
|
|
1340
|
-
},
|
|
1341
|
-
});
|
|
1342
|
-
|
|
1343
|
-
try {
|
|
1344
|
-
await act(async () => {
|
|
1345
|
-
await rendered.hook.commitDomTextFields(
|
|
1346
|
-
selection,
|
|
1347
|
-
[textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
1348
|
-
{
|
|
1349
|
-
importedFont: {
|
|
1350
|
-
family: "Imported",
|
|
1351
|
-
path: "fonts/Imported.woff2",
|
|
1352
|
-
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
1353
|
-
},
|
|
1354
|
-
},
|
|
1355
|
-
);
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
// The base patch already landed server-side before the font-face write
|
|
1359
|
-
// failed, so this is recorded as a completed edit (not reverted/re-toasted
|
|
1360
|
-
// as a full failure) — only the font embellishment is reported as lost.
|
|
1361
|
-
expect(rendered.showToast).toHaveBeenCalledTimes(1);
|
|
1362
|
-
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
1363
|
-
expect.stringContaining("Saved, but couldn't finish updating index.html"),
|
|
1364
|
-
"error",
|
|
1365
|
-
);
|
|
1366
|
-
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
1367
|
-
expect.stringContaining("Failed to save index.html (500)"),
|
|
1368
|
-
"error",
|
|
1320
|
+
it.each(["style", "text"])(
|
|
1321
|
+
"a %s edit with an imported font is one patch request that carries its @font-face",
|
|
1322
|
+
async (kind) => {
|
|
1323
|
+
const patchedContent =
|
|
1324
|
+
'<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
|
|
1325
|
+
const fetchMock = stubPatchFetch(
|
|
1326
|
+
{ ok: true, changed: true, matched: true, content: patchedContent },
|
|
1327
|
+
patchedContent,
|
|
1369
1328
|
);
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1329
|
+
const { iframe, element } = createPreviewElement();
|
|
1330
|
+
const selection = createSelection(element, {
|
|
1331
|
+
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
1332
|
+
});
|
|
1333
|
+
const importedFont = {
|
|
1334
|
+
family: "Imported",
|
|
1335
|
+
path: "fonts/Imported.woff2",
|
|
1336
|
+
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
1337
|
+
};
|
|
1338
|
+
const writeProjectFile = vi.fn(async () => {});
|
|
1339
|
+
const rendered = renderDomEditCommits(selection, iframe, {
|
|
1340
|
+
writeProjectFile,
|
|
1341
|
+
importedFontAssets: [importedFont],
|
|
1342
|
+
});
|
|
1376
1343
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
});
|
|
1388
|
-
const writeProjectFile = vi.fn(async () => {});
|
|
1389
|
-
const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
|
|
1344
|
+
try {
|
|
1345
|
+
await act(async () => {
|
|
1346
|
+
if (kind === "style") await rendered.hook.handleDomStyleCommit("font-family", "Imported");
|
|
1347
|
+
else
|
|
1348
|
+
await rendered.hook.commitDomTextFields(
|
|
1349
|
+
selection,
|
|
1350
|
+
[textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
1351
|
+
{ importedFont },
|
|
1352
|
+
);
|
|
1353
|
+
});
|
|
1390
1354
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
await rendered.hook.commitDomTextFields(
|
|
1394
|
-
selection,
|
|
1395
|
-
[textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
1396
|
-
{
|
|
1397
|
-
importedFont: {
|
|
1398
|
-
family: "Imported",
|
|
1399
|
-
path: "fonts/Imported.woff2",
|
|
1400
|
-
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
1401
|
-
},
|
|
1402
|
-
},
|
|
1355
|
+
const writes = fetchMock.mock.calls.filter(
|
|
1356
|
+
([, init]) => init?.method === "POST" || init?.method === "PUT",
|
|
1403
1357
|
);
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1358
|
+
expect(writes).toHaveLength(1);
|
|
1359
|
+
expect(requestUrl(writes[0]![0])).toContain("/file-mutations/patch-element/index.html");
|
|
1360
|
+
const body = JSON.parse(String(writes[0]![1]?.body)) as { fontFaceCss?: string };
|
|
1361
|
+
expect(body.fontFaceCss).toMatch(
|
|
1362
|
+
/^@font-face \{ font-family: "Imported"; src: url\("fonts\/Imported.woff2"\)/,
|
|
1363
|
+
);
|
|
1364
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1365
|
+
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
1366
|
+
} finally {
|
|
1367
|
+
rendered.cleanup();
|
|
1368
|
+
}
|
|
1369
|
+
},
|
|
1370
|
+
);
|
|
1416
1371
|
|
|
1417
1372
|
it("keeps a rejected patch request (HTTP error) to one toast", async () => {
|
|
1418
1373
|
const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
|
|
@@ -1736,3 +1691,128 @@ describe("useDomEditCommits attribute persist handling", () => {
|
|
|
1736
1691
|
}
|
|
1737
1692
|
});
|
|
1738
1693
|
});
|
|
1694
|
+
|
|
1695
|
+
describe("useDomEditCommits attribute batch commit", () => {
|
|
1696
|
+
beforeEach(() => {
|
|
1697
|
+
ensureCssEscape();
|
|
1698
|
+
vi.clearAllMocks();
|
|
1699
|
+
vi.unstubAllGlobals();
|
|
1700
|
+
document.body.replaceChildren();
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
const original = '<video data-hf-id="hf-card" data-has-audio="true"></video>';
|
|
1704
|
+
const patched = '<video data-hf-id="hf-card" muted></video>';
|
|
1705
|
+
const mute = [
|
|
1706
|
+
{ type: "html-attribute" as const, property: "muted", value: "true" },
|
|
1707
|
+
{ type: "attribute" as const, property: "has-audio", value: null },
|
|
1708
|
+
];
|
|
1709
|
+
|
|
1710
|
+
it("sends mixed html and data attribute ops in ONE patch and ONE undo entry", async () => {
|
|
1711
|
+
const fetchMock = stubPatchFetch(
|
|
1712
|
+
{
|
|
1713
|
+
ok: true,
|
|
1714
|
+
changed: true,
|
|
1715
|
+
matched: true,
|
|
1716
|
+
content: patched,
|
|
1717
|
+
path: "index.html",
|
|
1718
|
+
version: "v2",
|
|
1719
|
+
},
|
|
1720
|
+
original,
|
|
1721
|
+
);
|
|
1722
|
+
const { iframe, element } = createPreviewElement(original);
|
|
1723
|
+
const rendered = renderDomEditCommits(createSelection(element, { tagName: "video" }), iframe);
|
|
1724
|
+
|
|
1725
|
+
try {
|
|
1726
|
+
let ok = false;
|
|
1727
|
+
await act(async () => {
|
|
1728
|
+
ok = await rendered.hook.handleDomAttributeBatchCommit(
|
|
1729
|
+
createSelection(element, { tagName: "video" }),
|
|
1730
|
+
mute,
|
|
1731
|
+
{ label: "Edit muted" },
|
|
1732
|
+
);
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
const patches = fetchMock.mock.calls.filter(([input]) =>
|
|
1736
|
+
requestUrl(input).includes("/file-mutations/patch-element/"),
|
|
1737
|
+
);
|
|
1738
|
+
expect(ok).toBe(true);
|
|
1739
|
+
expect(patches).toHaveLength(1);
|
|
1740
|
+
const body = JSON.parse(String(patches[0]?.[1]?.body));
|
|
1741
|
+
expect(body.operations).toEqual(mute);
|
|
1742
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
1743
|
+
expect(rendered.recordEdit).toHaveBeenCalledWith(
|
|
1744
|
+
expect.objectContaining({
|
|
1745
|
+
files: { "index.html": { before: original, after: patched } },
|
|
1746
|
+
}),
|
|
1747
|
+
);
|
|
1748
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
1749
|
+
expect(element.hasAttribute("muted")).toBe(true);
|
|
1750
|
+
expect(element.hasAttribute("data-has-audio")).toBe(false);
|
|
1751
|
+
} finally {
|
|
1752
|
+
rendered.cleanup();
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
it("folds a document-level prepareContent into the same single undo entry", async () => {
|
|
1757
|
+
stubPatchFetch(
|
|
1758
|
+
{
|
|
1759
|
+
ok: true,
|
|
1760
|
+
changed: true,
|
|
1761
|
+
matched: true,
|
|
1762
|
+
content: patched,
|
|
1763
|
+
path: "index.html",
|
|
1764
|
+
version: "v2",
|
|
1765
|
+
},
|
|
1766
|
+
original,
|
|
1767
|
+
);
|
|
1768
|
+
const writes: string[] = [];
|
|
1769
|
+
const { iframe, element } = createPreviewElement(original);
|
|
1770
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe, {
|
|
1771
|
+
writeProjectFile: async (_path, content) => {
|
|
1772
|
+
writes.push(content);
|
|
1773
|
+
},
|
|
1774
|
+
});
|
|
1775
|
+
const withSibling = `<audio id="a"></audio>${patched}`;
|
|
1776
|
+
|
|
1777
|
+
try {
|
|
1778
|
+
await act(async () => {
|
|
1779
|
+
await rendered.hook.handleDomAttributeBatchCommit(createSelection(element), mute, {
|
|
1780
|
+
label: "Remove background",
|
|
1781
|
+
prepareContent: () => withSibling,
|
|
1782
|
+
});
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
expect(writes).toEqual([withSibling]);
|
|
1786
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
1787
|
+
expect(rendered.recordEdit).toHaveBeenCalledWith(
|
|
1788
|
+
expect.objectContaining({
|
|
1789
|
+
label: "Remove background",
|
|
1790
|
+
files: { "index.html": { before: original, after: withSibling } },
|
|
1791
|
+
}),
|
|
1792
|
+
);
|
|
1793
|
+
} finally {
|
|
1794
|
+
rendered.cleanup();
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("reverts every op in the batch and reports failure when the patch rejects", async () => {
|
|
1799
|
+
stubPatchFetch(new Error("network down"), original);
|
|
1800
|
+
const { iframe, element } = createPreviewElement(original);
|
|
1801
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
1802
|
+
|
|
1803
|
+
try {
|
|
1804
|
+
let ok = true;
|
|
1805
|
+
await act(async () => {
|
|
1806
|
+
ok = await rendered.hook.handleDomAttributeBatchCommit(createSelection(element), mute, {
|
|
1807
|
+
label: "Edit muted",
|
|
1808
|
+
});
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
expect(ok).toBe(false);
|
|
1812
|
+
expect(element.hasAttribute("muted")).toBe(false);
|
|
1813
|
+
expect(element.getAttribute("data-has-audio")).toBe("true");
|
|
1814
|
+
} finally {
|
|
1815
|
+
rendered.cleanup();
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
});
|
|
@@ -239,6 +239,7 @@ export function useDomEditCommits({
|
|
|
239
239
|
handleDomAttributeQuietCommit,
|
|
240
240
|
handleDomHtmlAttributeCommit,
|
|
241
241
|
handleDomAttributesCommit,
|
|
242
|
+
handleDomAttributeBatchCommit,
|
|
242
243
|
handleDomTextCommit,
|
|
243
244
|
handleDomTextCommitForSelection,
|
|
244
245
|
handleDomRichTextCommit,
|
|
@@ -305,6 +306,7 @@ export function useDomEditCommits({
|
|
|
305
306
|
handleDomAttributeQuietCommit,
|
|
306
307
|
handleDomHtmlAttributeCommit,
|
|
307
308
|
handleDomAttributesCommit,
|
|
309
|
+
handleDomAttributeBatchCommit,
|
|
308
310
|
handleDomTextCommit,
|
|
309
311
|
handleDomTextCommitForSelection,
|
|
310
312
|
handleDomRichTextCommit,
|
|
@@ -198,25 +198,23 @@ export async function postPatchElement(
|
|
|
198
198
|
return (await response.json()) as PatchElementResponse;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/** Writes `prepare`'s embellishment over the server's patch; returns what the file ends up holding. */
|
|
202
201
|
export async function writePreparedContent(
|
|
203
202
|
targetPath: string,
|
|
204
203
|
patchedContent: string,
|
|
205
204
|
prepare: (html: string, sourceFile: string) => string,
|
|
206
205
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
207
206
|
showToast: ShowToast,
|
|
208
|
-
): Promise<string> {
|
|
207
|
+
): Promise<{ content: string; failed: boolean }> {
|
|
209
208
|
const preparedContent = prepare(patchedContent, targetPath);
|
|
210
|
-
if (preparedContent === patchedContent) return patchedContent;
|
|
209
|
+
if (preparedContent === patchedContent) return { content: patchedContent, failed: false };
|
|
211
210
|
try {
|
|
212
211
|
await writeProjectFile(targetPath, preparedContent, patchedContent);
|
|
213
|
-
return preparedContent;
|
|
212
|
+
return { content: preparedContent, failed: false };
|
|
214
213
|
} catch (error) {
|
|
215
|
-
// The patch already landed on disk; keep it rather than revert a committed change.
|
|
216
214
|
showToast(
|
|
217
215
|
`Saved, but couldn't finish updating ${targetPath}: ${getErrorDetail(error)}`,
|
|
218
216
|
"error",
|
|
219
217
|
);
|
|
220
|
-
return patchedContent;
|
|
218
|
+
return { content: patchedContent, failed: true };
|
|
221
219
|
}
|
|
222
220
|
}
|
|
@@ -6,6 +6,7 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
|
6
6
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
7
7
|
import {
|
|
8
8
|
DomEditPersistUnsafeValueError,
|
|
9
|
+
DomEditPersistPreparedWriteError,
|
|
9
10
|
DomEditPersistUnresolvableError,
|
|
10
11
|
warnDomEditPersistNoOp,
|
|
11
12
|
} from "./domEditPersistFailure";
|
|
@@ -14,6 +15,8 @@ import {
|
|
|
14
15
|
postPatchElement,
|
|
15
16
|
writePreparedContent,
|
|
16
17
|
} from "./useDomEditCommitsHelpers";
|
|
18
|
+
import { importedFontFaceCssFor } from "../utils/studioFontHelpers";
|
|
19
|
+
import { countStudioManualEditSave } from "../components/editor/manualEditsDom";
|
|
17
20
|
import type { CutoverResult } from "../utils/sdkCutover";
|
|
18
21
|
import { reseekPreviewRuntime } from "./timelineTrackVisibility";
|
|
19
22
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
@@ -77,7 +80,8 @@ export function useDomEditPersist({
|
|
|
77
80
|
|
|
78
81
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
79
82
|
const completePersistence = <T>(result: T, changed: boolean): T => {
|
|
80
|
-
if (options?.skipRefresh && changed
|
|
83
|
+
if (options?.skipRefresh && changed && !options.deferRender)
|
|
84
|
+
reseekPreviewRuntime(previewIframeRef.current);
|
|
81
85
|
return result;
|
|
82
86
|
};
|
|
83
87
|
|
|
@@ -94,7 +98,12 @@ export function useDomEditPersist({
|
|
|
94
98
|
// after it let invalid numeric values bypass the guard whenever the
|
|
95
99
|
// cutover flag was on.
|
|
96
100
|
const patchTarget = buildDomEditPatchTarget(selection);
|
|
97
|
-
const
|
|
101
|
+
const font = options?.importedFont;
|
|
102
|
+
const patchBody = {
|
|
103
|
+
target: patchTarget,
|
|
104
|
+
operations,
|
|
105
|
+
...(font ? { fontFaceCss: importedFontFaceCssFor(font, targetPath) } : {}),
|
|
106
|
+
};
|
|
98
107
|
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
|
99
108
|
if (unsafeFields.length > 0) {
|
|
100
109
|
const fields = formatUnsafeFieldList(unsafeFields);
|
|
@@ -104,11 +113,9 @@ export function useDomEditPersist({
|
|
|
104
113
|
});
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
// The SDK joins the file queue itself and re-reads there; this read is only its fallback.
|
|
111
|
-
if (onTrySdkPersist && !options?.prepareContent) {
|
|
116
|
+
// An imported font or prepareContent takes the server patch; the SDK serializes only the patched
|
|
117
|
+
// DOM. The SDK re-reads in the file queue; this read is its fallback.
|
|
118
|
+
if (onTrySdkPersist && !font && !options?.prepareContent) {
|
|
112
119
|
const originalContent = await readTarget();
|
|
113
120
|
if (originalContent === null) return;
|
|
114
121
|
const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
|
@@ -133,6 +140,7 @@ export function useDomEditPersist({
|
|
|
133
140
|
coalesceMs: options?.coalesceMs,
|
|
134
141
|
};
|
|
135
142
|
const prepare = options?.prepareContent;
|
|
143
|
+
let preparedWriteFailed = false;
|
|
136
144
|
// Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
|
|
137
145
|
const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
|
|
138
146
|
const originalContent = await readTarget();
|
|
@@ -142,7 +150,7 @@ export function useDomEditPersist({
|
|
|
142
150
|
|
|
143
151
|
const patchedContent =
|
|
144
152
|
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
145
|
-
const
|
|
153
|
+
const prepared = prepare
|
|
146
154
|
? await writePreparedContent(
|
|
147
155
|
targetPath,
|
|
148
156
|
patchedContent,
|
|
@@ -150,7 +158,9 @@ export function useDomEditPersist({
|
|
|
150
158
|
writeProjectFile,
|
|
151
159
|
showToast,
|
|
152
160
|
)
|
|
153
|
-
: patchedContent;
|
|
161
|
+
: { content: patchedContent, failed: false };
|
|
162
|
+
preparedWriteFailed = prepared.failed;
|
|
163
|
+
const finalContent = prepared.content;
|
|
154
164
|
|
|
155
165
|
await editHistory.recordEdit({
|
|
156
166
|
...history,
|
|
@@ -188,7 +198,7 @@ export function useDomEditPersist({
|
|
|
188
198
|
if (!options?.skipRefresh) {
|
|
189
199
|
reloadPreview();
|
|
190
200
|
}
|
|
191
|
-
|
|
201
|
+
const outcome = completePersistence(
|
|
192
202
|
finalContent === patchedContent &&
|
|
193
203
|
typeof patchData.path === "string" &&
|
|
194
204
|
typeof patchData.version === "string"
|
|
@@ -196,6 +206,8 @@ export function useDomEditPersist({
|
|
|
196
206
|
: undefined,
|
|
197
207
|
true,
|
|
198
208
|
);
|
|
209
|
+
if (preparedWriteFailed) throw new DomEditPersistPreparedWriteError(targetPath);
|
|
210
|
+
return outcome;
|
|
199
211
|
},
|
|
200
212
|
[
|
|
201
213
|
activeCompPath,
|
|
@@ -214,9 +226,12 @@ export function useDomEditPersist({
|
|
|
214
226
|
(selection, operations, options) => {
|
|
215
227
|
const expectedProjectId = projectIdRef.current;
|
|
216
228
|
if (!expectedProjectId) return Promise.reject(new Error("No active project"));
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
229
|
+
// Counted, so a preview reload requested before this save settles is not shown over it.
|
|
230
|
+
const save = () =>
|
|
231
|
+
queueDomEditSave(() =>
|
|
232
|
+
performPersistDomEditOperations(selection, operations, options, expectedProjectId),
|
|
233
|
+
);
|
|
234
|
+
return selection.element ? countStudioManualEditSave(selection.element, save) : save();
|
|
220
235
|
},
|
|
221
236
|
[performPersistDomEditOperations, projectIdRef, queueDomEditSave],
|
|
222
237
|
);
|
|
@@ -17,6 +17,7 @@ type PositionPatchOptions = {
|
|
|
17
17
|
coalesceKey: string;
|
|
18
18
|
coalesceMs?: number;
|
|
19
19
|
skipRefresh?: boolean;
|
|
20
|
+
deferRender?: boolean;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export function useDomEditPositionPatchCommit({
|
|
@@ -31,6 +32,7 @@ export function useDomEditPositionPatchCommit({
|
|
|
31
32
|
coalesceKey: options.coalesceKey,
|
|
32
33
|
coalesceMs: options.coalesceMs,
|
|
33
34
|
skipRefresh: options.skipRefresh ?? true,
|
|
35
|
+
deferRender: options.deferRender,
|
|
34
36
|
})
|
|
35
37
|
.then(() => undefined)
|
|
36
38
|
.catch((error) => {
|
|
@@ -170,7 +170,6 @@ export function useDomEditSession({
|
|
|
170
170
|
refreshDomEditSelectionFromPreview,
|
|
171
171
|
});
|
|
172
172
|
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
|
|
173
|
-
|
|
174
173
|
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
|
175
174
|
|
|
176
175
|
const {
|
|
@@ -202,6 +201,7 @@ export function useDomEditSession({
|
|
|
202
201
|
handleDomAttributeQuietCommit,
|
|
203
202
|
handleDomHtmlAttributeCommit,
|
|
204
203
|
handleDomAttributesCommit,
|
|
204
|
+
handleDomAttributeBatchCommit,
|
|
205
205
|
handleDomTextCommit,
|
|
206
206
|
handleDomTextCommitForSelection,
|
|
207
207
|
handleDomRichTextCommit,
|
|
@@ -288,7 +288,6 @@ export function useDomEditSession({
|
|
|
288
288
|
});
|
|
289
289
|
|
|
290
290
|
// ── Element groups (wrap selected elements in a data-hf-group div) ──
|
|
291
|
-
|
|
292
291
|
const { groupSelection, ungroupSelection } = useGroupCommits({
|
|
293
292
|
activeCompPath,
|
|
294
293
|
showToast,
|
|
@@ -363,7 +362,6 @@ export function useDomEditSession({
|
|
|
363
362
|
}, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
|
|
364
363
|
|
|
365
364
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
366
|
-
|
|
367
365
|
const {
|
|
368
366
|
onClickToSource,
|
|
369
367
|
selectedGsapAnimations,
|
|
@@ -496,6 +494,7 @@ export function useDomEditSession({
|
|
|
496
494
|
handleDomAttributeQuietCommit,
|
|
497
495
|
handleDomHtmlAttributeCommit,
|
|
498
496
|
handleDomAttributesCommit,
|
|
497
|
+
handleDomAttributeBatchCommit,
|
|
499
498
|
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
|
|
500
499
|
handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
|
|
501
500
|
handleDomZIndexReorderCommit,
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
|
|
3
|
-
import {
|
|
4
|
-
injectPreviewGoogleFont,
|
|
5
|
-
injectPreviewImportedFont,
|
|
6
|
-
ensureImportedFontFace,
|
|
7
|
-
} from "../utils/studioFontHelpers";
|
|
3
|
+
import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
|
|
8
4
|
import {
|
|
9
5
|
buildDomEditRichTextPatchOperation,
|
|
10
6
|
findElementForSelection,
|
|
@@ -99,6 +95,7 @@ export function useDomEditTextCommits({
|
|
|
99
95
|
handleDomAttributeQuietCommit,
|
|
100
96
|
handleDomHtmlAttributeCommit,
|
|
101
97
|
handleDomAttributesCommit,
|
|
98
|
+
handleDomAttributeBatchCommit,
|
|
102
99
|
} = useDomEditAttributeCommits({
|
|
103
100
|
activeCompPath,
|
|
104
101
|
previewIframeRef,
|
|
@@ -354,9 +351,7 @@ export function useDomEditTextCommits({
|
|
|
354
351
|
await persistDomEditOperations(selection, textCommit.operations, {
|
|
355
352
|
label: "Edit text",
|
|
356
353
|
skipRefresh: true,
|
|
357
|
-
|
|
358
|
-
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
359
|
-
: undefined,
|
|
354
|
+
importedFont: importedFont ?? undefined,
|
|
360
355
|
});
|
|
361
356
|
},
|
|
362
357
|
shouldRevert: () => isLatestTextCommit(),
|
|
@@ -485,6 +480,7 @@ export function useDomEditTextCommits({
|
|
|
485
480
|
handleDomAttributeQuietCommit,
|
|
486
481
|
handleDomHtmlAttributeCommit,
|
|
487
482
|
handleDomAttributesCommit,
|
|
483
|
+
handleDomAttributeBatchCommit,
|
|
488
484
|
handleDomTextCommit,
|
|
489
485
|
handleDomTextCommitForSelection,
|
|
490
486
|
handleDomRichTextCommit,
|
|
@@ -32,7 +32,13 @@ export interface UseDomGeometryCommitsParams {
|
|
|
32
32
|
commitPositionPatchToHtml: (
|
|
33
33
|
selection: DomEditSelection,
|
|
34
34
|
patches: PatchOperation[],
|
|
35
|
-
options: {
|
|
35
|
+
options: {
|
|
36
|
+
label: string;
|
|
37
|
+
coalesceKey: string;
|
|
38
|
+
coalesceMs?: number;
|
|
39
|
+
skipRefresh?: boolean;
|
|
40
|
+
deferRender?: boolean;
|
|
41
|
+
},
|
|
36
42
|
) => Promise<void>;
|
|
37
43
|
readOnlyPreview: boolean;
|
|
38
44
|
}
|
|
@@ -65,6 +71,7 @@ export function useDomGeometryCommits({
|
|
|
65
71
|
next: { width: number; height: number },
|
|
66
72
|
offset?: { x: number; y: number },
|
|
67
73
|
restore?: () => void,
|
|
74
|
+
undoKey?: string,
|
|
68
75
|
) => {
|
|
69
76
|
if (readOnlyPreview) return Promise.resolve();
|
|
70
77
|
const element = selection.element;
|
|
@@ -80,7 +87,9 @@ export function useDomGeometryCommits({
|
|
|
80
87
|
if (offset) patches.push(...writePlainMove(element, offset));
|
|
81
88
|
return commitPositionPatchToHtml(selection, patches, {
|
|
82
89
|
label: "Resize layer box",
|
|
83
|
-
|
|
90
|
+
...(undoKey
|
|
91
|
+
? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
|
|
92
|
+
: { coalesceKey: `box-size:${getDomEditTargetKey(selection)}` }),
|
|
84
93
|
}).catch((error) => {
|
|
85
94
|
restoreStudioBoxSize(element, beforeSize);
|
|
86
95
|
if (offset) restoreStudioPathOffset(element, beforeOffset);
|