mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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 })));
|
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(() => {
|
beforeEach(() => {
|
||||||
window.history.replaceState({}, "", "/?view=storyboard");
|
window.history.replaceState({ entry: "timeline" }, "", "/");
|
||||||
|
window.history.pushState({ entry: "storyboard" }, "", "/?view=storyboard");
|
||||||
onSelectComposition.mockReset();
|
onSelectComposition.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,4 +148,23 @@ describe("dirty storyboard voiceover view-mode guard", () => {
|
|||||||
expect(onSelectComposition).toHaveBeenCalledWith("frames/01-opening.html");
|
expect(onSelectComposition).toHaveBeenCalledWith("frames/01-opening.html");
|
||||||
act(() => root.unmount());
|
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);
|
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 {
|
export interface ViewModeValue {
|
||||||
viewMode: StudioViewMode;
|
viewMode: StudioViewMode;
|
||||||
/** Returns false when an active editor vetoes the transition. */
|
/** Returns false when an active editor vetoes the transition. */
|
||||||
@@ -54,6 +67,14 @@ export interface ViewModeValue {
|
|||||||
export function useViewModeState(): ViewModeValue {
|
export function useViewModeState(): ViewModeValue {
|
||||||
const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
|
const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
|
||||||
const guardsRef = useRef(new Set<ViewModeGuard>());
|
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
|
// Reflect genuine browser back/forward between history entries with a different
|
||||||
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
|
// `?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
|
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
|
||||||
// reflected here, by design.
|
// reflected here, by design.
|
||||||
useEffect(() => {
|
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);
|
window.addEventListener("popstate", onPopState);
|
||||||
return () => window.removeEventListener("popstate", onPopState);
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
}, []);
|
}, [canSetViewMode, viewMode]);
|
||||||
|
|
||||||
const setViewMode = useCallback((mode: StudioViewMode) => {
|
const setViewMode = useCallback(
|
||||||
for (const guard of guardsRef.current) {
|
(mode: StudioViewMode) => {
|
||||||
if (!guard(mode)) return false;
|
if (!canSetViewMode(mode)) return false;
|
||||||
}
|
setMode(mode);
|
||||||
setMode(mode);
|
writeViewModeToUrl(mode);
|
||||||
writeViewModeToUrl(mode);
|
acceptedLocationRef.current = readHistoryLocation();
|
||||||
return true;
|
return true;
|
||||||
}, []);
|
},
|
||||||
|
[canSetViewMode],
|
||||||
|
);
|
||||||
|
|
||||||
const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
|
const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
|
||||||
guardsRef.current.add(guard);
|
guardsRef.current.add(guard);
|
||||||
|
|||||||
Reference in New Issue
Block a user