@plannotator/ui 0.39.0 → 0.40.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 +99 -10
- package/README.md +3 -3
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +96 -9
- package/components/html-viewer/bridge-script.ts +96 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +21 -6
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -8,6 +8,8 @@ import { OverlayScrollArea } from './OverlayScrollArea';
|
|
|
8
8
|
import { Button } from './ui/button';
|
|
9
9
|
import { cn } from '../lib/utils';
|
|
10
10
|
import { resolveReplyParents, resolveThreadRootTimestamps } from '@plannotator/core/annotation-threads';
|
|
11
|
+
import { ROOT_DOCUMENT_GROUP_KEY } from '../utils/annotationScope';
|
|
12
|
+
import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotationScope';
|
|
11
13
|
|
|
12
14
|
// Card type-word colors. Deletion uses `destructive` (reliably red on every
|
|
13
15
|
// theme, matching the in-document .deletion highlight). Comment uses the
|
|
@@ -137,6 +139,26 @@ interface PanelProps {
|
|
|
137
139
|
* HTML viewer's onUnanchoredChange report after a refresh). Matching
|
|
138
140
|
* cards show a small "Unanchored" chip. Absent: no chip, DOM unchanged. */
|
|
139
141
|
unanchoredIds?: ReadonlySet<string>;
|
|
142
|
+
/**
|
|
143
|
+
* Multi-document sessions (annotate folder sessions, and any session whose
|
|
144
|
+
* linked documents carry feedback): which documents the timeline shows.
|
|
145
|
+
* Supplying this together with `onAnnotationScopeChange` turns on the
|
|
146
|
+
* `This file | All files` toggle and the grouped cross-file view. Omitting
|
|
147
|
+
* them — as every host that has not opted in does — leaves the panel exactly
|
|
148
|
+
* as it was, including the legacy `+N in M other files` affordance.
|
|
149
|
+
*/
|
|
150
|
+
annotationScope?: AnnotationScope;
|
|
151
|
+
onAnnotationScopeChange?: (scope: AnnotationScope) => void;
|
|
152
|
+
/** One group per document carrying feedback, open document first (see
|
|
153
|
+
* `groupAnnotationsByDocument`). Only read while the scope is `all`. */
|
|
154
|
+
documentGroups?: readonly AnnotationDocumentGroup[];
|
|
155
|
+
/** Select/jump to an annotation by its owning document. A card in another
|
|
156
|
+
* document is the host's cue to navigate there first. */
|
|
157
|
+
onSelectInDocument?: (path: string, id: string) => void;
|
|
158
|
+
/** Delete an annotation from its owning document's store. */
|
|
159
|
+
onDeleteInDocument?: (path: string, id: string) => void;
|
|
160
|
+
/** Edit an annotation in its owning document's store. */
|
|
161
|
+
onEditInDocument?: (path: string, id: string, updates: Partial<Annotation>) => void;
|
|
140
162
|
}
|
|
141
163
|
|
|
142
164
|
export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
@@ -164,6 +186,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
164
186
|
readOnly = false,
|
|
165
187
|
presentation = 'panel',
|
|
166
188
|
unanchoredIds,
|
|
189
|
+
annotationScope,
|
|
190
|
+
onAnnotationScopeChange,
|
|
191
|
+
documentGroups,
|
|
192
|
+
onSelectInDocument,
|
|
193
|
+
onDeleteInDocument,
|
|
194
|
+
onEditInDocument,
|
|
167
195
|
}) => {
|
|
168
196
|
const isMobile = useIsMobile();
|
|
169
197
|
const embedded = presentation === 'embedded';
|
|
@@ -190,6 +218,54 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
190
218
|
});
|
|
191
219
|
const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
|
|
192
220
|
|
|
221
|
+
// --- Cross-file scope (opt-in: a host that passes neither prop is unchanged) ---
|
|
222
|
+
const scopeEnabled = annotationScope !== undefined && onAnnotationScopeChange !== undefined;
|
|
223
|
+
const scope: AnnotationScope = scopeEnabled ? annotationScope! : 'current';
|
|
224
|
+
const groups = documentGroups ?? [];
|
|
225
|
+
const otherGroups = groups.filter((group) => !group.isCurrent);
|
|
226
|
+
const otherGroupCount = otherGroups.reduce((n, group) => n + group.annotations.length, 0);
|
|
227
|
+
const groupedTotal = groups.reduce((n, group) => n + group.annotations.length, 0);
|
|
228
|
+
const showGroups = scope === 'all' && groups.length > 0;
|
|
229
|
+
const headerCount = showGroups
|
|
230
|
+
? groupedTotal + codeAnnotations.length + (editorAnnotations?.length ?? 0)
|
|
231
|
+
: totalCount;
|
|
232
|
+
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(() => new Set());
|
|
233
|
+
const toggleGroup = (path: string) => {
|
|
234
|
+
setCollapsedGroups((prev) => {
|
|
235
|
+
const next = new Set(prev);
|
|
236
|
+
if (next.has(path)) next.delete(path);
|
|
237
|
+
else next.add(path);
|
|
238
|
+
return next;
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
const scopeToggle = scopeEnabled ? (
|
|
242
|
+
<div
|
|
243
|
+
data-annotation-scope-toggle="true"
|
|
244
|
+
role="group"
|
|
245
|
+
aria-label="Annotation scope"
|
|
246
|
+
className="flex items-center gap-0.5 rounded-md bg-surface-1/60 p-0.5"
|
|
247
|
+
>
|
|
248
|
+
{(['current', 'all'] as const).map((value) => (
|
|
249
|
+
<button
|
|
250
|
+
key={value}
|
|
251
|
+
type="button"
|
|
252
|
+
data-pn-touch-target="true"
|
|
253
|
+
data-annotation-scope={value}
|
|
254
|
+
aria-pressed={scope === value}
|
|
255
|
+
onClick={() => onAnnotationScopeChange!(value)}
|
|
256
|
+
className={cn(
|
|
257
|
+
'cursor-pointer rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors',
|
|
258
|
+
scope === value
|
|
259
|
+
? 'bg-card text-foreground shadow-sm'
|
|
260
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
261
|
+
)}
|
|
262
|
+
>
|
|
263
|
+
{value === 'current' ? 'This file' : 'All files'}
|
|
264
|
+
</button>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
) : null;
|
|
268
|
+
|
|
193
269
|
// Scroll selected annotation card into view
|
|
194
270
|
useEffect(() => {
|
|
195
271
|
if (!selectedId || !listRef.current) return;
|
|
@@ -218,9 +294,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
218
294
|
<h2 className="text-xs font-medium text-foreground">
|
|
219
295
|
Annotations
|
|
220
296
|
</h2>
|
|
221
|
-
{
|
|
297
|
+
{headerCount > 0 && (
|
|
222
298
|
<span className="flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[10px] font-medium tabular-nums text-primary">
|
|
223
|
-
{
|
|
299
|
+
{headerCount}
|
|
224
300
|
</span>
|
|
225
301
|
)}
|
|
226
302
|
</div>
|
|
@@ -237,19 +313,39 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
237
313
|
</button>
|
|
238
314
|
)}
|
|
239
315
|
</div>
|
|
240
|
-
{
|
|
241
|
-
<
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
316
|
+
{scopeEnabled ? (
|
|
317
|
+
<div className="flex items-center justify-between gap-2 px-3 pb-2">
|
|
318
|
+
{scopeToggle}
|
|
319
|
+
{otherGroupCount > 0 && scope === 'current' && (
|
|
320
|
+
<span className="truncate text-[10px] text-muted-foreground/70">
|
|
321
|
+
+{otherGroupCount} elsewhere
|
|
322
|
+
</span>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
) : (
|
|
326
|
+
otherFileAnnotations && otherFileAnnotations.count > 0 && (
|
|
327
|
+
<button
|
|
328
|
+
onClick={onOtherFileAnnotationsClick}
|
|
329
|
+
className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
|
|
330
|
+
title="Show annotated files in sidebar"
|
|
331
|
+
>
|
|
332
|
+
+{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
|
|
333
|
+
</button>
|
|
334
|
+
)
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{embedded && scopeEnabled && (
|
|
340
|
+
<div className="flex min-h-11 flex-shrink-0 items-center justify-between gap-2 border-b border-border/50 px-3">
|
|
341
|
+
{scopeToggle}
|
|
342
|
+
{otherGroupCount > 0 && scope === 'current' && (
|
|
343
|
+
<span className="truncate text-[10px] text-muted-foreground/70">+{otherGroupCount} elsewhere</span>
|
|
248
344
|
)}
|
|
249
345
|
</div>
|
|
250
346
|
)}
|
|
251
347
|
|
|
252
|
-
{embedded && otherFileAnnotations && otherFileAnnotations.count > 0 && (
|
|
348
|
+
{embedded && !scopeEnabled && otherFileAnnotations && otherFileAnnotations.count > 0 && (
|
|
253
349
|
<button
|
|
254
350
|
type="button"
|
|
255
351
|
data-pn-touch-target="true"
|
|
@@ -267,7 +363,131 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
267
363
|
{directEdits?.map((item) => (
|
|
268
364
|
<DirectEditsCard key={item.id} {...item} onDiscard={readOnly ? undefined : item.onDiscard} />
|
|
269
365
|
))}
|
|
270
|
-
{
|
|
366
|
+
{showGroups ? (
|
|
367
|
+
<>
|
|
368
|
+
{groups.map((group) => {
|
|
369
|
+
const collapsed = collapsedGroups.has(group.path);
|
|
370
|
+
const sorted = [...group.annotations].sort((a, b) => a.createdA - b.createdA);
|
|
371
|
+
const threaded = threadReplies(sorted);
|
|
372
|
+
return (
|
|
373
|
+
<section key={group.path} data-annotation-group={group.path}>
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
data-pn-touch-target="true"
|
|
377
|
+
onClick={() => toggleGroup(group.path)}
|
|
378
|
+
aria-expanded={!collapsed}
|
|
379
|
+
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-surface-1/50"
|
|
380
|
+
>
|
|
381
|
+
<svg
|
|
382
|
+
className={cn('h-2.5 w-2.5 flex-shrink-0 text-muted-foreground/60 transition-transform', collapsed ? '' : 'rotate-90')}
|
|
383
|
+
fill="none"
|
|
384
|
+
viewBox="0 0 24 24"
|
|
385
|
+
stroke="currentColor"
|
|
386
|
+
strokeWidth={3}
|
|
387
|
+
>
|
|
388
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
389
|
+
</svg>
|
|
390
|
+
<span
|
|
391
|
+
className={cn('min-w-0 flex-1 truncate font-mono text-[10px]', group.isCurrent ? 'text-foreground' : 'text-muted-foreground')}
|
|
392
|
+
// The pathless root document (plan review) is keyed by a
|
|
393
|
+
// sentinel, which is not something to show a reader.
|
|
394
|
+
title={group.path === ROOT_DOCUMENT_GROUP_KEY ? group.label : group.path}
|
|
395
|
+
>
|
|
396
|
+
{group.label}
|
|
397
|
+
</span>
|
|
398
|
+
{group.isCurrent && (
|
|
399
|
+
<span className="flex-shrink-0 text-[9px] text-muted-foreground/50">open</span>
|
|
400
|
+
)}
|
|
401
|
+
<span className="flex h-[16px] min-w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[9px] font-medium tabular-nums text-primary">
|
|
402
|
+
{group.annotations.length}
|
|
403
|
+
</span>
|
|
404
|
+
</button>
|
|
405
|
+
{!collapsed && (
|
|
406
|
+
<div className="mt-0.5 flex flex-col gap-1.5">
|
|
407
|
+
{threaded.map(({ annotation, isReply }) => {
|
|
408
|
+
const card = (
|
|
409
|
+
<AnnotationCard
|
|
410
|
+
annotation={annotation}
|
|
411
|
+
isSelected={selectedId === annotation.id}
|
|
412
|
+
isMe={isCurrentUser(annotation.author)}
|
|
413
|
+
onSelect={() => (group.isCurrent
|
|
414
|
+
? onSelect(annotation.id)
|
|
415
|
+
: onSelectInDocument?.(group.path, annotation.id))}
|
|
416
|
+
onDelete={() => (group.isCurrent
|
|
417
|
+
? onDelete(annotation.id)
|
|
418
|
+
: onDeleteInDocument?.(group.path, annotation.id))}
|
|
419
|
+
onEdit={group.isCurrent
|
|
420
|
+
? (onEdit ? (updates: Partial<Annotation>) => onEdit(annotation.id, updates) : undefined)
|
|
421
|
+
: (onEditInDocument ? (updates: Partial<Annotation>) => onEditInDocument(group.path, annotation.id, updates) : undefined)}
|
|
422
|
+
readOnly={readOnly}
|
|
423
|
+
footer={group.isCurrent ? renderCardFooter?.(annotation) : undefined}
|
|
424
|
+
unanchored={group.isCurrent ? (unanchoredIds?.has(annotation.id) ?? false) : false}
|
|
425
|
+
/>
|
|
426
|
+
);
|
|
427
|
+
return isReply ? (
|
|
428
|
+
<div key={annotation.id} data-annotation-reply="true" className="ml-3 border-l-2 border-border/40 pl-1.5">
|
|
429
|
+
{card}
|
|
430
|
+
</div>
|
|
431
|
+
) : (
|
|
432
|
+
<React.Fragment key={annotation.id}>{card}</React.Fragment>
|
|
433
|
+
);
|
|
434
|
+
})}
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
</section>
|
|
438
|
+
);
|
|
439
|
+
})}
|
|
440
|
+
{sortedCodeAnnotations.length > 0 && (
|
|
441
|
+
<>
|
|
442
|
+
<div className="flex items-center gap-2 pt-2 pb-1">
|
|
443
|
+
<div className="flex-1 border-t border-border/30" />
|
|
444
|
+
<span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Code</span>
|
|
445
|
+
<div className="flex-1 border-t border-border/30" />
|
|
446
|
+
</div>
|
|
447
|
+
{sortedCodeAnnotations.map((annotation) => (
|
|
448
|
+
<CodeAnnotationCard
|
|
449
|
+
key={annotation.id}
|
|
450
|
+
annotation={annotation}
|
|
451
|
+
isSelected={selectedId === annotation.id}
|
|
452
|
+
isMe={isCurrentUser(annotation.author)}
|
|
453
|
+
onSelect={() => onSelectCodeAnnotation?.(annotation.id)}
|
|
454
|
+
onDelete={() => onDeleteCodeAnnotation?.(annotation.id)}
|
|
455
|
+
onEdit={onEditCodeAnnotation ? (updates: Partial<CodeAnnotation>) => onEditCodeAnnotation(annotation.id, updates) : undefined}
|
|
456
|
+
readOnly={readOnly}
|
|
457
|
+
/>
|
|
458
|
+
))}
|
|
459
|
+
</>
|
|
460
|
+
)}
|
|
461
|
+
{editorAnnotations && editorAnnotations.length > 0 && (
|
|
462
|
+
<>
|
|
463
|
+
<div className="flex items-center gap-2 pt-2 pb-1">
|
|
464
|
+
<div className="flex-1 border-t border-border/30" />
|
|
465
|
+
<span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Editor</span>
|
|
466
|
+
<div className="flex-1 border-t border-border/30" />
|
|
467
|
+
</div>
|
|
468
|
+
{editorAnnotations.map(ann => (
|
|
469
|
+
<EditorAnnotationCard
|
|
470
|
+
key={ann.id}
|
|
471
|
+
annotation={ann}
|
|
472
|
+
onDelete={readOnly ? undefined : () => onDeleteEditorAnnotation?.(ann.id)}
|
|
473
|
+
/>
|
|
474
|
+
))}
|
|
475
|
+
</>
|
|
476
|
+
)}
|
|
477
|
+
{otherGroups.length > 0 && onOtherFileAnnotationsClick && (
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
data-pn-touch-target="true"
|
|
481
|
+
data-annotation-show-in-files="true"
|
|
482
|
+
onClick={onOtherFileAnnotationsClick}
|
|
483
|
+
className="mt-1 cursor-pointer self-start px-1.5 text-[10px] text-muted-foreground/60 transition-colors hover:text-primary"
|
|
484
|
+
title="Show annotated files in the navigator"
|
|
485
|
+
>
|
|
486
|
+
Show in files
|
|
487
|
+
</button>
|
|
488
|
+
)}
|
|
489
|
+
</>
|
|
490
|
+
) : totalCount === 0 ? (
|
|
271
491
|
(!directEdits || directEdits.length === 0) && (
|
|
272
492
|
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
|
|
273
493
|
<p className="text-xs text-muted-foreground/60">
|
|
@@ -276,6 +496,17 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
276
496
|
<p className="mt-1 text-[11px] text-muted-foreground/40">
|
|
277
497
|
Select text to annotate
|
|
278
498
|
</p>
|
|
499
|
+
{scopeEnabled && otherGroupCount > 0 && (
|
|
500
|
+
<button
|
|
501
|
+
type="button"
|
|
502
|
+
data-pn-touch-target="true"
|
|
503
|
+
data-annotation-view-all="true"
|
|
504
|
+
onClick={() => onAnnotationScopeChange!('all')}
|
|
505
|
+
className="mt-3 cursor-pointer rounded-md px-2 py-1 text-[11px] text-primary/80 transition-colors hover:bg-surface-1 hover:text-primary"
|
|
506
|
+
>
|
|
507
|
+
View all {otherGroupCount} in {otherGroups.length} other file{otherGroups.length === 1 ? '' : 's'}
|
|
508
|
+
</button>
|
|
509
|
+
)}
|
|
279
510
|
</div>
|
|
280
511
|
)
|
|
281
512
|
) : (
|
|
@@ -352,7 +583,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
352
583
|
</OverlayScrollArea>
|
|
353
584
|
|
|
354
585
|
{/* Quick Actions Footer */}
|
|
355
|
-
{
|
|
586
|
+
{headerCount > 0 && (
|
|
356
587
|
<div className="border-t border-border/50 px-3 py-2 flex gap-1.5">
|
|
357
588
|
{onQuickCopy && (
|
|
358
589
|
<button
|
|
@@ -849,6 +849,77 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
849
849
|
* and box metrics MUST stay identical or the overlay drifts out of alignment. */
|
|
850
850
|
const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5';
|
|
851
851
|
|
|
852
|
+
/** Per-composer memo for the overlay gutter sync below. */
|
|
853
|
+
interface OverlayGutterState {
|
|
854
|
+
/** The overlay the applied value belongs to (a remount invalidates it). */
|
|
855
|
+
overlay: HTMLElement | null;
|
|
856
|
+
/** Last `offsetWidth - clientWidth` seen on the textarea. */
|
|
857
|
+
raw: number;
|
|
858
|
+
/** Extra right padding currently written inline on the overlay, in px. */
|
|
859
|
+
applied: number;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const cssPx = (value: string): number => {
|
|
863
|
+
const n = parseFloat(value);
|
|
864
|
+
return Number.isFinite(n) ? n : 0;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Keep the mirror's text column exactly as wide as the textarea's (#1525).
|
|
869
|
+
*
|
|
870
|
+
* A layout-consuming vertical scrollbar (Windows/Linux classic scrollbars; the
|
|
871
|
+
* app's `scrollbar-width: thin` makes them ~12px) narrows the textarea's
|
|
872
|
+
* content box once the composer hits its max height — but the overlay is
|
|
873
|
+
* `overflow: hidden` and keeps its full width, so from the first line that
|
|
874
|
+
* wraps differently the painted glyphs drift away from the native caret, which
|
|
875
|
+
* is the one thing the textarea itself still draws. `scrollbar-gutter: stable`
|
|
876
|
+
* cannot fix this: it is ignored on an `overflow: hidden` box. Mirroring the
|
|
877
|
+
* measured scrollbar width onto the overlay's right padding makes both boxes
|
|
878
|
+
* wrap identically again (and stops the overlay painting under the scrollbar).
|
|
879
|
+
*
|
|
880
|
+
* Overlay scrollbars (macOS, iOS, any touch surface) consume no layout, so the
|
|
881
|
+
* measured width is 0 — and then this writes NOTHING at all, leaving the
|
|
882
|
+
* class-driven padding untouched exactly as before the fix.
|
|
883
|
+
*/
|
|
884
|
+
function syncOverlayGutter(
|
|
885
|
+
el: HTMLTextAreaElement,
|
|
886
|
+
overlay: HTMLElement,
|
|
887
|
+
state: OverlayGutterState,
|
|
888
|
+
raw: number,
|
|
889
|
+
): void {
|
|
890
|
+
// Nothing measurable changed since the last sync — the common case.
|
|
891
|
+
if (overlay === state.overlay && raw === state.raw) return;
|
|
892
|
+
state.overlay = overlay;
|
|
893
|
+
state.raw = raw;
|
|
894
|
+
|
|
895
|
+
const clear = () => {
|
|
896
|
+
if (state.applied === 0) return; // never wrote anything; leave the DOM alone
|
|
897
|
+
overlay.style.removeProperty('padding-right');
|
|
898
|
+
state.applied = 0;
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const style = getComputedStyle(el);
|
|
902
|
+
// `offsetWidth - clientWidth` is scrollbar + left/right borders. The borders
|
|
903
|
+
// are shared with the overlay (both boxes are border-box), so only the
|
|
904
|
+
// scrollbar part is the extra gutter the mirror is missing.
|
|
905
|
+
const borders = cssPx(style.borderLeftWidth) + cssPx(style.borderRightWidth);
|
|
906
|
+
const scrollbar = Math.max(0, raw - borders);
|
|
907
|
+
if (scrollbar === 0) {
|
|
908
|
+
clear();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
// Base padding is read from the textarea (never the overlay, whose computed
|
|
912
|
+
// value may already include a gutter we wrote), so the two can never drift
|
|
913
|
+
// apart if COMPOSER_TEXT_CLASSES changes.
|
|
914
|
+
const base = parseFloat(style.paddingRight);
|
|
915
|
+
if (!Number.isFinite(base)) {
|
|
916
|
+
clear(); // unmeasurable: fall back to the pre-fix behavior
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
overlay.style.paddingRight = `${base + scrollbar}px`;
|
|
920
|
+
state.applied = scrollbar;
|
|
921
|
+
}
|
|
922
|
+
|
|
852
923
|
interface ComposerTextareaProps {
|
|
853
924
|
value: string;
|
|
854
925
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
@@ -897,12 +968,17 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
897
968
|
}) => {
|
|
898
969
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
899
970
|
const [composing, setComposing] = useState(false);
|
|
971
|
+
const gutterRef = useRef<OverlayGutterState>({ overlay: null, raw: 0, applied: 0 });
|
|
900
972
|
|
|
901
973
|
const syncScroll = useCallback((el: HTMLTextAreaElement) => {
|
|
902
974
|
const overlay = overlayRef.current;
|
|
903
975
|
if (!overlay) return;
|
|
904
|
-
|
|
905
|
-
|
|
976
|
+
// Reads before writes so a sync costs at most one forced layout.
|
|
977
|
+
const { scrollTop, scrollLeft } = el;
|
|
978
|
+
const raw = el.offsetWidth - el.clientWidth;
|
|
979
|
+
overlay.scrollTop = scrollTop;
|
|
980
|
+
overlay.scrollLeft = scrollLeft;
|
|
981
|
+
syncOverlayGutter(el, overlay, gutterRef.current, raw);
|
|
906
982
|
}, []);
|
|
907
983
|
|
|
908
984
|
const innerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
@@ -920,6 +996,19 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
920
996
|
if (innerRef.current) syncScroll(innerRef.current);
|
|
921
997
|
}, [value, syncScroll]);
|
|
922
998
|
|
|
999
|
+
// `syncScroll` otherwise only runs on scroll and on value change, so a
|
|
1000
|
+
// window/panel resize that grows or drops the textarea's scrollbar without a
|
|
1001
|
+
// keystroke would leave the gutter stale until the next one.
|
|
1002
|
+
useEffect(() => {
|
|
1003
|
+
const el = innerRef.current;
|
|
1004
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
1005
|
+
const observer = new ResizeObserver(() => {
|
|
1006
|
+
if (innerRef.current) syncScroll(innerRef.current);
|
|
1007
|
+
});
|
|
1008
|
+
observer.observe(el);
|
|
1009
|
+
return () => observer.disconnect();
|
|
1010
|
+
}, [skillReferences, syncScroll]);
|
|
1011
|
+
|
|
923
1012
|
if (!skillReferences) {
|
|
924
1013
|
return (
|
|
925
1014
|
<textarea
|