| 1 | import type { RestingComposerControlsMeasurement } from "../composerFooterLayout"; |
| 2 | |
| 3 | function elementOuterWidth(element: HTMLElement): number { |
| 4 | const width = element.getBoundingClientRect().width; |
| 5 | if (width === 0) return 0; |
| 6 | const style = getComputedStyle(element); |
| 7 | return ( |
| 8 | width + |
| 9 | (Number.parseFloat(style.marginInlineStart) || 0) + |
| 10 | (Number.parseFloat(style.marginInlineEnd) || 0) |
| 11 | ); |
| 12 | } |
| 13 | |
| 14 | function elementInlineMarginWidth(element: HTMLElement): number { |
| 15 | const style = getComputedStyle(element); |
| 16 | return ( |
| 17 | (Number.parseFloat(style.marginInlineStart) || 0) + |
| 18 | (Number.parseFloat(style.marginInlineEnd) || 0) |
| 19 | ); |
| 20 | } |
| 21 | |
| 22 | function providerModelPickerNaturalWidth(picker: HTMLElement): number { |
| 23 | const renderedWidth = picker.getBoundingClientRect().width; |
| 24 | if (renderedWidth === 0) return 0; |
| 25 | const style = getComputedStyle(picker); |
| 26 | const label = picker.querySelector<HTMLElement>('[data-chat-provider-model-picker-label="true"]'); |
| 27 | // Narrow composers deliberately collapse the label with w-0 and flex-none. |
| 28 | // A flexible label squeezed to zero still needs its natural width recovered. |
| 29 | const labelIsCollapsed = label?.clientWidth === 0 && getComputedStyle(label).flexGrow === "0"; |
| 30 | const hiddenLabelWidth = |
| 31 | label && !labelIsCollapsed ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; |
| 32 | const maxWidth = Number.parseFloat(style.maxWidth); |
| 33 | const naturalWidth = Math.min( |
| 34 | renderedWidth + hiddenLabelWidth, |
| 35 | Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY, |
| 36 | ); |
| 37 | return naturalWidth + elementInlineMarginWidth(picker); |
| 38 | } |
| 39 | |
| 40 | function providerModelPickerMinimumWidth(picker: HTMLElement): number { |
| 41 | const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0; |
| 42 | return minWidth + elementInlineMarginWidth(picker); |
| 43 | } |
| 44 | |
| 45 | function controlBlockWidths(block: HTMLElement): { natural: number; iconOnly: number } { |
| 46 | const compact = block.dataset.composerBlockIconOnly === "true"; |
| 47 | let natural = elementOuterWidth(block); |
| 48 | let iconOnly = natural; |
| 49 | for (const label of block.querySelectorAll<HTMLElement>("[data-composer-control-label]")) { |
| 50 | // Labels remain mounted at natural width when icons replace them. Reading |
| 51 | // both variants from one tree avoids duplicate controls or write/read probes. |
| 52 | const labelStyle = getComputedStyle(label); |
| 53 | const inFlow = labelStyle.position !== "absolute"; |
| 54 | // Phone widths already hide the build label with sr-only. Its clipping |
| 55 | // remains in effect even when compact styles replace its one-pixel width. |
| 56 | if (!inFlow && (!compact || labelStyle.clip !== "auto")) continue; |
| 57 | const labelWidth = label.scrollWidth; |
| 58 | const renderedWidth = inFlow ? label.getBoundingClientRect().width : 0; |
| 59 | const gap = Number.parseFloat(getComputedStyle(label.parentElement!).columnGap) || 0; |
| 60 | natural += labelWidth - renderedWidth + (inFlow ? 0 : gap); |
| 61 | iconOnly -= renderedWidth + (inFlow ? gap : 0); |
| 62 | } |
| 63 | for (const icon of block.querySelectorAll<HTMLElement>("[data-composer-control-compact-icon]")) { |
| 64 | const width = elementOuterWidth(icon); |
| 65 | const gap = Number.parseFloat(getComputedStyle(icon.parentElement!).columnGap) || 0; |
| 66 | natural -= compact ? width + gap : 0; |
| 67 | iconOnly += compact ? 0 : width + gap; |
| 68 | } |
| 69 | return { natural, iconOnly: Math.min(natural, iconOnly) }; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Read the natural widths of the resting composer controls from the DOM. |
| 74 | * |
| 75 | * Both the composer (deciding which blocks move into overflow) and the |
| 76 | * context strip (deciding whether its labels may expand) read the same |
| 77 | * numbers, so neither decision depends on what the other one hid last render. |
| 78 | * |
| 79 | * Hidden blocks and the unused overflow trigger stay mounted out of flow at |
| 80 | * full size. The picker is the one flexible item: its intended width is |
| 81 | * recovered from the truncated label. |
| 82 | */ |
| 83 | export function measureRestingComposerControls( |
| 84 | controls: HTMLElement, |
| 85 | ): RestingComposerControlsMeasurement | null { |
| 86 | const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0; |
| 87 | const picker = controls.querySelector<HTMLElement>("[data-chat-provider-model-picker]"); |
| 88 | const leadingControl = |
| 89 | picker ?? controls.querySelector<HTMLElement>('[data-chat-provider-unavailable="true"]'); |
| 90 | if (!leadingControl) return null; |
| 91 | // Separators are display:none on phone widths; a hidden one takes no gap. |
| 92 | const separator = controls.querySelector<HTMLElement>("[data-resting-controls-separator]"); |
| 93 | const separatorWidth = separator ? elementOuterWidth(separator) : 0; |
| 94 | const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]"); |
| 95 | const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; |
| 96 | const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]")); |
| 97 | const widths = blocks.map(controlBlockWidths); |
| 98 | return { |
| 99 | gap, |
| 100 | naturalFixedWidth: |
| 101 | (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) + |
| 102 | separatorAndGapWidth, |
| 103 | minimumFixedWidth: |
| 104 | (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + |
| 105 | separatorAndGapWidth, |
| 106 | blockWidths: widths.map((width) => width.natural), |
| 107 | iconOnlyBlockWidths: widths.map((width) => width.iconOnly), |
| 108 | overflowWidth: overflow ? elementOuterWidth(overflow) : 0, |
| 109 | }; |
| 110 | } |