@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,796 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
actualViewport,
|
|
4
|
+
atFit,
|
|
5
|
+
clampViewport,
|
|
6
|
+
DOUBLE_TAP_MS,
|
|
7
|
+
DOUBLE_TAP_SLOP_PX,
|
|
8
|
+
DOUBLE_TAP_ZOOM,
|
|
9
|
+
fitScale,
|
|
10
|
+
fitViewport,
|
|
11
|
+
naturalBoxFromPoints,
|
|
12
|
+
overflows,
|
|
13
|
+
paddingInset,
|
|
14
|
+
panBy,
|
|
15
|
+
TAP_SLOP_MOUSE_PX,
|
|
16
|
+
TAP_SLOP_TOUCH_PX,
|
|
17
|
+
WHEEL_LATCH_MS,
|
|
18
|
+
wheelTravel,
|
|
19
|
+
wheelZoomFactor,
|
|
20
|
+
zoomAt,
|
|
21
|
+
viewportForBox,
|
|
22
|
+
visibleNaturalBox,
|
|
23
|
+
zoomLimits,
|
|
24
|
+
type FitAxis,
|
|
25
|
+
type NaturalBox,
|
|
26
|
+
type ZoomInset,
|
|
27
|
+
type ZoomSize,
|
|
28
|
+
type ZoomViewport,
|
|
29
|
+
} from './imageZoom';
|
|
30
|
+
import { modKey, modKeySpoken } from '../utils/platform';
|
|
31
|
+
|
|
32
|
+
export type { FitAxis, NaturalBox } from './imageZoom';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the "Use ⌘ + scroll to zoom" hint (#2127) was shown already. A host
|
|
36
|
+
* holds one for the life of its viewer (the lightbox while it is open, the
|
|
37
|
+
* Inbox's file pane) and hands it to every stage it mounts, so the hint shows
|
|
38
|
+
* once per viewer open, not once per image. Without one a stage keeps its own.
|
|
39
|
+
*/
|
|
40
|
+
export interface WheelHintGate {
|
|
41
|
+
shown: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The hint stays this long after the last wheel event that panned. */
|
|
45
|
+
export const WHEEL_HINT_HOLD_MS = 2000;
|
|
46
|
+
/** The hint's fade out (none under reduced motion). */
|
|
47
|
+
export const WHEEL_HINT_FADE_MS = 300;
|
|
48
|
+
|
|
49
|
+
/** What the hint says, on screen and to a screen reader. */
|
|
50
|
+
export const wheelHintText = {
|
|
51
|
+
visible: { before: 'Use', key: modKey, after: '+ scroll to zoom' },
|
|
52
|
+
spoken: `Scrolling moves the image. To zoom, hold ${modKeySpoken} and scroll.`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A device with a mouse or trackpad (a touch-only one never sends a wheel, and gets no hint). */
|
|
56
|
+
function hasFinePointer(): boolean {
|
|
57
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true;
|
|
58
|
+
return window.matchMedia('(any-pointer: fine)').matches;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A box being drawn in Region mode (#2100), in the image's own pixels. */
|
|
62
|
+
export interface ZoomDraw {
|
|
63
|
+
/** The box so far (`move`), the box drawn (`end`), or null on `cancel`. */
|
|
64
|
+
box: NaturalBox | null;
|
|
65
|
+
phase: 'move' | 'end' | 'cancel';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fit (the whole image), Actual size (its own pixels), or anything a gesture left. */
|
|
69
|
+
export type ZoomMode = 'fit' | 'actual' | 'zoom';
|
|
70
|
+
|
|
71
|
+
/** What the host's capture-phase key handler reaches the stage through. */
|
|
72
|
+
export interface ImageZoomStageHandle {
|
|
73
|
+
/** The focusable stage, to tell whether a key was aimed at it. */
|
|
74
|
+
element: HTMLDivElement | null;
|
|
75
|
+
/**
|
|
76
|
+
* The browser's own scroll keys while the stage has focus (it stood in for
|
|
77
|
+
* an overflow-auto scroller): ↑ ↓, Page Up / Page Down, Home / End, Space
|
|
78
|
+
* and Shift+Space; ← → too when `arrowsSideways`. Answers whether the key
|
|
79
|
+
* was one of them (the host then prevents its default).
|
|
80
|
+
*/
|
|
81
|
+
scrollKey(key: string, shiftKey: boolean, arrowsSideways: boolean): boolean;
|
|
82
|
+
/** Show a box (in the image's own pixels) large and centred (#2100). */
|
|
83
|
+
zoomTo(box: NaturalBox, animate?: boolean): void;
|
|
84
|
+
/** The part of the image on screen now, in its own pixels; null before it loads. */
|
|
85
|
+
visibleBox(): NaturalBox | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One arrow key's travel, as a browser scrolls a page by a line. */
|
|
89
|
+
export const KEY_SCROLL_PX = 40;
|
|
90
|
+
/** Page Up / Page Down / Space: most of the stage, as a browser pages. */
|
|
91
|
+
export const KEY_PAGE_SHARE = 0.875;
|
|
92
|
+
/** A synthesized click this long after the tap that asked for it still closes. */
|
|
93
|
+
const CLICK_AFTER_TAP_MS = 800;
|
|
94
|
+
|
|
95
|
+
/** Safari's GestureEvent (trackpad pinch on macOS, two fingers on iOS). */
|
|
96
|
+
interface GestureLike extends Event {
|
|
97
|
+
scale: number;
|
|
98
|
+
clientX: number;
|
|
99
|
+
clientY: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface StageBox extends ZoomSize {
|
|
103
|
+
left: number;
|
|
104
|
+
top: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface Press {
|
|
108
|
+
id: number;
|
|
109
|
+
startX: number;
|
|
110
|
+
startY: number;
|
|
111
|
+
lastX: number;
|
|
112
|
+
lastY: number;
|
|
113
|
+
slop: number;
|
|
114
|
+
moved: boolean;
|
|
115
|
+
/** Region mode: this press draws a box instead of panning. */
|
|
116
|
+
draw?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A drawn box smaller than this on screen (either side) is a tap, not a box. */
|
|
120
|
+
export const MIN_DRAW_SCREEN_PX = 6;
|
|
121
|
+
|
|
122
|
+
function prefersReducedMotion(): boolean {
|
|
123
|
+
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The lightbox's image, zoomable (#2067). The image is drawn at its own size
|
|
128
|
+
* and moved with a transform only (translate + scale), so a gesture never
|
|
129
|
+
* lays anything out.
|
|
130
|
+
*
|
|
131
|
+
* - Ctrl/⌘ + wheel, and a trackpad pinch (which the browser sends as a wheel
|
|
132
|
+
* with ctrlKey), zoom about the pointer.
|
|
133
|
+
* - A plain wheel scrolls the image when it is larger than the stage (as a
|
|
134
|
+
* page scrolls, Shift or a sideways swipe for the other axis), and zooms
|
|
135
|
+
* about the pointer when the whole image is on screen, where there is
|
|
136
|
+
* nothing to scroll. One wheel gesture keeps the meaning it started with.
|
|
137
|
+
* - The first time a plain wheel scrolls the image (once per `wheelHint`
|
|
138
|
+
* gate: per viewer open), a pill in the middle of the stage says
|
|
139
|
+
* "Use ⌘ + scroll to zoom" ("Ctrl" off macOS) and a polite live region says
|
|
140
|
+
* it once (#2127). It takes no pointer, stays while the wheel keeps
|
|
141
|
+
* scrolling and 2 s after, then fades (no fade under reduced motion); a
|
|
142
|
+
* Ctrl/⌘ + wheel, a pinch or a press hides it at once. Never on a
|
|
143
|
+
* touch-only device, never for Ctrl/⌘ + wheel or a pinch.
|
|
144
|
+
* - Mouse or one-finger drag pans when there is something to pan; two
|
|
145
|
+
* fingers pinch-zoom about their midpoint and pan with it.
|
|
146
|
+
* - Double click (or double tap) on the image zooms to twice the Fit scale
|
|
147
|
+
* about that point, and back to Fit.
|
|
148
|
+
* - Limits: Fit (never above 1) to 800%.
|
|
149
|
+
* - Safari's gesture events (its trackpad pinch) zoom too, and are prevented.
|
|
150
|
+
* - The stage takes focus (it replaced an overflow-auto scroller) and pans
|
|
151
|
+
* with the browser's scroll keys through `scrollKey`, which the host's
|
|
152
|
+
* capture-phase key handler calls.
|
|
153
|
+
* - A tap beside the image calls `onBackgroundTap` from the click that
|
|
154
|
+
* follows it, so the overlay is still there to take a touch's synthesized
|
|
155
|
+
* click instead of the page beneath.
|
|
156
|
+
*
|
|
157
|
+
* `mode` is the host's (its Fit / Actual size control); a gesture reports
|
|
158
|
+
* `zoom` (or `fit` when it lands back on the Fit scale) through
|
|
159
|
+
* `onModeChange`. The host keys the stage by the image, so stepping to
|
|
160
|
+
* another image starts at Fit. Discrete jumps (the control, a double click)
|
|
161
|
+
* ease over 180 ms unless the reader prefers reduced motion; gestures never
|
|
162
|
+
* animate.
|
|
163
|
+
*
|
|
164
|
+
* Host seams (all optional; the lightbox passes none of them): `fitAxis`
|
|
165
|
+
* (Fit fills the width or height, the smallest scale stays the whole
|
|
166
|
+
* image), `padding` (Fit leaves that many pixels on every side), `overlay`
|
|
167
|
+
* (drawn over the image in the same transform, e.g. a marked-up copy, so it
|
|
168
|
+
* stays aligned at any zoom), and `onImageLoad` / `onImageError`.
|
|
169
|
+
*
|
|
170
|
+
* Region comments (#2100) add three more, also optional: `drawing` (a
|
|
171
|
+
* primary press and drag draws a box, reported through `onDraw` in the
|
|
172
|
+
* image's own pixels, instead of panning; two fingers still pinch and pan,
|
|
173
|
+
* and a second finger cancels the box), `onViewChange` (every viewport, so a
|
|
174
|
+
* host can draw boxes in stage pixels at a constant stroke), and `children`
|
|
175
|
+
* (drawn over the stage, untransformed). The handle's `zoomTo` shows a box
|
|
176
|
+
* large and centred, and `visibleBox` is the part of the image on screen.
|
|
177
|
+
*/
|
|
178
|
+
export const ImageZoomStage = forwardRef<ImageZoomStageHandle, {
|
|
179
|
+
src: string;
|
|
180
|
+
alt: string;
|
|
181
|
+
mode: ZoomMode;
|
|
182
|
+
onModeChange: (mode: ZoomMode) => void;
|
|
183
|
+
/** The current scale (1 = Actual size), on every change. */
|
|
184
|
+
onScaleChange?: (scale: number) => void;
|
|
185
|
+
/** The share of the stage Fit may fill on each axis. */
|
|
186
|
+
inset?: ZoomInset;
|
|
187
|
+
/** Fit leaves this many CSS pixels on every side (wins over `inset`). */
|
|
188
|
+
padding?: number;
|
|
189
|
+
/** Which way Fit fits (default: the whole image). */
|
|
190
|
+
fitAxis?: FitAxis;
|
|
191
|
+
/** Drawn over the image at its own size, in the image's transform. */
|
|
192
|
+
overlay?: React.ReactNode;
|
|
193
|
+
/** The image's own size, once it has loaded. */
|
|
194
|
+
onImageLoad?: (size: ZoomSize) => void;
|
|
195
|
+
/** The image failed to load; the host shows its own message (the stage then shows none). */
|
|
196
|
+
onImageError?: () => void;
|
|
197
|
+
/** A tap (a click that did not pan) beside the image, e.g. to close. */
|
|
198
|
+
onBackgroundTap?: () => void;
|
|
199
|
+
/** The stage's box, its position included (`relative` when absent). */
|
|
200
|
+
className?: string;
|
|
201
|
+
imageClassName?: string;
|
|
202
|
+
/** The image's corner radius at Actual size; kept constant on screen at every scale. */
|
|
203
|
+
radius?: number;
|
|
204
|
+
stageAttributes?: Record<string, string>;
|
|
205
|
+
/** The stage's accessible name (it takes focus, as a scroller would). */
|
|
206
|
+
label: string;
|
|
207
|
+
/** A longer description of the stage (its `aria-describedby` text), e.g. how Region mode draws. */
|
|
208
|
+
description?: string;
|
|
209
|
+
/** Region mode: a primary drag draws a box (`onDraw`) instead of panning. */
|
|
210
|
+
drawing?: boolean;
|
|
211
|
+
/** The box being drawn, its end, or its cancel (Region mode). */
|
|
212
|
+
onDraw?: (draw: ZoomDraw) => void;
|
|
213
|
+
/** Every viewport the image takes, with the stage's size; `eased` when the image moves there over 180 ms (draw boxes the same way). */
|
|
214
|
+
onViewChange?: (view: ZoomViewport, stage: ZoomSize, eased: boolean) => void;
|
|
215
|
+
/** Drawn over the stage, untransformed, in stage pixels (after the image). */
|
|
216
|
+
children?: React.ReactNode;
|
|
217
|
+
/** The "⌘ + scroll to zoom" hint's gate, shared across the viewer's stages; `false` turns the hint off. Absent: once per stage. */
|
|
218
|
+
wheelHint?: WheelHintGate | false;
|
|
219
|
+
}>(function ImageZoomStage({ src, alt, mode, onModeChange, onScaleChange, inset, padding, fitAxis, overlay, onImageLoad, onImageError, onBackgroundTap, className, imageClassName, radius = 6, stageAttributes, label, description, drawing = false, onDraw, onViewChange, children, wheelHint }, ref) {
|
|
220
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
221
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
222
|
+
const [dims, setDims] = useState<ZoomSize | null>(null);
|
|
223
|
+
const [failed, setFailed] = useState(false);
|
|
224
|
+
const [overflowing, setOverflowing] = useState(false);
|
|
225
|
+
const [panning, setPanning] = useState(false);
|
|
226
|
+
|
|
227
|
+
const insetRef = useRef(inset);
|
|
228
|
+
insetRef.current = inset;
|
|
229
|
+
const paddingRef = useRef(padding);
|
|
230
|
+
paddingRef.current = padding;
|
|
231
|
+
const axisRef = useRef<FitAxis>(fitAxis ?? 'contain');
|
|
232
|
+
axisRef.current = fitAxis ?? 'contain';
|
|
233
|
+
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
234
|
+
const dimsRef = useRef<ZoomSize | null>(null);
|
|
235
|
+
const boxRef = useRef<StageBox | null>(null);
|
|
236
|
+
const viewRef = useRef<ZoomViewport | null>(null);
|
|
237
|
+
const modeRef = useRef(mode);
|
|
238
|
+
const reportedRef = useRef<ZoomMode>(mode);
|
|
239
|
+
const callbacks = useRef({ onModeChange, onScaleChange, onBackgroundTap, onImageLoad, onImageError, onDraw, onViewChange });
|
|
240
|
+
callbacks.current = { onModeChange, onScaleChange, onBackgroundTap, onImageLoad, onImageError, onDraw, onViewChange };
|
|
241
|
+
const drawingRef = useRef(drawing);
|
|
242
|
+
drawingRef.current = drawing;
|
|
243
|
+
const descriptionId = React.useId();
|
|
244
|
+
const pointers = useRef(new Map<number, { x: number; y: number }>());
|
|
245
|
+
const press = useRef<Press | null>(null);
|
|
246
|
+
const pinch = useRef<{ dist: number; midX: number; midY: number } | null>(null);
|
|
247
|
+
const lastTap = useRef<{ t: number; x: number; y: number } | null>(null);
|
|
248
|
+
const latch = useRef<{ kind: 'zoom' | 'pan'; until: number } | null>(null);
|
|
249
|
+
// A background tap closes from the click that follows it, never from the
|
|
250
|
+
// pointerup: closing on pointerup unmounted the overlay before a touch's
|
|
251
|
+
// synthesized click, which then landed on whatever lay underneath.
|
|
252
|
+
const closeArmed = useRef(0);
|
|
253
|
+
const safariGesture = useRef<{ scale: number } | null>(null);
|
|
254
|
+
|
|
255
|
+
// The "⌘ + scroll to zoom" hint (#2127).
|
|
256
|
+
const ownGate = useRef<WheelHintGate>({ shown: false });
|
|
257
|
+
const gateRef = useRef<WheelHintGate | false>(wheelHint ?? ownGate.current);
|
|
258
|
+
gateRef.current = wheelHint ?? ownGate.current;
|
|
259
|
+
/** Mounted once shown; `visible` drives its opacity. */
|
|
260
|
+
const [hint, setHint] = useState<{ visible: boolean } | null>(null);
|
|
261
|
+
const [hintSpoken, setHintSpoken] = useState('');
|
|
262
|
+
const hintVisible = useRef(false);
|
|
263
|
+
const hintTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
264
|
+
const hideHint = useCallback(() => {
|
|
265
|
+
if (hintTimer.current) clearTimeout(hintTimer.current);
|
|
266
|
+
hintTimer.current = null;
|
|
267
|
+
if (!hintVisible.current) return;
|
|
268
|
+
hintVisible.current = false;
|
|
269
|
+
setHint({ visible: false });
|
|
270
|
+
// Said once already; an empty region is not read again as part of the stage.
|
|
271
|
+
setHintSpoken('');
|
|
272
|
+
}, []);
|
|
273
|
+
/** A plain wheel scrolled the image: show the hint the first time, keep it while the wheel keeps scrolling. */
|
|
274
|
+
const wheelPanned = useCallback(() => {
|
|
275
|
+
const gate = gateRef.current;
|
|
276
|
+
if (!hintVisible.current) {
|
|
277
|
+
if (!gate || gate.shown || !hasFinePointer()) return;
|
|
278
|
+
gate.shown = true;
|
|
279
|
+
hintVisible.current = true;
|
|
280
|
+
setHint({ visible: true });
|
|
281
|
+
setHintSpoken(wheelHintText.spoken);
|
|
282
|
+
}
|
|
283
|
+
if (hintTimer.current) clearTimeout(hintTimer.current);
|
|
284
|
+
hintTimer.current = setTimeout(hideHint, WHEEL_HINT_HOLD_MS);
|
|
285
|
+
}, [hideHint]);
|
|
286
|
+
useEffect(() => () => {
|
|
287
|
+
if (hintTimer.current) clearTimeout(hintTimer.current);
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
290
|
+
const measure = useCallback((): StageBox | null => {
|
|
291
|
+
const stage = stageRef.current;
|
|
292
|
+
if (!stage) return null;
|
|
293
|
+
const r = stage.getBoundingClientRect();
|
|
294
|
+
boxRef.current = { width: r.width, height: r.height, left: r.left, top: r.top };
|
|
295
|
+
return boxRef.current;
|
|
296
|
+
}, []);
|
|
297
|
+
|
|
298
|
+
const insetNow = (): ZoomInset | undefined => (paddingRef.current !== undefined && boxRef.current ? paddingInset(boxRef.current, paddingRef.current) : insetRef.current);
|
|
299
|
+
const limits = () => zoomLimits(dimsRef.current!, boxRef.current!, insetNow());
|
|
300
|
+
/** The Fit scale (the whole image, or its width or height). */
|
|
301
|
+
const fitNow = () => fitScale(dimsRef.current!, boxRef.current!, insetNow(), axisRef.current);
|
|
302
|
+
const ready = () => !!dimsRef.current && !!boxRef.current && boxRef.current.width > 0 && boxRef.current.height > 0;
|
|
303
|
+
|
|
304
|
+
const paint = useCallback(
|
|
305
|
+
(el: HTMLElement, view: ZoomViewport, animate: boolean) => {
|
|
306
|
+
el.style.transition = animate && !prefersReducedMotion() ? 'transform 180ms cubic-bezier(0.2, 0, 0, 1)' : 'none';
|
|
307
|
+
el.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.scale})`;
|
|
308
|
+
el.style.borderRadius = `${radius / view.scale}px`;
|
|
309
|
+
},
|
|
310
|
+
[radius],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// The overlay mounts and unmounts on its own (a comment's drawing shown or
|
|
314
|
+
// not): it takes the image's transform the moment it is attached.
|
|
315
|
+
const attachOverlay = useCallback(
|
|
316
|
+
(el: HTMLDivElement | null) => {
|
|
317
|
+
overlayRef.current = el;
|
|
318
|
+
if (el && viewRef.current) paint(el, viewRef.current, false);
|
|
319
|
+
},
|
|
320
|
+
[paint],
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const apply = useCallback(
|
|
324
|
+
(view: ZoomViewport, animate = false) => {
|
|
325
|
+
viewRef.current = view;
|
|
326
|
+
if (imgRef.current) paint(imgRef.current, view, animate);
|
|
327
|
+
if (overlayRef.current) paint(overlayRef.current, view, animate);
|
|
328
|
+
if (dimsRef.current && boxRef.current) setOverflowing(overflows(view, dimsRef.current, boxRef.current));
|
|
329
|
+
callbacks.current.onScaleChange?.(view.scale);
|
|
330
|
+
if (boxRef.current) callbacks.current.onViewChange?.(view, { width: boxRef.current.width, height: boxRef.current.height }, animate && !prefersReducedMotion());
|
|
331
|
+
},
|
|
332
|
+
[paint],
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
/** A gesture moved the image: report Fit when it is back at the Fit scale, else zoom. */
|
|
336
|
+
const gesture = (view: ZoomViewport) => {
|
|
337
|
+
apply(view);
|
|
338
|
+
const next: ZoomMode = atFit(view, fitNow()) ? 'fit' : 'zoom';
|
|
339
|
+
if (next !== reportedRef.current) {
|
|
340
|
+
reportedRef.current = next;
|
|
341
|
+
modeRef.current = next;
|
|
342
|
+
callbacks.current.onModeChange(next);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const applyMode = useCallback(
|
|
347
|
+
(next: ZoomMode, animate: boolean) => {
|
|
348
|
+
if (!ready()) return;
|
|
349
|
+
const image = dimsRef.current!;
|
|
350
|
+
const box = boxRef.current!;
|
|
351
|
+
if (next === 'fit') apply(fitViewport(image, box, insetNow(), axisRef.current), animate);
|
|
352
|
+
else if (next === 'actual') apply(actualViewport(image, box, insetNow()), animate);
|
|
353
|
+
else if (viewRef.current) apply(clampViewport(viewRef.current, image, box, limits()), animate);
|
|
354
|
+
},
|
|
355
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
356
|
+
[apply],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// The host's control: Fit or Actual size (a mode we reported ourselves is already on screen).
|
|
360
|
+
useLayoutEffect(() => {
|
|
361
|
+
modeRef.current = mode;
|
|
362
|
+
if (mode === reportedRef.current) return;
|
|
363
|
+
reportedRef.current = mode;
|
|
364
|
+
applyMode(mode, true);
|
|
365
|
+
}, [mode, applyMode]);
|
|
366
|
+
|
|
367
|
+
// The host changed how Fit fits (its padding, the fit axis): Fit refits, anything else stays within the new limits.
|
|
368
|
+
const fitShape = useRef({ padding, fitAxis });
|
|
369
|
+
useLayoutEffect(() => {
|
|
370
|
+
if (fitShape.current.padding === padding && fitShape.current.fitAxis === fitAxis) return;
|
|
371
|
+
fitShape.current = { padding, fitAxis };
|
|
372
|
+
measure();
|
|
373
|
+
if (!ready() || !viewRef.current) return;
|
|
374
|
+
if (modeRef.current === 'fit') applyMode('fit', false);
|
|
375
|
+
else apply(clampViewport(viewRef.current, dimsRef.current!, boxRef.current!, limits()));
|
|
376
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
377
|
+
}, [padding, fitAxis, measure, applyMode, apply]);
|
|
378
|
+
|
|
379
|
+
// The image's own size, once it has loaded (or at once when it was cached).
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
const img = imgRef.current;
|
|
382
|
+
if (img && img.complete && img.naturalWidth > 0) {
|
|
383
|
+
const size = { width: img.naturalWidth, height: img.naturalHeight };
|
|
384
|
+
setDims(size);
|
|
385
|
+
callbacks.current.onImageLoad?.(size);
|
|
386
|
+
}
|
|
387
|
+
}, []);
|
|
388
|
+
|
|
389
|
+
useLayoutEffect(() => {
|
|
390
|
+
dimsRef.current = dims;
|
|
391
|
+
if (!dims) return;
|
|
392
|
+
measure();
|
|
393
|
+
applyMode(modeRef.current === 'zoom' ? 'fit' : modeRef.current, false);
|
|
394
|
+
}, [dims, measure, applyMode]);
|
|
395
|
+
|
|
396
|
+
// The stage resized (the window, a phone turned): Fit refits, anything else keeps its scale within the new limits.
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const stage = stageRef.current;
|
|
399
|
+
if (!stage || typeof ResizeObserver === 'undefined') return;
|
|
400
|
+
const observer = new ResizeObserver(() => {
|
|
401
|
+
measure();
|
|
402
|
+
if (!ready()) return;
|
|
403
|
+
if (modeRef.current === 'fit' || !viewRef.current) applyMode('fit', false);
|
|
404
|
+
else if (modeRef.current === 'zoom') gesture(clampViewport(viewRef.current, dimsRef.current!, boxRef.current!, limits()));
|
|
405
|
+
else apply(clampViewport(viewRef.current, dimsRef.current!, boxRef.current!, limits()));
|
|
406
|
+
});
|
|
407
|
+
observer.observe(stage);
|
|
408
|
+
return () => observer.disconnect();
|
|
409
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
410
|
+
}, [measure, applyMode, apply]);
|
|
411
|
+
|
|
412
|
+
// Wheel and trackpad pinch. A non-passive listener, so preventDefault keeps
|
|
413
|
+
// the browser from zooming the page (Ctrl+wheel, pinch) or swiping back.
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
const stage = stageRef.current;
|
|
416
|
+
if (!stage) return;
|
|
417
|
+
const onWheel = (event: WheelEvent) => {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
420
|
+
const latched = latch.current && now < latch.current.until ? latch.current : null;
|
|
421
|
+
// One layout read at the start of a gesture, never between writes.
|
|
422
|
+
if (!latched || !boxRef.current) measure();
|
|
423
|
+
if (!ready() || !viewRef.current) return;
|
|
424
|
+
const image = dimsRef.current!;
|
|
425
|
+
const box = boxRef.current!;
|
|
426
|
+
const { dx, dy } = wheelTravel(event, box.height);
|
|
427
|
+
const px = Number.isFinite(event.clientX) ? event.clientX - box.left : box.width / 2;
|
|
428
|
+
const py = Number.isFinite(event.clientY) ? event.clientY - box.top : box.height / 2;
|
|
429
|
+
if (event.ctrlKey || event.metaKey) {
|
|
430
|
+
latch.current = null;
|
|
431
|
+
hideHint();
|
|
432
|
+
if (Math.abs(dy) < 0.1 || safariGesture.current) return;
|
|
433
|
+
gesture(zoomAt(viewRef.current, wheelZoomFactor(dy), px, py, image, box, limits()));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) return;
|
|
437
|
+
const kind = latched ? latched.kind : overflows(viewRef.current, image, box) ? 'pan' : 'zoom';
|
|
438
|
+
latch.current = { kind, until: now + WHEEL_LATCH_MS };
|
|
439
|
+
if (kind === 'pan') {
|
|
440
|
+
gesture(panBy(viewRef.current, -dx, -dy, image, box, limits()));
|
|
441
|
+
wheelPanned();
|
|
442
|
+
} else if (Math.abs(dy) > Math.abs(dx)) {
|
|
443
|
+
// A sideways swipe over a fitted image is not a zoom.
|
|
444
|
+
gesture(zoomAt(viewRef.current, wheelZoomFactor(dy), px, py, image, box, limits()));
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
stage.addEventListener('wheel', onWheel, { passive: false });
|
|
448
|
+
return () => stage.removeEventListener('wheel', onWheel);
|
|
449
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
450
|
+
}, [measure, hideHint, wheelPanned]);
|
|
451
|
+
|
|
452
|
+
// Safari sends a trackpad pinch (and, on iOS, two fingers) as gesture
|
|
453
|
+
// events, not Ctrl + wheel, and zooms the page with them unless they are
|
|
454
|
+
// prevented. On iOS the same fingers also arrive as pointers, which the
|
|
455
|
+
// pinch below already follows, so a gesture then only prevents the page zoom.
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
const stage = stageRef.current;
|
|
458
|
+
if (!stage) return;
|
|
459
|
+
const onStart = (event: Event) => {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
measure();
|
|
462
|
+
hideHint();
|
|
463
|
+
safariGesture.current = { scale: 1 };
|
|
464
|
+
};
|
|
465
|
+
const onChange = (event: Event) => {
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
const g = event as GestureLike;
|
|
468
|
+
const last = safariGesture.current ?? { scale: 1 };
|
|
469
|
+
const scale = Number.isFinite(g.scale) && g.scale > 0 ? g.scale : last.scale;
|
|
470
|
+
safariGesture.current = { scale };
|
|
471
|
+
if (pointers.current.size >= 2 || !ready() || !viewRef.current) return;
|
|
472
|
+
const box = boxRef.current!;
|
|
473
|
+
const px = Number.isFinite(g.clientX) ? g.clientX - box.left : box.width / 2;
|
|
474
|
+
const py = Number.isFinite(g.clientY) ? g.clientY - box.top : box.height / 2;
|
|
475
|
+
gesture(zoomAt(viewRef.current, scale / last.scale, px, py, dimsRef.current!, box, limits()));
|
|
476
|
+
};
|
|
477
|
+
const onEnd = (event: Event) => {
|
|
478
|
+
event.preventDefault();
|
|
479
|
+
safariGesture.current = null;
|
|
480
|
+
};
|
|
481
|
+
stage.addEventListener('gesturestart', onStart, { passive: false });
|
|
482
|
+
stage.addEventListener('gesturechange', onChange, { passive: false });
|
|
483
|
+
stage.addEventListener('gestureend', onEnd, { passive: false });
|
|
484
|
+
return () => {
|
|
485
|
+
stage.removeEventListener('gesturestart', onStart);
|
|
486
|
+
stage.removeEventListener('gesturechange', onChange);
|
|
487
|
+
stage.removeEventListener('gestureend', onEnd);
|
|
488
|
+
};
|
|
489
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
490
|
+
}, [measure, hideHint]);
|
|
491
|
+
|
|
492
|
+
useImperativeHandle(
|
|
493
|
+
ref,
|
|
494
|
+
() => ({
|
|
495
|
+
get element() {
|
|
496
|
+
return stageRef.current;
|
|
497
|
+
},
|
|
498
|
+
scrollKey(key: string, shiftKey: boolean, arrowsSideways: boolean) {
|
|
499
|
+
let dx = 0;
|
|
500
|
+
let dy = 0;
|
|
501
|
+
const page = (boxRef.current?.height ?? 0) * KEY_PAGE_SHARE;
|
|
502
|
+
switch (key) {
|
|
503
|
+
case 'ArrowUp': dy = KEY_SCROLL_PX; break;
|
|
504
|
+
case 'ArrowDown': dy = -KEY_SCROLL_PX; break;
|
|
505
|
+
case 'ArrowLeft': if (!arrowsSideways) return false; dx = KEY_SCROLL_PX; break;
|
|
506
|
+
case 'ArrowRight': if (!arrowsSideways) return false; dx = -KEY_SCROLL_PX; break;
|
|
507
|
+
case 'PageUp': dy = page; break;
|
|
508
|
+
case 'PageDown': dy = -page; break;
|
|
509
|
+
case ' ': dy = shiftKey ? page : -page; break;
|
|
510
|
+
case 'Home': dy = Number.MAX_SAFE_INTEGER; break;
|
|
511
|
+
case 'End': dy = -Number.MAX_SAFE_INTEGER; break;
|
|
512
|
+
default: return false;
|
|
513
|
+
}
|
|
514
|
+
measure();
|
|
515
|
+
if (ready() && viewRef.current) gesture(panBy(viewRef.current, dx, dy, dimsRef.current!, boxRef.current!, limits()));
|
|
516
|
+
return true;
|
|
517
|
+
},
|
|
518
|
+
zoomTo(box: NaturalBox, animate = true) {
|
|
519
|
+
measure();
|
|
520
|
+
if (!ready()) return;
|
|
521
|
+
const view = viewportForBox(box, dimsRef.current!, boxRef.current!, limits());
|
|
522
|
+
apply(view, animate);
|
|
523
|
+
const next: ZoomMode = atFit(view, fitNow()) ? 'fit' : 'zoom';
|
|
524
|
+
if (next !== reportedRef.current) {
|
|
525
|
+
reportedRef.current = next;
|
|
526
|
+
modeRef.current = next;
|
|
527
|
+
callbacks.current.onModeChange(next);
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
visibleBox() {
|
|
531
|
+
measure();
|
|
532
|
+
if (!ready() || !viewRef.current) return null;
|
|
533
|
+
return visibleNaturalBox(viewRef.current, dimsRef.current!, boxRef.current!);
|
|
534
|
+
},
|
|
535
|
+
}),
|
|
536
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
537
|
+
[measure],
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const toggleZoomAt = (px: number, py: number) => {
|
|
541
|
+
if (!ready() || !viewRef.current) return;
|
|
542
|
+
const lim = limits();
|
|
543
|
+
const fit = fitNow();
|
|
544
|
+
if (!atFit(viewRef.current, fit)) {
|
|
545
|
+
reportedRef.current = 'fit';
|
|
546
|
+
modeRef.current = 'fit';
|
|
547
|
+
apply(fitViewport(dimsRef.current!, boxRef.current!, insetNow(), axisRef.current), true);
|
|
548
|
+
callbacks.current.onModeChange('fit');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const target = Math.min(lim.max, fit * DOUBLE_TAP_ZOOM);
|
|
552
|
+
const view = zoomAt(viewRef.current, target / viewRef.current.scale, px, py, dimsRef.current!, boxRef.current!, lim);
|
|
553
|
+
reportedRef.current = 'zoom';
|
|
554
|
+
modeRef.current = 'zoom';
|
|
555
|
+
apply(view, true);
|
|
556
|
+
callbacks.current.onModeChange('zoom');
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const tap = (clientX: number, clientY: number) => {
|
|
560
|
+
const box = boxRef.current;
|
|
561
|
+
const view = viewRef.current;
|
|
562
|
+
const image = dimsRef.current;
|
|
563
|
+
if (!box || !view || !image) {
|
|
564
|
+
lastTap.current = null;
|
|
565
|
+
closeArmed.current = Date.now();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const px = clientX - box.left;
|
|
569
|
+
const py = clientY - box.top;
|
|
570
|
+
const onImage = px >= view.x && px <= view.x + image.width * view.scale && py >= view.y && py <= view.y + image.height * view.scale;
|
|
571
|
+
if (!onImage) {
|
|
572
|
+
lastTap.current = null;
|
|
573
|
+
closeArmed.current = Date.now();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
const prev = lastTap.current;
|
|
578
|
+
if (prev && now - prev.t <= DOUBLE_TAP_MS && Math.hypot(clientX - prev.x, clientY - prev.y) <= DOUBLE_TAP_SLOP_PX) {
|
|
579
|
+
lastTap.current = null;
|
|
580
|
+
toggleZoomAt(px, py);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
lastTap.current = { t: now, x: clientX, y: clientY };
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const pinchState = () => {
|
|
587
|
+
const [a, b] = [...pointers.current.values()];
|
|
588
|
+
return { dist: Math.max(1, Math.hypot(a!.x - b!.x, a!.y - b!.y)), midX: (a!.x + b!.x) / 2, midY: (a!.y + b!.y) / 2 };
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
592
|
+
closeArmed.current = 0;
|
|
593
|
+
hideHint();
|
|
594
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
595
|
+
measure();
|
|
596
|
+
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
597
|
+
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
|
598
|
+
try {
|
|
599
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
600
|
+
} catch {
|
|
601
|
+
// A synthetic pointer (tests) has nothing to capture.
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (pointers.current.size === 2) {
|
|
605
|
+
pinch.current = pinchState();
|
|
606
|
+
if (press.current) {
|
|
607
|
+
// A second finger turns a box being drawn into a pinch.
|
|
608
|
+
if (press.current.draw) {
|
|
609
|
+
press.current.draw = false;
|
|
610
|
+
callbacks.current.onDraw?.({ box: null, phase: 'cancel' });
|
|
611
|
+
}
|
|
612
|
+
press.current.moved = true;
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (pointers.current.size > 2) return;
|
|
617
|
+
press.current = {
|
|
618
|
+
id: event.pointerId,
|
|
619
|
+
startX: event.clientX,
|
|
620
|
+
startY: event.clientY,
|
|
621
|
+
lastX: event.clientX,
|
|
622
|
+
lastY: event.clientY,
|
|
623
|
+
slop: event.pointerType === 'touch' ? TAP_SLOP_TOUCH_PX : TAP_SLOP_MOUSE_PX,
|
|
624
|
+
moved: false,
|
|
625
|
+
draw: drawingRef.current,
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/** The box a draw press spans now, in the image's own pixels (null off the image or too small). */
|
|
630
|
+
const drawnBox = (p: Press, clientX: number, clientY: number): NaturalBox | null => {
|
|
631
|
+
const box = boxRef.current;
|
|
632
|
+
const view = viewRef.current;
|
|
633
|
+
const image = dimsRef.current;
|
|
634
|
+
if (!box || !view || !image) return null;
|
|
635
|
+
if (Math.abs(clientX - p.startX) < MIN_DRAW_SCREEN_PX || Math.abs(clientY - p.startY) < MIN_DRAW_SCREEN_PX) return null;
|
|
636
|
+
return naturalBoxFromPoints(view, { x: p.startX - box.left, y: p.startY - box.top }, { x: clientX - box.left, y: clientY - box.top }, image);
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
640
|
+
if (!pointers.current.has(event.pointerId)) return;
|
|
641
|
+
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
642
|
+
if (!ready() || !viewRef.current) return;
|
|
643
|
+
const image = dimsRef.current!;
|
|
644
|
+
const box = boxRef.current!;
|
|
645
|
+
if (pinch.current && pointers.current.size >= 2) {
|
|
646
|
+
const next = pinchState();
|
|
647
|
+
const prev = pinch.current;
|
|
648
|
+
let view = zoomAt(viewRef.current, next.dist / prev.dist, next.midX - box.left, next.midY - box.top, image, box, limits());
|
|
649
|
+
view = panBy(view, next.midX - prev.midX, next.midY - prev.midY, image, box, limits());
|
|
650
|
+
pinch.current = next;
|
|
651
|
+
gesture(view);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const p = press.current;
|
|
655
|
+
if (!p || p.id !== event.pointerId) return;
|
|
656
|
+
if (!p.moved) {
|
|
657
|
+
if (Math.hypot(event.clientX - p.startX, event.clientY - p.startY) < p.slop) return;
|
|
658
|
+
p.moved = true;
|
|
659
|
+
if (!p.draw && overflows(viewRef.current, image, box)) setPanning(true);
|
|
660
|
+
}
|
|
661
|
+
if (p.draw) {
|
|
662
|
+
callbacks.current.onDraw?.({ box: drawnBox(p, event.clientX, event.clientY), phase: 'move' });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const dx = event.clientX - p.lastX;
|
|
666
|
+
const dy = event.clientY - p.lastY;
|
|
667
|
+
p.lastX = event.clientX;
|
|
668
|
+
p.lastY = event.clientY;
|
|
669
|
+
gesture(panBy(viewRef.current, dx, dy, image, box, limits()));
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const endPointer = (event: React.PointerEvent<HTMLDivElement>, cancelled: boolean) => {
|
|
673
|
+
if (!pointers.current.has(event.pointerId)) return;
|
|
674
|
+
pointers.current.delete(event.pointerId);
|
|
675
|
+
if (pinch.current) {
|
|
676
|
+
if (pointers.current.size < 2) pinch.current = null;
|
|
677
|
+
// The finger left behind carries on panning from where it is, never as a tap.
|
|
678
|
+
const [rest] = [...pointers.current.entries()];
|
|
679
|
+
if (rest) {
|
|
680
|
+
const [id, at] = rest;
|
|
681
|
+
press.current = { id, startX: at.x, startY: at.y, lastX: at.x, lastY: at.y, slop: 0, moved: true };
|
|
682
|
+
} else {
|
|
683
|
+
press.current = null;
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const p = press.current;
|
|
688
|
+
if (!p || p.id !== event.pointerId) return;
|
|
689
|
+
press.current = null;
|
|
690
|
+
setPanning(false);
|
|
691
|
+
if (p.draw && p.moved) {
|
|
692
|
+
const drawn = cancelled ? null : drawnBox(p, event.clientX, event.clientY);
|
|
693
|
+
callbacks.current.onDraw?.(drawn ? { box: drawn, phase: 'end' } : { box: null, phase: 'cancel' });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!cancelled && !p.moved) tap(event.clientX, event.clientY);
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const onClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
700
|
+
const armed = closeArmed.current;
|
|
701
|
+
closeArmed.current = 0;
|
|
702
|
+
if (!armed || Date.now() - armed > CLICK_AFTER_TAP_MS || !callbacks.current.onBackgroundTap) return;
|
|
703
|
+
// The overlay goes away with this click, so nothing beneath receives it.
|
|
704
|
+
event.preventDefault();
|
|
705
|
+
event.stopPropagation();
|
|
706
|
+
callbacks.current.onBackgroundTap();
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const cursor = drawing ? 'crosshair' : panning ? 'grabbing' : overflowing ? 'grab' : onBackgroundTap ? 'zoom-out' : 'default';
|
|
710
|
+
return (
|
|
711
|
+
<div
|
|
712
|
+
ref={stageRef}
|
|
713
|
+
className={`touch-none select-none overflow-hidden outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${className ?? 'relative'}`}
|
|
714
|
+
style={{ cursor }}
|
|
715
|
+
tabIndex={0}
|
|
716
|
+
role="group"
|
|
717
|
+
aria-label={label}
|
|
718
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
719
|
+
onClick={onClick}
|
|
720
|
+
onPointerDown={onPointerDown}
|
|
721
|
+
onPointerMove={onPointerMove}
|
|
722
|
+
onPointerUp={(e) => endPointer(e, false)}
|
|
723
|
+
onPointerCancel={(e) => endPointer(e, true)}
|
|
724
|
+
{...stageAttributes}
|
|
725
|
+
>
|
|
726
|
+
<img
|
|
727
|
+
ref={imgRef}
|
|
728
|
+
src={src}
|
|
729
|
+
alt={alt}
|
|
730
|
+
draggable={false}
|
|
731
|
+
onLoad={(e) => {
|
|
732
|
+
const img = e.currentTarget;
|
|
733
|
+
if (img.naturalWidth > 0) {
|
|
734
|
+
const size = { width: img.naturalWidth, height: img.naturalHeight };
|
|
735
|
+
setDims(size);
|
|
736
|
+
callbacks.current.onImageLoad?.(size);
|
|
737
|
+
}
|
|
738
|
+
}}
|
|
739
|
+
onError={() => {
|
|
740
|
+
setFailed(true);
|
|
741
|
+
callbacks.current.onImageError?.();
|
|
742
|
+
}}
|
|
743
|
+
className={imageClassName}
|
|
744
|
+
style={{
|
|
745
|
+
position: 'absolute',
|
|
746
|
+
left: 0,
|
|
747
|
+
top: 0,
|
|
748
|
+
maxWidth: 'none',
|
|
749
|
+
maxHeight: 'none',
|
|
750
|
+
width: dims ? `${dims.width}px` : undefined,
|
|
751
|
+
height: dims ? `${dims.height}px` : undefined,
|
|
752
|
+
transformOrigin: '0 0',
|
|
753
|
+
opacity: dims ? 1 : 0,
|
|
754
|
+
// Beside the image a click closes (zoom-out); on it, a click does nothing.
|
|
755
|
+
cursor: drawing ? 'crosshair' : onBackgroundTap && !overflowing ? 'default' : undefined,
|
|
756
|
+
}}
|
|
757
|
+
data-lightbox-image=""
|
|
758
|
+
/>
|
|
759
|
+
{overlay != null && dims && (
|
|
760
|
+
<div
|
|
761
|
+
ref={attachOverlay}
|
|
762
|
+
style={{ position: 'absolute', left: 0, top: 0, width: `${dims.width}px`, height: `${dims.height}px`, transformOrigin: '0 0', pointerEvents: 'none', overflow: 'hidden' }}
|
|
763
|
+
data-zoom-overlay=""
|
|
764
|
+
>
|
|
765
|
+
{overlay}
|
|
766
|
+
</div>
|
|
767
|
+
)}
|
|
768
|
+
{children}
|
|
769
|
+
{hint && (
|
|
770
|
+
<div
|
|
771
|
+
className="pointer-events-none absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-full bg-black/75 px-4 py-2 text-sm font-medium text-white shadow-lg motion-safe:transition-opacity motion-reduce:transition-none"
|
|
772
|
+
style={{ opacity: hint.visible ? 1 : 0, transitionDuration: hint.visible ? '150ms' : `${WHEEL_HINT_FADE_MS}ms` }}
|
|
773
|
+
aria-hidden="true"
|
|
774
|
+
data-wheel-hint={hint.visible ? 'shown' : 'hidden'}
|
|
775
|
+
>
|
|
776
|
+
{wheelHintText.visible.before}{' '}
|
|
777
|
+
<kbd className="mx-0.5 inline-block min-w-[1.5em] rounded border border-white/40 bg-white/10 px-1.5 py-px text-center font-sans text-[0.95em] leading-tight">{wheelHintText.visible.key}</kbd>{' '}
|
|
778
|
+
{wheelHintText.visible.after}
|
|
779
|
+
</div>
|
|
780
|
+
)}
|
|
781
|
+
<span className="sr-only" role="status" aria-live="polite" data-wheel-hint-live="">
|
|
782
|
+
{hintSpoken}
|
|
783
|
+
</span>
|
|
784
|
+
{description && (
|
|
785
|
+
<span id={descriptionId} hidden>
|
|
786
|
+
{description}
|
|
787
|
+
</span>
|
|
788
|
+
)}
|
|
789
|
+
{failed && !onImageError && (
|
|
790
|
+
<div className="absolute inset-0 flex items-center justify-center text-sm opacity-70" data-lightbox-failed="">
|
|
791
|
+
This image could not be loaded.
|
|
792
|
+
</div>
|
|
793
|
+
)}
|
|
794
|
+
</div>
|
|
795
|
+
);
|
|
796
|
+
});
|