@plannotator/ui 0.53.0 → 0.53.1

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.
@@ -180,7 +180,7 @@ const CodeInlineAnnotation: React.FC<{
180
180
  <span className="rounded bg-muted px-1.5 py-0.5 font-mono normal-case text-foreground">
181
181
  {lineLabel(annotation.lineStart, annotation.lineEnd)}
182
182
  </span>
183
- {annotation.author && <span className="truncate normal-case">by {annotation.author}</span>}
183
+ {annotation.author && <span className="truncate normal-case">by <bdi>{annotation.author}</bdi></span>}
184
184
  <div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
185
185
  {onEdit && !isEditing && (
186
186
  <button
@@ -726,6 +726,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
726
726
  activeOptionId={activeComposerOptionId}
727
727
  />
728
728
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
729
+ {mentionSource?.renderNotice?.(mentionChipPeople)}
729
730
  <MentionAutocompleteMenu
730
731
  id={mentionListboxId}
731
732
  menu={mentionAc.menu}
@@ -879,6 +880,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
879
880
  activeOptionId={activeComposerOptionId}
880
881
  />
881
882
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
883
+ {mentionSource?.renderNotice?.(mentionChipPeople)}
882
884
  <MentionAutocompleteMenu
883
885
  id={mentionListboxId}
884
886
  menu={mentionAc.menu}
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import React from 'react';
33
33
  import { Tooltip } from './Tooltip';
34
+ import { modKey } from '../utils/platform';
34
35
  import { formatShortcutBindingText, formatShortcutBindingTokens } from '../shortcuts/core';
35
36
  import { htmlAnnotateShortcuts } from '../shortcuts/plan-review/htmlAnnotate.shortcuts';
36
37
 
@@ -135,7 +136,7 @@ export interface HtmlSurfaceControlLabels {
135
136
  export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
136
137
  Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
137
138
  > = {
138
- annotateTitle: 'Annotate mode: click an element or select text to comment. Esc to interact',
139
+ annotateTitle: `Annotate mode: click an element or select text to comment; links open (${modKey}-click one to comment on it). Esc to interact`,
139
140
  interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
140
141
  hideTools: 'Hide tools',
141
142
  showTools: 'Show tools',
@@ -34,6 +34,18 @@ export function mentionAnchorOf(element: HTMLElement | null): MentionAnchor | nu
34
34
  return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width };
35
35
  }
36
36
 
37
+ /** Consecutive runs of people with the same `group`, with each run's first index in the flat list. */
38
+ export function mentionGroups(people: readonly MentionPerson[]): { group: string; start: number; people: MentionPerson[] }[] {
39
+ const runs: { group: string; start: number; people: MentionPerson[] }[] = [];
40
+ people.forEach((person, index) => {
41
+ const group = person.group ?? '';
42
+ const last = runs[runs.length - 1];
43
+ if (last && last.group === group) last.people.push(person);
44
+ else runs.push({ group, start: index, people: [person] });
45
+ });
46
+ return runs;
47
+ }
48
+
37
49
  /** The optional avatar column: an image, else initials on a tinted disc. */
38
50
  function MentionAvatar({
39
51
  avatar,
@@ -116,6 +128,45 @@ export function MentionPicker({
116
128
  <p data-mention-empty className="px-2 py-1 text-[11px] text-muted-foreground">
117
129
  {emptyNotice}
118
130
  </p>
131
+ ) : people.some((person) => person.group) ? (
132
+ // Grouped (a host set `group`): each run of one group is a `role="group"` labelled by its heading.
133
+ mentionGroups(people).map((run) => {
134
+ const headingId = id ? `${id}-group-${run.start}` : undefined;
135
+ return (
136
+ <div key={`${run.group}-${run.start}`} role="group" aria-labelledby={headingId} data-mention-group={run.group}>
137
+ <p id={headingId} data-mention-group-heading className="px-2 pb-1 pt-1.5 text-[10px] font-medium text-muted-foreground">
138
+ {run.group}
139
+ </p>
140
+ {run.people.map((person, offset) => {
141
+ const index = run.start + offset;
142
+ return (
143
+ <button
144
+ key={person.id}
145
+ type="button"
146
+ role="option"
147
+ id={id ? `${id}-option-${index}` : undefined}
148
+ aria-selected={active === index}
149
+ data-mention-option={person.id}
150
+ onMouseDown={(event) => {
151
+ event.preventDefault();
152
+ onPick(person);
153
+ }}
154
+ onMouseEnter={() => onHover(index)}
155
+ className={`flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-left text-xs transition-colors ${
156
+ active === index ? 'bg-muted text-foreground' : 'text-foreground/85 hover:bg-muted/60'
157
+ }`}
158
+ >
159
+ {person.avatar && <MentionAvatar avatar={person.avatar} label={person.label} />}
160
+ <span className="min-w-0 flex-1 truncate">{person.label}</span>
161
+ {person.detail && (
162
+ <span className="min-w-0 max-w-[44%] shrink-0 truncate text-[10px] text-muted-foreground">{person.detail}</span>
163
+ )}
164
+ </button>
165
+ );
166
+ })}
167
+ </div>
168
+ );
169
+ })
119
170
  ) : (
120
171
  people.map((person, index) => (
121
172
  <button
@@ -15,6 +15,16 @@ interface SkillReferenceMenuProps {
15
15
  /** Explicitly activated row, or null — the menu opens with nothing active. */
16
16
  activeIndex: number | null;
17
17
  onSelect: (index: number) => void;
18
+ /**
19
+ * The box the menu must stay inside, in viewport coordinates, read at each
20
+ * measure. Default: the viewport. A host whose composer sits in a dialog
21
+ * with a footer passes the dialog's area above the footer.
22
+ */
23
+ boundary?: () => { top: number; bottom: number } | null;
24
+ /** Which side of the anchor the menu tries first. Default: above. */
25
+ prefer?: 'above' | 'below';
26
+ /** Upper bound on the list's height, in px. Default: 256. */
27
+ maxListHeight?: number;
18
28
  }
19
29
 
20
30
  /** Vertical gap between the composer wrapper and the menu (mb-1.5 / mt-1.5 = 6px). */
@@ -40,24 +50,29 @@ interface MenuPlacement {
40
50
  * When neither side fits the whole list, the side with more room wins and the
41
51
  * list is clamped to it, so the menu never extends past a viewport edge.
42
52
  */
43
- function computeMenuPlacement(
53
+ export function computeMenuPlacement(
44
54
  anchorRect: Pick<DOMRect, 'top' | 'bottom'>,
45
55
  naturalListHeight: number,
46
56
  chromeHeight: number,
47
57
  viewportHeight: number,
58
+ options: { boundaryTop?: number; prefer?: 'above' | 'below'; maxListHeight?: number } = {},
48
59
  ): MenuPlacement {
49
- const spaceAbove = anchorRect.top - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
60
+ const boundaryTop = options.boundaryTop ?? 0;
61
+ const cap = options.maxListHeight ?? MAX_LIST_HEIGHT;
62
+ const spaceAbove = anchorRect.top - boundaryTop - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
50
63
  const spaceBelow = viewportHeight - anchorRect.bottom - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
51
- const needed = Math.min(naturalListHeight, MAX_LIST_HEIGHT);
64
+ const needed = Math.min(naturalListHeight, cap);
65
+ const [first, second] = options.prefer === 'below' ? (['below', 'above'] as const) : (['above', 'below'] as const);
66
+ const space = (side: MenuPlacement['direction']) => (side === 'above' ? spaceAbove : spaceBelow);
52
67
  const direction: MenuPlacement['direction'] =
53
- needed <= spaceAbove ? 'above'
54
- : needed <= spaceBelow ? 'below'
55
- : spaceAbove >= spaceBelow ? 'above'
56
- : 'below';
57
- const available = direction === 'above' ? spaceAbove : spaceBelow;
68
+ needed <= space(first) ? first
69
+ : needed <= space(second) ? second
70
+ : space(first) >= space(second) ? first
71
+ : second;
72
+ const available = space(direction);
58
73
  return {
59
74
  direction,
60
- maxListHeight: Math.max(0, Math.min(MAX_LIST_HEIGHT, Math.round(available))),
75
+ maxListHeight: Math.max(0, Math.min(cap, Math.round(available))),
61
76
  };
62
77
  }
63
78
 
@@ -85,13 +100,19 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
85
100
  items,
86
101
  activeIndex,
87
102
  onSelect,
103
+ boundary,
104
+ prefer,
105
+ maxListHeight,
88
106
  }) => {
89
107
  const menuRef = useRef<HTMLDivElement>(null);
90
108
  const listRef = useRef<HTMLDivElement>(null);
91
109
  const [placement, setPlacement] = useState<MenuPlacement>({
92
- direction: 'above',
93
- maxListHeight: MAX_LIST_HEIGHT,
110
+ direction: prefer ?? 'above',
111
+ maxListHeight: maxListHeight ?? MAX_LIST_HEIGHT,
94
112
  });
113
+ // The optional seams, read through a ref so `measure` stays stable for its listeners.
114
+ const optionsRef = useRef({ boundary, prefer, maxListHeight });
115
+ optionsRef.current = { boundary, prefer, maxListHeight };
95
116
 
96
117
  const measure = useCallback(() => {
97
118
  const menuEl = menuRef.current;
@@ -106,7 +127,13 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
106
127
  // jitter loop this component once shipped.
107
128
  const chrome = Math.max(0, menuEl.offsetHeight - listEl.offsetHeight);
108
129
  // scrollHeight reports full content height even while clamped.
109
- const next = computeMenuPlacement(rect, listEl.scrollHeight, chrome, window.innerHeight);
130
+ const { boundary: readBoundary, prefer: side, maxListHeight: cap } = optionsRef.current;
131
+ const box = readBoundary?.() ?? null;
132
+ const next = computeMenuPlacement(rect, listEl.scrollHeight, chrome, box ? box.bottom : window.innerHeight, {
133
+ boundaryTop: box ? box.top : 0,
134
+ prefer: side,
135
+ maxListHeight: cap,
136
+ });
110
137
  setPlacement((prev) =>
111
138
  prev.direction === next.direction && prev.maxListHeight === next.maxListHeight
112
139
  ? prev
@@ -326,6 +326,18 @@ export interface ViewerProps {
326
326
  * blocks and no comments on its text. Absent → unchanged.
327
327
  */
328
328
  answerOnly?: boolean;
329
+ /**
330
+ * Opt-in host capability, with `answerOnly`: the reader may comment on the
331
+ * text. A text selection opens the selection toolbar, comment-only
332
+ * (Comment and the one-click 👍; no Delete, no label picker), and its
333
+ * comment composer; `annotations` are drawn as highlights. Question cards
334
+ * are left out of selection (their text is not commented on), and nothing
335
+ * else of authoring comes back (no pinpoint, code-block toolbar, Global
336
+ * comment or Copy, no Vim). For a host that shows a message whose words
337
+ * can be commented on (the Plannotator Inbox's agent messages). Ignored
338
+ * without `answerOnly` and under `readOnly`. Absent → unchanged.
339
+ */
340
+ textComments?: boolean;
329
341
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
330
342
  vimModeEnabled?: boolean;
331
343
  /** Replace the compact Vim badge with the live video-style key HUD. */
@@ -731,6 +743,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
731
743
  allowImages = true,
732
744
  readOnly = false,
733
745
  answerOnly = false,
746
+ textComments = false,
734
747
  vimModeEnabled = false,
735
748
  vimHudEnabled = false,
736
749
  vimHudKeyPanelEnabled = true,
@@ -740,6 +753,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
740
753
  // Annotation authoring is off when read-only, and when the host only wants
741
754
  // answers (`answerOnly`); question cards and checkboxes follow `readOnly`.
742
755
  const authoringOff = readOnly || answerOnly;
756
+ // `textComments`: an answer-only document whose words still take comments
757
+ // (the selection toolbar, comment-only, and its composer); everything else
758
+ // of authoring stays off. Without it this is exactly `!authoringOff`.
759
+ const textCommentsOnly = answerOnly && textComments && !readOnly;
760
+ const selectionCommentsOn = !authoringOff || textCommentsOnly;
743
761
  const hostComposes = onHostDraft !== undefined && !authoringOff;
744
762
  const viewerAnnotationHeader = authoringOff ? undefined : annotationHeader;
745
763
  const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
@@ -883,7 +901,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
883
901
  onSelectAnnotation,
884
902
  selectedAnnotationId,
885
903
  mode,
886
- enabled: !authoringOff,
904
+ enabled: selectionCommentsOn,
887
905
  // Markdown documents drift between the moment a draft/share is written and
888
906
  // the moment it is restored (a plan revision, a re-rendered block, a
889
907
  // renderer change that adds or drops elements — #1509 hoisted an alert's
@@ -1697,12 +1715,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1697
1715
  : undefined;
1698
1716
  if (hostCard !== undefined) {
1699
1717
  return (
1700
- <div key={group.block.id} data-block-id={group.block.id} data-block-type="directive">
1718
+ <div key={group.block.id} data-block-id={group.block.id} data-block-type="directive" className={textCommentsOnly ? 'annotation-exclude' : undefined}>
1701
1719
  {hostCard}
1702
1720
  </div>
1703
1721
  );
1704
1722
  }
1705
- return (
1723
+ const rendered = (
1706
1724
  <BlockRenderer
1707
1725
  imageBaseDir={imageBaseDir}
1708
1726
  onImageClick={(src, alt) => setLightbox({ src, alt })}
@@ -1732,16 +1750,28 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1732
1750
  renderQuestionBelowPrompt={renderQuestionBelowPrompt}
1733
1751
  />
1734
1752
  );
1753
+ // `textComments`: a question card's words are not commented on
1754
+ // (its own controls already are excluded); a box-less wrapper
1755
+ // keeps its layout.
1756
+ if (question && textCommentsOnly) {
1757
+ return (
1758
+ <div key={group.block.id} className="annotation-exclude" style={{ display: 'contents' }}>
1759
+ {rendered}
1760
+ </div>
1761
+ );
1762
+ }
1763
+ return rendered;
1735
1764
  })()
1736
1765
  )
1737
1766
  )}
1738
1767
 
1739
1768
  {/* Text selection toolbar */}
1740
- {!authoringOff && !hostComposes && toolbarState && (
1769
+ {selectionCommentsOn && !hostComposes && toolbarState && (
1741
1770
  <ToolbarErrorBoundary>
1742
1771
  <AnnotationToolbar
1743
1772
  element={toolbarState.element}
1744
1773
  positionMode="center-above"
1774
+ commentOnly={textCommentsOnly || undefined}
1745
1775
  onAnnotate={handleAnnotate}
1746
1776
  onClose={handleToolbarClose}
1747
1777
  onRequestComment={handleRequestComment}
@@ -1876,7 +1906,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1876
1906
  )}
1877
1907
 
1878
1908
  {/* Comment popover — hook handles text selection, Viewer handles global + code block */}
1879
- {!authoringOff && !hostComposes && hookCommentPopover && (
1909
+ {selectionCommentsOn && !hostComposes && hookCommentPopover && (
1880
1910
  <CommentPopover
1881
1911
  anchorEl={hookCommentPopover.anchorEl}
1882
1912
  contextText={hookCommentPopover.contextText}
@@ -1928,7 +1958,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1928
1958
  )}
1929
1959
 
1930
1960
  {/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
1931
- {!authoringOff && !hostComposes && hookQuickLabelPicker && (
1961
+ {selectionCommentsOn && !hostComposes && hookQuickLabelPicker && (
1932
1962
  <FloatingQuickLabelPicker
1933
1963
  anchorEl={hookQuickLabelPicker.anchorEl}
1934
1964
  cursorHint={hookQuickLabelPicker.cursorHint}
@@ -18,6 +18,9 @@ const QUESTION_CONTEXT_CSS = `
18
18
  .question-context > :first-child { margin-top: 0; }
19
19
  .question-context > :last-child { margin-bottom: 0; }
20
20
  .question-context p, .question-context .text-sm { font-size: 13px; line-height: 1.5; }
21
+ /* Context lines stay lines ("Stopped: …" then "Holds up: …"), as the
22
+ authoring guide writes them; the parser keeps them as soft breaks. */
23
+ .question-context p { white-space: pre-line; }
21
24
  .question-context p, .question-context [data-question-context-list] span { color: inherit; }
22
25
  .question-context h1, .question-context h2, .question-context h3 { margin: 0.75rem 0 0.25rem; font-size: 14px; }
23
26
  .question-context h3 { font-size: 13.5px; }
@@ -239,6 +239,13 @@ export interface HtmlViewerProps {
239
239
  * key. Honored only while the frame holds focus, and at most once per
240
240
  * STEP_MESSAGE_MIN_INTERVAL_MS. Absent: the message is ignored, as before. */
241
241
  onStep?: (dir: 1 | -1) => void;
242
+ /** Escape pressed inside the page in Interact that the page did not use
243
+ * itself (no handler of the page cancelled it or stopped it, and focus was
244
+ * not in one of its text fields): the host may close something of its own
245
+ * (the Inbox leaves full screen). Never sent in Annotate, whose Escape is
246
+ * `onAnnotateModeExit`. Honored only while the frame holds focus, and at
247
+ * most once per STEP_MESSAGE_MIN_INTERVAL_MS. Absent: ignored, as before. */
248
+ onPageEscape?: () => void;
242
249
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
243
250
  vimModeEnabled?: boolean;
244
251
  /** Replace the iframe-local compact badge with the shared live key HUD. */
@@ -384,6 +391,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
384
391
  onAnnotateModeToggle,
385
392
  onToolsToggle,
386
393
  onStep,
394
+ onPageEscape,
387
395
  vimModeEnabled = false,
388
396
  vimHudEnabled = false,
389
397
  vimHudKeyPanelEnabled = true,
@@ -452,6 +460,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
452
460
  const annotateModeActiveRef = useRef(annotateModeActive);
453
461
  annotateModeActiveRef.current = annotateModeActive;
454
462
  const lastStepAtRef = useRef(0);
463
+ const onPageEscapeRef = useRef(onPageEscape);
464
+ onPageEscapeRef.current = onPageEscape;
465
+ const lastPageEscapeAtRef = useRef(0);
455
466
 
456
467
  /** Single choke point for direct-to-bridge posts: live sessions get the
457
468
  * token + concrete targetOrigin, srcdoc keeps "*" and no token. */
@@ -809,6 +820,17 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
809
820
  onStepRef.current?.(dir);
810
821
  return;
811
822
  }
823
+ if (isRecord(e.data) && e.data.type === `${PREFIX}page-escape`) {
824
+ // Like a step, the page could post this itself: honored only for a
825
+ // key pressed in the frame, in Interact, at most once per interval.
826
+ if (annotateModeActiveRef.current) return;
827
+ if (document.activeElement !== iframeRef.current) return;
828
+ const now = Date.now();
829
+ if (now - lastPageEscapeAtRef.current < STEP_MESSAGE_MIN_INTERVAL_MS) return;
830
+ lastPageEscapeAtRef.current = now;
831
+ onPageEscapeRef.current?.();
832
+ return;
833
+ }
812
834
  const vimCopy = parseVimBridgeCopy(e.data);
813
835
  if (vimCopy !== null) {
814
836
  const iframe = iframeRef.current;