@hyperframes/studio 0.8.99 → 0.8.101
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-Rml34FRW.js → hyperframes-player-CFJLrHRF.js} +1 -1
- package/dist/assets/{index-CVDArg3m.js → index-BBNVnnyH.js} +179 -179
- package/dist/assets/{index-D9YzDuBx.js → index-C33EDBL2.js} +1 -1
- package/dist/assets/{index-C_fi-jTe.js → index-CJOzbslX.js} +1 -1
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +91 -21
- package/dist/index.html +1 -1
- package/dist/index.js +826 -538
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
- package/src/components/editor/manualOffsetDrag.test.ts +70 -0
- package/src/components/editor/manualOffsetDrag.ts +50 -40
- package/src/components/editor/plainTranslate.test.ts +62 -0
- package/src/components/editor/plainTranslate.ts +85 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/hooks/appHotkeysDispatch.ts +1 -7
- package/src/hooks/elementOffsetStager.ts +33 -2
- package/src/hooks/gsapRuntimeBridge.ts +1 -17
- package/src/hooks/gsapRuntimeKeyframes.ts +35 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useDomEditCommits.test.tsx +12 -3
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
- package/src/hooks/useDomEditTextCommits.ts +35 -35
- package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +4 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/useGsapAwareEditing.test.tsx +46 -18
- package/src/hooks/useGsapAwareEditing.ts +11 -11
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +4 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- package/dist/chunk-U3EE4ACX.js.map +0 -1
|
@@ -7,10 +7,15 @@ import type { DomEditSelection } from "../components/editor/domEditing";
|
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import { useAppHotkeys } from "./useAppHotkeys";
|
|
9
9
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
10
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
|
+
|
|
12
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
10
13
|
|
|
11
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
15
|
|
|
13
16
|
const domDelete = vi.fn(async () => undefined);
|
|
17
|
+
const historyUndo = vi.fn(async () => ({ ok: false }));
|
|
18
|
+
const historyRedo = vi.fn(async () => ({ ok: false }));
|
|
14
19
|
let root: Root | null = null;
|
|
15
20
|
let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
|
|
16
21
|
|
|
@@ -35,8 +40,8 @@ function Harness() {
|
|
|
35
40
|
domEditSelectionRef: selectionRef,
|
|
36
41
|
clearDomSelectionRef: useRef<() => void>(() => undefined),
|
|
37
42
|
editHistory: {
|
|
38
|
-
undo:
|
|
39
|
-
redo:
|
|
43
|
+
undo: historyUndo,
|
|
44
|
+
redo: historyRedo,
|
|
40
45
|
state: { undo: [], redo: [] },
|
|
41
46
|
},
|
|
42
47
|
readOptionalProjectFile: vi.fn(async () => ""),
|
|
@@ -62,6 +67,9 @@ afterEach(() => {
|
|
|
62
67
|
document.body.innerHTML = "";
|
|
63
68
|
usePlayerStore.getState().reset();
|
|
64
69
|
domDelete.mockClear();
|
|
70
|
+
historyUndo.mockClear();
|
|
71
|
+
historyRedo.mockClear();
|
|
72
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
65
73
|
});
|
|
66
74
|
|
|
67
75
|
describe("preview iframe hotkey forwarding", () => {
|
|
@@ -87,4 +95,61 @@ describe("preview iframe hotkey forwarding", () => {
|
|
|
87
95
|
|
|
88
96
|
expect(domDelete).toHaveBeenCalledTimes(1);
|
|
89
97
|
});
|
|
98
|
+
|
|
99
|
+
function mountWithPreview(): Window & typeof globalThis {
|
|
100
|
+
root = mountReactHarness(<Harness />);
|
|
101
|
+
const iframe = document.createElement("iframe");
|
|
102
|
+
document.body.append(iframe);
|
|
103
|
+
act(() => sync?.(iframe));
|
|
104
|
+
const inner = iframe.contentWindow as (Window & typeof globalThis) | null;
|
|
105
|
+
if (!inner) throw new Error("expected an iframe window");
|
|
106
|
+
return inner;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function press(inner: Window & typeof globalThis, init: KeyboardEventInit) {
|
|
110
|
+
await act(async () => {
|
|
111
|
+
inner.document.body.dispatchEvent(
|
|
112
|
+
new inner.KeyboardEvent("keydown", { ...init, bubbles: true }),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it("takes one tracked undo step per Cmd+Z pressed inside the preview", async () => {
|
|
118
|
+
await press(mountWithPreview(), { key: "z", ctrlKey: true });
|
|
119
|
+
|
|
120
|
+
expect(historyUndo).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it.each([
|
|
126
|
+
["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }],
|
|
127
|
+
["Ctrl+Y", { key: "y", ctrlKey: true }],
|
|
128
|
+
])("takes one tracked redo step per %s pressed inside the preview", async (_, init) => {
|
|
129
|
+
await press(mountWithPreview(), init);
|
|
130
|
+
|
|
131
|
+
expect(historyRedo).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "redo" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("stops handling preview keys once the app unmounts", async () => {
|
|
137
|
+
root = mountReactHarness(<Harness />);
|
|
138
|
+
const iframe = document.createElement("iframe");
|
|
139
|
+
document.body.append(iframe);
|
|
140
|
+
const inner = iframe.contentWindow as (Window & typeof globalThis) | null;
|
|
141
|
+
if (!inner) throw new Error("expected an iframe window");
|
|
142
|
+
const add = vi.spyOn(inner, "addEventListener");
|
|
143
|
+
const remove = vi.spyOn(inner, "removeEventListener");
|
|
144
|
+
act(() => sync?.(iframe));
|
|
145
|
+
act(() => root?.unmount());
|
|
146
|
+
root = null;
|
|
147
|
+
|
|
148
|
+
await press(inner, { key: "z", ctrlKey: true });
|
|
149
|
+
|
|
150
|
+
expect(historyUndo).not.toHaveBeenCalled();
|
|
151
|
+
// happy-dom removes a listener whatever its capture flag; a browser does not, so pin the flag.
|
|
152
|
+
expect(add).toHaveBeenCalled();
|
|
153
|
+
expect(remove.mock.calls).toEqual(add.mock.calls);
|
|
154
|
+
});
|
|
90
155
|
});
|
|
@@ -8,13 +8,7 @@ import {
|
|
|
8
8
|
applyCaptionModelToIframe,
|
|
9
9
|
isCaptionPreviewVisible,
|
|
10
10
|
} from "../captions/components/CaptionOverlayUtils";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
type HotkeyCallbacks,
|
|
14
|
-
dispatchModifierKey,
|
|
15
|
-
dispatchPlainKey,
|
|
16
|
-
handleUndoRedoKey,
|
|
17
|
-
} from "./appHotkeysDispatch";
|
|
11
|
+
import { type HotkeyCallbacks, dispatchModifierKey, dispatchPlainKey } from "./appHotkeysDispatch";
|
|
18
12
|
import {
|
|
19
13
|
useEditHistoryActions,
|
|
20
14
|
type EditHistoryHandle,
|
|
@@ -36,9 +30,14 @@ function safeAddListener(t: EventTarget | null, type: string, h: EventListener,
|
|
|
36
30
|
/* cross-origin */
|
|
37
31
|
}
|
|
38
32
|
}
|
|
39
|
-
function safeRemoveListener(
|
|
33
|
+
function safeRemoveListener(
|
|
34
|
+
t: EventTarget | null,
|
|
35
|
+
type: string,
|
|
36
|
+
h: EventListener,
|
|
37
|
+
capture = false,
|
|
38
|
+
) {
|
|
40
39
|
try {
|
|
41
|
-
t?.removeEventListener(type, h);
|
|
40
|
+
t?.removeEventListener(type, h, capture);
|
|
42
41
|
} catch {
|
|
43
42
|
/* cross-origin */
|
|
44
43
|
}
|
|
@@ -86,6 +85,7 @@ interface UseAppHotkeysParams {
|
|
|
86
85
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
87
86
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
88
87
|
syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
|
|
88
|
+
showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
|
|
89
89
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
90
90
|
handleCopy: () => boolean;
|
|
91
91
|
handlePaste: () => Promise<void>;
|
|
@@ -124,6 +124,7 @@ export function useAppHotkeys({
|
|
|
124
124
|
writeProjectFile,
|
|
125
125
|
showToast,
|
|
126
126
|
syncHistoryPreviewAfterApply,
|
|
127
|
+
showHistoryRestoreNow,
|
|
127
128
|
waitForPendingDomEditSaves,
|
|
128
129
|
handleCopy,
|
|
129
130
|
handlePaste,
|
|
@@ -150,6 +151,7 @@ export function useAppHotkeys({
|
|
|
150
151
|
writeProjectFile,
|
|
151
152
|
showToast,
|
|
152
153
|
syncHistoryPreviewAfterApply,
|
|
154
|
+
showHistoryRestoreNow,
|
|
153
155
|
waitForPendingDomEditSaves,
|
|
154
156
|
onAfterUndoRedo,
|
|
155
157
|
activeCompPath,
|
|
@@ -229,52 +231,23 @@ export function useAppHotkeys({
|
|
|
229
231
|
|
|
230
232
|
// ── Preview iframe forwarding ──
|
|
231
233
|
|
|
232
|
-
const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
|
|
233
|
-
if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
|
|
234
|
-
handleUndoRedoKey(
|
|
235
|
-
event,
|
|
236
|
-
() => void cbRef.current.handleUndo(),
|
|
237
|
-
() => void cbRef.current.handleRedo(),
|
|
238
|
-
);
|
|
239
|
-
}, []);
|
|
240
|
-
|
|
241
234
|
/**
|
|
242
|
-
* Give the preview iframe the app's hotkeys
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* Must run on every iframe LOAD, not once when the element mounts: a reload
|
|
246
|
-
* keeps the same element (so no ref callback) and the same WindowProxy (so an
|
|
247
|
-
* identity check sees no change) while replacing the inner window that holds
|
|
248
|
-
* the listeners. Attaching once left Delete dead in the canvas after the first
|
|
249
|
-
* reload — press it with a selection and nothing happened, no toast, nothing
|
|
250
|
-
* to explain it — while undo/redo kept working because they re-attached here.
|
|
235
|
+
* Give the preview iframe the app's hotkeys: clicking the canvas puts focus in there.
|
|
236
|
+
* Runs on every iframe LOAD: a reload keeps the element and WindowProxy but replaces the
|
|
237
|
+
* inner window holding the listeners, which once left Delete dead after the first reload.
|
|
251
238
|
*/
|
|
252
239
|
const syncPreviewHotkeys = useCallback(
|
|
253
240
|
(iframe: HTMLIFrameElement | null) => {
|
|
254
241
|
previewHistoryCleanupRef.current?.();
|
|
255
242
|
previewHistoryCleanupRef.current = null;
|
|
256
243
|
const win = iframeContentWindow(iframe);
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
doc = iframe?.contentDocument ?? null;
|
|
260
|
-
} catch {
|
|
261
|
-
doc = null;
|
|
262
|
-
}
|
|
263
|
-
if (!win && !doc) return;
|
|
264
|
-
const handler = handleHistoryHotkey as EventListener;
|
|
244
|
+
if (!win) return;
|
|
265
245
|
const appHandler = handleAppKeyDown as EventListener;
|
|
266
|
-
|
|
267
|
-
// Window only: the history pair also listens on the document, and a
|
|
268
|
-
// capture listener on both would run the app handler twice per press.
|
|
246
|
+
// Window only: a capture listener on the document too would run it twice per press.
|
|
269
247
|
safeAddListener(win, "keydown", appHandler, true);
|
|
270
|
-
|
|
271
|
-
previewHistoryCleanupRef.current = () => {
|
|
272
|
-
safeRemoveListener(win, "keydown", handler);
|
|
273
|
-
safeRemoveListener(win, "keydown", appHandler);
|
|
274
|
-
doc?.removeEventListener("keydown", handleHistoryHotkey, true);
|
|
275
|
-
};
|
|
248
|
+
previewHistoryCleanupRef.current = () => safeRemoveListener(win, "keydown", appHandler, true);
|
|
276
249
|
},
|
|
277
|
-
[handleAppKeyDown
|
|
250
|
+
[handleAppKeyDown],
|
|
278
251
|
);
|
|
279
252
|
|
|
280
253
|
useEffect(
|
|
@@ -228,7 +228,9 @@ function renderDomEditCommits(
|
|
|
228
228
|
applyDomSelection: vi.fn(),
|
|
229
229
|
clearDomSelection: vi.fn(),
|
|
230
230
|
refreshDomEditSelectionFromPreview: vi.fn(),
|
|
231
|
-
buildDomSelectionFromTarget: vi.fn(async () =>
|
|
231
|
+
buildDomSelectionFromTarget: vi.fn(async (target: HTMLElement) =>
|
|
232
|
+
target === selection.element ? selection : null,
|
|
233
|
+
),
|
|
232
234
|
onTrySdkPersist: options.onTrySdkPersist,
|
|
233
235
|
readOnlyPreview: false,
|
|
234
236
|
});
|
|
@@ -893,7 +895,7 @@ describe("useDomEditCommits rich-text persist handling", () => {
|
|
|
893
895
|
}
|
|
894
896
|
});
|
|
895
897
|
|
|
896
|
-
it("does not retarget a commit onto a replacement preview node", async () => {
|
|
898
|
+
it("does not retarget a commit onto a replacement preview node, and says the text was not saved", async () => {
|
|
897
899
|
const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
|
|
898
900
|
const html = '<span style="color: blue">After</span>';
|
|
899
901
|
const { iframe, element } = createPreviewElement(`<div data-hf-id="hf-card">${html}</div>`);
|
|
@@ -902,6 +904,7 @@ describe("useDomEditCommits rich-text persist handling", () => {
|
|
|
902
904
|
replacement.dataset.hfId = "hf-card";
|
|
903
905
|
replacement.innerHTML = "Reloaded elsewhere";
|
|
904
906
|
element.replaceWith(replacement);
|
|
907
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
905
908
|
|
|
906
909
|
try {
|
|
907
910
|
await act(async () => {
|
|
@@ -914,8 +917,14 @@ describe("useDomEditCommits rich-text persist handling", () => {
|
|
|
914
917
|
|
|
915
918
|
expect(replacement.innerHTML).toBe("Reloaded elsewhere");
|
|
916
919
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
917
|
-
expect(rendered.showToast).
|
|
920
|
+
expect(rendered.showToast).toHaveBeenCalledWith(
|
|
921
|
+
expect.stringMatching(
|
|
922
|
+
/Couldn't save the text edit: the text's element is gone from the preview/,
|
|
923
|
+
),
|
|
924
|
+
"error",
|
|
925
|
+
);
|
|
918
926
|
} finally {
|
|
927
|
+
errorSpy.mockRestore();
|
|
919
928
|
rendered.cleanup();
|
|
920
929
|
}
|
|
921
930
|
});
|
|
@@ -7,6 +7,7 @@ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSave
|
|
|
7
7
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
8
8
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
9
9
|
import type { CommitDomEditPatchBatches } from "./domEditCommitTypes";
|
|
10
|
+
import type { ResolveDomSelectionOptions } from "./useDomSelectionTypes";
|
|
10
11
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
11
12
|
import { DomEditPersistUnsafeValueError } from "./domEditPersistFailure";
|
|
12
13
|
import { useDomEditPersist, type RecordEditInput } from "./useDomEditPersist";
|
|
@@ -46,7 +47,7 @@ export interface UseDomEditCommitsParams {
|
|
|
46
47
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
|
47
48
|
buildDomSelectionFromTarget: (
|
|
48
49
|
target: HTMLElement,
|
|
49
|
-
options?:
|
|
50
|
+
options?: ResolveDomSelectionOptions,
|
|
50
51
|
) => Promise<DomEditSelection | null>;
|
|
51
52
|
/** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
|
|
52
53
|
* path, whose session is already current) so a later SDK edit doesn't
|
|
@@ -125,13 +125,20 @@ afterEach(() => {
|
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
describe("useDomEditTextCommits", () => {
|
|
128
|
-
function richTextProbe(
|
|
129
|
-
|
|
128
|
+
function richTextProbe(
|
|
129
|
+
html = '<h1 id="t">Old</h1>',
|
|
130
|
+
overrides: (doc: Document) => Partial<UseDomEditTextCommitsParams> = () => ({}),
|
|
131
|
+
) {
|
|
132
|
+
const { iframe, element } = previewElement(html, "t");
|
|
130
133
|
const persist = vi.fn().mockResolvedValue(undefined);
|
|
134
|
+
const showToast = vi.fn();
|
|
131
135
|
const base = commitParams({
|
|
132
136
|
previewIframeRef: { current: iframe },
|
|
133
137
|
domEditSelection: selectionFor(element),
|
|
138
|
+
buildDomSelectionFromTarget: vi.fn(async (target: HTMLElement) => selectionFor(target)),
|
|
134
139
|
persistDomEditOperations: persist,
|
|
140
|
+
showToast,
|
|
141
|
+
...overrides(element.ownerDocument),
|
|
135
142
|
});
|
|
136
143
|
const captured: { hook: ReturnType<typeof useDomEditTextCommits> | null } = { hook: null };
|
|
137
144
|
function Probe({ readOnlyPreview }: { readOnlyPreview: boolean }) {
|
|
@@ -143,13 +150,25 @@ describe("useDomEditTextCommits", () => {
|
|
|
143
150
|
const save = captured.hook!.handleDomRichTextCommit;
|
|
144
151
|
const commit = { element, html: "New", previousHtml: "Old" };
|
|
145
152
|
element.innerHTML = "New";
|
|
146
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
root,
|
|
155
|
+
Probe,
|
|
156
|
+
persist,
|
|
157
|
+
showToast,
|
|
158
|
+
element,
|
|
159
|
+
save: () => act(async () => save(commit)),
|
|
160
|
+
};
|
|
147
161
|
}
|
|
148
162
|
|
|
149
|
-
it("saves in-place text while the preview is editable", async () => {
|
|
150
|
-
const
|
|
163
|
+
it("saves in-place text while the preview is editable, and refreshes the selection it edited", async () => {
|
|
164
|
+
const applyDomSelection = vi.fn();
|
|
165
|
+
const { persist, element, save } = richTextProbe(undefined, () => ({ applyDomSelection }));
|
|
151
166
|
await save();
|
|
152
167
|
expect(persist).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(applyDomSelection).toHaveBeenCalledWith(
|
|
169
|
+
expect.objectContaining({ element }),
|
|
170
|
+
expect.objectContaining({ preserveGroup: true }),
|
|
171
|
+
);
|
|
153
172
|
});
|
|
154
173
|
|
|
155
174
|
it("refuses in-place text once the preview turns read-only, through an earlier handler, and puts the old text back", async () => {
|
|
@@ -160,6 +179,44 @@ describe("useDomEditTextCommits", () => {
|
|
|
160
179
|
expect(element.innerHTML).toBe("Old");
|
|
161
180
|
});
|
|
162
181
|
|
|
182
|
+
it("saves the edited element, and leaves the selection alone, when the selection is another element or none", async () => {
|
|
183
|
+
for (const selected of ["card", null]) {
|
|
184
|
+
const applyDomSelection = vi.fn();
|
|
185
|
+
const { persist, element, save } = richTextProbe(
|
|
186
|
+
'<div id="card"><p id="t">Old</p></div>',
|
|
187
|
+
(doc) => ({
|
|
188
|
+
domEditSelection: selected ? selectionFor(doc.getElementById(selected)!) : null,
|
|
189
|
+
applyDomSelection,
|
|
190
|
+
buildDomSelectionFromTarget: vi.fn(async (target: HTMLElement) => ({
|
|
191
|
+
...selectionFor(target),
|
|
192
|
+
label: "Resolved from the edited element",
|
|
193
|
+
})),
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
await save();
|
|
197
|
+
expect(persist).toHaveBeenCalledTimes(1);
|
|
198
|
+
expect(persist.mock.calls[0]![0]).toMatchObject({
|
|
199
|
+
element,
|
|
200
|
+
label: "Resolved from the edited element",
|
|
201
|
+
});
|
|
202
|
+
expect(applyDomSelection).not.toHaveBeenCalled();
|
|
203
|
+
cleanup?.();
|
|
204
|
+
cleanup = null;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("says so and puts the old text back when the edited text cannot be saved", async () => {
|
|
209
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
210
|
+
const { persist, showToast, element, save } = richTextProbe(undefined, () => ({
|
|
211
|
+
buildDomSelectionFromTarget: vi.fn(async () => null),
|
|
212
|
+
}));
|
|
213
|
+
await save();
|
|
214
|
+
expect(persist).not.toHaveBeenCalled();
|
|
215
|
+
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("Couldn't save"), "error");
|
|
216
|
+
expect(error).toHaveBeenCalled();
|
|
217
|
+
expect(element.innerHTML).toBe("Old");
|
|
218
|
+
});
|
|
219
|
+
|
|
163
220
|
it("keeps concurrent text commit ownership isolated by target", async () => {
|
|
164
221
|
const { iframe, element: firstElement } = previewElement(
|
|
165
222
|
"<div id='first'>First</div><div id='second'>Second</div>",
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { commitDomStyles } from "./domStyleCommit";
|
|
30
30
|
import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
|
|
31
31
|
import type { InlineTextEditCommit } from "./useInlineTextEdit";
|
|
32
|
+
import type { ResolveDomSelectionOptions } from "./useDomSelectionTypes";
|
|
32
33
|
|
|
33
34
|
// ── Types ──
|
|
34
35
|
|
|
@@ -44,7 +45,7 @@ export interface UseDomEditTextCommitsParams {
|
|
|
44
45
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
|
45
46
|
buildDomSelectionFromTarget: (
|
|
46
47
|
target: HTMLElement,
|
|
47
|
-
options?:
|
|
48
|
+
options?: ResolveDomSelectionOptions,
|
|
48
49
|
) => Promise<DomEditSelection | null>;
|
|
49
50
|
persistDomEditOperations: PersistDomEditOperations;
|
|
50
51
|
resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
|
|
@@ -56,15 +57,6 @@ function canCommitInlineTextSelection(selection: DomEditSelection, element: HTML
|
|
|
56
57
|
return canEditElementTextInline(element);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function ownsCurrentPreviewElement(
|
|
60
|
-
selection: DomEditSelection,
|
|
61
|
-
element: HTMLElement,
|
|
62
|
-
document: Document | null | undefined,
|
|
63
|
-
): document is Document {
|
|
64
|
-
if (!document || !element.isConnected) return false;
|
|
65
|
-
return element === selection.element && element.ownerDocument === document;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
60
|
async function resyncDomTextSelectionFromPreview(
|
|
69
61
|
doc: Document | null | undefined,
|
|
70
62
|
selection: DomEditSelection,
|
|
@@ -96,6 +88,8 @@ export function useDomEditTextCommits({
|
|
|
96
88
|
}: UseDomEditTextCommitsParams) {
|
|
97
89
|
const latestReadOnlyPreviewRef = useRef(readOnlyPreview);
|
|
98
90
|
latestReadOnlyPreviewRef.current = readOnlyPreview;
|
|
91
|
+
const latestSelectionRef = useRef(domEditSelection);
|
|
92
|
+
latestSelectionRef.current = domEditSelection;
|
|
99
93
|
const domTextCommitVersionRef = useRef(new Map<string, symbol>());
|
|
100
94
|
const domStyleCommitVersionRef = useRef(new Map<string, symbol>());
|
|
101
95
|
|
|
@@ -238,29 +232,36 @@ export function useDomEditTextCommits({
|
|
|
238
232
|
*/
|
|
239
233
|
const handleDomRichTextCommit = useCallback(
|
|
240
234
|
async ({ element, html, previousHtml }: InlineTextEditCommit) => {
|
|
241
|
-
|
|
242
|
-
if (latestReadOnlyPreviewRef.current) {
|
|
235
|
+
const putBack = () => {
|
|
243
236
|
if (element.isConnected && element.innerHTML === html) element.innerHTML = previousHtml;
|
|
244
|
-
|
|
237
|
+
};
|
|
238
|
+
if (latestReadOnlyPreviewRef.current) return putBack();
|
|
239
|
+
const refuse = (reason: string) => {
|
|
240
|
+
console.error("[Studio] text edit not saved:", reason, element);
|
|
241
|
+
showToast(`Couldn't save the text edit: ${reason}`, "error");
|
|
242
|
+
putBack();
|
|
243
|
+
};
|
|
244
|
+
// The edited node, not the current selection: a press can open an edit on a child of what
|
|
245
|
+
// is selected, and a host can clear the selection before the edit closes.
|
|
246
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
247
|
+
if (!doc || !element.isConnected || element.ownerDocument !== doc) {
|
|
248
|
+
return refuse("the text's element is gone from the preview");
|
|
249
|
+
}
|
|
250
|
+
const selection = await buildDomSelectionFromTarget(element, {
|
|
251
|
+
exactTarget: true,
|
|
252
|
+
skipSourceProbe: true,
|
|
253
|
+
});
|
|
254
|
+
if (selection?.element !== element) {
|
|
255
|
+
return refuse("this text was not found in the composition's source");
|
|
256
|
+
}
|
|
257
|
+
// The same gate that let the edit open, not the design panel's field rule: an element
|
|
258
|
+
// whose text holds a line break has no fields, and editing in place rewrites its markup.
|
|
259
|
+
if (!canCommitInlineTextSelection(selection, element)) {
|
|
260
|
+
return refuse("this text can't be edited in place");
|
|
245
261
|
}
|
|
246
|
-
// The same gate that let the edit open, not the design panel's.
|
|
247
|
-
//
|
|
248
|
-
// The panel's rule is about its text fields, and it has none for an
|
|
249
|
-
// element whose text contains a line break: a `<span>` holding `<br>`s
|
|
250
|
-
// is not a leaf, so nothing inside is a field and the element reports no
|
|
251
|
-
// editable text at all. Editing in place does not use fields — it
|
|
252
|
-
// rewrites the element's own markup — so refusing on that rule refused
|
|
253
|
-
// elements the caret had just been opened in, and every colour the user
|
|
254
|
-
// chose was dropped on the way out with nothing said about it.
|
|
255
|
-
if (!canCommitInlineTextSelection(domEditSelection, element)) return;
|
|
256
|
-
const iframe = previewIframeRef.current;
|
|
257
|
-
const doc = iframe?.contentDocument;
|
|
258
|
-
// A preview reload replaces the document. Never resolve this commit onto
|
|
259
|
-
// the replacement node: it did not own the edit or its rollback snapshot.
|
|
260
|
-
if (!ownsCurrentPreviewElement(domEditSelection, element, doc)) return;
|
|
261
262
|
const isLatestTextCommit = bumpDomEditCommitMapVersion(
|
|
262
263
|
domTextCommitVersionRef.current,
|
|
263
|
-
getDomEditTargetKey(
|
|
264
|
+
getDomEditTargetKey(selection),
|
|
264
265
|
);
|
|
265
266
|
const operations = [buildDomEditRichTextPatchOperation(html)];
|
|
266
267
|
let appliedHtml = "";
|
|
@@ -274,7 +275,7 @@ export function useDomEditTextCommits({
|
|
|
274
275
|
appliedHtml = element.innerHTML;
|
|
275
276
|
},
|
|
276
277
|
persist: async () => {
|
|
277
|
-
await persistDomEditOperations(
|
|
278
|
+
await persistDomEditOperations(selection, operations, {
|
|
278
279
|
label: "Edit text",
|
|
279
280
|
skipRefresh: true,
|
|
280
281
|
shouldSave: isLatestTextCommit,
|
|
@@ -288,13 +289,13 @@ export function useDomEditTextCommits({
|
|
|
288
289
|
element.innerHTML = previousHtml;
|
|
289
290
|
}
|
|
290
291
|
},
|
|
291
|
-
onError: (error) =>
|
|
292
|
-
|
|
293
|
-
shouldResync: isLatestTextCommit,
|
|
292
|
+
onError: (error) => reportDomEditPersistFailure(selection, operations, error, showToast),
|
|
293
|
+
// Re-select only what is still selected: the selection may have moved on, or a host cleared it.
|
|
294
|
+
shouldResync: () => isLatestTextCommit() && latestSelectionRef.current?.element === element,
|
|
294
295
|
resync: () =>
|
|
295
296
|
resyncDomTextSelectionFromPreview(
|
|
296
297
|
doc,
|
|
297
|
-
|
|
298
|
+
selection,
|
|
298
299
|
activeCompPath,
|
|
299
300
|
buildDomSelectionFromTarget,
|
|
300
301
|
applyDomSelection,
|
|
@@ -306,7 +307,6 @@ export function useDomEditTextCommits({
|
|
|
306
307
|
activeCompPath,
|
|
307
308
|
applyDomSelection,
|
|
308
309
|
buildDomSelectionFromTarget,
|
|
309
|
-
domEditSelection,
|
|
310
310
|
persistDomEditOperations,
|
|
311
311
|
previewIframeRef,
|
|
312
312
|
showToast,
|
|
@@ -16,6 +16,11 @@ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
|
16
16
|
|
|
17
17
|
const SOURCE = '<div id="card">Card</div><div id="other">Other</div>';
|
|
18
18
|
|
|
19
|
+
/** GSAP already renders this element's transform, so a move takes the GSAP writer. */
|
|
20
|
+
function gsapPositioned(element: HTMLElement): HTMLElement {
|
|
21
|
+
return Object.assign(element, { _gsap: { renderTransform: () => {} } });
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
/** A warm parse where only another element animates, and a GSAP writer answering `status`. */
|
|
20
25
|
function stubServer(status = 200, parseStatus = 200) {
|
|
21
26
|
const mutations: unknown[] = [];
|
|
@@ -116,7 +121,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
116
121
|
onRotationCommit: hook().commitRotation,
|
|
117
122
|
};
|
|
118
123
|
|
|
119
|
-
const
|
|
124
|
+
const card = makeSelection("card", gsapPositioned(element));
|
|
125
|
+
const outcome = await overlayCommits.onPathOffsetCommit(card, {
|
|
120
126
|
x: 40,
|
|
121
127
|
y: 20,
|
|
122
128
|
});
|
|
@@ -132,6 +138,56 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
132
138
|
unmount();
|
|
133
139
|
});
|
|
134
140
|
|
|
141
|
+
function stubPatchServer(patchStatus = 200) {
|
|
142
|
+
const calls = { urls: [] as string[], patches: [] as unknown[] };
|
|
143
|
+
vi.stubGlobal(
|
|
144
|
+
"fetch",
|
|
145
|
+
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
146
|
+
const url = String(input instanceof Request ? input.url : input);
|
|
147
|
+
calls.urls.push(url);
|
|
148
|
+
if (url.includes("/patch-element/")) calls.patches.push(JSON.parse(String(init?.body)));
|
|
149
|
+
const saved = { ok: true, changed: true, matched: true, content: "AFTER", version: "v2" };
|
|
150
|
+
const body = url.includes("/files/") ? { content: SOURCE } : saved;
|
|
151
|
+
const status = url.includes("/patch-element/") ? patchStatus : 200;
|
|
152
|
+
return new Response(JSON.stringify(body), {
|
|
153
|
+
status,
|
|
154
|
+
headers: { "content-type": "application/json" },
|
|
155
|
+
});
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
return calls;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it("saves a GSAP-free move as its inline translate, with no GSAP script and no animation read", async () => {
|
|
162
|
+
const calls = stubPatchServer();
|
|
163
|
+
const { element, recordEdit, hook, unmount } = renderHost();
|
|
164
|
+
|
|
165
|
+
const card = makeSelection("card", element);
|
|
166
|
+
await expect(hook().commitPathOffset(card, { x: 130.5, y: 90 })).resolves.toEqual({ ok: true });
|
|
167
|
+
|
|
168
|
+
expect(element.style.getPropertyValue("translate")).toBe("130.5px 90px");
|
|
169
|
+
expect(calls.patches).toEqual([
|
|
170
|
+
expect.objectContaining({
|
|
171
|
+
operations: [{ type: "inline-style", property: "translate", value: "130.5px 90px" }],
|
|
172
|
+
}),
|
|
173
|
+
]);
|
|
174
|
+
expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
|
|
175
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
176
|
+
unmount();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
|
|
180
|
+
stubPatchServer(500);
|
|
181
|
+
const { element, hook, unmount } = renderHost();
|
|
182
|
+
element.style.setProperty("translate", "40px 30px");
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
hook().commitPathOffset(makeSelection("card", element), { x: 1, y: 2 }),
|
|
186
|
+
).rejects.toThrow();
|
|
187
|
+
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
|
|
188
|
+
unmount();
|
|
189
|
+
});
|
|
190
|
+
|
|
135
191
|
it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
|
|
136
192
|
stubServer();
|
|
137
193
|
const { hook, rerender, unmount } = renderHost();
|
|
@@ -148,7 +204,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
148
204
|
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
|
|
149
205
|
|
|
150
206
|
await expect(
|
|
151
|
-
hook().commitPathOffset(makeSelection("card", element), { x: 40, y: 20 }),
|
|
207
|
+
hook().commitPathOffset(makeSelection("card", gsapPositioned(element)), { x: 40, y: 20 }),
|
|
152
208
|
).rejects.toThrow();
|
|
153
209
|
expect(recordEdit).not.toHaveBeenCalled();
|
|
154
210
|
expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
|
|
@@ -204,7 +260,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
204
260
|
const mutations = stubServer();
|
|
205
261
|
const { element, recordEdit, hook, unmount } = renderHost();
|
|
206
262
|
|
|
207
|
-
const
|
|
263
|
+
const card = makeSelection("card", gsapPositioned(element));
|
|
264
|
+
const move = hook().commitPathOffset(card, { x: 40, y: 20 });
|
|
208
265
|
await hook().waitForPendingSaves();
|
|
209
266
|
|
|
210
267
|
expect(mutations).toHaveLength(1);
|
|
@@ -280,7 +337,8 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
|
|
|
280
337
|
offsetLeft: { get: () => 100 + (Number.parseFloat(word.style.left) || 0) },
|
|
281
338
|
offsetTop: { get: () => 200 + (Number.parseFloat(word.style.top) || 0) },
|
|
282
339
|
});
|
|
283
|
-
|
|
340
|
+
const selection = makeSelection("How", gsapPositioned(word));
|
|
341
|
+
return { ...selection, id: undefined, selector: ".w", hfId: "hf-w0" };
|
|
284
342
|
}
|
|
285
343
|
|
|
286
344
|
it("saves left/top on the dragged word only and never rewrites the shared tween", async () => {
|
|
@@ -147,8 +147,10 @@ describe("useDomGeometryCommits read-only preview", () => {
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
describe("useDomGeometryCommits element position offset", () => {
|
|
150
|
-
it("persists left/top on
|
|
151
|
-
const element = document.createElement("span")
|
|
150
|
+
it("persists left/top on a word a shared tween positions, and no translate offset", async () => {
|
|
151
|
+
const element = Object.assign(document.createElement("span"), {
|
|
152
|
+
_gsap: { renderTransform: () => {} },
|
|
153
|
+
});
|
|
152
154
|
Object.defineProperties(element, {
|
|
153
155
|
offsetLeft: { get: () => 100 + (Number.parseFloat(element.style.left) || 0) },
|
|
154
156
|
offsetTop: { get: () => 200 + (Number.parseFloat(element.style.top) || 0) },
|