/** Three panes on one ground, with the document as the only raised surface. */ import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; import { Count } from "@chopin/editor/count"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { CloseIcon } from "@chopin/icons"; import { CHAT_CHOICE_STORAGE_KEY, initialWorkspaceState, presentWorkspace, storedDesktopChat, transitionWorkspace, workspaceDestinations, workspaceHeadingId, workspaceProfile, } from "./workspace-model"; import chatCloseIcon from "./assets/icons/panel-close.svg"; import chatIcon from "./assets/icons/chat.svg"; import { clampPane, ResizeHandle, usePaneWidth } from "./resizable-pane"; import { workspaceSizing } from "./workspace-sizing"; import "./workspace-sizing.css"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import { sidebarMoving, usePaneMotion, usePaneSettledWidth } from "./pane-motion"; import { listenForShortcuts } from "./global-shortcuts"; import { currentShortcutPlatform, shortcutLabel } from "./shortcuts"; import { DocumentActivityDot, documentActivityLabel } from "./document-activity"; import type { CSSProperties, Dispatch, ReactNode, RefObject } from "react"; import type { DocumentActivity } from "./document-activity"; import type { WorkspaceDestination, WorkspaceEvent, WorkspaceMode, WorkspacePresentation, WorkspaceProfile, WorkspaceState, } from "./workspace-model"; export type Pane = "chat"; const CHAT_PANE = { initial: 500, max: Number.MAX_SAFE_INTEGER, min: 250, 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` }, }; } export function useWorkspaceLayout() { let frame = useRef(null); let [available, setAvailable] = useState(() => Math.max(0, window.innerWidth - 24)); useLayoutEffect(() => { let element = frame.current; if (!element) return; let measure = () => { if (!sidebarMoving()) setAvailable(element.clientWidth); }; measure(); let observer = new ResizeObserver(measure); observer.observe(element); document.addEventListener("transitionend", measure); document.addEventListener("transitioncancel", measure); return () => { observer.disconnect(); document.removeEventListener("transitionend", measure); document.removeEventListener("transitioncancel", measure); }; }, []); return { available, frame, mode: workspaceSizing(available, 500).mode }; } /** Only the desktop Chat preference crosses a page load. */ export function useWorkspaceState( profile: WorkspaceProfile, ): [WorkspaceState, Dispatch] { let [state, dispatch] = useReducer( transitionWorkspace, undefined, // A child opens with its parent's saved Chat preference but never saves its own. () => initialWorkspaceState( storedDesktopChat(localStorage.getItem(CHAT_CHOICE_STORAGE_KEY)), ), ); useEffect(() => { if (!profile.persistChat || state.desktopChatOpen === undefined) return; localStorage.setItem( CHAT_CHOICE_STORAGE_KEY, String(state.desktopChatOpen), ); }, [profile.persistChat, state.desktopChatOpen]); return [state, dispatch]; } export type WorkspaceProps = { header: ReactNode; available?: number; frame?: RefObject; chat?: ReactNode; plan: ReactNode; decisions: ReactNode; controls: ReactNode; /** Connection and document status, right-aligned in the document header. */ status?: 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 }; documentActivity?: DocumentActivity; 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"], document: DocumentActivity, ): string { if (destination === "plan") return documentActivityLabel(document); 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( { available = 950, frame, chat, controls, ids, chatActivity, decisions, documentActivity, header, identity, mode, onChatOpen, onDesktopChatOpen, onDestination, plan, presentation: workspacePresentation, state, status, unanswered, view, }: WorkspaceProps, ) { let profile = workspaceProfile(workspacePresentation); let [preferredWidth, resizePreferred] = usePaneWidth({ active: mode === "split", ...CHAT_PANE, storageKey: profile.persistPaneSize ? CHAT_PANE.storageKey : undefined, }); let { chat: chatWidth, maximum } = workspaceSizing(available, preferredWidth); let resizeChat = (delta: number) => { let next = clampPane(chatWidth + delta, CHAT_PANE.min, maximum); resizePreferred(next - preferredWidth); }; 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 chatTrack = usePaneMotion(chatPresence.phase); let documentSwap = useRef(null); usePaneSettledWidth(documentSwap); // The outgoing document stays under Chat until Chat has faded in over it. let documentPresence = useTransitionPresence( presentation.documentVisible ? true : undefined, contentSwapMotion.closeDuration, immediately, ); let destination: WorkspaceDestination = mode !== "split" && presentation.chatVisible ? "chat" : view; let [travel, setTravel] = useState({ destination, back: false }); if (travel.destination !== destination) { let order = workspaceDestinations(); setTravel({ back: order.indexOf(destination) < order.indexOf(travel.destination), destination, }); } 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 previousMode = useRef(mode); useLayoutEffect(() => { if (previousMode.current === mode) return; previousMode.current = mode; if (document.activeElement?.closest("[hidden], [inert]")) { focusDestination(presentation.documentVisible ? view : "chat"); } }, [mode]); 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"); }); }; let toggleChat = useRef(() => {}); toggleChat.current = () => { if (!presentation.chatVisible) { if (mode === "split") showDesktopChat(); else navigate("chat"); return; } let pane = root.current?.querySelector(`#${CSS.escape(ids.pane.chat)}`); // Keep focus where it was unless closing the pane would strand it. if (mode !== "split" || pane?.contains(document.activeElement)) dismissChat(); else onDesktopChatOpen(false); }; let chatShortcutEnabled = !!chat && !paperObscured; useEffect(() => { if (!chatShortcutEnabled) return; return listenForShortcuts(() => ({ "toggle-chat": () => toggleChat.current() })); }, [chatShortcutEnabled]); return (
{header} {mode !== "split" && ( )}
{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); } }} > {controls}
{status} {chat && !presentation.chatVisible && ( )} {childPresentation && (
)}
)} {mode !== "split" && status &&
{status}
}

Document

{plan}
{decisions}
{/* `hidden` preserves pane state and subscriptions after its closing transition. */} {chat && ( )}
); }