Skip to content
File

Blob: src/client/components/editor/document-page.tsx

typescript152 lines
1import { useCallback, useRef, useState, type ReactNode } from "react";
2import type YProvider from "y-partyserver/provider";
3import { PageErrorState } from "@/client/components/ui/page-error-state";
4import { PageTitleSection } from "@/client/components/ui/page-title-section";
5import { Skeleton } from "@/client/components/ui/skeleton";
6import {
7 DocumentFrame,
8 type DocumentOutlineMode,
9 OUTLINE_RAIL_MEDIA_QUERY,
10 PAGE_CONTENT_COLUMN_CLASS,
11 PAGE_SHELL_CLASS,
12} from "@/shared/editor/presentation/document-layout";
13import { useMediaQuery } from "@/client/hooks/use-media-query";
14import type { EditorAffordance } from "@/client/lib/affordance/editor";
15import { reportClientError } from "@/client/lib/report-client-error";
16import { toast } from "@/client/components/toast-store";
17import { EditorBody } from "./editor-body";
18import { useEditorSession } from "./use-editor-session";
19 
20const INVALID_SCHEMA_MESSAGE = "This build can't parse this page. Reload to catch up.";
21const SNAPSHOT_ERROR_MESSAGE = "This page didn't load. Your connection might be flaky.";
22 
23interface DocumentPageProps {
24 pageId: string;
25 initialTitle: string;
26 onTitleChange?: (title: string) => void;
27 onProvider?: (provider: YProvider | null) => void;
28 shareToken?: string;
29 workspaceId?: string;
30 affordance: EditorAffordance;
31 outlineMode: DocumentOutlineMode;
32 chrome: ReactNode;
33 docFooterLeading?: ReactNode;
34}
35 
36export function DocumentPage({
37 pageId,
38 initialTitle,
39 onTitleChange,
40 onProvider,
41 shareToken,
42 workspaceId,
43 affordance,
44 outlineMode,
45 chrome,
46 docFooterLeading,
47}: DocumentPageProps) {
48 const [outlineTarget, setOutlineTargetState] = useState<HTMLDivElement | null>(null);
49 const setOutlineTarget = useCallback((node: HTMLDivElement | null) => {
50 setOutlineTargetState(node);
51 }, []);
52 
53 // The rail layout is only realized in CSS at min-[1440px]; below that, an
54 // <aside> stacks after the main column and the outline would render below
55 // metrics. Gate the JS placement on the same breakpoint so callers can keep
56 // expressing intent without re-deriving viewport state.
57 const railViewport = useMediaQuery(OUTLINE_RAIL_MEDIA_QUERY);
58 const effectiveOutlineMode: DocumentOutlineMode = outlineMode === "rail" && railViewport ? "rail" : "inline";
59 
60 const [schemaError, setSchemaError] = useState<Error | null>(null);
61 const schemaErrorReportedRef = useRef(false);
62 
63 const handleSchemaError = useCallback(
64 (error: Error) => {
65 setSchemaError((current) => current ?? error);
66 
67 if (schemaErrorReportedRef.current) return;
68 schemaErrorReportedRef.current = true;
69 
70 reportClientError({
71 source: "editor.invalid-schema",
72 error,
73 context: {
74 pageId,
75 workspaceId,
76 hasShareToken: !!shareToken,
77 readOnly: !affordance.documentEditable,
78 },
79 });
80 toast.error(INVALID_SCHEMA_MESSAGE);
81 },
82 [pageId, affordance.documentEditable, shareToken, workspaceId],
83 );
84 
85 const session = useEditorSession({
86 pageId,
87 initialTitle,
88 onTitleChange,
89 onProvider,
90 shareToken,
91 workspaceId,
92 enabled: !schemaError,
93 });
94 
95 const titleProps = {
96 title: session.title,
97 onInput: session.onTitleInput,
98 disabled: session.kind !== "ready" || !!schemaError,
99 readOnly: !affordance.documentEditable || schemaError !== null,
100 };
101 
102 const body = schemaError ? (
103 <PageErrorState
104 message={INVALID_SCHEMA_MESSAGE}
105 className="min-h-[12rem]"
106 action={{ label: "Reload", onClick: () => window.location.reload() }}
107 />
108 ) : session.kind === "ready" ? (
109 <EditorBody
110 fragment={session.fragment}
111 provider={session.provider}
112 pageId={pageId}
113 shareToken={shareToken}
114 workspaceId={workspaceId}
115 affordance={affordance}
116 onSchemaError={handleSchemaError}
117 outline={effectiveOutlineMode === "rail" ? { kind: "rail", target: outlineTarget } : { kind: "inline" }}
118 docFooterLeading={docFooterLeading}
119 />
120 ) : session.kind === "error" ? (
121 <PageErrorState
122 message={SNAPSHOT_ERROR_MESSAGE}
123 className="min-h-[12rem]"
124 action={{ label: "Retry", onClick: session.onRetry }}
125 />
126 ) : (
127 <div className="mt-4 space-y-3 pl-7">
128 <Skeleton className="h-4 w-full" />
129 <Skeleton className="h-4 w-5/6" />
130 <Skeleton className="h-4 w-3/6" />
131 </div>
132 );
133 
134 const showRail = effectiveOutlineMode === "rail";
135 
136 return (
137 <div className={PAGE_SHELL_CLASS}>
138 <DocumentFrame
139 main={
140 <>
141 {chrome}
142 <PageTitleSection {...titleProps} />
143 <div className={PAGE_CONTENT_COLUMN_CLASS}>{body}</div>
144 </>
145 }
146 rail={showRail ? <></> : undefined}
147 railRef={setOutlineTarget}
148 />
149 </div>
150 );
151}