@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.93",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,17 +47,18 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/core": "0.7.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
50
|
+
"@hyperframes/core": "0.7.93",
|
|
51
|
+
"@hyperframes/parsers": "0.7.93",
|
|
52
|
+
"@hyperframes/studio-server": "0.7.93",
|
|
53
|
+
"@hyperframes/sdk": "0.7.93",
|
|
54
|
+
"@hyperframes/player": "0.7.93"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
58
58
|
"@types/react-dom": "19",
|
|
59
59
|
"@vitejs/plugin-react": "^4.0.0",
|
|
60
60
|
"autoprefixer": "^10.4.0",
|
|
61
|
+
"fake-indexeddb": "^6.2.5",
|
|
61
62
|
"postcss": "^8.4.0",
|
|
62
63
|
"puppeteer-core": "^25.2.1",
|
|
63
64
|
"tailwindcss": "^3.4.0",
|
|
@@ -66,7 +67,7 @@
|
|
|
66
67
|
"vite": "^6.4.2",
|
|
67
68
|
"vitest": "^3.2.4",
|
|
68
69
|
"zustand": "^5.0.0",
|
|
69
|
-
"@hyperframes/producer": "0.7.
|
|
70
|
+
"@hyperframes/producer": "0.7.93"
|
|
70
71
|
},
|
|
71
72
|
"peerDependencies": {
|
|
72
73
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
|
4
4
|
import { usePlayerStore } from "./player";
|
|
5
5
|
import { StudioOverlays } from "./components/StudioOverlays";
|
|
6
6
|
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
|
7
|
+
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
|
|
7
8
|
import { useCaptionStore } from "./captions/store";
|
|
8
9
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
9
10
|
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
@@ -22,6 +23,7 @@ import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|
|
22
23
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
23
24
|
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
|
24
25
|
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
|
|
26
|
+
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
|
|
25
27
|
import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
|
26
28
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
|
27
29
|
import { useClipboard } from "./hooks/useClipboard";
|
|
@@ -56,25 +58,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
|
|
|
56
58
|
import { DomEditProvider } from "./contexts/DomEditContext";
|
|
57
59
|
import { StudioSplash } from "./components/StudioSplash";
|
|
58
60
|
import { useServerConnection } from "./hooks/useServerConnection";
|
|
61
|
+
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
|
|
59
62
|
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
|
|
60
63
|
import {
|
|
61
64
|
normalizeStudioCompositionPath,
|
|
62
65
|
readStudioUrlStateFromWindow,
|
|
63
66
|
resolveMasterCompositionPath,
|
|
64
67
|
} from "./utils/studioUrlState";
|
|
65
|
-
import { trackStudioSessionStart } from "./telemetry/events";
|
|
66
|
-
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
|
67
68
|
// fallow-ignore-next-line complexity
|
|
68
69
|
export function StudioApp() {
|
|
69
70
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
|
70
71
|
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
71
72
|
const viewModeValue = useViewModeState();
|
|
72
|
-
|
|
73
|
-
if (resolving || waitingForServer) return;
|
|
74
|
-
if (hasFiredSessionStart()) return;
|
|
75
|
-
markSessionStartFired();
|
|
76
|
-
trackStudioSessionStart({ has_project: projectId != null });
|
|
77
|
-
}, [projectId, resolving, waitingForServer]);
|
|
73
|
+
useStudioSessionStart(projectId, resolving, waitingForServer);
|
|
78
74
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
79
75
|
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
|
80
76
|
() => initialUrlStateRef.current.activeCompPath == null,
|
|
@@ -130,7 +126,6 @@ export function StudioApp() {
|
|
|
130
126
|
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
|
|
131
127
|
projectId,
|
|
132
128
|
activeCompPath,
|
|
133
|
-
domEditSaveTimestampRef,
|
|
134
129
|
masterCompPath,
|
|
135
130
|
);
|
|
136
131
|
useEffect(() => {
|
|
@@ -144,20 +139,24 @@ export function StudioApp() {
|
|
|
144
139
|
setActiveCompPathHydrated(true);
|
|
145
140
|
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
|
|
146
141
|
const previewPersistence = usePreviewPersistence({
|
|
147
|
-
projectId,
|
|
148
142
|
showToast,
|
|
149
143
|
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
|
150
144
|
writeProjectFile: fileManager.writeProjectFile,
|
|
151
145
|
recordEdit: editHistory.recordEdit,
|
|
152
146
|
previewIframeRef,
|
|
153
147
|
activeCompPathRef,
|
|
154
|
-
domEditSaveTimestampRef,
|
|
155
148
|
reloadPreview: () => setRefreshKey((k) => k + 1),
|
|
149
|
+
});
|
|
150
|
+
const externalFileChanges = useStudioExternalFileChanges({
|
|
151
|
+
projectId,
|
|
152
|
+
activeCompPath,
|
|
153
|
+
masterCompPath,
|
|
154
|
+
fileManager,
|
|
155
|
+
previewPersistence,
|
|
156
156
|
pendingTimelineEditPathRef,
|
|
157
|
+
reloadPreview,
|
|
157
158
|
});
|
|
158
159
|
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
|
159
|
-
// Stable identity — what the ref indirection is for. An inline arrow re-created
|
|
160
|
-
// the memoized timeline handlers (it is in their deps) on every render.
|
|
161
160
|
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
|
|
162
161
|
const timelineEditing = useTimelineEditing({
|
|
163
162
|
projectId,
|
|
@@ -483,12 +482,13 @@ export function StudioApp() {
|
|
|
483
482
|
})();
|
|
484
483
|
}}
|
|
485
484
|
/>
|
|
486
|
-
{previewPersistence.domEditSaveQueuePaused && (
|
|
485
|
+
{previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
|
|
487
486
|
<SaveQueuePausedBanner
|
|
488
487
|
message={previewPersistence.domEditSaveQueuePaused}
|
|
489
488
|
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
|
|
490
489
|
/>
|
|
491
490
|
)}
|
|
491
|
+
<ExternalFileConflictBanner coordinator={externalFileChanges} />
|
|
492
492
|
{viewModeValue.viewMode === "storyboard" && (
|
|
493
493
|
<StoryboardView
|
|
494
494
|
projectId={projectId}
|
|
@@ -52,7 +52,7 @@ export function DesignPanelPromoteProvider({
|
|
|
52
52
|
children: ReactNode;
|
|
53
53
|
}) {
|
|
54
54
|
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
|
|
55
|
-
const handle = useSdkSession(projectId, targetPath
|
|
55
|
+
const handle = useSdkSession(projectId, targetPath);
|
|
56
56
|
const rawPersist = useVariablesPersist({
|
|
57
57
|
...persistDeps,
|
|
58
58
|
sdkSession: handle.session,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
6
|
+
import type { ExternalFileChangeCoordinatorHandle } from "../hooks/useExternalFileChangeCoordinator";
|
|
7
|
+
import { ExternalFileConflictBanner } from "./ExternalFileConflictBanner";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
describe("ExternalFileConflictBanner", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.replaceChildren();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps destructive choices explicit and exposes both full versions for review", async () => {
|
|
18
|
+
const container = document.createElement("div");
|
|
19
|
+
document.body.append(container);
|
|
20
|
+
const root = createRoot(container);
|
|
21
|
+
const conflict = new StudioFileConflictError({
|
|
22
|
+
filePath: "index.html",
|
|
23
|
+
currentVersion: "v2",
|
|
24
|
+
currentContent: "<html>external</html>",
|
|
25
|
+
attemptedContent: "<html>studio</html>",
|
|
26
|
+
});
|
|
27
|
+
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
|
28
|
+
blocked: { status: "conflict", generation: 1, error: conflict, payload: {} },
|
|
29
|
+
retry: vi.fn(async () => undefined),
|
|
30
|
+
useExternalFile: vi.fn(async () => undefined),
|
|
31
|
+
keepStudioFile: vi.fn(async () => undefined),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
|
35
|
+
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
|
|
36
|
+
"Preview is paused so neither version is lost",
|
|
37
|
+
);
|
|
38
|
+
expect(document.body.textContent).toContain("Discard Studio edits and reload file");
|
|
39
|
+
expect(document.body.textContent).toContain("Overwrite file with Studio version");
|
|
40
|
+
|
|
41
|
+
const review = Array.from(document.querySelectorAll("button")).find((button) =>
|
|
42
|
+
button.textContent?.includes("Review or export both"),
|
|
43
|
+
);
|
|
44
|
+
await act(async () => review?.click());
|
|
45
|
+
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
|
46
|
+
expect(Array.from(document.querySelectorAll("textarea"), (field) => field.value)).toEqual([
|
|
47
|
+
"<html>external</html>",
|
|
48
|
+
"<html>studio</html>",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
await act(async () => root.unmount());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("lets authors review and export the local candidate after a drain failure", async () => {
|
|
55
|
+
const container = document.createElement("div");
|
|
56
|
+
document.body.append(container);
|
|
57
|
+
const root = createRoot(container);
|
|
58
|
+
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
|
59
|
+
blocked: {
|
|
60
|
+
status: "failed",
|
|
61
|
+
generation: 1,
|
|
62
|
+
path: "index.html",
|
|
63
|
+
error: new Error("offline"),
|
|
64
|
+
payload: {},
|
|
65
|
+
studioContent: "<html>recover me</html>",
|
|
66
|
+
recovered: false,
|
|
67
|
+
},
|
|
68
|
+
retry: vi.fn(async () => undefined),
|
|
69
|
+
useExternalFile: vi.fn(async () => undefined),
|
|
70
|
+
keepStudioFile: vi.fn(async () => undefined),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
|
74
|
+
const review = Array.from(document.querySelectorAll("button")).find((button) =>
|
|
75
|
+
button.textContent?.includes("Review or export Studio draft"),
|
|
76
|
+
);
|
|
77
|
+
await act(async () => review?.click());
|
|
78
|
+
expect(document.querySelector("textarea")?.value).toBe("<html>recover me</html>");
|
|
79
|
+
expect(document.body.textContent).toContain("Copy");
|
|
80
|
+
expect(document.body.textContent).toContain("Download");
|
|
81
|
+
await act(async () => root.unmount());
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not offer a fake retry after remount and instead offers explicit overwrite", async () => {
|
|
85
|
+
const container = document.createElement("div");
|
|
86
|
+
document.body.append(container);
|
|
87
|
+
const root = createRoot(container);
|
|
88
|
+
const keepStudioFile = vi.fn(async () => undefined);
|
|
89
|
+
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
|
90
|
+
blocked: {
|
|
91
|
+
status: "failed",
|
|
92
|
+
generation: 1,
|
|
93
|
+
path: "index.html",
|
|
94
|
+
error: new Error("offline"),
|
|
95
|
+
payload: { path: "index.html", version: "v2", content: "external" },
|
|
96
|
+
studioContent: "recovered draft",
|
|
97
|
+
recovered: true,
|
|
98
|
+
},
|
|
99
|
+
retry: vi.fn(async () => undefined),
|
|
100
|
+
useExternalFile: vi.fn(async () => undefined),
|
|
101
|
+
keepStudioFile,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
|
105
|
+
expect(document.body.textContent).not.toContain("Retry save");
|
|
106
|
+
const overwrite = Array.from(document.querySelectorAll("button")).find((button) =>
|
|
107
|
+
button.textContent?.includes("Overwrite file with recovered Studio draft"),
|
|
108
|
+
);
|
|
109
|
+
expect(overwrite).toBeTruthy();
|
|
110
|
+
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
111
|
+
await act(async () => overwrite?.click());
|
|
112
|
+
expect(keepStudioFile).toHaveBeenCalledOnce();
|
|
113
|
+
|
|
114
|
+
await act(async () => root.unmount());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not offer retry when a failed DOM edit has no recoverable source candidate", async () => {
|
|
118
|
+
const container = document.createElement("div");
|
|
119
|
+
document.body.append(container);
|
|
120
|
+
const root = createRoot(container);
|
|
121
|
+
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
|
122
|
+
blocked: {
|
|
123
|
+
status: "failed",
|
|
124
|
+
generation: 1,
|
|
125
|
+
path: "index.html",
|
|
126
|
+
error: new Error("offline"),
|
|
127
|
+
payload: { path: "index.html", version: "v2", content: "external" },
|
|
128
|
+
studioContent: null,
|
|
129
|
+
recovered: false,
|
|
130
|
+
},
|
|
131
|
+
retry: vi.fn(async () => undefined),
|
|
132
|
+
useExternalFile: vi.fn(async () => undefined),
|
|
133
|
+
keepStudioFile: vi.fn(async () => undefined),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
|
137
|
+
expect(document.body.textContent).not.toContain("Retry save");
|
|
138
|
+
expect(document.body.textContent).toContain("Discard Studio edits and reload file");
|
|
139
|
+
|
|
140
|
+
await act(async () => root.unmount());
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ExternalFileChangeBlockedState,
|
|
4
|
+
ExternalFileChangeCoordinatorHandle,
|
|
5
|
+
} from "../hooks/useExternalFileChangeCoordinator";
|
|
6
|
+
import { useDialogBehavior } from "./ui/useDialogBehavior";
|
|
7
|
+
|
|
8
|
+
function errorMessage(error: unknown): string {
|
|
9
|
+
return error instanceof Error ? error.message : String(error);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function downloadText(filename: string, content: string): void {
|
|
13
|
+
const url = URL.createObjectURL(new Blob([content], { type: "text/html;charset=utf-8" }));
|
|
14
|
+
const anchor = document.createElement("a");
|
|
15
|
+
anchor.href = url;
|
|
16
|
+
anchor.download = filename;
|
|
17
|
+
anchor.click();
|
|
18
|
+
URL.revokeObjectURL(url);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ConflictReview({
|
|
22
|
+
conflict,
|
|
23
|
+
onClose,
|
|
24
|
+
}: {
|
|
25
|
+
conflict: Extract<ExternalFileChangeBlockedState, { status: "conflict" }>;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
}) {
|
|
28
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
useDialogBehavior({ open: true, onClose, containerRef });
|
|
30
|
+
const external = conflict.error.currentContent ?? "(The server did not return file contents.)";
|
|
31
|
+
const studio = conflict.error.attemptedContent;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
|
|
36
|
+
onClick={onClose}
|
|
37
|
+
>
|
|
38
|
+
<div
|
|
39
|
+
ref={containerRef}
|
|
40
|
+
role="dialog"
|
|
41
|
+
aria-modal="true"
|
|
42
|
+
aria-labelledby="external-conflict-title"
|
|
43
|
+
tabIndex={-1}
|
|
44
|
+
className="max-h-[88vh] w-full max-w-5xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
|
|
45
|
+
onClick={(event) => event.stopPropagation()}
|
|
46
|
+
>
|
|
47
|
+
<div className="flex items-start justify-between gap-4">
|
|
48
|
+
<div>
|
|
49
|
+
<h2 id="external-conflict-title" className="text-base font-semibold">
|
|
50
|
+
Review both versions of {conflict.error.filePath}
|
|
51
|
+
</h2>
|
|
52
|
+
<p className="mt-1 text-xs text-neutral-400">
|
|
53
|
+
Reviewing or exporting does not change either version.
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
<button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
|
|
57
|
+
Close
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
61
|
+
{[
|
|
62
|
+
{ title: "File on disk", content: external, suffix: "external" },
|
|
63
|
+
{ title: "Unsaved Studio version", content: studio, suffix: "studio" },
|
|
64
|
+
].map((side) => (
|
|
65
|
+
<section key={side.suffix} aria-label={side.title}>
|
|
66
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
67
|
+
<h3 className="text-sm font-medium">{side.title}</h3>
|
|
68
|
+
<div className="flex gap-2">
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
|
72
|
+
onClick={() => void navigator.clipboard.writeText(side.content)}
|
|
73
|
+
>
|
|
74
|
+
Copy
|
|
75
|
+
</button>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
|
79
|
+
onClick={() =>
|
|
80
|
+
downloadText(`${conflict.error.filePath}.${side.suffix}.html`, side.content)
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
Download
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<textarea
|
|
88
|
+
readOnly
|
|
89
|
+
value={side.content}
|
|
90
|
+
className="h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
|
|
91
|
+
/>
|
|
92
|
+
</section>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function FailedDraftReview({
|
|
101
|
+
path,
|
|
102
|
+
content,
|
|
103
|
+
onClose,
|
|
104
|
+
}: {
|
|
105
|
+
path: string;
|
|
106
|
+
content: string;
|
|
107
|
+
onClose: () => void;
|
|
108
|
+
}) {
|
|
109
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
110
|
+
useDialogBehavior({ open: true, onClose, containerRef });
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
|
|
114
|
+
onClick={onClose}
|
|
115
|
+
>
|
|
116
|
+
<div
|
|
117
|
+
ref={containerRef}
|
|
118
|
+
role="dialog"
|
|
119
|
+
aria-modal="true"
|
|
120
|
+
aria-labelledby="failed-draft-title"
|
|
121
|
+
tabIndex={-1}
|
|
122
|
+
className="max-h-[88vh] w-full max-w-3xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
|
|
123
|
+
onClick={(event) => event.stopPropagation()}
|
|
124
|
+
>
|
|
125
|
+
<div className="flex items-start justify-between gap-4">
|
|
126
|
+
<div>
|
|
127
|
+
<h2 id="failed-draft-title" className="text-base font-semibold">
|
|
128
|
+
Recover unsaved Studio draft for {path}
|
|
129
|
+
</h2>
|
|
130
|
+
<p className="mt-1 text-xs text-neutral-400">
|
|
131
|
+
Copy or download this draft before choosing to discard it.
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
<button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
|
|
135
|
+
Close
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
|
142
|
+
onClick={() => void navigator.clipboard.writeText(content)}
|
|
143
|
+
>
|
|
144
|
+
Copy
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
|
149
|
+
onClick={() => downloadText(`${path}.studio.html`, content)}
|
|
150
|
+
>
|
|
151
|
+
Download
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
<textarea
|
|
155
|
+
readOnly
|
|
156
|
+
value={content}
|
|
157
|
+
className="mt-2 h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function ExternalFileConflictBanner({
|
|
165
|
+
coordinator,
|
|
166
|
+
}: {
|
|
167
|
+
coordinator: ExternalFileChangeCoordinatorHandle;
|
|
168
|
+
}) {
|
|
169
|
+
const [reviewing, setReviewing] = useState(false);
|
|
170
|
+
const blocked = coordinator.blocked;
|
|
171
|
+
if (!blocked) return null;
|
|
172
|
+
|
|
173
|
+
const conflict = blocked.status === "conflict" ? blocked : null;
|
|
174
|
+
const failure = blocked.status === "failed" ? blocked : null;
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
<div
|
|
178
|
+
role="alert"
|
|
179
|
+
className="absolute left-1/2 top-14 z-[94] flex max-w-[calc(100vw-32px)] -translate-x-1/2 flex-wrap items-center gap-3 rounded-md border border-amber-400/30 bg-amber-950/95 px-4 py-2 text-[12px] font-medium text-amber-50 shadow-lg"
|
|
180
|
+
>
|
|
181
|
+
<span>
|
|
182
|
+
{conflict
|
|
183
|
+
? `${conflict.error.filePath} changed outside Studio. Preview is paused so neither version is lost.`
|
|
184
|
+
: `Studio could not safely finish local saves: ${errorMessage(blocked.error)}. Preview is paused.`}
|
|
185
|
+
</span>
|
|
186
|
+
{conflict && (
|
|
187
|
+
<button type="button" onClick={() => setReviewing(true)} className="underline">
|
|
188
|
+
Review or export both
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
{failure?.studioContent != null && (
|
|
192
|
+
<button type="button" onClick={() => setReviewing(true)} className="underline">
|
|
193
|
+
Review or export Studio draft
|
|
194
|
+
</button>
|
|
195
|
+
)}
|
|
196
|
+
{failure && !failure.recovered && failure.studioContent != null && (
|
|
197
|
+
<button type="button" onClick={() => void coordinator.retry()} className="underline">
|
|
198
|
+
Retry save
|
|
199
|
+
</button>
|
|
200
|
+
)}
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => void coordinator.useExternalFile()}
|
|
204
|
+
className="rounded border border-amber-200/30 px-2 py-1"
|
|
205
|
+
>
|
|
206
|
+
Discard Studio edits and reload file
|
|
207
|
+
</button>
|
|
208
|
+
{conflict && (
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
onClick={() => {
|
|
212
|
+
if (
|
|
213
|
+
window.confirm(
|
|
214
|
+
"Overwrite the externally changed file with the Studio version? The server will preserve its normal backup before writing.",
|
|
215
|
+
)
|
|
216
|
+
) {
|
|
217
|
+
void coordinator.keepStudioFile();
|
|
218
|
+
}
|
|
219
|
+
}}
|
|
220
|
+
className="rounded border border-red-300/40 px-2 py-1 text-red-100"
|
|
221
|
+
>
|
|
222
|
+
Overwrite file with Studio version
|
|
223
|
+
</button>
|
|
224
|
+
)}
|
|
225
|
+
{failure?.recovered && failure.studioContent != null && (
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={() => {
|
|
229
|
+
if (
|
|
230
|
+
window.confirm(
|
|
231
|
+
"Overwrite the file with the recovered Studio draft? The current file will be preserved by the server's normal backup before writing.",
|
|
232
|
+
)
|
|
233
|
+
) {
|
|
234
|
+
void coordinator.keepStudioFile();
|
|
235
|
+
}
|
|
236
|
+
}}
|
|
237
|
+
className="rounded border border-red-300/40 px-2 py-1 text-red-100"
|
|
238
|
+
>
|
|
239
|
+
Overwrite file with recovered Studio draft
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
{reviewing && conflict && (
|
|
244
|
+
<ConflictReview conflict={conflict} onClose={() => setReviewing(false)} />
|
|
245
|
+
)}
|
|
246
|
+
{reviewing && failure?.studioContent != null && (
|
|
247
|
+
<FailedDraftReview
|
|
248
|
+
path={failure.path}
|
|
249
|
+
content={failure.studioContent}
|
|
250
|
+
onClose={() => setReviewing(false)}
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
</>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -253,7 +253,7 @@ function Transform3dField({
|
|
|
253
253
|
onCommit={(next) => {
|
|
254
254
|
const v = parse(next);
|
|
255
255
|
if (v != null && onCommitAnimatedProperty) {
|
|
256
|
-
|
|
256
|
+
return onCommitAnimatedProperty(ctx.element, prop, v);
|
|
257
257
|
}
|
|
258
258
|
}}
|
|
259
259
|
/>
|
|
@@ -21,7 +21,7 @@ export function CommitField({
|
|
|
21
21
|
liveCommit?: boolean;
|
|
22
22
|
align?: "left" | "right";
|
|
23
23
|
onPreview?: (nextValue: string) => void;
|
|
24
|
-
onCommit: (nextValue: string) => void
|
|
24
|
+
onCommit: (nextValue: string) => void | Promise<void>;
|
|
25
25
|
}) {
|
|
26
26
|
const [draft, setDraft] = useState(value);
|
|
27
27
|
const valueRef = useRef(value);
|
|
@@ -29,6 +29,19 @@ export function CommitField({
|
|
|
29
29
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
30
30
|
const focusedRef = useRef(false);
|
|
31
31
|
const dirtyRef = useRef(false);
|
|
32
|
+
const commitGenerationRef = useRef(0);
|
|
33
|
+
const pendingCommitRef = useRef<{
|
|
34
|
+
baseline: string;
|
|
35
|
+
optimistic: string;
|
|
36
|
+
} | null>(null);
|
|
37
|
+
const lastValueRef = useRef(value);
|
|
38
|
+
if (!Object.is(lastValueRef.current, value)) {
|
|
39
|
+
lastValueRef.current = value;
|
|
40
|
+
if (!Object.is(pendingCommitRef.current?.optimistic, value)) {
|
|
41
|
+
commitGenerationRef.current += 1;
|
|
42
|
+
pendingCommitRef.current = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
32
45
|
valueRef.current = value;
|
|
33
46
|
draftRef.current = draft;
|
|
34
47
|
|
|
@@ -67,14 +80,34 @@ export function CommitField({
|
|
|
67
80
|
}, 250);
|
|
68
81
|
};
|
|
69
82
|
const cancelGesture = () => {
|
|
83
|
+
commitGenerationRef.current += 1;
|
|
70
84
|
clearGestureSettleTimer();
|
|
71
85
|
gestureActiveRef.current = false;
|
|
72
86
|
gestureTransaction.cancel();
|
|
73
87
|
};
|
|
74
88
|
const commitDraft = (nextValue: string) => {
|
|
89
|
+
const generation = ++commitGenerationRef.current;
|
|
75
90
|
setDraft(nextValue);
|
|
76
91
|
onPreview?.(nextValue);
|
|
77
|
-
if (nextValue !== valueRef.current)
|
|
92
|
+
if (nextValue !== valueRef.current) {
|
|
93
|
+
const baseline = valueRef.current;
|
|
94
|
+
pendingCommitRef.current = { baseline, optimistic: nextValue };
|
|
95
|
+
const rollback = () => {
|
|
96
|
+
if (generation !== commitGenerationRef.current) return;
|
|
97
|
+
pendingCommitRef.current = null;
|
|
98
|
+
// The source write is authoritative. A rejected mutation must not leave
|
|
99
|
+
// the field showing an optimistic value that will disappear on seek.
|
|
100
|
+
setDraft(baseline);
|
|
101
|
+
onPreview?.(baseline);
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
void Promise.resolve(onCommit(nextValue)).then(() => {
|
|
105
|
+
if (generation === commitGenerationRef.current) pendingCommitRef.current = null;
|
|
106
|
+
}, rollback);
|
|
107
|
+
} catch {
|
|
108
|
+
rollback();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
78
111
|
};
|
|
79
112
|
const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
80
113
|
if (!gestureActiveRef.current) return false;
|
|
@@ -89,6 +122,7 @@ export function CommitField({
|
|
|
89
122
|
const nextDraft = adjustNumericToken(draftRef.current, direction, event);
|
|
90
123
|
if (!nextDraft) return;
|
|
91
124
|
event.preventDefault();
|
|
125
|
+
commitGenerationRef.current += 1;
|
|
92
126
|
dirtyRef.current = false;
|
|
93
127
|
gestureActiveRef.current = true;
|
|
94
128
|
gestureTransaction.preview(nextDraft);
|
|
@@ -148,6 +182,7 @@ export function CommitField({
|
|
|
148
182
|
focusedRef.current = true;
|
|
149
183
|
}}
|
|
150
184
|
onChange={(event) => {
|
|
185
|
+
commitGenerationRef.current += 1;
|
|
151
186
|
settleGesture();
|
|
152
187
|
dirtyRef.current = true;
|
|
153
188
|
setDraft(event.target.value);
|