@plannotator/ui 0.53.0 → 0.54.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 (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. package/utils/vscodePanel.ts +9 -0
@@ -1,8 +1,9 @@
1
- import React, { useState, useRef, useEffect, useId } from 'react';
1
+ import React, { createContext, useContext, useMemo, useState, useRef, useEffect, useId } from 'react';
2
2
  import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
3
3
  import type { MentionSource } from '../utils/mentions';
4
4
  import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete';
5
5
  import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete';
6
+ import { buildSectionRefIndex, parseSectionRefs } from '../utils/sectionRefs';
6
7
  import { isCurrentUser } from '../utils/identity';
7
8
  import { ImageThumbnail } from './ImageThumbnail';
8
9
  import { EditorAnnotationCard } from './EditorAnnotationCard';
@@ -16,6 +17,15 @@ import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotati
16
17
  import type { QuestionPanelRow } from '../utils/questionAnswers';
17
18
  import { QuestionsPanelSection } from './QuestionsPanelSection';
18
19
  import { HostTitle } from './HostTitle';
20
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
21
+ import { useConfigValue } from '../config/useConfig';
22
+ import { imageRegionNumbers, RegionCardInfo, RegionNumber, regionOf } from './ImageRegions';
23
+
24
+ /** What a region card (#2100) needs from its panel: each region's number on
25
+ * its image, and how to load the image for the crop thumbnail. */
26
+ const RegionCardContext = createContext<{ numbers: ReadonlyMap<string, number>; thumbSrc?: (src: string) => string | null }>({
27
+ numbers: new Map(),
28
+ });
19
29
 
20
30
  // Card type-word colors. Deletion uses `destructive` (reliably red on every
21
31
  // theme, matching the in-document .deletion highlight). Comment uses the
@@ -120,6 +130,8 @@ interface PanelProps {
120
130
  editorAnnotations?: EditorAnnotation[];
121
131
  onDeleteEditorAnnotation?: (id: string) => void;
122
132
  onClose?: () => void;
133
+ /** Navigate the document to a heading anchor, for `#Heading` references in comment text. */
134
+ onNavigateAnchor?: (hash: string) => void;
123
135
  /** Copy the full feedback payload. May resolve a success boolean; resolving
124
136
  * `false` suppresses the "Copied" flash. A void resolution (existing hosts)
125
137
  * is treated as success, preserving the original behavior. */
@@ -213,13 +225,30 @@ interface PanelProps {
213
225
  * the frame in `renderCardHeader`) turns them off per card. Absent: both
214
226
  * drawn, exactly as before.
215
227
  */
216
- cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean };
228
+ cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean; text?: boolean };
229
+ /**
230
+ * Optional: the host's own body for a plan-annotation card, drawn where the
231
+ * comment text goes, inside the card (a click on it selects the card). The
232
+ * Inbox draws a drawing that has no words as its picture, with `cardParts`
233
+ * `text: false` (its stored text is a stand-in for the agent, not the
234
+ * person's words: not drawn, and the edit box starts empty). Absent:
235
+ * nothing extra, exactly as before.
236
+ */
237
+ renderCardBody?: (annotation: Annotation) => React.ReactNode;
238
+ /**
239
+ * Comments on part of an image (`imageRegion`, #2100) draw a crop of their
240
+ * box in place of the quote; this turns an image's src as written into a
241
+ * URL the browser can load (null: no crop, the file and size still show).
242
+ * Absent: no crop.
243
+ */
244
+ imageRegionSrc?: (src: string) => string | null;
217
245
  }
218
246
 
219
247
  export const AnnotationPanel: React.FC<PanelProps> = ({
220
248
  emptyHint = 'Select text to annotate',
221
249
  isOpen,
222
250
  annotations,
251
+ blocks,
223
252
  onSelect,
224
253
  onDelete,
225
254
  onEdit,
@@ -256,7 +285,19 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
256
285
  title = 'Annotations',
257
286
  titleNote,
258
287
  cardParts,
288
+ renderCardBody,
289
+ imageRegionSrc,
290
+ onNavigateAnchor,
259
291
  }) => {
292
+ // `#Heading` links in comment text; nothing is parsed unless the host can navigate.
293
+ const sectionRefs = useMemo(
294
+ () => (onNavigateAnchor ? buildSectionRefIndex(blocks) : undefined),
295
+ [blocks, onNavigateAnchor],
296
+ );
297
+ const regionCards = useMemo(
298
+ () => ({ numbers: imageRegionNumbers(annotations), thumbSrc: imageRegionSrc }),
299
+ [annotations, imageRegionSrc],
300
+ );
260
301
  const isMobile = useIsMobile();
261
302
  const embedded = presentation === 'embedded';
262
303
  const mobilePanel = isMobile && !embedded;
@@ -517,8 +558,11 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
517
558
  footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
518
559
  header={group.isCurrent ? renderCardHeader?.(annotation) : undefined}
519
560
  parts={group.isCurrent ? cardParts?.(annotation) : undefined}
561
+ body={group.isCurrent ? renderCardBody?.(annotation) : undefined}
520
562
  mentionSource={mentionSource}
521
563
  unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
564
+ sectionRefs={group.isCurrent ? sectionRefs : undefined}
565
+ onNavigateAnchor={onNavigateAnchor}
522
566
  />
523
567
  );
524
568
  return isReply ? (
@@ -623,6 +667,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
623
667
  >
624
668
  <AnnotationCard
625
669
  annotation={entry.annotation}
670
+ sectionRefs={sectionRefs}
671
+ onNavigateAnchor={onNavigateAnchor}
626
672
  isSelected={selectedId === entry.annotation.id}
627
673
  isMe={isCurrentUser(entry.annotation.author)}
628
674
  onSelect={() => onSelect(entry.annotation.id)}
@@ -632,6 +678,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
632
678
  footer={renderCardFooter?.(entry.annotation)}
633
679
  header={renderCardHeader?.(entry.annotation)}
634
680
  parts={cardParts?.(entry.annotation)}
681
+ body={renderCardBody?.(entry.annotation)}
635
682
  mentionSource={mentionSource}
636
683
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
637
684
  />
@@ -640,6 +687,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
640
687
  <AnnotationCard
641
688
  key={entry.annotation.id}
642
689
  annotation={entry.annotation}
690
+ sectionRefs={sectionRefs}
691
+ onNavigateAnchor={onNavigateAnchor}
643
692
  isSelected={selectedId === entry.annotation.id}
644
693
  isMe={isCurrentUser(entry.annotation.author)}
645
694
  onSelect={() => onSelect(entry.annotation.id)}
@@ -649,6 +698,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
649
698
  footer={renderCardFooter?.(entry.annotation)}
650
699
  header={renderCardHeader?.(entry.annotation)}
651
700
  parts={cardParts?.(entry.annotation)}
701
+ body={renderCardBody?.(entry.annotation)}
652
702
  mentionSource={mentionSource}
653
703
  unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
654
704
  />
@@ -691,7 +741,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
691
741
  </OverlayScrollArea>
692
742
 
693
743
  {/* Quick Actions Footer */}
694
- {headerCount > 0 && (
744
+ {headerCount > 0 && (onQuickCopy || (sharingEnabled && onShare)) && (
695
745
  <div className="border-t border-border/50 px-3 py-2 flex gap-1.5">
696
746
  {onQuickCopy && (
697
747
  <button
@@ -745,12 +795,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
745
795
  className="fixed inset-0 z-[59] bg-background/60 backdrop-blur-sm"
746
796
  onClick={onClose}
747
797
  />
748
- {panel}
798
+ <RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>
749
799
  </>
750
800
  );
751
801
  }
752
802
 
753
- return panel;
803
+ return <RegionCardContext.Provider value={regionCards}>{panel}</RegionCardContext.Provider>;
754
804
  };
755
805
 
756
806
  function formatTimestamp(ts: number): string {
@@ -766,9 +816,38 @@ function formatTimestamp(ts: number): string {
766
816
  if (hours < 24) return `${hours}h`;
767
817
  if (days < 7) return `${days}d`;
768
818
 
769
- return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
819
+ // An older date names its year: "Oct 20" alone reads as this October.
820
+ const date = new Date(ts);
821
+ const thisYear = date.getFullYear() === new Date(now).getFullYear();
822
+ return date.toLocaleDateString(
823
+ undefined,
824
+ thisYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' },
825
+ );
770
826
  }
771
827
 
828
+ /**
829
+ * A card's "author · time" line. The author truncates (its full name in the
830
+ * title) while the time never shrinks, so a long name cannot push the time
831
+ * out; `<bdi>` keeps a right-to-left name from reordering the time around it.
832
+ * The text reads exactly as before: "Alice (me) · 5m".
833
+ */
834
+ const CardByline: React.FC<{ author?: string; isMe: boolean; timestamp: number }> = ({ author, isMe, timestamp }) => (
835
+ <span data-annotation-byline="true" className="flex min-w-0 items-center text-[10px] text-muted-foreground/50">
836
+ {author ? (
837
+ <>
838
+ <HostTitle title={author} cut>
839
+ <span data-annotation-author="true" className="min-w-0 truncate">
840
+ <bdi>{author}</bdi>
841
+ </span>
842
+ </HostTitle>
843
+ {isMe ? <span data-annotation-me="true" className="shrink-0 whitespace-pre"> (me)</span> : null}
844
+ <span className="shrink-0 whitespace-pre"> · </span>
845
+ </>
846
+ ) : null}
847
+ <span data-annotation-time="true" className="shrink-0">{formatTimestamp(timestamp)}</span>
848
+ </span>
849
+ );
850
+
772
851
  /** Pinned card for committed direct edits: +N/−M summary, expandable unified
773
852
  * diff, and a two-step discard. Not part of the annotation timeline — edits
774
853
  * are document state, not a selection-anchored note. */
@@ -870,6 +949,44 @@ const DirectEditsCard: React.FC<{
870
949
  );
871
950
  };
872
951
 
952
+ /** Comment text, with each `#Heading text` as a link that scrolls to that heading. */
953
+ const AnnotationText: React.FC<{
954
+ text: string;
955
+ className: string;
956
+ sectionRefs?: Map<string, string>;
957
+ onNavigateAnchor?: (hash: string) => void;
958
+ }> = ({ text, className, sectionRefs, onNavigateAnchor }) => {
959
+ const parts = React.useMemo(
960
+ () => (sectionRefs && onNavigateAnchor ? parseSectionRefs(text, sectionRefs) : [text]),
961
+ [text, sectionRefs, onNavigateAnchor],
962
+ );
963
+
964
+ return (
965
+ <p className={className}>
966
+ {parts.map((part, i) =>
967
+ typeof part === 'string' ? (
968
+ part
969
+ ) : (
970
+ <a
971
+ key={i}
972
+ href={`#${part.anchor}`}
973
+ className="rounded-sm font-medium text-primary underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
974
+ onClick={(e) => {
975
+ // The host scrolls; the card's own onSelect would scroll to the
976
+ // annotation instead, and the URL hash stays as it was.
977
+ e.preventDefault();
978
+ e.stopPropagation();
979
+ onNavigateAnchor!(`#${part.anchor}`);
980
+ }}
981
+ >
982
+ {part.label}
983
+ </a>
984
+ ),
985
+ )}
986
+ </p>
987
+ );
988
+ };
989
+
873
990
  /**
874
991
  * The plan-annotation card's edit box.
875
992
  *
@@ -900,6 +1017,7 @@ const AnnotationEditComposer: React.FC<{
900
1017
  const mention = useMentionAutocomplete({ text: value, setText: onChange, textareaRef, source: mentionSource });
901
1018
  const listboxId = `annotation-card-mentions-${useId().replace(/:/g, '')}`;
902
1019
  const menuOpen = mention.menu !== null;
1020
+ const enterSavesComment = useConfigValue('enterSavesComment');
903
1021
 
904
1022
  // Focus-on-open, unchanged: this component mounts exactly when editing starts.
905
1023
  useEffect(() => {
@@ -914,7 +1032,7 @@ const AnnotationEditComposer: React.FC<{
914
1032
  // (and only a second Escape cancels the edit), and an arrowed-to row
915
1033
  // takes Enter. Mod+Enter is never consumed by the menu.
916
1034
  if (mention.onKeyDown(e)) return;
917
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
1035
+ if (isCommentSubmitKey(e, enterSavesComment)) {
918
1036
  e.preventDefault();
919
1037
  onSave(mention.mentionIds);
920
1038
  } else if (e.key === 'Escape') {
@@ -967,21 +1085,32 @@ const AnnotationCard: React.FC<{
967
1085
  /** The annotation has no live location in the document (host-reported). */
968
1086
  unanchored?: boolean;
969
1087
  /** Built-in parts to draw (see `cardParts`). */
970
- parts?: { quote?: boolean; images?: boolean };
971
- }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts }) => {
1088
+ parts?: { quote?: boolean; images?: boolean; text?: boolean };
1089
+ /** Host body, drawn where the comment text goes (see `renderCardBody`). */
1090
+ body?: React.ReactNode;
1091
+ sectionRefs?: Map<string, string>;
1092
+ onNavigateAnchor?: (hash: string) => void;
1093
+ }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, header, mentionSource, unanchored = false, parts, body, sectionRefs, onNavigateAnchor }) => {
1094
+ // `text: false`: the stored text is the host's stand-in, never the person's words; the edit box starts empty.
1095
+ const shownText = parts?.text === false ? '' : annotation.text || '';
972
1096
  const [isEditing, setIsEditing] = useState(false);
973
- const [editText, setEditText] = useState(annotation.text || '');
1097
+ const [editText, setEditText] = useState(shownText);
1098
+ // A comment on part of an image (#2100): numbered like its box, with the
1099
+ // crop, file, size and line in place of the quote.
1100
+ const regionCards = useContext(RegionCardContext);
1101
+ const region = regionOf(annotation);
1102
+ const regionNumber = region ? regionCards.numbers.get(annotation.id) : undefined;
974
1103
 
975
1104
  // Update editText when annotation.text changes
976
1105
  useEffect(() => {
977
1106
  if (!isEditing) {
978
- setEditText(annotation.text || '');
1107
+ setEditText(shownText);
979
1108
  }
980
- }, [annotation.text, isEditing]);
1109
+ }, [shownText, isEditing]);
981
1110
 
982
1111
  const handleStartEdit = (e: React.MouseEvent) => {
983
1112
  e.stopPropagation();
984
- setEditText(annotation.text || '');
1113
+ setEditText(shownText);
985
1114
  setIsEditing(true);
986
1115
  };
987
1116
 
@@ -998,7 +1127,7 @@ const AnnotationCard: React.FC<{
998
1127
  };
999
1128
 
1000
1129
  const handleCancelEdit = () => {
1001
- setEditText(annotation.text || '');
1130
+ setEditText(shownText);
1002
1131
  setIsEditing(false);
1003
1132
  };
1004
1133
 
@@ -1037,6 +1166,12 @@ const AnnotationCard: React.FC<{
1037
1166
  {/* Header: type word + author · time + actions */}
1038
1167
  <div className="mb-1.5 flex items-center gap-1.5">
1039
1168
  <span className={cn('text-[11px] font-medium', typeColor)}>{typeLabel}</span>
1169
+ {region && regionNumber !== undefined && <RegionNumber n={regionNumber} selected={isSelected} />}
1170
+ {region && (
1171
+ <span data-annotation-region="true" className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1172
+ Region
1173
+ </span>
1174
+ )}
1040
1175
  {annotation.diffContext && (
1041
1176
  <span className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">
1042
1177
  diff
@@ -1056,9 +1191,7 @@ const AnnotationCard: React.FC<{
1056
1191
  </span>
1057
1192
  </HostTitle>
1058
1193
  )}
1059
- <span className="text-[10px] text-muted-foreground/50 truncate">
1060
- {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
1061
- </span>
1194
+ <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdA} />
1062
1195
  {/* Host header slot (a status stamp etc.) — interactions inside it
1063
1196
  must not toggle card selection, exactly like the footer slot.
1064
1197
  Rendered under readOnly too: its contents are host-owned. */}
@@ -1073,7 +1206,7 @@ const AnnotationCard: React.FC<{
1073
1206
  </div>
1074
1207
  )}
1075
1208
  {!readOnly && (
1076
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1209
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
1077
1210
  {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
1078
1211
  <HostTitle title="Edit annotation" name>
1079
1212
  <button
@@ -1103,15 +1236,22 @@ const AnnotationCard: React.FC<{
1103
1236
  isEditing ? (
1104
1237
  editComposer
1105
1238
  ) : (
1106
- <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1107
- {annotation.text}
1108
- </p>
1239
+ <AnnotationText
1240
+ text={annotation.text || ''}
1241
+ className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
1242
+ sectionRefs={sectionRefs}
1243
+ onNavigateAnchor={onNavigateAnchor}
1244
+ />
1109
1245
  )
1110
1246
  ) : (
1111
1247
  <>
1248
+ {/* A region shows its crop, file, size and line instead of a quote. */}
1249
+ {region && parts?.quote !== false && (
1250
+ <RegionCardInfo region={region} thumbSrc={regionCards.thumbSrc?.(region.src) ?? null} />
1251
+ )}
1112
1252
  {/* Quote — the annotated text */}
1113
- {parts?.quote !== false && (
1114
- <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1253
+ {!region && parts?.quote !== false && (
1254
+ <p className="mb-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1115
1255
  "{annotation.originalText}"
1116
1256
  </p>
1117
1257
  )}
@@ -1121,13 +1261,17 @@ const AnnotationCard: React.FC<{
1121
1261
  isEditing ? (
1122
1262
  editComposer
1123
1263
  ) : (
1124
- annotation.text && (
1125
- <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1126
- {annotation.text}
1127
- </p>
1264
+ shownText && (
1265
+ <AnnotationText
1266
+ text={shownText}
1267
+ className="whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90"
1268
+ sectionRefs={sectionRefs}
1269
+ onNavigateAnchor={onNavigateAnchor}
1270
+ />
1128
1271
  )
1129
1272
  )
1130
1273
  )}
1274
+ {body != null && body !== false && body}
1131
1275
  </>
1132
1276
  )}
1133
1277
 
@@ -1176,6 +1320,7 @@ const CodeAnnotationCard: React.FC<{
1176
1320
  }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => {
1177
1321
  const [isEditing, setIsEditing] = useState(false);
1178
1322
  const [editText, setEditText] = useState(annotation.text || '');
1323
+ const enterSavesComment = useConfigValue('enterSavesComment');
1179
1324
  const textareaRef = useRef<HTMLTextAreaElement>(null);
1180
1325
 
1181
1326
  useEffect(() => {
@@ -1224,11 +1369,9 @@ const CodeAnnotationCard: React.FC<{
1224
1369
  {/* Header: type word + author · time + actions */}
1225
1370
  <div className="mb-1.5 flex items-center gap-1.5">
1226
1371
  <span className="text-[11px] font-medium text-primary">Code</span>
1227
- <span className="text-[10px] text-muted-foreground/50 truncate">
1228
- {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)}
1229
- </span>
1372
+ <CardByline author={annotation.author} isMe={isMe} timestamp={annotation.createdAt} />
1230
1373
  {!readOnly && (
1231
- <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
1374
+ <div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
1232
1375
  {onEdit && !isEditing && (
1233
1376
  <HostTitle title="Edit annotation" name>
1234
1377
  <button
@@ -1261,7 +1404,7 @@ const CodeAnnotationCard: React.FC<{
1261
1404
  </HostTitle>
1262
1405
 
1263
1406
  {annotation.originalCode && (
1264
- <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1407
+ <p className="mt-1.5 line-clamp-2 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-[11px] leading-relaxed text-muted-foreground/80">
1265
1408
  {annotation.originalCode}
1266
1409
  </p>
1267
1410
  )}
@@ -1274,7 +1417,7 @@ const CodeAnnotationCard: React.FC<{
1274
1417
  value={editText}
1275
1418
  onChange={(e) => setEditText(e.target.value)}
1276
1419
  onKeyDown={(e) => {
1277
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
1420
+ if (isCommentSubmitKey(e, enterSavesComment)) {
1278
1421
  e.preventDefault();
1279
1422
  handleSaveEdit();
1280
1423
  } else if (e.key === 'Escape') {
@@ -1294,7 +1437,7 @@ const CodeAnnotationCard: React.FC<{
1294
1437
  </div>
1295
1438
  ) : (
1296
1439
  annotation.text && (
1297
- <p className="mt-1.5 whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/90">
1440
+ <p className="mt-1.5 whitespace-pre-wrap [overflow-wrap:anywhere] text-[13px] leading-relaxed text-foreground/90">
1298
1441
  {annotation.text}
1299
1442
  </p>
1300
1443
  )
@@ -61,7 +61,7 @@ export const AnnotationSidebar: React.FC<SidebarProps> = ({
61
61
  </span>
62
62
  <button
63
63
  onClick={(e) => { e.stopPropagation(); onDelete(ann.id); }}
64
- className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
64
+ className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
65
65
  title="Remove Annotation"
66
66
  >
67
67
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
4
4
  import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
5
5
  import { type QuickLabel, getQuickLabels, quickLabelIndexForDigit, THUMBS_UP_LABEL } from "../utils/quickLabels";
6
6
  import { copyTextToClipboard } from "../utils/clipboard";
7
+ import { copyRichSelection, richCopyOutcomeLabel, type RichCopyOutcome, type RichCopySource } from "../utils/richClipboard";
7
8
  import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
8
9
  import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
9
10
  import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown";
@@ -30,6 +31,12 @@ interface AnnotationToolbarProps {
30
31
  onQuickLabel?: (label: QuickLabel) => void;
31
32
  /** Text to copy when the button is clicked */
32
33
  copyText?: string;
34
+ /** Builds the captured text selection's sanitized HTML and plain text when
35
+ * Copy is pressed (never before). When present, Copy writes text/html plus
36
+ * text/plain in one clipboard write (a plain field still pastes plain
37
+ * text), and the Copy button shows even where hideCopyButton hides it.
38
+ * Absent: Copy copies plain text, exactly as before. No shortcut. */
39
+ getRichCopy?: () => RichCopySource | Promise<RichCopySource>;
33
40
  /** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
34
41
  * the quick-label picker, and the Alt+digit label shortcuts. A provided
35
42
  * onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
@@ -57,7 +64,7 @@ interface AnnotationToolbarProps {
57
64
  * this toolbar. The one-click 👍 is a separate affordance and is unaffected.
58
65
  */
59
66
  quickLabels?: boolean;
60
- /** Hide the copy button (set when a keyboard copy handler exists) */
67
+ /** Hide the plain copy button (set when a keyboard copy handler exists) */
61
68
  hideCopyButton?: boolean;
62
69
  /** Close toolbar when element scrolls out of viewport */
63
70
  closeOnScrollOut?: boolean;
@@ -79,6 +86,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
79
86
  selectionActionsIcon,
80
87
  quickLabels: quickLabelsEnabled = true,
81
88
  copyText,
89
+ getRichCopy,
82
90
  commentOnly = false,
83
91
  hideCopyButton = false,
84
92
  closeOnScrollOut = false,
@@ -88,6 +96,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
88
96
  }) => {
89
97
  const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
90
98
  const [copied, setCopied] = useState(false);
99
+ const [richCopied, setRichCopied] = useState<RichCopyOutcome | false>(false);
100
+ const richCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
101
+ const richCopySelectionRef = useRef({ element, getRichCopy });
102
+ richCopySelectionRef.current = { element, getRichCopy };
91
103
  const [showQuickLabels, setShowQuickLabels] = useState(false);
92
104
  const [showSelectionActions, setShowSelectionActions] = useState(false);
93
105
  const toolbarRef = useRef<HTMLDivElement>(null);
@@ -107,7 +119,18 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
107
119
  const selectionActionsOpen = showSelectionActions && hasSelectionActions;
108
120
  const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled;
109
121
 
110
- useEffect(() => { setCopied(false); }, [element]);
122
+ useEffect(() => {
123
+ setCopied(false);
124
+ setRichCopied(false);
125
+ }, [element]);
126
+ useEffect(() => {
127
+ setRichCopied(false);
128
+ if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
129
+ richCopyTimeoutRef.current = null;
130
+ }, [element, getRichCopy]);
131
+ useEffect(() => () => {
132
+ if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
133
+ }, []);
111
134
 
112
135
  const handleCopy = async () => {
113
136
  let textToCopy = copyText;
@@ -121,6 +144,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
121
144
  }
122
145
  };
123
146
 
147
+ const handleRichCopy = async () => {
148
+ setRichCopied(false);
149
+ const build = getRichCopy;
150
+ if (!build) return;
151
+ const fallback = copyText || element.querySelector('code')?.textContent || element.textContent || '';
152
+ const result = await copyRichSelection(build, fallback);
153
+ const current = richCopySelectionRef.current;
154
+ if (current.element !== element || current.getRichCopy !== build) return;
155
+ if (result) {
156
+ setRichCopied(result);
157
+ if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
158
+ richCopyTimeoutRef.current = setTimeout(() => {
159
+ setRichCopied(false);
160
+ richCopyTimeoutRef.current = null;
161
+ }, 1500);
162
+ }
163
+ };
164
+
124
165
  // Update position on scroll/resize
125
166
  useEffect(() => {
126
167
  const updatePosition = () => {
@@ -252,14 +293,28 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
252
293
  }
253
294
  `}</style>
254
295
  <div data-pn-annotation-toolbar-row="true" className="flex items-center p-1 gap-0.5">
255
- {!hideCopyButton && (
296
+ {(!hideCopyButton || getRichCopy) && (
256
297
  <>
257
- <ToolbarButton
258
- onClick={handleCopy}
259
- icon={copied ? <CheckIcon /> : <CopyIcon />}
260
- label={copied ? "Copied!" : "Copy"}
261
- className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
262
- />
298
+ {getRichCopy ? (
299
+ // One Copy button: rich text (text/html plus text/plain) where
300
+ // the host turned rich copy on. "Copied!" for a rich copy; a
301
+ // plain-text fallback says what went on the clipboard.
302
+ <ToolbarButton
303
+ onClick={handleRichCopy}
304
+ icon={richCopied ? <CheckIcon /> : <CopyIcon />}
305
+ label={!richCopied ? "Copy"
306
+ : richCopied.copied === 'rich' ? "Copied!"
307
+ : richCopyOutcomeLabel(richCopied)}
308
+ className={richCopied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
309
+ />
310
+ ) : (
311
+ <ToolbarButton
312
+ onClick={handleCopy}
313
+ icon={copied ? <CheckIcon /> : <CopyIcon />}
314
+ label={copied ? "Copied!" : "Copy"}
315
+ className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
316
+ />
317
+ )}
263
318
  <div className="w-px h-5 bg-border mx-0.5" />
264
319
  </>
265
320
  )}