/** Three panes on one ground, with the document as the only raised surface. */ import { useCallback, useEffect, useReducer, useRef, useState, useSyncExternalStore } from "react"; import { presentWorkspace, transitionWorkspace, WORKSPACE_MEDIA, workspaceMode, } from "./workspace-model"; import type { Dispatch, ReactNode } from "react"; import type { WorkspaceDestination, WorkspaceEvent, WorkspaceMode, WorkspaceState, } from "./workspace-model"; const MIN = 240; const MAX = 400; /** How far one arrow key moves an edge, and how far one with Shift held moves it. */ const STEP = 16; const LEAP = 64; type Side = "left" | "right"; function clamp(value: number): number { return Math.min(MAX, Math.max(MIN, value)); } /** Accessible resize handle; pointer capture keeps drags active off the boundary. */ function Handle( { label, onResize, side, width }: { label: string; onResize: (delta: number) => void; side: Side; width: number; }, ) { let origin = useRef(0); let [dragging, setDragging] = useState(false); return (
{ let step = event.shiftKey ? LEAP : STEP; // A rail on the right grows leftward, so the key names a direction // on the screen rather than a direction in the layout. let toward = side === "left" ? 1 : -1; if (event.key === "ArrowRight") onResize(step * toward); else if (event.key === "ArrowLeft") onResize(-step * toward); else if (event.key === "Home") onResize(MIN - width); else if (event.key === "End") onResize(MAX - width); else return; event.preventDefault(); }} onPointerDown={event => { origin.current = event.clientX; setDragging(true); event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={event => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; let delta = event.clientX - origin.current; origin.current = event.clientX; onResize(side === "left" ? delta : -delta); }} // Lost capture covers ordinary release and every form of pointer cancellation. onLostPointerCapture={() => setDragging(false)} role="separator" tabIndex={0} /> ); } function usePaneWidth(key: string, initial: number, active: boolean) { let [width, setWidth] = useState(initial); let loaded = useRef(false); useEffect(() => { if (!active) return; if (!loaded.current) { loaded.current = true; let stored = Number(localStorage.getItem(key)); let restored = Number.isFinite(stored) && stored > 0 ? clamp(stored) : initial; if (restored !== width) { setWidth(restored); return; } } localStorage.setItem(key, String(width)); }, [active, initial, key, width]); let resize = useCallback((delta: number) => setWidth(current => clamp(current + delta)), []); return [width, resize] as const; } export type Pane = "chat"; export function paneId(pane: Pane): string { return `pane-${pane}`; } function currentMode(): WorkspaceMode { return workspaceMode(matchMedia); } function subscribeMode(notify: () => void): () => void { let queries = WORKSPACE_MEDIA.map(matchMedia); for (let query of queries) query.addEventListener("change", notify); return () => { for (let query of queries) query.removeEventListener("change", notify); }; } /** Read CSS width atomically so a render cannot contain two workspace modes. */ export function useWorkspaceMode(): WorkspaceMode { return useSyncExternalStore(subscribeMode, currentMode, currentMode); } /** Only the desktop Conversation preference crosses a page load. */ export function useWorkspaceState(): [WorkspaceState, Dispatch] { let [state, dispatch] = useReducer(transitionWorkspace, undefined, (): WorkspaceState => ({ conversationOpen: false, desktopConversationOpen: localStorage.getItem("chopin:pane:chat:open") !== "false", })); useEffect(() => { localStorage.setItem( "chopin:pane:chat:open", String(state.desktopConversationOpen), ); }, [state.desktopConversationOpen]); return [state, dispatch]; } export type WorkspaceProps = { header: ReactNode; chat?: ReactNode; plan: ReactNode; decisions: ReactNode; controls: ReactNode; mode: WorkspaceMode; state: WorkspaceState; view: "plan" | "decisions"; onConversationOpen: (open: boolean) => void; onDestination: (destination: "plan" | "decisions") => void; unanswered: number; conversationActivity: { unread: number; busy: boolean }; }; const HEADING: Record = { plan: "workspace-plan-heading", decisions: "workspace-decisions-heading", conversation: "workspace-conversation-heading", }; function destinationLabel( destination: WorkspaceDestination, unanswered: number, activity: WorkspaceProps["conversationActivity"], ): string { if (destination === "decisions" && unanswered > 0) { return `Decisions, ${unanswered} unanswered`; } if (destination === "conversation" && activity.busy && activity.unread > 0) { return `Conversation, Planner working, ${activity.unread} unread`; } if (destination === "conversation" && activity.busy) return "Conversation, Planner working"; if (destination === "conversation" && activity.unread > 0) { return `Conversation, ${activity.unread} unread`; } return destination === "conversation" ? "Conversation" : destination === "decisions" ? "Decisions" : "Plan"; } export function Workspace( { chat, controls, conversationActivity, decisions, header, mode, onConversationOpen, onDestination, plan, state, unanswered, view, }: WorkspaceProps, ) { let [chatWidth, resizeChat] = usePaneWidth("chopin:pane:chat", 280, mode === "split"); let presentation = presentWorkspace(state, mode, view); let opener = useRef(undefined); let previousConversationOpen = useRef(state.conversationOpen); let conversationHidden = !presentation.conversationVisible; let planHidden = !presentation.documentVisible || presentation.documentView !== "plan"; let decisionsHidden = !presentation.documentVisible || presentation.documentView !== "decisions"; let destinations: WorkspaceDestination[] = ["conversation", "plan", "decisions"]; useEffect(() => { if (!previousConversationOpen.current && state.conversationOpen) { let active = document.activeElement; if (active instanceof HTMLElement) opener.current = active; if (mode !== "split") { requestAnimationFrame(() => { document.getElementById(HEADING.conversation)?.focus({ preventScroll: true }); }); } } previousConversationOpen.current = state.conversationOpen; }, [mode, state.conversationOpen]); let navigate = (destination: WorkspaceDestination, source?: HTMLElement) => { if (destination === "conversation" && source) opener.current = source; if (destination === "conversation") onConversationOpen(true); else onDestination(destination); requestAnimationFrame(() => { document.getElementById(HEADING[destination])?.focus({ preventScroll: true }); }); }; let dismissConversation = () => { onConversationOpen(false); requestAnimationFrame(() => opener.current?.focus({ preventScroll: true })); }; return (
{header}
{/* `hidden` preserves pane state and subscriptions while removing it from layout. */} {chat && ( )}
{/* Decoration extends below the viewport without moving the editor contents. */}
{mode !== "split" && ( )}
); }