Files
hyperframes/packages/studio/src/contexts/ViewModeContext.tsx
T

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>;
}