@plannotator/ui 0.53.1 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +217 -11
- package/README.md +65 -10
- 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 +19 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- 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 +658 -120
- 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 +152 -24
- 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/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/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 +280 -41
- 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 +522 -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 +47 -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 +265 -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/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/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +23 -22
- 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/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -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/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
|
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
|
|
|
4
4
|
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
|
|
5
5
|
import { type QuickLabel, getQuickLabels, quickLabelIndexForDigit, THUMBS_UP_LABEL } from "../utils/quickLabels";
|
|
6
6
|
import { copyTextToClipboard } from "../utils/clipboard";
|
|
7
|
+
import { copyRichSelection, richCopyOutcomeLabel, type RichCopyOutcome, type RichCopySource } from "../utils/richClipboard";
|
|
7
8
|
import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
|
|
8
9
|
import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
|
|
9
10
|
import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown";
|
|
@@ -30,6 +31,12 @@ interface AnnotationToolbarProps {
|
|
|
30
31
|
onQuickLabel?: (label: QuickLabel) => void;
|
|
31
32
|
/** Text to copy when the button is clicked */
|
|
32
33
|
copyText?: string;
|
|
34
|
+
/** Builds the captured text selection's sanitized HTML and plain text when
|
|
35
|
+
* Copy is pressed (never before). When present, Copy writes text/html plus
|
|
36
|
+
* text/plain in one clipboard write (a plain field still pastes plain
|
|
37
|
+
* text), and the Copy button shows even where hideCopyButton hides it.
|
|
38
|
+
* Absent: Copy copies plain text, exactly as before. No shortcut. */
|
|
39
|
+
getRichCopy?: () => RichCopySource | Promise<RichCopySource>;
|
|
33
40
|
/** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
|
|
34
41
|
* the quick-label picker, and the Alt+digit label shortcuts. A provided
|
|
35
42
|
* onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
|
|
@@ -57,7 +64,7 @@ interface AnnotationToolbarProps {
|
|
|
57
64
|
* this toolbar. The one-click 👍 is a separate affordance and is unaffected.
|
|
58
65
|
*/
|
|
59
66
|
quickLabels?: boolean;
|
|
60
|
-
/** Hide the copy button (set when a keyboard copy handler exists) */
|
|
67
|
+
/** Hide the plain copy button (set when a keyboard copy handler exists) */
|
|
61
68
|
hideCopyButton?: boolean;
|
|
62
69
|
/** Close toolbar when element scrolls out of viewport */
|
|
63
70
|
closeOnScrollOut?: boolean;
|
|
@@ -79,6 +86,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
79
86
|
selectionActionsIcon,
|
|
80
87
|
quickLabels: quickLabelsEnabled = true,
|
|
81
88
|
copyText,
|
|
89
|
+
getRichCopy,
|
|
82
90
|
commentOnly = false,
|
|
83
91
|
hideCopyButton = false,
|
|
84
92
|
closeOnScrollOut = false,
|
|
@@ -88,6 +96,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
88
96
|
}) => {
|
|
89
97
|
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
|
|
90
98
|
const [copied, setCopied] = useState(false);
|
|
99
|
+
const [richCopied, setRichCopied] = useState<RichCopyOutcome | false>(false);
|
|
100
|
+
const richCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
101
|
+
const richCopySelectionRef = useRef({ element, getRichCopy });
|
|
102
|
+
richCopySelectionRef.current = { element, getRichCopy };
|
|
91
103
|
const [showQuickLabels, setShowQuickLabels] = useState(false);
|
|
92
104
|
const [showSelectionActions, setShowSelectionActions] = useState(false);
|
|
93
105
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
@@ -107,7 +119,18 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
107
119
|
const selectionActionsOpen = showSelectionActions && hasSelectionActions;
|
|
108
120
|
const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled;
|
|
109
121
|
|
|
110
|
-
useEffect(() => {
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
setCopied(false);
|
|
124
|
+
setRichCopied(false);
|
|
125
|
+
}, [element]);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
setRichCopied(false);
|
|
128
|
+
if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
|
|
129
|
+
richCopyTimeoutRef.current = null;
|
|
130
|
+
}, [element, getRichCopy]);
|
|
131
|
+
useEffect(() => () => {
|
|
132
|
+
if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
|
|
133
|
+
}, []);
|
|
111
134
|
|
|
112
135
|
const handleCopy = async () => {
|
|
113
136
|
let textToCopy = copyText;
|
|
@@ -121,6 +144,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
121
144
|
}
|
|
122
145
|
};
|
|
123
146
|
|
|
147
|
+
const handleRichCopy = async () => {
|
|
148
|
+
setRichCopied(false);
|
|
149
|
+
const build = getRichCopy;
|
|
150
|
+
if (!build) return;
|
|
151
|
+
const fallback = copyText || element.querySelector('code')?.textContent || element.textContent || '';
|
|
152
|
+
const result = await copyRichSelection(build, fallback);
|
|
153
|
+
const current = richCopySelectionRef.current;
|
|
154
|
+
if (current.element !== element || current.getRichCopy !== build) return;
|
|
155
|
+
if (result) {
|
|
156
|
+
setRichCopied(result);
|
|
157
|
+
if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
|
|
158
|
+
richCopyTimeoutRef.current = setTimeout(() => {
|
|
159
|
+
setRichCopied(false);
|
|
160
|
+
richCopyTimeoutRef.current = null;
|
|
161
|
+
}, 1500);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
124
165
|
// Update position on scroll/resize
|
|
125
166
|
useEffect(() => {
|
|
126
167
|
const updatePosition = () => {
|
|
@@ -252,14 +293,28 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
252
293
|
}
|
|
253
294
|
`}</style>
|
|
254
295
|
<div data-pn-annotation-toolbar-row="true" className="flex items-center p-1 gap-0.5">
|
|
255
|
-
{!hideCopyButton && (
|
|
296
|
+
{(!hideCopyButton || getRichCopy) && (
|
|
256
297
|
<>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
298
|
+
{getRichCopy ? (
|
|
299
|
+
// One Copy button: rich text (text/html plus text/plain) where
|
|
300
|
+
// the host turned rich copy on. "Copied!" for a rich copy; a
|
|
301
|
+
// plain-text fallback says what went on the clipboard.
|
|
302
|
+
<ToolbarButton
|
|
303
|
+
onClick={handleRichCopy}
|
|
304
|
+
icon={richCopied ? <CheckIcon /> : <CopyIcon />}
|
|
305
|
+
label={!richCopied ? "Copy"
|
|
306
|
+
: richCopied.copied === 'rich' ? "Copied!"
|
|
307
|
+
: richCopyOutcomeLabel(richCopied)}
|
|
308
|
+
className={richCopied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
|
|
309
|
+
/>
|
|
310
|
+
) : (
|
|
311
|
+
<ToolbarButton
|
|
312
|
+
onClick={handleCopy}
|
|
313
|
+
icon={copied ? <CheckIcon /> : <CopyIcon />}
|
|
314
|
+
label={copied ? "Copied!" : "Copy"}
|
|
315
|
+
className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
263
318
|
<div className="w-px h-5 bg-border mx-0.5" />
|
|
264
319
|
</>
|
|
265
320
|
)}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useState, useRef, useCallback } from 'react';
|
|
2
2
|
import type { Agent } from '../hooks/useAgents';
|
|
3
3
|
import { useDismissablePopover } from '../hooks/useDismissablePopover';
|
|
4
|
-
import {
|
|
4
|
+
import { agentSwitchTargetLabel, getAgentSwitchDefaults, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch';
|
|
5
|
+
import { useConfigValue } from '../config/useConfig';
|
|
5
6
|
|
|
6
7
|
interface ApproveDropdownProps {
|
|
7
8
|
onApprove: () => void;
|
|
@@ -11,12 +12,7 @@ interface ApproveDropdownProps {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null {
|
|
14
|
-
|
|
15
|
-
if (setting.switchTo === 'custom' && setting.customName) {
|
|
16
|
-
return setting.customName;
|
|
17
|
-
}
|
|
18
|
-
const match = agents.find(a => a.id.toLowerCase() === setting.switchTo.toLowerCase());
|
|
19
|
-
return match?.name ?? setting.switchTo;
|
|
15
|
+
return agentSwitchTargetLabel(setting, agents);
|
|
20
16
|
}
|
|
21
17
|
|
|
22
18
|
function isSelected(agentId: string, setting: AgentSwitchSettings): boolean {
|
|
@@ -37,7 +33,10 @@ export const ApproveDropdown: React.FC<ApproveDropdownProps> = ({
|
|
|
37
33
|
disabled = false,
|
|
38
34
|
isLoading = false,
|
|
39
35
|
}) => {
|
|
40
|
-
|
|
36
|
+
// Read reactively: config.json's value lands through configStore.init()
|
|
37
|
+
// once /api/plan answers, which may be after this mounts (#1390).
|
|
38
|
+
const stored = useConfigValue('agentSwitch');
|
|
39
|
+
const setting: AgentSwitchSettings = stored ?? getAgentSwitchDefaults('plan');
|
|
41
40
|
const [isOpen, setIsOpen] = useState(false);
|
|
42
41
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
43
42
|
|
|
@@ -52,7 +51,6 @@ export const ApproveDropdown: React.FC<ApproveDropdownProps> = ({
|
|
|
52
51
|
});
|
|
53
52
|
|
|
54
53
|
const handleSelect = (newSetting: AgentSwitchSettings) => {
|
|
55
|
-
setSetting(newSetting);
|
|
56
54
|
saveAgentSwitchSettings(newSetting);
|
|
57
55
|
setIsOpen(false);
|
|
58
56
|
};
|
|
@@ -151,7 +151,7 @@ function DemoPlayer({ reducedMotion }: { readonly reducedMotion: boolean }) {
|
|
|
151
151
|
data-ask-session-playback={playing ? 'pause' : 'play'}
|
|
152
152
|
className={
|
|
153
153
|
playing
|
|
154
|
-
? 'absolute bottom-3 left-3 grid size-9 place-items-center rounded-full border border-border/60 bg-background/70 text-foreground opacity-0 outline-none backdrop-blur-sm transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary'
|
|
154
|
+
? 'absolute bottom-3 left-3 grid size-9 place-items-center rounded-full border border-border/60 bg-background/70 text-foreground opacity-0 outline-none backdrop-blur-sm transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 focus-visible:ring-2 focus-visible:ring-primary'
|
|
155
155
|
: 'absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg outline-none backdrop-blur-sm transition-transform motion-reduce:transition-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-primary'
|
|
156
156
|
}
|
|
157
157
|
>
|
|
@@ -248,7 +248,7 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
|
|
|
248
248
|
{/* Clear all button on hover */}
|
|
249
249
|
<button
|
|
250
250
|
onClick={handleClearAll}
|
|
251
|
-
className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
|
251
|
+
className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity flex items-center justify-center"
|
|
252
252
|
>
|
|
253
253
|
<svg className="w-2 h-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
254
254
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
@@ -18,9 +18,10 @@ import {
|
|
|
18
18
|
} from "@plannotator/core/question-block";
|
|
19
19
|
|
|
20
20
|
/** A question block rendered outside a Viewer's document index (plan diff,
|
|
21
|
-
* other markdown surfaces): parsed alone, unnumbered.
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
* other markdown surfaces): parsed alone, unnumbered. Its contract lines
|
|
22
|
+
* show as context unless the host asks for them (`questionContractLines`). */
|
|
23
|
+
const standaloneQuestion = (block: Block, contractLines: boolean): IndexedQuestion | undefined => {
|
|
24
|
+
const [indexed] = indexQuestionBlocks([block], { contractLines });
|
|
24
25
|
return indexed ? { ...indexed, number: 0 } : undefined;
|
|
25
26
|
};
|
|
26
27
|
|
|
@@ -66,7 +67,14 @@ export const BlockRenderer: React.FC<{
|
|
|
66
67
|
questionStatusLabel?: QuestionBlockProps['statusLabel'];
|
|
67
68
|
/** Host content under a question's prompt (see `QuestionBlock`). */
|
|
68
69
|
renderQuestionBelowPrompt?: QuestionBlockProps['renderBelowPrompt'];
|
|
69
|
-
|
|
70
|
+
/** Read a standalone question's contract lines into the card (default
|
|
71
|
+
* false: context). A `question` the host passes was parsed by the host. */
|
|
72
|
+
questionContractLines?: boolean;
|
|
73
|
+
/** Picture choices as tiles (see `QuestionBlock` `pictureChoices`). */
|
|
74
|
+
questionPictureChoices?: QuestionBlockProps['pictureChoices'];
|
|
75
|
+
/** A label over each picture tile (see `QuestionBlock` `pictureBadge`). */
|
|
76
|
+
questionPictureBadge?: QuestionBlockProps['pictureBadge'];
|
|
77
|
+
}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag, questionStatusLabel, renderQuestionBelowPrompt, questionContractLines = false, questionPictureChoices, questionPictureBadge }) => {
|
|
70
78
|
switch (block.type) {
|
|
71
79
|
case 'heading': {
|
|
72
80
|
const Tag = `h${block.level || 1}` as React.ElementType;
|
|
@@ -133,6 +141,10 @@ export const BlockRenderer: React.FC<{
|
|
|
133
141
|
<div
|
|
134
142
|
className="flex items-start gap-3 my-1.5"
|
|
135
143
|
data-block-id={block.id}
|
|
144
|
+
data-rich-list-level={block.level || 0}
|
|
145
|
+
data-rich-list-kind={block.ordered ? 'ol' : 'ul'}
|
|
146
|
+
data-rich-list-index={orderedIndex ?? undefined}
|
|
147
|
+
data-rich-list-checked={isCheckbox ? String(isChecked) : undefined}
|
|
136
148
|
style={{ marginLeft: `${indent}rem` }}
|
|
137
149
|
>
|
|
138
150
|
<ListItemBody
|
|
@@ -180,7 +192,7 @@ export const BlockRenderer: React.FC<{
|
|
|
180
192
|
const kind = block.directiveKind || 'note';
|
|
181
193
|
if (isQuestionDirectiveKind(kind)) {
|
|
182
194
|
// An unparseable question (no prompt) falls through to the callout.
|
|
183
|
-
const indexed = question ?? standaloneQuestion(block);
|
|
195
|
+
const indexed = question ?? standaloneQuestion(block, questionContractLines);
|
|
184
196
|
if (indexed) {
|
|
185
197
|
return (
|
|
186
198
|
<QuestionBlock
|
|
@@ -199,6 +211,8 @@ export const BlockRenderer: React.FC<{
|
|
|
199
211
|
statusTag={questionStatusTag}
|
|
200
212
|
statusLabel={questionStatusLabel}
|
|
201
213
|
renderBelowPrompt={renderQuestionBelowPrompt}
|
|
214
|
+
pictureChoices={questionPictureChoices}
|
|
215
|
+
pictureBadge={questionPictureBadge}
|
|
202
216
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
203
217
|
onOpenCodeFile={onOpenCodeFile}
|
|
204
218
|
imageBaseDir={imageBaseDir}
|
|
@@ -2,11 +2,14 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
|
2
2
|
import { File, type LineAnnotation } from '@pierre/diffs/react';
|
|
3
3
|
import type { SelectedLineRange as PierreSelectedLineRange, LineEventBaseProps } from '@pierre/diffs';
|
|
4
4
|
import { PopoutDialog } from './PopoutDialog';
|
|
5
|
+
import { isCommentSubmitKey } from '../utils/commentSubmitKey';
|
|
6
|
+
import { useConfigValue } from '../config/useConfig';
|
|
5
7
|
import { useTheme } from './ThemeProvider';
|
|
6
8
|
import { CommentPopover } from './CommentPopover';
|
|
7
9
|
import { ImageThumbnail } from './ImageThumbnail';
|
|
8
10
|
import type { CodeAnnotation, ImageAttachment } from '../types';
|
|
9
11
|
import { copyTextToClipboard } from '../utils/clipboard';
|
|
12
|
+
import { whenCodeLineRendered } from '../utils/codeLineScroll';
|
|
10
13
|
|
|
11
14
|
export interface CodeFileAnnotationInput {
|
|
12
15
|
filePath: string;
|
|
@@ -22,6 +25,10 @@ interface CodeFilePopoutProps {
|
|
|
22
25
|
onClose: () => void;
|
|
23
26
|
filepath: string;
|
|
24
27
|
contents: string;
|
|
28
|
+
line?: number;
|
|
29
|
+
lineEnd?: number;
|
|
30
|
+
/** Changes on every open, so reopening the same file:line scrolls to it again. */
|
|
31
|
+
revealKey?: number;
|
|
25
32
|
prerenderedHTML?: string;
|
|
26
33
|
error?: string;
|
|
27
34
|
requestedPath?: string;
|
|
@@ -145,6 +152,7 @@ const CodeInlineAnnotation: React.FC<{
|
|
|
145
152
|
const [isEditing, setIsEditing] = useState(false);
|
|
146
153
|
const [editText, setEditText] = useState(annotation.text ?? '');
|
|
147
154
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
155
|
+
const enterSavesComment = useConfigValue('enterSavesComment');
|
|
148
156
|
|
|
149
157
|
useEffect(() => {
|
|
150
158
|
if (!isEditing) setEditText(annotation.text ?? '');
|
|
@@ -181,7 +189,17 @@ const CodeInlineAnnotation: React.FC<{
|
|
|
181
189
|
{lineLabel(annotation.lineStart, annotation.lineEnd)}
|
|
182
190
|
</span>
|
|
183
191
|
{annotation.author && <span className="truncate normal-case">by <bdi>{annotation.author}</bdi></span>}
|
|
184
|
-
|
|
192
|
+
{annotation.outsideDiff && (
|
|
193
|
+
<span
|
|
194
|
+
className="rounded px-1.5 py-0.5 normal-case font-medium"
|
|
195
|
+
style={{ background: 'color-mix(in oklab, var(--warning) 18%, transparent)', color: 'var(--warning)' }}
|
|
196
|
+
data-code-annotation-outside-diff=""
|
|
197
|
+
title="A comment on lines outside the diff"
|
|
198
|
+
>
|
|
199
|
+
not in the diff
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
|
185
203
|
{onEdit && !isEditing && (
|
|
186
204
|
<button
|
|
187
205
|
type="button"
|
|
@@ -227,7 +245,7 @@ const CodeInlineAnnotation: React.FC<{
|
|
|
227
245
|
e.preventDefault();
|
|
228
246
|
setIsEditing(false);
|
|
229
247
|
setEditText(annotation.text ?? '');
|
|
230
|
-
} else if (
|
|
248
|
+
} else if (isCommentSubmitKey(e, enterSavesComment)) {
|
|
231
249
|
e.preventDefault();
|
|
232
250
|
save();
|
|
233
251
|
}
|
|
@@ -286,6 +304,10 @@ const CodeInlineAnnotation: React.FC<{
|
|
|
286
304
|
export interface CodeFileViewProps {
|
|
287
305
|
filepath: string;
|
|
288
306
|
contents: string;
|
|
307
|
+
line?: number;
|
|
308
|
+
lineEnd?: number;
|
|
309
|
+
/** Changes on every open, so reopening the same file:line scrolls to it again. */
|
|
310
|
+
revealKey?: number;
|
|
289
311
|
prerenderedHTML?: string;
|
|
290
312
|
annotations?: CodeAnnotation[];
|
|
291
313
|
selectedAnnotationId?: string | null;
|
|
@@ -301,6 +323,26 @@ export interface CodeFileViewProps {
|
|
|
301
323
|
lang?: string;
|
|
302
324
|
/** Long lines: `'scroll'` sideways (the default) or `'wrap'` onto the next row, for a minified file. */
|
|
303
325
|
overflow?: 'scroll' | 'wrap';
|
|
326
|
+
/** CSS added inside the file's shadow root after the theme (a host's line
|
|
327
|
+
* marks, e.g. code review's change bars on `[data-column-number="12"]`). */
|
|
328
|
+
extraCss?: string;
|
|
329
|
+
/** Scroll this line to the middle and mark it, once per `token` (a jump to
|
|
330
|
+
* a definition, a reference or a line). */
|
|
331
|
+
revealLine?: { line: number; token: number };
|
|
332
|
+
/** The token under the pointer (`enter` / `leave`) and the last one clicked
|
|
333
|
+
* (`click`): a host's "go to definition" looks there. Turning it on wraps
|
|
334
|
+
* the file's tokens (Pierre's token transformer). */
|
|
335
|
+
onTokenTarget?: (kind: 'enter' | 'leave' | 'click', target: CodeFileTokenTarget) => void;
|
|
336
|
+
/** Cmd/Ctrl-click (or Alt-click) on a token: a host's references. */
|
|
337
|
+
onTokenNavigate?: (target: CodeFileTokenTarget) => void;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A token of a file drawn by `CodeFileView` (Pierre's token event, file mode). */
|
|
341
|
+
export interface CodeFileTokenTarget {
|
|
342
|
+
tokenText: string;
|
|
343
|
+
tokenElement: HTMLElement;
|
|
344
|
+
lineNumber: number;
|
|
345
|
+
lineCharStart: number;
|
|
304
346
|
}
|
|
305
347
|
|
|
306
348
|
/**
|
|
@@ -312,6 +354,9 @@ export interface CodeFileViewProps {
|
|
|
312
354
|
export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
313
355
|
filepath,
|
|
314
356
|
contents,
|
|
357
|
+
line,
|
|
358
|
+
lineEnd,
|
|
359
|
+
revealKey,
|
|
315
360
|
prerenderedHTML,
|
|
316
361
|
annotations = [],
|
|
317
362
|
selectedAnnotationId,
|
|
@@ -323,6 +368,10 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
|
323
368
|
className = 'flex-1 min-h-0',
|
|
324
369
|
lang,
|
|
325
370
|
overflow = 'scroll',
|
|
371
|
+
extraCss,
|
|
372
|
+
revealLine,
|
|
373
|
+
onTokenTarget,
|
|
374
|
+
onTokenNavigate,
|
|
326
375
|
}) => {
|
|
327
376
|
const { resolvedMode } = useTheme();
|
|
328
377
|
const mode = resolvedMode ?? 'dark';
|
|
@@ -367,6 +416,9 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
|
367
416
|
}));
|
|
368
417
|
}, [annotations]);
|
|
369
418
|
|
|
419
|
+
// The line a host jumped to stays marked until a comment or a selection
|
|
420
|
+
// takes the mark (or the next jump moves it).
|
|
421
|
+
const [revealedLine, setRevealedLine] = useState<number | null>(null);
|
|
370
422
|
const selectedLines = useMemo((): PierreSelectedLineRange | null => {
|
|
371
423
|
if (pendingComment) {
|
|
372
424
|
return { start: pendingComment.range.start, end: pendingComment.range.end };
|
|
@@ -374,8 +426,49 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
|
374
426
|
if (selectedCodeAnnotation) {
|
|
375
427
|
return { start: selectedCodeAnnotation.lineStart, end: selectedCodeAnnotation.lineEnd };
|
|
376
428
|
}
|
|
429
|
+
// A jump (Go to Definition, a reference) marks its line until a comment
|
|
430
|
+
// or a selection takes the mark; otherwise the host's line, if any.
|
|
431
|
+
if (revealedLine != null) return { start: revealedLine, end: revealedLine };
|
|
432
|
+
if (line != null) {
|
|
433
|
+
return { start: line, end: lineEnd ?? line };
|
|
434
|
+
}
|
|
377
435
|
return null;
|
|
378
|
-
}, [pendingComment, selectedCodeAnnotation]);
|
|
436
|
+
}, [pendingComment, selectedCodeAnnotation, revealedLine, line, lineEnd]);
|
|
437
|
+
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (!revealLine) return;
|
|
440
|
+
setRevealedLine(revealLine.line);
|
|
441
|
+
// Pierre renders after mount (and again after highlighting): look for the
|
|
442
|
+
// line for a moment.
|
|
443
|
+
let frame = 0;
|
|
444
|
+
let attempts = 0;
|
|
445
|
+
const find = () => {
|
|
446
|
+
const root = fileAreaRef.current?.querySelector('diffs-container')?.shadowRoot;
|
|
447
|
+
const row = root?.querySelector(`[data-line="${revealLine.line}"]`);
|
|
448
|
+
if (row) {
|
|
449
|
+
// Vertically only: scrollIntoView would also scroll a long line sideways.
|
|
450
|
+
let node: Element | null = fileAreaRef.current;
|
|
451
|
+
while (node) {
|
|
452
|
+
if (node instanceof HTMLElement && node.scrollHeight > node.clientHeight && /(auto|scroll)/.test(getComputedStyle(node).overflowY)) {
|
|
453
|
+
const box = node.getBoundingClientRect();
|
|
454
|
+
const rect = row.getBoundingClientRect();
|
|
455
|
+
node.scrollTop += rect.top - (box.top + box.height / 2) + rect.height / 2;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
node = node.parentElement;
|
|
459
|
+
}
|
|
460
|
+
row.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
attempts += 1;
|
|
464
|
+
if (attempts < 120) frame = requestAnimationFrame(find);
|
|
465
|
+
};
|
|
466
|
+
frame = requestAnimationFrame(find);
|
|
467
|
+
return () => cancelAnimationFrame(frame);
|
|
468
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
469
|
+
}, [revealLine?.token, revealLine?.line]);
|
|
470
|
+
// Another file drops the mark (cleanups run before the next jump sets one).
|
|
471
|
+
useEffect(() => () => setRevealedLine(null), [filepath]);
|
|
379
472
|
const effectivePrerenderedHTML = lineAnnotations.length === 0 ? prerenderedHTML : undefined;
|
|
380
473
|
|
|
381
474
|
useEffect(() => {
|
|
@@ -388,6 +481,19 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
|
388
481
|
return () => clearTimeout(timer);
|
|
389
482
|
}, [selectedAnnotationId, filepath]);
|
|
390
483
|
|
|
484
|
+
// Bring the referenced line into view once Pierre has drawn it. Runs again
|
|
485
|
+
// only when the file or the line changes; a selected comment scrolls itself.
|
|
486
|
+
const selectedAnnotationIdRef = useRef(selectedAnnotationId);
|
|
487
|
+
selectedAnnotationIdRef.current = selectedAnnotationId;
|
|
488
|
+
const lineCount = useMemo(() => contents.split('\n').length, [contents]);
|
|
489
|
+
useEffect(() => {
|
|
490
|
+
const area = fileAreaRef.current;
|
|
491
|
+
if (line == null || !area || selectedAnnotationIdRef.current) return;
|
|
492
|
+
return whenCodeLineRendered(area, line, (lineEl) => {
|
|
493
|
+
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
494
|
+
}, { lineCount });
|
|
495
|
+
}, [line, filepath, lineCount, revealKey]);
|
|
496
|
+
|
|
391
497
|
const openCommentForRange = useCallback((
|
|
392
498
|
range: { start: number; end: number },
|
|
393
499
|
anchorEl?: HTMLElement,
|
|
@@ -470,6 +576,28 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
|
470
576
|
openCommentForRange({ start: range.start, end: range.end }, undefined, lastPointerRectRef.current ?? undefined);
|
|
471
577
|
}, [onAddAnnotation, openCommentForRange]);
|
|
472
578
|
|
|
579
|
+
const tokenTargetOf = (props: { tokenText: string; tokenElement: HTMLElement; lineNumber: number; lineCharStart: number }): CodeFileTokenTarget => ({
|
|
580
|
+
tokenText: props.tokenText,
|
|
581
|
+
tokenElement: props.tokenElement,
|
|
582
|
+
lineNumber: props.lineNumber,
|
|
583
|
+
lineCharStart: props.lineCharStart,
|
|
584
|
+
});
|
|
585
|
+
const tokenOptions = onTokenTarget || onTokenNavigate
|
|
586
|
+
? {
|
|
587
|
+
useTokenTransformer: true,
|
|
588
|
+
onTokenEnter: (props: CodeFileTokenTarget) => onTokenTarget?.('enter', tokenTargetOf(props)),
|
|
589
|
+
onTokenLeave: (props: CodeFileTokenTarget) => onTokenTarget?.('leave', tokenTargetOf(props)),
|
|
590
|
+
onTokenClick: (props: CodeFileTokenTarget, event: MouseEvent) => {
|
|
591
|
+
const target = tokenTargetOf(props);
|
|
592
|
+
onTokenTarget?.('click', target);
|
|
593
|
+
if (onTokenNavigate && (event.metaKey || event.ctrlKey || event.altKey)) {
|
|
594
|
+
suppressLineClickUntilRef.current = Date.now() + 300;
|
|
595
|
+
onTokenNavigate(target);
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
}
|
|
599
|
+
: {};
|
|
600
|
+
|
|
473
601
|
const handleLineClick = useCallback((props: LineEventBaseProps & { event: PointerEvent }) => {
|
|
474
602
|
if (!onAddAnnotation) return;
|
|
475
603
|
if (Date.now() < suppressLineClickUntilRef.current) return;
|
|
@@ -509,7 +637,7 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
|
509
637
|
} as React.CSSProperties}
|
|
510
638
|
options={{
|
|
511
639
|
themeType: pierreTheme.type,
|
|
512
|
-
unsafeCSS: pierreTheme.css,
|
|
640
|
+
unsafeCSS: extraCss ? `${pierreTheme.css}\n${extraCss}` : pierreTheme.css,
|
|
513
641
|
overflow,
|
|
514
642
|
disableFileHeader: true,
|
|
515
643
|
enableLineSelection: true,
|
|
@@ -517,6 +645,7 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
|
|
|
517
645
|
lineHoverHighlight: onAddAnnotation ? 'line' : 'disabled',
|
|
518
646
|
onLineClick: handleLineClick,
|
|
519
647
|
onLineSelectionEnd: handleLineSelectionEnd,
|
|
648
|
+
...tokenOptions,
|
|
520
649
|
}}
|
|
521
650
|
/>
|
|
522
651
|
</div>
|
|
@@ -551,6 +680,9 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
|
|
|
551
680
|
onClose,
|
|
552
681
|
filepath,
|
|
553
682
|
contents,
|
|
683
|
+
line,
|
|
684
|
+
lineEnd,
|
|
685
|
+
revealKey,
|
|
554
686
|
prerenderedHTML,
|
|
555
687
|
error,
|
|
556
688
|
requestedPath,
|
|
@@ -653,6 +785,9 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
|
|
|
653
785
|
<CodeFileView
|
|
654
786
|
filepath={filepath}
|
|
655
787
|
contents={contents}
|
|
788
|
+
line={line}
|
|
789
|
+
lineEnd={lineEnd}
|
|
790
|
+
revealKey={revealKey}
|
|
656
791
|
prerenderedHTML={prerenderedHTML}
|
|
657
792
|
annotations={annotations}
|
|
658
793
|
selectedAnnotationId={selectedAnnotationId}
|
|
@@ -5,6 +5,8 @@ import { AttachmentsButton } from './AttachmentsButton';
|
|
|
5
5
|
import { useDraggable } from '../hooks/useDraggable';
|
|
6
6
|
import { SparklesIcon } from './SparklesIcon';
|
|
7
7
|
import { hasUnsavedCommentContent } from '../utils/commentContent';
|
|
8
|
+
import { isCommentSubmitKey } from '../utils/commentSubmitKey';
|
|
9
|
+
import { useConfigValue } from '../config/useConfig';
|
|
8
10
|
import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
|
|
9
11
|
import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
|
|
10
12
|
import { ComposerTokenTextarea, type ComposerMentionChips } from './ComposerTokenTextarea';
|
|
@@ -119,6 +121,20 @@ interface CommentPopoverProps {
|
|
|
119
121
|
/** Opt-in composer yield while shift-selecting: 'near' fades the composer,
|
|
120
122
|
* 'over' makes it near-invisible and click-through. Undefined → no-op. */
|
|
121
123
|
yieldState?: CommentPopoverYieldState;
|
|
124
|
+
/** Opt-in: the header's text in place of the quoted `contextText` (an image
|
|
125
|
+
* region's "Region 3 · 356 × 486 px"). Absent → the quote, as always. */
|
|
126
|
+
headerText?: string;
|
|
127
|
+
/** Opt-in: `side` opens the popover beside the anchor (right, else left),
|
|
128
|
+
* falling back to below/above when neither side has room (an image region
|
|
129
|
+
* in the image viewer). Absent → below/above, as always. */
|
|
130
|
+
placement?: 'below' | 'side';
|
|
131
|
+
/** Opt-in: the stacking level, for a composer over a surface that is
|
|
132
|
+
* itself above the page (the image viewer is z-200). Absent → 100. */
|
|
133
|
+
zIndex?: number;
|
|
134
|
+
/** Opt-out: `false` mounts the composer without taking focus (an HTML
|
|
135
|
+
* pin's composer shown again when its hidden target returns, so focus
|
|
136
|
+
* stays where the person is). Absent → focuses on mount, as always. */
|
|
137
|
+
autoFocus?: boolean;
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
const MAX_POPOVER_WIDTH = 384;
|
|
@@ -151,7 +167,19 @@ interface CommentPopoverPosition {
|
|
|
151
167
|
export function computeCommentPopoverPosition(
|
|
152
168
|
anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
|
|
153
169
|
bounds: VisibleViewportBounds,
|
|
170
|
+
placement: 'below' | 'side' = 'below',
|
|
154
171
|
): CommentPopoverPosition {
|
|
172
|
+
if (placement === 'side') {
|
|
173
|
+
const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
|
|
174
|
+
const right = anchorRect.right + GAP * 1.75;
|
|
175
|
+
const left = anchorRect.left - GAP * 1.75 - width;
|
|
176
|
+
const sideLeft = right + width <= bounds.right ? right : left >= bounds.left ? left : null;
|
|
177
|
+
if (sideLeft !== null) {
|
|
178
|
+
const top = Math.max(bounds.top, Math.min(anchorRect.top, bounds.bottom - 280));
|
|
179
|
+
const maxHeight = Math.max(0, bounds.bottom - top);
|
|
180
|
+
if (maxHeight >= 280) return { top, left: sideLeft, flipAbove: false, width, maxHeight, requiresExpanded: false };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
155
183
|
const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
|
|
156
184
|
const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
|
|
157
185
|
const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
|
|
@@ -199,6 +227,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
199
227
|
refocusToken,
|
|
200
228
|
captureStrayKeys = false,
|
|
201
229
|
yieldState,
|
|
230
|
+
headerText,
|
|
231
|
+
placement = 'below',
|
|
232
|
+
zIndex,
|
|
233
|
+
autoFocus = true,
|
|
202
234
|
}) => {
|
|
203
235
|
const visibleBounds = useVisibleViewportBounds(16);
|
|
204
236
|
const coarsePointer = hasPrimaryCoarsePointer();
|
|
@@ -266,7 +298,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
266
298
|
const update = () => {
|
|
267
299
|
const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
|
|
268
300
|
if (!rect) return;
|
|
269
|
-
const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
|
|
301
|
+
const nextPosition = computeCommentPopoverPosition(rect, visibleBounds, placement);
|
|
270
302
|
if (nextPosition.requiresExpanded) {
|
|
271
303
|
setDialogIsForced(true);
|
|
272
304
|
setMode('dialog');
|
|
@@ -280,7 +312,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
280
312
|
return () => {
|
|
281
313
|
window.removeEventListener('scroll', update, true);
|
|
282
314
|
};
|
|
283
|
-
}, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
|
|
315
|
+
}, [anchorEl, anchorRect, mode, visibleBounds, wasDragged, placement]);
|
|
284
316
|
|
|
285
317
|
// Surface a "jump back" arrow when an open popover scrolls out of view.
|
|
286
318
|
// Re-measures whenever the popover repositions (position updates every scroll
|
|
@@ -307,7 +339,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
307
339
|
// renders after `position` is measured, and WebKit fires 0ms timers ahead of
|
|
308
340
|
// that commit, so an effect keyed on mode alone can run before the textarea
|
|
309
341
|
// exists and never focus it (e.g. in WKWebView hosts like Glimpse).
|
|
310
|
-
const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
|
|
342
|
+
const shouldAutoFocus = autoFocus && (!coarsePointer || initialText.length > 0 || isGlobal);
|
|
311
343
|
const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
|
|
312
344
|
textareaRef.current = el;
|
|
313
345
|
if (!el || !shouldAutoFocus) return;
|
|
@@ -322,13 +354,13 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
322
354
|
// summon the software keyboard), but hardware-keyboard users still need a
|
|
323
355
|
// coherent focus target and Escape dismissal.
|
|
324
356
|
useEffect(() => {
|
|
325
|
-
if (mode !== 'dialog' || shouldAutoFocus) return;
|
|
357
|
+
if (mode !== 'dialog' || shouldAutoFocus || !autoFocus) return;
|
|
326
358
|
const timer = setTimeout(() => {
|
|
327
359
|
if (!popoverRef.current?.isConnected) return;
|
|
328
360
|
popoverRef.current.focus({ preventScroll: true });
|
|
329
361
|
}, 0);
|
|
330
362
|
return () => clearTimeout(timer);
|
|
331
|
-
}, [mode, shouldAutoFocus]);
|
|
363
|
+
}, [autoFocus, mode, shouldAutoFocus]);
|
|
332
364
|
|
|
333
365
|
const restoreOpeningFocus = useCallback(() => {
|
|
334
366
|
const target = openingFocusRef.current;
|
|
@@ -561,6 +593,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
561
593
|
const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
|
|
562
594
|
const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
|
|
563
595
|
|
|
596
|
+
const enterSavesComment = useConfigValue('enterSavesComment');
|
|
564
597
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
565
598
|
if (skillAc.onKeyDown(e)) return;
|
|
566
599
|
if (mentionAc.onKeyDown(e)) return;
|
|
@@ -577,17 +610,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
577
610
|
}
|
|
578
611
|
return;
|
|
579
612
|
}
|
|
580
|
-
if (
|
|
613
|
+
if (isCommentSubmitKey(e, enterSavesComment)) {
|
|
581
614
|
e.preventDefault();
|
|
582
615
|
handleSubmit();
|
|
583
616
|
}
|
|
584
617
|
};
|
|
585
618
|
|
|
586
|
-
const headerLabel = isGlobal
|
|
619
|
+
const headerLabel = headerText ?? (isGlobal
|
|
587
620
|
? 'Global Comment'
|
|
588
621
|
: contextText
|
|
589
622
|
? `"${contextText.length > 50 ? contextText.slice(0, 50) + '...' : contextText}"`
|
|
590
|
-
: 'Comment';
|
|
623
|
+
: 'Comment');
|
|
591
624
|
|
|
592
625
|
const canSubmit =
|
|
593
626
|
hasUnsavedContent ||
|
|
@@ -630,6 +663,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
630
663
|
<div
|
|
631
664
|
data-comment-popover="true"
|
|
632
665
|
className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
|
|
666
|
+
style={zIndex !== undefined ? { zIndex } : undefined}
|
|
633
667
|
>
|
|
634
668
|
{/* Backdrop */}
|
|
635
669
|
<button
|
|
@@ -797,6 +831,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
797
831
|
className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
|
|
798
832
|
style={dragPosition
|
|
799
833
|
? {
|
|
834
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
800
835
|
top: dragPosition.top,
|
|
801
836
|
left: dragPosition.left,
|
|
802
837
|
width: position.width,
|
|
@@ -804,6 +839,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
804
839
|
overflowY: showsSkillMenu ? 'visible' : 'auto',
|
|
805
840
|
}
|
|
806
841
|
: {
|
|
842
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
807
843
|
top: position.top,
|
|
808
844
|
left: position.left,
|
|
809
845
|
width: position.width,
|