@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
createStudioApi,
|
|
10
|
+
openProjectHistory,
|
|
11
|
+
type StudioApiAdapter,
|
|
12
|
+
} from "@hyperframes/studio-server";
|
|
13
|
+
import { useEditHistoryActions } from "./useEditHistoryActions";
|
|
14
|
+
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
|
15
|
+
import { usePreviewPersistence } from "./usePreviewPersistence";
|
|
16
|
+
|
|
17
|
+
const page = (left: string, top = "0px") =>
|
|
18
|
+
`<!doctype html><html><head></head><body><div id="root" data-composition-id="main"><div id="box" style="position: absolute; left: ${left}"></div><div id="other" style="position: absolute; top: ${top}"></div></div></body></html>`;
|
|
19
|
+
const BEFORE = page("10px");
|
|
20
|
+
const AFTER = page("50px");
|
|
21
|
+
const OUTSIDE = page("50px", "99px");
|
|
22
|
+
|
|
23
|
+
const cleanup: Array<() => unknown> = [];
|
|
24
|
+
let scratch = "";
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
scratch = mkdtempSync(join(tmpdir(), "hf-undo-paint-"));
|
|
27
|
+
});
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
for (const step of cleanup.splice(0).reverse()) await step();
|
|
30
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Studio's undo wired as App.tsx wires it, over the real history engine, with a live preview document. */
|
|
35
|
+
async function studio() {
|
|
36
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
37
|
+
const dir = join(scratch, "project");
|
|
38
|
+
mkdirSync(dir);
|
|
39
|
+
const path = join(dir, "index.html");
|
|
40
|
+
writeFileSync(path, BEFORE);
|
|
41
|
+
const engine = await openProjectHistory({
|
|
42
|
+
projectDir: dir,
|
|
43
|
+
historyRoot: join(scratch, "history"),
|
|
44
|
+
});
|
|
45
|
+
cleanup.push(() => engine.close());
|
|
46
|
+
const api = createStudioApi({
|
|
47
|
+
listProjects: () => [],
|
|
48
|
+
resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
|
|
49
|
+
history: () => engine,
|
|
50
|
+
} as unknown as StudioApiAdapter);
|
|
51
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
52
|
+
api.request(url.replace(/^\/api/, ""), init),
|
|
53
|
+
);
|
|
54
|
+
const iframe = document.createElement("iframe");
|
|
55
|
+
document.body.append(iframe);
|
|
56
|
+
cleanup.push(() => iframe.remove());
|
|
57
|
+
const reloads = vi.fn();
|
|
58
|
+
const readFile = async (p: string) => readFileSync(join(dir, p), "utf8");
|
|
59
|
+
let history!: ReturnType<typeof usePersistentEditHistory>;
|
|
60
|
+
let persistence!: ReturnType<typeof usePreviewPersistence>;
|
|
61
|
+
let actions!: ReturnType<typeof useEditHistoryActions>;
|
|
62
|
+
function Harness() {
|
|
63
|
+
history = usePersistentEditHistory({ projectId: "demo" });
|
|
64
|
+
persistence = usePreviewPersistence({
|
|
65
|
+
showToast: () => {},
|
|
66
|
+
readOptionalProjectFile: async () => "",
|
|
67
|
+
writeProjectFile: async () => {},
|
|
68
|
+
recordEdit: async () => {},
|
|
69
|
+
previewIframeRef: { current: iframe },
|
|
70
|
+
activeCompPathRef: { current: "index.html" },
|
|
71
|
+
reloadPreview: reloads,
|
|
72
|
+
});
|
|
73
|
+
actions = useEditHistoryActions({
|
|
74
|
+
editHistory: history,
|
|
75
|
+
readOptionalProjectFile: async () => "",
|
|
76
|
+
readProjectFile: readFile,
|
|
77
|
+
writeProjectFile: async (p, content) => writeFileSync(join(dir, p), content),
|
|
78
|
+
showToast: () => {},
|
|
79
|
+
syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
|
|
80
|
+
showHistoryRestoreNow: persistence.showHistoryRestoreNow,
|
|
81
|
+
waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
|
|
82
|
+
});
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const root = createRoot(document.createElement("div"));
|
|
86
|
+
await act(async () => root.render(createElement(Harness)));
|
|
87
|
+
cleanup.push(() => act(() => root.unmount()));
|
|
88
|
+
const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
|
|
89
|
+
const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
|
|
90
|
+
const show = (html: string) => {
|
|
91
|
+
iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
|
|
92
|
+
html,
|
|
93
|
+
"text/html",
|
|
94
|
+
).documentElement.innerHTML;
|
|
95
|
+
};
|
|
96
|
+
/** A move Studio saved: the live preview already shows it, then the file and the history claim land. */
|
|
97
|
+
const edit = async () => {
|
|
98
|
+
show(AFTER);
|
|
99
|
+
writeFileSync(path, AFTER);
|
|
100
|
+
await act(() =>
|
|
101
|
+
history.recordEdit({
|
|
102
|
+
label: "Move layer",
|
|
103
|
+
files: { "index.html": { before: BEFORE, after: AFTER } },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
history: () => history,
|
|
110
|
+
persistence: () => persistence,
|
|
111
|
+
actions: () => actions,
|
|
112
|
+
box,
|
|
113
|
+
other,
|
|
114
|
+
show,
|
|
115
|
+
path,
|
|
116
|
+
edit,
|
|
117
|
+
file: () => readFileSync(path, "utf8"),
|
|
118
|
+
reloads,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it("undo and redo of a style edit paint in the key's own task, before the server answers", async () => {
|
|
123
|
+
const s = await studio();
|
|
124
|
+
await s.edit();
|
|
125
|
+
|
|
126
|
+
const undone = s.actions().undo();
|
|
127
|
+
expect(s.box()).toBe("10px");
|
|
128
|
+
await act(() => undone);
|
|
129
|
+
expect(s.file()).toBe(BEFORE);
|
|
130
|
+
expect(s.box()).toBe("10px");
|
|
131
|
+
|
|
132
|
+
await vi.waitFor(() => expect(s.history().redoLabel).toBeTruthy());
|
|
133
|
+
const redone = s.actions().redo();
|
|
134
|
+
expect(s.box()).toBe("50px");
|
|
135
|
+
await act(() => redone);
|
|
136
|
+
expect(s.file()).toBe(AFTER);
|
|
137
|
+
expect(s.box()).toBe("50px");
|
|
138
|
+
expect(s.reloads).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("an undo pressed while a save is still running waits for the server instead of guessing", async () => {
|
|
142
|
+
const s = await studio();
|
|
143
|
+
await s.edit();
|
|
144
|
+
let finish!: () => void;
|
|
145
|
+
const saving = new Promise<void>((resolve) => (finish = resolve));
|
|
146
|
+
void s.persistence().queueDomEditSave(() => saving);
|
|
147
|
+
|
|
148
|
+
const undone = s.actions().undo();
|
|
149
|
+
expect(s.box()).toBe("50px");
|
|
150
|
+
finish();
|
|
151
|
+
await act(() => undone);
|
|
152
|
+
expect(s.file()).toBe(BEFORE);
|
|
153
|
+
expect(s.box()).toBe("10px");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
|
|
157
|
+
const s = await studio();
|
|
158
|
+
await s.edit();
|
|
159
|
+
writeFileSync(s.path, OUTSIDE);
|
|
160
|
+
s.show(OUTSIDE);
|
|
161
|
+
|
|
162
|
+
await act(() => s.actions().undo());
|
|
163
|
+
expect(s.file()).toBe(AFTER);
|
|
164
|
+
expect(s.other()).toBe("0px");
|
|
165
|
+
expect(s.box()).toBe("50px");
|
|
166
|
+
});
|
|
@@ -11,24 +11,36 @@ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryA
|
|
|
11
11
|
let root: Root | null = null;
|
|
12
12
|
afterEach(() => act(() => root?.unmount()));
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
type RestoreFiles = Record<string, { previous: string; restored: string }>;
|
|
15
|
+
type Prediction = { id: string; files: RestoreFiles };
|
|
16
|
+
|
|
17
|
+
function mount(
|
|
18
|
+
result: {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
reason?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
paths?: string[];
|
|
24
|
+
undoes?: string;
|
|
25
|
+
files?: RestoreFiles;
|
|
26
|
+
},
|
|
27
|
+
predicted?: Prediction,
|
|
28
|
+
) {
|
|
21
29
|
const editHistory = {
|
|
22
30
|
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
|
|
23
31
|
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
32
|
+
predict: () => predicted ?? null,
|
|
24
33
|
};
|
|
34
|
+
const putBack = vi.fn();
|
|
25
35
|
const deps = {
|
|
26
36
|
editHistory,
|
|
27
37
|
readOptionalProjectFile: vi.fn(async () => ""),
|
|
28
38
|
readProjectFile: vi.fn(async () => ""),
|
|
29
39
|
writeProjectFile: vi.fn(async () => undefined),
|
|
30
40
|
showToast: vi.fn(),
|
|
31
|
-
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
41
|
+
syncHistoryPreviewAfterApply: vi.fn(async (_restore: unknown) => undefined),
|
|
42
|
+
putBack,
|
|
43
|
+
showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => putBack),
|
|
32
44
|
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
33
45
|
onAfterUndoRedo: vi.fn(),
|
|
34
46
|
activeCompPath: "index.html",
|
|
@@ -44,7 +56,53 @@ function mount(result: {
|
|
|
44
56
|
return { deps, actions };
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored: "A" } } };
|
|
60
|
+
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
|
|
61
|
+
|
|
47
62
|
describe("useEditHistoryActions", () => {
|
|
63
|
+
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
|
|
64
|
+
const { deps, actions } = mount(
|
|
65
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
66
|
+
PREDICTED,
|
|
67
|
+
);
|
|
68
|
+
await act(() => actions.undo());
|
|
69
|
+
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
|
|
70
|
+
expect(deps.putBack).not.toHaveBeenCalled();
|
|
71
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
|
|
72
|
+
paths: ["index.html"],
|
|
73
|
+
files: { "index.html": { previous: "A", restored: "A2" } },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
|
|
78
|
+
const { deps, actions } = mount(
|
|
79
|
+
{
|
|
80
|
+
ok: true,
|
|
81
|
+
label: "Undid: Outside",
|
|
82
|
+
paths: ["index.html"],
|
|
83
|
+
undoes: "e0",
|
|
84
|
+
files: SERVER_FILES,
|
|
85
|
+
},
|
|
86
|
+
PREDICTED,
|
|
87
|
+
);
|
|
88
|
+
await act(() => actions.undo());
|
|
89
|
+
expect(deps.putBack).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
|
|
91
|
+
paths: ["index.html"],
|
|
92
|
+
files: SERVER_FILES,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("puts a shown step back when the server refuses it", async () => {
|
|
97
|
+
const { deps, actions } = mount(
|
|
98
|
+
{ ok: false, reason: "failed", message: "disk full" },
|
|
99
|
+
PREDICTED,
|
|
100
|
+
);
|
|
101
|
+
await act(() => actions.undo());
|
|
102
|
+
expect(deps.putBack).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
48
106
|
it("undo resyncs the preview and toasts the step as the history names it", async () => {
|
|
49
107
|
const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] });
|
|
50
108
|
await act(() => actions.undo());
|
|
@@ -80,7 +138,7 @@ describe("useEditHistoryActions", () => {
|
|
|
80
138
|
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
81
139
|
});
|
|
82
140
|
|
|
83
|
-
it("says why when the history could not take the step", async () => {
|
|
141
|
+
it("says why when the history could not take the step, with nothing shown to take back", async () => {
|
|
84
142
|
const { deps, actions } = mount({ ok: false, reason: "failed", message: "disk full" });
|
|
85
143
|
await act(() => actions.undo());
|
|
86
144
|
expect(deps.showToast).toHaveBeenCalledWith("Undo failed: disk full", "error");
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
|
+
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
5
6
|
|
|
6
7
|
interface HistoryResult {
|
|
7
8
|
ok: boolean;
|
|
@@ -9,8 +10,9 @@ interface HistoryResult {
|
|
|
9
10
|
message?: string;
|
|
10
11
|
label?: string;
|
|
11
12
|
paths?: string[];
|
|
13
|
+
undoes?: string;
|
|
12
14
|
/** Per-file restored/previous content, used to soft-apply the preview. */
|
|
13
|
-
files?:
|
|
15
|
+
files?: RestoreFiles;
|
|
14
16
|
}
|
|
15
17
|
interface HistoryFileCallbacks {
|
|
16
18
|
readFile: (path: string) => Promise<string>;
|
|
@@ -19,6 +21,7 @@ interface HistoryFileCallbacks {
|
|
|
19
21
|
export interface EditHistoryHandle {
|
|
20
22
|
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
21
23
|
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
24
|
+
predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
|
|
22
25
|
state: {
|
|
23
26
|
undo: ReadonlyArray<{ createdAt: number }>;
|
|
24
27
|
redo: ReadonlyArray<{ createdAt: number }>;
|
|
@@ -26,12 +29,13 @@ export interface EditHistoryHandle {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface UseEditHistoryActionsOptions {
|
|
29
|
-
editHistory: Pick<EditHistoryHandle, "undo" | "redo">;
|
|
32
|
+
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
|
|
30
33
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
31
34
|
readProjectFile: (path: string) => Promise<string>;
|
|
32
35
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
33
36
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
37
|
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
|
38
|
+
showHistoryRestoreNow?: (files: RestoreFiles) => (() => void) | null;
|
|
35
39
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
36
40
|
onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
|
|
37
41
|
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
|
@@ -48,6 +52,7 @@ export function useEditHistoryActions({
|
|
|
48
52
|
writeProjectFile,
|
|
49
53
|
showToast,
|
|
50
54
|
syncHistoryPreviewAfterApply,
|
|
55
|
+
showHistoryRestoreNow,
|
|
51
56
|
waitForPendingDomEditSaves,
|
|
52
57
|
onAfterUndoRedo,
|
|
53
58
|
activeCompPath,
|
|
@@ -67,11 +72,22 @@ export function useEditHistoryActions({
|
|
|
67
72
|
const apply = useCallback(
|
|
68
73
|
async (direction: "undo" | "redo") => {
|
|
69
74
|
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
|
|
76
|
+
const predicted = editHistory.predict?.(direction) ?? null;
|
|
77
|
+
const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
78
|
+
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
79
|
+
let serverSteppedShown = false;
|
|
80
|
+
try {
|
|
81
|
+
await waitForPendingDomEditSaves();
|
|
82
|
+
result = await editHistory[direction]({
|
|
83
|
+
readFile: readHistoryFile,
|
|
84
|
+
serialize: serializeHistoryFiles,
|
|
85
|
+
});
|
|
86
|
+
serverSteppedShown =
|
|
87
|
+
Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
|
|
88
|
+
} finally {
|
|
89
|
+
if (putBack && !serverSteppedShown) putBack();
|
|
90
|
+
}
|
|
75
91
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
76
92
|
showToast(
|
|
77
93
|
`Can't ${direction}: ${result.paths?.join(", ")} changed since that edit.`,
|
|
@@ -84,7 +100,8 @@ export function useEditHistoryActions({
|
|
|
84
100
|
return;
|
|
85
101
|
}
|
|
86
102
|
if (result.ok && result.label) {
|
|
87
|
-
const
|
|
103
|
+
const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
|
|
104
|
+
const restore = { paths: result.paths, files };
|
|
88
105
|
onAfterUndoRedo?.(restore);
|
|
89
106
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
90
107
|
forceReloadSdkSession?.();
|
|
@@ -98,6 +115,7 @@ export function useEditHistoryActions({
|
|
|
98
115
|
readHistoryFile,
|
|
99
116
|
showToast,
|
|
100
117
|
syncHistoryPreviewAfterApply,
|
|
118
|
+
showHistoryRestoreNow,
|
|
101
119
|
waitForPendingDomEditSaves,
|
|
102
120
|
serializeHistoryFiles,
|
|
103
121
|
onAfterUndoRedo,
|
|
@@ -110,3 +128,13 @@ export function useEditHistoryActions({
|
|
|
110
128
|
const redo = useCallback(() => apply("redo"), [apply]);
|
|
111
129
|
return useMemo(() => ({ undo, redo }), [undo, redo]);
|
|
112
130
|
}
|
|
131
|
+
|
|
132
|
+
function fromShown(files: RestoreFiles | undefined, shown: RestoreFiles) {
|
|
133
|
+
if (!files) return files;
|
|
134
|
+
return Object.fromEntries(
|
|
135
|
+
Object.entries(files).map(([path, f]) => [
|
|
136
|
+
path,
|
|
137
|
+
{ previous: shown[path]?.restored ?? f.previous, restored: f.restored },
|
|
138
|
+
]),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -22,7 +22,6 @@ const mocks = vi.hoisted(() => ({
|
|
|
22
22
|
|
|
23
23
|
vi.mock("./gsapResizeIntercept", () => ({ tryGsapResizeIntercept: mocks.resize }));
|
|
24
24
|
vi.mock("./gsapRuntimeBridge", () => ({
|
|
25
|
-
POSITION_CHANNELS: ["x", "y"],
|
|
26
25
|
tryGsapDragIntercept: mocks.drag,
|
|
27
26
|
tryGsapRotationIntercept: vi.fn(),
|
|
28
27
|
}));
|
|
@@ -47,6 +46,11 @@ afterEach(() => {
|
|
|
47
46
|
vi.clearAllMocks();
|
|
48
47
|
});
|
|
49
48
|
|
|
49
|
+
/** An element GSAP already renders the transform of, so moves take the GSAP route. */
|
|
50
|
+
function gsapPositioned(tag: string): HTMLElement {
|
|
51
|
+
return Object.assign(document.createElement(tag), { _gsap: { renderTransform: () => {} } });
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
function mountResizeHandler(
|
|
51
55
|
animations: GsapAnimation[],
|
|
52
56
|
targetAnimations: GsapAnimation[] = animations,
|
|
@@ -150,6 +154,28 @@ function mountGroupHandler({
|
|
|
150
154
|
};
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
describe("useGsapAwareEditing moves of an element GSAP does not position", () => {
|
|
158
|
+
it("saves the move on the element itself, with no GSAP write and no animation read", async () => {
|
|
159
|
+
const save = vi.fn().mockResolvedValue(undefined);
|
|
160
|
+
const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
|
|
161
|
+
const gsapCommitMutation = vi.fn();
|
|
162
|
+
const makeFetchFallback = vi.fn();
|
|
163
|
+
const { pathOffsetCommit, root } = mountGroupHandler({
|
|
164
|
+
gsapCommitMutation,
|
|
165
|
+
makeFetchFallback,
|
|
166
|
+
stageElementPositionOffset,
|
|
167
|
+
});
|
|
168
|
+
const box = { element: document.createElement("div"), id: "box", selector: "#box" };
|
|
169
|
+
await act(() => pathOffsetCommit(box as unknown as DomEditSelection, { x: 130.25, y: 90 }));
|
|
170
|
+
expect(stageElementPositionOffset).toHaveBeenCalledWith(box, { x: 130.25, y: 90 });
|
|
171
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(gsapCommitMutation).not.toHaveBeenCalled();
|
|
173
|
+
expect(makeFetchFallback).not.toHaveBeenCalled();
|
|
174
|
+
expect(mocks.drag).not.toHaveBeenCalled();
|
|
175
|
+
act(() => root.unmount());
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
153
179
|
describe("useGsapAwareEditing shared-tween moves", () => {
|
|
154
180
|
it("saves a single drag through the element's own offset", async () => {
|
|
155
181
|
mocks.drag.mockResolvedValue({ status: "element-offset" });
|
|
@@ -161,7 +187,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
|
|
|
161
187
|
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
|
|
162
188
|
stageElementPositionOffset,
|
|
163
189
|
});
|
|
164
|
-
const word = { element:
|
|
190
|
+
const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
|
|
165
191
|
await act(() => pathOffsetCommit(word as unknown as DomEditSelection, { x: 40, y: 20 }));
|
|
166
192
|
expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 });
|
|
167
193
|
expect(save).toHaveBeenCalledTimes(1);
|
|
@@ -169,7 +195,7 @@ describe("useGsapAwareEditing shared-tween moves", () => {
|
|
|
169
195
|
act(() => root.unmount());
|
|
170
196
|
});
|
|
171
197
|
|
|
172
|
-
it("saves
|
|
198
|
+
it("saves shared-tween and GSAP-free group members on themselves, under the group key", async () => {
|
|
173
199
|
mocks.drag.mockImplementation(async (selection, _next, _a, _i, commit, _f, options) => {
|
|
174
200
|
if (selection.hfId === "w0") return { status: "element-offset" };
|
|
175
201
|
if (!options?.preflightOnly) await commit(selection, { type: "move" }, { label: "Move" });
|
|
@@ -182,19 +208,20 @@ describe("useGsapAwareEditing shared-tween moves", () => {
|
|
|
182
208
|
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
|
|
183
209
|
stageElementPositionOffset,
|
|
184
210
|
});
|
|
185
|
-
const word = { element:
|
|
186
|
-
const box = { element:
|
|
211
|
+
const word = { element: gsapPositioned("span"), hfId: "w0", selector: ".w" };
|
|
212
|
+
const box = { element: gsapPositioned("div"), id: "box", selector: "#box" };
|
|
213
|
+
const plain = { element: document.createElement("div"), id: "plain", selector: "#plain" };
|
|
187
214
|
await act(() =>
|
|
188
215
|
groupCommit([
|
|
189
216
|
{ selection: word, next: { x: 40, y: 20 } },
|
|
190
217
|
{ selection: box, next: { x: 40, y: 20 } },
|
|
218
|
+
{ selection: plain, next: { x: 40, y: 20 } },
|
|
191
219
|
] as unknown as DomEditGroupPathOffsetCommit[]),
|
|
192
220
|
);
|
|
193
|
-
expect(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
221
|
+
const groupKey = expect.stringMatching(/^group-drag:\d+$/);
|
|
222
|
+
expect(stageElementPositionOffset).toHaveBeenCalledWith(word, { x: 40, y: 20 }, groupKey);
|
|
223
|
+
expect(stageElementPositionOffset).toHaveBeenCalledWith(plain, { x: 40, y: 20 }, groupKey);
|
|
224
|
+
expect(mocks.drag.mock.calls.some((call) => call[0] === plain)).toBe(false);
|
|
198
225
|
act(() => root.unmount());
|
|
199
226
|
});
|
|
200
227
|
});
|
|
@@ -207,7 +234,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
207
234
|
const h = mountResizeHandler([humanAnimation], [targetAnimation]);
|
|
208
235
|
const target = {
|
|
209
236
|
...h.selection,
|
|
210
|
-
element:
|
|
237
|
+
element: gsapPositioned("div"),
|
|
211
238
|
id: "agent-target",
|
|
212
239
|
selector: "#agent-target",
|
|
213
240
|
} as DomEditSelection;
|
|
@@ -447,11 +474,11 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
447
474
|
});
|
|
448
475
|
const updates = [
|
|
449
476
|
{
|
|
450
|
-
selection: { element:
|
|
477
|
+
selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
|
|
451
478
|
next: { x: 10, y: 10 },
|
|
452
479
|
},
|
|
453
480
|
{
|
|
454
|
-
selection: { element:
|
|
481
|
+
selection: { element: gsapPositioned("div"), id: "b", selector: "#b" },
|
|
455
482
|
next: { x: 10, y: 10 },
|
|
456
483
|
},
|
|
457
484
|
] as unknown as DomEditGroupPathOffsetCommit[];
|
|
@@ -485,12 +512,12 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
485
512
|
});
|
|
486
513
|
const updates = [
|
|
487
514
|
{
|
|
488
|
-
selection: { element:
|
|
515
|
+
selection: { element: gsapPositioned("div"), id: "ok", selector: "#ok" },
|
|
489
516
|
next: { x: 10, y: 10 },
|
|
490
517
|
},
|
|
491
518
|
{
|
|
492
519
|
selection: {
|
|
493
|
-
element:
|
|
520
|
+
element: gsapPositioned("div"),
|
|
494
521
|
id: "blocked",
|
|
495
522
|
selector: "#blocked",
|
|
496
523
|
},
|
|
@@ -526,7 +553,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
526
553
|
});
|
|
527
554
|
const updates = [
|
|
528
555
|
{
|
|
529
|
-
selection: { element:
|
|
556
|
+
selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
|
|
530
557
|
next: { x: 10, y: 10 },
|
|
531
558
|
},
|
|
532
559
|
] as unknown as DomEditGroupPathOffsetCommit[];
|
|
@@ -551,11 +578,11 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
551
578
|
});
|
|
552
579
|
const updates = [
|
|
553
580
|
{
|
|
554
|
-
selection: { element:
|
|
581
|
+
selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
|
|
555
582
|
next: { x: 10, y: 10 },
|
|
556
583
|
},
|
|
557
584
|
{
|
|
558
|
-
selection: { element:
|
|
585
|
+
selection: { element: gsapPositioned("div"), id: "b", selector: "#b" },
|
|
559
586
|
next: { x: 20, y: 20 },
|
|
560
587
|
},
|
|
561
588
|
] as unknown as DomEditGroupPathOffsetCommit[];
|
|
@@ -605,6 +632,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
605
632
|
*/
|
|
606
633
|
it("applies the anchor when the resize leaves the drop point to the caller", async () => {
|
|
607
634
|
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
635
|
+
mocks.drag.mockResolvedValue({ status: "persisted" });
|
|
608
636
|
const scale = { propertyGroup: "scale" } as GsapAnimation;
|
|
609
637
|
const h = mountResizeHandler([scale]);
|
|
610
638
|
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
|
|
@@ -10,11 +10,7 @@
|
|
|
10
10
|
import { useCallback } from "react";
|
|
11
11
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
12
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
|
-
import {
|
|
14
|
-
POSITION_CHANNELS,
|
|
15
|
-
tryGsapDragIntercept,
|
|
16
|
-
tryGsapRotationIntercept,
|
|
17
|
-
} from "./gsapRuntimeBridge";
|
|
13
|
+
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
18
14
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
19
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
20
16
|
import { readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
@@ -33,7 +29,11 @@ import { setElementGsapPosition } from "../utils/elementGsap";
|
|
|
33
29
|
import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
34
30
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
35
31
|
import { runGestureTransaction } from "./gestureTransaction";
|
|
36
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
gsapWritesPosition,
|
|
34
|
+
hasNonHoldTweenForElement,
|
|
35
|
+
POSITION_CHANNELS,
|
|
36
|
+
} from "./gsapRuntimeKeyframes";
|
|
37
37
|
import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome";
|
|
38
38
|
import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
|
|
39
39
|
import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
|
|
@@ -153,6 +153,8 @@ export function useGsapAwareEditing({
|
|
|
153
153
|
next: { x: number; y: number },
|
|
154
154
|
modifiers?: { altKey?: boolean },
|
|
155
155
|
) => {
|
|
156
|
+
if (!gsapWritesPosition(selection.element))
|
|
157
|
+
return stageElementPositionOffset(selection, next).save();
|
|
156
158
|
if (gsapCommitMutation) {
|
|
157
159
|
try {
|
|
158
160
|
const ownedAnimations = getGsapAnimationsForSelection(selection);
|
|
@@ -185,11 +187,8 @@ export function useGsapAwareEditing({
|
|
|
185
187
|
],
|
|
186
188
|
);
|
|
187
189
|
|
|
188
|
-
// Multi-select (group) drag:
|
|
189
|
-
//
|
|
190
|
-
// gsap.set) — NEVER the deprecated `--hf-studio-offset` CSS var, and GSAP-animated
|
|
191
|
-
// elements are no longer blocked in a group. No CSS fallback: with no GSAP
|
|
192
|
-
// composition there's nothing to write (a no-op, exactly like the single-drag path).
|
|
190
|
+
// Multi-select (group) drag: each member takes the single drag's writer, so a member GSAP
|
|
191
|
+
// does not position is saved on itself and the rest go through the GSAP intercept.
|
|
193
192
|
const handleGsapAwareGroupPathOffsetCommit = useCallback(
|
|
194
193
|
async (updates: DomEditGroupPathOffsetCommit[]) => {
|
|
195
194
|
if (!gsapCommitMutation || updates.length === 0) return;
|
|
@@ -247,6 +246,7 @@ export function useGsapAwareEditing({
|
|
|
247
246
|
// turns N sequential round trips into one.
|
|
248
247
|
const preflightResults = await Promise.allSettled(
|
|
249
248
|
updates.map(async ({ selection }) => {
|
|
249
|
+
if (!gsapWritesPosition(selection.element)) return void offsetMembers.add(selection);
|
|
250
250
|
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
|
|
251
251
|
preflightAnimations.set(selection, animations);
|
|
252
252
|
const outcome = await tryGsapDragIntercept(
|
|
@@ -76,6 +76,7 @@ it("an edit Studio saved is undone and redone by the project's history, with the
|
|
|
76
76
|
expect(undone).toEqual({
|
|
77
77
|
ok: true,
|
|
78
78
|
label: "Undid: Moved Title",
|
|
79
|
+
undoes: expect.any(String),
|
|
79
80
|
paths: ["index.html"],
|
|
80
81
|
files: { "index.html": { previous: "B", restored: "A" } },
|
|
81
82
|
});
|
|
@@ -241,3 +242,48 @@ it("a step that cannot reach the server says so", async () => {
|
|
|
241
242
|
message: "Studio could not reach its server.",
|
|
242
243
|
});
|
|
243
244
|
});
|
|
245
|
+
|
|
246
|
+
it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
|
|
247
|
+
const { hook, save, readFile } = await studio();
|
|
248
|
+
save("B");
|
|
249
|
+
const claim = hook().recordEdit({
|
|
250
|
+
label: "Moved Title",
|
|
251
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
252
|
+
});
|
|
253
|
+
expect(hook().predict("undo")).toBeNull();
|
|
254
|
+
await act(() => claim);
|
|
255
|
+
const predicted = await vi.waitFor(() => {
|
|
256
|
+
const next = hook().predict("undo");
|
|
257
|
+
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
258
|
+
return next!;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
hook().noteOutsideChange();
|
|
262
|
+
expect(hook().predict("undo")).toBeNull();
|
|
263
|
+
await vi.waitFor(() => expect(hook().predict("undo")?.id).toBe(predicted.id));
|
|
264
|
+
|
|
265
|
+
const undone = hook().undo({ readFile });
|
|
266
|
+
expect(hook().predict("undo")).toBeNull();
|
|
267
|
+
expect((await act(() => undone)).undoes).toBe(predicted.id);
|
|
268
|
+
await vi.waitFor(() =>
|
|
269
|
+
expect(hook().predict("redo")?.files).toEqual({
|
|
270
|
+
"index.html": { previous: "A", restored: "B" },
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("an undo taken before the view caught up with the edit still reports the preview's before and after", async () => {
|
|
276
|
+
const { hook, save, readFile } = await studio();
|
|
277
|
+
save("B");
|
|
278
|
+
const record = hook().recordEdit({
|
|
279
|
+
label: "Moved Title",
|
|
280
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
281
|
+
});
|
|
282
|
+
const undo = hook().undo;
|
|
283
|
+
await act(() => record);
|
|
284
|
+
const undone = await act(() => undo({ readFile }));
|
|
285
|
+
expect(undone).toMatchObject({
|
|
286
|
+
ok: true,
|
|
287
|
+
files: { "index.html": { previous: "B", restored: "A" } },
|
|
288
|
+
});
|
|
289
|
+
});
|