mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): guard storyboard history navigation
This commit is contained in:
@@ -98,8 +98,16 @@ function clickButton(host: HTMLElement, label: string): void {
|
||||
act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
}
|
||||
|
||||
async function goBack(): Promise<void> {
|
||||
await act(async () => {
|
||||
window.history.back();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, "", "/?view=storyboard");
|
||||
window.history.replaceState({ entry: "timeline" }, "", "/");
|
||||
window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
|
||||
onSelectComposition.mockReset();
|
||||
});
|
||||
|
||||
@@ -140,4 +148,23 @@ describe("dirty storyboard voiceover view-mode guard", () => {
|
||||
expect(onSelectComposition).toHaveBeenCalledWith("frames/01-opening.html");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("guards browser history transitions on decline and allows them on accept", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const { host, root } = renderApp();
|
||||
makeVoiceoverDirty(host);
|
||||
|
||||
await goBack();
|
||||
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("storyboard");
|
||||
expect(window.location.search).toBe("?view=storyboard");
|
||||
expect(window.history.state).toEqual({ entry: "storyboard" });
|
||||
expect(confirm).toHaveBeenCalledWith("Discard unsaved voiceover changes?");
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await goBack();
|
||||
expect(host.querySelector("[data-view-mode]")?.textContent).toBe("timeline");
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.history.state).toEqual({ entry: "timeline" });
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,19 @@ function writeViewModeToUrl(mode: StudioViewMode): void {
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
}
|
||||
|
||||
interface HistoryLocation {
|
||||
href: string;
|
||||
state: unknown;
|
||||
}
|
||||
|
||||
function readHistoryLocation(): HistoryLocation | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return {
|
||||
href: window.location.href,
|
||||
state: window.history.state,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ViewModeValue {
|
||||
viewMode: StudioViewMode;
|
||||
/** Returns false when an active editor vetoes the transition. */
|
||||
@@ -54,6 +67,14 @@ export interface ViewModeValue {
|
||||
export function useViewModeState(): ViewModeValue {
|
||||
const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
|
||||
const guardsRef = useRef(new Set<ViewModeGuard>());
|
||||
const acceptedLocationRef = useRef<HistoryLocation | null>(readHistoryLocation());
|
||||
|
||||
const canSetViewMode = useCallback((mode: StudioViewMode) => {
|
||||
for (const guard of guardsRef.current) {
|
||||
if (!guard(mode)) return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Reflect genuine browser back/forward between history entries with a different
|
||||
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
|
||||
@@ -62,19 +83,32 @@ export function useViewModeState(): ViewModeValue {
|
||||
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
|
||||
// reflected here, by design.
|
||||
useEffect(() => {
|
||||
const onPopState = () => setMode(readViewModeFromUrl());
|
||||
const onPopState = () => {
|
||||
const mode = readViewModeFromUrl();
|
||||
if (mode !== viewMode && !canSetViewMode(mode)) {
|
||||
const acceptedLocation = acceptedLocationRef.current;
|
||||
if (acceptedLocation) {
|
||||
window.history.pushState(acceptedLocation.state, "", acceptedLocation.href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setMode(mode);
|
||||
acceptedLocationRef.current = readHistoryLocation();
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
}, [canSetViewMode, viewMode]);
|
||||
|
||||
const setViewMode = useCallback((mode: StudioViewMode) => {
|
||||
for (const guard of guardsRef.current) {
|
||||
if (!guard(mode)) return false;
|
||||
}
|
||||
setMode(mode);
|
||||
writeViewModeToUrl(mode);
|
||||
return true;
|
||||
}, []);
|
||||
const setViewMode = useCallback(
|
||||
(mode: StudioViewMode) => {
|
||||
if (!canSetViewMode(mode)) return false;
|
||||
setMode(mode);
|
||||
writeViewModeToUrl(mode);
|
||||
acceptedLocationRef.current = readHistoryLocation();
|
||||
return true;
|
||||
},
|
||||
[canSetViewMode],
|
||||
);
|
||||
|
||||
const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
|
||||
guardsRef.current.add(guard);
|
||||
|
||||
Reference in New Issue
Block a user