@plannotator/ui 0.52.1 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/HANDOFF.md +231 -27
  2. package/README.md +29 -4
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +189 -48
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +183 -96
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. package/utils/updateNotice.ts +120 -0
@@ -92,7 +92,7 @@ function buildPierreCSS(mode: 'dark' | 'light', bg: string, fg: string): string
92
92
  pre, code { background-color: ${bg} !important; }
93
93
  [data-column-number] { background-color: ${bg} !important; }
94
94
  [data-file] { height: 100% !important; }
95
- [data-code] { height: 100% !important; overflow-y: auto !important; }
95
+ [data-code] { height: 100% !important; overflow-y: auto !important; align-content: start; }
96
96
  [data-line] { cursor: pointer; }
97
97
  `;
98
98
  }
@@ -283,21 +283,46 @@ const CodeInlineAnnotation: React.FC<{
283
283
  );
284
284
  };
285
285
 
286
- export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
287
- open,
288
- onClose,
286
+ export interface CodeFileViewProps {
287
+ filepath: string;
288
+ contents: string;
289
+ prerenderedHTML?: string;
290
+ annotations?: CodeAnnotation[];
291
+ selectedAnnotationId?: string | null;
292
+ onAddAnnotation?: (annotation: CodeFileAnnotationInput) => void;
293
+ onEditAnnotation?: (id: string, updates: Partial<CodeAnnotation>) => void;
294
+ onDeleteAnnotation?: (id: string) => void;
295
+ onSelectAnnotation?: (id: string | null) => void;
296
+ /** Whether the comment composer offers image attachments. Default true (the popout's behavior). */
297
+ allowImages?: boolean;
298
+ /** The file area's class. Default: fills the rest of a flex column. */
299
+ className?: string;
300
+ /** The highlighting language, instead of Pierre's inference from the name (`'text'`: no highlighting, for a very large file). */
301
+ lang?: string;
302
+ /** Long lines: `'scroll'` sideways (the default) or `'wrap'` onto the next row, for a minified file. */
303
+ overflow?: 'scroll' | 'wrap';
304
+ }
305
+
306
+ /**
307
+ * One code file drawn by Pierre's `File`, with line and range comments: the
308
+ * gutter `+`, a click on a line, a dragged range or a text selection opens
309
+ * the comment composer, and saved comments render inline under their lines.
310
+ * The body of `CodeFilePopout`, usable on its own (the Inbox's file pane).
311
+ */
312
+ export const CodeFileView: React.FC<CodeFileViewProps> = ({
289
313
  filepath,
290
314
  contents,
291
315
  prerenderedHTML,
292
- error,
293
- requestedPath,
294
316
  annotations = [],
295
317
  selectedAnnotationId,
296
318
  onAddAnnotation,
297
319
  onEditAnnotation,
298
320
  onDeleteAnnotation,
299
321
  onSelectAnnotation,
300
- container,
322
+ allowImages = true,
323
+ className = 'flex-1 min-h-0',
324
+ lang,
325
+ overflow = 'scroll',
301
326
  }) => {
302
327
  const { resolvedMode } = useTheme();
303
328
  const mode = resolvedMode ?? 'dark';
@@ -306,7 +331,6 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
306
331
  type: mode as 'dark' | 'light',
307
332
  css: buildPierreCSS(mode, colors.bg, colors.fg),
308
333
  }));
309
- const [copied, setCopied] = useState(false);
310
334
  const [pendingComment, setPendingComment] = useState<PendingComment | null>(null);
311
335
  const fileAreaRef = useRef<HTMLDivElement>(null);
312
336
  const lastPointerRectRef = useRef<DOMRect | null>(null);
@@ -328,7 +352,6 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
328
352
 
329
353
  const displayName = filepath.split('/').pop() || filepath;
330
354
  const relativePath = filepath.replace(/.*\/(?=.*\/)/, '');
331
- const lineCount = useMemo(() => contents.split('\n').length, [contents]);
332
355
  const selectedCodeAnnotation = useMemo(
333
356
  () => annotations.find((ann) => ann.id === selectedAnnotationId),
334
357
  [annotations, selectedAnnotationId],
@@ -457,6 +480,94 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
457
480
  );
458
481
  }, [onAddAnnotation, openCommentForRange]);
459
482
 
483
+ return (
484
+ <>
485
+ <div
486
+ ref={fileAreaRef}
487
+ className={className}
488
+ onPointerMove={(e) => {
489
+ lastPointerRectRef.current = new DOMRect(e.clientX, e.clientY, 0, 0);
490
+ }}
491
+ onMouseUp={() => {
492
+ requestAnimationFrame(openCommentForBrowserSelection);
493
+ }}
494
+ >
495
+ <File
496
+ key={filepath}
497
+ file={lang ? { name: displayName, contents, lang } : { name: displayName, contents }}
498
+ prerenderedHTML={effectivePrerenderedHTML}
499
+ className="h-full"
500
+ lineAnnotations={lineAnnotations}
501
+ selectedLines={selectedLines}
502
+ renderAnnotation={renderAnnotation}
503
+ renderGutterUtility={renderGutterUtility}
504
+ style={{
505
+ '--diffs-dark-bg': colors.bg,
506
+ '--diffs-light-bg': colors.bg,
507
+ '--diffs-dark': colors.fg,
508
+ '--diffs-light': colors.fg,
509
+ } as React.CSSProperties}
510
+ options={{
511
+ themeType: pierreTheme.type,
512
+ unsafeCSS: pierreTheme.css,
513
+ overflow,
514
+ disableFileHeader: true,
515
+ enableLineSelection: true,
516
+ enableGutterUtility: !!onAddAnnotation,
517
+ lineHoverHighlight: onAddAnnotation ? 'line' : 'disabled',
518
+ onLineClick: handleLineClick,
519
+ onLineSelectionEnd: handleLineSelectionEnd,
520
+ }}
521
+ />
522
+ </div>
523
+ {pendingComment && onAddAnnotation && (
524
+ <CommentPopover
525
+ anchorEl={pendingComment.anchorEl}
526
+ anchorRect={pendingComment.anchorRect}
527
+ contextText={pendingComment.contextText}
528
+ isGlobal={false}
529
+ skillReferences
530
+ allowImages={allowImages}
531
+ onSubmit={(text, images) => {
532
+ onAddAnnotation({
533
+ filePath: filepath,
534
+ lineStart: pendingComment.range.start,
535
+ lineEnd: pendingComment.range.end,
536
+ text,
537
+ images,
538
+ originalCode: pendingComment.originalCode,
539
+ });
540
+ setPendingComment(null);
541
+ }}
542
+ onClose={() => setPendingComment(null)}
543
+ />
544
+ )}
545
+ </>
546
+ );
547
+ };
548
+
549
+ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
550
+ open,
551
+ onClose,
552
+ filepath,
553
+ contents,
554
+ prerenderedHTML,
555
+ error,
556
+ requestedPath,
557
+ annotations = [],
558
+ selectedAnnotationId,
559
+ onAddAnnotation,
560
+ onEditAnnotation,
561
+ onDeleteAnnotation,
562
+ onSelectAnnotation,
563
+ container,
564
+ }) => {
565
+ const [copied, setCopied] = useState(false);
566
+
567
+ const displayName = filepath.split('/').pop() || filepath;
568
+ const relativePath = filepath.replace(/.*\/(?=.*\/)/, '');
569
+ const lineCount = useMemo(() => contents.split('\n').length, [contents]);
570
+
460
571
  const handleCopy = async () => {
461
572
  if (await copyTextToClipboard(contents)) {
462
573
  setCopied(true);
@@ -539,65 +650,17 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
539
650
  </div>
540
651
 
541
652
  <div className="border-t border-border/30" />
542
- <div
543
- ref={fileAreaRef}
544
- className="flex-1 min-h-0"
545
- onPointerMove={(e) => {
546
- lastPointerRectRef.current = new DOMRect(e.clientX, e.clientY, 0, 0);
547
- }}
548
- onMouseUp={() => {
549
- requestAnimationFrame(openCommentForBrowserSelection);
550
- }}
551
- >
552
- <File
553
- key={filepath}
554
- file={{ name: displayName, contents }}
555
- prerenderedHTML={effectivePrerenderedHTML}
556
- className="h-full"
557
- lineAnnotations={lineAnnotations}
558
- selectedLines={selectedLines}
559
- renderAnnotation={renderAnnotation}
560
- renderGutterUtility={renderGutterUtility}
561
- style={{
562
- '--diffs-dark-bg': colors.bg,
563
- '--diffs-light-bg': colors.bg,
564
- '--diffs-dark': colors.fg,
565
- '--diffs-light': colors.fg,
566
- } as React.CSSProperties}
567
- options={{
568
- themeType: pierreTheme.type,
569
- unsafeCSS: pierreTheme.css,
570
- overflow: 'scroll',
571
- disableFileHeader: true,
572
- enableLineSelection: true,
573
- enableGutterUtility: !!onAddAnnotation,
574
- lineHoverHighlight: onAddAnnotation ? 'line' : 'disabled',
575
- onLineClick: handleLineClick,
576
- onLineSelectionEnd: handleLineSelectionEnd,
577
- }}
578
- />
579
- </div>
580
- {pendingComment && onAddAnnotation && (
581
- <CommentPopover
582
- anchorEl={pendingComment.anchorEl}
583
- anchorRect={pendingComment.anchorRect}
584
- contextText={pendingComment.contextText}
585
- isGlobal={false}
586
- skillReferences
587
- onSubmit={(text, images) => {
588
- onAddAnnotation({
589
- filePath: filepath,
590
- lineStart: pendingComment.range.start,
591
- lineEnd: pendingComment.range.end,
592
- text,
593
- images,
594
- originalCode: pendingComment.originalCode,
595
- });
596
- setPendingComment(null);
597
- }}
598
- onClose={() => setPendingComment(null)}
599
- />
600
- )}
653
+ <CodeFileView
654
+ filepath={filepath}
655
+ contents={contents}
656
+ prerenderedHTML={prerenderedHTML}
657
+ annotations={annotations}
658
+ selectedAnnotationId={selectedAnnotationId}
659
+ onAddAnnotation={onAddAnnotation}
660
+ onEditAnnotation={onEditAnnotation}
661
+ onDeleteAnnotation={onDeleteAnnotation}
662
+ onSelectAnnotation={onSelectAnnotation}
663
+ />
601
664
  </PopoutDialog>
602
665
  );
603
666
  };
@@ -7,16 +7,10 @@ import { SparklesIcon } from './SparklesIcon';
7
7
  import { hasUnsavedCommentContent } from '../utils/commentContent';
8
8
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
9
9
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
10
- import type { SkillReferenceToken } from '../utils/skillReferences';
11
- import {
12
- mentionTokenRanges,
13
- mergeTokenRanges,
14
- skillTokenRanges,
15
- type ComposerTokenRange,
16
- } from '../utils/composerTokens';
10
+ import { ComposerTokenTextarea, type ComposerMentionChips } from './ComposerTokenTextarea';
17
11
  import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
18
12
  import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
19
- import type { MentionPerson, MentionSource } from '../utils/mentions';
13
+ import type { MentionSource } from '../utils/mentions';
20
14
  import {
21
15
  hasPrimaryCoarsePointer,
22
16
  shouldUseExpandedComposer,
@@ -24,6 +18,8 @@ import {
24
18
  type VisibleViewportBounds,
25
19
  } from '../hooks/useViewportEnvironment';
26
20
 
21
+ export type { ComposerMentionChips } from './ComposerTokenTextarea';
22
+
27
23
  export interface CommentAskAIContext {
28
24
  kind: 'general' | 'selection';
29
25
  label?: string;
@@ -714,7 +710,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
714
710
  onSelect={skillAc.select}
715
711
  />
716
712
  )}
717
- <ComposerTextarea
713
+ <ComposerTokenTextarea
718
714
  textareaRef={focusOnMountRef}
719
715
  value={text}
720
716
  onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
@@ -867,7 +863,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
867
863
  onSelect={skillAc.select}
868
864
  />
869
865
  )}
870
- <ComposerTextarea
866
+ <ComposerTokenTextarea
871
867
  textareaRef={focusOnMountRef}
872
868
  value={text}
873
869
  onChange={(e) => { setText(e.target.value); readComposerCaret(); }}
@@ -930,345 +926,6 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
930
926
  );
931
927
  };
932
928
 
933
- // ---------------------------------------------------------------------------
934
- // Composer textarea with skill-reference token highlighting
935
- // ---------------------------------------------------------------------------
936
-
937
- /** Classes shared by the textarea and its highlight mirror — font, size,
938
- * and box metrics MUST stay identical or the overlay drifts out of alignment. */
939
- const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5';
940
-
941
- /** Per-composer memo for the overlay gutter sync below. */
942
- interface OverlayGutterState {
943
- /** The overlay the applied value belongs to (a remount invalidates it). */
944
- overlay: HTMLElement | null;
945
- /** Last `offsetWidth - clientWidth` seen on the textarea. */
946
- raw: number;
947
- /** Extra right padding currently written inline on the overlay, in px. */
948
- applied: number;
949
- }
950
-
951
- const cssPx = (value: string): number => {
952
- const n = parseFloat(value);
953
- return Number.isFinite(n) ? n : 0;
954
- };
955
-
956
- /**
957
- * Keep the mirror's text column exactly as wide as the textarea's (#1525).
958
- *
959
- * A layout-consuming vertical scrollbar (Windows/Linux classic scrollbars; the
960
- * app's `scrollbar-width: thin` makes them ~12px) narrows the textarea's
961
- * content box once the composer hits its max height — but the overlay is
962
- * `overflow: hidden` and keeps its full width, so from the first line that
963
- * wraps differently the painted glyphs drift away from the native caret, which
964
- * is the one thing the textarea itself still draws. `scrollbar-gutter: stable`
965
- * cannot fix this: it is ignored on an `overflow: hidden` box. Mirroring the
966
- * measured scrollbar width onto the overlay's right padding makes both boxes
967
- * wrap identically again (and stops the overlay painting under the scrollbar).
968
- *
969
- * Overlay scrollbars (macOS, iOS, any touch surface) consume no layout, so the
970
- * measured width is 0 — and then this writes NOTHING at all, leaving the
971
- * class-driven padding untouched exactly as before the fix.
972
- */
973
- function syncOverlayGutter(
974
- el: HTMLTextAreaElement,
975
- overlay: HTMLElement,
976
- state: OverlayGutterState,
977
- raw: number,
978
- ): void {
979
- // Nothing measurable changed since the last sync — the common case.
980
- if (overlay === state.overlay && raw === state.raw) return;
981
- state.overlay = overlay;
982
- state.raw = raw;
983
-
984
- const clear = () => {
985
- if (state.applied === 0) return; // never wrote anything; leave the DOM alone
986
- overlay.style.removeProperty('padding-right');
987
- state.applied = 0;
988
- };
989
-
990
- const style = getComputedStyle(el);
991
- // `offsetWidth - clientWidth` is scrollbar + left/right borders. The borders
992
- // are shared with the overlay (both boxes are border-box), so only the
993
- // scrollbar part is the extra gutter the mirror is missing.
994
- const borders = cssPx(style.borderLeftWidth) + cssPx(style.borderRightWidth);
995
- const scrollbar = Math.max(0, raw - borders);
996
- if (scrollbar === 0) {
997
- clear();
998
- return;
999
- }
1000
- // Base padding is read from the textarea (never the overlay, whose computed
1001
- // value may already include a gutter we wrote), so the two can never drift
1002
- // apart if COMPOSER_TEXT_CLASSES changes.
1003
- const base = parseFloat(style.paddingRight);
1004
- if (!Number.isFinite(base)) {
1005
- clear(); // unmeasurable: fall back to the pre-fix behavior
1006
- return;
1007
- }
1008
- overlay.style.paddingRight = `${base + scrollbar}px`;
1009
- state.applied = scrollbar;
1010
- }
1011
-
1012
- /**
1013
- * The default chip look: the theme's primary at a wash, one shade stronger
1014
- * than a skill reference's so the two token kinds in one overlay read as
1015
- * siblings rather than the same thing. Deliberately no ring: every class here
1016
- * is one the package already emitted, so a host build's CSS (and the portable
1017
- * guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds
1018
- * `box-shadow: 0 0 0 Npx <background>` through `tokenClassName` — paint, not
1019
- * layout, per the metric rule above.
1020
- */
1021
- const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]';
1022
-
1023
- /**
1024
- * One token's span in the highlight overlay.
1025
- *
1026
- * METRIC RULE, load-bearing for every token kind: a span may change COLOR,
1027
- * BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
1028
- * that moves a glyph — padding, margin, border width, font-weight,
1029
- * letter-spacing, font-size — would shift the overlay's text off the
1030
- * textarea's own layout and drift the caret away from the painted glyphs. A
1031
- * pill's breathing room is faked with a paint-only `box-shadow` ring in the
1032
- * chip's own background color.
1033
- */
1034
- function renderTokenSpan(
1035
- range: ComposerTokenRange,
1036
- text: string,
1037
- mentionTokenClassName?: string,
1038
- ): React.ReactNode {
1039
- if (range.kind === 'mention') {
1040
- // `data-mention-token` / `data-mention-kind` are the host's styling hook;
1041
- // `tokenClassName` is appended verbatim and is the host's to keep
1042
- // metric-safe (see the rule above).
1043
- return (
1044
- <span
1045
- key={`mention-${range.start}`}
1046
- data-mention-token={range.person.id}
1047
- data-mention-kind={range.person.kind}
1048
- className={`${MENTION_CHIP_CLASSES}${
1049
- mentionTokenClassName ? ` ${mentionTokenClassName}` : ''
1050
- }`}
1051
- >
1052
- {text}
1053
- </span>
1054
- );
1055
- }
1056
- // Human-only tokens carry a quiet dotted underline as their inline marker
1057
- // (text-decoration never affects glyph layout, so overlay alignment is
1058
- // safe). The accessible explanation lives in HumanOnlySkillNotice below
1059
- // the textarea — this overlay is aria-hidden.
1060
- return (
1061
- <span
1062
- key={`skill-${range.start}`}
1063
- data-skill-ref-token={range.skill.entry.name}
1064
- data-skill-ref-human-only={range.skill.entry.humanOnly ? 'true' : undefined}
1065
- className={`text-primary bg-primary/10 rounded-[3px] ${
1066
- range.skill.entry.humanOnly
1067
- ? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
1068
- : ''
1069
- }`}
1070
- >
1071
- {text}
1072
- </span>
1073
- );
1074
- }
1075
-
1076
- /** The mention half of the highlight layer. Null → no mention source at all. */
1077
- export interface ComposerMentionChips {
1078
- /** The people whose `@Label` token still survives in the text. */
1079
- readonly people: readonly MentionPerson[];
1080
- /** Host class appended to each chip (see `MentionSource.tokenClassName`). */
1081
- readonly tokenClassName?: string;
1082
- }
1083
-
1084
- interface ComposerTextareaProps {
1085
- value: string;
1086
- onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
1087
- onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
1088
- /** Caret observer (skill autocomplete). */
1089
- onSelectCaret: () => void;
1090
- placeholder: string;
1091
- /** Mode-specific min/max height classes. */
1092
- sizeClassName: string;
1093
- textareaRef: (el: HTMLTextAreaElement | null) => void;
1094
- /** Positioned skill-reference occurrences to highlight. */
1095
- tokens: SkillReferenceToken[];
1096
- /** Off → no skill-reference contribution to the highlight layer. */
1097
- skillReferences: boolean;
1098
- /**
1099
- * The mention contribution, or null with no `mentionSource`. Present (even
1100
- * with nobody tagged yet) it turns the overlay on, so the first pick paints
1101
- * a chip without swapping the textarea for a different element.
1102
- */
1103
- mentionChips: ComposerMentionChips | null;
1104
- /** ARIA relationship to the skill-reference listbox. */
1105
- listboxId: string;
1106
- listboxOpen: boolean;
1107
- activeOptionId?: string;
1108
- }
1109
-
1110
- /**
1111
- * The composer's textarea. With NEITHER token source active this is exactly
1112
- * the pre-feature `<textarea>`; with either one on, its tokens are painted by
1113
- * a mirrored, aria-hidden overlay rendered BEHIND a transparent-text textarea
1114
- * (a textarea cannot style substrings). ONE overlay serves both sources: two
1115
- * mirrored layers could never stay pixel-aligned with each other, and only
1116
- * one of them could own the scroll sync. The overlay shares the exact
1117
- * font/padding/wrapping metrics and mirrors scroll position, and token spans
1118
- * obey the metric rule on `renderTokenSpan` (paint only, never layout), so
1119
- * the glyphs the browser lays out in the textarea and the glyphs the overlay
1120
- * paints coincide. During IME composition the overlay hides and the textarea
1121
- * text becomes visible again (`.pn-ref-composing`), keeping native
1122
- * composition rendering (underlines, candidate highlights) intact.
1123
- */
1124
- const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
1125
- value,
1126
- onChange,
1127
- onKeyDown,
1128
- onSelectCaret,
1129
- placeholder,
1130
- sizeClassName,
1131
- textareaRef,
1132
- tokens,
1133
- skillReferences,
1134
- mentionChips,
1135
- listboxId,
1136
- listboxOpen,
1137
- activeOptionId,
1138
- }) => {
1139
- const overlayRef = useRef<HTMLDivElement>(null);
1140
- const [composing, setComposing] = useState(false);
1141
- const gutterRef = useRef<OverlayGutterState>({ overlay: null, raw: 0, applied: 0 });
1142
-
1143
- const syncScroll = useCallback((el: HTMLTextAreaElement) => {
1144
- const overlay = overlayRef.current;
1145
- if (!overlay) return;
1146
- // Reads before writes so a sync costs at most one forced layout.
1147
- const { scrollTop, scrollLeft } = el;
1148
- const raw = el.offsetWidth - el.clientWidth;
1149
- overlay.scrollTop = scrollTop;
1150
- overlay.scrollLeft = scrollLeft;
1151
- syncOverlayGutter(el, overlay, gutterRef.current, raw);
1152
- }, []);
1153
-
1154
- const innerRef = useRef<HTMLTextAreaElement | null>(null);
1155
- const attachRef = useCallback(
1156
- (el: HTMLTextAreaElement | null) => {
1157
- innerRef.current = el;
1158
- textareaRef(el);
1159
- },
1160
- [textareaRef],
1161
- );
1162
-
1163
- // The overlay exists while EITHER source is active. `mentionChips` turns it
1164
- // on for the whole life of a mention composer, not only once somebody is
1165
- // tagged, so a pick never swaps the textarea element under the caret.
1166
- const mentionPeople = mentionChips?.people;
1167
- const highlight = skillReferences || mentionChips !== null;
1168
-
1169
- // The one list of spans the overlay paints, from every active source.
1170
- // Skill references are the earlier group, so they win a byte both claim.
1171
- const ranges = useMemo(
1172
- () =>
1173
- mergeTokenRanges(value, [
1174
- skillReferences ? skillTokenRanges(tokens) : [],
1175
- mentionPeople ? mentionTokenRanges(value, mentionPeople) : [],
1176
- ]),
1177
- [value, tokens, skillReferences, mentionPeople],
1178
- );
1179
-
1180
- // Keep the mirror aligned when the value changes without a scroll event
1181
- // (e.g. programmatic insertion moving the caret into a scrolled region).
1182
- useEffect(() => {
1183
- if (innerRef.current) syncScroll(innerRef.current);
1184
- }, [value, syncScroll]);
1185
-
1186
- // `syncScroll` otherwise only runs on scroll and on value change, so a
1187
- // window/panel resize that grows or drops the textarea's scrollbar without a
1188
- // keystroke would leave the gutter stale until the next one.
1189
- useEffect(() => {
1190
- const el = innerRef.current;
1191
- if (!el || typeof ResizeObserver === 'undefined') return;
1192
- const observer = new ResizeObserver(() => {
1193
- if (innerRef.current) syncScroll(innerRef.current);
1194
- });
1195
- observer.observe(el);
1196
- return () => observer.disconnect();
1197
- }, [highlight, syncScroll]);
1198
-
1199
- if (!highlight) {
1200
- return (
1201
- <textarea
1202
- data-pn-mobile-editable="true"
1203
- // Absent any open menu these are all undefined, so the plain textarea
1204
- // renders exactly the attributes it always did.
1205
- aria-autocomplete={listboxOpen ? 'list' : undefined}
1206
- aria-controls={listboxOpen ? listboxId : undefined}
1207
- aria-activedescendant={activeOptionId}
1208
- ref={attachRef}
1209
- value={value}
1210
- onChange={onChange}
1211
- onKeyDown={onKeyDown}
1212
- onSelect={onSelectCaret}
1213
- placeholder={placeholder}
1214
- className={`${COMPOSER_TEXT_CLASSES} placeholder:text-muted-foreground resize-none focus:outline-none ${sizeClassName}`}
1215
- style={{ fieldSizing: 'content' } as React.CSSProperties}
1216
- />
1217
- );
1218
- }
1219
-
1220
- const segments: React.ReactNode[] = [];
1221
- let pos = 0;
1222
- for (const range of ranges) {
1223
- if (range.start > pos) segments.push(value.slice(pos, range.start));
1224
- segments.push(
1225
- renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
1226
- );
1227
- pos = range.end;
1228
- }
1229
- segments.push(value.slice(pos));
1230
-
1231
- return (
1232
- <div className="relative">
1233
- <div
1234
- ref={overlayRef}
1235
- aria-hidden="true"
1236
- // Names the SKILL-reference layer, so a skill composer's overlay is
1237
- // byte-identical to the one it rendered before mentions existed; a
1238
- // mentions-only overlay is found by its mirror attribute below.
1239
- data-skill-ref-overlay={skillReferences ? 'true' : undefined}
1240
- data-pn-mobile-editable-mirror="true"
1241
- className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
1242
- style={composing ? { visibility: 'hidden' } : undefined}
1243
- >
1244
- {segments}
1245
- {'\n'}
1246
- </div>
1247
- <textarea
1248
- data-pn-mobile-editable="true"
1249
- aria-label={placeholder}
1250
- aria-autocomplete="list"
1251
- aria-haspopup="listbox"
1252
- aria-controls={listboxOpen ? listboxId : undefined}
1253
- aria-owns={listboxOpen ? listboxId : undefined}
1254
- aria-activedescendant={activeOptionId}
1255
- ref={attachRef}
1256
- value={value}
1257
- onChange={onChange}
1258
- onKeyDown={onKeyDown}
1259
- onSelect={onSelectCaret}
1260
- onScroll={(e) => syncScroll(e.currentTarget)}
1261
- onCompositionStart={() => setComposing(true)}
1262
- onCompositionEnd={() => setComposing(false)}
1263
- placeholder={placeholder}
1264
- className={`${COMPOSER_TEXT_CLASSES} placeholder:text-muted-foreground resize-none focus:outline-none relative pn-ref-input ${
1265
- composing ? 'pn-ref-composing' : ''
1266
- } ${sizeClassName}`}
1267
- style={{ fieldSizing: 'content' } as React.CSSProperties}
1268
- />
1269
- </div>
1270
- );
1271
- };
1272
929
 
1273
930
  // Icons
1274
931