@hyperframes/studio 0.7.92 → 0.7.93
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-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useRef, useState } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TextAreaField } from "../components/editor/propertyPanelSections";
|
|
7
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
8
|
+
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
|
9
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
|
+
import { useAppHotkeys } from "./useAppHotkeys";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
const timelineDelete = vi.fn(async () => undefined);
|
|
15
|
+
const domDelete = vi.fn(async () => undefined);
|
|
16
|
+
const keyframeDelete = vi.fn();
|
|
17
|
+
const textCommits = vi.fn();
|
|
18
|
+
let root: Root | null = null;
|
|
19
|
+
|
|
20
|
+
function parentWithTextChild(): DomEditSelection {
|
|
21
|
+
const element = document.createElement("section");
|
|
22
|
+
element.id = "selected-card";
|
|
23
|
+
const child = document.createElement("span");
|
|
24
|
+
child.textContent = "Kicker";
|
|
25
|
+
element.append(child);
|
|
26
|
+
return {
|
|
27
|
+
element,
|
|
28
|
+
id: "selected-card",
|
|
29
|
+
selector: "#selected-card",
|
|
30
|
+
selectorIndex: 0,
|
|
31
|
+
label: "Selected card",
|
|
32
|
+
tagName: "section",
|
|
33
|
+
sourceFile: "index.html",
|
|
34
|
+
compositionPath: "index.html",
|
|
35
|
+
isCompositionHost: false,
|
|
36
|
+
isInsideLockedComposition: false,
|
|
37
|
+
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
|
|
38
|
+
textContent: "Kicker",
|
|
39
|
+
dataAttributes: {},
|
|
40
|
+
inlineStyles: {},
|
|
41
|
+
computedStyles: {},
|
|
42
|
+
textFields: [
|
|
43
|
+
{
|
|
44
|
+
key: "child:0:span",
|
|
45
|
+
label: "Content",
|
|
46
|
+
value: "Kicker",
|
|
47
|
+
tagName: "span",
|
|
48
|
+
attributes: [],
|
|
49
|
+
inlineStyles: {},
|
|
50
|
+
computedStyles: {},
|
|
51
|
+
source: "child",
|
|
52
|
+
sourceChildIndex: 0,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
capabilities: {
|
|
56
|
+
canSelect: true,
|
|
57
|
+
canEditStyles: true,
|
|
58
|
+
canCrop: false,
|
|
59
|
+
canMove: true,
|
|
60
|
+
canResize: true,
|
|
61
|
+
canApplyManualOffset: true,
|
|
62
|
+
canApplyManualSize: true,
|
|
63
|
+
canApplyManualRotation: true,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Harness() {
|
|
69
|
+
const [value, setValue] = useState("Kicker");
|
|
70
|
+
const [selectionRefreshed, setSelectionRefreshed] = useState(false);
|
|
71
|
+
const selectionRef = useRef<DomEditSelection | null>(parentWithTextChild());
|
|
72
|
+
const clearSelectionRef = useRef<() => void>(() => undefined);
|
|
73
|
+
const saveTimestampRef = useRef(0);
|
|
74
|
+
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
|
|
75
|
+
|
|
76
|
+
useAppHotkeys({
|
|
77
|
+
handleTimelineElementDelete: timelineDelete,
|
|
78
|
+
handleTimelineElementSplit: vi.fn(async () => undefined),
|
|
79
|
+
handleDomEditElementDelete: domDelete,
|
|
80
|
+
domEditSelectionRef: selectionRef,
|
|
81
|
+
clearDomSelectionRef: clearSelectionRef,
|
|
82
|
+
editHistory: {
|
|
83
|
+
undo: vi.fn(async () => ({ ok: false })),
|
|
84
|
+
redo: vi.fn(async () => ({ ok: false })),
|
|
85
|
+
state: { undo: [], redo: [] },
|
|
86
|
+
},
|
|
87
|
+
readOptionalProjectFile: vi.fn(async () => ""),
|
|
88
|
+
readProjectFile: vi.fn(async () => ""),
|
|
89
|
+
writeProjectFile: vi.fn(async () => undefined),
|
|
90
|
+
domEditSaveTimestampRef: saveTimestampRef,
|
|
91
|
+
showToast: vi.fn(),
|
|
92
|
+
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
93
|
+
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
94
|
+
leftSidebarRef,
|
|
95
|
+
handleCopy: vi.fn(() => false),
|
|
96
|
+
handlePaste: vi.fn(async () => undefined),
|
|
97
|
+
handleCut: vi.fn(async () => false),
|
|
98
|
+
onResetKeyframes: vi.fn(() => false),
|
|
99
|
+
onDeleteSelectedKeyframes: keyframeDelete,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<TextAreaField
|
|
105
|
+
label="Content"
|
|
106
|
+
value={value}
|
|
107
|
+
onCommit={(next) => {
|
|
108
|
+
textCommits(next);
|
|
109
|
+
setValue(next);
|
|
110
|
+
// Text persistence rebuilds the selected parent's text-field model
|
|
111
|
+
// from the preview. Model that refresh: it must not transfer keyboard
|
|
112
|
+
// ownership away from the still-mounted Content editor.
|
|
113
|
+
setSelectionRefreshed(true);
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
<button type="button" data-testid="canvas" data-selection-refreshed={selectionRefreshed}>
|
|
117
|
+
Canvas
|
|
118
|
+
</button>
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
|
124
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
|
125
|
+
if (!setter) throw new Error("expected native textarea value setter");
|
|
126
|
+
setter.call(textarea, value);
|
|
127
|
+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
128
|
+
textarea.setSelectionRange(value.length, value.length);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function pressBackspace(target: HTMLElement): KeyboardEvent {
|
|
132
|
+
const event = new KeyboardEvent("keydown", {
|
|
133
|
+
key: "Backspace",
|
|
134
|
+
bubbles: true,
|
|
135
|
+
cancelable: true,
|
|
136
|
+
});
|
|
137
|
+
target.dispatchEvent(event);
|
|
138
|
+
return event;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
vi.useFakeTimers();
|
|
143
|
+
timelineDelete.mockClear();
|
|
144
|
+
domDelete.mockClear();
|
|
145
|
+
keyframeDelete.mockClear();
|
|
146
|
+
textCommits.mockClear();
|
|
147
|
+
usePlayerStore.getState().reset();
|
|
148
|
+
usePlayerStore.getState().setElements([
|
|
149
|
+
{
|
|
150
|
+
id: "selected-card",
|
|
151
|
+
tag: "section",
|
|
152
|
+
start: 0,
|
|
153
|
+
duration: 10,
|
|
154
|
+
track: 0,
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
usePlayerStore.getState().setSelectedElementId("selected-card");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
if (root) act(() => root?.unmount());
|
|
162
|
+
root = null;
|
|
163
|
+
document.body.innerHTML = "";
|
|
164
|
+
usePlayerStore.getState().reset();
|
|
165
|
+
vi.useRealTimers();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("useAppHotkeys text-field ownership", () => {
|
|
169
|
+
it("keeps two Backspaces in a child text editor across its scheduled save", () => {
|
|
170
|
+
const host = document.createElement("div");
|
|
171
|
+
document.body.append(host);
|
|
172
|
+
root = createRoot(host);
|
|
173
|
+
act(() => root?.render(<Harness />));
|
|
174
|
+
|
|
175
|
+
const textarea = host.querySelector("textarea");
|
|
176
|
+
const canvas = host.querySelector<HTMLButtonElement>('[data-testid="canvas"]');
|
|
177
|
+
if (!textarea || !canvas) throw new Error("expected text editor and canvas target");
|
|
178
|
+
|
|
179
|
+
textarea.focus();
|
|
180
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
181
|
+
|
|
182
|
+
const first = pressBackspace(textarea);
|
|
183
|
+
act(() => setTextareaValue(textarea, "Kicke"));
|
|
184
|
+
act(() => vi.advanceTimersByTime(120));
|
|
185
|
+
|
|
186
|
+
expect(first.defaultPrevented).toBe(false);
|
|
187
|
+
expect(textCommits).toHaveBeenLastCalledWith("Kicke");
|
|
188
|
+
expect(canvas.dataset.selectionRefreshed).toBe("true");
|
|
189
|
+
expect(document.activeElement).toBe(textarea);
|
|
190
|
+
expect(textarea.selectionStart).toBe(5);
|
|
191
|
+
expect(textarea.selectionEnd).toBe(5);
|
|
192
|
+
|
|
193
|
+
const second = pressBackspace(textarea);
|
|
194
|
+
act(() => setTextareaValue(textarea, "Kick"));
|
|
195
|
+
act(() => vi.advanceTimersByTime(120));
|
|
196
|
+
|
|
197
|
+
expect(second.defaultPrevented).toBe(false);
|
|
198
|
+
expect(textarea.value).toBe("Kick");
|
|
199
|
+
expect(textCommits.mock.calls).toEqual([["Kicke"], ["Kick"]]);
|
|
200
|
+
expect(document.activeElement).toBe(textarea);
|
|
201
|
+
expect(textarea.selectionStart).toBe(4);
|
|
202
|
+
expect(textarea.selectionEnd).toBe(4);
|
|
203
|
+
expect(timelineDelete).not.toHaveBeenCalled();
|
|
204
|
+
expect(domDelete).not.toHaveBeenCalled();
|
|
205
|
+
expect(keyframeDelete).not.toHaveBeenCalled();
|
|
206
|
+
|
|
207
|
+
canvas.focus();
|
|
208
|
+
const canvasDelete = pressBackspace(canvas);
|
|
209
|
+
|
|
210
|
+
expect(canvasDelete.defaultPrevented).toBe(true);
|
|
211
|
+
expect(timelineDelete).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(domDelete).not.toHaveBeenCalled();
|
|
213
|
+
expect(keyframeDelete).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
|
+
import {
|
|
8
|
+
applyStudioBoxSize,
|
|
9
|
+
applyStudioPathOffset,
|
|
10
|
+
applyStudioRotation,
|
|
11
|
+
readStudioBoxSize,
|
|
12
|
+
readStudioPathOffset,
|
|
13
|
+
readStudioRotation,
|
|
14
|
+
} from "../components/editor/manualEdits";
|
|
15
|
+
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
16
|
+
|
|
17
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
|
|
19
|
+
describe("useDomGeometryCommits rollback", () => {
|
|
20
|
+
it("restores every optimistic geometry mutation when persistence rejects", async () => {
|
|
21
|
+
const element = document.createElement("div");
|
|
22
|
+
element.id = "box";
|
|
23
|
+
document.body.append(element);
|
|
24
|
+
applyStudioPathOffset(element, { x: 10, y: 20 });
|
|
25
|
+
applyStudioBoxSize(element, { width: 100, height: 80 });
|
|
26
|
+
applyStudioRotation(element, { angle: 15 });
|
|
27
|
+
const selection = {
|
|
28
|
+
id: "box",
|
|
29
|
+
selector: "#box",
|
|
30
|
+
element,
|
|
31
|
+
} as unknown as DomEditSelection;
|
|
32
|
+
const failure = new Error("save failed");
|
|
33
|
+
const commitPositionPatchToHtml = vi.fn().mockRejectedValue(failure);
|
|
34
|
+
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
|
|
35
|
+
const host = document.createElement("div");
|
|
36
|
+
const root = createRoot(host);
|
|
37
|
+
|
|
38
|
+
function Probe() {
|
|
39
|
+
commits = useDomGeometryCommits({
|
|
40
|
+
previewIframeRef: { current: null },
|
|
41
|
+
showToast: vi.fn(),
|
|
42
|
+
commitPositionPatchToHtml,
|
|
43
|
+
});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
act(() => root.render(<Probe />));
|
|
48
|
+
await expect(commits!.handleDomPathOffsetCommit(selection, { x: 50, y: 60 })).rejects.toBe(
|
|
49
|
+
failure,
|
|
50
|
+
);
|
|
51
|
+
await expect(
|
|
52
|
+
commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
|
|
53
|
+
).rejects.toBe(failure);
|
|
54
|
+
await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
|
|
55
|
+
|
|
56
|
+
expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
|
|
57
|
+
expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });
|
|
58
|
+
expect(readStudioRotation(element)).toEqual({ angle: 15 });
|
|
59
|
+
act(() => root.unmount());
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -4,6 +4,12 @@ import {
|
|
|
4
4
|
applyStudioPathOffset,
|
|
5
5
|
applyStudioBoxSize,
|
|
6
6
|
applyStudioRotation,
|
|
7
|
+
captureStudioPathOffset,
|
|
8
|
+
captureStudioBoxSize,
|
|
9
|
+
captureStudioRotation,
|
|
10
|
+
restoreStudioPathOffset,
|
|
11
|
+
restoreStudioBoxSize,
|
|
12
|
+
restoreStudioRotation,
|
|
7
13
|
clearStudioPathOffset,
|
|
8
14
|
clearStudioBoxSize,
|
|
9
15
|
clearStudioRotation,
|
|
@@ -51,10 +57,14 @@ export function useDomGeometryCommits({
|
|
|
51
57
|
showToast(error.message, "error");
|
|
52
58
|
return Promise.reject(error);
|
|
53
59
|
}
|
|
60
|
+
const before = captureStudioPathOffset(selection.element);
|
|
54
61
|
applyStudioPathOffset(selection.element, next);
|
|
55
62
|
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
56
63
|
label: "Move layer",
|
|
57
64
|
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
65
|
+
}).catch((error) => {
|
|
66
|
+
restoreStudioPathOffset(selection.element, before);
|
|
67
|
+
throw error;
|
|
58
68
|
});
|
|
59
69
|
},
|
|
60
70
|
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
@@ -71,6 +81,8 @@ export function useDomGeometryCommits({
|
|
|
71
81
|
showToast(error.message, "error");
|
|
72
82
|
return Promise.reject(error);
|
|
73
83
|
}
|
|
84
|
+
const beforeSize = captureStudioBoxSize(selection.element);
|
|
85
|
+
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
|
|
74
86
|
applyStudioBoxSize(selection.element, next);
|
|
75
87
|
// Anchored-corner resize (NW/NE/SW) also moves the element to keep the
|
|
76
88
|
// opposite corner fixed. Apply the offset and emit BOTH patch sets in a
|
|
@@ -86,6 +98,10 @@ export function useDomGeometryCommits({
|
|
|
86
98
|
return commitPositionPatchToHtml(selection, patches, {
|
|
87
99
|
label: "Resize layer box",
|
|
88
100
|
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
101
|
+
}).catch((error) => {
|
|
102
|
+
restoreStudioBoxSize(selection.element, beforeSize);
|
|
103
|
+
if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
|
|
104
|
+
throw error;
|
|
89
105
|
});
|
|
90
106
|
},
|
|
91
107
|
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
@@ -98,10 +114,14 @@ export function useDomGeometryCommits({
|
|
|
98
114
|
showToast(error.message, "error");
|
|
99
115
|
return Promise.reject(error);
|
|
100
116
|
}
|
|
117
|
+
const before = captureStudioRotation(selection.element);
|
|
101
118
|
applyStudioRotation(selection.element, next);
|
|
102
119
|
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
|
103
120
|
label: "Rotate layer",
|
|
104
121
|
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
122
|
+
}).catch((error) => {
|
|
123
|
+
restoreStudioRotation(selection.element, before);
|
|
124
|
+
throw error;
|
|
105
125
|
});
|
|
106
126
|
},
|
|
107
127
|
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
|
|
6
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
type WriteProjectFile = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
13
|
+
const captured: { handle: EditorSaveHandle | null } = { handle: null };
|
|
14
|
+
|
|
15
|
+
function Probe() {
|
|
16
|
+
captured.handle = useEditorSave({
|
|
17
|
+
editingPathRef: { current: "index.html" },
|
|
18
|
+
projectIdRef: { current: "project-a" },
|
|
19
|
+
readProjectFile: vi.fn(async () => "before"),
|
|
20
|
+
writeProjectFile,
|
|
21
|
+
recordEdit: vi.fn(async () => undefined),
|
|
22
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
23
|
+
setRefreshKey: vi.fn(),
|
|
24
|
+
showToast: vi.fn(),
|
|
25
|
+
});
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const root = createRoot(document.createElement("div"));
|
|
30
|
+
await act(async () => root.render(<Probe />));
|
|
31
|
+
if (!captured.handle) throw new Error("Editor save handle was not mounted");
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
handle: captured.handle,
|
|
35
|
+
unmount: () => act(async () => root.unmount()),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("useEditorSave pending work", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.stubGlobal(
|
|
42
|
+
"requestAnimationFrame",
|
|
43
|
+
vi.fn(() => 41),
|
|
44
|
+
);
|
|
45
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
49
|
+
|
|
50
|
+
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
|
|
51
|
+
const writeProjectFile = vi.fn(async () => undefined);
|
|
52
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
53
|
+
act(() => mounted.handle.handleContentChange("studio candidate"));
|
|
54
|
+
|
|
55
|
+
expect(mounted.handle.getPendingCandidate()).toEqual({
|
|
56
|
+
projectId: "project-a",
|
|
57
|
+
path: "index.html",
|
|
58
|
+
content: "studio candidate",
|
|
59
|
+
});
|
|
60
|
+
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
|
|
61
|
+
expect(writeProjectFile).toHaveBeenCalledWith("index.html", "studio candidate", "before");
|
|
62
|
+
|
|
63
|
+
await mounted.unmount();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("joins an in-flight source save instead of writing the frozen candidate twice", async () => {
|
|
67
|
+
let frame: FrameRequestCallback | null = null;
|
|
68
|
+
vi.stubGlobal(
|
|
69
|
+
"requestAnimationFrame",
|
|
70
|
+
vi.fn((callback: FrameRequestCallback) => {
|
|
71
|
+
frame = callback;
|
|
72
|
+
return 42;
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
let finishWrite!: () => void;
|
|
76
|
+
const writeProjectFile = vi
|
|
77
|
+
.fn()
|
|
78
|
+
.mockImplementationOnce(
|
|
79
|
+
() =>
|
|
80
|
+
new Promise<void>((resolve) => {
|
|
81
|
+
finishWrite = resolve;
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
.mockResolvedValue(undefined);
|
|
85
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
86
|
+
act(() => mounted.handle.handleContentChange("candidate"));
|
|
87
|
+
act(() => frame?.(0));
|
|
88
|
+
await vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledOnce());
|
|
89
|
+
|
|
90
|
+
const drained = mounted.handle.flushPendingSave();
|
|
91
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
92
|
+
finishWrite();
|
|
93
|
+
await expect(drained).resolves.toEqual({ status: "clean" });
|
|
94
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
95
|
+
await mounted.unmount();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("preserves conflict details when flushing a buffered source candidate", async () => {
|
|
99
|
+
const conflict = new StudioFileConflictError({
|
|
100
|
+
filePath: "index.html",
|
|
101
|
+
currentVersion: "external-v2",
|
|
102
|
+
currentContent: "external",
|
|
103
|
+
attemptedContent: "studio candidate",
|
|
104
|
+
});
|
|
105
|
+
const mounted = await mountEditorSave(async () => {
|
|
106
|
+
throw conflict;
|
|
107
|
+
});
|
|
108
|
+
act(() => mounted.handle.handleContentChange("studio candidate"));
|
|
109
|
+
|
|
110
|
+
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({
|
|
111
|
+
status: "conflict",
|
|
112
|
+
error: conflict,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await mounted.unmount();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("discards an rAF-buffered candidate without persisting it", async () => {
|
|
119
|
+
const writeProjectFile = vi.fn(async () => undefined);
|
|
120
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
121
|
+
act(() => mounted.handle.handleContentChange("discard me"));
|
|
122
|
+
act(() => mounted.handle.discardPendingSave());
|
|
123
|
+
|
|
124
|
+
expect(mounted.handle.getPendingCandidate()).toBeNull();
|
|
125
|
+
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
|
|
126
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
127
|
+
expect(cancelAnimationFrame).toHaveBeenCalledWith(41);
|
|
128
|
+
|
|
129
|
+
await mounted.unmount();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -2,6 +2,10 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
3
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
4
4
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
|
+
import {
|
|
6
|
+
StudioFileConflictError,
|
|
7
|
+
type StudioSaveDrainResult,
|
|
8
|
+
} from "../utils/studioSaveDiagnostics";
|
|
5
9
|
|
|
6
10
|
interface RecordEditInput {
|
|
7
11
|
label: string;
|
|
@@ -21,6 +25,25 @@ interface UseEditorSaveOptions {
|
|
|
21
25
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
export interface EditorSaveCandidate {
|
|
29
|
+
projectId: string;
|
|
30
|
+
path: string;
|
|
31
|
+
content: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type EditorSaveDrainResult = StudioSaveDrainResult;
|
|
35
|
+
|
|
36
|
+
export interface EditorSaveHandle {
|
|
37
|
+
saveRafRef: React.MutableRefObject<number | null>;
|
|
38
|
+
handleContentChange: (content: string) => void;
|
|
39
|
+
/** Read by the external-reload reconciliation introduced in stack PR #2993. */
|
|
40
|
+
getPendingCandidate: () => EditorSaveCandidate | null;
|
|
41
|
+
/** Wired into the external-reload drain by stack PR #2993. */
|
|
42
|
+
flushPendingSave: () => Promise<EditorSaveDrainResult>;
|
|
43
|
+
/** Used by PR #2993 when the external version wins. */
|
|
44
|
+
discardPendingSave: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export function useEditorSave({
|
|
25
48
|
editingPathRef,
|
|
26
49
|
projectIdRef,
|
|
@@ -30,12 +53,70 @@ export function useEditorSave({
|
|
|
30
53
|
domEditSaveTimestampRef,
|
|
31
54
|
setRefreshKey,
|
|
32
55
|
showToast,
|
|
33
|
-
}: UseEditorSaveOptions) {
|
|
56
|
+
}: UseEditorSaveOptions): EditorSaveHandle {
|
|
34
57
|
const saveRafRef = useRef<number | null>(null);
|
|
35
58
|
const refreshRafRef = useRef<number | null>(null);
|
|
36
59
|
// One error toast per burst of failures — every keystroke retries the save,
|
|
37
60
|
// and error toasts persist until dismissed, so don't stack duplicates.
|
|
38
61
|
const lastFailureToastAtRef = useRef(0);
|
|
62
|
+
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
63
|
+
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
|
|
64
|
+
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
65
|
+
|
|
66
|
+
const reportFailure = useCallback(
|
|
67
|
+
(path: string, error: unknown) => {
|
|
68
|
+
trackStudioEvent("save_failure", {
|
|
69
|
+
source: "code_editor",
|
|
70
|
+
error_message: error instanceof Error ? error.message : "unknown",
|
|
71
|
+
});
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
if (now - lastFailureToastAtRef.current > 5000) {
|
|
74
|
+
lastFailureToastAtRef.current = now;
|
|
75
|
+
showToast(
|
|
76
|
+
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
|
77
|
+
"error",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[showToast],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const persistCandidate = useCallback(
|
|
85
|
+
(candidate: EditorSaveCandidate): Promise<EditorSaveDrainResult> => {
|
|
86
|
+
const task = saveProjectFilesWithHistory({
|
|
87
|
+
projectId: candidate.projectId,
|
|
88
|
+
label: "Edit source",
|
|
89
|
+
kind: "source",
|
|
90
|
+
coalesceKey: `source:${candidate.path}`,
|
|
91
|
+
files: { [candidate.path]: candidate.content },
|
|
92
|
+
readFile: readProjectFile,
|
|
93
|
+
writeFile: writeProjectFile,
|
|
94
|
+
recordEdit,
|
|
95
|
+
})
|
|
96
|
+
.then<EditorSaveDrainResult>(() => {
|
|
97
|
+
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
|
|
98
|
+
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
99
|
+
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
100
|
+
return { status: "clean" };
|
|
101
|
+
})
|
|
102
|
+
.catch<EditorSaveDrainResult>((error: unknown) => {
|
|
103
|
+
reportFailure(candidate.path, error);
|
|
104
|
+
return error instanceof StudioFileConflictError
|
|
105
|
+
? { status: "conflict", error }
|
|
106
|
+
: { status: "failed", error };
|
|
107
|
+
})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
if (inFlightRef.current === task) {
|
|
110
|
+
inFlightRef.current = null;
|
|
111
|
+
inFlightCandidateRef.current = null;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
inFlightRef.current = task;
|
|
115
|
+
inFlightCandidateRef.current = candidate;
|
|
116
|
+
return task;
|
|
117
|
+
},
|
|
118
|
+
[readProjectFile, recordEdit, reportFailure, setRefreshKey, writeProjectFile],
|
|
119
|
+
);
|
|
39
120
|
|
|
40
121
|
const handleContentChange = useCallback(
|
|
41
122
|
(content: string) => {
|
|
@@ -44,53 +125,46 @@ export function useEditorSave({
|
|
|
44
125
|
const path = editingPathRef.current;
|
|
45
126
|
if (!path) return;
|
|
46
127
|
|
|
128
|
+
const candidate = { projectId: pid, path, content };
|
|
129
|
+
pendingCandidateRef.current = candidate;
|
|
130
|
+
|
|
47
131
|
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
|
48
132
|
saveRafRef.current = requestAnimationFrame(() => {
|
|
133
|
+
saveRafRef.current = null;
|
|
49
134
|
domEditSaveTimestampRef.current = Date.now();
|
|
50
|
-
|
|
51
|
-
projectId: pid,
|
|
52
|
-
label: "Edit source",
|
|
53
|
-
kind: "source",
|
|
54
|
-
coalesceKey: `source:${path}`,
|
|
55
|
-
files: { [path]: content },
|
|
56
|
-
readFile: readProjectFile,
|
|
57
|
-
writeFile: writeProjectFile,
|
|
58
|
-
recordEdit,
|
|
59
|
-
})
|
|
60
|
-
.then(() => {
|
|
61
|
-
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
62
|
-
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
63
|
-
})
|
|
64
|
-
.catch((error) => {
|
|
65
|
-
trackStudioEvent("save_failure", {
|
|
66
|
-
source: "code_editor",
|
|
67
|
-
error_message: error instanceof Error ? error.message : "unknown",
|
|
68
|
-
});
|
|
69
|
-
const now = Date.now();
|
|
70
|
-
if (now - lastFailureToastAtRef.current > 5000) {
|
|
71
|
-
lastFailureToastAtRef.current = now;
|
|
72
|
-
showToast(
|
|
73
|
-
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
|
74
|
-
"error",
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
135
|
+
void persistCandidate(candidate);
|
|
78
136
|
});
|
|
79
137
|
},
|
|
80
|
-
[
|
|
81
|
-
domEditSaveTimestampRef,
|
|
82
|
-
editingPathRef,
|
|
83
|
-
projectIdRef,
|
|
84
|
-
readProjectFile,
|
|
85
|
-
recordEdit,
|
|
86
|
-
setRefreshKey,
|
|
87
|
-
showToast,
|
|
88
|
-
writeProjectFile,
|
|
89
|
-
],
|
|
138
|
+
[domEditSaveTimestampRef, editingPathRef, projectIdRef, persistCandidate],
|
|
90
139
|
);
|
|
91
140
|
|
|
141
|
+
const flushPendingSave = useCallback(async (): Promise<EditorSaveDrainResult> => {
|
|
142
|
+
if (saveRafRef.current != null) {
|
|
143
|
+
cancelAnimationFrame(saveRafRef.current);
|
|
144
|
+
saveRafRef.current = null;
|
|
145
|
+
}
|
|
146
|
+
const candidate = pendingCandidateRef.current;
|
|
147
|
+
if (candidate && candidate === inFlightCandidateRef.current && inFlightRef.current) {
|
|
148
|
+
return inFlightRef.current;
|
|
149
|
+
}
|
|
150
|
+
if (candidate) {
|
|
151
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
152
|
+
return persistCandidate(candidate);
|
|
153
|
+
}
|
|
154
|
+
return (await inFlightRef.current) ?? { status: "clean" };
|
|
155
|
+
}, [domEditSaveTimestampRef, persistCandidate]);
|
|
156
|
+
|
|
157
|
+
const discardPendingSave = useCallback(() => {
|
|
158
|
+
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
|
159
|
+
saveRafRef.current = null;
|
|
160
|
+
pendingCandidateRef.current = null;
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
92
163
|
return {
|
|
93
164
|
saveRafRef,
|
|
94
165
|
handleContentChange,
|
|
166
|
+
getPendingCandidate: () => pendingCandidateRef.current,
|
|
167
|
+
flushPendingSave,
|
|
168
|
+
discardPendingSave,
|
|
95
169
|
};
|
|
96
170
|
}
|