| 1 | import { |
| 2 | clampPreviewMiniPlayerPosition, |
| 3 | resolvePreviewMiniPlayerFrame, |
| 4 | type PreviewMiniPlayerFrame, |
| 5 | type PreviewMiniPlayerObstacles, |
| 6 | } from "../preview/previewMiniPlayerLayout"; |
| 7 | import type { |
| 8 | PreviewMiniPlayerPosition, |
| 9 | PreviewMiniPlayerSize, |
| 10 | PreviewMiniPlayerState, |
| 11 | } from "~/previewMiniPlayerStore"; |
| 12 | |
| 13 | export 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 | |
| 21 | const 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. |
| 24 | export const DETAILS_CARD_CLEARANCE = 32; |
| 25 | |
| 26 | /** Pure geometry shared by the conversation, composer, workspace card, and floating preview. */ |
| 27 | export 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 | } |