diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e77cf5d9..59dfffed1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,9 +47,12 @@ jobs: packages/ui/markdownEditorFidelity.test.tsx packages/ui/annotationDraftPersistence.test.tsx packages/ui/codeAnnotationDraftPersistence.test.tsx + packages/ui/components/AnnotationPanel.props.test.tsx + packages/ui/components/Viewer.consumer.test.tsx packages/ui/components/InlineMarkdown.seam.test.tsx packages/ui/components/ImageThumbnail.seam.test.tsx packages/ui/hooks/useAnnotationHighlighter.test.tsx + packages/ui/hooks/annotationRestoreVerify.test.tsx packages/ui/hooks/useAnnotationDraft.seam.test.tsx packages/ui/hooks/useExternalAnnotations.seam.test.tsx packages/ui/hooks/useAIChat.seam.test.tsx diff --git a/bun.lock b/bun.lock index acc7699f5..e5063aa89 100644 --- a/bun.lock +++ b/bun.lock @@ -250,7 +250,7 @@ }, "packages/ui": { "name": "@plannotator/ui", - "version": "0.23.0", + "version": "0.24.0", "dependencies": { "@base-ui/react": "^1.6.0", "@codemirror/autocomplete": "^6.20.3", diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md index 7023bd2fc..909b97bd8 100644 --- a/packages/ui/HANDOFF.md +++ b/packages/ui/HANDOFF.md @@ -197,6 +197,11 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r | `components/AttachmentsButton` | Routes through `uploadTransport`. | | Seam-backed hooks: `useAnnotationHighlighter`, `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`, `useFileBrowser` | Their network access goes through the seams in the catalog above. | | `config` (`ConfigStore`) | Persists through `storageBackend`. | +| `components/TableOfContents` | Pure — renders from `blocks`; pair with `useActiveSection` for scroll-spy. *(Blessed in 0.24.0.)* | +| `components/ResizeHandle` + `hooks/useResizablePanel` | Layout pair for draggable panel widths; persists the width through the `storageBackend` seam. *(Blessed in 0.24.0.)* | +| `hooks/useActiveSection` | Scroll-spy over rendered headings; no backend. *(Blessed in 0.24.0.)* | +| `hooks/useScrollViewport` | Resolves the scrolling element for viewport-aware UI; no backend. *(Blessed in 0.24.0.)* | +| `utils/annotationHelpers` | Pure annotation utilities (`getAnnotationCountBySection`, `buildTocHierarchy` + `TocItem`). *(Blessed in 0.24.0.)* | **AI is fully avoidable** — with one precision worth knowing. No AI *UI* is reachable from the supported components: `useAIChat` is imported only by `components/ai/DocumentAIChatPanel` and `useAIProviderConfig`, neither of which any supported component imports, and `CommentPopover`'s Ask-AI affordance exists only behind the optional `onAskAI` prop. `configure.ts` does statically import the `useAIChat` module (it needs `setAITransport`), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero `/api/ai` strings. Don't import `components/ai/*` and don't pass `aiTransport`, and you ship no AI code. @@ -313,9 +318,22 @@ Re-verify your seam contract against `0.23.0` before adopting; the list above is --- +## Consumer enablement (0.24.0) + +Six items accumulated through Workspaces' first three integration slices. All are additive; every default reproduces 0.23.0 behavior. + +1. **`AnnotationPanel` host props.** `renderCardFooter?: (annotation) => ReactNode` — a per-card slot at each plan-annotation card's foot (plug reply/resolve UI in; clicks inside the slot don't select the card). `readOnly?: boolean` — hides every mutation affordance (delete/edit on all card kinds); selection and scrolling still work. +2. **Six more supported imports** (already in the table above, tagged *Blessed in 0.24.0*): `TableOfContents`, `ResizeHandle` + `useResizablePanel`, `useActiveSection`, `useScrollViewport`, `utils/annotationHelpers`. All verified under the strict-consumer gate. +3. **`Viewer`/`CommentPopover` `allowImages?: boolean`.** Pass `false` when you have no `uploadTransport` — the attach-image affordance disappears instead of dead-ending. (CommentPopover already had the prop; Viewer now exposes and threads it.) +4. **`Viewer` `readOnly?: boolean`.** View-only users: suppresses every composer entry point (selection toolbar, comment popovers, quick labels, pinpoint, global comment, attachments, checkbox toggles) while existing annotations still render and select. +5. **Stricter consumer gate.** `tsconfig.strict-consumer.json` now also enforces `verbatimModuleSyntax`, `noUnusedLocals`, `noUnusedParameters` — the shipped source passes them, so you no longer have to relax those flags in your own tsconfig. +6. **Content-verifying restore (opt-in).** `useAnnotationHighlighter({ verifyRestoredContent: true, onRestoreMismatch })`: a position-based restore that resolves onto the wrong text (document drift) is removed and re-anchored by text search; if the original text is gone entirely, `onRestoreMismatch(annotation, restoredText)` fires and nothing is painted. Default off. If you built a host-side guard for this, you can delete it. + +--- + ## Publishing & versioning -- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (`@plannotator/ui` is now `0.23.0`; `@plannotator/core` is untouched by the Base UI migration and remains `0.22.0` until its next change — the ui→core dependency still resolves exactly at pack time). +- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (`@plannotator/ui` is now `0.24.0`; `@plannotator/core` remains `0.22.0` until its next change — the ui→core dependency still resolves exactly at pack time). - They depend on each other via `workspace:*`. At publish time that must resolve to the **exact** version in the tarball, so publish with a tool that does that resolution (the repo's existing flow uses `bun pm pack` to build the tarball, then `npm publish *.tgz --provenance --access public`). Publish **`core` first, then `ui`**. - `styles.css` is built by the `prepack` script (`bun run build:css`) so the published tarball always carries fresh precompiled CSS. - There is **no CI publish job for these two packages yet** — first publish is manual from `main` after merge. (Wiring a CI publish job is a follow-up.) diff --git a/packages/ui/components/AnnotationPanel.props.test.tsx b/packages/ui/components/AnnotationPanel.props.test.tsx new file mode 100644 index 000000000..542795d5e --- /dev/null +++ b/packages/ui/components/AnnotationPanel.props.test.tsx @@ -0,0 +1,113 @@ +/** + * Consumer-surface contract for AnnotationPanel's host props: + * - readOnly hides every mutation affordance (delete/edit on all card kinds) + * - renderCardFooter renders a per-card slot whose interactions do NOT + * select the card + * Both default to today's behavior (mutable, no footer). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +import { AnnotationPanel } from './AnnotationPanel'; +import { AnnotationType, type Annotation } from '../types'; + +const hasDom = typeof document !== 'undefined'; + +const annotation: Annotation = { + id: 'a1', + blockId: 'b1', + startOffset: 0, + endOffset: 5, + type: AnnotationType.COMMENT, + text: 'a note', + originalText: 'hello', + createdA: 1, +}; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +async function mount(ui: React.ReactElement): Promise { + host = document.createElement('div'); + document.body.appendChild(host); + await act(async () => { + root = createRoot(host!); + root.render(ui); + }); +} + +afterEach(async () => { + if (root) { + await act(async () => { + root!.unmount(); + }); + root = null; + } + host?.remove(); + host = null; + if (hasDom) document.body.innerHTML = ''; +}); + +const baseProps = { + isOpen: true, + annotations: [annotation], + blocks: [], + onSelect: () => {}, + onDelete: () => {}, + selectedId: null, +}; + +describe('AnnotationPanel consumer props', () => { + test.skipIf(!hasDom)('default renders the delete affordance (today’s behavior)', async () => { + await mount(); + expect(document.querySelector('button[title="Delete annotation"]')).not.toBeNull(); + }); + + test.skipIf(!hasDom)('readOnly hides delete and edit affordances', async () => { + await mount( + {}} + />, + ); + expect(document.querySelector('button[title="Delete annotation"]')).toBeNull(); + expect(document.querySelector('button[title="Edit annotation"]')).toBeNull(); + }); + + test.skipIf(!hasDom)('renderCardFooter renders per-card and does not select the card', async () => { + const selected: string[] = []; + let footerClicks = 0; + await mount( + selected.push(id)} + renderCardFooter={(a) => ( + + )} + />, + ); + + const slot = document.querySelector('[data-annotation-card-footer="true"]'); + expect(slot).not.toBeNull(); + expect(slot!.textContent).toContain('reply to a1'); + + const replyBtn = document.querySelector('[data-testid="reply"]') as HTMLButtonElement; + await act(async () => { + replyBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(footerClicks).toBe(1); + // The click bubbled only to the slot wrapper, which stops propagation — + // the card's onSelect must not fire. + expect(selected).toHaveLength(0); + }); + + test.skipIf(!hasDom)('no footer prop → no slot rendered', async () => { + await mount(); + expect(document.querySelector('[data-annotation-card-footer="true"]')).toBeNull(); + }); +}); diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index ba49ee7e9..d6dd577f4 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Annotation, AnnotationType, Block, type CodeAnnotation, type EditorAnnotation } from '../types'; +import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types'; import { isCurrentUser } from '../utils/identity'; import { ImageThumbnail } from './ImageThumbnail'; import { EditorAnnotationCard } from './EditorAnnotationCard'; @@ -73,12 +73,18 @@ interface PanelProps { /** Committed direct edits to one or more documents. Rendered as pinned cards * above the annotation timeline with expandable unified diffs. */ directEdits?: DirectEditsPanelItem[] | null; + /** Host slot rendered at the foot of each plan-annotation card (e.g. reply/ + * resolve UI). The panel stays presentation-only; clicks inside the slot + * do not select the card. Default: nothing rendered. */ + renderCardFooter?: (annotation: Annotation) => React.ReactNode; + /** Hide every mutation affordance (delete/edit buttons on all card kinds). + * Selection and scrolling still work. Default false — today's behavior. */ + readOnly?: boolean; } export const AnnotationPanel: React.FC = ({ isOpen, annotations, - blocks, onSelect, onDelete, onEdit, @@ -97,6 +103,8 @@ export const AnnotationPanel: React.FC = ({ otherFileAnnotations, onOtherFileAnnotationsClick, directEdits = null, + renderCardFooter, + readOnly = false, }) => { const isMobile = useIsMobile(); const [copiedText, setCopiedText] = useState(false); @@ -195,6 +203,8 @@ export const AnnotationPanel: React.FC = ({ onSelect={() => onSelect(entry.annotation.id)} onDelete={() => onDelete(entry.annotation.id)} onEdit={onEdit ? (updates: Partial) => onEdit(entry.annotation.id, updates) : undefined} + readOnly={readOnly} + footer={renderCardFooter?.(entry.annotation)} /> ) : ( = ({ onSelect={() => onSelectCodeAnnotation?.(entry.annotation.id)} onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)} onEdit={onEditCodeAnnotation ? (updates: Partial) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined} + readOnly={readOnly} /> ) ))} @@ -221,7 +232,7 @@ export const AnnotationPanel: React.FC = ({ onDeleteEditorAnnotation?.(ann.id)} + onDelete={readOnly ? undefined : () => onDeleteEditorAnnotation?.(ann.id)} /> ))} @@ -418,7 +429,9 @@ const AnnotationCard: React.FC<{ onSelect: () => void; onDelete: () => void; onEdit?: (updates: Partial) => void; -}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => { + readOnly?: boolean; + footer?: React.ReactNode; +}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer }) => { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(annotation.text || ''); const textareaRef = useRef(null); @@ -517,26 +530,28 @@ const AnnotationCard: React.FC<{ {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)} -
- {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && ( + {!readOnly && ( +
+ {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && ( + + )} - )} - -
+
+ )} {/* Global Comment - show text directly */} @@ -585,6 +600,19 @@ const AnnotationCard: React.FC<{ ))} )} + + {/* Host footer slot (reply/resolve UI etc.) — interactions inside it + must not toggle card selection. */} + {footer != null && footer !== false && ( +
e.stopPropagation()} + onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} + > + {footer} +
+ )} ); }; @@ -596,7 +624,8 @@ const CodeAnnotationCard: React.FC<{ onSelect: () => void; onDelete: () => void; onEdit?: (updates: Partial) => void; -}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => { + readOnly?: boolean; +}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(annotation.text || ''); const textareaRef = useRef(null); @@ -650,26 +679,28 @@ const CodeAnnotationCard: React.FC<{ {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)} -
- {onEdit && !isEditing && ( + {!readOnly && ( +
+ {onEdit && !isEditing && ( + + )} - )} - -
+
+ )} {/* File / line meta */} diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 7ec3c9718..3af3abcc0 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { AnnotationType } from "../types"; import { createPortal } from "react-dom"; import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape"; diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index 542ec1165..9ed8433a1 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Block } from "../types"; +import type { Block } from "../types"; import { InlineMarkdown } from "./InlineMarkdown"; import { ListItemBody } from "./ListItemBody"; import { CodeBlock } from "./blocks/CodeBlock"; diff --git a/packages/ui/components/EditorAnnotationCard.tsx b/packages/ui/components/EditorAnnotationCard.tsx index 7fd858521..cb757ee33 100644 --- a/packages/ui/components/EditorAnnotationCard.tsx +++ b/packages/ui/components/EditorAnnotationCard.tsx @@ -14,7 +14,8 @@ type EditorAnnotationVariant = 'plan' | 'code-review'; interface EditorAnnotationCardProps { annotation: EditorAnnotation; - onDelete: () => void; + /** Omit to render the card read-only (no delete affordance). */ + onDelete?: () => void; variant?: EditorAnnotationVariant; } @@ -38,15 +39,17 @@ export const EditorAnnotationCard: React.FC = ({ anno {annotation.filePath}:{lineRange} - + {onDelete && ( + + )} {/* Selected text */} diff --git a/packages/ui/components/ImageAnnotator/index.tsx b/packages/ui/components/ImageAnnotator/index.tsx index 9486e022c..02a4609f2 100644 --- a/packages/ui/components/ImageAnnotator/index.tsx +++ b/packages/ui/components/ImageAnnotator/index.tsx @@ -2,7 +2,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { Canvas } from './Canvas'; import { Toolbar } from './Toolbar'; import { renderStroke } from './utils'; -import type { Point, Stroke, Tool, AnnotatorState } from './types'; +import type { Point, AnnotatorState } from './types'; import { DEFAULT_STATE } from './types'; interface ImageAnnotatorProps { diff --git a/packages/ui/components/Viewer.consumer.test.tsx b/packages/ui/components/Viewer.consumer.test.tsx new file mode 100644 index 000000000..fb2ebd8ae --- /dev/null +++ b/packages/ui/components/Viewer.consumer.test.tsx @@ -0,0 +1,152 @@ +/** + * Consumer-surface contract for Viewer's host props: + * - readOnly suppresses the composer entry points (global-comment button, + * attachments) while the document still renders + * - allowImages threads to CommentPopover, which hides its attach affordance + * Defaults preserve today's behavior (composer on, images on). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +import { AnnotationType, type Block } from '../types'; + +const hasDom = typeof document !== 'undefined'; + +// Viewer pulls in @plannotator/web-highlighter, whose UMD bundle reads +// `window` at module-eval time and throws under the default DOM-less +// `bun test`. Import lazily so this file loads cleanly when DOM tests are +// skipped; DOM_TESTS=1 supplies a real DOM and the real modules. +const viewerMod = hasDom ? await import('./Viewer') : null; +const Viewer = viewerMod?.Viewer as typeof import('./Viewer')['Viewer']; +const popoverMod = hasDom ? await import('./CommentPopover') : null; +const CommentPopover = + popoverMod?.CommentPopover as typeof import('./CommentPopover')['CommentPopover']; + +const blocks: Block[] = [ + { id: 'b1', type: 'paragraph', content: 'hello world', order: 0, startLine: 1 }, +]; + +let root: Root | null = null; +let host: HTMLElement | null = null; + +async function mount(ui: React.ReactElement): Promise { + host = document.createElement('div'); + document.body.appendChild(host); + await act(async () => { + root = createRoot(host!); + root.render(ui); + }); +} + +afterEach(async () => { + if (root) { + await act(async () => { + root!.unmount(); + }); + root = null; + } + host?.remove(); + host = null; + if (hasDom) document.body.innerHTML = ''; +}); + +const viewerProps = { + blocks, + markdown: 'hello world', + annotations: [], + onAddAnnotation: () => {}, + onSelectAnnotation: () => {}, + selectedAnnotationId: null, + mode: 'comment' as const, + taterMode: false, + // Host posture: no /api/doc/exists endpoint. + disableCodePathValidation: true, +}; + +function globalCommentButton(): Element | null { + return document.querySelector('button[title="Add global comment"]'); +} + +describe('Viewer consumer props', () => { + test.skipIf(!hasDom)('default renders the global-comment composer entry (today’s behavior)', async () => { + await mount( + {}} + onRemoveGlobalAttachment={() => {}} + />, + ); + expect(globalCommentButton()).not.toBeNull(); + expect(document.querySelector('button[title="Attachments"]')).not.toBeNull(); + expect(document.body.textContent).toContain('hello world'); + }); + + test.skipIf(!hasDom)('readOnly hides composer entry points but still renders the document', async () => { + await mount( + {}} + onRemoveGlobalAttachment={() => {}} + />, + ); + expect(globalCommentButton()).toBeNull(); + expect(document.querySelector('button[title="Attachments"]')).toBeNull(); + expect(document.body.textContent).toContain('hello world'); + }); +}); + +describe('CommentPopover allowImages', () => { + function makeAnchor(): HTMLElement { + const el = document.createElement('span'); + el.textContent = 'anchor'; + document.body.appendChild(el); + return el; + } + const popoverProps = { + contextText: 'ctx', + isGlobal: true, + onSubmit: () => {}, + onClose: () => {}, + }; + + test.skipIf(!hasDom)('default shows the attach affordance', async () => { + await mount(); + expect(document.querySelector('button[title="Attachments"]')).not.toBeNull(); + }); + + test.skipIf(!hasDom)('allowImages={false} hides the attach affordance', async () => { + await mount(); + expect(document.querySelector('button[title="Attachments"]')).toBeNull(); + }); + + test.skipIf(!hasDom)('submit with allowImages={false} never reports images', async () => { + const submitted: Array = []; + await mount( + submitted.push({ text, images })} + />, + ); + const textarea = document.querySelector('textarea') as HTMLTextAreaElement; + await act(async () => { + const proto = Object.getPrototypeOf(textarea); + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + setter?.call(textarea, 'a comment'); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }), + ); + }); + expect(submitted).toEqual([{ text: 'a comment', images: undefined }]); + }); +}); + +// Keep the import shape honest: AnnotationType is part of the tested surface. +void AnnotationType; diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index ce8fd9fe1..42c24dcf3 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1,8 +1,8 @@ import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react'; import { createPortal } from 'react-dom'; import hljs from 'highlight.js'; -import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types'; -import { Frontmatter, computeListIndices, groupBlocks } from '../utils/parser'; +import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types'; +import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser'; import { buildHeadingSlugMap } from '../utils/slugify'; import { BlockRenderer } from './BlockRenderer'; import { CodeBlock } from './blocks/CodeBlock'; @@ -11,7 +11,6 @@ import { TableToolbar } from './blocks/TableToolbar'; import { TablePopout } from './blocks/TablePopout'; import { CodePathValidationContext } from './CodePathValidationContext'; import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths'; -import { ListMarker } from './ListMarker'; import { AnnotationToolbar } from './AnnotationToolbar'; import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker'; @@ -33,13 +32,12 @@ class ToolbarErrorBoundary extends React.Component< } } -import { CommentPopover, type CommentAskAIContext, type CommentAskAIHandler } from './CommentPopover'; +import { CommentPopover, type CommentAskAIHandler } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; import { MessagesIcon } from './icons/MessagesIcon'; import { GraphvizBlock } from './GraphvizBlock'; import { MermaidBlock } from './MermaidBlock'; -import { getImageSrc } from './ImageThumbnail'; import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages'; import { getIdentity } from '../utils/identity'; import { type QuickLabel } from '../utils/quickLabels'; @@ -112,6 +110,15 @@ interface ViewerProps { onToggleCheckbox?: (blockId: string, checked: boolean) => void; checkboxOverrides?: Map; onAskAI?: CommentAskAIHandler; + /** Whether comment popovers offer image attachments. Hosts without an + * uploadTransport pass false so the attach affordance never dead-ends. + * Default true — today's behavior. */ + allowImages?: boolean; + /** View-only mode: suppresses every annotation-creation entry point + * (selection toolbar, comment popovers, quick labels, pinpoint, global + * comment, attachments, checkbox toggles). Existing annotations still + * render and remain selectable. Default false — today's behavior. */ + readOnly?: boolean; } export interface ViewerHandle { @@ -191,6 +198,8 @@ export const Viewer = forwardRef(({ onToggleCheckbox, checkboxOverrides, onAskAI, + allowImages = true, + readOnly = false, }, ref) => { const [copied, setCopied] = useState(false); const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); @@ -283,6 +292,7 @@ export const Viewer = forwardRef(({ onSelectAnnotation, selectedAnnotationId, mode, + enabled: !readOnly, }); // Refs for code block annotation path @@ -319,7 +329,7 @@ export const Viewer = forwardRef(({ containerRef, highlighterRef, inputMethod, - enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), + enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), onCodeBlockClick: handlePinpointCodeBlockClick, }); @@ -612,7 +622,7 @@ export const Viewer = forwardRef(({ )} {/* Attachments button */} - {onAddGlobalAttachment && onRemoveGlobalAttachment && ( + {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && ( (({ )} {/* CommentGlobal comment button */} + {!readOnly && ( + )} {/* Copy plan/file button */}