mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
/**
|
|
* Top-level Studio view mode.
|
|
*
|
|
* `timeline` is the existing NLE/preview stage. `storyboard` replaces that stage
|
|
* with the storyboard contact sheet. The mode is mirrored to the `?view=` query
|
|
* param so it survives reloads and — importantly — so an agent can deep-link the
|
|
* user straight into the storyboard by navigating the tab to `?view=storyboard`.
|
|
*/
|
|
export type StudioViewMode = "timeline" | "storyboard";
|
|
export type ViewModeGuard = (nextMode: StudioViewMode) => boolean;
|
|
|
|
const VIEW_QUERY_PARAM = "view";
|
|
|
|
function readViewModeFromUrl(): StudioViewMode {
|
|
if (typeof window === "undefined") return "timeline";
|
|
return new URLSearchParams(window.location.search).get(VIEW_QUERY_PARAM) === "storyboard"
|
|
? "storyboard"
|
|
: "timeline";
|
|
}
|
|
|
|
function writeViewModeToUrl(mode: StudioViewMode): void {
|
|
if (typeof window === "undefined") return;
|
|
const url = new URL(window.location.href);
|
|
if (mode === "storyboard") {
|
|
url.searchParams.set(VIEW_QUERY_PARAM, "storyboard");
|
|
} else {
|
|
url.searchParams.delete(VIEW_QUERY_PARAM);
|
|
}
|
|
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. */
|
|
setViewMode: (mode: StudioViewMode) => boolean;
|
|
registerViewModeGuard: (guard: ViewModeGuard) => () => void;
|
|
}
|
|
|
|
/**
|
|
* Owns the view-mode state — initial read from `?view=`, toggling, popstate sync.
|
|
* Storyboard mode is always available; no flag gating.
|
|
*/
|
|
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
|
|
// `popstate`, so this listener never sees them — `setViewMode` updates state directly.
|
|
// An agent deep-links by doing a full navigation to `?view=storyboard` (picked up by
|
|
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
|
|
// reflected here, by design.
|
|
useEffect(() => {
|
|
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) => {
|
|
if (!canSetViewMode(mode)) return false;
|
|
setMode(mode);
|
|
writeViewModeToUrl(mode);
|
|
acceptedLocationRef.current = readHistoryLocation();
|
|
return true;
|
|
},
|
|
[canSetViewMode],
|
|
);
|
|
|
|
const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
|
|
guardsRef.current.add(guard);
|
|
return () => {
|
|
guardsRef.current.delete(guard);
|
|
};
|
|
}, []);
|
|
|
|
return useMemo(
|
|
() => ({ viewMode, setViewMode, registerViewModeGuard }),
|
|
[viewMode, setViewMode, registerViewModeGuard],
|
|
);
|
|
}
|
|
|
|
const ViewModeContext = createContext<ViewModeValue | null>(null);
|
|
|
|
export function useViewMode(): ViewModeValue {
|
|
const ctx = useContext(ViewModeContext);
|
|
if (!ctx) throw new Error("useViewMode must be used within ViewModeProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function ViewModeProvider({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: ViewModeValue;
|
|
children: ReactNode;
|
|
}) {
|
|
return <ViewModeContext value={value}>{children}</ViewModeContext>;
|
|
}
|