import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { documentPath } from "@chopin/protocol/document-url"; import * as Api from "./api"; import { anchoredChildPaths, AnchoredChildSurface, rebaseChildHistoryState, } from "./anchored-child-surface"; import { readDocumentRecovery, rememberChannel } from "./channel-recovery"; import { prepareDocumentLoad } from "./document-loader"; import { documentRouteIdentity } from "./document-route-swap"; import { initialDocumentWorkspaceState, transitionDocumentWorkspace, } from "./document-workspace-state"; import type { ComponentType } from "react"; import type { ResearchOpener } from "@chopin/editor"; import type { ChildFocusToken } from "./anchored-child-surface"; import type { DocumentWorkspaceAction } from "./document-workspace-state"; import type { DocumentRouteIdentity } from "./document-route-swap"; import type { HostedRoute } from "./hosted"; import type { WorkspacePresentation } from "./workspace-model"; let RoomWorkspace = lazy(() => import("./room-workspace").then(module => ({ default: module.RoomWorkspace })) ); type DocumentRoute = Extract; type Metadata = Pick< Api.Channel, | "archivedAt" | "description" | "descriptionRevision" | "slug" | "title" | "updatedAt" >; // Popover menus and the editor's insert listbox render as dialogs, listboxes or comboboxes and // handle their own Escape; the sheet must not close under them. let DISMISSIBLE_TARGET = 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="menu"], [role="listbox"], [role="dialog"], [role="combobox"]'; function workspaceProps( detail: Api.ChannelDetail, agent: boolean, user: Api.User, presentation: WorkspacePresentation, onMetadataChanged: (metadata: Metadata) => void, ) { let channel = detail.channel; return { agent, archivedAt: channel.archivedAt, canEdit: !channel.archivedAt && (detail.canEdit || detail.canManage), canManage: detail.canManage, description: channel.description, descriptionRevision: channel.descriptionRevision, handle: user.login, label: channel.title, onMetadataChanged, repository: detail.repository, room: channel.id, slug: channel.slug, presentation, updatedAt: channel.updatedAt, userId: user.id, }; } // How long after a request the outgoing document may wait for this one to sync. Past // it, the header and body would disagree with the sidebar for too long. const REVEAL_LIMIT = 200; export default function DocumentWorkspaceHost( { agent, Failure, layerKey, Loading, onCanonicalPath, onChildClose, onChildClosing, onParentRestored, onReady, retryable, route, user, }: { agent: boolean; Failure: ComponentType<{ channel?: { title?: string; slug?: string }; error: unknown; onRetry?: () => void; repository?: Pick; }>; layerKey: DocumentRouteIdentity; Loading: ComponentType<{ label?: string }>; onCanonicalPath: ( key: DocumentRouteIdentity, routeKey: DocumentRouteIdentity, pathname: string, ) => void; onChildClose: (parentId: string, parentPath: string, opener?: ResearchOpener) => void; onChildClosing: (parentId: string, parentPath: string) => ChildFocusToken; onParentRestored: (token: ChildFocusToken) => void; onReady: ( key: DocumentRouteIdentity, resolution?: { canonicalPath: string; channel: Api.Channel; routeKey: DocumentRouteIdentity; }, ) => void; retryable: (error: unknown) => boolean; route: DocumentRoute; user: Api.User; }, ) { let [state, setState] = useState(initialDocumentWorkspaceState); let stateRef = useRef(state); stateRef.current = state; let send = useCallback((action: DocumentWorkspaceAction) => { let next = transitionDocumentWorkspace(stateRef.current, action); stateRef.current = next; setState(next); return next; }, []); let loaded = state.status === "ready" ? state.loaded : undefined; let routeRef = useRef(route); routeRef.current = route; let routeKey = documentRouteIdentity(route); let error = state.error; let presentation = state.presentation; let previousPresentation = useRef(presentation); let closingFocus = useRef(undefined); let parentScrollTop = useRef(undefined); let parentSurface = useRef(null); useEffect(() => { let active = true; let controller = new AbortController(); let requestedRoute = routeRef.current; let requestedAt = performance.now(); send({ type: "loading" }); let currentState = stateRef.current; let current = currentState.status === "ready" ? currentState.loaded : undefined; if (requestedRoute.page === "child" && current && !current.child) { let scroller = parentSurface.current?.querySelector("[data-plan-scroll]"); if (scroller) parentScrollTop.current = scroller.scrollTop; } let address = { owner: requestedRoute.owner, repository: requestedRoute.repository, slug: requestedRoute.page === "child" ? requestedRoute.childSlug : requestedRoute.slug, ...(requestedRoute.page === "child" ? { parentSlug: requestedRoute.parentSlug } : {}), }; void (async () => { if (requestedRoute.page === "child" && !current) { let prepared = await prepareDocumentLoad({ owner: requestedRoute.owner, repository: requestedRoute.repository, slug: requestedRoute.parentSlug, }, controller.signal); if (!active) return; let parent = prepared.parent ?? prepared.detail; rememberChannel(user.id, parent.channel, parent.repository); send({ parent, route: requestedRoute, type: "parent-ready" }); } return await prepareDocumentLoad(address, controller.signal); })().then(prepared => { if (!prepared) return; if (!active) return; let parent = prepared.parent ?? prepared.detail; let child = prepared.parent ? prepared.detail : undefined; rememberChannel(user.id, parent.channel, parent.repository); if (child) rememberChannel(user.id, child.channel, child.repository); send({ child, parent, route: requestedRoute, type: "ready" }); let routeKey = child ? documentRouteIdentity({ childSlug: child.channel.slug, owner: child.repository.owner, page: "child", parentSlug: parent.channel.slug, repository: child.repository.name, }) : documentRouteIdentity({ owner: parent.repository.owner, page: "document", repository: parent.repository.name, slug: parent.channel.slug, }); let resolution = { canonicalPath: prepared.pathname, channel: (child ?? parent).channel, routeKey, }; // The outgoing document stays on screen until this one has synced, briefly. let synced = `[data-workspace-room="${ CSS.escape(resolution.channel.id) }"] [data-plan-synced]`; let reveal = () => { if (!active) return; if ( parentSurface.current?.closest(".document-route-layer")?.querySelector(synced) || performance.now() - requestedAt > REVEAL_LIMIT ) { onReady(layerKey, resolution); } else { requestAnimationFrame(reveal); } }; reveal(); }, reason => { if (active) { send({ error: reason, type: "failed" }); onReady(layerKey); } }); return () => { active = false; controller.abort(); }; }, [layerKey, onReady, routeKey, send, state.retry, user.id]); useLayoutEffect(() => { if (!loaded || parentScrollTop.current === undefined) return; let scroller = parentSurface.current?.querySelector("[data-plan-scroll]"); if (scroller) scroller.scrollTop = parentScrollTop.current; if (presentation === "closed") parentScrollTop.current = undefined; }, [loaded, presentation]); useLayoutEffect(() => { let previous = previousPresentation.current; previousPresentation.current = presentation; if (previous !== "closing" || presentation !== "closed") return; let token = closingFocus.current; closingFocus.current = undefined; let current = stateRef.current; if ( token && current.status === "ready" && current.loaded.parent.channel.id === token.parentId ) { onParentRestored(token); } }, [onParentRestored, presentation]); useEffect(() => { let current = stateRef.current; let action = { route, type: "route" } as const; let next = transitionDocumentWorkspace(current, action); if (route.page === "child") { closingFocus.current = undefined; } else if ( current.status === "ready" && current.presentation === "open" && next.status === "ready" && next.presentation === "closing" ) { closingFocus.current = onChildClosing( current.loaded.parent.channel.id, documentPath( current.loaded.parent.repository.owner, current.loaded.parent.repository.name, current.loaded.parent.channel.slug, ), ); } else if (!(next.status === "ready" && next.presentation === "closing")) { closingFocus.current = undefined; } send(action); }, [onChildClosing, route, send]); useEffect(() => { if (presentation !== "closing") return; let timer = window.setTimeout(() => { send({ type: "closed" }); }, 190); return () => window.clearTimeout(timer); }, [presentation, send]); useEffect(() => { if (presentation !== "open") return; let closeOnEscape = (event: KeyboardEvent) => { if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return; // Escape dismisses the innermost thing first: a field gives up focus, then the next one closes. let target = event.target instanceof Element ? event.target : undefined; if (target?.closest(DISMISSIBLE_TARGET)) { event.preventDefault(); document.querySelector(".anchored-child-surface")?.focus({ preventScroll: true, }); return; } event.preventDefault(); let current = stateRef.current; if (current.status === "ready") { onChildClose( current.loaded.parent.channel.id, documentPath( current.loaded.parent.repository.owner, current.loaded.parent.repository.name, current.loaded.parent.channel.slug, ), ); } }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); }, [onChildClose, presentation]); let metadataChanged = useCallback((kind: "parent" | "child", metadata: Metadata) => { if (documentRouteIdentity(routeRef.current) !== routeKey) return; let current = stateRef.current; if (current.status === "empty") return; let target = kind === "parent" ? current.loaded.parent : current.loaded.child; if (!target) return; let updatedState = send({ kind, metadata, type: "metadata" }); if (updatedState.status === "empty") return; let updated = updatedState.loaded; let route = routeRef.current; let paths = anchoredChildPaths( { owner: updated.parent.repository.owner, repository: updated.parent.repository.name, slug: updated.parent.channel.slug, }, // Parent metadata can arrive before the requested child detail finishes loading. route.page === "child" ? updated.child?.channel.slug ?? route.childSlug : undefined, ); let pathname = route.page === "child" ? paths.child! : paths.parent; if (kind === "parent" && routeRef.current.page === "child") { history.replaceState(rebaseChildHistoryState(history.state, paths.parent), ""); } onCanonicalPath(layerKey, routeKey, pathname); }, [layerKey, onCanonicalPath, routeKey, send]); let parentMetadataChanged = useCallback( (metadata: Metadata) => metadataChanged("parent", metadata), [metadataChanged], ); let childMetadataChanged = useCallback( (metadata: Metadata) => metadataChanged("child", metadata), [metadataChanged], ); let retryFailure = error && retryable(error) ? () => send({ type: "retry" }) : undefined; let requestedSlug = route.page === "child" ? route.childSlug : route.slug; let recovery = readDocumentRecovery(user.id, route.owner, route.repository, requestedSlug); let requestedRepository = { fullName: `${route.owner}/${route.repository}`, name: route.repository, owner: route.owner, }; if (error && !loaded) { return ( ); } if (!loaded) { return ; } let parentPath = anchoredChildPaths( { owner: loaded.parent.repository.owner, repository: loaded.parent.repository.name, slug: loaded.parent.channel.slug, }, loaded.child?.channel.slug, ).parent; let childVisible = presentation !== "closed"; let childLabel = loaded.child?.channel.title ?? (route.page === "child" ? route.childSlug : ""); let closeChild = () => onChildClose(loaded.parent.channel.id, parentPath); let parent = ( }> ); let child = loaded.child ? ( }> onChildClose(loaded.parent.channel.id, parentPath, opener), }, type: "child", }, childMetadataChanged, )} key={loaded.child.channel.id} /> ) : route.page === "child" && presentation === "open" ? error ? : : undefined; return ( ); }