@plannotator/ui 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MentionPicker.tsx +51 -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/SkillReferenceMenu.tsx +39 -12
- 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 +316 -47
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- 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 +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +14 -6
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
package/components/ImagePeek.tsx
CHANGED
|
@@ -51,6 +51,14 @@ export interface ImagePeekProps {
|
|
|
51
51
|
/** An id for the panel, for the opener's `aria-controls`. */
|
|
52
52
|
id?: string;
|
|
53
53
|
className?: string;
|
|
54
|
+
/**
|
|
55
|
+
* The sheet only: controls drawn above the grid, outside it (the Inbox's
|
|
56
|
+
* Previous / Next). Shift+Tab from the grid moves into them; Escape in them,
|
|
57
|
+
* or Shift+Tab out of the first, closes the sheet (`onClose('key')`), so
|
|
58
|
+
* focus never leaves it for the page behind. Absent (the default): the
|
|
59
|
+
* sheet is unchanged.
|
|
60
|
+
*/
|
|
61
|
+
sheetHeader?: React.ReactNode;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
const PaperclipIcon = () => (
|
|
@@ -94,8 +102,9 @@ export interface ImagePeekHandle {
|
|
|
94
102
|
handleKey: (event: { key: string; isComposing?: boolean }) => boolean;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function ImagePeek({ rows, current, onPick, onClose, variant = 'popover', label, id, className = '' }, ref) {
|
|
105
|
+
export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function ImagePeek({ rows, current, onPick, onClose, variant = 'popover', label, id, className = '', sheetHeader }, ref) {
|
|
98
106
|
const sheet = variant === 'sheet';
|
|
107
|
+
const header = sheet && sheetHeader ? sheetHeader : null;
|
|
99
108
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
100
109
|
const autoId = useId();
|
|
101
110
|
const labelId = `${(id ?? `pn-peek-${autoId}`).replace(/:/g, '')}-label`;
|
|
@@ -162,6 +171,8 @@ export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function Im
|
|
|
162
171
|
useImperativeHandle(ref, () => ({ handleKey: (event) => handleKeyRef.current(event) }), []);
|
|
163
172
|
|
|
164
173
|
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
174
|
+
// With a header above the grid, Shift+Tab moves up into it instead of closing the sheet.
|
|
175
|
+
if (header && event.key === 'Tab' && event.shiftKey) return;
|
|
165
176
|
if (!handleKey({ key: event.key, isComposing: event.nativeEvent.isComposing })) return;
|
|
166
177
|
event.preventDefault();
|
|
167
178
|
event.stopPropagation();
|
|
@@ -177,14 +188,16 @@ export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function Im
|
|
|
177
188
|
data-image-peek={variant}
|
|
178
189
|
className={
|
|
179
190
|
sheet
|
|
180
|
-
?
|
|
191
|
+
? header
|
|
192
|
+
? `pointer-events-auto max-h-[70vh] w-full overflow-y-auto bg-popover px-3 pb-[calc(16px+env(safe-area-inset-bottom))] text-popover-foreground ${className}`
|
|
193
|
+
: `pointer-events-auto max-h-[78vh] w-full overflow-y-auto rounded-t-[12px] border-t border-border bg-popover px-3 pb-[calc(16px+env(safe-area-inset-bottom))] pt-2 text-popover-foreground shadow-2xl ${className}`
|
|
181
194
|
: `max-h-[min(460px,62vh)] w-[min(600px,calc(100vw-24px))] overflow-y-auto rounded-xl border border-border bg-popover p-2 text-popover-foreground shadow-[0_14px_36px_rgba(0,0,0,0.28)] ${className}`
|
|
182
195
|
}
|
|
183
196
|
>
|
|
184
197
|
<span id={labelId} className="sr-only">
|
|
185
198
|
{label}
|
|
186
199
|
</span>
|
|
187
|
-
{sheet && <div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-muted-foreground/30" aria-hidden="true" />}
|
|
200
|
+
{sheet && !header && <div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-muted-foreground/30" aria-hidden="true" />}
|
|
188
201
|
{rows.map((row, r) => (
|
|
189
202
|
<div
|
|
190
203
|
key={row.key}
|
|
@@ -249,7 +262,28 @@ export const ImagePeek = forwardRef<ImagePeekHandle, ImagePeekProps>(function Im
|
|
|
249
262
|
return (
|
|
250
263
|
<div className="fixed inset-0 z-[220] flex flex-col justify-end" data-image-peek-sheet="">
|
|
251
264
|
<div className="absolute inset-0 bg-black/40" aria-hidden="true" onClick={() => onClose('outside')} data-peek-backdrop="" />
|
|
252
|
-
<div className=
|
|
265
|
+
<div className={header ? 'relative overflow-hidden rounded-t-[12px] border-t border-border bg-popover shadow-2xl' : 'relative'}>
|
|
266
|
+
{header && (
|
|
267
|
+
<div
|
|
268
|
+
className="px-3 pt-2 text-popover-foreground"
|
|
269
|
+
onKeyDown={(event) => {
|
|
270
|
+
// Escape closes the sheet; so does Shift+Tab out of its first control (focus leaving the sheet
|
|
271
|
+
// closes it, as Tab out of the grid does: the host puts focus back on its opener).
|
|
272
|
+
const first = event.currentTarget.querySelector<HTMLElement>('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
273
|
+
const leaving = event.key === 'Tab' && event.shiftKey && event.target === first;
|
|
274
|
+
if (event.key !== 'Escape' && !leaving) return;
|
|
275
|
+
event.preventDefault();
|
|
276
|
+
event.stopPropagation();
|
|
277
|
+
onClose('key');
|
|
278
|
+
}}
|
|
279
|
+
data-peek-sheet-header=""
|
|
280
|
+
>
|
|
281
|
+
<div className="mx-auto mb-2 h-1.5 w-10 rounded-full bg-muted-foreground/30" aria-hidden="true" />
|
|
282
|
+
{header}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
{panel}
|
|
286
|
+
</div>
|
|
253
287
|
</div>
|
|
254
288
|
);
|
|
255
289
|
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { imageRegionFileName, imageRegionSize, parseImageRegion, type ImageRegion } from '@plannotator/core/image-region';
|
|
3
|
+
import type { Annotation } from '../types';
|
|
4
|
+
import type { NaturalBox, StageRect, ZoomSize, ZoomViewport } from './imageZoom';
|
|
5
|
+
import { naturalBoxToStage } from './imageZoom';
|
|
6
|
+
import { getImageSrc } from './ImageThumbnail';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The URL a markdown image's src (as written) loads from, the way the
|
|
10
|
+
* document renders it: http(s), data: and blob: as written, anything else
|
|
11
|
+
* through the host's image route relative to `baseDir`. For the crop
|
|
12
|
+
* thumbnails of region cards in the document's annotations panel.
|
|
13
|
+
*/
|
|
14
|
+
export function loadableImageSrc(src: string, baseDir?: string): string {
|
|
15
|
+
return /^(https?:\/\/|data:|blob:)/i.test(src) ? src : getImageSrc(src, baseDir);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The pieces of image region comments (#2100, approval
|
|
20
|
+
* "image-region-comments", variant C): the Move | Region strip, the boxes
|
|
21
|
+
* drawn over the image in the viewer, the crop thumbnail and the card body
|
|
22
|
+
* the annotations panel shows. The lightbox (`ImageLightbox`) composes them.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The validated region of an annotation, or null (a malformed region is no region). */
|
|
26
|
+
export function regionOf(annotation: Pick<Annotation, 'imageRegion'>): ImageRegion | null {
|
|
27
|
+
return annotation.imageRegion == null ? null : parseImageRegion(annotation.imageRegion);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Each region comment's number on its image: 1, 2, … in the order they were
|
|
32
|
+
* made, counted per image (the src as written). Replies are not regions.
|
|
33
|
+
*/
|
|
34
|
+
export function imageRegionNumbers(annotations: readonly Annotation[]): Map<string, number> {
|
|
35
|
+
const bySrc = new Map<string, Annotation[]>();
|
|
36
|
+
for (const ann of annotations) {
|
|
37
|
+
const region = regionOf(ann);
|
|
38
|
+
if (!region) continue;
|
|
39
|
+
const list = bySrc.get(region.src) ?? [];
|
|
40
|
+
list.push(ann);
|
|
41
|
+
bySrc.set(region.src, list);
|
|
42
|
+
}
|
|
43
|
+
const out = new Map<string, number>();
|
|
44
|
+
for (const list of bySrc.values()) {
|
|
45
|
+
[...list].sort((a, b) => a.createdA - b.createdA).forEach((ann, i) => out.set(ann.id, i + 1));
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ICON = { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true };
|
|
51
|
+
|
|
52
|
+
export const HandIcon: React.FC<{ size?: number }> = ({ size = 15 }) => (
|
|
53
|
+
<svg {...ICON} width={size} height={size}>
|
|
54
|
+
<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V12M11 11.5v-7a1.5 1.5 0 0 1 3 0V12M14 11.5V6.5a1.5 1.5 0 0 1 3 0V15a6 6 0 0 1-6 6h-1a6 6 0 0 1-4.7-2.3L3.6 15.4a1.6 1.6 0 0 1 2.4-2l2 2" />
|
|
55
|
+
</svg>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const RegionIcon: React.FC<{ size?: number }> = ({ size = 15 }) => (
|
|
59
|
+
<svg {...ICON} width={size} height={size}>
|
|
60
|
+
<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3" />
|
|
61
|
+
<path d="M12 9v6M9 12h6" />
|
|
62
|
+
</svg>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
export const ChatIcon: React.FC<{ size?: number }> = ({ size = 15 }) => (
|
|
66
|
+
<svg {...ICON} width={size} height={size}>
|
|
67
|
+
<path d="M7.5 8.25h9m-9 3H12M3 20.25V6a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 6v9a2.25 2.25 0 0 1-2.25 2.25H7.5L3 20.25Z" />
|
|
68
|
+
</svg>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A crop of the image showing the region, as a CSS background (no canvas, no
|
|
73
|
+
* second request: the browser reuses the image it already loaded). The crop
|
|
74
|
+
* keeps the region's shape within `maxWidth` × `maxHeight`.
|
|
75
|
+
*/
|
|
76
|
+
export const RegionThumb: React.FC<{
|
|
77
|
+
src: string;
|
|
78
|
+
region: ImageRegion;
|
|
79
|
+
maxWidth?: number;
|
|
80
|
+
maxHeight?: number;
|
|
81
|
+
}> = ({ src, region, maxWidth = 76, maxHeight = 48 }) => {
|
|
82
|
+
const { box, naturalWidth, naturalHeight } = region;
|
|
83
|
+
const s = Math.min(maxWidth / box.w, maxHeight / box.h);
|
|
84
|
+
const width = Math.max(4, Math.round(box.w * s));
|
|
85
|
+
const height = Math.max(4, Math.round(box.h * s));
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
data-region-thumb=""
|
|
90
|
+
className="flex-none rounded-[5px] bg-white bg-no-repeat ring-1 ring-border/80"
|
|
91
|
+
style={{
|
|
92
|
+
width,
|
|
93
|
+
height,
|
|
94
|
+
backgroundImage: `url(${JSON.stringify(src)})`,
|
|
95
|
+
backgroundSize: `${naturalWidth * s}px ${naturalHeight * s}px`,
|
|
96
|
+
backgroundPosition: `${-box.x * s}px ${-box.y * s}px`,
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** The number badge of a region (the same number the box on the image carries). */
|
|
103
|
+
export const RegionNumber: React.FC<{ n: number; selected?: boolean; className?: string }> = ({ n, selected = false, className = '' }) => (
|
|
104
|
+
<span
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[9.5px] font-bold tabular-nums text-white ${selected ? 'bg-primary' : 'bg-annotation-comment'} ${className}`}
|
|
107
|
+
>
|
|
108
|
+
{n}
|
|
109
|
+
</span>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What a region card shows in place of a quote: the crop, the file, the
|
|
114
|
+
* box's size in the image's own pixels and the image's line.
|
|
115
|
+
*/
|
|
116
|
+
export const RegionCardInfo: React.FC<{ region: ImageRegion; thumbSrc?: string | null }> = ({ region, thumbSrc }) => (
|
|
117
|
+
<div className="mb-1.5 flex items-center gap-2.5" data-region-card-info="">
|
|
118
|
+
{thumbSrc && <RegionThumb src={thumbSrc} region={region} />}
|
|
119
|
+
<div className="min-w-0 font-mono text-[10.5px] leading-normal text-muted-foreground">
|
|
120
|
+
<div className="truncate">{imageRegionFileName(region.src)}</div>
|
|
121
|
+
<div className="truncate">
|
|
122
|
+
{imageRegionSize(region)}
|
|
123
|
+
{region.sourceLine !== null && <> · line {region.sourceLine}</>}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
/** One region drawn on the image. */
|
|
130
|
+
export interface RegionBoxItem {
|
|
131
|
+
id: string;
|
|
132
|
+
n: number;
|
|
133
|
+
box: NaturalBox;
|
|
134
|
+
/** Its comment, for the box's accessible name. */
|
|
135
|
+
text?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const boxStyle = (rect: StageRect, eased: boolean): React.CSSProperties => ({
|
|
139
|
+
position: 'absolute',
|
|
140
|
+
left: rect.left,
|
|
141
|
+
top: rect.top,
|
|
142
|
+
width: rect.width,
|
|
143
|
+
height: rect.height,
|
|
144
|
+
transition: eased ? 'left 180ms cubic-bezier(0.2, 0, 0, 1), top 180ms cubic-bezier(0.2, 0, 0, 1), width 180ms cubic-bezier(0.2, 0, 0, 1), height 180ms cubic-bezier(0.2, 0, 0, 1), opacity 150ms' : 'opacity 150ms',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The boxes over the image, in stage pixels (so a stroke and a badge keep
|
|
149
|
+
* their size at every zoom): the saved regions (numbered, the comment
|
|
150
|
+
* colour), the selected one (ringed, the rest and the image dimmed), the box
|
|
151
|
+
* being drawn (dashed, with its size in the image's own pixels) and the box
|
|
152
|
+
* waiting for its comment (solid, numbered).
|
|
153
|
+
*/
|
|
154
|
+
export const RegionBoxLayer: React.FC<{
|
|
155
|
+
view: ZoomViewport | null;
|
|
156
|
+
stage: ZoomSize | null;
|
|
157
|
+
eased: boolean;
|
|
158
|
+
regions: readonly RegionBoxItem[];
|
|
159
|
+
selectedId: string | null;
|
|
160
|
+
/** The box being drawn now. */
|
|
161
|
+
draft: NaturalBox | null;
|
|
162
|
+
/** The box waiting for its comment, and its number. */
|
|
163
|
+
pending: { box: NaturalBox; n: number } | null;
|
|
164
|
+
/** Saved boxes take a click (Move mode); in Region mode the drag draws through them. */
|
|
165
|
+
interactive: boolean;
|
|
166
|
+
onPick: (id: string) => void;
|
|
167
|
+
/** The pending box's element, the comment box's anchor. */
|
|
168
|
+
pendingRef?: React.Ref<HTMLDivElement>;
|
|
169
|
+
}> = ({ view, stage, eased, regions, selectedId, draft, pending, interactive, onPick, pendingRef }) => {
|
|
170
|
+
if (!view || !stage) return null;
|
|
171
|
+
const anySelected = selectedId !== null && regions.some((r) => r.id === selectedId);
|
|
172
|
+
const badge = 'absolute -left-3 -top-3 flex h-[22px] min-w-[22px] items-center justify-center rounded-full px-1 text-[11.5px] font-bold tabular-nums text-white shadow-[0_0_0_2px_var(--background),0_2px_6px_-2px_rgb(0_0_0/0.4)]';
|
|
173
|
+
return (
|
|
174
|
+
<div className="pointer-events-none absolute inset-0" data-region-layer="">
|
|
175
|
+
{regions.map((r) => {
|
|
176
|
+
const rect = naturalBoxToStage(view, r.box);
|
|
177
|
+
const selected = r.id === selectedId;
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
key={r.id}
|
|
181
|
+
data-region-box={r.id}
|
|
182
|
+
data-region-selected={selected ? '' : undefined}
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
onPointerDown={interactive ? (e) => e.stopPropagation() : undefined}
|
|
185
|
+
onClick={interactive ? (e) => { e.stopPropagation(); onPick(r.id); } : undefined}
|
|
186
|
+
className={
|
|
187
|
+
selected
|
|
188
|
+
? 'rounded-[3px] border-[3px] border-primary shadow-[0_0_0_9999px_rgb(15_20_30/0.3),0_0_0_6px_color-mix(in_oklch,var(--primary)_30%,transparent)]'
|
|
189
|
+
: `rounded-[3px] border-2 border-annotation-comment bg-annotation-comment/10 ${anySelected ? 'opacity-35' : ''}`
|
|
190
|
+
}
|
|
191
|
+
style={{ ...boxStyle(rect, eased), pointerEvents: interactive ? 'auto' : 'none', cursor: interactive ? 'pointer' : undefined }}
|
|
192
|
+
>
|
|
193
|
+
<span className={`${badge} ${selected ? 'bg-primary' : 'bg-annotation-comment'}`}>{r.n}</span>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
{pending && (() => {
|
|
198
|
+
const rect = naturalBoxToStage(view, pending.box);
|
|
199
|
+
return (
|
|
200
|
+
<div ref={pendingRef} data-region-pending="" aria-hidden="true" className="rounded-[3px] border-2 border-primary bg-primary/[0.07]" style={boxStyle(rect, eased)}>
|
|
201
|
+
<span className={`${badge} bg-primary`}>{pending.n}</span>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
})()}
|
|
205
|
+
{draft && (() => {
|
|
206
|
+
const rect = naturalBoxToStage(view, draft);
|
|
207
|
+
const below = rect.top + rect.height + 30 <= stage.height;
|
|
208
|
+
return (
|
|
209
|
+
<>
|
|
210
|
+
<div data-region-draft="" aria-hidden="true" className="rounded-[3px] border-2 border-dashed border-primary bg-primary/[0.07]" style={boxStyle(rect, false)} />
|
|
211
|
+
<div
|
|
212
|
+
data-region-size=""
|
|
213
|
+
aria-hidden="true"
|
|
214
|
+
className="absolute whitespace-nowrap rounded-[5px] bg-[rgb(28_32_42/0.88)] px-1.5 py-0.5 font-mono text-[11px] tabular-nums text-white"
|
|
215
|
+
style={{
|
|
216
|
+
left: Math.max(4, rect.left + rect.width - 2),
|
|
217
|
+
top: below ? rect.top + rect.height + 8 : Math.max(4, rect.top - 26),
|
|
218
|
+
transform: 'translateX(-100%)',
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{draft.w} × {draft.h}
|
|
222
|
+
</div>
|
|
223
|
+
</>
|
|
224
|
+
);
|
|
225
|
+
})()}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The Move | Region switch and the comment count (approval variant C):
|
|
232
|
+
* floating over the image on a desktop, a bottom bar on a phone. The switch
|
|
233
|
+
* is a radio group (arrows move between the two, as a segmented control);
|
|
234
|
+
* the count opens the drawer of this image's region comments.
|
|
235
|
+
*/
|
|
236
|
+
export const RegionToolStrip: React.FC<{
|
|
237
|
+
tool: 'move' | 'region';
|
|
238
|
+
onToolChange: (tool: 'move' | 'region') => void;
|
|
239
|
+
/** False on a read-only document: only the count shows. */
|
|
240
|
+
canDraw: boolean;
|
|
241
|
+
count: number;
|
|
242
|
+
drawerOpen: boolean;
|
|
243
|
+
onToggleDrawer: () => void;
|
|
244
|
+
drawerId: string;
|
|
245
|
+
phone: boolean;
|
|
246
|
+
countRef?: React.Ref<HTMLButtonElement>;
|
|
247
|
+
}> = ({ tool, onToolChange, canDraw, count, drawerOpen, onToggleDrawer, drawerId, phone, countRef }) => {
|
|
248
|
+
const moveRef = React.useRef<HTMLButtonElement>(null);
|
|
249
|
+
const regionRef = React.useRef<HTMLButtonElement>(null);
|
|
250
|
+
const seg = phone ? 'h-10 px-3.5 text-[13px]' : 'h-7 px-2.5 text-[12px]';
|
|
251
|
+
const choose = (next: 'move' | 'region') => {
|
|
252
|
+
onToolChange(next);
|
|
253
|
+
(next === 'move' ? moveRef : regionRef).current?.focus();
|
|
254
|
+
};
|
|
255
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
256
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
choose(tool === 'move' ? 'region' : 'move');
|
|
259
|
+
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
choose(tool === 'move' ? 'region' : 'move');
|
|
262
|
+
} else if (e.key === 'Home') {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
choose('move');
|
|
265
|
+
} else if (e.key === 'End') {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
choose('region');
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const radio = (value: 'move' | 'region', label: string, icon: React.ReactNode, ref: React.Ref<HTMLButtonElement>) => {
|
|
271
|
+
const on = tool === value;
|
|
272
|
+
return (
|
|
273
|
+
<button
|
|
274
|
+
ref={ref}
|
|
275
|
+
type="button"
|
|
276
|
+
role="radio"
|
|
277
|
+
aria-checked={on}
|
|
278
|
+
tabIndex={on ? 0 : -1}
|
|
279
|
+
data-region-tool={value}
|
|
280
|
+
onClick={() => onToolChange(value)}
|
|
281
|
+
className={`inline-flex items-center gap-1.5 rounded-[7px] font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${seg} ${
|
|
282
|
+
on ? `bg-card shadow-[0_1px_2px_rgb(0_0_0/0.2)] ${value === 'region' ? 'text-primary' : 'text-foreground'}` : 'text-muted-foreground hover:text-foreground'
|
|
283
|
+
}`}
|
|
284
|
+
>
|
|
285
|
+
{icon}
|
|
286
|
+
{label}
|
|
287
|
+
</button>
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
return (
|
|
291
|
+
<div
|
|
292
|
+
data-region-strip=""
|
|
293
|
+
className={`pointer-events-auto flex items-center gap-1.5 rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-[0_10px_30px_-16px_rgb(0_0_0/0.45)]`}
|
|
294
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
295
|
+
>
|
|
296
|
+
{canDraw && (
|
|
297
|
+
<>
|
|
298
|
+
<div
|
|
299
|
+
role="radiogroup"
|
|
300
|
+
aria-label="Image tool"
|
|
301
|
+
onKeyDown={onKeyDown}
|
|
302
|
+
className="inline-flex gap-0.5 rounded-[9px] border border-border/30 bg-muted/50 p-0.5"
|
|
303
|
+
>
|
|
304
|
+
{radio('move', 'Move', <HandIcon size={phone ? 17 : 15} />, moveRef)}
|
|
305
|
+
{radio('region', 'Region', <RegionIcon size={phone ? 17 : 15} />, regionRef)}
|
|
306
|
+
</div>
|
|
307
|
+
<span className="h-5 w-px bg-border" aria-hidden="true" />
|
|
308
|
+
</>
|
|
309
|
+
)}
|
|
310
|
+
<button
|
|
311
|
+
ref={countRef}
|
|
312
|
+
type="button"
|
|
313
|
+
data-region-count=""
|
|
314
|
+
aria-expanded={drawerOpen}
|
|
315
|
+
aria-controls={drawerId}
|
|
316
|
+
aria-label={`${count} ${count === 1 ? 'comment' : 'comments'} on this image`}
|
|
317
|
+
onClick={onToggleDrawer}
|
|
318
|
+
className={`inline-flex items-center gap-1.5 rounded-[7px] font-medium tabular-nums transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${seg} ${
|
|
319
|
+
drawerOpen ? 'bg-primary/12 text-primary' : 'text-muted-foreground hover:text-foreground'
|
|
320
|
+
}`}
|
|
321
|
+
>
|
|
322
|
+
<ChatIcon size={phone ? 17 : 15} />
|
|
323
|
+
{count}
|
|
324
|
+
</button>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/** The annotations among `all` that are region comments on the image `src` (as written). */
|
|
330
|
+
export function useImageRegionAnnotations(all: readonly Annotation[], src: string | null | undefined): Annotation[] {
|
|
331
|
+
return useMemo(() => {
|
|
332
|
+
if (!src) return [];
|
|
333
|
+
return all.filter((ann) => regionOf(ann)?.src === src).sort((a, b) => a.createdA - b.createdA);
|
|
334
|
+
}, [all, src]);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The document line an image is written on: its block's first line plus the
|
|
339
|
+
* line breaks before `](src` in the block's text (a paragraph, a list item, a
|
|
340
|
+
* table, a quote's own markdown). The block's first line when the reference
|
|
341
|
+
* cannot be found; null when the block has no line.
|
|
342
|
+
*/
|
|
343
|
+
export function imageSourceLine(block: { content: string; startLine?: number }, src: string): number | null {
|
|
344
|
+
if (typeof block.startLine !== 'number' || block.startLine < 1) return null;
|
|
345
|
+
let at = imageReferenceIndex(block.content, src);
|
|
346
|
+
if (at < 0) at = block.content.indexOf(src);
|
|
347
|
+
if (at < 0) return block.startLine;
|
|
348
|
+
return block.startLine + (block.content.slice(0, at).match(/\n/g)?.length ?? 0);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Where the markdown image reference to `src` starts in `text` (`](src)`,
|
|
353
|
+
* `](src "title")`, `](<src>)`), or -1. The src must be the whole reference:
|
|
354
|
+
* `a.png` never matches `](data.png)` or `](a.png.bak)`.
|
|
355
|
+
*/
|
|
356
|
+
export function imageReferenceIndex(text: string, src: string): number {
|
|
357
|
+
if (!src) return -1;
|
|
358
|
+
for (const opener of [`](${src}`, `](<${src}>`]) {
|
|
359
|
+
let from = 0;
|
|
360
|
+
for (;;) {
|
|
361
|
+
const at = text.indexOf(opener, from);
|
|
362
|
+
if (at < 0) break;
|
|
363
|
+
const next = text.charAt(at + opener.length);
|
|
364
|
+
if (next === ')' || /\s/.test(next)) return at;
|
|
365
|
+
from = at + 1;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return -1;
|
|
369
|
+
}
|
|
@@ -114,7 +114,7 @@ export const ImageThumbnail: React.FC<ImageThumbnailProps> = ({
|
|
|
114
114
|
e.stopPropagation();
|
|
115
115
|
onRemove();
|
|
116
116
|
}}
|
|
117
|
-
className="absolute -top-1 -right-1 w-4 h-4 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
|
117
|
+
className="absolute -top-1 -right-1 w-4 h-4 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity flex items-center justify-center"
|
|
118
118
|
>
|
|
119
119
|
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
120
120
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|