@plannotator/ui 0.53.1 → 0.55.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.
- package/HANDOFF.md +274 -14
- package/README.md +114 -11
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +22 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiagramBlock.tsx +17 -12
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +722 -126
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +167 -174
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TableOfContents.tsx +20 -6
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +288 -43
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +532 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +82 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +276 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/contents-rail.css +337 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSwipeStep.ts +597 -0
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +26 -23
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationHelpers.ts +32 -4
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/blockParse.ts +45 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/headingScrollSpy.ts +92 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
package/components/Viewer.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { generateId } from '../utils/generateId';
|
|
2
2
|
import React, { useRef, useState, useEffect, useLayoutEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
|
-
import { collectDocumentImages, ImageLightbox, type LightboxImage } from './ImageLightbox';
|
|
4
|
+
import { collectDocumentImages, ImageLightbox, type LightboxImage, type LightboxRegionComments } from './ImageLightbox';
|
|
5
|
+
import { imageReferenceIndex, imageSourceLine, regionOf } from './ImageRegions';
|
|
5
6
|
import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode, type HostDraft } from '../types';
|
|
6
7
|
import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
|
|
7
8
|
import { paintCodeBlockMark } from '../utils/codeBlockMark';
|
|
@@ -9,7 +10,9 @@ import { useFenceTheme } from '../hooks/useFenceTheme';
|
|
|
9
10
|
import { computeListIndices, groupBlocks, selectionSourceLines, type Frontmatter, type FrontmatterValue } from '../utils/parser';
|
|
10
11
|
import { buildHeadingSlugMap } from '../utils/slugify';
|
|
11
12
|
import { copyTextToClipboard } from '../utils/clipboard';
|
|
13
|
+
import { getRichCopyEnabled } from '../utils/richCopyPolicy';
|
|
12
14
|
import { BlockRenderer } from './BlockRenderer';
|
|
15
|
+
import { BELOW_TITLE_SLOT_MARKER } from '../utils/belowTitleSlot';
|
|
13
16
|
import { indexQuestionBlocks, type IndexedQuestion, type ParsedQuestion, type QuestionAnswer } from '@plannotator/core/question-block';
|
|
14
17
|
import type { QuestionBlockProps } from './blocks/QuestionBlock';
|
|
15
18
|
import { resolveQuestionAnswers } from '../utils/questionAnswers';
|
|
@@ -36,6 +39,19 @@ import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
|
|
|
36
39
|
*/
|
|
37
40
|
const MermaidBlock = lazy(async () => ({ default: (await import('./MermaidBlock')).MermaidBlock }));
|
|
38
41
|
const GraphvizBlock = lazy(async () => ({ default: (await import('./GraphvizBlock')).GraphvizBlock }));
|
|
42
|
+
/** ```html and ```svg fences drawn, for a host that opts in (`htmlEmbeds`); loaded only then. */
|
|
43
|
+
const HtmlEmbedBlock = lazy(async () => ({ default: (await import('./html-embed/HtmlEmbedBlock')).HtmlEmbedBlock }));
|
|
44
|
+
const SvgFenceBlock = lazy(async () => ({ default: (await import('./html-embed/SvgFenceBlock')).SvgFenceBlock }));
|
|
45
|
+
/**
|
|
46
|
+
* Each lazy block sits in a `LazyBlockBoundary`: when its chunk cannot load,
|
|
47
|
+
* the block shows its source and a Reload notice, and the document stays.
|
|
48
|
+
*/
|
|
49
|
+
const embedFallback = (block: Block) => (notice: React.ReactNode) => (
|
|
50
|
+
<div className="my-5 [&>[data-block-id]]:my-0">
|
|
51
|
+
{notice}
|
|
52
|
+
<CodeBlock block={block} isHovered={false} />
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
39
55
|
|
|
40
56
|
// Debug error boundary to catch silent toolbar crashes
|
|
41
57
|
class ToolbarErrorBoundary extends React.Component<
|
|
@@ -61,7 +77,10 @@ import { AttachmentsButton } from './AttachmentsButton';
|
|
|
61
77
|
import { MessagesIcon } from './icons/MessagesIcon';
|
|
62
78
|
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
63
79
|
import { DiagramBlockPending } from './diagram/DiagramPending';
|
|
80
|
+
import { LazyBlockBoundary } from './LazyBlockBoundary';
|
|
64
81
|
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
|
|
82
|
+
import { isHtmlEmbedLanguage, isSvgFenceLanguage } from './html-embed/languages';
|
|
83
|
+
import type { HtmlEmbedHost } from './html-embed/HtmlEmbedBlock';
|
|
65
84
|
import { getIdentity } from '../utils/identity';
|
|
66
85
|
import { type QuickLabel } from '../utils/quickLabels';
|
|
67
86
|
import type { SelectionAction } from '../utils/selectionActions';
|
|
@@ -152,6 +171,14 @@ export interface ViewerProps {
|
|
|
152
171
|
annotationHeader?: ViewerAnnotationHeaderConfig;
|
|
153
172
|
/** Render the plan as a floating card on a grid background (shadow/border/padding). Default false. */
|
|
154
173
|
gridEnabled?: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Grow the document card to the full height the parent gives the Viewer, so
|
|
176
|
+
* a short document's card still runs to the bottom of the view instead of
|
|
177
|
+
* stopping halfway down the grid paper. The parent must give the Viewer a
|
|
178
|
+
* height (a stretched flex item does). Default false: the card sizes to its
|
|
179
|
+
* content.
|
|
180
|
+
*/
|
|
181
|
+
fillHeight?: boolean;
|
|
155
182
|
onOpenLinkedDoc?: (path: string) => void;
|
|
156
183
|
onOpenCodeFile?: (path: string) => void;
|
|
157
184
|
/**
|
|
@@ -175,6 +202,19 @@ export interface ViewerProps {
|
|
|
175
202
|
* version). Absent or false: the single image overlay, as always.
|
|
176
203
|
*/
|
|
177
204
|
imageLightboxStepping?: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Comments on part of an image (#2100, approval "image-region-comments"):
|
|
207
|
+
* the lightbox gets a Move | Region switch over each markdown image, a
|
|
208
|
+
* drag in Region mode draws a box, and Save adds an ordinary COMMENT through
|
|
209
|
+
* `onAddAnnotation` carrying `imageRegion`. Selecting such a comment from
|
|
210
|
+
* outside (the document's annotations panel) opens the lightbox zoomed to
|
|
211
|
+
* its box. Absent or false: the lightbox is unchanged.
|
|
212
|
+
*/
|
|
213
|
+
imageRegionComments?: boolean;
|
|
214
|
+
/** Delete and edit from the lightbox's drawer of region comments (with
|
|
215
|
+
* `imageRegionComments`). Absent: the drawer's cards are read-only. */
|
|
216
|
+
onDeleteAnnotation?: (id: string) => void;
|
|
217
|
+
onEditAnnotation?: (id: string, updates: Partial<Annotation>) => void;
|
|
178
218
|
/** Directory the active document lives in — used by the code-path validator
|
|
179
219
|
* so out-of-tree relative references (e.g. `../foo.ts` in a linked doc)
|
|
180
220
|
* resolve against the doc's own directory rather than only cwd. */
|
|
@@ -266,6 +306,24 @@ export interface ViewerProps {
|
|
|
266
306
|
* `Decision:` line is shown as context and no question carries a decision
|
|
267
307
|
* tag or row from it. The prompt, key and numbering are unchanged. */
|
|
268
308
|
questionDecisionLines?: boolean;
|
|
309
|
+
/** Whether `:::question` cards read the contract lines (`Stakes:`,
|
|
310
|
+
* `Shows:`, `Why:`, `Reversible:`) into the card's Stakes line, Shows
|
|
311
|
+
* chip, Why line and "Can't be undone" tag. Default false: the lines show
|
|
312
|
+
* as context (plain text), as plan review and annotate draw them. The
|
|
313
|
+
* Plannotator Inbox passes true. The prompt, key, choices and numbering
|
|
314
|
+
* are the same either way. */
|
|
315
|
+
questionContractLines?: boolean;
|
|
316
|
+
/** Whether picture choices (`- [ ] Label — `) draw as picture
|
|
317
|
+
* tiles (default true; see `QuestionBlock` `pictureChoices`). False: text
|
|
318
|
+
* rows. */
|
|
319
|
+
questionPictureChoices?: boolean;
|
|
320
|
+
/** A small label over each picture tile's picture (see `QuestionBlock`
|
|
321
|
+
* `pictureBadge`); absent: none. */
|
|
322
|
+
questionPictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
|
|
323
|
+
/** A picture tile's zoom, told its question's key and the choice's index (see
|
|
324
|
+
* `QuestionBlock` `onZoomChoice`). Absent: the zoom opens the picture
|
|
325
|
+
* through `onImageClick`, as before. */
|
|
326
|
+
onQuestionPictureZoom?: (key: string, index: number, url: string, alt: string) => void;
|
|
269
327
|
/** Makes a question's "Records a decision" tag a toggle button
|
|
270
328
|
* (`aria-pressed`); a click calls this with the question's key and the
|
|
271
329
|
* next state, which the host stores and returns from
|
|
@@ -306,6 +364,13 @@ export interface ViewerProps {
|
|
|
306
364
|
/** Host content right under a question card's prompt, per question (e.g.
|
|
307
365
|
* "Replaces <an earlier question>"); null for none. Absent: nothing there. */
|
|
308
366
|
renderQuestionBelowPrompt?: (question: IndexedQuestion) => React.ReactNode;
|
|
367
|
+
/** A host's own drawing of a directive block that is not a question (ui
|
|
368
|
+
* seam for a host's own directives, such as the Plannotator Inbox's
|
|
369
|
+
* `:::action` action item card). Called per `:::kind` block; a returned node is
|
|
370
|
+
* drawn in place of the block's callout, inside a wrapper carrying the
|
|
371
|
+
* block's `data-block-id`; `undefined` keeps the callout. Absent: every
|
|
372
|
+
* such block draws as today. */
|
|
373
|
+
renderDirectiveBlock?: (block: Block) => React.ReactNode | undefined;
|
|
309
374
|
onAskAI?: CommentAskAIHandler;
|
|
310
375
|
/** Whether comment popovers offer image attachments. Hosts without an
|
|
311
376
|
* uploadTransport pass false so the attach affordance never dead-ends.
|
|
@@ -326,6 +391,14 @@ export interface ViewerProps {
|
|
|
326
391
|
* blocks and no comments on its text. Absent → unchanged.
|
|
327
392
|
*/
|
|
328
393
|
answerOnly?: boolean;
|
|
394
|
+
/**
|
|
395
|
+
* Opt-in host capability (approval "inbox-inline-html"): a host that serves
|
|
396
|
+
* embed documents (see `HtmlEmbedHost`, @plannotator/core/html-embed) draws
|
|
397
|
+
* a ```html fence as a live, sandboxed page and a ```svg fence as an
|
|
398
|
+
* image, each with a Code button for the source. Absent → both stay code,
|
|
399
|
+
* unchanged (plan review, annotate and code review pass nothing).
|
|
400
|
+
*/
|
|
401
|
+
htmlEmbeds?: HtmlEmbedHost;
|
|
329
402
|
/**
|
|
330
403
|
* Opt-in host capability, with `answerOnly`: the reader may comment on the
|
|
331
404
|
* text. A text selection opens the selection toolbar, comment-only
|
|
@@ -338,6 +411,12 @@ export interface ViewerProps {
|
|
|
338
411
|
* without `answerOnly` and under `readOnly`. Absent → unchanged.
|
|
339
412
|
*/
|
|
340
413
|
textComments?: boolean;
|
|
414
|
+
/**
|
|
415
|
+
* Opt-in: words a text-selection comment box opens with, for the selected
|
|
416
|
+
* text (a host's suggested comment, kept with one press or written over).
|
|
417
|
+
* Returning undefined opens an empty box. Absent → unchanged.
|
|
418
|
+
*/
|
|
419
|
+
suggestComment?: (selectedText: string) => string | undefined;
|
|
341
420
|
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
342
421
|
vimModeEnabled?: boolean;
|
|
343
422
|
/** Replace the compact Vim badge with the live video-style key HUD. */
|
|
@@ -361,12 +440,32 @@ export interface ViewerProps {
|
|
|
361
440
|
* unchanged.
|
|
362
441
|
*/
|
|
363
442
|
onHostDraft?: (draft: HostDraft | null) => void;
|
|
443
|
+
/**
|
|
444
|
+
* Host content drawn right under the document's title (e.g. a host's
|
|
445
|
+
* references rows). The title is the body's first block when that is a
|
|
446
|
+
* level-1 heading; the node is drawn right after that heading. With no
|
|
447
|
+
* leading `# heading` it is drawn at the top of the body, before the first
|
|
448
|
+
* block (after the frontmatter card). The heading itself is unchanged.
|
|
449
|
+
*
|
|
450
|
+
* The wrapper (`[data-viewer-below-title]`, `data-placement` `below-title`
|
|
451
|
+
* or `top`) sits outside everything that addresses the document's text: it
|
|
452
|
+
* is `annotation-exclude` (no comment starts in it and its text is never
|
|
453
|
+
* searched or quoted), `data-pinpoint-ignore` (pinpoint and Vim skip it),
|
|
454
|
+
* `data-print-hide`, and has no `data-block-id`. Stored comment positions
|
|
455
|
+
* are kept as if it were not there, so its contents never renumber a
|
|
456
|
+
* comment's parent element. `null`, `undefined` and booleans count as
|
|
457
|
+
* absent: no wrapper is drawn and the DOM is exactly as without the prop.
|
|
458
|
+
*/
|
|
459
|
+
belowTitle?: React.ReactNode;
|
|
364
460
|
}
|
|
365
461
|
|
|
366
462
|
export interface ViewerHandle {
|
|
367
463
|
removeHighlight: (id: string) => void;
|
|
368
464
|
clearAllHighlights: () => void;
|
|
369
465
|
applySharedAnnotations: (annotations: Annotation[]) => void;
|
|
466
|
+
/** Markdown `Viewer` only: scroll a heading anchor (`#id`) into view.
|
|
467
|
+
* Returns false when it is not in this document. */
|
|
468
|
+
scrollToAnchor?: (hash: string) => boolean;
|
|
370
469
|
/** `HtmlViewer` only: move a pinned draft to the element around it
|
|
371
470
|
* (`'parent'`) or back in (`'child'`), for a host that composes
|
|
372
471
|
* (`onHostDraft`). The markdown `Viewer` does not implement it. */
|
|
@@ -465,7 +564,7 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
|
|
|
465
564
|
if (entries.length === 0) return null;
|
|
466
565
|
|
|
467
566
|
return (
|
|
468
|
-
<div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg">
|
|
567
|
+
<div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg" data-doc-text="">
|
|
469
568
|
<div className="grid gap-2 text-sm">
|
|
470
569
|
{entries.map(([key, value]) => (
|
|
471
570
|
<FrontmatterRow key={key} field={key} value={value} />
|
|
@@ -697,6 +796,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
697
796
|
stickyActions = true,
|
|
698
797
|
annotationHeader,
|
|
699
798
|
gridEnabled = false,
|
|
799
|
+
fillHeight = false,
|
|
700
800
|
planDiffStats,
|
|
701
801
|
isPlanDiffActive,
|
|
702
802
|
onPlanDiffToggle,
|
|
@@ -712,6 +812,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
712
812
|
imageBaseDir,
|
|
713
813
|
onImageClick: hostImageClick,
|
|
714
814
|
imageLightboxStepping = false,
|
|
815
|
+
imageRegionComments = false,
|
|
816
|
+
onDeleteAnnotation,
|
|
817
|
+
onEditAnnotation,
|
|
715
818
|
codePathBaseDir,
|
|
716
819
|
disableCodePathValidation,
|
|
717
820
|
onRestoreReport,
|
|
@@ -731,6 +834,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
731
834
|
questionDecisionRecording,
|
|
732
835
|
questionDecisionScope,
|
|
733
836
|
questionDecisionLines = true,
|
|
837
|
+
questionContractLines = false,
|
|
838
|
+
questionPictureChoices = true,
|
|
839
|
+
questionPictureBadge,
|
|
840
|
+
onQuestionPictureZoom,
|
|
734
841
|
onToggleQuestionDecisionRecording,
|
|
735
842
|
onOpenQuestionDecision,
|
|
736
843
|
questionDecisionLocked,
|
|
@@ -738,17 +845,21 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
738
845
|
questionStatusTag,
|
|
739
846
|
questionStatusLabel,
|
|
740
847
|
renderQuestionCard,
|
|
848
|
+
renderDirectiveBlock,
|
|
741
849
|
renderQuestionBelowPrompt,
|
|
742
850
|
onAskAI,
|
|
743
851
|
allowImages = true,
|
|
744
852
|
readOnly = false,
|
|
745
853
|
answerOnly = false,
|
|
854
|
+
htmlEmbeds,
|
|
746
855
|
textComments = false,
|
|
856
|
+
suggestComment,
|
|
747
857
|
vimModeEnabled = false,
|
|
748
858
|
vimHudEnabled = false,
|
|
749
859
|
vimHudKeyPanelEnabled = true,
|
|
750
860
|
onVimHudKeyPanelChange,
|
|
751
861
|
onHostDraft,
|
|
862
|
+
belowTitle,
|
|
752
863
|
}, ref) => {
|
|
753
864
|
// Annotation authoring is off when read-only, and when the host only wants
|
|
754
865
|
// answers (`answerOnly`); question cards and checkboxes follow `readOnly`.
|
|
@@ -764,16 +875,27 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
764
875
|
// Where `#123` / `@user` link: `refRepo` when the host gave one, else `repoInfo`.
|
|
765
876
|
const linkRepo = refRepo ?? repoInfo;
|
|
766
877
|
const [copied, setCopied] = useState(false);
|
|
767
|
-
const [lightbox, setLightboxState] = useState<{ src: string; alt: string; images?: LightboxImage[] } | null>(null);
|
|
878
|
+
const [lightbox, setLightboxState] = useState<{ src: string; alt: string; images?: LightboxImage[]; focusId?: string | null } | null>(null);
|
|
879
|
+
// The document's images for the lightbox: with region comments each knows
|
|
880
|
+
// its src as written, block and line.
|
|
881
|
+
const documentImages = (): LightboxImage[] =>
|
|
882
|
+
collectDocumentImages(containerRef.current).map((image) => {
|
|
883
|
+
if (!imageRegionComments || !image.writtenSrc || !image.blockId) return image;
|
|
884
|
+
const block = blocks.find((b) => b.id === image.blockId);
|
|
885
|
+
return { ...image, line: block ? imageSourceLine(block, image.writtenSrc) : null };
|
|
886
|
+
});
|
|
768
887
|
// A host that handles image clicks (the Inbox) gets them; otherwise the lightbox opens, stepping through
|
|
769
|
-
// the document's images when the host asked for it.
|
|
888
|
+
// the document's images when the host asked for it (and with region comments, always with the list).
|
|
770
889
|
const setLightbox = (image: { src: string; alt: string }) => {
|
|
771
890
|
if (hostImageClick) {
|
|
772
891
|
hostImageClick(image.src, image.alt);
|
|
773
892
|
return;
|
|
774
893
|
}
|
|
775
|
-
setLightboxState(imageLightboxStepping ? { ...image, images:
|
|
894
|
+
setLightboxState(imageLightboxStepping || imageRegionComments ? { ...image, images: documentImages() } : image);
|
|
776
895
|
};
|
|
896
|
+
// Region comments made in the lightbox are selected by the host as they are
|
|
897
|
+
// added; that selection must not reopen or refocus the lightbox.
|
|
898
|
+
const regionCreatedHere = useRef(new Set<string>());
|
|
777
899
|
const [locationHash, setLocationHash] = useState(() => window.location.hash);
|
|
778
900
|
const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
|
|
779
901
|
// Read through a ref: only the imperative removeHighlight path below needs
|
|
@@ -828,9 +950,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
828
950
|
// annotations that carry `questionAnswer` (matched by question key, so an
|
|
829
951
|
// answer follows its question across re-parses).
|
|
830
952
|
const questionIndex = useMemo(() => {
|
|
831
|
-
const list = indexQuestionBlocks(blocks, { decisionLines: questionDecisionLines });
|
|
953
|
+
const list = indexQuestionBlocks(blocks, { decisionLines: questionDecisionLines, contractLines: questionContractLines });
|
|
832
954
|
return { byBlock: new Map(list.map((q) => [q.blockId, q])), total: list.length };
|
|
833
|
-
}, [blocks, questionDecisionLines]);
|
|
955
|
+
}, [blocks, questionDecisionLines, questionContractLines]);
|
|
834
956
|
// A host that keeps answers itself (`questionAnswers`) replaces the
|
|
835
957
|
// annotation-derived map; each entry is validated like any other reader.
|
|
836
958
|
const answersByKey = useMemo(
|
|
@@ -1212,8 +1334,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1212
1334
|
const container = containerRef.current;
|
|
1213
1335
|
if (!container || !scrollViewport) return false;
|
|
1214
1336
|
|
|
1215
|
-
const target =
|
|
1216
|
-
if (!target
|
|
1337
|
+
const target = container.querySelector<HTMLElement>(`#${CSS.escape(anchor)}`);
|
|
1338
|
+
if (!target) return false;
|
|
1217
1339
|
|
|
1218
1340
|
const documentHeader = stickyActions
|
|
1219
1341
|
? container.querySelector<HTMLElement>('[data-viewer-document-header]')
|
|
@@ -1271,6 +1393,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1271
1393
|
|
|
1272
1394
|
// Imperative handle — delegates to hook, extends removeHighlight for code blocks
|
|
1273
1395
|
useImperativeHandle(ref, () => ({
|
|
1396
|
+
scrollToAnchor,
|
|
1274
1397
|
removeHighlight: (id: string) => {
|
|
1275
1398
|
// The re-highlight below notifies the swap listener, which would happily
|
|
1276
1399
|
// paint this annotation's mark straight back in — the host has not
|
|
@@ -1298,7 +1421,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1298
1421
|
clearAllHighlights,
|
|
1299
1422
|
applySharedAnnotations: applyAnnotations,
|
|
1300
1423
|
takeSelection,
|
|
1301
|
-
}), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection]);
|
|
1424
|
+
}), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection, scrollToAnchor]);
|
|
1302
1425
|
|
|
1303
1426
|
// --- Viewer-specific: code block annotation ---
|
|
1304
1427
|
|
|
@@ -1469,6 +1592,70 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1469
1592
|
</>
|
|
1470
1593
|
);
|
|
1471
1594
|
|
|
1595
|
+
// --- Comments on part of an image (#2100) ---------------------------------
|
|
1596
|
+
const regionHost = useMemo<LightboxRegionComments | undefined>(() => {
|
|
1597
|
+
if (!imageRegionComments) return undefined;
|
|
1598
|
+
return {
|
|
1599
|
+
annotations,
|
|
1600
|
+
canCreate: !authoringOff,
|
|
1601
|
+
onAdd: (annotation) => {
|
|
1602
|
+
regionCreatedHere.current.add(annotation.id);
|
|
1603
|
+
onAddAnnotation(annotation);
|
|
1604
|
+
},
|
|
1605
|
+
onDelete: authoringOff ? undefined : onDeleteAnnotation,
|
|
1606
|
+
onEdit: authoringOff ? undefined : onEditAnnotation,
|
|
1607
|
+
focusId: lightbox?.focusId ?? null,
|
|
1608
|
+
onAskAI: authoringOff ? undefined : onAskAI,
|
|
1609
|
+
mentionSource,
|
|
1610
|
+
};
|
|
1611
|
+
}, [annotations, authoringOff, imageRegionComments, lightbox?.focusId, mentionSource, onAddAnnotation, onAskAI, onDeleteAnnotation, onEditAnnotation]);
|
|
1612
|
+
|
|
1613
|
+
const closeLightbox = () => {
|
|
1614
|
+
// Closing the viewer lets go of the region comment selected while it was
|
|
1615
|
+
// open (one it was opened on, or one just made in it), so the same card
|
|
1616
|
+
// in the panel opens it again.
|
|
1617
|
+
const selected = selectedAnnotationId ? annotations.find((a) => a.id === selectedAnnotationId) : undefined;
|
|
1618
|
+
if (selected && regionOf(selected)) onSelectAnnotation(null);
|
|
1619
|
+
regionCreatedHere.current.clear();
|
|
1620
|
+
setLightboxState(null);
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
// A region comment selected from outside (its card in the annotations
|
|
1624
|
+
// panel) opens the lightbox on its image, zoomed to its box.
|
|
1625
|
+
useEffect(() => {
|
|
1626
|
+
if (!imageRegionComments || !selectedAnnotationId || hostImageClick) return;
|
|
1627
|
+
// The host selects what the viewer just added: skip that one selection.
|
|
1628
|
+
if (regionCreatedHere.current.delete(selectedAnnotationId)) return;
|
|
1629
|
+
const ann = annotations.find((a) => a.id === selectedAnnotationId);
|
|
1630
|
+
const region = ann ? regionOf(ann) : null;
|
|
1631
|
+
if (!region) return;
|
|
1632
|
+
if (lightbox) {
|
|
1633
|
+
setLightboxState((lb) => (lb ? { ...lb, focusId: selectedAnnotationId } : lb));
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
const images = documentImages();
|
|
1637
|
+
const target = images.find((image) => image.writtenSrc === region.src);
|
|
1638
|
+
if (!target) return;
|
|
1639
|
+
setLightboxState({ src: target.src, alt: target.alt, images, focusId: selectedAnnotationId });
|
|
1640
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1641
|
+
}, [selectedAnnotationId]);
|
|
1642
|
+
|
|
1643
|
+
// The highlighter skips region comments; one whose image is no longer in
|
|
1644
|
+
// the document (or whose region is malformed) is unanchored, and says so.
|
|
1645
|
+
useEffect(() => {
|
|
1646
|
+
if (onRestoreReport === undefined) return;
|
|
1647
|
+
const rows = annotations.filter((ann) => ann.imageRegion != null);
|
|
1648
|
+
if (rows.length === 0) return;
|
|
1649
|
+
const text = blocks.map((b) => b.content).join('\n');
|
|
1650
|
+
const unanchored = rows
|
|
1651
|
+
.filter((ann) => {
|
|
1652
|
+
const region = regionOf(ann);
|
|
1653
|
+
return region === null || imageReferenceIndex(text, region.src) < 0;
|
|
1654
|
+
})
|
|
1655
|
+
.map((ann) => ann.id);
|
|
1656
|
+
onRestoreReport({ attempted: rows.map((ann) => ann.id), unanchored });
|
|
1657
|
+
}, [annotations, blocks, onRestoreReport]);
|
|
1658
|
+
|
|
1472
1659
|
// The document's diagram blocks, in order: a diagram comment that names
|
|
1473
1660
|
// none of them (an external POST, a deleted fence) is resolved by anchor
|
|
1474
1661
|
// against each, first resolver wins (see diagram/anchorClaims).
|
|
@@ -1491,15 +1678,44 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1491
1678
|
if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
|
|
1492
1679
|
}, [annotations, diagramBlockKey, onRestoreReport]);
|
|
1493
1680
|
|
|
1681
|
+
// The host's `belowTitle` slot (see the prop). Absent → the block list is
|
|
1682
|
+
// returned as is, so the DOM is the one without the prop. The wrapper's
|
|
1683
|
+
// attribute carries utils/belowTitleSlot's per-load marker, which is how
|
|
1684
|
+
// the highlighter tells it from raw HTML an author wrote, to keep stored
|
|
1685
|
+
// positions slot-free.
|
|
1686
|
+
const belowTitleSlot = belowTitle == null || typeof belowTitle === 'boolean' ? null : belowTitle;
|
|
1687
|
+
const titleLeads = blocks[0]?.type === 'heading' && blocks[0].level === 1;
|
|
1688
|
+
const withBelowTitle = (rendered: React.ReactNode[]): React.ReactNode[] => {
|
|
1689
|
+
if (belowTitleSlot === null) return rendered;
|
|
1690
|
+
const slot = (
|
|
1691
|
+
<div
|
|
1692
|
+
key="viewer-below-title"
|
|
1693
|
+
data-viewer-below-title={BELOW_TITLE_SLOT_MARKER}
|
|
1694
|
+
data-placement={titleLeads ? 'below-title' : 'top'}
|
|
1695
|
+
data-pinpoint-ignore=""
|
|
1696
|
+
data-print-hide=""
|
|
1697
|
+
className="annotation-exclude"
|
|
1698
|
+
>
|
|
1699
|
+
{belowTitleSlot}
|
|
1700
|
+
</div>
|
|
1701
|
+
);
|
|
1702
|
+
return titleLeads ? [rendered[0], slot, ...rendered.slice(1)] : [slot, ...rendered];
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1494
1705
|
return (
|
|
1495
1706
|
<CodePathValidationContext.Provider value={codePathValidation}>
|
|
1496
1707
|
<LocalLinkContext.Provider value={resolveLocalLink ?? null}>
|
|
1497
1708
|
<DiagramAnchorClaimsContext.Provider value={diagramClaims}>
|
|
1498
|
-
<div
|
|
1709
|
+
<div
|
|
1710
|
+
className={`relative z-50 w-full${fillHeight ? ' flex flex-col print:block' : ''}`}
|
|
1711
|
+
data-viewer-fill-height={fillHeight ? '' : undefined}
|
|
1712
|
+
style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}
|
|
1713
|
+
>
|
|
1499
1714
|
{taterMode && <TaterSpriteSitting />}
|
|
1500
1715
|
<article
|
|
1501
1716
|
ref={containerRef}
|
|
1502
1717
|
data-print-region="article"
|
|
1718
|
+
data-doc-text-root=""
|
|
1503
1719
|
data-vim-mode={vimModeActive ? 'enabled' : undefined}
|
|
1504
1720
|
data-vim-phase={vimModeActive ? vim.state.phase : undefined}
|
|
1505
1721
|
data-vim-focused={vimModeActive ? String(vim.focused) : undefined}
|
|
@@ -1509,7 +1725,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1509
1725
|
onFocus={vim.onFocus}
|
|
1510
1726
|
onBlur={vim.onBlur}
|
|
1511
1727
|
onMouseDown={vim.onMouseDown}
|
|
1512
|
-
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' : ''}`}
|
|
1728
|
+
className={`w-full bg-card rounded-xl py-5 md:py-8 lg:py-10 xl:py-12 relative ${fillHeight ? 'flex-1 ' : ''}${gridEnabled ? 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50' : ''} ${inputMethod === 'pinpoint' ? 'cursor-pointer' : ''}`}
|
|
1513
1729
|
style={{
|
|
1514
1730
|
WebkitTouchCallout: 'none',
|
|
1515
1731
|
...(vimModeActive ? { outline: 'none' } : {}),
|
|
@@ -1578,7 +1794,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1578
1794
|
)}
|
|
1579
1795
|
{frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
|
|
1580
1796
|
{!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && <div className="mt-4" />}
|
|
1581
|
-
{groupBlocks(blocks).map(group =>
|
|
1797
|
+
{withBelowTitle(groupBlocks(blocks).map(group =>
|
|
1582
1798
|
group.type === 'list-group' ? (
|
|
1583
1799
|
(() => {
|
|
1584
1800
|
const indices = computeListIndices(group.blocks);
|
|
@@ -1604,34 +1820,50 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1604
1820
|
</div>
|
|
1605
1821
|
);
|
|
1606
1822
|
})()
|
|
1823
|
+
) : htmlEmbeds && group.block.type === 'code' && isHtmlEmbedLanguage(group.block.language) ? (
|
|
1824
|
+
<LazyBlockBoundary key={group.block.id} fallback={embedFallback(group.block)}>
|
|
1825
|
+
<Suspense fallback={<CodeBlock block={group.block} isHovered={false} />}>
|
|
1826
|
+
<HtmlEmbedBlock block={group.block} host={htmlEmbeds} />
|
|
1827
|
+
</Suspense>
|
|
1828
|
+
</LazyBlockBoundary>
|
|
1829
|
+
) : htmlEmbeds && group.block.type === 'code' && isSvgFenceLanguage(group.block.language) ? (
|
|
1830
|
+
<LazyBlockBoundary key={group.block.id} fallback={embedFallback(group.block)}>
|
|
1831
|
+
<Suspense fallback={<CodeBlock block={group.block} isHovered={false} />}>
|
|
1832
|
+
<SvgFenceBlock block={group.block} />
|
|
1833
|
+
</Suspense>
|
|
1834
|
+
</LazyBlockBoundary>
|
|
1607
1835
|
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
|
|
1608
|
-
<
|
|
1609
|
-
<
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1836
|
+
<LazyBlockBoundary key={group.block.id} fallback={(notice) => <DiagramBlockPending block={group.block} kind="mermaid" notice={notice} />}>
|
|
1837
|
+
<Suspense fallback={<DiagramBlockPending block={group.block} kind="mermaid" />}>
|
|
1838
|
+
<MermaidBlock
|
|
1839
|
+
block={group.block}
|
|
1840
|
+
annotations={annotations}
|
|
1841
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1842
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1843
|
+
onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
|
|
1844
|
+
readOnly={authoringOff}
|
|
1845
|
+
onRestoreReport={onRestoreReport}
|
|
1846
|
+
onAskAI={authoringOff ? undefined : onAskAI}
|
|
1847
|
+
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1848
|
+
/>
|
|
1849
|
+
</Suspense>
|
|
1850
|
+
</LazyBlockBoundary>
|
|
1621
1851
|
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
|
|
1622
|
-
<
|
|
1623
|
-
<
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1852
|
+
<LazyBlockBoundary key={group.block.id} fallback={(notice) => <DiagramBlockPending block={group.block} kind="graphviz" notice={notice} />}>
|
|
1853
|
+
<Suspense fallback={<DiagramBlockPending block={group.block} kind="graphviz" />}>
|
|
1854
|
+
<GraphvizBlock
|
|
1855
|
+
block={group.block}
|
|
1856
|
+
annotations={annotations}
|
|
1857
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1858
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1859
|
+
onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
|
|
1860
|
+
readOnly={authoringOff}
|
|
1861
|
+
onRestoreReport={onRestoreReport}
|
|
1862
|
+
onAskAI={authoringOff ? undefined : onAskAI}
|
|
1863
|
+
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1864
|
+
/>
|
|
1865
|
+
</Suspense>
|
|
1866
|
+
</LazyBlockBoundary>
|
|
1635
1867
|
) : group.block.type === 'table' ? (
|
|
1636
1868
|
<TableBlock
|
|
1637
1869
|
key={group.block.id}
|
|
@@ -1712,7 +1944,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1712
1944
|
const question = questionIndex.byBlock.get(group.block.id);
|
|
1713
1945
|
const hostCard = question && renderQuestionCard
|
|
1714
1946
|
? renderQuestionCard(question, answersByKey.get(question.question.key))
|
|
1715
|
-
:
|
|
1947
|
+
: !question && group.block.type === 'directive' && renderDirectiveBlock
|
|
1948
|
+
? renderDirectiveBlock(group.block)
|
|
1949
|
+
: undefined;
|
|
1716
1950
|
if (hostCard !== undefined) {
|
|
1717
1951
|
return (
|
|
1718
1952
|
<div key={group.block.id} data-block-id={group.block.id} data-block-type="directive" className={textCommentsOnly ? 'annotation-exclude' : undefined}>
|
|
@@ -1748,6 +1982,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1748
1982
|
questionStatusTag={questionStatusTag}
|
|
1749
1983
|
questionStatusLabel={questionStatusLabel}
|
|
1750
1984
|
renderQuestionBelowPrompt={renderQuestionBelowPrompt}
|
|
1985
|
+
questionContractLines={questionContractLines}
|
|
1986
|
+
questionPictureChoices={questionPictureChoices}
|
|
1987
|
+
questionPictureBadge={questionPictureBadge}
|
|
1988
|
+
onQuestionPictureZoom={onQuestionPictureZoom}
|
|
1751
1989
|
/>
|
|
1752
1990
|
);
|
|
1753
1991
|
// `textComments`: a question card's words are not commented on
|
|
@@ -1763,7 +2001,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1763
2001
|
return rendered;
|
|
1764
2002
|
})()
|
|
1765
2003
|
)
|
|
1766
|
-
)}
|
|
2004
|
+
))}
|
|
1767
2005
|
|
|
1768
2006
|
{/* Text selection toolbar */}
|
|
1769
2007
|
{selectionCommentsOn && !hostComposes && toolbarState && (
|
|
@@ -1780,6 +2018,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1780
2018
|
selectionActionsIcon={selectionActionsIcon}
|
|
1781
2019
|
quickLabels={quickLabels}
|
|
1782
2020
|
copyText={toolbarState.selectionText}
|
|
2021
|
+
getRichCopy={getRichCopyEnabled() ? toolbarState.getRichCopy : undefined}
|
|
1783
2022
|
hideCopyButton={!isTouchDevice}
|
|
1784
2023
|
closeOnScrollOut
|
|
1785
2024
|
/>
|
|
@@ -1911,7 +2150,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1911
2150
|
anchorEl={hookCommentPopover.anchorEl}
|
|
1912
2151
|
contextText={hookCommentPopover.contextText}
|
|
1913
2152
|
isGlobal={false}
|
|
1914
|
-
initialText={hookCommentPopover.initialText}
|
|
2153
|
+
initialText={hookCommentPopover.initialText || suggestComment?.(hookCommentPopover.selectedText ?? hookCommentPopover.contextText) || undefined}
|
|
1915
2154
|
draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
|
|
1916
2155
|
onSubmit={hookCommentSubmit}
|
|
1917
2156
|
onClose={hookCommentClose}
|
|
@@ -1990,7 +2229,13 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1990
2229
|
|
|
1991
2230
|
{/* Image lightbox */}
|
|
1992
2231
|
{lightbox && createPortal(
|
|
1993
|
-
<ImageLightbox
|
|
2232
|
+
<ImageLightbox
|
|
2233
|
+
src={lightbox.src}
|
|
2234
|
+
alt={lightbox.alt}
|
|
2235
|
+
images={lightbox.images}
|
|
2236
|
+
regions={regionHost}
|
|
2237
|
+
onClose={closeLightbox}
|
|
2238
|
+
/>,
|
|
1994
2239
|
document.body
|
|
1995
2240
|
)}
|
|
1996
2241
|
</div>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import type
|
|
3
|
-
import { hasNonInterruptibleSession, type AIProviderOption } from '../../utils/aiProvider';
|
|
2
|
+
import { hasPendingBusyChoice, type AIChatEntry, type PendingPermission } from '../../hooks/useAIChat';
|
|
3
|
+
import { findSessionBridge, hasNonInterruptibleSession, type AIProviderOption } from '../../utils/aiProvider';
|
|
4
4
|
import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat';
|
|
5
5
|
import { OverlayScrollArea } from '../OverlayScrollArea';
|
|
6
6
|
import { SparklesIcon } from '../SparklesIcon';
|
|
7
7
|
import { AIProviderBar } from './AIProviderBar';
|
|
8
|
+
import { NewChatButton } from './NewChatButton';
|
|
8
9
|
import { SessionAskActions, SessionAskNote, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
|
|
9
10
|
import { submitHint } from '../../utils/platform';
|
|
10
11
|
|
|
@@ -24,6 +25,12 @@ interface DocumentAIChatPanelProps {
|
|
|
24
25
|
onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
|
|
25
26
|
/** Optional note under the provider bar on how to get "Ask this session" (askSessionSetupHint). */
|
|
26
27
|
askSessionSetupHint?: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Start a new chat: clear the panel so the next question starts a fresh AI
|
|
30
|
+
* session (#1981). Shown above the messages once there are any; disabled
|
|
31
|
+
* while an answer is in flight. Absent: no button, the panel renders as before.
|
|
32
|
+
*/
|
|
33
|
+
onNewChat?: () => void;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
function truncate(text: string, max = 180): string {
|
|
@@ -71,6 +78,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
71
78
|
onAIConfigChange,
|
|
72
79
|
onSessionAskAction,
|
|
73
80
|
askSessionSetupHint,
|
|
81
|
+
onNewChat,
|
|
74
82
|
}) => {
|
|
75
83
|
// A session that is never interrupted (T3 Code): Stop only stops listening.
|
|
76
84
|
const listenOnly = hasNonInterruptibleSession(aiProviders);
|
|
@@ -85,6 +93,16 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
85
93
|
last?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
86
94
|
}, [messages.length, latestResponseText]);
|
|
87
95
|
|
|
96
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
97
|
+
const handleNewChat = useCallback(() => {
|
|
98
|
+
if (!onNewChat) return;
|
|
99
|
+
onNewChat();
|
|
100
|
+
// The button leaves with the messages; keep focus in the panel.
|
|
101
|
+
requestAnimationFrame(() => {
|
|
102
|
+
rootRef.current?.querySelector<HTMLTextAreaElement>('textarea')?.focus();
|
|
103
|
+
});
|
|
104
|
+
}, [onNewChat]);
|
|
105
|
+
|
|
88
106
|
const handleGeneralSubmit = useCallback(() => {
|
|
89
107
|
const question = generalInput.trim();
|
|
90
108
|
if (!question || !onAskGeneral) return;
|
|
@@ -93,7 +111,15 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
|
|
|
93
111
|
}, [generalInput, onAskGeneral]);
|
|
94
112
|
|
|
95
113
|
return (
|
|
96
|
-
<div className="flex flex-col flex-1 min-h-0">
|
|
114
|
+
<div ref={rootRef} className="flex flex-col flex-1 min-h-0">
|
|
115
|
+
{onNewChat && messages.length > 0 && (
|
|
116
|
+
<NewChatButton
|
|
117
|
+
onNewChat={handleNewChat}
|
|
118
|
+
busy={isStreaming || isCreatingSession}
|
|
119
|
+
pendingChoice={hasPendingBusyChoice(messages)}
|
|
120
|
+
sessionBridge={!!findSessionBridge(aiProviders)}
|
|
121
|
+
/>
|
|
122
|
+
)}
|
|
97
123
|
<OverlayScrollArea className="flex-1 min-h-0">
|
|
98
124
|
<div ref={scrollRef} className="p-2 space-y-3">
|
|
99
125
|
{messages.length === 0 && !isCreatingSession && (
|