1import {
2 useCallback,
3 useLayoutEffect,
4 useMemo,
5 useRef,
6 useState,
7 type ComponentProps,
8 type CSSProperties,
9} from "react";
10import { ChatCanvasContext } from "./ChatCanvasContext";
11import {
12 DETAILS_CARD_CLEARANCE,
13 resolveChatCanvasLayout,
14 type ChatCanvasPreview,
15} from "./chatCanvasLayout";
16import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
17import { observeResize } from "../../lib/observeResize";
18
19/**
20 * Owns the available conversation space. Cards only report where they sit; the
21 * canvas decides when chat moves over to make room for them.
22 */
23export function ChatCanvas({
24 composerOverlayElement,
25 detailsCardTopInset = 0,
26 children,
27 ...props
28}: Omit<ComponentProps<"div">, "className" | "style" | "ref"> & {
29 composerOverlayElement: HTMLElement | null;
30 detailsCardTopInset?: number;
31}) {
32 const elementRef = useRef<HTMLDivElement | null>(null);
33 const widthProbeRef = useRef<HTMLDivElement | null>(null);
34 const [timelineElement, registerTimeline] = useState<HTMLElement | null>(null);
35 const [preview, setPreview] = useState<ChatCanvasPreview | null>(null);
36 const [detailsCard, setDetailsCard] = useState<PreviewMiniPlayerObstacles["detailsCard"]>(null);
37 const reportDetailsCard = useCallback((next: PreviewMiniPlayerObstacles["detailsCard"]) => {
38 setDetailsCard((current) =>
39 current?.left === next?.left &&
40 current?.right === next?.right &&
41 current?.bottom === next?.bottom
42 ? current
43 : next,
44 );
45 }, []);
46 const [measurements, setMeasurements] = useState({
47 width: 0,
48 height: 0,
49 padding: 48,
50 maxChatWidth: 768,
51 minChatWidth: 640,
52 composerHeight: 0,
53 timelineGutter: 0,
54 findBarLeft: 0,
55 findBarRight: 0,
56 findBarBottom: 0,
57 });
58 const reportPreview = useCallback((next: ChatCanvasPreview) => {
59 setPreview((current) =>
60 current?.key === next.key &&
61 current.width === next.width &&
62 current.lastInteraction === next.lastInteraction &&
63 current.position?.x === next.position?.x &&
64 current.position?.y === next.position?.y &&
65 current.source.width === next.source.width &&
66 current.source.height === next.source.height
67 ? current
68 : next,
69 );
70 }, []);
71 const clearPreview = useCallback(
72 (key: string) => setPreview((current) => (current?.key === key ? null : current)),
73 [],
74 );
75 useLayoutEffect(() => {
76 const element = elementRef.current;
77 const probe = widthProbeRef.current;
78 if (!element || !probe) return;
79 const findBar =
80 detailsCardTopInset > 0 ? element.querySelector<HTMLElement>("[data-thread-find-bar]") : null;
81 const measure = () => {
82 const canvasBounds = element.getBoundingClientRect();
83 const findBarBounds = findBar?.getBoundingClientRect();
84 const styles = getComputedStyle(probe);
85 const next = {
86 findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0,
87 findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0,
88 findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0,
89 width: element.clientWidth,
90 height: element.clientHeight,
91 padding: Number.parseFloat(styles.paddingLeft),
92 maxChatWidth: Number.parseFloat(styles.width),
93 minChatWidth: Number.parseFloat(styles.minWidth),
94 composerHeight: composerOverlayElement?.getBoundingClientRect().height ?? 0,
95 timelineGutter: timelineElement
96 ? (timelineElement.offsetWidth - timelineElement.clientWidth) / 2
97 : 0,
98 };
99 setMeasurements((current) =>
100 Object.keys(next).every(
101 (key) => current[key as keyof typeof current] === next[key as keyof typeof next],
102 )
103 ? current
104 : next,
105 );
106 };
107 measure();
108 const observed: Element[] = [element, probe];
109 if (findBar) observed.push(findBar);
110 if (composerOverlayElement) observed.push(composerOverlayElement);
111 if (timelineElement) observed.push(timelineElement);
112 return observeResize(observed, measure);
113 }, [composerOverlayElement, timelineElement, detailsCardTopInset]);
114 const context = useMemo(() => {
115 const container = { width: measurements.width, height: measurements.height };
116 const findBar =
117 detailsCardTopInset > 0
118 ? {
119 left: measurements.findBarLeft,
120 right: measurements.findBarRight,
121 bottom: measurements.findBarBottom,
122 }
123 : null;
124 return {
125 container,
126 lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth },
127 layout: resolveChatCanvasLayout({
128 ...measurements,
129 container,
130 preview,
131 detailsCard,
132 findBar,
133 }),
134 previewKey: preview?.key ?? null,
135 reportPreview,
136 clearPreview,
137 registerTimeline,
138 reportDetailsCard,
139 detailsCardTopInset: findBar?.bottom ?? 0,
140 };
141 }, [
142 measurements,
143 preview,
144 detailsCard,
145 reportPreview,
146 clearPreview,
147 reportDetailsCard,
148 detailsCardTopInset,
149 ]);
150 const { layout } = context;
151 const blockOutsetStart = Math.max(0, layout.chat.left - measurements.padding);
152 const blockRight = detailsCard
153 ? detailsCard.left - DETAILS_CARD_CLEARANCE
154 : measurements.width - measurements.padding;
155 const blockOutsetEnd =
156 layout.chat.insetEnd > 0
157 ? 0
158 : Math.max(0, Math.min(blockOutsetStart, blockRight - layout.chat.left - layout.chat.width));
159
160 return (
161 <ChatCanvasContext value={context}>
162 <div
163 {...props}
164 ref={elementRef}
165 data-chat-canvas
166 data-preview-overlaps-chat={layout.overlapsChat || undefined}
167 className="relative flex min-h-0 min-w-0 flex-1 flex-col"
168 style={
169 {
170 "--chat-timeline-gutter": `${measurements.timelineGutter}px`,
171 "--chat-lane-inset-start": `${layout.chat.insetStart}px`,
172 "--chat-lane-inset-end": `${layout.chat.insetEnd}px`,
173 "--chat-block-outset-start": `${blockOutsetStart}px`,
174 "--chat-block-outset-end": `${blockOutsetEnd}px`,
175 } as CSSProperties
176 }
177 >
178 <div
179 ref={widthProbeRef}
180 aria-hidden
181 className="pointer-events-none invisible absolute h-0 w-(--chat-content-max-width) min-w-[40rem] box-content ps-3 sm:ps-12"
182 />
183 {children}
184 </div>
185 </ChatCanvasContext>
186 );
187}