Skip to content
Source
Sign inSign up
Open History to see the last commit

ChatCanvas.tsx

119 lines4.8 KB

1import { useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties } from "react";
2import { useStore } from "zustand";
3import { ChatCanvasContext } from "./ChatCanvasContext";
4import type { ChatCanvasPreview } from "./chatCanvasLayout";
5import { resolveChatCanvasModel, type ChatCanvasStore } from "./canvasStore";
6import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore";
7import { observeResize } from "../../lib/observeResize";
8import type { ScopedThreadRef } from "@t3tools/contracts";
9​
10export function ChatCanvas({
11 store,
12 threadRef,
13 preview,
14 composerOverlayElement,
15 detailsCardTopInset = 0,
16 children,
17 ...props
18}: Omit<ComponentProps<"div">, "className" | "style" | "ref"> & {
19 store: ChatCanvasStore;
20 /** The thread whose details card sits on the canvas; other threads' measurements are stale. */
21 threadRef: ScopedThreadRef | null;
22 /** The thread's mini player while it renders on this canvas. */
23 preview: ChatCanvasPreview | null;
24 composerOverlayElement: HTMLElement | null;
25 detailsCardTopInset?: number;
26}) {
27 const elementRef = useRef<HTMLDivElement | null>(null);
28 const widthProbeRef = useRef<HTMLDivElement | null>(null);
29 const [timelineElement, registerTimeline] = useState<HTMLElement | null>(null);
30 const measurements = useStore(store, (state) => state.measurements);
31 const detailsCardMeasurement = useStore(store, (state) => state.detailsCardMeasurement);
32 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes canvas, find bar and timeline resizes and writes the measurements to the canvas store
33 useLayoutEffect(() => {
34 const element = elementRef.current;
35 const probe = widthProbeRef.current;
36 if (!element || !probe) return;
37 const findBar =
38 detailsCardTopInset > 0 ? element.querySelector<HTMLElement>("[data-thread-find-bar]") : null;
39 const measure = () => {
40 const canvasBounds = element.getBoundingClientRect();
41 const findBarBounds = findBar?.getBoundingClientRect();
42 const styles = getComputedStyle(probe);
43 store.getState().setMeasurements({
44 findBar:
45 detailsCardTopInset > 0
46 ? {
47 left: findBarBounds ? findBarBounds.left - canvasBounds.left : 0,
48 right: findBarBounds ? findBarBounds.right - canvasBounds.left : 0,
49 bottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0,
50 }
51 : null,
52 width: element.clientWidth,
53 height: element.clientHeight,
54 padding: Number.parseFloat(styles.paddingLeft),
55 maxChatWidth: Number.parseFloat(styles.width),
56 minChatWidth: Number.parseFloat(styles.minWidth),
57 composerHeight: composerOverlayElement?.getBoundingClientRect().height ?? 0,
58 timelineGutter: timelineElement
59 ? (timelineElement.offsetWidth - timelineElement.clientWidth) / 2
60 : 0,
61 });
62 };
63 measure();
64 const observed: Element[] = [element, probe];
65 if (findBar) observed.push(findBar);
66 if (composerOverlayElement) observed.push(composerOverlayElement);
67 if (timelineElement) observed.push(timelineElement);
68 return observeResize(observed, measure);
69 }, [store, composerOverlayElement, timelineElement, detailsCardTopInset]);
70 const detailsCardInlineOpen = useRightPanelStore((state) =>
71 threadRef === null
72 ? false
73 : selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"),
74 );
75 const model = resolveChatCanvasModel({
76 measurements,
77 detailsCardMeasurement,
78 threadRef,
79 preview,
80 detailsCardInlineOpen,
81 });
82 const context = {
83 container: model.container,
84 lane: model.lane,
85 layout: model.layout,
86 previewKey: preview?.key ?? null,
87 registerTimeline,
88 measureDetailsCard: store.getState().measureDetailsCard,
89 detailsCardTopInset: model.detailsCardTopInset,
90 preferredDetailsCard: model.preferredDetailsCard,
91 detailsCardPlacement: model.detailsCardPlacement,
92 };
93 const { layout } = context;
94 return (
95 <ChatCanvasContext value={context}>
96 <div
97 {...props}
98 ref={elementRef}
99 data-chat-canvas
100 data-preview-overlaps-chat={layout.overlapsChat || undefined}
101 className="relative flex min-h-0 min-w-0 flex-1 flex-col"
102 style={
103 {
104 "--chat-timeline-gutter": `${measurements.timelineGutter}px`,
105 "--chat-lane-inset-start": `${layout.chat.insetStart}px`,
106 "--chat-lane-inset-end": `${layout.chat.insetEnd}px`,
107 } as CSSProperties
108 }
109 >
110 <div
111 ref={widthProbeRef}
112 aria-hidden
113 className="pointer-events-none invisible absolute h-0 w-(--chat-content-max-width) min-w-[40rem] box-content ps-3 sm:ps-12"
114 />
115 {children}
116 </div>
117 </ChatCanvasContext>
118 );