@dumiflex/wp-kit 0.3.0 → 0.4.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +77 -51
  2. package/LICENSE +674 -674
  3. package/README.md +73 -76
  4. package/package.json +78 -77
  5. package/src/Avatar.vue +83 -83
  6. package/src/Badge.vue +97 -97
  7. package/src/Banner.vue +118 -118
  8. package/src/BookmarkButton.vue +27 -27
  9. package/src/BundleMeta.vue +51 -51
  10. package/src/Button.vue +103 -103
  11. package/src/Card.vue +68 -68
  12. package/src/Checkbox.vue +109 -107
  13. package/src/Chip.vue +163 -97
  14. package/src/ChoiceCards.vue +133 -133
  15. package/src/CloseButton.vue +69 -69
  16. package/src/CodeBlock.vue +119 -119
  17. package/src/ColorField.vue +188 -206
  18. package/src/ColorPicker.vue +201 -201
  19. package/src/Combobox.vue +331 -334
  20. package/src/ConfirmDialog.vue +165 -163
  21. package/src/ConfirmModal.vue +147 -147
  22. package/src/ConstraintMatrixPreview.vue +708 -708
  23. package/src/ContextMenu.vue +386 -379
  24. package/src/CopyButton.vue +156 -156
  25. package/src/DataTable.vue +292 -292
  26. package/src/DatePicker.vue +234 -234
  27. package/src/DescriptionList.vue +73 -73
  28. package/src/DiffView.vue +68 -68
  29. package/src/Disclosure.vue +166 -166
  30. package/src/DragHandle.vue +49 -49
  31. package/src/Drawer.vue +131 -131
  32. package/src/DropZone.vue +196 -196
  33. package/src/DropdownMenu.vue +72 -72
  34. package/src/EmptyState.vue +111 -111
  35. package/src/Field.vue +28 -28
  36. package/src/HsvPicker.vue +208 -208
  37. package/src/Icon.vue +39 -39
  38. package/src/IconPicker.vue +209 -209
  39. package/src/ImageGrid.vue +125 -125
  40. package/src/InlineEdit.vue +183 -183
  41. package/src/Input.vue +99 -99
  42. package/src/Kbd.vue +56 -56
  43. package/src/Lightbox.vue +173 -173
  44. package/src/ListFilter.vue +85 -85
  45. package/src/ListRow.vue +204 -204
  46. package/src/LoadingMark.vue +175 -161
  47. package/src/Logo.vue +60 -60
  48. package/src/Modal.vue +78 -76
  49. package/src/ModalShell.vue +109 -107
  50. package/src/Pagination.vue +106 -106
  51. package/src/Popover.vue +180 -171
  52. package/src/PostCard.vue +210 -211
  53. package/src/ProgressBar.vue +90 -90
  54. package/src/RadioGroup.vue +177 -177
  55. package/src/RangeSlider.vue +183 -183
  56. package/src/RelativeDate.vue +35 -35
  57. package/src/ResizeHandle.vue +143 -143
  58. package/src/SearchInput.vue +192 -199
  59. package/src/Select.vue +644 -641
  60. package/src/Separator.vue +54 -54
  61. package/src/Skeleton.vue +82 -82
  62. package/src/Slider.vue +198 -198
  63. package/src/SortableList.vue +260 -260
  64. package/src/SplitPane.vue +68 -68
  65. package/src/StarButton.vue +40 -40
  66. package/src/Stepper.vue +141 -141
  67. package/src/SupportLinks.vue +93 -93
  68. package/src/TagInput.vue +297 -300
  69. package/src/Textarea.vue +57 -57
  70. package/src/Timeline.vue +92 -92
  71. package/src/ToastCard.vue +119 -119
  72. package/src/ToastHost.vue +40 -40
  73. package/src/Toggle.vue +36 -36
  74. package/src/TokenizedValue.vue +310 -310
  75. package/src/Toolbar.vue +110 -110
  76. package/src/VirtualList.vue +87 -87
  77. package/src/VisuallyHidden.vue +29 -29
  78. package/src/WpButton.vue +75 -75
  79. package/src/WpCheck.vue +104 -102
  80. package/src/WpIconButton.vue +107 -107
  81. package/src/WpImageZoom.vue +544 -544
  82. package/src/WpNumber.vue +178 -183
  83. package/src/WpSegmented.vue +175 -175
  84. package/src/WpSelect.vue +93 -89
  85. package/src/WpSwitch.vue +163 -163
  86. package/src/WpTabs.vue +188 -188
  87. package/src/_modal-motion.css +33 -33
  88. package/src/_skeleton.css +50 -50
  89. package/src/axis-color.ts +42 -42
  90. package/src/button.css +169 -169
  91. package/src/calendar.ts +59 -59
  92. package/src/clipboard.ts +50 -50
  93. package/src/colour-presets.ts +12 -12
  94. package/src/diff.ts +51 -51
  95. package/src/drop-zone.ts +41 -41
  96. package/src/field.css +50 -0
  97. package/src/flip.ts +237 -237
  98. package/src/floating-layer.ts +48 -0
  99. package/src/floating.ts +99 -99
  100. package/src/hsv.ts +100 -100
  101. package/src/icon-catalog.ts +52 -52
  102. package/src/image-zoom.ts +93 -93
  103. package/src/kind-colour.ts +28 -0
  104. package/src/logo-paths.ts +21 -21
  105. package/src/matrix-axis.ts +91 -91
  106. package/src/pagination.ts +27 -27
  107. package/src/platform-keys.ts +29 -29
  108. package/src/rich-tokenize.ts +357 -357
  109. package/src/select-types.ts +32 -32
  110. package/src/slider-math.ts +45 -45
  111. package/src/sortable.ts +22 -22
  112. package/src/theme/fonts/OFL-Fraunces.txt +93 -93
  113. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
  114. package/src/theme/fonts/OFL-Manrope.txt +93 -93
  115. package/src/theme/looks.css +428 -428
  116. package/src/theme/palettes.css +254 -254
  117. package/src/theme/tokens.css +2896 -2894
  118. package/src/tokens.ts +56 -56
  119. package/src/tooltip.css +43 -43
  120. package/src/tooltip.ts +146 -146
  121. package/src/useDialogFocus.ts +73 -73
  122. package/src/useGrowableField.ts +357 -357
  123. package/src/useModalKeyShield.ts +116 -116
  124. package/src/useToast.ts +48 -48
  125. package/src/wp-logo.svg +14 -14
  126. package/src/ChipToggle.vue +0 -92
  127. package/src/KindChip.vue +0 -53
  128. package/src/RatingBadge.vue +0 -42
@@ -1,544 +1,544 @@
1
- <script setup lang="ts">
2
- /**
3
- * Image zoom: one picture with real zoom levels. Scroll zooms toward the
4
- * pointer, drag moves, double-click switches between Fit and 100% at that
5
- * spot. The bar on top has − / Fit / 100% / 200% / + and the level; when
6
- * the viewer has focus, + − 0 1 2 do the same. With `fullscreen` the bar
7
- * also gets a Fullscreen button (key F) that fills the screen with the
8
- * viewer, bar and slots included; Esc leaves it.
9
- *
10
- * `minimap` adds a small copy of the picture in the corner once it is bigger
11
- * than the stage; its box shows the part in view and a click or drag there
12
- * moves the view. `autohide` fades the bar, hint and mini-map after
13
- * `IDLE_MS` without a pointer move or key (hovering the bar keeps them).
14
- * `second` shows another picture beside the first, both locked to the same
15
- * level and spot (a synced compare; the first picture's size drives the
16
- * maths). Slots: `bar-start` / `bar-end` in the bar, `first` / `second` over
17
- * each picture, `fullscreen` along the bottom only while fullscreen (it fades
18
- * with the bar), default over the whole stage.
19
- *
20
- * `v-model:zoom` is the level (`null` = Fit). The viewer also keeps the spot
21
- * it shows when `src` changes, so stepping through a series stays on the same
22
- * part of each picture (the hands in every frame). Works on the canvas and
23
- * in the manager.
24
- *
25
- * <WpImageZoom v-model:zoom="zoom" :src="url" alt="Frame 3" />
26
- */
27
- import { computed, onBeforeUnmount, ref, watch } from "vue";
28
- import WpButton from "./WpButton.vue";
29
- import {
30
- centreOf, clampView, fitScale, percent, stepScale, viewAround, visibleRect, zoomAt, IDLE_MS, type Size, type View,
31
- } from "./image-zoom";
32
-
33
- /** The gap between the two pictures of a synced compare. */
34
- const PANE_GAP = 8;
35
-
36
- const props = withDefaults(defineProps<{
37
- src: string;
38
- alt?: string | undefined;
39
- /** The level as a real-size factor (1 = 100%); null fits the picture in. */
40
- zoom?: number | null | undefined;
41
- /** Show the zoom bar. */
42
- controls?: boolean | undefined;
43
- /** Hint shown under the bar's buttons, e.g. "Drag to move · scroll to zoom". */
44
- hint?: string | undefined;
45
- /** Offer a Fullscreen button (and key F). */
46
- fullscreen?: boolean | undefined;
47
- /** Show a mini-map once the picture is bigger than the stage. */
48
- minimap?: boolean | undefined;
49
- /** Fade the bar, hint and mini-map while the pointer and keys are still. */
50
- autohide?: boolean | undefined;
51
- /** A second picture beside the first, locked to the same level and spot. */
52
- second?: string | null | undefined;
53
- secondAlt?: string | undefined;
54
- size?: "sm" | "md" | undefined;
55
- }>(), {
56
- alt: "", zoom: null, controls: true, hint: undefined, fullscreen: false,
57
- minimap: false, autohide: false, second: null, secondAlt: "", size: "sm",
58
- });
59
-
60
- const emit = defineEmits<{ "update:zoom": [zoom: number | null] }>();
61
-
62
- const root = ref<HTMLElement | null>(null);
63
- const stage = ref<HTMLElement | null>(null);
64
- const stageSize = ref<Size>({ width: 0, height: 0 });
65
- const image = ref<Size>({ width: 0, height: 0 });
66
- const view = ref<View>({ scale: 1, x: 0, y: 0 });
67
- /** The image point (0..1) at the middle of the stage, carried to the next `src`. */
68
- let centre = { x: 0.5, y: 0.5 };
69
-
70
- /** The level shown; follows `zoom`, and works without a v-model too. */
71
- const current = ref<number | null>(props.zoom);
72
- const fit = computed(() => fitScale(image.value, stageSize.value));
73
- const isFit = computed(() => current.value === null);
74
- const ready = computed(() => image.value.width > 0 && stageSize.value.width > 0);
75
- const canPan = computed(() =>
76
- image.value.width * view.value.scale > stageSize.value.width + 0.5
77
- || image.value.height * view.value.scale > stageSize.value.height + 0.5,
78
- );
79
-
80
- function place(next: View): void {
81
- view.value = next;
82
- centre = centreOf(next, image.value, stageSize.value);
83
- }
84
-
85
- /** Lay the picture out for the current `zoom` around the kept spot. */
86
- function relayout(): void {
87
- if (!ready.value) return;
88
- if (current.value === null) {
89
- view.value = clampView({ scale: fit.value, x: 0, y: 0 }, image.value, stageSize.value);
90
- return;
91
- }
92
- place(viewAround(current.value, centre, image.value, stageSize.value));
93
- }
94
-
95
- /** Change the level around a stage point (default: the middle). */
96
- function setZoom(scale: number | null, at?: { x: number; y: number }): void {
97
- if (scale === null) {
98
- current.value = null;
99
- relayout();
100
- emit("update:zoom", null);
101
- return;
102
- }
103
- const point = at ?? { x: stageSize.value.width / 2, y: stageSize.value.height / 2 };
104
- const next = zoomAt(view.value, scale, point, image.value, stageSize.value);
105
- place(next);
106
- current.value = next.scale;
107
- emit("update:zoom", next.scale);
108
- }
109
-
110
- function zoomIn(): void { setZoom(stepScale(view.value.scale, 1, fit.value)); }
111
- function zoomOut(): void { setZoom(stepScale(view.value.scale, -1, fit.value)); }
112
-
113
- const isFull = ref(false);
114
- const canFull = computed(() => props.fullscreen && typeof document !== "undefined" && document.fullscreenEnabled !== false);
115
- function onFullChange(): void {
116
- isFull.value = !!root.value && document.fullscreenElement === root.value;
117
- }
118
- function toggleFullscreen(): void {
119
- if (!canFull.value || !root.value) return;
120
- if (document.fullscreenElement === root.value) void document.exitFullscreen?.();
121
- else void root.value.requestFullscreen?.().then(() => stage.value?.focus()).catch(() => undefined);
122
- }
123
- if (typeof document !== "undefined") document.addEventListener("fullscreenchange", onFullChange);
124
- onBeforeUnmount(() => {
125
- document.removeEventListener("fullscreenchange", onFullChange);
126
- if (root.value && document.fullscreenElement === root.value) void document.exitFullscreen?.();
127
- });
128
-
129
- defineExpose({ zoomIn, zoomOut, setZoom, toggleFullscreen });
130
-
131
- watch(() => props.zoom, (z) => {
132
- // A level set from outside (the parent, a key): keep the middle spot.
133
- if (z === current.value) return;
134
- current.value = z;
135
- relayout();
136
- });
137
-
138
- function onLoad(ev: Event): void {
139
- const img = ev.target as HTMLImageElement;
140
- image.value = { width: img.naturalWidth, height: img.naturalHeight };
141
- relayout();
142
- }
143
-
144
- let observer: ResizeObserver | null = null;
145
- let measureStage: (() => void) | null = null;
146
- watch(() => !!props.second, () => measureStage?.());
147
- watch(stage, (el) => {
148
- observer?.disconnect();
149
- if (!el) return;
150
- const measure = (): void => {
151
- // A synced compare splits the stage into two panes that share one view.
152
- const width = props.second ? Math.max(0, (el.clientWidth - PANE_GAP) / 2) : el.clientWidth;
153
- stageSize.value = { width, height: el.clientHeight };
154
- relayout();
155
- };
156
- measureStage = measure;
157
- measure();
158
- if (typeof ResizeObserver === "undefined") return;
159
- observer = new ResizeObserver(measure);
160
- observer.observe(el);
161
- });
162
- onBeforeUnmount(() => observer?.disconnect());
163
-
164
- /** A pointer position in pane px; in a synced compare, in whichever pane it is over. */
165
- function local(ev: { clientX: number; clientY: number }): { x: number; y: number } {
166
- const r = stage.value?.getBoundingClientRect();
167
- if (!r) return { x: 0, y: 0 };
168
- let x = ev.clientX - r.left;
169
- const pane = stageSize.value.width;
170
- if (props.second && x > pane + PANE_GAP / 2) x -= pane + PANE_GAP;
171
- return { x, y: ev.clientY - r.top };
172
- }
173
-
174
- function onWheel(ev: WheelEvent): void {
175
- if (!ready.value) return;
176
- // A mouse notch is ~100 px (or 3 lines); a trackpad pinch sends many small ones.
177
- const delta = ev.deltaMode === 1 ? ev.deltaY * 33 : ev.deltaY;
178
- const factor = Math.min(2, Math.max(0.5, Math.exp(-delta * 0.0025)));
179
- setZoom(view.value.scale * factor, local(ev));
180
- }
181
-
182
- let drag: { id: number; x: number; y: number; vx: number; vy: number } | null = null;
183
- const dragging = ref(false);
184
- function onDown(ev: PointerEvent): void {
185
- if (ev.button !== 0 || !canPan.value) return;
186
- drag = { id: ev.pointerId, x: ev.clientX, y: ev.clientY, vx: view.value.x, vy: view.value.y };
187
- dragging.value = true;
188
- (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
189
- }
190
- function onMove(ev: PointerEvent): void {
191
- if (!drag || drag.id !== ev.pointerId) return;
192
- place(clampView({ scale: view.value.scale, x: drag.vx + ev.clientX - drag.x, y: drag.vy + ev.clientY - drag.y }, image.value, stageSize.value));
193
- }
194
- function onUp(ev: PointerEvent): void {
195
- if (!drag || drag.id !== ev.pointerId) return;
196
- drag = null;
197
- dragging.value = false;
198
- }
199
- function onDouble(ev: MouseEvent): void {
200
- if (!ready.value) return;
201
- if (isFit.value || view.value.scale < 1 - 1e-3) setZoom(1, local(ev));
202
- else setZoom(null);
203
- }
204
-
205
- function onKey(ev: KeyboardEvent): void {
206
- if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
207
- const act: Record<string, () => void> = {
208
- "+": zoomIn, "=": zoomIn, "-": zoomOut, "_": zoomOut,
209
- "0": () => setZoom(null), "1": () => setZoom(1), "2": () => setZoom(2),
210
- };
211
- if (canFull.value) { act.f = toggleFullscreen; act.F = toggleFullscreen; }
212
- const fn = act[ev.key];
213
- if (!fn) return;
214
- ev.preventDefault();
215
- ev.stopPropagation();
216
- fn();
217
- }
218
-
219
- const imgStyle = computed(() => ({
220
- width: `${image.value.width}px`,
221
- height: `${image.value.height}px`,
222
- transform: `translate(${view.value.x}px, ${view.value.y}px) scale(${view.value.scale})`,
223
- visibility: ready.value ? ("visible" as const) : ("hidden" as const),
224
- }));
225
- const level = computed(() => percent(view.value.scale));
226
-
227
- /* Mini-map: the box is the part in view; a click or drag centres the view there. */
228
- const showMap = computed(() => props.minimap && ready.value && canPan.value);
229
- const mapBox = computed(() => {
230
- const r = visibleRect(view.value, image.value, stageSize.value);
231
- return { left: `${r.x * 100}%`, top: `${r.y * 100}%`, width: `${r.w * 100}%`, height: `${r.h * 100}%` };
232
- });
233
- const mapStyle = computed(() => ({ aspectRatio: `${image.value.width} / ${image.value.height}` }));
234
- let mapId: number | null = null;
235
- function mapTo(ev: PointerEvent): void {
236
- const r = (ev.currentTarget as HTMLElement).getBoundingClientRect();
237
- if (r.width <= 0 || r.height <= 0) return;
238
- const at = { x: Math.min(1, Math.max(0, (ev.clientX - r.left) / r.width)), y: Math.min(1, Math.max(0, (ev.clientY - r.top) / r.height)) };
239
- place(viewAround(view.value.scale, at, image.value, stageSize.value));
240
- }
241
- function onMapDown(ev: PointerEvent): void {
242
- if (ev.button !== 0) return;
243
- ev.stopPropagation();
244
- mapId = ev.pointerId;
245
- (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
246
- mapTo(ev);
247
- }
248
- function onMapMove(ev: PointerEvent): void {
249
- if (mapId === ev.pointerId) mapTo(ev);
250
- }
251
- function onMapUp(ev: PointerEvent): void {
252
- if (mapId === ev.pointerId) mapId = null;
253
- }
254
-
255
- /* Autohide: the chrome fades after IDLE_MS without a move or key, and stays while hovered. */
256
- const idle = ref(false);
257
- let idleTimer: ReturnType<typeof setTimeout> | null = null;
258
- let onChrome = false;
259
- function wake(): void {
260
- idle.value = false;
261
- if (idleTimer) clearTimeout(idleTimer);
262
- idleTimer = null;
263
- if (!props.autohide || onChrome) return;
264
- idleTimer = setTimeout(() => { idle.value = true; }, IDLE_MS);
265
- }
266
- function chromeEnter(): void { onChrome = true; wake(); }
267
- function chromeLeave(): void { onChrome = false; wake(); }
268
- watch(() => props.autohide, wake, { immediate: true });
269
- // Stepping to another picture or changing the level (keys from the parent too) counts as activity.
270
- watch(() => [props.src, props.second, current.value], wake);
271
- // Any move or key anywhere in the viewer (slots included) brings the chrome back.
272
- watch(root, (el, old) => {
273
- old?.removeEventListener("pointermove", wake);
274
- old?.removeEventListener("keydown", wake, true);
275
- el?.addEventListener("pointermove", wake);
276
- el?.addEventListener("keydown", wake, true);
277
- });
278
- onBeforeUnmount(() => { if (idleTimer) clearTimeout(idleTimer); });
279
- const at = (s: number): boolean => !isFit.value && Math.abs(view.value.scale - s) < 1e-3;
280
- </script>
281
-
282
- <template>
283
- <div
284
- ref="root"
285
- class="wp-imgzoom"
286
- :class="{ 'is-full': isFull, 'is-idle': idle, 'is-pair': !!second }"
287
- :data-size="size"
288
- >
289
- <!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
290
- <div
291
- v-if="controls"
292
- class="wp-imgzoom__bar wp-imgzoom__chrome"
293
- role="toolbar"
294
- aria-label="Zoom"
295
- @pointerenter="chromeEnter"
296
- @pointerleave="chromeLeave"
297
- @focusin="chromeEnter"
298
- @focusout="chromeLeave"
299
- >
300
- <slot name="bar-start" />
301
- <WpButton variant="ghost" :size="size" icon="pi-minus" aria-label="Zoom out" kbd="−" data-test="imgzoom-out" @click="zoomOut" />
302
- <WpButton :variant="isFit ? 'secondary' : 'ghost'" :size="size" kbd="0" hint="Show the whole picture" :aria-pressed="isFit" data-test="imgzoom-fit" @click="setZoom(null)">Fit</WpButton>
303
- <WpButton :variant="at(1) ? 'secondary' : 'ghost'" :size="size" kbd="1" hint="Real size: one image pixel per screen pixel" :aria-pressed="at(1)" data-test="imgzoom-100" @click="setZoom(1)">100%</WpButton>
304
- <WpButton :variant="at(2) ? 'secondary' : 'ghost'" :size="size" kbd="2" hint="Twice the real size" :aria-pressed="at(2)" data-test="imgzoom-200" @click="setZoom(2)">200%</WpButton>
305
- <WpButton variant="ghost" :size="size" icon="pi-plus" aria-label="Zoom in" kbd="+" data-test="imgzoom-in" @click="zoomIn" />
306
- <span class="wp-imgzoom__level" aria-live="polite" data-test="imgzoom-level">{{ level }}</span>
307
- <WpButton
308
- v-if="canFull"
309
- variant="ghost"
310
- :size="size"
311
- :icon="isFull ? 'pi-window-minimize' : 'pi-window-maximize'"
312
- :aria-label="isFull ? 'Leave fullscreen' : 'Fullscreen'"
313
- :aria-pressed="isFull"
314
- kbd="F"
315
- data-test="imgzoom-full"
316
- @click="toggleFullscreen"
317
- />
318
- <slot name="bar-end" />
319
- </div>
320
- <!-- The stage is a focusable viewport: its keys (+ − 0 1 2) and the bar do everything the pointer does. -->
321
- <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
322
- <div
323
- ref="stage"
324
- class="wp-imgzoom__stage"
325
- :class="{ 'can-pan': canPan, 'is-dragging': dragging }"
326
- tabindex="0"
327
- role="application"
328
- aria-roledescription="picture zoom"
329
- :aria-label="alt ? `${alt}, ${level}` : `Picture, ${level}`"
330
- data-test="imgzoom-stage"
331
- @wheel.prevent="onWheel"
332
- @pointerdown="onDown"
333
- @pointermove="onMove"
334
- @pointerup="onUp"
335
- @pointercancel="onUp"
336
- @dblclick="onDouble"
337
- @keydown="onKey"
338
- >
339
- <div class="wp-imgzoom__pane">
340
- <img
341
- :src="src"
342
- alt=""
343
- class="wp-imgzoom__img"
344
- :class="{ 'is-pixels': view.scale >= 4 }"
345
- :style="imgStyle"
346
- draggable="false"
347
- @load="onLoad"
348
- >
349
- <slot name="first" />
350
- </div>
351
- <div v-if="second" class="wp-imgzoom__pane" data-test="imgzoom-second">
352
- <img
353
- :src="second"
354
- :alt="secondAlt"
355
- class="wp-imgzoom__img"
356
- :class="{ 'is-pixels': view.scale >= 4 }"
357
- :style="imgStyle"
358
- draggable="false"
359
- >
360
- <slot name="second" />
361
- </div>
362
- <slot />
363
- <!-- The mini-map takes its own pointer drags, so the stage behind it never pans. -->
364
- <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
365
- <div
366
- v-if="showMap"
367
- class="wp-imgzoom__map wp-imgzoom__chrome"
368
- :style="mapStyle"
369
- aria-hidden="true"
370
- data-test="imgzoom-map"
371
- @pointerdown="onMapDown"
372
- @pointermove="onMapMove"
373
- @pointerup="onMapUp"
374
- @pointercancel="onMapUp"
375
- @pointerenter="chromeEnter"
376
- @pointerleave="chromeLeave"
377
- >
378
- <img :src="src" alt="" draggable="false">
379
- <span class="wp-imgzoom__map-box" :style="mapBox" data-test="imgzoom-map-box" />
380
- </div>
381
- </div>
382
- <div v-if="hint || (isFull && $slots.fullscreen)" class="wp-imgzoom__bottom">
383
- <p v-if="hint" class="wp-imgzoom__hint wp-imgzoom__chrome">{{ hint }}</p>
384
- <!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
385
- <div
386
- v-if="isFull && $slots.fullscreen"
387
- class="wp-imgzoom__full-foot wp-imgzoom__chrome"
388
- data-test="imgzoom-full-foot"
389
- @pointerenter="chromeEnter"
390
- @pointerleave="chromeLeave"
391
- @focusin="chromeEnter"
392
- @focusout="chromeLeave"
393
- >
394
- <slot name="fullscreen" />
395
- </div>
396
- </div>
397
- </div>
398
- </template>
399
-
400
- <style scoped>
401
- .wp-imgzoom {
402
- position: relative;
403
- display: flex;
404
- flex-direction: column;
405
- min-width: 0;
406
- min-height: 0;
407
- height: 100%;
408
- }
409
- .wp-imgzoom.is-full {
410
- width: 100vw;
411
- height: 100vh;
412
- padding: var(--wp-space-3);
413
- box-sizing: border-box;
414
- background: var(--wp-bg);
415
- }
416
- .wp-imgzoom__bar {
417
- position: absolute;
418
- top: var(--wp-space-3);
419
- left: 50%;
420
- transform: translateX(-50%);
421
- z-index: 2;
422
- display: flex;
423
- align-items: center;
424
- gap: var(--wp-space-1);
425
- padding: var(--wp-space-1);
426
- border: 1px solid var(--wp-border);
427
- border-radius: var(--wp-radius-md);
428
- background: var(--wp-bg-2);
429
- box-shadow: var(--wp-shadow-md);
430
- }
431
- .wp-imgzoom__level {
432
- min-width: 44px;
433
- padding: 0 var(--wp-space-2);
434
- text-align: right;
435
- font: var(--wp-text-xs) var(--wp-font-mono);
436
- color: var(--wp-text-muted);
437
- }
438
- .wp-imgzoom__stage {
439
- position: relative;
440
- display: flex;
441
- gap: 8px; /* PANE_GAP */
442
- flex: 1;
443
- min-height: 0;
444
- overflow: hidden;
445
- border-radius: var(--wp-radius);
446
- touch-action: none;
447
- outline: none;
448
- }
449
- .wp-imgzoom__pane {
450
- position: relative;
451
- flex: 1;
452
- min-width: 0;
453
- overflow: hidden;
454
- border-radius: var(--wp-radius);
455
- background: var(--wp-bg);
456
- }
457
- .wp-imgzoom__chrome { transition: opacity 0.25s ease; }
458
- .wp-imgzoom.is-idle .wp-imgzoom__chrome { opacity: 0; pointer-events: none; }
459
- .wp-imgzoom.is-idle .wp-imgzoom__stage.can-pan { cursor: none; }
460
- @media (prefers-reduced-motion: reduce) {
461
- .wp-imgzoom__chrome { transition: none; }
462
- }
463
- .wp-imgzoom__map {
464
- position: absolute;
465
- /* Top right: the bottom holds the hint, the slots' captions and the fullscreen strip. */
466
- top: var(--wp-space-3);
467
- right: var(--wp-space-3);
468
- z-index: 2;
469
- width: 96px;
470
- max-height: 128px;
471
- overflow: hidden;
472
- border: 1px solid rgb(255 255 255 / 0.35);
473
- border-radius: var(--wp-radius-sm);
474
- background: black;
475
- box-shadow: var(--wp-shadow-md);
476
- cursor: pointer;
477
- touch-action: none;
478
- }
479
- .wp-imgzoom__map img {
480
- display: block;
481
- width: 100%;
482
- height: 100%;
483
- object-fit: fill;
484
- opacity: 0.6;
485
- pointer-events: none;
486
- user-select: none;
487
- }
488
- .wp-imgzoom__map-box {
489
- position: absolute;
490
- border: 1.5px solid white;
491
- /* Darken what is out of view; the map clips the spread. */
492
- box-shadow: 0 0 0 999px rgb(0 0 0 / 0.35);
493
- pointer-events: none;
494
- }
495
- .wp-imgzoom__bottom {
496
- position: absolute;
497
- bottom: var(--wp-space-3);
498
- left: 50%;
499
- transform: translateX(-50%);
500
- z-index: 2;
501
- display: flex;
502
- flex-direction: column;
503
- align-items: center;
504
- gap: var(--wp-space-2);
505
- max-width: calc(100% - 2 * var(--wp-space-3));
506
- pointer-events: none;
507
- }
508
- .wp-imgzoom.is-full .wp-imgzoom__bottom { bottom: calc(var(--wp-space-3) * 2); }
509
- .wp-imgzoom__full-foot {
510
- display: flex;
511
- align-items: center;
512
- gap: var(--wp-space-2);
513
- padding: var(--wp-space-2);
514
- border: 1px solid var(--wp-border);
515
- border-radius: var(--wp-radius-md);
516
- background: var(--wp-bg-2);
517
- box-shadow: var(--wp-shadow-md);
518
- pointer-events: auto;
519
- }
520
- .wp-imgzoom__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--wp-border-focus); }
521
- .wp-imgzoom__stage.can-pan { cursor: grab; }
522
- .wp-imgzoom__stage.is-dragging { cursor: grabbing; }
523
- .wp-imgzoom__img {
524
- position: absolute;
525
- top: 0;
526
- left: 0;
527
- max-width: none;
528
- transform-origin: 0 0;
529
- user-select: none;
530
- pointer-events: none;
531
- }
532
- .wp-imgzoom__img.is-pixels { image-rendering: pixelated; }
533
- .wp-imgzoom__hint {
534
- margin: 0;
535
- padding: var(--wp-space-1) var(--wp-space-3);
536
- border-radius: var(--wp-radius-sm);
537
- /* Over a picture: a dark scrim in both themes, so it reads on any image. */
538
- background: rgb(0 0 0 / 0.6);
539
- color: white;
540
- font: var(--wp-text-xs) var(--wp-font-sans);
541
- white-space: nowrap;
542
- pointer-events: none;
543
- }
544
- </style>
1
+ <script setup lang="ts">
2
+ /**
3
+ * Image zoom: one picture with real zoom levels. Scroll zooms toward the
4
+ * pointer, drag moves, double-click switches between Fit and 100% at that
5
+ * spot. The bar on top has − / Fit / 100% / 200% / + and the level; when
6
+ * the viewer has focus, + − 0 1 2 do the same. With `fullscreen` the bar
7
+ * also gets a Fullscreen button (key F) that fills the screen with the
8
+ * viewer, bar and slots included; Esc leaves it.
9
+ *
10
+ * `minimap` adds a small copy of the picture in the corner once it is bigger
11
+ * than the stage; its box shows the part in view and a click or drag there
12
+ * moves the view. `autohide` fades the bar, hint and mini-map after
13
+ * `IDLE_MS` without a pointer move or key (hovering the bar keeps them).
14
+ * `second` shows another picture beside the first, both locked to the same
15
+ * level and spot (a synced compare; the first picture's size drives the
16
+ * maths). Slots: `bar-start` / `bar-end` in the bar, `first` / `second` over
17
+ * each picture, `fullscreen` along the bottom only while fullscreen (it fades
18
+ * with the bar), default over the whole stage.
19
+ *
20
+ * `v-model:zoom` is the level (`null` = Fit). The viewer also keeps the spot
21
+ * it shows when `src` changes, so stepping through a series stays on the same
22
+ * part of each picture (the hands in every frame). Works on the canvas and
23
+ * in the manager.
24
+ *
25
+ * <WpImageZoom v-model:zoom="zoom" :src="url" alt="Frame 3" />
26
+ */
27
+ import { computed, onBeforeUnmount, ref, watch } from "vue";
28
+ import WpButton from "./WpButton.vue";
29
+ import {
30
+ centreOf, clampView, fitScale, percent, stepScale, viewAround, visibleRect, zoomAt, IDLE_MS, type Size, type View,
31
+ } from "./image-zoom";
32
+
33
+ /** The gap between the two pictures of a synced compare. */
34
+ const PANE_GAP = 8;
35
+
36
+ const props = withDefaults(defineProps<{
37
+ src: string;
38
+ alt?: string | undefined;
39
+ /** The level as a real-size factor (1 = 100%); null fits the picture in. */
40
+ zoom?: number | null | undefined;
41
+ /** Show the zoom bar. */
42
+ controls?: boolean | undefined;
43
+ /** Hint shown under the bar's buttons, e.g. "Drag to move · scroll to zoom". */
44
+ hint?: string | undefined;
45
+ /** Offer a Fullscreen button (and key F). */
46
+ fullscreen?: boolean | undefined;
47
+ /** Show a mini-map once the picture is bigger than the stage. */
48
+ minimap?: boolean | undefined;
49
+ /** Fade the bar, hint and mini-map while the pointer and keys are still. */
50
+ autohide?: boolean | undefined;
51
+ /** A second picture beside the first, locked to the same level and spot. */
52
+ second?: string | null | undefined;
53
+ secondAlt?: string | undefined;
54
+ size?: "sm" | "md" | undefined;
55
+ }>(), {
56
+ alt: "", zoom: null, controls: true, hint: undefined, fullscreen: false,
57
+ minimap: false, autohide: false, second: null, secondAlt: "", size: "sm",
58
+ });
59
+
60
+ const emit = defineEmits<{ "update:zoom": [zoom: number | null] }>();
61
+
62
+ const root = ref<HTMLElement | null>(null);
63
+ const stage = ref<HTMLElement | null>(null);
64
+ const stageSize = ref<Size>({ width: 0, height: 0 });
65
+ const image = ref<Size>({ width: 0, height: 0 });
66
+ const view = ref<View>({ scale: 1, x: 0, y: 0 });
67
+ /** The image point (0..1) at the middle of the stage, carried to the next `src`. */
68
+ let centre = { x: 0.5, y: 0.5 };
69
+
70
+ /** The level shown; follows `zoom`, and works without a v-model too. */
71
+ const current = ref<number | null>(props.zoom);
72
+ const fit = computed(() => fitScale(image.value, stageSize.value));
73
+ const isFit = computed(() => current.value === null);
74
+ const ready = computed(() => image.value.width > 0 && stageSize.value.width > 0);
75
+ const canPan = computed(() =>
76
+ image.value.width * view.value.scale > stageSize.value.width + 0.5
77
+ || image.value.height * view.value.scale > stageSize.value.height + 0.5,
78
+ );
79
+
80
+ function place(next: View): void {
81
+ view.value = next;
82
+ centre = centreOf(next, image.value, stageSize.value);
83
+ }
84
+
85
+ /** Lay the picture out for the current `zoom` around the kept spot. */
86
+ function relayout(): void {
87
+ if (!ready.value) return;
88
+ if (current.value === null) {
89
+ view.value = clampView({ scale: fit.value, x: 0, y: 0 }, image.value, stageSize.value);
90
+ return;
91
+ }
92
+ place(viewAround(current.value, centre, image.value, stageSize.value));
93
+ }
94
+
95
+ /** Change the level around a stage point (default: the middle). */
96
+ function setZoom(scale: number | null, at?: { x: number; y: number }): void {
97
+ if (scale === null) {
98
+ current.value = null;
99
+ relayout();
100
+ emit("update:zoom", null);
101
+ return;
102
+ }
103
+ const point = at ?? { x: stageSize.value.width / 2, y: stageSize.value.height / 2 };
104
+ const next = zoomAt(view.value, scale, point, image.value, stageSize.value);
105
+ place(next);
106
+ current.value = next.scale;
107
+ emit("update:zoom", next.scale);
108
+ }
109
+
110
+ function zoomIn(): void { setZoom(stepScale(view.value.scale, 1, fit.value)); }
111
+ function zoomOut(): void { setZoom(stepScale(view.value.scale, -1, fit.value)); }
112
+
113
+ const isFull = ref(false);
114
+ const canFull = computed(() => props.fullscreen && typeof document !== "undefined" && document.fullscreenEnabled !== false);
115
+ function onFullChange(): void {
116
+ isFull.value = !!root.value && document.fullscreenElement === root.value;
117
+ }
118
+ function toggleFullscreen(): void {
119
+ if (!canFull.value || !root.value) return;
120
+ if (document.fullscreenElement === root.value) void document.exitFullscreen?.();
121
+ else void root.value.requestFullscreen?.().then(() => stage.value?.focus()).catch(() => undefined);
122
+ }
123
+ if (typeof document !== "undefined") document.addEventListener("fullscreenchange", onFullChange);
124
+ onBeforeUnmount(() => {
125
+ document.removeEventListener("fullscreenchange", onFullChange);
126
+ if (root.value && document.fullscreenElement === root.value) void document.exitFullscreen?.();
127
+ });
128
+
129
+ defineExpose({ zoomIn, zoomOut, setZoom, toggleFullscreen });
130
+
131
+ watch(() => props.zoom, (z) => {
132
+ // A level set from outside (the parent, a key): keep the middle spot.
133
+ if (z === current.value) return;
134
+ current.value = z;
135
+ relayout();
136
+ });
137
+
138
+ function onLoad(ev: Event): void {
139
+ const img = ev.target as HTMLImageElement;
140
+ image.value = { width: img.naturalWidth, height: img.naturalHeight };
141
+ relayout();
142
+ }
143
+
144
+ let observer: ResizeObserver | null = null;
145
+ let measureStage: (() => void) | null = null;
146
+ watch(() => !!props.second, () => measureStage?.());
147
+ watch(stage, (el) => {
148
+ observer?.disconnect();
149
+ if (!el) return;
150
+ const measure = (): void => {
151
+ // A synced compare splits the stage into two panes that share one view.
152
+ const width = props.second ? Math.max(0, (el.clientWidth - PANE_GAP) / 2) : el.clientWidth;
153
+ stageSize.value = { width, height: el.clientHeight };
154
+ relayout();
155
+ };
156
+ measureStage = measure;
157
+ measure();
158
+ if (typeof ResizeObserver === "undefined") return;
159
+ observer = new ResizeObserver(measure);
160
+ observer.observe(el);
161
+ });
162
+ onBeforeUnmount(() => observer?.disconnect());
163
+
164
+ /** A pointer position in pane px; in a synced compare, in whichever pane it is over. */
165
+ function local(ev: { clientX: number; clientY: number }): { x: number; y: number } {
166
+ const r = stage.value?.getBoundingClientRect();
167
+ if (!r) return { x: 0, y: 0 };
168
+ let x = ev.clientX - r.left;
169
+ const pane = stageSize.value.width;
170
+ if (props.second && x > pane + PANE_GAP / 2) x -= pane + PANE_GAP;
171
+ return { x, y: ev.clientY - r.top };
172
+ }
173
+
174
+ function onWheel(ev: WheelEvent): void {
175
+ if (!ready.value) return;
176
+ // A mouse notch is ~100 px (or 3 lines); a trackpad pinch sends many small ones.
177
+ const delta = ev.deltaMode === 1 ? ev.deltaY * 33 : ev.deltaY;
178
+ const factor = Math.min(2, Math.max(0.5, Math.exp(-delta * 0.0025)));
179
+ setZoom(view.value.scale * factor, local(ev));
180
+ }
181
+
182
+ let drag: { id: number; x: number; y: number; vx: number; vy: number } | null = null;
183
+ const dragging = ref(false);
184
+ function onDown(ev: PointerEvent): void {
185
+ if (ev.button !== 0 || !canPan.value) return;
186
+ drag = { id: ev.pointerId, x: ev.clientX, y: ev.clientY, vx: view.value.x, vy: view.value.y };
187
+ dragging.value = true;
188
+ (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
189
+ }
190
+ function onMove(ev: PointerEvent): void {
191
+ if (!drag || drag.id !== ev.pointerId) return;
192
+ place(clampView({ scale: view.value.scale, x: drag.vx + ev.clientX - drag.x, y: drag.vy + ev.clientY - drag.y }, image.value, stageSize.value));
193
+ }
194
+ function onUp(ev: PointerEvent): void {
195
+ if (!drag || drag.id !== ev.pointerId) return;
196
+ drag = null;
197
+ dragging.value = false;
198
+ }
199
+ function onDouble(ev: MouseEvent): void {
200
+ if (!ready.value) return;
201
+ if (isFit.value || view.value.scale < 1 - 1e-3) setZoom(1, local(ev));
202
+ else setZoom(null);
203
+ }
204
+
205
+ function onKey(ev: KeyboardEvent): void {
206
+ if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
207
+ const act: Record<string, () => void> = {
208
+ "+": zoomIn, "=": zoomIn, "-": zoomOut, "_": zoomOut,
209
+ "0": () => setZoom(null), "1": () => setZoom(1), "2": () => setZoom(2),
210
+ };
211
+ if (canFull.value) { act.f = toggleFullscreen; act.F = toggleFullscreen; }
212
+ const fn = act[ev.key];
213
+ if (!fn) return;
214
+ ev.preventDefault();
215
+ ev.stopPropagation();
216
+ fn();
217
+ }
218
+
219
+ const imgStyle = computed(() => ({
220
+ width: `${image.value.width}px`,
221
+ height: `${image.value.height}px`,
222
+ transform: `translate(${view.value.x}px, ${view.value.y}px) scale(${view.value.scale})`,
223
+ visibility: ready.value ? ("visible" as const) : ("hidden" as const),
224
+ }));
225
+ const level = computed(() => percent(view.value.scale));
226
+
227
+ /* Mini-map: the box is the part in view; a click or drag centres the view there. */
228
+ const showMap = computed(() => props.minimap && ready.value && canPan.value);
229
+ const mapBox = computed(() => {
230
+ const r = visibleRect(view.value, image.value, stageSize.value);
231
+ return { left: `${r.x * 100}%`, top: `${r.y * 100}%`, width: `${r.w * 100}%`, height: `${r.h * 100}%` };
232
+ });
233
+ const mapStyle = computed(() => ({ aspectRatio: `${image.value.width} / ${image.value.height}` }));
234
+ let mapId: number | null = null;
235
+ function mapTo(ev: PointerEvent): void {
236
+ const r = (ev.currentTarget as HTMLElement).getBoundingClientRect();
237
+ if (r.width <= 0 || r.height <= 0) return;
238
+ const at = { x: Math.min(1, Math.max(0, (ev.clientX - r.left) / r.width)), y: Math.min(1, Math.max(0, (ev.clientY - r.top) / r.height)) };
239
+ place(viewAround(view.value.scale, at, image.value, stageSize.value));
240
+ }
241
+ function onMapDown(ev: PointerEvent): void {
242
+ if (ev.button !== 0) return;
243
+ ev.stopPropagation();
244
+ mapId = ev.pointerId;
245
+ (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
246
+ mapTo(ev);
247
+ }
248
+ function onMapMove(ev: PointerEvent): void {
249
+ if (mapId === ev.pointerId) mapTo(ev);
250
+ }
251
+ function onMapUp(ev: PointerEvent): void {
252
+ if (mapId === ev.pointerId) mapId = null;
253
+ }
254
+
255
+ /* Autohide: the chrome fades after IDLE_MS without a move or key, and stays while hovered. */
256
+ const idle = ref(false);
257
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
258
+ let onChrome = false;
259
+ function wake(): void {
260
+ idle.value = false;
261
+ if (idleTimer) clearTimeout(idleTimer);
262
+ idleTimer = null;
263
+ if (!props.autohide || onChrome) return;
264
+ idleTimer = setTimeout(() => { idle.value = true; }, IDLE_MS);
265
+ }
266
+ function chromeEnter(): void { onChrome = true; wake(); }
267
+ function chromeLeave(): void { onChrome = false; wake(); }
268
+ watch(() => props.autohide, wake, { immediate: true });
269
+ // Stepping to another picture or changing the level (keys from the parent too) counts as activity.
270
+ watch(() => [props.src, props.second, current.value], wake);
271
+ // Any move or key anywhere in the viewer (slots included) brings the chrome back.
272
+ watch(root, (el, old) => {
273
+ old?.removeEventListener("pointermove", wake);
274
+ old?.removeEventListener("keydown", wake, true);
275
+ el?.addEventListener("pointermove", wake);
276
+ el?.addEventListener("keydown", wake, true);
277
+ });
278
+ onBeforeUnmount(() => { if (idleTimer) clearTimeout(idleTimer); });
279
+ const at = (s: number): boolean => !isFit.value && Math.abs(view.value.scale - s) < 1e-3;
280
+ </script>
281
+
282
+ <template>
283
+ <div
284
+ ref="root"
285
+ class="wp-imgzoom"
286
+ :class="{ 'is-full': isFull, 'is-idle': idle, 'is-pair': !!second }"
287
+ :data-size="size"
288
+ >
289
+ <!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
290
+ <div
291
+ v-if="controls"
292
+ class="wp-imgzoom__bar wp-imgzoom__chrome"
293
+ role="toolbar"
294
+ aria-label="Zoom"
295
+ @pointerenter="chromeEnter"
296
+ @pointerleave="chromeLeave"
297
+ @focusin="chromeEnter"
298
+ @focusout="chromeLeave"
299
+ >
300
+ <slot name="bar-start" />
301
+ <WpButton variant="ghost" :size="size" icon="pi-minus" aria-label="Zoom out" kbd="−" data-test="imgzoom-out" @click="zoomOut" />
302
+ <WpButton :variant="isFit ? 'secondary' : 'ghost'" :size="size" kbd="0" hint="Show the whole picture" :aria-pressed="isFit" data-test="imgzoom-fit" @click="setZoom(null)">Fit</WpButton>
303
+ <WpButton :variant="at(1) ? 'secondary' : 'ghost'" :size="size" kbd="1" hint="Real size: one image pixel per screen pixel" :aria-pressed="at(1)" data-test="imgzoom-100" @click="setZoom(1)">100%</WpButton>
304
+ <WpButton :variant="at(2) ? 'secondary' : 'ghost'" :size="size" kbd="2" hint="Twice the real size" :aria-pressed="at(2)" data-test="imgzoom-200" @click="setZoom(2)">200%</WpButton>
305
+ <WpButton variant="ghost" :size="size" icon="pi-plus" aria-label="Zoom in" kbd="+" data-test="imgzoom-in" @click="zoomIn" />
306
+ <span class="wp-imgzoom__level" aria-live="polite" data-test="imgzoom-level">{{ level }}</span>
307
+ <WpButton
308
+ v-if="canFull"
309
+ variant="ghost"
310
+ :size="size"
311
+ :icon="isFull ? 'pi-window-minimize' : 'pi-window-maximize'"
312
+ :aria-label="isFull ? 'Leave fullscreen' : 'Fullscreen'"
313
+ :aria-pressed="isFull"
314
+ kbd="F"
315
+ data-test="imgzoom-full"
316
+ @click="toggleFullscreen"
317
+ />
318
+ <slot name="bar-end" />
319
+ </div>
320
+ <!-- The stage is a focusable viewport: its keys (+ − 0 1 2) and the bar do everything the pointer does. -->
321
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
322
+ <div
323
+ ref="stage"
324
+ class="wp-imgzoom__stage"
325
+ :class="{ 'can-pan': canPan, 'is-dragging': dragging }"
326
+ tabindex="0"
327
+ role="application"
328
+ aria-roledescription="picture zoom"
329
+ :aria-label="alt ? `${alt}, ${level}` : `Picture, ${level}`"
330
+ data-test="imgzoom-stage"
331
+ @wheel.prevent="onWheel"
332
+ @pointerdown="onDown"
333
+ @pointermove="onMove"
334
+ @pointerup="onUp"
335
+ @pointercancel="onUp"
336
+ @dblclick="onDouble"
337
+ @keydown="onKey"
338
+ >
339
+ <div class="wp-imgzoom__pane">
340
+ <img
341
+ :src="src"
342
+ alt=""
343
+ class="wp-imgzoom__img"
344
+ :class="{ 'is-pixels': view.scale >= 4 }"
345
+ :style="imgStyle"
346
+ draggable="false"
347
+ @load="onLoad"
348
+ >
349
+ <slot name="first" />
350
+ </div>
351
+ <div v-if="second" class="wp-imgzoom__pane" data-test="imgzoom-second">
352
+ <img
353
+ :src="second"
354
+ :alt="secondAlt"
355
+ class="wp-imgzoom__img"
356
+ :class="{ 'is-pixels': view.scale >= 4 }"
357
+ :style="imgStyle"
358
+ draggable="false"
359
+ >
360
+ <slot name="second" />
361
+ </div>
362
+ <slot />
363
+ <!-- The mini-map takes its own pointer drags, so the stage behind it never pans. -->
364
+ <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
365
+ <div
366
+ v-if="showMap"
367
+ class="wp-imgzoom__map wp-imgzoom__chrome"
368
+ :style="mapStyle"
369
+ aria-hidden="true"
370
+ data-test="imgzoom-map"
371
+ @pointerdown="onMapDown"
372
+ @pointermove="onMapMove"
373
+ @pointerup="onMapUp"
374
+ @pointercancel="onMapUp"
375
+ @pointerenter="chromeEnter"
376
+ @pointerleave="chromeLeave"
377
+ >
378
+ <img :src="src" alt="" draggable="false">
379
+ <span class="wp-imgzoom__map-box" :style="mapBox" data-test="imgzoom-map-box" />
380
+ </div>
381
+ </div>
382
+ <div v-if="hint || (isFull && $slots.fullscreen)" class="wp-imgzoom__bottom">
383
+ <p v-if="hint" class="wp-imgzoom__hint wp-imgzoom__chrome">{{ hint }}</p>
384
+ <!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
385
+ <div
386
+ v-if="isFull && $slots.fullscreen"
387
+ class="wp-imgzoom__full-foot wp-imgzoom__chrome"
388
+ data-test="imgzoom-full-foot"
389
+ @pointerenter="chromeEnter"
390
+ @pointerleave="chromeLeave"
391
+ @focusin="chromeEnter"
392
+ @focusout="chromeLeave"
393
+ >
394
+ <slot name="fullscreen" />
395
+ </div>
396
+ </div>
397
+ </div>
398
+ </template>
399
+
400
+ <style scoped>
401
+ .wp-imgzoom {
402
+ position: relative;
403
+ display: flex;
404
+ flex-direction: column;
405
+ min-width: 0;
406
+ min-height: 0;
407
+ height: 100%;
408
+ }
409
+ .wp-imgzoom.is-full {
410
+ width: 100vw;
411
+ height: 100vh;
412
+ padding: var(--wp-space-3);
413
+ box-sizing: border-box;
414
+ background: var(--wp-bg);
415
+ }
416
+ .wp-imgzoom__bar {
417
+ position: absolute;
418
+ top: var(--wp-space-3);
419
+ left: 50%;
420
+ transform: translateX(-50%);
421
+ z-index: 2;
422
+ display: flex;
423
+ align-items: center;
424
+ gap: var(--wp-space-1);
425
+ padding: var(--wp-space-1);
426
+ border: 1px solid var(--wp-border);
427
+ border-radius: var(--wp-radius-md);
428
+ background: var(--wp-bg-2);
429
+ box-shadow: var(--wp-shadow-md);
430
+ }
431
+ .wp-imgzoom__level {
432
+ min-width: 44px;
433
+ padding: 0 var(--wp-space-2);
434
+ text-align: right;
435
+ font: var(--wp-text-xs) var(--wp-font-mono);
436
+ color: var(--wp-text-muted);
437
+ }
438
+ .wp-imgzoom__stage {
439
+ position: relative;
440
+ display: flex;
441
+ gap: 8px; /* PANE_GAP */
442
+ flex: 1;
443
+ min-height: 0;
444
+ overflow: hidden;
445
+ border-radius: var(--wp-radius);
446
+ touch-action: none;
447
+ outline: none;
448
+ }
449
+ .wp-imgzoom__pane {
450
+ position: relative;
451
+ flex: 1;
452
+ min-width: 0;
453
+ overflow: hidden;
454
+ border-radius: var(--wp-radius);
455
+ background: var(--wp-bg);
456
+ }
457
+ .wp-imgzoom__chrome { transition: opacity 0.25s ease; }
458
+ .wp-imgzoom.is-idle .wp-imgzoom__chrome { opacity: 0; pointer-events: none; }
459
+ .wp-imgzoom.is-idle .wp-imgzoom__stage.can-pan { cursor: none; }
460
+ @media (prefers-reduced-motion: reduce) {
461
+ .wp-imgzoom__chrome { transition: none; }
462
+ }
463
+ .wp-imgzoom__map {
464
+ position: absolute;
465
+ /* Top right: the bottom holds the hint, the slots' captions and the fullscreen strip. */
466
+ top: var(--wp-space-3);
467
+ right: var(--wp-space-3);
468
+ z-index: 2;
469
+ width: 96px;
470
+ max-height: 128px;
471
+ overflow: hidden;
472
+ border: 1px solid rgb(255 255 255 / 0.35);
473
+ border-radius: var(--wp-radius-sm);
474
+ background: black;
475
+ box-shadow: var(--wp-shadow-md);
476
+ cursor: pointer;
477
+ touch-action: none;
478
+ }
479
+ .wp-imgzoom__map img {
480
+ display: block;
481
+ width: 100%;
482
+ height: 100%;
483
+ object-fit: fill;
484
+ opacity: 0.6;
485
+ pointer-events: none;
486
+ user-select: none;
487
+ }
488
+ .wp-imgzoom__map-box {
489
+ position: absolute;
490
+ border: 1.5px solid white;
491
+ /* Darken what is out of view; the map clips the spread. */
492
+ box-shadow: 0 0 0 999px rgb(0 0 0 / 0.35);
493
+ pointer-events: none;
494
+ }
495
+ .wp-imgzoom__bottom {
496
+ position: absolute;
497
+ bottom: var(--wp-space-3);
498
+ left: 50%;
499
+ transform: translateX(-50%);
500
+ z-index: 2;
501
+ display: flex;
502
+ flex-direction: column;
503
+ align-items: center;
504
+ gap: var(--wp-space-2);
505
+ max-width: calc(100% - 2 * var(--wp-space-3));
506
+ pointer-events: none;
507
+ }
508
+ .wp-imgzoom.is-full .wp-imgzoom__bottom { bottom: calc(var(--wp-space-3) * 2); }
509
+ .wp-imgzoom__full-foot {
510
+ display: flex;
511
+ align-items: center;
512
+ gap: var(--wp-space-2);
513
+ padding: var(--wp-space-2);
514
+ border: 1px solid var(--wp-border);
515
+ border-radius: var(--wp-radius-md);
516
+ background: var(--wp-bg-2);
517
+ box-shadow: var(--wp-shadow-md);
518
+ pointer-events: auto;
519
+ }
520
+ .wp-imgzoom__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--wp-border-focus); }
521
+ .wp-imgzoom__stage.can-pan { cursor: grab; }
522
+ .wp-imgzoom__stage.is-dragging { cursor: grabbing; }
523
+ .wp-imgzoom__img {
524
+ position: absolute;
525
+ top: 0;
526
+ left: 0;
527
+ max-width: none;
528
+ transform-origin: 0 0;
529
+ user-select: none;
530
+ pointer-events: none;
531
+ }
532
+ .wp-imgzoom__img.is-pixels { image-rendering: pixelated; }
533
+ .wp-imgzoom__hint {
534
+ margin: 0;
535
+ padding: var(--wp-space-1) var(--wp-space-3);
536
+ border-radius: var(--wp-radius-sm);
537
+ /* Over a picture: a dark scrim in both themes, so it reads on any image. */
538
+ background: rgb(0 0 0 / 0.6);
539
+ color: white;
540
+ font: var(--wp-text-xs) var(--wp-font-sans);
541
+ white-space: nowrap;
542
+ pointer-events: none;
543
+ }
544
+ </style>