@plannotator/ui 0.30.0 → 0.31.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/README.md +1 -0
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +62 -32
- package/components/AnnotationToolbar.tsx +28 -8
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +212 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/InlineMarkdown.tsx +3 -2
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +136 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +208 -39
- package/components/html-viewer/bridge-script.ts +319 -65
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +146 -57
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +3 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +34 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/htmlChrome.ts +20 -16
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/parser.ts +37 -2
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, useId } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import type { ImageAttachment } from '../types';
|
|
4
4
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
@@ -9,6 +9,12 @@ import { hasUnsavedCommentContent } from '../utils/commentContent';
|
|
|
9
9
|
import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
|
|
10
10
|
import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
|
|
11
11
|
import type { SkillReferenceToken } from '../utils/skillReferences';
|
|
12
|
+
import {
|
|
13
|
+
hasPrimaryCoarsePointer,
|
|
14
|
+
shouldUseExpandedComposer,
|
|
15
|
+
useVisibleViewportBounds,
|
|
16
|
+
type VisibleViewportBounds,
|
|
17
|
+
} from '../hooks/useViewportEnvironment';
|
|
12
18
|
|
|
13
19
|
export interface CommentAskAIContext {
|
|
14
20
|
kind: 'general' | 'selection';
|
|
@@ -99,19 +105,40 @@ function useCommentDraftSync(draftKey: string | undefined, text: string, images:
|
|
|
99
105
|
}, [draftKey, text, images]);
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
interface CommentPopoverPosition {
|
|
109
|
+
top: number;
|
|
110
|
+
left: number;
|
|
111
|
+
flipAbove: boolean;
|
|
112
|
+
width: number;
|
|
113
|
+
maxHeight: number;
|
|
114
|
+
requiresExpanded: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function computeCommentPopoverPosition(
|
|
118
|
+
anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
|
|
119
|
+
bounds: VisibleViewportBounds,
|
|
120
|
+
): CommentPopoverPosition {
|
|
121
|
+
const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
|
|
122
|
+
const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
|
|
123
|
+
const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
|
|
124
|
+
const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
|
|
106
125
|
|
|
107
126
|
const top = flipAbove
|
|
108
127
|
? anchorRect.top - GAP
|
|
109
128
|
: anchorRect.bottom + GAP;
|
|
110
129
|
|
|
111
130
|
let left = anchorRect.left + anchorRect.width / 2 - width / 2;
|
|
112
|
-
left = Math.max(
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
left = Math.max(bounds.left, Math.min(left, bounds.right - width));
|
|
132
|
+
const maxHeight = flipAbove ? spaceAbove : spaceBelow;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
top,
|
|
136
|
+
left,
|
|
137
|
+
flipAbove,
|
|
138
|
+
width,
|
|
139
|
+
maxHeight,
|
|
140
|
+
requiresExpanded: maxHeight < 280,
|
|
141
|
+
};
|
|
115
142
|
}
|
|
116
143
|
|
|
117
144
|
export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
@@ -137,11 +164,27 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
137
164
|
captureStrayKeys = false,
|
|
138
165
|
yieldState,
|
|
139
166
|
}) => {
|
|
140
|
-
const
|
|
167
|
+
const visibleBounds = useVisibleViewportBounds(16);
|
|
168
|
+
const coarsePointer = hasPrimaryCoarsePointer();
|
|
169
|
+
const prefersExpandedComposer = shouldUseExpandedComposer({
|
|
170
|
+
bounds: visibleBounds,
|
|
171
|
+
coarsePointer,
|
|
172
|
+
});
|
|
173
|
+
const [mode, setMode] = useState<'popover' | 'dialog'>(() =>
|
|
174
|
+
prefersExpandedComposer ? 'dialog' : 'popover'
|
|
175
|
+
);
|
|
176
|
+
// Dialog mode has two very different origins. Either the viewport PREFERS an
|
|
177
|
+
// expanded composer (phones, small windows), or the anchor simply has no room
|
|
178
|
+
// for a popover and the geometry FORCED it. Only the forced kind can bounce:
|
|
179
|
+
// collapsing recomputes the same geometry and immediately re-expands, which
|
|
180
|
+
// ate Escape and made the Collapse button a no-op. Track which one we are in.
|
|
181
|
+
const [dialogIsForced, setDialogIsForced] = useState(false);
|
|
182
|
+
// A preference-driven dialog is never the forced kind.
|
|
183
|
+
const forcedDialog = dialogIsForced && !prefersExpandedComposer;
|
|
141
184
|
const initialDraft = draftKey ? draftStore.get(draftKey) : undefined;
|
|
142
185
|
const [text, setText] = useState(initialDraft?.text ?? initialText);
|
|
143
186
|
const [images, setImages] = useState<ImageAttachment[]>(allowImages ? initialDraft?.images ?? [] : []);
|
|
144
|
-
const [position, setPosition] = useState<
|
|
187
|
+
const [position, setPosition] = useState<CommentPopoverPosition | null>(null);
|
|
145
188
|
// Direction of an open popover that has scrolled out of view, or null when on-screen.
|
|
146
189
|
const [offscreen, setOffscreen] = useState<'above' | 'below' | null>(null);
|
|
147
190
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -150,6 +193,18 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
150
193
|
const hasUnsavedContentRef = useRef(hasUnsavedContent);
|
|
151
194
|
hasUnsavedContentRef.current = hasUnsavedContent;
|
|
152
195
|
const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);
|
|
196
|
+
const openingFocusRef = useRef<HTMLElement | null>(
|
|
197
|
+
typeof document !== 'undefined'
|
|
198
|
+
&& document.activeElement instanceof HTMLElement
|
|
199
|
+
&& document.activeElement !== document.body
|
|
200
|
+
&& document.activeElement !== document.documentElement
|
|
201
|
+
? document.activeElement
|
|
202
|
+
: anchorEl ?? null,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (prefersExpandedComposer && mode === 'popover') setMode('dialog');
|
|
207
|
+
}, [mode, prefersExpandedComposer]);
|
|
153
208
|
|
|
154
209
|
useEffect(() => {
|
|
155
210
|
const nextDraft = draftKey ? draftStore.get(draftKey) : undefined;
|
|
@@ -167,23 +222,29 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
167
222
|
useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]);
|
|
168
223
|
useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);
|
|
169
224
|
|
|
170
|
-
// Track anchor position on scroll
|
|
225
|
+
// Track anchor position on scroll and observed viewport changes (popover
|
|
226
|
+
// mode only, not after user drag).
|
|
171
227
|
useEffect(() => {
|
|
172
228
|
if (mode !== 'popover' || wasDragged) return;
|
|
173
229
|
|
|
174
230
|
const update = () => {
|
|
175
231
|
const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
|
|
176
|
-
if (rect)
|
|
232
|
+
if (!rect) return;
|
|
233
|
+
const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
|
|
234
|
+
if (nextPosition.requiresExpanded) {
|
|
235
|
+
setDialogIsForced(true);
|
|
236
|
+
setMode('dialog');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
setPosition(nextPosition);
|
|
177
240
|
};
|
|
178
241
|
|
|
179
242
|
update();
|
|
180
243
|
window.addEventListener('scroll', update, true);
|
|
181
|
-
window.addEventListener('resize', update);
|
|
182
244
|
return () => {
|
|
183
245
|
window.removeEventListener('scroll', update, true);
|
|
184
|
-
window.removeEventListener('resize', update);
|
|
185
246
|
};
|
|
186
|
-
}, [anchorEl, anchorRect, mode, wasDragged]);
|
|
247
|
+
}, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
|
|
187
248
|
|
|
188
249
|
// Surface a "jump back" arrow when an open popover scrolls out of view.
|
|
189
250
|
// Re-measures whenever the popover repositions (position updates every scroll
|
|
@@ -194,14 +255,12 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
194
255
|
const el = popoverRef.current;
|
|
195
256
|
if (!el) return;
|
|
196
257
|
const rect = el.getBoundingClientRect();
|
|
197
|
-
if (rect.bottom <
|
|
198
|
-
else if (rect.top >
|
|
258
|
+
if (rect.bottom < visibleBounds.top) setOffscreen('above');
|
|
259
|
+
else if (rect.top > visibleBounds.bottom) setOffscreen('below');
|
|
199
260
|
else setOffscreen(null);
|
|
200
261
|
};
|
|
201
262
|
measure();
|
|
202
|
-
|
|
203
|
-
return () => window.removeEventListener('resize', measure);
|
|
204
|
-
}, [position, dragPosition, mode]);
|
|
263
|
+
}, [position, dragPosition, mode, visibleBounds]);
|
|
205
264
|
|
|
206
265
|
const scrollToPopover = useCallback(() => {
|
|
207
266
|
anchorEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
@@ -212,19 +271,56 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
212
271
|
// renders after `position` is measured, and WebKit fires 0ms timers ahead of
|
|
213
272
|
// that commit, so an effect keyed on mode alone can run before the textarea
|
|
214
273
|
// exists and never focus it (e.g. in WKWebView hosts like Glimpse).
|
|
274
|
+
const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
|
|
215
275
|
const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
|
|
216
276
|
textareaRef.current = el;
|
|
217
|
-
if (!el) return;
|
|
277
|
+
if (!el || !shouldAutoFocus) return;
|
|
218
278
|
setTimeout(() => {
|
|
219
279
|
if (!el.isConnected) return;
|
|
220
280
|
el.focus();
|
|
221
281
|
el.selectionStart = el.selectionEnd = el.value.length;
|
|
222
282
|
}, 0);
|
|
283
|
+
}, [shouldAutoFocus]);
|
|
284
|
+
|
|
285
|
+
// A touch-opened composer deliberately does not focus the textarea (and
|
|
286
|
+
// summon the software keyboard), but hardware-keyboard users still need a
|
|
287
|
+
// coherent focus target and Escape dismissal.
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (mode !== 'dialog' || shouldAutoFocus) return;
|
|
290
|
+
const timer = setTimeout(() => {
|
|
291
|
+
if (!popoverRef.current?.isConnected) return;
|
|
292
|
+
popoverRef.current.focus({ preventScroll: true });
|
|
293
|
+
}, 0);
|
|
294
|
+
return () => clearTimeout(timer);
|
|
295
|
+
}, [mode, shouldAutoFocus]);
|
|
296
|
+
|
|
297
|
+
const restoreOpeningFocus = useCallback(() => {
|
|
298
|
+
const target = openingFocusRef.current;
|
|
299
|
+
requestAnimationFrame(() => {
|
|
300
|
+
if (!target?.isConnected) return;
|
|
301
|
+
target.focus({ preventScroll: true });
|
|
302
|
+
});
|
|
223
303
|
}, []);
|
|
224
304
|
|
|
305
|
+
const handleClose = useCallback(
|
|
306
|
+
(focusDisposition: 'restore-opener' | 'preserve-pointer-target' = 'restore-opener') => {
|
|
307
|
+
if (draftKey) {
|
|
308
|
+
if (hasUnsavedCommentContent(text, allowImages ? images : [])) {
|
|
309
|
+
draftStore.set(draftKey, { text, images: allowImages ? images : [] });
|
|
310
|
+
} else {
|
|
311
|
+
draftStore.delete(draftKey);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
onClose();
|
|
315
|
+
if (focusDisposition === 'restore-opener') restoreOpeningFocus();
|
|
316
|
+
},
|
|
317
|
+
[allowImages, draftKey, images, onClose, restoreOpeningFocus, text],
|
|
318
|
+
);
|
|
319
|
+
|
|
225
320
|
// Click-outside for popover mode
|
|
226
321
|
useEffect(() => {
|
|
227
322
|
if (mode !== 'popover') return;
|
|
323
|
+
const shiftSelectionActive = Boolean(targetChips?.length);
|
|
228
324
|
|
|
229
325
|
const handlePointerDown = (e: PointerEvent) => {
|
|
230
326
|
const target = e.target as Node | null;
|
|
@@ -234,12 +330,16 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
234
330
|
const el = target as HTMLElement;
|
|
235
331
|
if (el.closest?.('[data-popover-layer]')) return;
|
|
236
332
|
if (hasUnsavedContentRef.current) return;
|
|
237
|
-
|
|
333
|
+
// A same-document multi-select target receives pointerdown before click.
|
|
334
|
+
// Preserve the existing draft so the following Shift-click can extend
|
|
335
|
+
// it instead of silently replacing it with a new one.
|
|
336
|
+
if (shiftSelectionActive && e.shiftKey) return;
|
|
337
|
+
handleClose('preserve-pointer-target');
|
|
238
338
|
};
|
|
239
339
|
|
|
240
340
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
241
341
|
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
242
|
-
}, [mode,
|
|
342
|
+
}, [handleClose, mode, targetChips?.length]);
|
|
243
343
|
|
|
244
344
|
// Focus choreography (multi-select): after a shift-click adds/removes a
|
|
245
345
|
// target, focus returns to the textarea so typing continues uninterrupted.
|
|
@@ -347,8 +447,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
347
447
|
if (hasUnsavedContent || canSubmitEmpty) {
|
|
348
448
|
if (draftKey) draftStore.delete(draftKey);
|
|
349
449
|
onSubmit(text, allowImages && images.length > 0 ? images : undefined);
|
|
450
|
+
restoreOpeningFocus();
|
|
350
451
|
}
|
|
351
|
-
}, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent]);
|
|
452
|
+
}, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus]);
|
|
352
453
|
|
|
353
454
|
const handleAskAI = useCallback(async () => {
|
|
354
455
|
const question = text.trim();
|
|
@@ -382,15 +483,24 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
382
483
|
textareaRef,
|
|
383
484
|
enabled: skillReferences,
|
|
384
485
|
});
|
|
486
|
+
const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`;
|
|
487
|
+
const activeSkillOptionId =
|
|
488
|
+
skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined
|
|
489
|
+
? undefined
|
|
490
|
+
: `${skillListboxId}-option-${skillAc.menu.activeIndex}`;
|
|
385
491
|
|
|
386
492
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
387
493
|
if (skillAc.onKeyDown(e)) return;
|
|
388
494
|
if (e.key === 'Escape') {
|
|
389
495
|
e.stopPropagation();
|
|
390
496
|
if (mode === 'dialog') {
|
|
391
|
-
|
|
497
|
+
// Collapsing a forced dialog is geometrically impossible, so Escape
|
|
498
|
+
// closes it (draft-preserving) instead of being swallowed by the
|
|
499
|
+
// re-expand.
|
|
500
|
+
if (prefersExpandedComposer || forcedDialog) handleClose();
|
|
501
|
+
else setMode('popover');
|
|
392
502
|
} else {
|
|
393
|
-
|
|
503
|
+
handleClose();
|
|
394
504
|
}
|
|
395
505
|
return;
|
|
396
506
|
}
|
|
@@ -413,9 +523,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
413
523
|
|
|
414
524
|
if (mode === 'dialog') {
|
|
415
525
|
return createPortal(
|
|
416
|
-
<div
|
|
526
|
+
<div
|
|
527
|
+
data-comment-popover="true"
|
|
528
|
+
className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
|
|
529
|
+
>
|
|
417
530
|
{/* Backdrop */}
|
|
418
|
-
<
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
aria-label="Dismiss comment"
|
|
534
|
+
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
|
535
|
+
onClick={() => handleClose()}
|
|
536
|
+
/>
|
|
419
537
|
|
|
420
538
|
{/* Dialog card */}
|
|
421
539
|
{/* The expanded dialog deliberately does not yield: it is an explicit
|
|
@@ -424,11 +542,21 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
424
542
|
meaningless there. */}
|
|
425
543
|
<div
|
|
426
544
|
ref={popoverRef}
|
|
427
|
-
|
|
545
|
+
role="dialog"
|
|
546
|
+
aria-modal="true"
|
|
547
|
+
aria-label={isGlobal ? 'Global comment' : 'Comment'}
|
|
548
|
+
tabIndex={-1}
|
|
549
|
+
className="relative w-full max-w-xl max-h-full min-h-0 bg-popover border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden"
|
|
428
550
|
style={{
|
|
429
551
|
animation: 'comment-dialog-in 0.15s ease-out',
|
|
430
552
|
}}
|
|
431
553
|
onPointerDown={(e) => e.stopPropagation()}
|
|
554
|
+
onKeyDown={(e) => {
|
|
555
|
+
if (e.key !== 'Escape') return;
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
e.stopPropagation();
|
|
558
|
+
handleClose();
|
|
559
|
+
}}
|
|
432
560
|
>
|
|
433
561
|
<style>{`
|
|
434
562
|
@keyframes comment-dialog-in {
|
|
@@ -443,15 +571,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
443
571
|
{headerLabel}
|
|
444
572
|
</span>
|
|
445
573
|
<div className="flex items-center gap-1">
|
|
574
|
+
{!prefersExpandedComposer && !forcedDialog && (
|
|
575
|
+
<button
|
|
576
|
+
onClick={() => { setDialogIsForced(false); setMode('popover'); }}
|
|
577
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
578
|
+
title="Collapse"
|
|
579
|
+
>
|
|
580
|
+
<CollapseIcon />
|
|
581
|
+
</button>
|
|
582
|
+
)}
|
|
446
583
|
<button
|
|
447
|
-
onClick={() =>
|
|
448
|
-
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
449
|
-
title="Collapse"
|
|
450
|
-
>
|
|
451
|
-
<CollapseIcon />
|
|
452
|
-
</button>
|
|
453
|
-
<button
|
|
454
|
-
onClick={onClose}
|
|
584
|
+
onClick={() => handleClose()}
|
|
455
585
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
456
586
|
title="Close"
|
|
457
587
|
>
|
|
@@ -463,9 +593,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
463
593
|
{chipsRow}
|
|
464
594
|
|
|
465
595
|
{/* Textarea */}
|
|
466
|
-
<div className="relative px-4 py-3 flex-1">
|
|
596
|
+
<div className="relative px-4 py-3 min-h-0 flex-1 overflow-y-auto">
|
|
467
597
|
{skillAc.menu && (
|
|
468
598
|
<SkillReferenceMenu
|
|
599
|
+
id={skillListboxId}
|
|
469
600
|
items={skillAc.menu.items}
|
|
470
601
|
activeIndex={skillAc.menu.activeIndex}
|
|
471
602
|
onSelect={skillAc.select}
|
|
@@ -478,16 +609,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
478
609
|
onKeyDown={handleKeyDown}
|
|
479
610
|
onSelectCaret={skillAc.onSelect}
|
|
480
611
|
placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
|
|
481
|
-
sizeClassName="min-h-
|
|
612
|
+
sizeClassName="min-h-32 max-h-full"
|
|
482
613
|
skillReferences={skillReferences}
|
|
483
614
|
tokens={skillAc.referenceTokens}
|
|
615
|
+
listboxId={skillListboxId}
|
|
616
|
+
listboxOpen={skillAc.menu !== null}
|
|
617
|
+
activeOptionId={activeSkillOptionId}
|
|
484
618
|
/>
|
|
485
619
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
486
620
|
</div>
|
|
487
621
|
|
|
488
622
|
{/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
|
|
489
|
-
<div className="flex flex-row-reverse items-center justify-between px-4 py-3 border-t border-border/50">
|
|
490
|
-
<div className="flex flex-row-reverse items-center gap-3">
|
|
623
|
+
<div className="flex flex-row-reverse flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-border/50">
|
|
624
|
+
<div className="flex flex-row-reverse flex-wrap items-center gap-3">
|
|
491
625
|
<button
|
|
492
626
|
onClick={handleSubmit}
|
|
493
627
|
disabled={!canSubmit}
|
|
@@ -495,7 +629,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
495
629
|
>
|
|
496
630
|
{isGlobal ? 'Add' : 'Save'}
|
|
497
631
|
</button>
|
|
498
|
-
|
|
632
|
+
{!coarsePointer && (
|
|
633
|
+
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
634
|
+
)}
|
|
499
635
|
{onAskAI && (
|
|
500
636
|
<button
|
|
501
637
|
onClick={handleAskAI}
|
|
@@ -547,11 +683,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
547
683
|
data-comment-popover="true"
|
|
548
684
|
className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
|
|
549
685
|
style={dragPosition
|
|
550
|
-
? {
|
|
686
|
+
? {
|
|
687
|
+
top: dragPosition.top,
|
|
688
|
+
left: dragPosition.left,
|
|
689
|
+
width: position.width,
|
|
690
|
+
maxHeight: visibleBounds.height,
|
|
691
|
+
overflowY: 'auto',
|
|
692
|
+
}
|
|
551
693
|
: {
|
|
552
694
|
top: position.top,
|
|
553
695
|
left: position.left,
|
|
554
696
|
width: position.width,
|
|
697
|
+
maxHeight: position.maxHeight,
|
|
698
|
+
overflowY: 'auto',
|
|
555
699
|
...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
|
|
556
700
|
animation: position.flipAbove
|
|
557
701
|
? 'comment-popover-in-above 0.15s ease-out'
|
|
@@ -579,14 +723,14 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
579
723
|
</span>
|
|
580
724
|
<div className="flex items-center gap-1">
|
|
581
725
|
<button
|
|
582
|
-
onClick={() => setMode('dialog')}
|
|
726
|
+
onClick={() => { setDialogIsForced(false); setMode('dialog'); }}
|
|
583
727
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
584
728
|
title="Expand"
|
|
585
729
|
>
|
|
586
730
|
<ExpandIcon />
|
|
587
731
|
</button>
|
|
588
732
|
<button
|
|
589
|
-
onClick={
|
|
733
|
+
onClick={() => handleClose()}
|
|
590
734
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
591
735
|
title="Close"
|
|
592
736
|
>
|
|
@@ -601,6 +745,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
601
745
|
<div className="relative px-3 py-2">
|
|
602
746
|
{skillAc.menu && (
|
|
603
747
|
<SkillReferenceMenu
|
|
748
|
+
id={skillListboxId}
|
|
604
749
|
items={skillAc.menu.items}
|
|
605
750
|
activeIndex={skillAc.menu.activeIndex}
|
|
606
751
|
onSelect={skillAc.select}
|
|
@@ -616,6 +761,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
616
761
|
sizeClassName="max-h-64 min-h-[4.5rem]"
|
|
617
762
|
skillReferences={skillReferences}
|
|
618
763
|
tokens={skillAc.referenceTokens}
|
|
764
|
+
listboxId={skillListboxId}
|
|
765
|
+
listboxOpen={skillAc.menu !== null}
|
|
766
|
+
activeOptionId={activeSkillOptionId}
|
|
619
767
|
/>
|
|
620
768
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
621
769
|
</div>
|
|
@@ -630,7 +778,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
630
778
|
>
|
|
631
779
|
{isGlobal ? 'Add' : 'Save'}
|
|
632
780
|
</button>
|
|
633
|
-
|
|
781
|
+
{!coarsePointer && (
|
|
782
|
+
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
783
|
+
)}
|
|
634
784
|
{onAskAI && (
|
|
635
785
|
<button
|
|
636
786
|
onClick={handleAskAI}
|
|
@@ -682,6 +832,10 @@ interface ComposerTextareaProps {
|
|
|
682
832
|
tokens: SkillReferenceToken[];
|
|
683
833
|
/** Off → render the plain pre-feature textarea, byte-for-byte. */
|
|
684
834
|
skillReferences: boolean;
|
|
835
|
+
/** ARIA relationship to the skill-reference listbox. */
|
|
836
|
+
listboxId: string;
|
|
837
|
+
listboxOpen: boolean;
|
|
838
|
+
activeOptionId?: string;
|
|
685
839
|
}
|
|
686
840
|
|
|
687
841
|
/**
|
|
@@ -706,6 +860,9 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
706
860
|
textareaRef,
|
|
707
861
|
tokens,
|
|
708
862
|
skillReferences,
|
|
863
|
+
listboxId,
|
|
864
|
+
listboxOpen,
|
|
865
|
+
activeOptionId,
|
|
709
866
|
}) => {
|
|
710
867
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
711
868
|
const [composing, setComposing] = useState(false);
|
|
@@ -735,6 +892,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
735
892
|
if (!skillReferences) {
|
|
736
893
|
return (
|
|
737
894
|
<textarea
|
|
895
|
+
data-pn-mobile-editable="true"
|
|
738
896
|
ref={attachRef}
|
|
739
897
|
value={value}
|
|
740
898
|
onChange={onChange}
|
|
@@ -780,6 +938,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
780
938
|
ref={overlayRef}
|
|
781
939
|
aria-hidden="true"
|
|
782
940
|
data-skill-ref-overlay="true"
|
|
941
|
+
data-pn-mobile-editable-mirror="true"
|
|
783
942
|
className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
|
|
784
943
|
style={composing ? { visibility: 'hidden' } : undefined}
|
|
785
944
|
>
|
|
@@ -787,6 +946,13 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
787
946
|
{'\n'}
|
|
788
947
|
</div>
|
|
789
948
|
<textarea
|
|
949
|
+
data-pn-mobile-editable="true"
|
|
950
|
+
aria-label={placeholder}
|
|
951
|
+
aria-autocomplete="list"
|
|
952
|
+
aria-haspopup="listbox"
|
|
953
|
+
aria-controls={listboxOpen ? listboxId : undefined}
|
|
954
|
+
aria-owns={listboxOpen ? listboxId : undefined}
|
|
955
|
+
aria-activedescendant={activeOptionId}
|
|
790
956
|
ref={attachRef}
|
|
791
957
|
value={value}
|
|
792
958
|
onChange={onChange}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Reusable confirmation dialog component
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import React, { useEffect } from 'react';
|
|
5
|
+
import React, { useEffect, useId, useRef } from 'react';
|
|
6
|
+
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
|
6
7
|
|
|
7
8
|
export interface ConfirmDialogProps {
|
|
8
9
|
isOpen: boolean;
|
|
@@ -31,6 +32,10 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
31
32
|
showCancel = false,
|
|
32
33
|
wide = false,
|
|
33
34
|
}) => {
|
|
35
|
+
const descriptionId = useId();
|
|
36
|
+
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
37
|
+
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
|
38
|
+
|
|
34
39
|
useEffect(() => {
|
|
35
40
|
if (!isOpen) return;
|
|
36
41
|
const handleKey = (event: KeyboardEvent) => {
|
|
@@ -40,11 +45,6 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
40
45
|
if (onConfirm) onConfirm();
|
|
41
46
|
else onClose();
|
|
42
47
|
}
|
|
43
|
-
if (event.key === 'Escape') {
|
|
44
|
-
event.preventDefault();
|
|
45
|
-
event.stopPropagation();
|
|
46
|
-
onClose();
|
|
47
|
-
}
|
|
48
48
|
};
|
|
49
49
|
window.addEventListener('keydown', handleKey);
|
|
50
50
|
return () => window.removeEventListener('keydown', handleKey);
|
|
@@ -58,8 +58,8 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
const buttonColors = {
|
|
61
|
-
info: 'bg-primary text-primary-foreground hover:opacity-90',
|
|
62
|
-
warning: 'bg-warning text-warning-foreground hover:opacity-90',
|
|
61
|
+
info: 'bg-primary text-primary-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
|
|
62
|
+
warning: 'bg-warning text-warning-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
const icons = {
|
|
@@ -76,40 +76,54 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
76
76
|
};
|
|
77
77
|
|
|
78
78
|
return (
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
<Dialog
|
|
80
|
+
open={isOpen}
|
|
81
|
+
disablePointerDismissal
|
|
82
|
+
onOpenChange={(open) => {
|
|
83
|
+
if (!open) onClose();
|
|
84
|
+
}}
|
|
82
85
|
>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
aria-
|
|
86
|
-
|
|
86
|
+
<DialogContent
|
|
87
|
+
hideClose
|
|
88
|
+
aria-describedby={descriptionId}
|
|
89
|
+
initialFocus={() => (showCancel ? cancelButtonRef.current : confirmButtonRef.current)}
|
|
90
|
+
backdropClassName="bg-background/80 backdrop-blur-sm"
|
|
91
|
+
className={`bg-card text-foreground rounded-xl shadow-2xl p-6 transition-none ${wide ? 'max-w-md' : 'max-w-sm'}`}
|
|
92
|
+
data-plannotator-confirm-dialog="true"
|
|
87
93
|
>
|
|
88
94
|
<div className="flex items-center gap-3 mb-4">
|
|
89
|
-
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
|
|
95
|
+
<div className={`w-10 h-10 shrink-0 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
|
|
90
96
|
{icons[variant]}
|
|
91
97
|
</div>
|
|
92
|
-
<
|
|
98
|
+
<DialogTitle className="font-semibold tracking-normal">{title}</DialogTitle>
|
|
93
99
|
</div>
|
|
94
|
-
<div
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{subMessage && (
|
|
98
|
-
<div className="text-xs text-muted-foreground mb-6">
|
|
99
|
-
{subMessage}
|
|
100
|
+
<div id={descriptionId}>
|
|
101
|
+
<div className="text-sm text-muted-foreground mb-2">
|
|
102
|
+
{message}
|
|
100
103
|
</div>
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
{subMessage && (
|
|
105
|
+
<div className="text-xs text-muted-foreground mb-6">
|
|
106
|
+
{subMessage}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{!subMessage && <div className="mb-4" />}
|
|
110
|
+
</div>
|
|
103
111
|
<div className="flex justify-end gap-2">
|
|
104
112
|
{showCancel && (
|
|
105
113
|
<button
|
|
114
|
+
ref={cancelButtonRef}
|
|
115
|
+
type="button"
|
|
116
|
+
data-pn-touch-target="true"
|
|
106
117
|
onClick={onClose}
|
|
107
|
-
className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-opacity"
|
|
118
|
+
className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted/80 transition-opacity"
|
|
108
119
|
>
|
|
109
120
|
{cancelText}
|
|
110
121
|
</button>
|
|
111
122
|
)}
|
|
112
123
|
<button
|
|
124
|
+
ref={confirmButtonRef}
|
|
125
|
+
type="button"
|
|
126
|
+
data-pn-touch-target="true"
|
|
113
127
|
onClick={() => {
|
|
114
128
|
if (onConfirm) {
|
|
115
129
|
onConfirm();
|
|
@@ -122,7 +136,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
122
136
|
{confirmText}
|
|
123
137
|
</button>
|
|
124
138
|
</div>
|
|
125
|
-
</
|
|
126
|
-
</
|
|
139
|
+
</DialogContent>
|
|
140
|
+
</Dialog>
|
|
127
141
|
);
|
|
128
142
|
};
|
|
@@ -4,6 +4,7 @@ import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePa
|
|
|
4
4
|
import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
|
|
5
5
|
import { useFenceTheme } from "../hooks/useFenceTheme";
|
|
6
6
|
import { transformPlainText } from "../utils/inlineTransforms";
|
|
7
|
+
import { hasLinkedDocExtension } from "../utils/markdownExtensions";
|
|
7
8
|
import { getImageSrc } from "./ImageThumbnail";
|
|
8
9
|
import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
|
|
9
10
|
import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
|
|
@@ -877,7 +878,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
877
878
|
// targets are opaque doc ids, not paths. null → today's rendering.
|
|
878
879
|
const resolution = resolveLinkedDoc?.(target) ?? null;
|
|
879
880
|
const display = resolution?.label || storedLabel || target;
|
|
880
|
-
const targetPath =
|
|
881
|
+
const targetPath = hasLinkedDocExtension(target)
|
|
881
882
|
? target
|
|
882
883
|
: `${target}.md`;
|
|
883
884
|
|
|
@@ -1009,7 +1010,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
1009
1010
|
// Fragment is stripped before handing to onOpenLinkedDoc (overlay has
|
|
1010
1011
|
// no anchor-scroll support today).
|
|
1011
1012
|
const isLocalDoc =
|
|
1012
|
-
|
|
1013
|
+
hasLinkedDocExtension(linkUrl, { allowFragment: true }) &&
|
|
1013
1014
|
!linkUrl.startsWith("http://") &&
|
|
1014
1015
|
!linkUrl.startsWith("https://");
|
|
1015
1016
|
const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl);
|