@plannotator/ui 0.28.0 → 0.29.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.
Files changed (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -1,9 +1,12 @@
1
1
  import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import hljs from 'highlight.js';
4
3
  import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
4
+ import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
5
+ import { paintCodeBlockMark } from '../utils/codeBlockMark';
6
+ import { useFenceTheme } from '../hooks/useFenceTheme';
5
7
  import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser';
6
8
  import { buildHeadingSlugMap } from '../utils/slugify';
9
+ import { copyTextToClipboard } from '../utils/clipboard';
7
10
  import { BlockRenderer } from './BlockRenderer';
8
11
  import { CodeBlock } from './blocks/CodeBlock';
9
12
  import { TableBlock } from './blocks/TableBlock';
@@ -45,8 +48,10 @@ import { DocBadges, type LinkedDocBadgeInfo } from './DocBadges';
45
48
  import { PinpointOverlay } from './PinpointOverlay';
46
49
  import { usePinpoint } from '../hooks/usePinpoint';
47
50
  import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
51
+ import { useVimSelection } from '../hooks/useVimSelection';
48
52
  import { useScrollViewport } from '../hooks/useScrollViewport';
49
53
  import { decodeAnchorHash } from '../utils/anchors';
54
+ import { VimModeOverlay } from './VimModeOverlay';
50
55
 
51
56
  interface ViewerProps {
52
57
  blocks: Block[];
@@ -82,6 +87,10 @@ interface ViewerProps {
82
87
  isPlanDiffActive?: boolean;
83
88
  onPlanDiffToggle?: () => void;
84
89
  hasPreviousVersion?: boolean;
90
+ /** Baseline suffix + tooltip for the plan-diff badge (see DocBadges) —
91
+ * annotate/folder sessions pass "since last review"; plan review omits. */
92
+ planDiffBaselineLabel?: string;
93
+ planDiffBaselineTooltip?: string;
85
94
  /** Show amber "Demo" badge (portal mode, no shared content loaded) */
86
95
  showDemoBadge?: boolean;
87
96
  /** Max width in px for the plan card; null removes the cap entirely. */
@@ -119,6 +128,14 @@ interface ViewerProps {
119
128
  * comment, attachments, checkbox toggles). Existing annotations still
120
129
  * render and remain selectable. Default false — today's behavior. */
121
130
  readOnly?: boolean;
131
+ /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
132
+ vimModeEnabled?: boolean;
133
+ /** Replace the compact Vim badge with the live video-style key HUD. */
134
+ vimHudEnabled?: boolean;
135
+ /** Show the bottom-right key panel without affecting the HUD reticle. */
136
+ vimHudKeyPanelEnabled?: boolean;
137
+ /** Persist a user request to hide the bottom-right key panel. */
138
+ onVimHudKeyPanelChange?: (enabled: boolean) => void;
122
139
  }
123
140
 
124
141
  export interface ViewerHandle {
@@ -127,6 +144,12 @@ export interface ViewerHandle {
127
144
  applySharedAnnotations: (annotations: Annotation[]) => void;
128
145
  }
129
146
 
147
+ interface CodeBlockToolbarTarget {
148
+ readonly block: Block;
149
+ readonly element: HTMLElement;
150
+ readonly activation: 'pointer' | 'keyboard';
151
+ }
152
+
130
153
  /**
131
154
  * Renders YAML frontmatter as a styled metadata card.
132
155
  */
@@ -160,6 +183,12 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
160
183
  );
161
184
  };
162
185
 
186
+ /**
187
+ * Render and annotate a parsed Markdown document.
188
+ *
189
+ * Pointer and opt-in keyboard annotations share the same highlight callbacks;
190
+ * the imperative handle mutates only highlights owned by this viewer.
191
+ */
163
192
  export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
164
193
  blocks,
165
194
  markdown,
@@ -181,6 +210,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
181
210
  isPlanDiffActive,
182
211
  onPlanDiffToggle,
183
212
  hasPreviousVersion,
213
+ planDiffBaselineLabel,
214
+ planDiffBaselineTooltip,
184
215
  showDemoBadge,
185
216
  maxWidth,
186
217
  onOpenLinkedDoc,
@@ -200,22 +231,33 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
200
231
  onAskAI,
201
232
  allowImages = true,
202
233
  readOnly = false,
234
+ vimModeEnabled = false,
235
+ vimHudEnabled = false,
236
+ vimHudKeyPanelEnabled = true,
237
+ onVimHudKeyPanelChange,
203
238
  }, ref) => {
204
239
  const [copied, setCopied] = useState(false);
205
240
  const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
206
241
  const [locationHash, setLocationHash] = useState(() => window.location.hash);
207
242
  const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
243
+ // Read through a ref: only the imperative removeHighlight path below needs
244
+ // it, and CodeBlock re-highlights itself on palette change.
245
+ const fenceTheme = useFenceTheme();
246
+ const fenceThemeRef = useRef(fenceTheme);
247
+ fenceThemeRef.current = fenceTheme;
208
248
 
209
249
  const handleCopyPlan = async () => {
210
- try {
211
- await navigator.clipboard.writeText(markdown);
250
+ if (await copyTextToClipboard(markdown)) {
212
251
  setCopied(true);
213
252
  setTimeout(() => setCopied(false), 2000);
214
- } catch (e) {
215
- console.error('Failed to copy:', e);
253
+ } else {
254
+ console.error('Failed to copy');
216
255
  }
217
256
  };
218
257
  const containerRef = useRef<HTMLDivElement>(null);
258
+ // The element that actually scrolls; shared by the Vim scroll math, the
259
+ // sticky-header observer, and the reticle geometry.
260
+ const scrollViewport = useScrollViewport();
219
261
  // The badge cluster (repo chips / diff badge) is absolutely positioned in the
220
262
  // card's top padding. One row fits; a second row (diff badge) or mobile
221
263
  // wrapping outgrows the padding and lands on the document's first heading.
@@ -243,7 +285,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
243
285
  // `-1`/`-2`/... suffixes rather than colliding on the same id.
244
286
  const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]);
245
287
  const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []);
246
- const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
288
+ const [codeBlockToolbar, setCodeBlockToolbar] =
289
+ useState<CodeBlockToolbarTarget | null>(null);
247
290
  const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
248
291
  const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null);
249
292
  const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false);
@@ -264,13 +307,14 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
264
307
  codeBlock: { block: Block; element: HTMLElement };
265
308
  } | null>(null);
266
309
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
310
+ const readOnlyRef = useRef(readOnly);
311
+ readOnlyRef.current = readOnly;
267
312
  const stickySentinelRef = useRef<HTMLDivElement>(null);
268
313
  const lastAutoScrolledHashRef = useRef<string | null>(null);
269
314
  const [isStuck, setIsStuck] = useState(false);
270
315
 
271
316
  // Shared annotation infrastructure via hook
272
317
  const {
273
- highlighterRef,
274
318
  toolbarState,
275
319
  commentPopover: hookCommentPopover,
276
320
  quickLabelPicker: hookQuickLabelPicker,
@@ -282,6 +326,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
282
326
  handleCommentClose: hookCommentClose,
283
327
  handleFloatingQuickLabel: hookFloatingQuickLabel,
284
328
  handleQuickLabelPickerDismiss: hookQuickLabelPickerDismiss,
329
+ highlightRange,
330
+ highlightMathElement,
285
331
  removeHighlight: hookRemoveHighlight,
286
332
  clearAllHighlights,
287
333
  applyAnnotations,
@@ -301,17 +347,112 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
301
347
  const modeRef = useRef<EditorMode>(mode);
302
348
  useEffect(() => { modeRef.current = mode; }, [mode]);
303
349
 
350
+ const applyCodeBlockAnnotation = useCallback((
351
+ blockId: string,
352
+ codeEl: Element,
353
+ type: AnnotationType,
354
+ text?: string,
355
+ images?: ImageAttachment[],
356
+ isQuickLabel?: boolean,
357
+ quickLabelTip?: string,
358
+ ) => {
359
+ if (readOnlyRef.current) return;
360
+
361
+ const id = `codeblock-${Date.now()}`;
362
+ const codeText = codeEl.textContent || '';
363
+
364
+ paintCodeBlockMark(codeEl, id, type);
365
+
366
+ const newAnnotation: Annotation = {
367
+ id,
368
+ blockId,
369
+ startOffset: 0,
370
+ endOffset: codeText.length,
371
+ type,
372
+ text,
373
+ originalText: codeText,
374
+ createdA: Date.now(),
375
+ author: getIdentity(),
376
+ images,
377
+ ...(isQuickLabel ? { isQuickLabel: true } : {}),
378
+ ...(quickLabelTip ? { quickLabelTip } : {}),
379
+ };
380
+
381
+ onAddAnnotationRef.current(newAnnotation);
382
+ window.getSelection()?.removeAllRanges();
383
+ }, []);
384
+
385
+ // Live annotation list for the imperative DOM paths below, which run outside
386
+ // React's render (highlight swaps, the imperative handle).
387
+ const annotationsRef = useRef(annotations);
388
+ annotationsRef.current = annotations;
389
+
390
+ // `removeHighlight` runs BEFORE the host drops the annotation from state and
391
+ // re-highlights the block on the way out, so for one tick `annotationsRef`
392
+ // still lists an annotation whose mark is deliberately gone. Remember those
393
+ // ids so the swap listener below never paints a removed annotation back in,
394
+ // whichever tick that block's re-highlight lands in.
395
+ const removedAnnotationIdsRef = useRef<Set<string>>(new Set());
396
+ // Retire a tombstone as soon as the host's list agrees the annotation is
397
+ // gone: the window it guards is only the tick between removeHighlight and
398
+ // the state update, and keeping it would block a later restore that brings
399
+ // the same annotation (same id) back from a draft.
400
+ for (const id of removedAnnotationIdsRef.current) {
401
+ if (!annotations.some((a) => a.id === id)) removedAnnotationIdsRef.current.delete(id);
402
+ }
403
+
404
+ // A highlight swap replaces a `<code>` element's children — that is how the
405
+ // palette/mode change repaints tokens, and how the first async grammar
406
+ // attach lands after load. It also destroys any annotation mark inside the
407
+ // fence. Re-paint it here, SYNCHRONOUSLY after the write, so the block ends
408
+ // up with both the new theme's tokens and its mark.
409
+ //
410
+ // Being driven by the swap is also what makes the restore race safe without
411
+ // timing: a share/draft restore that painted before the swap is
412
+ // re-established in the same task the swap ran in, and one that runs after
413
+ // it finds the mark already present and leaves it alone.
414
+ useEffect(() => onCodeHighlightSwap((codeEl) => {
415
+ const container = containerRef.current;
416
+ if (!container || !container.contains(codeEl)) return;
417
+ // The swap always clears the element, so a surviving mark means this write
418
+ // was not the one that owns this block's contents.
419
+ if (codeEl.querySelector('[data-bind-id]')) return;
420
+
421
+ const codeText = codeEl.textContent ?? '';
422
+ if (!codeText) return;
423
+ const blockId = codeEl.closest('[data-block-id]')?.getAttribute('data-block-id') ?? '';
424
+
425
+ // Fenced code is annotated all-or-nothing, so this block's annotations are
426
+ // exactly the ones whose originalText is its full text. Share-restored
427
+ // annotations arrive with an empty blockId (it is filled in during restore),
428
+ // so an unset blockId still counts. The last one wins, matching what
429
+ // annotating the same block twice does.
430
+ const owner = annotationsRef.current.filter((a) =>
431
+ a.type !== AnnotationType.GLOBAL_COMMENT
432
+ && !a.diffContext
433
+ && a.originalText === codeText
434
+ && (a.blockId === blockId || !a.blockId)
435
+ && !removedAnnotationIdsRef.current.has(a.id)
436
+ && !container.querySelector(`[data-bind-id="${a.id}"], [data-highlight-id="${a.id}"]`)
437
+ ).at(-1);
438
+
439
+ if (owner) paintCodeBlockMark(codeEl, owner.id, owner.type);
440
+ }), []);
441
+
304
442
  // Pinpoint mode: hover + click to select elements
305
443
  const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => {
444
+ if (readOnlyRef.current) return;
445
+
446
+ const block = blocks.find((candidate) => candidate.id === blockId);
306
447
  const codeEl = element.querySelector('code');
307
- if (!codeEl) return;
448
+ if (!block || !codeEl) return;
308
449
  // In pinpoint mode, apply code block annotation based on current editor mode
309
450
  if (modeRef.current === 'redline') {
310
451
  applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION);
311
452
  } else if (modeRef.current === 'quickLabel') {
312
453
  setCodeBlockQuickLabelPicker({
313
454
  anchorEl: element,
314
- codeBlock: { block: blocks.find(b => b.id === blockId)!, element },
455
+ codeBlock: { block, element },
315
456
  });
316
457
  } else {
317
458
  // Show comment popover anchored to the code block
@@ -320,18 +461,126 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
320
461
  contextText: (codeEl.textContent || '').slice(0, 80),
321
462
  selectedText: codeEl.textContent || '',
322
463
  isGlobal: false,
323
- codeBlock: { block: blocks.find(b => b.id === blockId)!, element },
464
+ codeBlock: { block, element },
324
465
  });
325
466
  }
326
- }, [blocks]);
467
+ }, [applyCodeBlockAnnotation, blocks]);
468
+
469
+ const handleKeyboardCodeBlockAction = useCallback((
470
+ blockId: string,
471
+ element: HTMLElement,
472
+ modeOverride?: EditorMode,
473
+ ) => {
474
+ if (readOnlyRef.current) return;
475
+
476
+ const block = blocks.find((candidate) => candidate.id === blockId);
477
+ const codeEl = element.querySelector('code');
478
+ if (!block || !codeEl) return;
479
+
480
+ const effectiveMode = modeOverride ?? modeRef.current;
481
+ if (effectiveMode === 'redline') {
482
+ applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION);
483
+ return;
484
+ }
485
+ if (effectiveMode === 'quickLabel') {
486
+ setCodeBlockQuickLabelPicker({
487
+ anchorEl: element,
488
+ codeBlock: { block, element },
489
+ });
490
+ return;
491
+ }
492
+ if (effectiveMode === 'selection') {
493
+ if (hoverTimeoutRef.current) {
494
+ clearTimeout(hoverTimeoutRef.current);
495
+ hoverTimeoutRef.current = null;
496
+ }
497
+ setCodeBlockToolbar({ block, element, activation: 'keyboard' });
498
+ return;
499
+ }
500
+ setViewerCommentPopover({
501
+ anchorEl: element,
502
+ contextText: (codeEl.textContent || '').slice(0, 80),
503
+ selectedText: codeEl.textContent || '',
504
+ isGlobal: false,
505
+ codeBlock: { block, element },
506
+ });
507
+ }, [applyCodeBlockAnnotation, blocks]);
508
+
509
+ const vimModeActive = vimModeEnabled && !readOnly;
510
+ const keyboardCodeBlockToolbarOpen = codeBlockToolbar?.activation === 'keyboard';
511
+ const vimBlocked = !!toolbarState
512
+ || !!hookCommentPopover
513
+ || !!viewerCommentPopover
514
+ || !!hookQuickLabelPicker
515
+ || !!codeBlockQuickLabelPicker
516
+ || keyboardCodeBlockToolbarOpen
517
+ || !!isPlanDiffActive
518
+ || !!popoutTable
519
+ || !!lightbox;
520
+ const clearPinpointHoverRef = useRef<() => void>(() => {});
521
+ const handleVimCommand = useCallback(() => {
522
+ clearPinpointHoverRef.current();
523
+ }, []);
524
+ const vim = useVimSelection({
525
+ containerRef,
526
+ scrollViewport,
527
+ enabled: vimModeActive,
528
+ hudEnabled: vimHudEnabled,
529
+ blocked: vimBlocked,
530
+ activeMode: mode,
531
+ contentVersion: blocks,
532
+ onHighlightRange: highlightRange,
533
+ onCodeBlockAction: handleKeyboardCodeBlockAction,
534
+ onMathAction: highlightMathElement,
535
+ onHandledCommand: handleVimCommand,
536
+ });
327
537
 
328
- const { hoverTarget } = usePinpoint({
538
+ const { hoverTarget, clearHover: clearPinpointHover } = usePinpoint({
329
539
  containerRef,
330
- highlighterRef,
331
540
  inputMethod,
332
- enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false),
541
+ enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false) && !vim.helpOpen,
542
+ onSelectRange: highlightRange,
333
543
  onCodeBlockClick: handlePinpointCodeBlockClick,
334
544
  });
545
+ clearPinpointHoverRef.current = clearPinpointHover;
546
+ const vimOwnsHudTarget = vimHudEnabled
547
+ && vim.state.phase !== 'inactive'
548
+ && (vim.focused || vim.state.phase === 'action');
549
+ const vimOwnsDocumentNavigation = vimModeActive
550
+ && vim.focused
551
+ && vim.state.phase !== 'inactive'
552
+ && vim.state.phase !== 'action';
553
+ const legacyVimTarget = !vimHudEnabled
554
+ && (vim.focused || vim.state.phase === 'action')
555
+ ? vim.activeTarget
556
+ : null;
557
+ const pinpointOverlayTarget = vimOwnsHudTarget
558
+ ? null
559
+ : (inputMethod === 'pinpoint' ? hoverTarget : null) ?? legacyVimTarget;
560
+
561
+ useEffect(() => {
562
+ if (!readOnly) return;
563
+ if (hoverTimeoutRef.current) {
564
+ clearTimeout(hoverTimeoutRef.current);
565
+ hoverTimeoutRef.current = null;
566
+ }
567
+ setCodeBlockToolbar(null);
568
+ setIsCodeBlockToolbarExiting(false);
569
+ setViewerCommentPopover(null);
570
+ setCodeBlockQuickLabelPicker(null);
571
+ }, [readOnly]);
572
+
573
+ useEffect(() => {
574
+ if (!vimOwnsDocumentNavigation) return;
575
+ if (hoverTimeoutRef.current) {
576
+ clearTimeout(hoverTimeoutRef.current);
577
+ hoverTimeoutRef.current = null;
578
+ }
579
+ setCodeBlockToolbar((current) => (
580
+ current?.activation === 'pointer' ? null : current
581
+ ));
582
+ setIsCodeBlockToolbarExiting(false);
583
+ }, [vimOwnsDocumentNavigation]);
335
584
 
336
585
  // Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile)
337
586
  useEffect(() => {
@@ -349,16 +598,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
349
598
  // Detect when sticky action bar is "stuck" to show card background.
350
599
  // The IntersectionObserver root must be the actual scroll element — the
351
600
  // OverlayScrollArea viewport — not the <main> host, which doesn't scroll.
352
- const stickyScrollViewport = useScrollViewport();
353
601
  useEffect(() => {
354
- if (!stickyActions || !stickySentinelRef.current || !stickyScrollViewport) return;
602
+ if (!stickyActions || !stickySentinelRef.current || !scrollViewport) return;
355
603
  const observer = new IntersectionObserver(
356
604
  ([entry]) => setIsStuck(!entry.isIntersecting),
357
- { root: stickyScrollViewport, threshold: 0 }
605
+ { root: scrollViewport, threshold: 0 }
358
606
  );
359
607
  observer.observe(stickySentinelRef.current);
360
608
  return () => observer.disconnect();
361
- }, [stickyActions, stickyScrollViewport]);
609
+ }, [stickyActions, scrollViewport]);
362
610
 
363
611
  useEffect(() => {
364
612
  const handleHashChange = () => {
@@ -375,7 +623,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
375
623
  if (!anchor) return false;
376
624
 
377
625
  const container = containerRef.current;
378
- if (!container || !stickyScrollViewport) return false;
626
+ if (!container || !scrollViewport) return false;
379
627
 
380
628
  const target = document.getElementById(anchor);
381
629
  if (!target || !container.contains(target)) return false;
@@ -387,27 +635,27 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
387
635
  const headerOffset = stickyActionsEl
388
636
  ? stickyActionsEl.getBoundingClientRect().height + stickyTop
389
637
  : 0;
390
- const containerRect = stickyScrollViewport.getBoundingClientRect();
638
+ const containerRect = scrollViewport.getBoundingClientRect();
391
639
  const targetRect = target.getBoundingClientRect();
392
640
  const relativeTop = targetRect.top - containerRect.top;
393
- const offsetPosition = stickyScrollViewport.scrollTop + relativeTop - headerOffset;
641
+ const offsetPosition = scrollViewport.scrollTop + relativeTop - headerOffset;
394
642
 
395
- stickyScrollViewport.scrollTo({
643
+ scrollViewport.scrollTo({
396
644
  top: Math.max(0, offsetPosition),
397
645
  behavior: 'smooth',
398
646
  });
399
647
  return true;
400
- }, [stickyScrollViewport]);
648
+ }, [scrollViewport]);
401
649
 
402
650
  useEffect(() => {
403
- if (!stickyScrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
651
+ if (!scrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
404
652
  const timer = window.setTimeout(() => {
405
653
  if (scrollToAnchor(locationHash)) {
406
654
  lastAutoScrolledHashRef.current = locationHash;
407
655
  }
408
656
  }, 0);
409
657
  return () => window.clearTimeout(timer);
410
- }, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]);
658
+ }, [blocks, locationHash, scrollToAnchor, scrollViewport]);
411
659
 
412
660
  // Use the native copy event so clipboard writes are synchronous (Safari
413
661
  // rejects the async navigator.clipboard API outside the user-gesture window).
@@ -431,6 +679,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
431
679
  // Imperative handle — delegates to hook, extends removeHighlight for code blocks
432
680
  useImperativeHandle(ref, () => ({
433
681
  removeHighlight: (id: string) => {
682
+ // The re-highlight below notifies the swap listener, which would happily
683
+ // paint this annotation's mark straight back in — the host has not
684
+ // dropped it from state yet. Tombstone the id first.
685
+ removedAnnotationIdsRef.current.add(id);
434
686
  // Code block annotations need syntax re-highlighting after removal.
435
687
  // Must run BEFORE hookRemoveHighlight, which removes the <mark> elements.
436
688
  const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
@@ -442,9 +694,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
442
694
  el.remove();
443
695
  codeEl.textContent = plainText;
444
696
  const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id'));
445
- codeEl.removeAttribute('data-highlighted');
446
- codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`;
447
- hljs.highlightElement(codeEl);
697
+ codeEl.className = codeBlockClassName(block?.language);
698
+ // Language-less fences stay plain (#1212) — applyHighlight never guesses.
699
+ applyHighlight(codeEl, plainText, block?.language, fenceThemeRef.current);
448
700
  }
449
701
  });
450
702
 
@@ -456,86 +708,47 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
456
708
 
457
709
  // --- Viewer-specific: code block annotation ---
458
710
 
459
- const applyCodeBlockAnnotation = (
460
- blockId: string,
461
- codeEl: Element,
462
- type: AnnotationType,
463
- text?: string,
464
- images?: ImageAttachment[],
465
- isQuickLabel?: boolean,
466
- quickLabelTip?: string,
467
- ) => {
468
- const id = `codeblock-${Date.now()}`;
469
- const codeText = codeEl.textContent || '';
470
-
471
- const wrapper = document.createElement('mark');
472
- wrapper.className = `annotation-highlight ${type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''}`.trim();
473
- wrapper.dataset.bindId = id;
474
- wrapper.textContent = codeText;
475
-
476
- codeEl.innerHTML = '';
477
- codeEl.appendChild(wrapper);
478
-
479
- const newAnnotation: Annotation = {
480
- id,
481
- blockId,
482
- startOffset: 0,
483
- endOffset: codeText.length,
484
- type,
485
- text,
486
- originalText: codeText,
487
- createdA: Date.now(),
488
- author: getIdentity(),
489
- images,
490
- ...(isQuickLabel ? { isQuickLabel: true } : {}),
491
- ...(quickLabelTip ? { quickLabelTip } : {}),
492
- };
493
-
494
- onAddAnnotationRef.current(newAnnotation);
495
- window.getSelection()?.removeAllRanges();
496
- };
497
-
498
711
  const handleCodeBlockAnnotate = (type: AnnotationType) => {
499
- if (!hoveredCodeBlock) return;
500
- const codeEl = hoveredCodeBlock.element.querySelector('code');
712
+ if (readOnlyRef.current || !codeBlockToolbar) return;
713
+ const codeEl = codeBlockToolbar.element.querySelector('code');
501
714
  if (!codeEl) return;
502
- applyCodeBlockAnnotation(hoveredCodeBlock.block.id, codeEl, type);
503
- setHoveredCodeBlock(null);
715
+ applyCodeBlockAnnotation(codeBlockToolbar.block.id, codeEl, type);
716
+ setCodeBlockToolbar(null);
504
717
  };
505
718
 
506
719
  const handleCodeBlockQuickLabel = (label: QuickLabel) => {
507
- if (!hoveredCodeBlock) return;
508
- const codeEl = hoveredCodeBlock.element.querySelector('code');
720
+ if (readOnlyRef.current || !codeBlockToolbar) return;
721
+ const codeEl = codeBlockToolbar.element.querySelector('code');
509
722
  if (!codeEl) return;
510
723
  applyCodeBlockAnnotation(
511
- hoveredCodeBlock.block.id, codeEl, AnnotationType.COMMENT,
724
+ codeBlockToolbar.block.id, codeEl, AnnotationType.COMMENT,
512
725
  `${label.emoji} ${label.text}`, undefined, true, label.tip
513
726
  );
514
- setHoveredCodeBlock(null);
727
+ setCodeBlockToolbar(null);
515
728
  };
516
729
 
517
730
  const handleCodeBlockToolbarClose = () => {
518
- setHoveredCodeBlock(null);
731
+ setCodeBlockToolbar(null);
519
732
  };
520
733
 
521
734
  // Viewer-specific comment popover handlers (code blocks + global comments)
522
735
 
523
736
  const handleCodeBlockRequestComment = (initialChar?: string) => {
524
- if (!hoveredCodeBlock) return;
525
- const codeText = hoveredCodeBlock.element.querySelector('code')?.textContent || '';
737
+ if (readOnlyRef.current || !codeBlockToolbar) return;
738
+ const codeText = codeBlockToolbar.element.querySelector('code')?.textContent || '';
526
739
  setViewerCommentPopover({
527
- anchorEl: hoveredCodeBlock.element,
740
+ anchorEl: codeBlockToolbar.element,
528
741
  contextText: codeText.slice(0, 80),
529
742
  selectedText: codeText,
530
743
  initialText: initialChar,
531
744
  isGlobal: false,
532
- codeBlock: hoveredCodeBlock,
745
+ codeBlock: codeBlockToolbar,
533
746
  });
534
- setHoveredCodeBlock(null);
747
+ setCodeBlockToolbar(null);
535
748
  };
536
749
 
537
750
  const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => {
538
- if (!viewerCommentPopover) return;
751
+ if (readOnlyRef.current || !viewerCommentPopover) return;
539
752
 
540
753
  if (viewerCommentPopover.isGlobal) {
541
754
  const newAnnotation: Annotation = {
@@ -574,8 +787,20 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
574
787
  <article
575
788
  ref={containerRef}
576
789
  data-print-region="article"
577
- className={`w-full bg-card rounded-xl py-5 md:py-8 lg:py-10 xl:py-12 relative ${gridEnabled ? 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50' : ''} ${inputMethod === 'pinpoint' ? 'cursor-crosshair' : ''}`}
578
- style={{ WebkitTouchCallout: 'none' } as React.CSSProperties}
790
+ data-vim-mode={vimModeActive ? 'enabled' : undefined}
791
+ data-vim-phase={vimModeActive ? vim.state.phase : undefined}
792
+ data-vim-focused={vimModeActive ? String(vim.focused) : undefined}
793
+ data-vim-blocked={vimModeActive ? String(vimBlocked) : undefined}
794
+ data-vim-target-key={vimModeActive ? vim.activeTarget?.key : undefined}
795
+ tabIndex={vimModeActive ? 0 : undefined}
796
+ onFocus={vim.onFocus}
797
+ onBlur={vim.onBlur}
798
+ onMouseDown={vim.onMouseDown}
799
+ className={`w-full bg-card rounded-xl py-5 md:py-8 lg:py-10 xl:py-12 relative ${gridEnabled ? 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50' : ''} ${inputMethod === 'pinpoint' ? 'cursor-pointer' : ''}`}
800
+ style={{
801
+ WebkitTouchCallout: 'none',
802
+ ...(vimModeActive ? { outline: 'none' } : {}),
803
+ } as React.CSSProperties}
579
804
  >
580
805
  {/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */}
581
806
  {(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
@@ -587,6 +812,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
587
812
  isPlanDiffActive={isPlanDiffActive}
588
813
  hasPreviousVersion={hasPreviousVersion}
589
814
  onPlanDiffToggle={onPlanDiffToggle}
815
+ planDiffBaselineLabel={planDiffBaselineLabel}
816
+ planDiffBaselineTooltip={planDiffBaselineTooltip}
590
817
  showDemoBadge={showDemoBadge}
591
818
  archiveInfo={archiveInfo}
592
819
  linkedDocInfo={linkedDocInfo}
@@ -743,7 +970,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
743
970
  <CodeBlock
744
971
  key={group.block.id}
745
972
  block={group.block}
746
- onHover={inputMethod === 'pinpoint' ? () => {} : (element) => {
973
+ onHover={readOnly || inputMethod === 'pinpoint' ? undefined : (element) => {
747
974
  // Clear any pending leave timeout
748
975
  if (hoverTimeoutRef.current) {
749
976
  clearTimeout(hoverTimeoutRef.current);
@@ -752,22 +979,36 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
752
979
  // Cancel exit animation if re-entering
753
980
  setIsCodeBlockToolbarExiting(false);
754
981
  // Only show hover toolbar if no selection toolbar is active
755
- if (!toolbarState) {
756
- setHoveredCodeBlock({ block: group.block, element });
982
+ if (
983
+ !toolbarState
984
+ && !vimOwnsDocumentNavigation
985
+ && !keyboardCodeBlockToolbarOpen
986
+ ) {
987
+ setCodeBlockToolbar({
988
+ block: group.block,
989
+ element,
990
+ activation: 'pointer',
991
+ });
757
992
  }
758
993
  }}
759
- onLeave={inputMethod === 'pinpoint' ? () => {} : () => {
994
+ onLeave={readOnly || inputMethod === 'pinpoint' ? undefined : () => {
995
+ if (keyboardCodeBlockToolbarOpen) return;
760
996
  // Delay then start exit animation
761
997
  hoverTimeoutRef.current = setTimeout(() => {
762
998
  setIsCodeBlockToolbarExiting(true);
763
999
  // After exit animation, unmount
764
1000
  setTimeout(() => {
765
- setHoveredCodeBlock(null);
1001
+ setCodeBlockToolbar(null);
766
1002
  setIsCodeBlockToolbarExiting(false);
767
1003
  }, 150);
768
1004
  }, 100);
769
1005
  }}
770
- isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
1006
+ isHovered={
1007
+ !readOnly
1008
+ && inputMethod !== 'pinpoint'
1009
+ && !vimOwnsDocumentNavigation
1010
+ && codeBlockToolbar?.block.id === group.block.id
1011
+ }
771
1012
  />
772
1013
  ) : (
773
1014
  <BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={readOnly ? undefined : onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
@@ -775,7 +1016,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
775
1016
  )}
776
1017
 
777
1018
  {/* Text selection toolbar */}
778
- {toolbarState && (
1019
+ {!readOnly && toolbarState && (
779
1020
  <ToolbarErrorBoundary>
780
1021
  <AnnotationToolbar
781
1022
  element={toolbarState.element}
@@ -826,35 +1067,40 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
826
1067
  )}
827
1068
 
828
1069
  {/* Code block hover toolbar */}
829
- {hoveredCodeBlock && !toolbarState && (
830
- <ToolbarErrorBoundary>
831
- <AnnotationToolbar
832
- element={hoveredCodeBlock.element}
833
- positionMode="top-right"
834
- onAnnotate={handleCodeBlockAnnotate}
835
- onClose={handleCodeBlockToolbarClose}
836
- onRequestComment={handleCodeBlockRequestComment}
837
- onQuickLabel={handleCodeBlockQuickLabel}
838
- isExiting={isCodeBlockToolbarExiting}
839
- onMouseEnter={() => {
840
- if (hoverTimeoutRef.current) {
841
- clearTimeout(hoverTimeoutRef.current);
842
- hoverTimeoutRef.current = null;
843
- }
844
- setIsCodeBlockToolbarExiting(false);
845
- }}
846
- onMouseLeave={() => {
847
- hoverTimeoutRef.current = setTimeout(() => {
848
- setIsCodeBlockToolbarExiting(true);
849
- setTimeout(() => {
850
- setHoveredCodeBlock(null);
1070
+ {!readOnly
1071
+ && codeBlockToolbar
1072
+ && !toolbarState
1073
+ && !(vimOwnsDocumentNavigation && codeBlockToolbar.activation === 'pointer')
1074
+ && (
1075
+ <ToolbarErrorBoundary>
1076
+ <AnnotationToolbar
1077
+ element={codeBlockToolbar.element}
1078
+ positionMode="top-right"
1079
+ onAnnotate={handleCodeBlockAnnotate}
1080
+ onClose={handleCodeBlockToolbarClose}
1081
+ onRequestComment={handleCodeBlockRequestComment}
1082
+ onQuickLabel={handleCodeBlockQuickLabel}
1083
+ isExiting={isCodeBlockToolbarExiting}
1084
+ onMouseEnter={() => {
1085
+ if (hoverTimeoutRef.current) {
1086
+ clearTimeout(hoverTimeoutRef.current);
1087
+ hoverTimeoutRef.current = null;
1088
+ }
851
1089
  setIsCodeBlockToolbarExiting(false);
852
- }, 150);
853
- }, 100);
854
- }}
855
- />
856
- </ToolbarErrorBoundary>
857
- )}
1090
+ }}
1091
+ onMouseLeave={() => {
1092
+ if (codeBlockToolbar.activation === 'keyboard') return;
1093
+ hoverTimeoutRef.current = setTimeout(() => {
1094
+ setIsCodeBlockToolbarExiting(true);
1095
+ setTimeout(() => {
1096
+ setCodeBlockToolbar(null);
1097
+ setIsCodeBlockToolbarExiting(false);
1098
+ }, 150);
1099
+ }, 100);
1100
+ }}
1101
+ />
1102
+ </ToolbarErrorBoundary>
1103
+ )}
858
1104
 
859
1105
  {/* Table popout dialog — portaled into containerRef so annotations */}
860
1106
  {/* can walk into its text nodes the same way they do the inline table. */}
@@ -874,12 +1120,35 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
874
1120
  )}
875
1121
 
876
1122
  {/* Pinpoint hover overlay */}
877
- {inputMethod === 'pinpoint' && (
878
- <PinpointOverlay target={hoverTarget} containerRef={containerRef} />
1123
+ {(inputMethod === 'pinpoint' || vim.activeTarget) && (
1124
+ <PinpointOverlay
1125
+ target={pinpointOverlayTarget}
1126
+ containerRef={containerRef}
1127
+ />
1128
+ )}
1129
+ {vimModeActive && (
1130
+ <VimModeOverlay
1131
+ containerRef={containerRef}
1132
+ inputMethod={inputMethod}
1133
+ state={vim.state}
1134
+ focused={vim.focused}
1135
+ hudEnabled={vimHudEnabled}
1136
+ keyPanelEnabled={vimHudKeyPanelEnabled}
1137
+ hudCommand={vim.hudCommand}
1138
+ activeTarget={vim.activeTarget}
1139
+ helpOpen={vim.helpOpen}
1140
+ onHelpOpenChange={vim.onHelpOpenChange}
1141
+ onKeyPanelHide={
1142
+ onVimHudKeyPanelChange
1143
+ ? () => onVimHudKeyPanelChange(false)
1144
+ : undefined
1145
+ }
1146
+ onHudFocusLeave={vim.onHudFocusLeave}
1147
+ />
879
1148
  )}
880
1149
 
881
1150
  {/* Comment popover — hook handles text selection, Viewer handles global + code block */}
882
- {hookCommentPopover && (
1151
+ {!readOnly && hookCommentPopover && (
883
1152
  <CommentPopover
884
1153
  anchorEl={hookCommentPopover.anchorEl}
885
1154
  contextText={hookCommentPopover.contextText}
@@ -888,6 +1157,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
888
1157
  onSubmit={hookCommentSubmit}
889
1158
  onClose={hookCommentClose}
890
1159
  allowImages={allowImages}
1160
+ skillReferences
891
1161
  onAskAI={onAskAI}
892
1162
  askAIContext={{
893
1163
  kind: 'selection',
@@ -897,7 +1167,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
897
1167
  }}
898
1168
  />
899
1169
  )}
900
- {viewerCommentPopover && (
1170
+ {!readOnly && viewerCommentPopover && (
901
1171
  <CommentPopover
902
1172
  anchorEl={viewerCommentPopover.anchorEl}
903
1173
  contextText={viewerCommentPopover.contextText}
@@ -906,6 +1176,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
906
1176
  onSubmit={handleViewerCommentSubmit}
907
1177
  onClose={handleViewerCommentClose}
908
1178
  allowImages={allowImages}
1179
+ skillReferences
909
1180
  onAskAI={onAskAI}
910
1181
  askAIContext={{
911
1182
  kind: viewerCommentPopover.isGlobal ? 'general' : 'selection',
@@ -917,7 +1188,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
917
1188
  )}
918
1189
 
919
1190
  {/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
920
- {hookQuickLabelPicker && (
1191
+ {!readOnly && hookQuickLabelPicker && (
921
1192
  <FloatingQuickLabelPicker
922
1193
  anchorEl={hookQuickLabelPicker.anchorEl}
923
1194
  cursorHint={hookQuickLabelPicker.cursorHint}
@@ -925,7 +1196,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
925
1196
  onDismiss={hookQuickLabelPickerDismiss}
926
1197
  />
927
1198
  )}
928
- {codeBlockQuickLabelPicker && (
1199
+ {!readOnly && codeBlockQuickLabelPicker && (
929
1200
  <FloatingQuickLabelPicker
930
1201
  anchorEl={codeBlockQuickLabelPicker.anchorEl}
931
1202
  onSelect={(label: QuickLabel) => {
@@ -984,8 +1255,3 @@ const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }>
984
1255
  </div>
985
1256
  );
986
1257
  };
987
-
988
-
989
-
990
-
991
-