@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.
- package/CHANGELOG.md +77 -51
- package/LICENSE +674 -674
- package/README.md +73 -76
- package/package.json +78 -77
- package/src/Avatar.vue +83 -83
- package/src/Badge.vue +97 -97
- package/src/Banner.vue +118 -118
- package/src/BookmarkButton.vue +27 -27
- package/src/BundleMeta.vue +51 -51
- package/src/Button.vue +103 -103
- package/src/Card.vue +68 -68
- package/src/Checkbox.vue +109 -107
- package/src/Chip.vue +163 -97
- package/src/ChoiceCards.vue +133 -133
- package/src/CloseButton.vue +69 -69
- package/src/CodeBlock.vue +119 -119
- package/src/ColorField.vue +188 -206
- package/src/ColorPicker.vue +201 -201
- package/src/Combobox.vue +331 -334
- package/src/ConfirmDialog.vue +165 -163
- package/src/ConfirmModal.vue +147 -147
- package/src/ConstraintMatrixPreview.vue +708 -708
- package/src/ContextMenu.vue +386 -379
- package/src/CopyButton.vue +156 -156
- package/src/DataTable.vue +292 -292
- package/src/DatePicker.vue +234 -234
- package/src/DescriptionList.vue +73 -73
- package/src/DiffView.vue +68 -68
- package/src/Disclosure.vue +166 -166
- package/src/DragHandle.vue +49 -49
- package/src/Drawer.vue +131 -131
- package/src/DropZone.vue +196 -196
- package/src/DropdownMenu.vue +72 -72
- package/src/EmptyState.vue +111 -111
- package/src/Field.vue +28 -28
- package/src/HsvPicker.vue +208 -208
- package/src/Icon.vue +39 -39
- package/src/IconPicker.vue +209 -209
- package/src/ImageGrid.vue +125 -125
- package/src/InlineEdit.vue +183 -183
- package/src/Input.vue +99 -99
- package/src/Kbd.vue +56 -56
- package/src/Lightbox.vue +173 -173
- package/src/ListFilter.vue +85 -85
- package/src/ListRow.vue +204 -204
- package/src/LoadingMark.vue +175 -161
- package/src/Logo.vue +60 -60
- package/src/Modal.vue +78 -76
- package/src/ModalShell.vue +109 -107
- package/src/Pagination.vue +106 -106
- package/src/Popover.vue +180 -171
- package/src/PostCard.vue +210 -211
- package/src/ProgressBar.vue +90 -90
- package/src/RadioGroup.vue +177 -177
- package/src/RangeSlider.vue +183 -183
- package/src/RelativeDate.vue +35 -35
- package/src/ResizeHandle.vue +143 -143
- package/src/SearchInput.vue +192 -199
- package/src/Select.vue +644 -641
- package/src/Separator.vue +54 -54
- package/src/Skeleton.vue +82 -82
- package/src/Slider.vue +198 -198
- package/src/SortableList.vue +260 -260
- package/src/SplitPane.vue +68 -68
- package/src/StarButton.vue +40 -40
- package/src/Stepper.vue +141 -141
- package/src/SupportLinks.vue +93 -93
- package/src/TagInput.vue +297 -300
- package/src/Textarea.vue +57 -57
- package/src/Timeline.vue +92 -92
- package/src/ToastCard.vue +119 -119
- package/src/ToastHost.vue +40 -40
- package/src/Toggle.vue +36 -36
- package/src/TokenizedValue.vue +310 -310
- package/src/Toolbar.vue +110 -110
- package/src/VirtualList.vue +87 -87
- package/src/VisuallyHidden.vue +29 -29
- package/src/WpButton.vue +75 -75
- package/src/WpCheck.vue +104 -102
- package/src/WpIconButton.vue +107 -107
- package/src/WpImageZoom.vue +544 -544
- package/src/WpNumber.vue +178 -183
- package/src/WpSegmented.vue +175 -175
- package/src/WpSelect.vue +93 -89
- package/src/WpSwitch.vue +163 -163
- package/src/WpTabs.vue +188 -188
- package/src/_modal-motion.css +33 -33
- package/src/_skeleton.css +50 -50
- package/src/axis-color.ts +42 -42
- package/src/button.css +169 -169
- package/src/calendar.ts +59 -59
- package/src/clipboard.ts +50 -50
- package/src/colour-presets.ts +12 -12
- package/src/diff.ts +51 -51
- package/src/drop-zone.ts +41 -41
- package/src/field.css +50 -0
- package/src/flip.ts +237 -237
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -99
- package/src/hsv.ts +100 -100
- package/src/icon-catalog.ts +52 -52
- package/src/image-zoom.ts +93 -93
- package/src/kind-colour.ts +28 -0
- package/src/logo-paths.ts +21 -21
- package/src/matrix-axis.ts +91 -91
- package/src/pagination.ts +27 -27
- package/src/platform-keys.ts +29 -29
- package/src/rich-tokenize.ts +357 -357
- package/src/select-types.ts +32 -32
- package/src/slider-math.ts +45 -45
- package/src/sortable.ts +22 -22
- package/src/theme/fonts/OFL-Fraunces.txt +93 -93
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
- package/src/theme/fonts/OFL-Manrope.txt +93 -93
- package/src/theme/looks.css +428 -428
- package/src/theme/palettes.css +254 -254
- package/src/theme/tokens.css +2896 -2894
- package/src/tokens.ts +56 -56
- package/src/tooltip.css +43 -43
- package/src/tooltip.ts +146 -146
- package/src/useDialogFocus.ts +73 -73
- package/src/useGrowableField.ts +357 -357
- package/src/useModalKeyShield.ts +116 -116
- package/src/useToast.ts +48 -48
- package/src/wp-logo.svg +14 -14
- package/src/ChipToggle.vue +0 -92
- package/src/KindChip.vue +0 -53
- package/src/RatingBadge.vue +0 -42
package/src/WpImageZoom.vue
CHANGED
|
@@ -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>
|