/** Three panes on one ground, with the document as the only raised surface. */ import { useEffect, useId, useLayoutEffect, useReducer, useRef, useSyncExternalStore } from "react"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { CloseIcon } from "@chopin/icons"; import { initialWorkspaceState, presentWorkspace, transitionWorkspace, WORKSPACE_MEDIA, workspaceDestinations, workspaceHeadingId, workspaceMode, workspaceProfile, } from "./workspace-model"; import chatCloseIcon from "./assets/icons/panel-close.svg"; import chatIcon from "./assets/icons/chat.svg"; import { ResizeHandle, usePaneWidth } from "./resizable-pane"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import type { Dispatch, ReactNode, RefObject } from "react"; import type { WorkspaceDestination, WorkspaceEvent, WorkspaceMode, WorkspacePresentation, WorkspaceProfile, WorkspaceState, } from "./workspace-model"; export type Pane = "chat"; const CHAT_PANE = { initial: 364, max: 400, min: 304, storageKey: "chopin:pane:chat", }; export type WorkspaceIds = { heading: Record; pane: Record; }; export function useWorkspaceIds(): WorkspaceIds { let instance = useId(); return { heading: { plan: workspaceHeadingId("plan", instance), decisions: workspaceHeadingId("decisions", instance), chat: workspaceHeadingId("chat", instance), }, pane: { chat: `${instance}-pane-chat` }, }; } 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 Chat preference crosses a page load. */ export function useWorkspaceState( profile: WorkspaceProfile, ): [WorkspaceState, Dispatch] { let [state, dispatch] = useReducer( transitionWorkspace, undefined, () => initialWorkspaceState( profile, localStorage.getItem("chopin:pane:chat:open") !== "false", ), ); useEffect(() => { if (!profile.persistChat) return; localStorage.setItem( "chopin:pane:chat:open", String(state.desktopChatOpen), ); }, [profile.persistChat, state.desktopChatOpen]); return [state, dispatch]; } export type WorkspaceProps = { header: ReactNode; chat?: ReactNode; plan: ReactNode; decisions: ReactNode; controls: ReactNode; ids: WorkspaceIds; mode: WorkspaceMode; state: WorkspaceState; view: "plan" | "decisions"; onChatOpen: (open: boolean) => void; onDesktopChatOpen: (open: boolean) => void; onDestination: (destination: "plan" | "decisions") => void; unanswered: number; chatActivity: { unread: number; busy: boolean }; identity?: string; presentation: WorkspacePresentation; }; export function ChatToggle( { activity, buttonRef, className, controls, onToggle, open, swapOnHover = false, }: { activity: WorkspaceProps["chatActivity"]; buttonRef?: RefObject; className?: string; controls: string; onToggle: () => void; open: boolean; swapOnHover?: boolean; }, ) { let status = activity.busy ? "Planner working" : activity.unread > 0 ? `${activity.unread} unread` : undefined; let feedback = motionContract("feedback").className; return ( ); } function destinationLabel( destination: WorkspaceDestination, unanswered: number, activity: WorkspaceProps["chatActivity"], ): string { if (destination === "decisions" && unanswered > 0) { return `Decisions, ${unanswered} unanswered`; } if (destination === "chat" && activity.busy && activity.unread > 0) { return `Chat, Planner working, ${activity.unread} unread`; } if (destination === "chat" && activity.busy) return "Chat, Planner working"; if (destination === "chat" && activity.unread > 0) { return `Chat, ${activity.unread} unread`; } return destination === "chat" ? "Chat" : destination === "decisions" ? "Decisions" : "Document"; } export function Workspace( { chat, controls, ids, chatActivity, decisions, header, identity, mode, onChatOpen, onDesktopChatOpen, onDestination, plan, presentation: workspacePresentation, state, unanswered, view, }: WorkspaceProps, ) { let profile = workspaceProfile(workspacePresentation); let [chatWidth, resizeChat] = usePaneWidth({ active: mode === "split", ...CHAT_PANE, storageKey: profile.persistPaneSize ? CHAT_PANE.storageKey : undefined, }); let root = useRef(null); let presentation = presentWorkspace(state, mode, view); let childPresentation = workspacePresentation.type === "child" ? workspacePresentation : undefined; let paperObscured = workspacePresentation.type === "parent-with-child"; let immediately = motionImmediately(); let contentSwapMotion = motionContract("content-swap"); let chatPresence = useTransitionPresence( presentation.chatVisible ? true : undefined, 220, immediately, ); let opener = useRef(undefined); let edgeTab = useRef(null); let previousChatOpen = useRef(state.chatOpen); let chatInactive = !presentation.chatVisible; let destinations = workspaceDestinations(); let focusDestination = (destination: WorkspaceDestination) => { root.current?.querySelector(`#${CSS.escape(ids.heading[destination])}`) ?.focus({ preventScroll: true }); }; useLayoutEffect(() => { if (!previousChatOpen.current && state.chatOpen) { let active = document.activeElement; if (active instanceof HTMLElement) opener.current = active; if (mode !== "split") { focusDestination("chat"); } } previousChatOpen.current = state.chatOpen; }, [mode, state.chatOpen]); let navigate = (destination: WorkspaceDestination, source?: HTMLElement) => { if (destination === "chat" && source) opener.current = source; if (destination === "chat") onChatOpen(true); else onDestination(destination); requestAnimationFrame(() => { focusDestination(destination); }); }; let dismissChat = () => { if (mode === "split") { onDesktopChatOpen(false); requestAnimationFrame(() => edgeTab.current?.focus({ preventScroll: true })); } else { onChatOpen(false); requestAnimationFrame(() => opener.current?.focus({ preventScroll: true })); } }; let showDesktopChat = () => { onDesktopChatOpen(true); requestAnimationFrame(() => { focusDestination("chat"); }); }; return (
{header}
{/* `hidden` preserves pane state and subscriptions after its closing transition. */} {chat && ( )}
{mode === "split" && (
{ if (!(event.target instanceof HTMLElement)) return; let control = event.target.getBoundingClientRect(); let toolbar = event.currentTarget.getBoundingClientRect(); if (control.left < toolbar.left) { event.currentTarget.scrollLeft += Math.floor(control.left - toolbar.left); } else if (control.right > toolbar.right) { event.currentTarget.scrollLeft += Math.ceil(control.right - toolbar.right); } }} > {chat && !presentation.chatVisible && ( )} {controls} {childPresentation && (
)}
)}

Document

{plan}
{decisions}
{mode !== "split" && ( )}
); }