1import {
2 clampPreviewMiniPlayerPosition,
3 resolvePreviewMiniPlayerFrame,
4 type PreviewMiniPlayerFrame,
5 type PreviewMiniPlayerObstacles,
6} from "../preview/previewMiniPlayerLayout";
7import type {
8 PreviewMiniPlayerPosition,
9 PreviewMiniPlayerSize,
10 PreviewMiniPlayerState,
11} from "~/previewMiniPlayerStore";
12
13export interface ChatCanvasPreview {
14 readonly key: string;
15 readonly width: number | null;
16 readonly position: PreviewMiniPlayerPosition | null;
17 readonly source: PreviewMiniPlayerSize;
18 readonly lastInteraction?: PreviewMiniPlayerState["lastInteraction"];
19}
20
21const GAP = 12;
22// Minimum space between chat and the workspace card. Chat stays centered while
23// the card fits beside it with this much room.
24export const DETAILS_CARD_CLEARANCE = 32;
25
26/** Pure geometry shared by the conversation, composer, workspace card, and floating preview. */
27export function resolveChatCanvasLayout({
28 container,
29 preview,
30 padding = 48,
31 maxChatWidth = 768,
32 minChatWidth = 640,
33 composerHeight = 0,
34 detailsCard = null,
35 findBar = null,
36}: {
37 container: PreviewMiniPlayerSize;
38 preview: ChatCanvasPreview | null;
39 padding?: number;
40 maxChatWidth?: number;
41 minChatWidth?: number;
42 composerHeight?: number;
43 detailsCard?: PreviewMiniPlayerObstacles["detailsCard"];
44 /** The open find bar. It only keeps the floating preview clear; chat stays put. */
45 findBar?: PreviewMiniPlayerObstacles["detailsCard"];
46}) {
47 const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2));
48 // A workspace card that does not fit beside the centered chat first moves
49 // chat left, only as far as it needs. Chat narrows only after it reaches the
50 // left padding.
51 const laneRight = detailsCard
52 ? detailsCard.left - DETAILS_CARD_CLEARANCE
53 : container.width - padding;
54 const normalWidth = Math.max(0, Math.min(centeredWidth, laneRight - padding));
55 const normalLeft = Math.max(
56 padding,
57 Math.min((container.width - normalWidth) / 2, laneRight - normalWidth),
58 );
59 let chat = {
60 left: normalLeft,
61 width: normalWidth,
62 insetStart: 0,
63 insetEnd: Math.max(0, container.width - normalLeft * 2 - normalWidth),
64 };
65 let frame: PreviewMiniPlayerFrame | null = null;
66 let overlapsChat = false;
67 if (preview && container.width > 0 && container.height > 0) {
68 frame = resolvePreviewMiniPlayerFrame({ ...preview, container });
69 // Reserve a readable chat lane only while it leaves room to drag. Otherwise
70 // the container's right edge would pin the preview at every pointer position.
71 const minimumPreviewX =
72 preview.lastInteraction === "resize" ||
73 container.width <= padding + minChatWidth + frame.width + GAP * 2
74 ? GAP
75 : padding + minChatWidth + GAP;
76 // New players start beside the composer, with the workspace card above them.
77 if (preview.position === null) frame = { ...frame, y: container.height - frame.height - GAP };
78 if (preview.lastInteraction === "resize" && composerHeight > 0) {
79 // Lift the preview while chat uses its remaining shrink room, reaching
80 // the composer's top before the preview enters the readable chat lane.
81 const laneWidth = Math.min(normalWidth, minChatWidth);
82 const transitionWidth = Math.max(GAP, normalWidth - laneWidth);
83 const lift = Math.min(
84 1,
85 Math.max(0, (padding + normalWidth + GAP - frame.x) / transitionWidth),
86 );
87 if (lift > 0) {
88 frame = resolvePreviewMiniPlayerFrame({
89 width: frame.width,
90 position: frame,
91 source: preview.source,
92 container: {
93 ...container,
94 height: Math.max(GAP * 2 + 1, container.height - composerHeight * lift),
95 },
96 });
97 }
98 }
99 const findBarObstacles = findBar ? { detailsCard: findBar, composer: null } : undefined;
100 const preferredFrame = {
101 ...frame,
102 ...clampPreviewMiniPlayerPosition(frame, container, frame, findBarObstacles, minimumPreviewX),
103 };
104 // A resize keeps its anchored edge and consumes card height first. A drag
105 // clears the full card whenever it can, so moving alone never folds it.
106 // The find bar can be wider than the inline card when the root font is enlarged.
107 const dragObstacle =
108 detailsCard && findBar
109 ? {
110 left: Math.min(detailsCard.left, findBar.left),
111 right: Math.max(detailsCard.right, findBar.right),
112 bottom: Math.max(detailsCard.bottom, findBar.bottom),
113 }
114 : (detailsCard ?? findBar);
115 const cardObstacle = preview.lastInteraction === "resize" ? findBar : dragObstacle;
116 frame = {
117 ...frame,
118 ...clampPreviewMiniPlayerPosition(
119 frame,
120 container,
121 frame,
122 { detailsCard: cardObstacle, composer: null },
123 minimumPreviewX,
124 ),
125 };
126 const chatBeside = (player: PreviewMiniPlayerFrame) => {
127 const normalRight = normalLeft + normalWidth;
128 if (player.x >= normalRight + GAP) return chat;
129 const width = Math.min(maxChatWidth, player.x - GAP - padding);
130 if (width < minChatWidth) return null;
131 const left = Math.min(normalLeft, player.x - GAP - width);
132 return {
133 left,
134 width,
135 insetStart: 0,
136 insetEnd: Math.max(0, container.width - left * 2 - width),
137 };
138 };
139 let nextChat = chatBeside(frame);
140 // Clearing the full card must also leave a readable chat. If only the
141 // preferred position does, keep the resized player and let the card fold.
142 if (!nextChat && detailsCard) {
143 nextChat = chatBeside(preferredFrame);
144 if (nextChat) frame = preferredFrame;
145 }
146 if (nextChat) chat = nextChat;
147 else overlapsChat = true;
148 if (overlapsChat && preview.lastInteraction === "resize") {
149 const width = Math.min(normalWidth, minChatWidth);
150 chat = {
151 left: padding,
152 width,
153 insetStart: 0,
154 insetEnd: Math.max(0, container.width - padding * 2 - width),
155 };
156 } else if (overlapsChat) {
157 const obstacles = {
158 detailsCard: cardObstacle,
159 composer: { left: chat.left, right: chat.left + chat.width, height: composerHeight },
160 };
161 frame = resolvePreviewMiniPlayerFrame({ ...preview, container, obstacles });
162 frame = {
163 ...frame,
164 ...clampPreviewMiniPlayerPosition(
165 preview.position ?? { x: frame.x, y: container.height - frame.height - GAP },
166 container,
167 frame,
168 obstacles,
169 minimumPreviewX,
170 ),
171 };
172 // If the full card and composer leave no slot, keep typing usable while
173 // the card folds to the remaining height above this fallback frame.
174 if (
175 frame.x < obstacles.composer.right &&
176 frame.x + frame.width > obstacles.composer.left &&
177 frame.y + frame.height > container.height - composerHeight
178 ) {
179 frame = {
180 ...frame,
181 ...clampPreviewMiniPlayerPosition(
182 frame,
183 container,
184 frame,
185 { composer: obstacles.composer, detailsCard: findBar },
186 minimumPreviewX,
187 ),
188 };
189 }
190 }
191 }
192 const overlapsDetailsCard = Boolean(
193 frame &&
194 detailsCard &&
195 frame.x < detailsCard.right &&
196 frame.x + frame.width > detailsCard.left &&
197 frame.y < detailsCard.bottom,
198 );
199 return { chat, frame, overlapsChat, overlapsDetailsCard };
200}