@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.
Files changed (59) hide show
  1. package/dist/assets/{hyperframes-player-Rml34FRW.js → hyperframes-player-CFJLrHRF.js} +1 -1
  2. package/dist/assets/{index-CVDArg3m.js → index-BBNVnnyH.js} +179 -179
  3. package/dist/assets/{index-D9YzDuBx.js → index-C33EDBL2.js} +1 -1
  4. package/dist/assets/{index-C_fi-jTe.js → index-CJOzbslX.js} +1 -1
  5. package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
  6. package/dist/chunk-7X4XRP3L.js.map +1 -0
  7. package/dist/index.d.ts +91 -21
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +826 -538
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +2 -0
  14. package/src/components/dock/Dock.test.tsx +78 -3
  15. package/src/components/dock/Dock.tsx +45 -18
  16. package/src/components/dock/dockLayout.ts +27 -14
  17. package/src/components/dock/dockLayoutSchema.ts +11 -4
  18. package/src/components/dock/dockLayoutStore.ts +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +3 -3
  20. package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
  21. package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
  22. package/src/components/editor/manualOffsetDrag.test.ts +70 -0
  23. package/src/components/editor/manualOffsetDrag.ts +50 -40
  24. package/src/components/editor/plainTranslate.test.ts +62 -0
  25. package/src/components/editor/plainTranslate.ts +85 -0
  26. package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
  27. package/src/components/editor/useDomEditNudge.ts +1 -1
  28. package/src/hooks/appHotkeysDispatch.ts +1 -7
  29. package/src/hooks/elementOffsetStager.ts +33 -2
  30. package/src/hooks/gsapRuntimeBridge.ts +1 -17
  31. package/src/hooks/gsapRuntimeKeyframes.ts +35 -0
  32. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
  33. package/src/hooks/useAppHotkeys.ts +18 -45
  34. package/src/hooks/useDomEditCommits.test.tsx +12 -3
  35. package/src/hooks/useDomEditCommits.ts +2 -1
  36. package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
  37. package/src/hooks/useDomEditTextCommits.ts +35 -35
  38. package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
  39. package/src/hooks/useDomGeometryCommits.test.tsx +4 -2
  40. package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
  41. package/src/hooks/useEditHistoryActions.test.tsx +67 -9
  42. package/src/hooks/useEditHistoryActions.ts +36 -8
  43. package/src/hooks/useGsapAwareEditing.test.tsx +46 -18
  44. package/src/hooks/useGsapAwareEditing.ts +11 -11
  45. package/src/hooks/usePersistentEditHistory.test.ts +46 -0
  46. package/src/hooks/usePersistentEditHistory.ts +101 -19
  47. package/src/hooks/usePreviewPersistence.ts +25 -2
  48. package/src/hooks/useStudioExternalFileChanges.ts +7 -3
  49. package/src/index.ts +4 -0
  50. package/src/player/components/timelineDragDrop.test.tsx +61 -5
  51. package/src/player/components/timelineDragDrop.ts +29 -8
  52. package/src/player/components/useTimelineProviderState.tsx +1 -0
  53. package/src/utils/domEditSaveQueue.ts +7 -2
  54. package/src/utils/gsapSoftReload.ts +1 -1
  55. package/src/utils/gsapUndoRestore.test.ts +46 -0
  56. package/src/utils/gsapUndoRestore.ts +166 -91
  57. package/src/utils/studioPendingEdits.ts +14 -5
  58. package/src/utils/studioUiPreferences.ts +8 -6
  59. 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
- function mount(result: {
15
- ok: boolean;
16
- reason?: string;
17
- message?: string;
18
- label?: string;
19
- paths?: string[];
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?: Record<string, { previous: string; restored: string }>;
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
- await waitForPendingDomEditSaves();
71
- const result = await editHistory[direction]({
72
- readFile: readHistoryFile,
73
- serialize: serializeHistoryFiles,
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 restore = { paths: result.paths, files: result.files };
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: document.createElement("span"), hfId: "w0", selector: ".w" };
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 a group member that shares a tween through its own offset, under the group key", async () => {
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: document.createElement("span"), hfId: "w0", selector: ".w" };
186
- const box = { element: document.createElement("div"), id: "box", selector: "#box" };
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(stageElementPositionOffset).toHaveBeenCalledWith(
194
- word,
195
- { x: 40, y: 20 },
196
- expect.stringMatching(/^group-drag:\d+$/),
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: document.createElement("div"),
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: document.createElement("div"), id: "a", selector: "#a" },
477
+ selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
451
478
  next: { x: 10, y: 10 },
452
479
  },
453
480
  {
454
- selection: { element: document.createElement("div"), id: "b", selector: "#b" },
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: document.createElement("div"), id: "ok", selector: "#ok" },
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: document.createElement("div"),
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: document.createElement("div"), id: "a", selector: "#a" },
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: document.createElement("div"), id: "a", selector: "#a" },
581
+ selection: { element: gsapPositioned("div"), id: "a", selector: "#a" },
555
582
  next: { x: 10, y: 10 },
556
583
  },
557
584
  {
558
- selection: { element: document.createElement("div"), id: "b", selector: "#b" },
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 { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
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: route EACH element through the SAME GSAP intercept as
189
- // a single drag, so every position is written as GSAP code (tl.set / keyframes /
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
+ });