@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
|
|
4
|
+
import { useFenceTheme } from "../hooks/useFenceTheme";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The floating code window a code-file reference opens on hover: a few lines
|
|
8
|
+
* of one file, syntax highlighted, with line numbers. Portalled to the body
|
|
9
|
+
* and positioned under (or above) its anchor.
|
|
10
|
+
*
|
|
11
|
+
* Used by plan / annotate code-file links (`CodeFileLink` in InlineMarkdown,
|
|
12
|
+
* the exact range only) and by code review's Call Flow locations
|
|
13
|
+
* (`CallFlowTreeView`, with `contextLines` around the target).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Lines highlighted above the shown ones so a range opening inside a block comment or string tokenises as one. */
|
|
17
|
+
const HIGHLIGHT_LEAD_IN_LINES = 40;
|
|
18
|
+
/** Gap kept between the window and the viewport edge. */
|
|
19
|
+
const VIEWPORT_MARGIN = 8;
|
|
20
|
+
|
|
21
|
+
/** The highlighter language for a file path, by extension; undefined renders plain text. */
|
|
22
|
+
export function codeSnippetLanguage(filepath: string): string | undefined {
|
|
23
|
+
const ext = filepath.split('.').pop()?.toLowerCase();
|
|
24
|
+
const map: Record<string, string> = {
|
|
25
|
+
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
26
|
+
mts: 'typescript', cts: 'typescript', mjs: 'javascript', cjs: 'javascript',
|
|
27
|
+
py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
|
|
28
|
+
css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
|
|
29
|
+
sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
|
|
30
|
+
html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
|
|
31
|
+
lua: 'lua', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cs: 'csharp',
|
|
32
|
+
scala: 'scala', dart: 'dart', ex: 'elixir', exs: 'elixir', zig: 'zig',
|
|
33
|
+
vue: 'vue', svelte: 'svelte', astro: 'astro', php: 'php',
|
|
34
|
+
};
|
|
35
|
+
return ext ? map[ext] : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CodeSnippetPreviewProps {
|
|
39
|
+
anchorEl: HTMLElement | null;
|
|
40
|
+
/** The whole file; the preview slices the lines it shows. */
|
|
41
|
+
contents: string;
|
|
42
|
+
filepath: string;
|
|
43
|
+
/** One-based first line of the target range. */
|
|
44
|
+
line: number;
|
|
45
|
+
lineEnd?: number;
|
|
46
|
+
/** Lines shown above and below the target range, the target rows marked. 0 (default) shows the range alone. */
|
|
47
|
+
contextLines?: number;
|
|
48
|
+
/** Header label; defaults to the file's basename. */
|
|
49
|
+
title?: string;
|
|
50
|
+
/** Called when the page or a scroller scrolls: the window is placed once and would drift from its anchor. */
|
|
51
|
+
onDismiss?: () => void;
|
|
52
|
+
onMouseEnter?: () => void;
|
|
53
|
+
onMouseLeave?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const CodeSnippetPreview: React.FC<CodeSnippetPreviewProps> = ({
|
|
57
|
+
anchorEl,
|
|
58
|
+
contents,
|
|
59
|
+
filepath,
|
|
60
|
+
line,
|
|
61
|
+
lineEnd,
|
|
62
|
+
contextLines = 0,
|
|
63
|
+
title,
|
|
64
|
+
onDismiss,
|
|
65
|
+
onMouseEnter,
|
|
66
|
+
onMouseLeave,
|
|
67
|
+
}) => {
|
|
68
|
+
const allLines = contents.split('\n');
|
|
69
|
+
const targetStart = Math.max(0, line - 1);
|
|
70
|
+
const targetEnd = Math.min(allLines.length, lineEnd ?? line);
|
|
71
|
+
const start = Math.max(0, targetStart - contextLines);
|
|
72
|
+
const end = Math.min(allLines.length, targetEnd + contextLines);
|
|
73
|
+
const snippet = allLines.slice(start, end).join('\n');
|
|
74
|
+
// Highlight from a little earlier than the first shown line: a range that
|
|
75
|
+
// starts inside a block comment or template literal would otherwise colour
|
|
76
|
+
// that text as code.
|
|
77
|
+
const highlightStart = Math.max(0, start - HIGHLIGHT_LEAD_IN_LINES);
|
|
78
|
+
const highlightSource = allLines.slice(highlightStart, end).join('\n');
|
|
79
|
+
const marksTarget = contextLines > 0;
|
|
80
|
+
|
|
81
|
+
const fenceTheme = useFenceTheme();
|
|
82
|
+
// Bumped once the grammar is attached, to re-run the memo below with the
|
|
83
|
+
// highlighter warm. Until then the snippet renders as plain text — no
|
|
84
|
+
// auto-detection, and an unknown extension simply stays plain.
|
|
85
|
+
const [highlighterGeneration, setHighlighterGeneration] = useState(0);
|
|
86
|
+
const lang = codeSnippetLanguage(filepath);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!lang) return;
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
void ensureHighlight(lang, fenceTheme).then((ok) => {
|
|
92
|
+
if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
|
|
93
|
+
});
|
|
94
|
+
return () => { cancelled = true; };
|
|
95
|
+
}, [lang, fenceTheme]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!onDismiss) return;
|
|
99
|
+
const dismiss = () => onDismiss();
|
|
100
|
+
window.addEventListener('scroll', dismiss, { capture: true, passive: true });
|
|
101
|
+
return () => window.removeEventListener('scroll', dismiss, { capture: true });
|
|
102
|
+
}, [onDismiss]);
|
|
103
|
+
|
|
104
|
+
const highlightedLines = useMemo(() => {
|
|
105
|
+
// Highlight the snippet as one unit so multi-line constructs (block
|
|
106
|
+
// comments, template literals) tokenise correctly, then split back into
|
|
107
|
+
// rows: the highlighter joins lines with "\n" and never emits one inside a
|
|
108
|
+
// span, so the split is exact.
|
|
109
|
+
const html = lang ? highlightToHtml(highlightSource, lang, fenceTheme) : null;
|
|
110
|
+
if (html !== null) return html.split('\n').slice(start - highlightStart);
|
|
111
|
+
return snippet.split('\n').map(text =>
|
|
112
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'));
|
|
113
|
+
// highlighterGeneration is the "grammar just became available" signal.
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [snippet, highlightSource, start, highlightStart, lang, fenceTheme, highlighterGeneration]);
|
|
116
|
+
|
|
117
|
+
// Clamp against the window's rendered width (it sizes to its longest line,
|
|
118
|
+
// up to its max width) so a wide snippet never runs off the right edge.
|
|
119
|
+
const windowRef = useRef<HTMLDivElement | null>(null);
|
|
120
|
+
const [renderedWidth, setRenderedWidth] = useState<number | null>(null);
|
|
121
|
+
useLayoutEffect(() => {
|
|
122
|
+
const width = windowRef.current?.offsetWidth ?? null;
|
|
123
|
+
if (width !== renderedWidth) setRenderedWidth(width);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!anchorEl) return null;
|
|
127
|
+
|
|
128
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
129
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
130
|
+
const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
|
|
131
|
+
const top = showAbove ? undefined : rect.bottom + 4;
|
|
132
|
+
const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
|
|
133
|
+
const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left, window.innerWidth - (renderedWidth ?? 520) - VIEWPORT_MARGIN));
|
|
134
|
+
|
|
135
|
+
return createPortal(
|
|
136
|
+
<div
|
|
137
|
+
ref={windowRef}
|
|
138
|
+
className="code-snippet-preview-window fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
|
|
139
|
+
style={{ top, bottom, left, maxWidth: `min(600px, calc(100vw - ${VIEWPORT_MARGIN * 2}px))`, maxHeight: '300px' }}
|
|
140
|
+
data-code-snippet-preview={filepath}
|
|
141
|
+
onMouseEnter={onMouseEnter}
|
|
142
|
+
onMouseLeave={onMouseLeave}
|
|
143
|
+
>
|
|
144
|
+
<div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
|
|
145
|
+
<span>{title ?? filepath.split('/').pop()}</span>
|
|
146
|
+
<span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
|
|
149
|
+
<table className="border-collapse w-full">
|
|
150
|
+
<tbody>
|
|
151
|
+
{snippet.split('\n').map((_, i) => {
|
|
152
|
+
const index = start + i;
|
|
153
|
+
const target = marksTarget && index >= targetStart && index < targetEnd;
|
|
154
|
+
return (
|
|
155
|
+
<tr
|
|
156
|
+
key={index}
|
|
157
|
+
className={target ? 'bg-primary/15' : 'hover:bg-white/5'}
|
|
158
|
+
data-target-line={target ? 'true' : undefined}
|
|
159
|
+
>
|
|
160
|
+
<td className={`select-none text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap ${target ? 'text-primary' : 'text-muted-foreground/40'}`} style={{ userSelect: 'none' }}>{index + 1}</td>
|
|
161
|
+
<td
|
|
162
|
+
className="font-mono pr-3 py-0 whitespace-pre"
|
|
163
|
+
dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
|
|
164
|
+
/>
|
|
165
|
+
</tr>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</tbody>
|
|
169
|
+
</table>
|
|
170
|
+
</div>
|
|
171
|
+
</div>,
|
|
172
|
+
document.body,
|
|
173
|
+
);
|
|
174
|
+
};
|
|
@@ -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,
|
|
@@ -8,6 +8,7 @@ import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
|
|
|
8
8
|
import { useDismissablePopover } from '../hooks/useDismissablePopover';
|
|
9
9
|
import { useCompactTouchLayout } from '../hooks/useIsMobile';
|
|
10
10
|
import { cn } from '../lib/utils';
|
|
11
|
+
import { isCommentSubmitKey } from '../utils/commentSubmitKey';
|
|
11
12
|
import type {
|
|
12
13
|
DecisionActionId,
|
|
13
14
|
DecisionComposer,
|
|
@@ -57,6 +58,16 @@ export interface DecisionControlProps {
|
|
|
57
58
|
dismissOnIframeFocus?: boolean;
|
|
58
59
|
/** Confirm surface override; defaults to the ui ConfirmDialog. */
|
|
59
60
|
confirmDialog?: React.ComponentType<ConfirmDialogProps>;
|
|
61
|
+
/**
|
|
62
|
+
* Rows rendered under the decisions, inside the same menu (#1057: plan
|
|
63
|
+
* review's "After approval, switch to" agent picker and Claude Code's
|
|
64
|
+
* permission-mode line). Not decisions: they never close the menu by
|
|
65
|
+
* themselves. Interactive rows use `role="menuitem"` or
|
|
66
|
+
* `role="menuitemradio"` so the arrow keys reach them. Absent: the menu is
|
|
67
|
+
* the decisions alone, exactly as before. A function receives
|
|
68
|
+
* `closeMenu`, for a row that leaves the menu (e.g. one opening Settings).
|
|
69
|
+
*/
|
|
70
|
+
menuFooter?: React.ReactNode | ((api: { closeMenu: () => void }) => React.ReactNode);
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
const NOTE_MAX_HEIGHT_PX = 144;
|
|
@@ -98,6 +109,14 @@ export interface DecisionNoteFieldProps {
|
|
|
98
109
|
placeholder?: string;
|
|
99
110
|
disabled?: boolean;
|
|
100
111
|
autoFocus?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* The field is a COMMENT composer (the review sidebar's "+ General
|
|
114
|
+
* comment"), so the "Enter saves a comment" setting applies: pass its value
|
|
115
|
+
* and a plain Enter also submits (Shift+Enter stays a new line). Default
|
|
116
|
+
* false: in the decision control's own composer a note rides a decision,
|
|
117
|
+
* and only Mod+Enter fires it.
|
|
118
|
+
*/
|
|
119
|
+
enterSubmits?: boolean;
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
/**
|
|
@@ -114,6 +133,7 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
|
|
|
114
133
|
placeholder = 'Add a note...',
|
|
115
134
|
disabled = false,
|
|
116
135
|
autoFocus = true,
|
|
136
|
+
enterSubmits = false,
|
|
117
137
|
}) => {
|
|
118
138
|
const ref = useAutoGrow(text);
|
|
119
139
|
|
|
@@ -132,15 +152,15 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
|
|
|
132
152
|
onCancel();
|
|
133
153
|
return;
|
|
134
154
|
}
|
|
135
|
-
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
155
|
+
if (enterSubmits ? isCommentSubmitKey(event, true) : event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
136
156
|
event.preventDefault();
|
|
137
157
|
event.stopPropagation();
|
|
138
158
|
if (!disabled) onSubmit();
|
|
139
159
|
}
|
|
140
|
-
//
|
|
141
|
-
// a newline.
|
|
160
|
+
// Otherwise plain Enter falls through: the field is multi-line and
|
|
161
|
+
// Enter's job is a newline.
|
|
142
162
|
},
|
|
143
|
-
[disabled, onCancel, onSubmit],
|
|
163
|
+
[disabled, enterSubmits, onCancel, onSubmit],
|
|
144
164
|
);
|
|
145
165
|
|
|
146
166
|
return (
|
|
@@ -255,6 +275,7 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
|
|
|
255
275
|
labelBreakpoint = 'md',
|
|
256
276
|
dismissOnIframeFocus = false,
|
|
257
277
|
confirmDialog,
|
|
278
|
+
menuFooter,
|
|
258
279
|
}) => {
|
|
259
280
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
260
281
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
@@ -402,7 +423,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
|
|
|
402
423
|
const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
|
|
403
424
|
if (!keys.includes(event.key)) return;
|
|
404
425
|
const items = Array.from(
|
|
405
|
-
popoverRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
426
|
+
popoverRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
427
|
+
'[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)',
|
|
428
|
+
) ?? [],
|
|
406
429
|
);
|
|
407
430
|
if (items.length === 0) return;
|
|
408
431
|
event.preventDefault();
|
|
@@ -548,6 +571,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
|
|
|
548
571
|
/>
|
|
549
572
|
</React.Fragment>
|
|
550
573
|
))}
|
|
574
|
+
{typeof menuFooter === 'function'
|
|
575
|
+
? menuFooter({ closeMenu: () => closePopover() })
|
|
576
|
+
: menuFooter}
|
|
551
577
|
</div>
|
|
552
578
|
) : activeItem?.composer ? (
|
|
553
579
|
<div className="p-2">
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Moved from the Workspaces app's DeferredMarkdownContentsRail with its
|
|
2
|
+
// contents rail (MarkdownContentsRail, approved 2026-10-03). Port: the props
|
|
3
|
+
// are the rail's own (`MarkdownContentsRailProps`), passed through.
|
|
4
|
+
|
|
5
|
+
import { lazy, Suspense, useEffect, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { MarkdownContentsRailProps } from './MarkdownContentsRail';
|
|
8
|
+
|
|
9
|
+
const MarkdownContentsRail = lazy(() =>
|
|
10
|
+
import('./MarkdownContentsRail').then((module) => ({
|
|
11
|
+
default: module.MarkdownContentsRail,
|
|
12
|
+
})),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export function scheduleMarkdownContentsRail(callback: () => void): () => void {
|
|
16
|
+
if (typeof window.requestIdleCallback === 'function') {
|
|
17
|
+
const handle = window.requestIdleCallback(callback, { timeout: 2000 });
|
|
18
|
+
return () => window.cancelIdleCallback(handle);
|
|
19
|
+
}
|
|
20
|
+
const handle = window.setTimeout(callback, 0);
|
|
21
|
+
return () => window.clearTimeout(handle);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Load the reading aid after the document is interactive, never in the
|
|
25
|
+
* critical document-paint path. The timeout still guarantees it appears on
|
|
26
|
+
* a busy page; Safari and tests use the zero-delay fallback. */
|
|
27
|
+
export function DeferredMarkdownContentsRail(props: MarkdownContentsRailProps) {
|
|
28
|
+
const [ready, setReady] = useState(false);
|
|
29
|
+
|
|
30
|
+
useEffect(() => scheduleMarkdownContentsRail(() => setReady(true)), []);
|
|
31
|
+
|
|
32
|
+
if (!ready) return null;
|
|
33
|
+
return (
|
|
34
|
+
<Suspense fallback={null}>
|
|
35
|
+
<MarkdownContentsRail {...props} />
|
|
36
|
+
</Suspense>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -16,6 +16,7 @@ import { svgContentSize } from './diagram/svgContentSize';
|
|
|
16
16
|
import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
|
|
17
17
|
import type { DiagramRenderState } from './diagram/useDiagramRender';
|
|
18
18
|
import { useTheme } from './ThemeProvider';
|
|
19
|
+
import { LazyBlockBoundary } from './LazyBlockBoundary';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* A diagram fence in the document: the fence's language picks the engine
|
|
@@ -345,7 +346,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
345
346
|
appears in a node label) can never wrap a <mark> inside the svg. */}
|
|
346
347
|
<div ref={rootRef} className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
|
|
347
348
|
{svgReady && !showSource && (
|
|
348
|
-
<div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
|
349
|
+
<div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity">
|
|
349
350
|
<button
|
|
350
351
|
type="button"
|
|
351
352
|
onClick={() => setShowSource(true)}
|
|
@@ -404,17 +405,21 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
404
405
|
)}
|
|
405
406
|
</div>
|
|
406
407
|
{isExpanded && svgReady && typeof document !== 'undefined' && (
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
408
|
+
// A popout chunk that cannot load leaves the inline diagram as it is
|
|
409
|
+
// and says so under it.
|
|
410
|
+
<LazyBlockBoundary fallback={(notice) => <div className="mb-5">{notice}</div>}>
|
|
411
|
+
<Suspense fallback={null}>
|
|
412
|
+
<DiagramPopout
|
|
413
|
+
{...viewerProps}
|
|
414
|
+
open
|
|
415
|
+
onClose={() => setIsExpanded(false)}
|
|
416
|
+
title={`${label} diagram`}
|
|
417
|
+
renderId={`${kind}-${block.id}-popout`}
|
|
418
|
+
onAskAI={handlePopoutAskAI}
|
|
419
|
+
dataAttributes={{ 'data-block-id': block.id }}
|
|
420
|
+
/>
|
|
421
|
+
</Suspense>
|
|
422
|
+
</LazyBlockBoundary>
|
|
418
423
|
)}
|
|
419
424
|
</>
|
|
420
425
|
);
|
|
@@ -11,12 +11,13 @@ export type { DiffFileTreeFile, DiffFileTreeNode, DiffFileTreeStatus } from '../
|
|
|
11
11
|
export type { DiffFileTreeDefaultExpanded } from '../hooks/useDiffFileTreeExpansion';
|
|
12
12
|
|
|
13
13
|
/*
|
|
14
|
-
* The diff file tree's presentational parts
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
14
|
+
* The diff file tree's presentational parts: the atoms below (folder row,
|
|
15
|
+
* file-row content, change letter, +/- counts, indentation) for a host's own
|
|
16
|
+
* rows, and the `DiffFileTree` component at the bottom, the read-only,
|
|
17
|
+
* embeddable tree a host drops beside its own diff list. Plannotator's code
|
|
18
|
+
* review shares `ChangeTypeLetter` and `diffFileTreeIndent` (its All files
|
|
19
|
+
* tree) but draws its own Changes rows since its aligned sidebar (see
|
|
20
|
+
* HANDOFF.md § "Diff file tree").
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
/** Left padding of a row at `depth` (px). */
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import type { Annotation, Block } from '../types';
|
|
3
|
+
import { CONTENTS_RAIL_MAX_LEVEL, tocHeadings } from '../utils/annotationHelpers';
|
|
4
|
+
import { useHeadingScrollSpy } from '../hooks/useHeadingScrollSpy';
|
|
5
|
+
import { TableOfContents } from './TableOfContents';
|
|
6
|
+
|
|
7
|
+
interface DocumentContentsProps {
|
|
8
|
+
blocks: Block[];
|
|
9
|
+
annotations: Annotation[];
|
|
10
|
+
/**
|
|
11
|
+
* The element the document scrolls in. The current heading is read from it
|
|
12
|
+
* and a click scrolls it. `null` until it has mounted.
|
|
13
|
+
*/
|
|
14
|
+
scrollViewport: HTMLElement | null;
|
|
15
|
+
/** Called with the heading's block id after a click (a sheet closes itself here). */
|
|
16
|
+
onNavigate?: (blockId: string) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A document's Contents list for a host without plan review's sidebar, on the
|
|
22
|
+
* contents rail's surface (the Inbox's sheet, where the rail yields): the same
|
|
23
|
+
* `TableOfContents` (click to scroll, the current heading highlighted) with
|
|
24
|
+
* the rail's headings (levels 1 to 4) and the rail's scroll math
|
|
25
|
+
* (`useHeadingScrollSpy`), so the list and the rail never disagree. It draws
|
|
26
|
+
* no chrome; the host places it and titles it.
|
|
27
|
+
*
|
|
28
|
+
* The host mounts it afresh (a sheet that opens), often mid-document: the
|
|
29
|
+
* current heading is read at once. The marked entry is kept in view inside the
|
|
30
|
+
* list's own scroll box (a sheet).
|
|
31
|
+
*/
|
|
32
|
+
export function DocumentContents({ blocks, annotations, scrollViewport, onNavigate, className = '' }: DocumentContentsProps) {
|
|
33
|
+
const headingIds = useMemo(
|
|
34
|
+
() => tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).map((heading) => heading.id),
|
|
35
|
+
[blocks],
|
|
36
|
+
);
|
|
37
|
+
const activeId = useHeadingScrollSpy(scrollViewport, headingIds);
|
|
38
|
+
|
|
39
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const item = wrapRef.current?.querySelector<HTMLElement>('[aria-current="location"]');
|
|
42
|
+
if (item) revealInList(item);
|
|
43
|
+
}, [activeId]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div ref={wrapRef} style={{ display: 'contents' }}>
|
|
47
|
+
<TableOfContents
|
|
48
|
+
blocks={blocks}
|
|
49
|
+
annotations={annotations}
|
|
50
|
+
activeId={activeId}
|
|
51
|
+
onNavigate={onNavigate ?? noop}
|
|
52
|
+
scrollViewport={scrollViewport}
|
|
53
|
+
maxLevel={CONTENTS_RAIL_MAX_LEVEL}
|
|
54
|
+
className={className}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Scroll the list's own scroll box (the nearest ancestor that scrolls) just
|
|
62
|
+
* enough to show `item`, at once. Never the page or the document behind it.
|
|
63
|
+
*/
|
|
64
|
+
function revealInList(item: HTMLElement) {
|
|
65
|
+
let box = item.parentElement;
|
|
66
|
+
while (box && !/(auto|scroll)/.test(getComputedStyle(box).overflowY)) box = box.parentElement;
|
|
67
|
+
if (!box || box.scrollHeight <= box.clientHeight) return;
|
|
68
|
+
const area = box.getBoundingClientRect();
|
|
69
|
+
const rect = item.getBoundingClientRect();
|
|
70
|
+
if (rect.top < area.top) box.scrollTop -= area.top - rect.top;
|
|
71
|
+
else if (rect.bottom > area.bottom) box.scrollTop += Math.min(rect.bottom - area.bottom, rect.top - area.top);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function noop() {}
|
|
@@ -42,7 +42,7 @@ export const EditorAnnotationCard: React.FC<EditorAnnotationCardProps> = ({ anno
|
|
|
42
42
|
{onDelete && (
|
|
43
43
|
<button
|
|
44
44
|
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
|
45
|
-
className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
|
|
45
|
+
className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
|
|
46
46
|
title="Delete annotation"
|
|
47
47
|
>
|
|
48
48
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|