@mystravil/hardpoint 0.0.0-stage → 0.2.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 +38 -0
- package/LICENSE +25 -0
- package/README.md +64 -3
- package/dist/base.css +625 -0
- package/dist/components/AppShell.svelte +698 -0
- package/dist/components/AppShell.svelte.d.ts +77 -0
- package/dist/components/Button.svelte +174 -0
- package/dist/components/Button.svelte.d.ts +20 -0
- package/dist/components/Checkbox.svelte +235 -0
- package/dist/components/Checkbox.svelte.d.ts +23 -0
- package/dist/components/CheckboxGroup.svelte +207 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
- package/dist/components/Combobox.svelte +901 -0
- package/dist/components/Combobox.svelte.d.ts +72 -0
- package/dist/components/Dialog.svelte +359 -0
- package/dist/components/Dialog.svelte.d.ts +43 -0
- package/dist/components/Disclosure.svelte +201 -0
- package/dist/components/Disclosure.svelte.d.ts +27 -0
- package/dist/components/EmptyState.svelte +88 -0
- package/dist/components/EmptyState.svelte.d.ts +22 -0
- package/dist/components/ErrorState.svelte +135 -0
- package/dist/components/ErrorState.svelte.d.ts +26 -0
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -0
- package/dist/components/KeyValueList.svelte.d.ts +43 -0
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/LoadRegion.svelte +219 -0
- package/dist/components/LoadRegion.svelte.d.ts +27 -0
- package/dist/components/MediaFrame.svelte +110 -0
- package/dist/components/MediaFrame.svelte.d.ts +20 -0
- package/dist/components/MediaImage.svelte +256 -0
- package/dist/components/MediaImage.svelte.d.ts +16 -0
- package/dist/components/Menu.svelte +258 -0
- package/dist/components/Menu.svelte.d.ts +36 -0
- package/dist/components/MenuCheckboxItem.svelte +119 -0
- package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
- package/dist/components/MenuGroup.svelte +50 -0
- package/dist/components/MenuGroup.svelte.d.ts +15 -0
- package/dist/components/MenuItem.svelte +94 -0
- package/dist/components/MenuItem.svelte.d.ts +19 -0
- package/dist/components/MenuRadioGroup.svelte +62 -0
- package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
- package/dist/components/MenuRadioItem.svelte +110 -0
- package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
- package/dist/components/MenuSeparator.svelte +28 -0
- package/dist/components/MenuSeparator.svelte.d.ts +6 -0
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +135 -0
- package/dist/components/NativeSelect.svelte.d.ts +52 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -0
- package/dist/components/Pagination.svelte +132 -0
- package/dist/components/Pagination.svelte.d.ts +18 -0
- package/dist/components/Popover.svelte +222 -0
- package/dist/components/Popover.svelte.d.ts +35 -0
- package/dist/components/Progress.svelte +157 -0
- package/dist/components/Progress.svelte.d.ts +34 -0
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -0
- package/dist/components/Select.svelte.d.ts +48 -0
- package/dist/components/Skeleton.svelte +149 -0
- package/dist/components/Skeleton.svelte.d.ts +24 -0
- package/dist/components/Spinner.svelte +118 -0
- package/dist/components/Spinner.svelte.d.ts +13 -0
- package/dist/components/Stat.svelte +253 -0
- package/dist/components/Stat.svelte.d.ts +46 -0
- package/dist/components/Steps.svelte +189 -0
- package/dist/components/Steps.svelte.d.ts +31 -0
- package/dist/components/Switch.svelte +222 -0
- package/dist/components/Switch.svelte.d.ts +18 -0
- package/dist/components/Table.svelte +504 -0
- package/dist/components/Table.svelte.d.ts +92 -0
- package/dist/components/Tabs.svelte +246 -0
- package/dist/components/Tabs.svelte.d.ts +44 -0
- package/dist/components/Tag.svelte +40 -0
- package/dist/components/Tag.svelte.d.ts +15 -0
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/TextField.svelte +256 -0
- package/dist/components/TextField.svelte.d.ts +32 -0
- package/dist/components/Timeline.svelte +156 -0
- package/dist/components/Timeline.svelte.d.ts +30 -0
- package/dist/components/Toaster.svelte +411 -0
- package/dist/components/Toaster.svelte.d.ts +11 -0
- package/dist/components/Tooltip.svelte +200 -0
- package/dist/components/Tooltip.svelte.d.ts +38 -0
- package/dist/components/TooltipGroup.svelte +46 -0
- package/dist/components/TooltipGroup.svelte.d.ts +29 -0
- package/dist/components/internal/Scrim.svelte +52 -0
- package/dist/components/internal/Scrim.svelte.d.ts +15 -0
- package/dist/components/internal/Sheet.svelte +263 -0
- package/dist/components/internal/Sheet.svelte.d.ts +36 -0
- package/dist/components/internal/fade-in.d.ts +10 -0
- package/dist/components/internal/fade-in.js +20 -0
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/components/internal/resolve.d.ts +8 -0
- package/dist/components/internal/resolve.js +247 -0
- package/dist/components/internal/swipe-dismiss.d.ts +47 -0
- package/dist/components/internal/swipe-dismiss.js +220 -0
- package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
- package/dist/components/internal/visual-viewport.svelte.js +79 -0
- package/dist/components/toast.svelte.d.ts +57 -0
- package/dist/components/toast.svelte.js +84 -0
- package/dist/fonts/OFL-geist-pixel.txt +92 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/fonts/geist-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-pixel-square.woff2 +0 -0
- package/dist/fonts.css +71 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +45 -0
- package/dist/motion/Motion.svelte +15 -0
- package/dist/motion/Motion.svelte.d.ts +9 -0
- package/dist/motion/context.d.ts +18 -0
- package/dist/motion/context.js +26 -0
- package/dist/motion/index.d.ts +3 -0
- package/dist/motion/index.js +3 -0
- package/dist/motion/transitions.d.ts +31 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/tokens.css +283 -0
- package/package.json +76 -6
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { Dialog as BitsDialog } from 'bits-ui';
|
|
4
|
+
|
|
5
|
+
export type ImageViewerTriggerSnippetProps = {
|
|
6
|
+
/**
|
|
7
|
+
* Spread onto the button that opens the viewer, usually around a
|
|
8
|
+
* thumbnail, so the image opens from the keyboard too.
|
|
9
|
+
*/
|
|
10
|
+
props: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Attributes and handlers not listed here go to the viewer element (Bits
|
|
15
|
+
* UI `Dialog.Content`), among them `onOpenAutoFocus`, `onCloseAutoFocus`,
|
|
16
|
+
* `onEscapeKeydown` and `onInteractOutside`.
|
|
17
|
+
*/
|
|
18
|
+
export type ImageViewerProps = Omit<
|
|
19
|
+
BitsDialog.ContentProps,
|
|
20
|
+
'child' | 'children' | 'forceMount' | 'ref' | 'title'
|
|
21
|
+
> & {
|
|
22
|
+
open?: boolean;
|
|
23
|
+
/** The image's URL. A different URL while open starts a new load at scale 1. */
|
|
24
|
+
src: string;
|
|
25
|
+
/**
|
|
26
|
+
* The text alternative, required. It also names the viewer when there
|
|
27
|
+
* is no `title`, so it should describe the image, never be empty.
|
|
28
|
+
*/
|
|
29
|
+
alt: string;
|
|
30
|
+
/**
|
|
31
|
+
* A line in the top bar, shown as written (a file name, a caption) on
|
|
32
|
+
* one line, cut with an ellipsis. It names the viewer when set.
|
|
33
|
+
*/
|
|
34
|
+
title?: string;
|
|
35
|
+
/** The button that opens the viewer: spread `props` onto it. */
|
|
36
|
+
trigger?: Snippet<[ImageViewerTriggerSnippetProps]>;
|
|
37
|
+
/**
|
|
38
|
+
* Shows the Download button, which saves `src` under this file name
|
|
39
|
+
* through a link with `download`. A `data:` URL becomes a `blob:` URL
|
|
40
|
+
* first. Browsers ignore `download` on a link to another origin, so an
|
|
41
|
+
* image from another origin needs `ondownload`.
|
|
42
|
+
*/
|
|
43
|
+
downloadName?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Shows the Download button and replaces the built-in save, for an app
|
|
46
|
+
* with its own save picker or confirmation. While its promise is
|
|
47
|
+
* pending, another press does nothing. Errors are the app's to catch.
|
|
48
|
+
*/
|
|
49
|
+
ondownload?: () => void | Promise<void>;
|
|
50
|
+
/** The close button's accessible name. */
|
|
51
|
+
closeLabel?: string;
|
|
52
|
+
/** The Download button's label. */
|
|
53
|
+
downloadLabel?: string;
|
|
54
|
+
/** The spinner's label while the image loads. */
|
|
55
|
+
loadingLabel?: string;
|
|
56
|
+
/** The viewer element. */
|
|
57
|
+
ref?: HTMLElement | null;
|
|
58
|
+
};
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<script lang="ts">
|
|
62
|
+
import { untrack } from 'svelte';
|
|
63
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
64
|
+
import { duration, fade } from '../motion/index.js';
|
|
65
|
+
import Button from './Button.svelte';
|
|
66
|
+
import Spinner from './Spinner.svelte';
|
|
67
|
+
import { swipeDismiss } from './internal/swipe-dismiss.js';
|
|
68
|
+
|
|
69
|
+
/** The largest scale, of the size that fits the screen. */
|
|
70
|
+
const MAX_SCALE = 6;
|
|
71
|
+
/** How far a pinch may shrink the image before it lets go. */
|
|
72
|
+
const PINCH_MIN = 0.5;
|
|
73
|
+
/** A pinch released below this settles to scale 1, so the image never rests a few pixels too large. */
|
|
74
|
+
const SNAP = 1.05;
|
|
75
|
+
/** The scale a double-tap or double-click zooms to. */
|
|
76
|
+
const DOUBLE_SCALE = 2.5;
|
|
77
|
+
/** One key press of + or - multiplies or divides the scale by this. */
|
|
78
|
+
const KEY_STEP = 1.5;
|
|
79
|
+
/** One arrow key press pans this share of the stage. */
|
|
80
|
+
const KEY_PAN = 1 / 8;
|
|
81
|
+
/** Travel before a press counts as a drag (px): touch and pen, then mouse. */
|
|
82
|
+
const SLOP = { touch: 8, mouse: 4 };
|
|
83
|
+
/** A second tap within this time (ms) and distance (px) is a double-tap. */
|
|
84
|
+
const DOUBLE_TIME = { touch: 300, mouse: 500 };
|
|
85
|
+
const DOUBLE_DISTANCE = { touch: 40, mouse: 8 };
|
|
86
|
+
/** A blob: URL made for a download is revoked after this (ms); a browser may read it after click() returns. */
|
|
87
|
+
const REVOKE_AFTER = 60_000;
|
|
88
|
+
|
|
89
|
+
const uid = $props.id();
|
|
90
|
+
|
|
91
|
+
let {
|
|
92
|
+
open = $bindable(false),
|
|
93
|
+
src,
|
|
94
|
+
alt,
|
|
95
|
+
title,
|
|
96
|
+
trigger,
|
|
97
|
+
downloadName,
|
|
98
|
+
ondownload,
|
|
99
|
+
closeLabel = 'Close',
|
|
100
|
+
downloadLabel = 'Download',
|
|
101
|
+
loadingLabel = 'Loading image',
|
|
102
|
+
ref = $bindable(null),
|
|
103
|
+
onOpenAutoFocus,
|
|
104
|
+
class: className,
|
|
105
|
+
...rest
|
|
106
|
+
}: ImageViewerProps = $props();
|
|
107
|
+
|
|
108
|
+
const titleId = `${uid}-title`;
|
|
109
|
+
const downloadable = $derived(ondownload !== undefined || downloadName !== undefined);
|
|
110
|
+
|
|
111
|
+
// loading: the image has not arrived; a spinner shows after its delay.
|
|
112
|
+
// loaded: decoded and on screen. error: it failed; the alt shows instead.
|
|
113
|
+
let phase = $state<'loading' | 'loaded' | 'error'>('loading');
|
|
114
|
+
|
|
115
|
+
// The view: the image's scale of its fitted size, and its offset from
|
|
116
|
+
// the stage's centre in px. transform: translate(x, y) scale(scale),
|
|
117
|
+
// about the image's centre.
|
|
118
|
+
let scale = $state(1);
|
|
119
|
+
let x = $state(0);
|
|
120
|
+
let y = $state(0);
|
|
121
|
+
// A pan or a pinch in progress, for the cursor and for apps.
|
|
122
|
+
let gesture = $state<'pan' | 'pinch' | null>(null);
|
|
123
|
+
const atRest = $derived(scale === 1);
|
|
124
|
+
|
|
125
|
+
let stage = $state<HTMLElement | null>(null);
|
|
126
|
+
let image = $state<HTMLImageElement | null>(null);
|
|
127
|
+
|
|
128
|
+
type Kind = 'touch' | 'mouse';
|
|
129
|
+
type Press = { id: number; kind: Kind; x0: number; y0: number; tx0: number; ty0: number; moved: boolean };
|
|
130
|
+
type Pinch = { d0: number; s0: number; qx: number; qy: number };
|
|
131
|
+
|
|
132
|
+
// Pointers on the stage by id, at their last position.
|
|
133
|
+
const pointers = new Map<number, { x: number; y: number; kind: Kind }>();
|
|
134
|
+
// One pointer down: a tap, a pan, or a swipe's drag.
|
|
135
|
+
let press: Press | null = null;
|
|
136
|
+
// Two pointers down: the scale and the image point under their midpoint
|
|
137
|
+
// when the pinch began.
|
|
138
|
+
let pinch: Pinch | null = null;
|
|
139
|
+
// The last tap, for a double-tap.
|
|
140
|
+
let lastTap: { t: number; x: number; y: number; kind: Kind } | null = null;
|
|
141
|
+
// The animation of a double-tap zoom or a settle back to 1.
|
|
142
|
+
let settle: Animation | null = null;
|
|
143
|
+
// A pending ondownload.
|
|
144
|
+
let saving = false;
|
|
145
|
+
|
|
146
|
+
function resetView() {
|
|
147
|
+
settle?.cancel();
|
|
148
|
+
settle = null;
|
|
149
|
+
scale = 1;
|
|
150
|
+
x = 0;
|
|
151
|
+
y = 0;
|
|
152
|
+
gesture = null;
|
|
153
|
+
pointers.clear();
|
|
154
|
+
press = null;
|
|
155
|
+
pinch = null;
|
|
156
|
+
lastTap = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Every open, and every new src, starts at scale 1 with a new load.
|
|
160
|
+
$effect.pre(() => {
|
|
161
|
+
if (!open) return;
|
|
162
|
+
void src;
|
|
163
|
+
untrack(() => {
|
|
164
|
+
resetView();
|
|
165
|
+
phase = 'loading';
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function close() {
|
|
170
|
+
open = false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Focus starts on the close button (Bits UI would pick the first
|
|
174
|
+
// control, which is Download when there is one). An app's own
|
|
175
|
+
// onOpenAutoFocus may take over by calling preventDefault().
|
|
176
|
+
function openAutoFocus(e: Event) {
|
|
177
|
+
onOpenAutoFocus?.(e);
|
|
178
|
+
if (e.defaultPrevented) return;
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
const closeButton = () => ref?.querySelector<HTMLElement>('.hp-image-viewer-close');
|
|
181
|
+
const now = closeButton();
|
|
182
|
+
if (now) now.focus({ preventScroll: true });
|
|
183
|
+
else requestAnimationFrame(() => closeButton()?.focus({ preventScroll: true }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- Loading -----------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
// One <img> per src (see the {#key} below), so this runs once per load.
|
|
189
|
+
// The image shows once it is decoded, so its first frame is never blank.
|
|
190
|
+
function watch(img: HTMLImageElement) {
|
|
191
|
+
return untrack(() => {
|
|
192
|
+
let alive = true;
|
|
193
|
+
const done = () => {
|
|
194
|
+
if (alive) phase = 'loaded';
|
|
195
|
+
};
|
|
196
|
+
const fail = () => {
|
|
197
|
+
if (alive && phase === 'loading') phase = 'error';
|
|
198
|
+
};
|
|
199
|
+
const onload = () => {
|
|
200
|
+
img.decode().then(done, () => (img.naturalWidth > 0 ? done() : fail()));
|
|
201
|
+
};
|
|
202
|
+
if (img.complete) {
|
|
203
|
+
// Cached: at once. A complete image without a size is broken,
|
|
204
|
+
// unless it is an SVG without one.
|
|
205
|
+
if (img.naturalWidth > 0) phase = 'loaded';
|
|
206
|
+
else img.decode().then(done, fail);
|
|
207
|
+
}
|
|
208
|
+
img.addEventListener('load', onload);
|
|
209
|
+
img.addEventListener('error', fail);
|
|
210
|
+
return () => {
|
|
211
|
+
alive = false;
|
|
212
|
+
img.removeEventListener('load', onload);
|
|
213
|
+
img.removeEventListener('error', fail);
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- Geometry ------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
const transform = (s: number, tx: number, ty: number) => `translate(${tx}px, ${ty}px) scale(${s})`;
|
|
221
|
+
|
|
222
|
+
/** The stage's centre and size, and the image's size at scale 1 (it is centred in the stage). */
|
|
223
|
+
function frame() {
|
|
224
|
+
const r = stage?.getBoundingClientRect();
|
|
225
|
+
return {
|
|
226
|
+
cx: r ? r.left + r.width / 2 : 0,
|
|
227
|
+
cy: r ? r.top + r.height / 2 : 0,
|
|
228
|
+
sw: r?.width ?? 0,
|
|
229
|
+
sh: r?.height ?? 0,
|
|
230
|
+
w: image?.offsetWidth ?? 0,
|
|
231
|
+
h: image?.offsetHeight ?? 0
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
type Frame = ReturnType<typeof frame>;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The offset held so the image leaves no gap at an edge it can fill:
|
|
238
|
+
* along an axis where it is larger than the stage its edges stay at or
|
|
239
|
+
* past the stage's; along one where it is not, it stays centred.
|
|
240
|
+
*/
|
|
241
|
+
function hold(s: number, tx: number, ty: number, f: Frame): [number, number] {
|
|
242
|
+
const mx = Math.max(0, (s * f.w - f.sw) / 2);
|
|
243
|
+
const my = Math.max(0, (s * f.h - f.sh) / 2);
|
|
244
|
+
return [Math.min(mx, Math.max(-mx, tx)), Math.min(my, Math.max(-my, ty))];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The view at scale `s` that keeps the image point under (px, py) where it is. */
|
|
248
|
+
function about(s: number, px: number, py: number, f = frame()): [number, number, number] {
|
|
249
|
+
const k = s / scale;
|
|
250
|
+
return [s, ...hold(s, px - f.cx - k * (px - f.cx - x), py - f.cy - k * (py - f.cy - y), f)];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function onImage(px: number, py: number) {
|
|
254
|
+
if (phase !== 'loaded' || !image) return false;
|
|
255
|
+
const r = image.getBoundingClientRect();
|
|
256
|
+
return px >= r.left && px <= r.right && py >= r.top && py <= r.bottom;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- Motion ------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/** Ends a double-tap zoom or a settle where it is on screen, so a new gesture starts from there. */
|
|
262
|
+
function stop() {
|
|
263
|
+
if (!settle) return;
|
|
264
|
+
if (image && settle.playState === 'running') {
|
|
265
|
+
const m = new DOMMatrixReadOnly(getComputedStyle(image).transform);
|
|
266
|
+
scale = m.a;
|
|
267
|
+
x = m.e;
|
|
268
|
+
y = m.f;
|
|
269
|
+
}
|
|
270
|
+
settle.cancel();
|
|
271
|
+
settle = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Pinch, pan, wheel and keys: the view follows at once. */
|
|
275
|
+
function place([s, tx, ty]: [number, number, number]) {
|
|
276
|
+
stop();
|
|
277
|
+
scale = s;
|
|
278
|
+
x = tx;
|
|
279
|
+
y = ty;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A double-tap zoom and the settle back to 1: the transform moves over
|
|
284
|
+
* --dur-pop on --settle, both read from the image. Reduced motion jumps.
|
|
285
|
+
*/
|
|
286
|
+
function animate([s, tx, ty]: [number, number, number]) {
|
|
287
|
+
stop();
|
|
288
|
+
const from = transform(scale, x, y);
|
|
289
|
+
scale = s;
|
|
290
|
+
x = tx;
|
|
291
|
+
y = ty;
|
|
292
|
+
const to = transform(s, tx, ty);
|
|
293
|
+
if (!image || from === to || prefersReducedMotion.current) return;
|
|
294
|
+
const ms = duration(image, '--dur-pop');
|
|
295
|
+
if (ms <= 0) return;
|
|
296
|
+
const curve = getComputedStyle(image).getPropertyValue('--settle').trim();
|
|
297
|
+
const run = image.animate([{ transform: from }, { transform: to }], {
|
|
298
|
+
duration: ms,
|
|
299
|
+
easing: CSS.supports('transition-timing-function', curve) ? curve : 'ease-out'
|
|
300
|
+
});
|
|
301
|
+
settle = run;
|
|
302
|
+
run.onfinish = () => {
|
|
303
|
+
if (settle === run) settle = null;
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The view at rest: scale 1, centred. */
|
|
308
|
+
const REST: [number, number, number] = [1, 0, 0];
|
|
309
|
+
|
|
310
|
+
function toggle(px: number, py: number) {
|
|
311
|
+
animate(scale > 1 ? REST : about(DOUBLE_SCALE, px, py));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- Pointers ----------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
const kindOf = (e: PointerEvent): Kind => (e.pointerType === 'mouse' ? 'mouse' : 'touch');
|
|
317
|
+
|
|
318
|
+
function startPinch() {
|
|
319
|
+
const [a, b] = pointers.values();
|
|
320
|
+
const f = frame();
|
|
321
|
+
const mx = (a.x + b.x) / 2;
|
|
322
|
+
const my = (a.y + b.y) / 2;
|
|
323
|
+
pinch = {
|
|
324
|
+
d0: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
|
|
325
|
+
s0: scale,
|
|
326
|
+
qx: (mx - f.cx - x) / scale,
|
|
327
|
+
qy: (my - f.cy - y) / scale
|
|
328
|
+
};
|
|
329
|
+
gesture = 'pinch';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function movePinch() {
|
|
333
|
+
if (!pinch) return;
|
|
334
|
+
const [a, b] = pointers.values();
|
|
335
|
+
const f = frame();
|
|
336
|
+
const mx = (a.x + b.x) / 2;
|
|
337
|
+
const my = (a.y + b.y) / 2;
|
|
338
|
+
const s = Math.min(MAX_SCALE, Math.max(PINCH_MIN, (pinch.s0 * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d0));
|
|
339
|
+
place([s, ...hold(s, mx - f.cx - s * pinch.qx, my - f.cy - s * pinch.qy, f)]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function down(e: PointerEvent) {
|
|
343
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
344
|
+
// Focus stays where it is, as on a scrim.
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
stop();
|
|
347
|
+
const kind = kindOf(e);
|
|
348
|
+
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, kind });
|
|
349
|
+
stage?.setPointerCapture(e.pointerId);
|
|
350
|
+
if (pointers.size === 1) {
|
|
351
|
+
press = { id: e.pointerId, kind, x0: e.clientX, y0: e.clientY, tx0: x, ty0: y, moved: false };
|
|
352
|
+
} else {
|
|
353
|
+
// A second finger: never a tap, and a pinch once the image is there.
|
|
354
|
+
if (press) press.moved = true;
|
|
355
|
+
if (pointers.size === 2 && phase === 'loaded') startPinch();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function move(e: PointerEvent) {
|
|
360
|
+
const p = pointers.get(e.pointerId);
|
|
361
|
+
if (!p) return;
|
|
362
|
+
p.x = e.clientX;
|
|
363
|
+
p.y = e.clientY;
|
|
364
|
+
if (pinch) {
|
|
365
|
+
movePinch();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!press || press.id !== e.pointerId) return;
|
|
369
|
+
const dx = e.clientX - press.x0;
|
|
370
|
+
const dy = e.clientY - press.y0;
|
|
371
|
+
if (!press.moved && Math.hypot(dx, dy) > SLOP[press.kind]) {
|
|
372
|
+
press.moved = true;
|
|
373
|
+
// Zoomed: the image follows the pointer. At scale 1 a drag is the
|
|
374
|
+
// swipe's, or nothing.
|
|
375
|
+
if (scale > 1 && phase === 'loaded') gesture = 'pan';
|
|
376
|
+
}
|
|
377
|
+
if (gesture === 'pan') {
|
|
378
|
+
const f = frame();
|
|
379
|
+
place([scale, ...hold(scale, press.tx0 + dx, press.ty0 + dy, f)]);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function up(e: PointerEvent) {
|
|
384
|
+
if (!pointers.delete(e.pointerId)) return;
|
|
385
|
+
if (pinch) {
|
|
386
|
+
if (pointers.size >= 2) {
|
|
387
|
+
// A third finger lifted, or one of the two: pinch on with the two left.
|
|
388
|
+
startPinch();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
pinch = null;
|
|
392
|
+
gesture = null;
|
|
393
|
+
if (scale < SNAP) animate(REST);
|
|
394
|
+
// The finger left over pans from here, if the image is still zoomed.
|
|
395
|
+
const left = [...pointers.entries()][0];
|
|
396
|
+
press = left
|
|
397
|
+
? { id: left[0], kind: left[1].kind, x0: left[1].x, y0: left[1].y, tx0: x, ty0: y, moved: true }
|
|
398
|
+
: null;
|
|
399
|
+
if (press && scale > 1) gesture = 'pan';
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!press || press.id !== e.pointerId) return;
|
|
403
|
+
const done = press;
|
|
404
|
+
press = null;
|
|
405
|
+
gesture = null;
|
|
406
|
+
if (!done.moved && e.type === 'pointerup') tap(done, e);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// A tap: the second of a double-tap toggles the zoom about the pressed
|
|
410
|
+
// point; a single one on the backdrop, outside the image, closes at
|
|
411
|
+
// scale 1. A tap on the image waits for its second.
|
|
412
|
+
function tap(start: Press, e: PointerEvent) {
|
|
413
|
+
const t = e.timeStamp;
|
|
414
|
+
const last = lastTap;
|
|
415
|
+
if (
|
|
416
|
+
last &&
|
|
417
|
+
last.kind === start.kind &&
|
|
418
|
+
t - last.t < DOUBLE_TIME[start.kind] &&
|
|
419
|
+
Math.hypot(start.x0 - last.x, start.y0 - last.y) < DOUBLE_DISTANCE[start.kind]
|
|
420
|
+
) {
|
|
421
|
+
lastTap = null;
|
|
422
|
+
if (phase === 'loaded') toggle(start.x0, start.y0);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
lastTap = { t, x: start.x0, y: start.y0, kind: start.kind };
|
|
426
|
+
if (scale === 1 && !onImage(start.x0, start.y0)) close();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The wheel zooms about the cursor; a trackpad pinch arrives as a wheel
|
|
430
|
+
// with ctrlKey and smaller steps.
|
|
431
|
+
function wheel(e: WheelEvent) {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
if (phase !== 'loaded' || pinch) return;
|
|
434
|
+
stop();
|
|
435
|
+
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? (stage?.clientHeight ?? 800) : 1;
|
|
436
|
+
const rate = e.ctrlKey ? 0.01 : 0.002;
|
|
437
|
+
const s = Math.min(MAX_SCALE, Math.max(1, scale * Math.exp(-e.deltaY * unit * rate)));
|
|
438
|
+
place(about(s, e.clientX, e.clientY));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function gestures(node: HTMLElement) {
|
|
442
|
+
const capture = { capture: true };
|
|
443
|
+
// Capture, so a swipe that stops a drag's pointerup at the stage
|
|
444
|
+
// cannot hide it from here.
|
|
445
|
+
node.addEventListener('pointerdown', down, capture);
|
|
446
|
+
node.addEventListener('pointermove', move, capture);
|
|
447
|
+
node.addEventListener('pointerup', up, capture);
|
|
448
|
+
node.addEventListener('pointercancel', up, capture);
|
|
449
|
+
node.addEventListener('wheel', wheel, { passive: false });
|
|
450
|
+
return () => {
|
|
451
|
+
node.removeEventListener('pointerdown', down, capture);
|
|
452
|
+
node.removeEventListener('pointermove', move, capture);
|
|
453
|
+
node.removeEventListener('pointerup', up, capture);
|
|
454
|
+
node.removeEventListener('pointercancel', up, capture);
|
|
455
|
+
node.removeEventListener('wheel', wheel);
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---- Keys --------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/** Each arrow key's pan, as a share of the stage: it shows more of that side, so the image moves the other way. */
|
|
462
|
+
const ARROWS: Record<string, [number, number]> = {
|
|
463
|
+
ArrowLeft: [KEY_PAN, 0],
|
|
464
|
+
ArrowRight: [-KEY_PAN, 0],
|
|
465
|
+
ArrowUp: [0, KEY_PAN],
|
|
466
|
+
ArrowDown: [0, -KEY_PAN]
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// + or = zooms in, - zooms out, 0 goes back to 1, about the stage's
|
|
470
|
+
// centre; arrow keys pan while zoomed. Each press jumps: no motion
|
|
471
|
+
// repeats on a key press. Keys with Ctrl, Alt or Meta stay the browser's.
|
|
472
|
+
function key(e: KeyboardEvent) {
|
|
473
|
+
if (e.ctrlKey || e.altKey || e.metaKey || e.defaultPrevented || phase !== 'loaded') return;
|
|
474
|
+
const arrow = ARROWS[e.key];
|
|
475
|
+
if (arrow ? scale <= 1 : !['+', '=', '-', '0'].includes(e.key)) return;
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
stop();
|
|
478
|
+
const f = frame();
|
|
479
|
+
if (arrow) place([scale, ...hold(scale, x + arrow[0] * f.sw, y + arrow[1] * f.sh, f)]);
|
|
480
|
+
else if (e.key === '0') place(REST);
|
|
481
|
+
else if (e.key === '-') place(about(Math.max(1, scale / KEY_STEP), f.cx, f.cy, f));
|
|
482
|
+
else place(about(Math.min(MAX_SCALE, scale * KEY_STEP), f.cx, f.cy, f));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function keys(node: HTMLElement) {
|
|
486
|
+
node.addEventListener('keydown', key);
|
|
487
|
+
return () => node.removeEventListener('keydown', key);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- Download ----------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/** A `data:` URL's bytes as a Blob, decoded here, so no fetch() meets a Content Security Policy. */
|
|
493
|
+
function dataBlob(url: string): Blob {
|
|
494
|
+
const comma = url.indexOf(',');
|
|
495
|
+
const head = url.slice(5, comma);
|
|
496
|
+
const raw = url.slice(comma + 1);
|
|
497
|
+
const body = raw.includes('%') ? decodeURIComponent(raw) : raw;
|
|
498
|
+
const base64 = /;base64$/i.test(head);
|
|
499
|
+
const type = (base64 ? head.slice(0, -7) : head) || 'text/plain';
|
|
500
|
+
if (!base64) return new Blob([body], { type });
|
|
501
|
+
const text = atob(body);
|
|
502
|
+
const bytes = new Uint8Array(text.length);
|
|
503
|
+
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
|
|
504
|
+
return new Blob([bytes], { type });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// A link with `download` saves the file under its name. It opens in a
|
|
508
|
+
// new tab, so an image from another origin, whose `download` the browser
|
|
509
|
+
// ignores, never replaces the app.
|
|
510
|
+
function save(name: string) {
|
|
511
|
+
const made = /^data:/i.test(src) ? URL.createObjectURL(dataBlob(src)) : null;
|
|
512
|
+
const link = document.createElement('a');
|
|
513
|
+
link.href = made ?? src;
|
|
514
|
+
link.download = name;
|
|
515
|
+
link.target = '_blank';
|
|
516
|
+
link.rel = 'noopener';
|
|
517
|
+
(ref ?? document.body).append(link);
|
|
518
|
+
link.click();
|
|
519
|
+
link.remove();
|
|
520
|
+
if (made) setTimeout(() => URL.revokeObjectURL(made), REVOKE_AFTER);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function download() {
|
|
524
|
+
if (!ondownload) {
|
|
525
|
+
if (downloadName !== undefined) save(downloadName);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (saving) return;
|
|
529
|
+
saving = true;
|
|
530
|
+
try {
|
|
531
|
+
await ondownload();
|
|
532
|
+
} finally {
|
|
533
|
+
saving = false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
</script>
|
|
537
|
+
|
|
538
|
+
<!--
|
|
539
|
+
One image over everything, to look at closely: a top bar with the
|
|
540
|
+
`title`, Download and Close, and the image fitted inside the rest of the
|
|
541
|
+
screen at scale 1, centred. Full screen at every width, on
|
|
542
|
+
--layer-dialog, filled with --canvas. Bits UI Dialog traps focus, locks
|
|
543
|
+
the page's scroll, closes on Escape and gives focus back to the opener;
|
|
544
|
+
focus starts on Close.
|
|
545
|
+
|
|
546
|
+
Zoom (1 to 6 times the fitted size): a pinch about the fingers'
|
|
547
|
+
midpoint, a double-tap or double-click (1 and 2.5 about the pressed
|
|
548
|
+
point), the wheel about the cursor, + = - and 0. While zoomed, one
|
|
549
|
+
pointer or the arrow keys pan, held so the image leaves no gap at an edge
|
|
550
|
+
it can fill. A pinch released below 1, or less than 5 percent above it,
|
|
551
|
+
settles back to 1.
|
|
552
|
+
|
|
553
|
+
Closing: Close, Escape, and at scale 1 a swipe down
|
|
554
|
+
(internal/swipe-dismiss.ts) or a press on the backdrop outside the
|
|
555
|
+
image. A drag is never a press. No gesture closes it while zoomed. The
|
|
556
|
+
system back gesture is the app's: push a history entry on open and close
|
|
557
|
+
on popstate (see the docs).
|
|
558
|
+
|
|
559
|
+
Motion: the viewer fades in over --dur-sheet and out over
|
|
560
|
+
--dur-sheet-exit. Pinch, pan, wheel and keys follow at once. A
|
|
561
|
+
double-tap zoom and the settle back to 1 move the transform over
|
|
562
|
+
--dur-pop on --settle, read from the image; reduced motion jumps.
|
|
563
|
+
|
|
564
|
+
The stage `.hp-image-viewer-stage` carries `data-state` loading |
|
|
565
|
+
loaded | error, `data-zoomed` above scale 1 and `data-gesture` pan |
|
|
566
|
+
pinch. While loading, a Spinner shows after its delay; on an error the
|
|
567
|
+
alt shows as a muted caption and Download stays. Nothing is announced on
|
|
568
|
+
zoom.
|
|
569
|
+
-->
|
|
570
|
+
<BitsDialog.Root bind:open>
|
|
571
|
+
{#if trigger}
|
|
572
|
+
<BitsDialog.Trigger>
|
|
573
|
+
{#snippet child({ props })}
|
|
574
|
+
{@render trigger({ props })}
|
|
575
|
+
{/snippet}
|
|
576
|
+
</BitsDialog.Trigger>
|
|
577
|
+
{/if}
|
|
578
|
+
|
|
579
|
+
<BitsDialog.Portal>
|
|
580
|
+
<BitsDialog.Content
|
|
581
|
+
forceMount
|
|
582
|
+
bind:ref
|
|
583
|
+
aria-labelledby={title ? titleId : undefined}
|
|
584
|
+
aria-label={title ? undefined : alt}
|
|
585
|
+
onOpenAutoFocus={openAutoFocus}
|
|
586
|
+
{...rest}
|
|
587
|
+
>
|
|
588
|
+
{#snippet child({
|
|
589
|
+
props: { interactOutsideBehavior: _outside, escapeKeydownBehavior: _escape, ...props },
|
|
590
|
+
open: shown
|
|
591
|
+
})}
|
|
592
|
+
{#if shown}
|
|
593
|
+
<div {...props} class={['hp-image-viewer', className]} {@attach keys} transition:fade>
|
|
594
|
+
<div class="hp-image-viewer-bar">
|
|
595
|
+
{#if title}
|
|
596
|
+
<h2 id={titleId} class="hp-image-viewer-title">{title}</h2>
|
|
597
|
+
{/if}
|
|
598
|
+
{#if downloadable}
|
|
599
|
+
<Button class="hp-image-viewer-download" onclick={download}>{downloadLabel}</Button>
|
|
600
|
+
{/if}
|
|
601
|
+
<Button
|
|
602
|
+
variant="tertiary"
|
|
603
|
+
icon
|
|
604
|
+
class="hp-image-viewer-close"
|
|
605
|
+
aria-label={closeLabel}
|
|
606
|
+
onclick={close}
|
|
607
|
+
>
|
|
608
|
+
<svg
|
|
609
|
+
viewBox="0 0 16 16"
|
|
610
|
+
fill="none"
|
|
611
|
+
stroke="currentColor"
|
|
612
|
+
stroke-width="1.5"
|
|
613
|
+
stroke-linecap="square"
|
|
614
|
+
aria-hidden="true"
|
|
615
|
+
>
|
|
616
|
+
<path d="M3 3l10 10M13 3L3 13" />
|
|
617
|
+
</svg>
|
|
618
|
+
</Button>
|
|
619
|
+
</div>
|
|
620
|
+
<div
|
|
621
|
+
class="hp-image-viewer-stage"
|
|
622
|
+
data-state={phase}
|
|
623
|
+
data-zoomed={scale > 1 ? '' : undefined}
|
|
624
|
+
data-gesture={gesture ?? undefined}
|
|
625
|
+
bind:this={stage}
|
|
626
|
+
{@attach gestures}
|
|
627
|
+
{@attach atRest ? swipeDismiss(close) : undefined}
|
|
628
|
+
>
|
|
629
|
+
{#key src}
|
|
630
|
+
<img
|
|
631
|
+
class="hp-image-viewer-image"
|
|
632
|
+
{src}
|
|
633
|
+
{alt}
|
|
634
|
+
draggable="false"
|
|
635
|
+
style:transform={transform(scale, x, y)}
|
|
636
|
+
bind:this={image}
|
|
637
|
+
{@attach watch}
|
|
638
|
+
/>
|
|
639
|
+
{/key}
|
|
640
|
+
{#if phase === 'loading'}
|
|
641
|
+
<div class="hp-image-viewer-spinner"><Spinner label={loadingLabel} /></div>
|
|
642
|
+
{:else if phase === 'error' && alt}
|
|
643
|
+
<span class="hp-image-viewer-caption hp-caption" aria-hidden="true">{alt}</span>
|
|
644
|
+
{/if}
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
{/if}
|
|
648
|
+
{/snippet}
|
|
649
|
+
</BitsDialog.Content>
|
|
650
|
+
</BitsDialog.Portal>
|
|
651
|
+
</BitsDialog.Root>
|
|
652
|
+
|
|
653
|
+
<style>
|
|
654
|
+
/* The whole screen in --canvas, so it follows the theme. The side and
|
|
655
|
+
bottom safe areas keep the stage clear of the screen's cut-outs; the
|
|
656
|
+
bar adds the top one. Bits UI may have turned pointer events off on
|
|
657
|
+
<body>. */
|
|
658
|
+
.hp-image-viewer {
|
|
659
|
+
position: fixed;
|
|
660
|
+
inset: 0;
|
|
661
|
+
z-index: var(--layer-dialog);
|
|
662
|
+
display: flex;
|
|
663
|
+
flex-direction: column;
|
|
664
|
+
box-sizing: border-box;
|
|
665
|
+
padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
666
|
+
background: var(--canvas);
|
|
667
|
+
color: var(--text);
|
|
668
|
+
overscroll-behavior: contain;
|
|
669
|
+
pointer-events: auto;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* The shell header's height under the top safe area: the title takes
|
|
673
|
+
what the buttons leave and ends in an ellipsis; without a title the
|
|
674
|
+
buttons keep the end. Download and Close keep their size, 8px apart,
|
|
675
|
+
with Close 8px from the edge, so its focus contour (4px out, 2px wide)
|
|
676
|
+
stays on screen. */
|
|
677
|
+
.hp-image-viewer-bar {
|
|
678
|
+
flex: none;
|
|
679
|
+
display: flex;
|
|
680
|
+
align-items: center;
|
|
681
|
+
justify-content: flex-end;
|
|
682
|
+
gap: var(--space-2);
|
|
683
|
+
box-sizing: border-box;
|
|
684
|
+
min-height: calc(var(--header-height) + env(safe-area-inset-top));
|
|
685
|
+
padding: env(safe-area-inset-top) var(--space-2) 0 var(--gutter);
|
|
686
|
+
}
|
|
687
|
+
.hp-image-viewer-bar > :global(.hp-button) {
|
|
688
|
+
flex: none;
|
|
689
|
+
}
|
|
690
|
+
.hp-image-viewer-title {
|
|
691
|
+
flex: 1 1 auto;
|
|
692
|
+
min-width: 0;
|
|
693
|
+
overflow: hidden;
|
|
694
|
+
font-size: var(--type-body);
|
|
695
|
+
line-height: 24px;
|
|
696
|
+
text-overflow: ellipsis;
|
|
697
|
+
white-space: nowrap;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* The rest of the screen. Every touch is the viewer's: no browser pan,
|
|
701
|
+
zoom or overscroll. The image never shows outside it, so the bar stays
|
|
702
|
+
clear. */
|
|
703
|
+
.hp-image-viewer-stage {
|
|
704
|
+
position: relative;
|
|
705
|
+
flex: 1 1 auto;
|
|
706
|
+
min-height: 0;
|
|
707
|
+
overflow: hidden;
|
|
708
|
+
overscroll-behavior: contain;
|
|
709
|
+
touch-action: none;
|
|
710
|
+
user-select: none;
|
|
711
|
+
-webkit-user-select: none;
|
|
712
|
+
}
|
|
713
|
+
.hp-image-viewer-stage[data-zoomed] {
|
|
714
|
+
cursor: grab;
|
|
715
|
+
}
|
|
716
|
+
.hp-image-viewer-stage[data-gesture] {
|
|
717
|
+
cursor: grabbing;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/* Fitted inside the stage at its own size or smaller, centred. Hidden by
|
|
721
|
+
opacity only while it loads or after an error, so it stays in the
|
|
722
|
+
accessibility tree and no broken-image glyph shows. */
|
|
723
|
+
.hp-image-viewer-image {
|
|
724
|
+
position: absolute;
|
|
725
|
+
inset: 0;
|
|
726
|
+
width: auto;
|
|
727
|
+
height: auto;
|
|
728
|
+
max-width: 100%;
|
|
729
|
+
max-height: 100%;
|
|
730
|
+
margin: auto;
|
|
731
|
+
transform-origin: 50% 50%;
|
|
732
|
+
-webkit-user-drag: none;
|
|
733
|
+
}
|
|
734
|
+
.hp-image-viewer-stage[data-state='loading'] > .hp-image-viewer-image,
|
|
735
|
+
.hp-image-viewer-stage[data-state='error'] > .hp-image-viewer-image {
|
|
736
|
+
opacity: 0;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/* The spinner in the middle, on whole pixels (Spinner: never centre
|
|
740
|
+
pixel text in odd free space). Browsers without round() centre it. */
|
|
741
|
+
.hp-image-viewer-spinner {
|
|
742
|
+
position: absolute;
|
|
743
|
+
top: 50%;
|
|
744
|
+
left: 50%;
|
|
745
|
+
translate: -50% -50%;
|
|
746
|
+
top: round(down, 50%, 1px);
|
|
747
|
+
left: round(down, 50%, 1px);
|
|
748
|
+
translate: round(down, -50%, 1px) round(down, -50%, 1px);
|
|
749
|
+
pointer-events: none;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/* The alt of an image that failed, as MediaImage shows it. */
|
|
753
|
+
.hp-image-viewer-caption {
|
|
754
|
+
position: absolute;
|
|
755
|
+
inset: 0;
|
|
756
|
+
display: grid;
|
|
757
|
+
place-items: center;
|
|
758
|
+
padding: var(--space-8);
|
|
759
|
+
overflow: hidden;
|
|
760
|
+
color: var(--muted);
|
|
761
|
+
text-align: center;
|
|
762
|
+
pointer-events: none;
|
|
763
|
+
}
|
|
764
|
+
</style>
|