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

canvasStore.ts

157 lines5.4 KB

1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { createStore } from "zustand";
3import type { ThreadPanelPresentation } from "../../rightPanelLayout";
4import type { DetailsCardMeasurement } from "./ChatCanvasContext";
5import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
6import { resolveThreadDetailsCardLayout } from "./threadDetailsCardLayout";
7​
8interface CanvasEdges {
9 readonly left: number;
10 readonly right: number;
11 readonly bottom: number;
13​
14export interface ChatCanvasMeasurements {
15 readonly width: number;
16 readonly height: number;
17 readonly padding: number;
18 readonly maxChatWidth: number;
19 readonly minChatWidth: number;
20 readonly composerHeight: number;
21 readonly timelineGutter: number;
22 /** The find bar's box in canvas coordinates while it is shown. */
23 readonly findBar: CanvasEdges | null;
25​
26interface ChatCanvasStoreState {
27 readonly measurements: ChatCanvasMeasurements;
28 readonly detailsCardMeasurement: DetailsCardMeasurement | null;
29 readonly setMeasurements: (next: ChatCanvasMeasurements) => void;
30 readonly measureDetailsCard: (next: DetailsCardMeasurement) => void;
32​
33const INITIAL_MEASUREMENTS: ChatCanvasMeasurements = {
34 width: 0,
35 height: 0,
36 padding: 48,
37 maxChatWidth: 768,
38 minChatWidth: 640,
39 composerHeight: 0,
40 timelineGutter: 0,
41 findBar: null,
42};
43​
44const sameEdges = (a: CanvasEdges | null, b: CanvasEdges | null) =>
45 a === b ||
46 (a !== null && b !== null && a.left === b.left && a.right === b.right && a.bottom === b.bottom);
47​
48const sameMeasurements = (a: ChatCanvasMeasurements, b: ChatCanvasMeasurements) =>
49 a.width === b.width &&
50 a.height === b.height &&
51 a.padding === b.padding &&
52 a.maxChatWidth === b.maxChatWidth &&
53 a.minChatWidth === b.minChatWidth &&
54 a.composerHeight === b.composerHeight &&
55 a.timelineGutter === b.timelineGutter &&
56 sameEdges(a.findBar, b.findBar);
57​
58/**
59 * One store per chat view: the canvas measures into it, and the chat view's
60 * header controls derive the details card presentation from the same inputs.
61 */
62export const createChatCanvasStore = () =>
63 createStore<ChatCanvasStoreState>()((set) => ({
64 measurements: INITIAL_MEASUREMENTS,
65 detailsCardMeasurement: null,
66 setMeasurements: (next) =>
67 set((state) => (sameMeasurements(state.measurements, next) ? state : { measurements: next })),
68 measureDetailsCard: (next) =>
69 set((state) => {
70 const current = state.detailsCardMeasurement;
71 return current?.threadRef.environmentId === next.threadRef.environmentId &&
72 current.threadRef.threadId === next.threadRef.threadId &&
73 current.width === next.width &&
74 current.fullContentHeight === next.fullContentHeight
75 ? state
76 : { detailsCardMeasurement: next };
77 }),
78 }));
79​
80export type ChatCanvasStore = ReturnType<typeof createChatCanvasStore>;
81​
82export function resolveChatCanvasModel({
83 measurements,
84 detailsCardMeasurement,
85 threadRef,
86 preview,
87 detailsCardInlineOpen,
88}: {
89 measurements: ChatCanvasMeasurements;
90 detailsCardMeasurement: DetailsCardMeasurement | null;
91 /** The thread whose details card sits on the canvas; other threads' measurements are stale. */
92 threadRef: ScopedThreadRef | null;
93 preview: ChatCanvasPreview | null;
94 detailsCardInlineOpen: boolean;
95}) {
96 const container = { width: measurements.width, height: measurements.height };
97 const { findBar } = measurements;
98 const detailsCardTopInset = findBar?.bottom ?? 0;
99 const lane = { padding: measurements.padding, minChatWidth: measurements.minChatWidth };
100 // The preview avoids where an open inline card would sit, whether or not a
101 // preview overlap has pushed the card itself into popover mode.
102 const preferredDetailsCard = resolveThreadDetailsCardLayout({
103 container,
104 lane,
105 frame: null,
106 topInset: detailsCardTopInset,
107 });
108 // The canvas outlives thread switches, so a measurement only counts for the
109 // thread whose card is shown now.
110 const detailsCardMeasured =
111 threadRef !== null &&
112 detailsCardMeasurement !== null &&
113 detailsCardMeasurement.threadRef.environmentId === threadRef.environmentId &&
114 detailsCardMeasurement.threadRef.threadId === threadRef.threadId
115 ? detailsCardMeasurement
116 : null;
117 const detailsCard =
118 detailsCardInlineOpen &&
119 preferredDetailsCard &&
120 detailsCardMeasured?.width === preferredDetailsCard.width &&
121 detailsCardMeasured.fullContentHeight > 0
122 ? {
123 left: preferredDetailsCard.x,
124 right: preferredDetailsCard.x + preferredDetailsCard.width,
125 bottom:
126 preferredDetailsCard.y +
127 Math.min(detailsCardMeasured.fullContentHeight, preferredDetailsCard.height),
128 }
129 : null;
130 const layout = resolveChatCanvasLayout({
131 container,
132 preview,
133 padding: measurements.padding,
134 maxChatWidth: measurements.maxChatWidth,
135 minChatWidth: measurements.minChatWidth,
136 composerHeight: measurements.composerHeight,
137 detailsCard,
138 findBar,
139 });
140 const detailsCardPlacement = resolveThreadDetailsCardLayout({
141 container,
142 lane,
143 frame: layout.frame,
144 overlapsDetailsCard: layout.overlapsDetailsCard,
145 topInset: detailsCardTopInset,
146 });
147 const detailsCardMode: ThreadPanelPresentation = detailsCardPlacement ? "inline" : "popover";
148 return {
149 container,
150 lane,
151 layout,
152 detailsCardTopInset,
153 preferredDetailsCard,
154 detailsCardPlacement,
155 detailsCardMode,
156 };