/** * The comment composer's textarea with its token highlight layer (skill * references, host `@` mentions), moved out of CommentPopover so a host can * put the same field in its own composer (the Plannotator Inbox's reply box * and New message). CommentPopover renders it with every default, so its * markup is unchanged. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { SkillReferenceToken } from '../utils/skillReferences'; import { mentionTokenRanges, mergeTokenRanges, skillTokenRanges, type ComposerTokenRange, } from '../utils/composerTokens'; import type { MentionPerson } from '../utils/mentions'; /** Classes shared by the textarea and its highlight mirror — font, size, * and box metrics MUST stay identical or the overlay drifts out of alignment. */ export const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5'; /** Per-composer memo for the overlay gutter sync below. */ interface OverlayGutterState { /** The overlay the applied value belongs to (a remount invalidates it). */ overlay: HTMLElement | null; /** Last `offsetWidth - clientWidth` seen on the textarea. */ raw: number; /** Extra right padding currently written inline on the overlay, in px. */ applied: number; } const cssPx = (value: string): number => { const n = parseFloat(value); return Number.isFinite(n) ? n : 0; }; /** * Keep the mirror's text column exactly as wide as the textarea's (#1525). * * A layout-consuming vertical scrollbar (Windows/Linux classic scrollbars; the * app's `scrollbar-width: thin` makes them ~12px) narrows the textarea's * content box once the composer hits its max height — but the overlay is * `overflow: hidden` and keeps its full width, so from the first line that * wraps differently the painted glyphs drift away from the native caret, which * is the one thing the textarea itself still draws. `scrollbar-gutter: stable` * cannot fix this: it is ignored on an `overflow: hidden` box. Mirroring the * measured scrollbar width onto the overlay's right padding makes both boxes * wrap identically again (and stops the overlay painting under the scrollbar). * * Overlay scrollbars (macOS, iOS, any touch surface) consume no layout, so the * measured width is 0 — and then this writes NOTHING at all, leaving the * class-driven padding untouched exactly as before the fix. */ function syncOverlayGutter( el: HTMLTextAreaElement, overlay: HTMLElement, state: OverlayGutterState, raw: number, ): void { // Nothing measurable changed since the last sync — the common case. if (overlay === state.overlay && raw === state.raw) return; state.overlay = overlay; state.raw = raw; const clear = () => { if (state.applied === 0) return; // never wrote anything; leave the DOM alone overlay.style.removeProperty('padding-right'); state.applied = 0; }; const style = getComputedStyle(el); // `offsetWidth - clientWidth` is scrollbar + left/right borders. The borders // are shared with the overlay (both boxes are border-box), so only the // scrollbar part is the extra gutter the mirror is missing. const borders = cssPx(style.borderLeftWidth) + cssPx(style.borderRightWidth); const scrollbar = Math.max(0, raw - borders); if (scrollbar === 0) { clear(); return; } // Base padding is read from the textarea (never the overlay, whose computed // value may already include a gutter we wrote), so the two can never drift // apart if COMPOSER_TEXT_CLASSES changes. const base = parseFloat(style.paddingRight); if (!Number.isFinite(base)) { clear(); // unmeasurable: fall back to the pre-fix behavior return; } overlay.style.paddingRight = `${base + scrollbar}px`; state.applied = scrollbar; } /** * The default chip look: the theme's primary at a wash, one shade stronger * than a skill reference's so the two token kinds in one overlay read as * siblings rather than the same thing. Deliberately no ring: every class here * is one the package already emitted, so a host build's CSS (and the portable * guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds * `box-shadow: 0 0 0 Npx ` through `tokenClassName` — paint, not * layout, per the metric rule above. */ const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]'; /** * One token's span in the highlight overlay. * * METRIC RULE, load-bearing for every token kind: a span may change COLOR, * BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything * that moves a glyph — padding, margin, border width, font-weight, * letter-spacing, font-size — would shift the overlay's text off the * textarea's own layout and drift the caret away from the painted glyphs. A * pill's breathing room is faked with a paint-only `box-shadow` ring in the * chip's own background color. */ function renderTokenSpan( range: ComposerTokenRange, text: string, mentionTokenClassName?: string, ): React.ReactNode { if (range.kind === 'mention') { // `data-mention-token` / `data-mention-kind` are the host's styling hook; // `tokenClassName` is appended verbatim and is the host's to keep // metric-safe (see the rule above). return ( {text} ); } // Human-only tokens carry a quiet dotted underline as their inline marker // (text-decoration never affects glyph layout, so overlay alignment is // safe). The accessible explanation lives in HumanOnlySkillNotice below // the textarea — this overlay is aria-hidden. return ( {text} ); } /** The mention half of the highlight layer. Null → no mention source at all. */ export interface ComposerMentionChips { /** The people whose `@Label` token still survives in the text. */ readonly people: readonly MentionPerson[]; /** Host class appended to each chip (see `MentionSource.tokenClassName`). */ readonly tokenClassName?: string; } /** A host's extra classes, appended after a space; nothing at all when absent (the default markup is unchanged). */ const extra = (className: string | undefined): string => (className ? ` ${className}` : ''); export interface ComposerTokenTextareaProps { value: string; onChange: (e: React.ChangeEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; /** Caret observer (skill autocomplete). */ onSelectCaret: () => void; placeholder: string; /** Mode-specific min/max height classes. */ sizeClassName: string; textareaRef: (el: HTMLTextAreaElement | null) => void; /** Positioned skill-reference occurrences to highlight. */ tokens: SkillReferenceToken[]; /** Off → no skill-reference contribution to the highlight layer. */ skillReferences: boolean; /** * The mention contribution, or null with no `mentionSource`. Present (even * with nobody tagged yet) it turns the overlay on, so the first pick paints * a chip without swapping the textarea for a different element. */ mentionChips: ComposerMentionChips | null; /** ARIA relationship to the skill-reference listbox. */ listboxId: string; listboxOpen: boolean; activeOptionId?: string; /** * Font, size and box classes shared by the textarea and its mirror; they * MUST be the same on both or the overlay drifts. Default: * `COMPOSER_TEXT_CLASSES` (the comment composer's). */ textClassName?: string; /** Extra classes on the textarea only (a host's own hook). */ inputClassName?: string; /** Extra classes on the mirror only (a host that keeps its metrics in its own class). */ mirrorClassName?: string; /** * Further textarea attributes (`aria-label`, `aria-describedby`, `rows`, …). * An `aria-label` here replaces the default, the placeholder. */ inputProps?: Omit< React.TextareaHTMLAttributes, 'value' | 'onChange' | 'onKeyDown' | 'onSelect' | 'onScroll' | 'className' | 'placeholder' >; } /** * The composer's textarea. With NEITHER token source active this is exactly * the pre-feature `