@gallerist/plugin-zoom 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +103 -2
- package/dist/index.cjs +174 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +39 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +169 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shafayet Ahmmed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gallerist/plugin-zoom
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Gallerist plugin — a **zoom tool** in the toolbar: **− 100% +**. Zoom out, the current zoom (press
|
|
4
|
+
it to fit the image again), zoom in; the `+`, `-` and `0` keys do the same.
|
|
5
|
+
|
|
6
|
+
Part of [Gallerist](https://github.com/shafayet9780/gallerist#readme) — a headless, framework-agnostic media viewer for grouped / nested galleries with image-space overlays and virtualized rendering.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm i @gallerist/plugin-zoom
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { createViewer } from '@gallerist/core'
|
|
18
|
+
import { zoom } from '@gallerist/plugin-zoom'
|
|
19
|
+
import { mount } from '@gallerist/renderer-dom'
|
|
20
|
+
import '@gallerist/theme-default/gallerist.css'
|
|
21
|
+
|
|
22
|
+
const viewer = createViewer({ groups, plugins: [zoom()] })
|
|
23
|
+
mount(viewer, { preset: 'reviewer' })
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
One tool joins the toolbar, a group named "Zoom" of three buttons:
|
|
27
|
+
|
|
28
|
+
- **−** zooms out to the previous stop, **+** in to the next. The stops are fit (100%), 150%, 200%,
|
|
29
|
+
300% and 400% by default: 100% is the image fitted to the stage, and 400% the DOM renderer's
|
|
30
|
+
largest zoom. Every step pivots on the centre of the stage, and the view stays inside the image.
|
|
31
|
+
- **the percentage** shows the current zoom, and fits the image again when pressed (Enter or Space
|
|
32
|
+
from the keyboard). It follows every zoom as it happens: a pinch, a wheel or a double click shows
|
|
33
|
+
its percentage while it moves, not only once it settles, and a new item shows its own.
|
|
34
|
+
|
|
35
|
+
While the viewer has focus, **`+`** (or **`=`**) and **`-`** step through the same stops, and **`0`**
|
|
36
|
+
fits the image. They do nothing while the focus is in a text field inside the viewer (a pin's note,
|
|
37
|
+
say), and nothing with Ctrl, ⌘ or Alt held (the browser's own page zoom). Without this plugin the
|
|
38
|
+
renderer zooms by a fixed factor on those keys; with it, the keys and the buttons take the same
|
|
39
|
+
steps (`keys: false` leaves the keys to the renderer).
|
|
40
|
+
|
|
41
|
+
These keys are the tool's while `keys` is on, whatever the item: while it can't zoom they are
|
|
42
|
+
marked taken (`preventDefault()`) and the renderer runs nothing for them, so another plugin's own
|
|
43
|
+
`+`, `=`, `-` or `0` in its `keymap` never runs either. A plugin (or host) that wants one of those keys
|
|
44
|
+
sets `keys: false`, or handles it first in a capture-phase listener above the viewer's root (on
|
|
45
|
+
`document`, say) and calls `preventDefault()`, which this plugin leaves alone.
|
|
46
|
+
|
|
47
|
+
**−** and **+** are unavailable at either end of the range, and while the current item can't zoom: a
|
|
48
|
+
video or another non-image, an image still loading, one that failed to load. The percentage is
|
|
49
|
+
unavailable at fit, where there's nothing to reset. Unavailable buttons are `aria-disabled` and dimmed
|
|
50
|
+
(the percentage keeps its colour), and stay focusable, so the focus never drops out of the toolbar when
|
|
51
|
+
a press reaches the end of the range.
|
|
52
|
+
|
|
53
|
+
The renderer's own zoom-reset button (shown in the bar while the image is zoomed) is left out while
|
|
54
|
+
this tool shows in the bar: the percentage does the same. A tool given `hidden`, or left out on a
|
|
55
|
+
phone, leaves the reset where it was; without the plugin it works as before. A zoom from the tool or
|
|
56
|
+
its keys is announced in the viewer's live region (`150%`); a pinch or a wheel only shows it.
|
|
57
|
+
|
|
58
|
+
## Options
|
|
59
|
+
|
|
60
|
+
| Option | Default | |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `stops` | `[1, 1.5, 2, 3, 4]` | the zoom levels − and + step through, as scales (1 = fit); the first and last are the ends of the range. The DOM renderer zooms from fit to 400%: a stop outside that range is brought into it |
|
|
63
|
+
| `zoomable(item)` | `item.type === 'image'` | whether an item can zoom once it has loaded (a custom adapter's media that zooms); [plugin-shortcuts](../plugin-shortcuts/README.md) lists the zoom keys while an item in the collection can |
|
|
64
|
+
| `keys` | `true` | `+` / `=`, `-` and `0` step through the stops; `false` leaves them to the renderer |
|
|
65
|
+
| `strings` | English | the labels, for translation; defaults in `DEFAULT_ZOOM_STRINGS`: `zoom` (the group), `zoomIn`, `zoomOut`, `reset(percent)` (the percentage button's name, which includes the percentage it shows), `percent(scale)` (how a zoom reads, `125%`) |
|
|
66
|
+
|
|
67
|
+
`nextStop(stops, scale, dir)` (the stop a step lands on) and `DEFAULT_STOPS` are exported too.
|
|
68
|
+
|
|
69
|
+
## Look
|
|
70
|
+
|
|
71
|
+
The buttons are bar tools like the others, so they take each skin's tool style, in both schemes.
|
|
72
|
+
With a fine pointer the Console skin draws them as one group in a hairline border, 30px tall, the
|
|
73
|
+
percentage between two hairlines (`--gallerist-zoom-group: 1`); a touch, Gallery and Editorial keep
|
|
74
|
+
three loose 44px tools.
|
|
75
|
+
Right to left the group mirrors with the bar (− stays at the inline start, now on the right). The
|
|
76
|
+
percentage uses tabular figures and keeps one width, so the bar doesn't shift as it changes. On a phone (600px wide or less) the bar
|
|
77
|
+
leaves the tool out (a pinch zooms there, and a double tap fits); a page that keeps it
|
|
78
|
+
(`.gallerist { --gallerist-phone-zoom: flex }`) gets − and + without the percentage, and the
|
|
79
|
+
toolbar's strip scrolls if the tools still don't fit. A tool given `hidden` stays hidden. The styles ship in `@gallerist/theme-default` (`base.css`).
|
|
80
|
+
|
|
81
|
+
## How it reads the zoom
|
|
82
|
+
|
|
83
|
+
The committed zoom comes from the viewer's state (`viewer.subscribe`). A gesture's zoom isn't
|
|
84
|
+
committed until it ends, so while one moves the plugin reads the transform the renderer gives the
|
|
85
|
+
current slide (`.gallerist-content[data-gallerist-current]`), through a `MutationObserver` on the
|
|
86
|
+
viewer's root that only looks at `style` changes. The keys are a capture-phase `keydown` listener on
|
|
87
|
+
the viewer's root; it stops the keys it takes (`+`, `=`, `-`, `0`) from reaching the renderer and
|
|
88
|
+
anything above the viewer. It leaves them alone where they are someone else's: typed in a field
|
|
89
|
+
(`input`, `textarea`, `select`, contenteditable, one in an open shadow root included), inside a
|
|
90
|
+
dialog or menu in the viewer (the View popover, a host's popover), inside a `slider`, `spinbutton`,
|
|
91
|
+
`tree` or `grid`, or with Ctrl, ⌘ or Alt. While the current item can't zoom they do nothing (as the
|
|
92
|
+
tool shows) and go on to the page, marked taken (`preventDefault()`), so the renderer doesn't zoom it
|
|
93
|
+
either.
|
|
94
|
+
|
|
95
|
+
## Not in the CDN bundle
|
|
96
|
+
|
|
97
|
+
`@gallerist/cdn` (the one-`<script>` build) doesn't include this plugin: it would take that bundle
|
|
98
|
+
past its size budget. Install it from npm alongside the packages above.
|
|
99
|
+
|
|
100
|
+
Full docs: the [monorepo README](https://github.com/shafayet9780/gallerist#readme) and [`docs/`](https://github.com/shafayet9780/gallerist/tree/main/docs).
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT © Shafayet Ahmmed
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var DEFAULT_ZOOM_STRINGS = {
|
|
5
|
+
zoom: "Zoom",
|
|
6
|
+
zoomIn: "Zoom in",
|
|
7
|
+
zoomOut: "Zoom out",
|
|
8
|
+
reset: (percent) => `${percent}, fit to screen`,
|
|
9
|
+
percent: (scale) => `${Math.round(scale * 100)}%`
|
|
10
|
+
};
|
|
11
|
+
var DEFAULT_STOPS = [1, 1.5, 2, 3, 4];
|
|
12
|
+
var icon = (body) => `<svg class="gallerist-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${body}</svg>`;
|
|
13
|
+
var MINUS = icon('<path d="M5 12h14"/>');
|
|
14
|
+
var PLUS = icon('<path d="M5 12h14"/><path d="M12 5v14"/>');
|
|
15
|
+
var EPS = 0.01;
|
|
16
|
+
function nextStop(stops, scale, dir) {
|
|
17
|
+
const sorted = [...stops].sort((a, b) => a - b);
|
|
18
|
+
if (dir > 0) return sorted.find((s) => s > scale + EPS) ?? scale;
|
|
19
|
+
return [...sorted].reverse().find((s) => s < scale - EPS) ?? scale;
|
|
20
|
+
}
|
|
21
|
+
var SCALE = /scale\(\s*([\d.]+)/;
|
|
22
|
+
var RANGE = [1, 4];
|
|
23
|
+
var OWNED = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role=dialog],[role=menu],[role=spinbutton],[role=slider],[role=tree],[role=grid]';
|
|
24
|
+
var theirs = (e, root) => {
|
|
25
|
+
const at = e.composedPath()[0].closest?.(OWNED);
|
|
26
|
+
return !!at && !at.contains(root);
|
|
27
|
+
};
|
|
28
|
+
function zoom(opts = {}) {
|
|
29
|
+
const t = { ...DEFAULT_ZOOM_STRINGS, ...opts.strings };
|
|
30
|
+
const stops = (opts.stops?.length ? opts.stops : DEFAULT_STOPS).map((s) => Math.min(RANGE[1], Math.max(RANGE[0], s))).sort((a, b) => a - b);
|
|
31
|
+
const min = stops[0] ?? 1;
|
|
32
|
+
const max = stops[stops.length - 1] ?? 1;
|
|
33
|
+
const zoomable = opts.zoomable ?? ((item) => item.type === "image");
|
|
34
|
+
return (viewer) => {
|
|
35
|
+
let group = null;
|
|
36
|
+
let out = null;
|
|
37
|
+
let level = null;
|
|
38
|
+
let into = null;
|
|
39
|
+
let root = null;
|
|
40
|
+
let unsub = null;
|
|
41
|
+
let watch = null;
|
|
42
|
+
let live = null;
|
|
43
|
+
const canZoom = () => {
|
|
44
|
+
const { cursor, loaded } = viewer.getState();
|
|
45
|
+
const item = viewer.getCollection().groups[cursor.g]?.items[cursor.i];
|
|
46
|
+
return !!item && zoomable(item) && loaded[item.id] === "loaded";
|
|
47
|
+
};
|
|
48
|
+
const scale = () => live ?? viewer.getState().zoom.scale;
|
|
49
|
+
const off = (btn, disabled) => {
|
|
50
|
+
if (!btn) return;
|
|
51
|
+
if (disabled) btn.setAttribute("aria-disabled", "true");
|
|
52
|
+
else btn.removeAttribute("aria-disabled");
|
|
53
|
+
};
|
|
54
|
+
const paint = () => {
|
|
55
|
+
if (!level) return;
|
|
56
|
+
const s = Math.max(min, scale());
|
|
57
|
+
const percent = t.percent(s);
|
|
58
|
+
const ok = canZoom();
|
|
59
|
+
if (level.textContent !== percent) level.textContent = percent;
|
|
60
|
+
level.setAttribute("aria-label", t.reset(percent));
|
|
61
|
+
off(out, !ok || s <= min + EPS);
|
|
62
|
+
off(into, !ok || s >= max - EPS);
|
|
63
|
+
off(level, s <= 1 + EPS);
|
|
64
|
+
};
|
|
65
|
+
const say = () => {
|
|
66
|
+
const live2 = root?.querySelector(".gallerist-live");
|
|
67
|
+
if (live2) live2.textContent = t.percent(viewer.getState().zoom.scale);
|
|
68
|
+
};
|
|
69
|
+
const step = (dir) => {
|
|
70
|
+
if (!canZoom()) return;
|
|
71
|
+
const s = scale();
|
|
72
|
+
const to = nextStop(stops, s, dir);
|
|
73
|
+
if (to === s) return;
|
|
74
|
+
viewer.zoomTo(to);
|
|
75
|
+
say();
|
|
76
|
+
};
|
|
77
|
+
const fit = () => {
|
|
78
|
+
if (viewer.getState().zoom.scale <= 1 + EPS) return;
|
|
79
|
+
viewer.resetZoom();
|
|
80
|
+
say();
|
|
81
|
+
};
|
|
82
|
+
const onKey = (e) => {
|
|
83
|
+
const dir = e.key === "+" || e.key === "=" ? 1 : e.key === "-" ? -1 : e.key === "0" ? 0 : null;
|
|
84
|
+
if (dir === null || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
85
|
+
if (theirs(e, e.currentTarget)) return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
if (!canZoom()) return;
|
|
88
|
+
e.stopImmediatePropagation();
|
|
89
|
+
if (dir === 0) fit();
|
|
90
|
+
else step(dir);
|
|
91
|
+
};
|
|
92
|
+
const onMutate = (records) => {
|
|
93
|
+
let seen = false;
|
|
94
|
+
for (const r of records) {
|
|
95
|
+
const el = r.target;
|
|
96
|
+
if (!el.classList?.contains("gallerist-content")) continue;
|
|
97
|
+
if (!el.hasAttribute("data-gallerist-current")) continue;
|
|
98
|
+
const m = SCALE.exec(el.style.transform);
|
|
99
|
+
const s = m ? Number(m[1]) : Number.NaN;
|
|
100
|
+
if (!Number.isFinite(s)) continue;
|
|
101
|
+
live = Math.abs(s - viewer.getState().zoom.scale) > EPS && s >= min ? s : null;
|
|
102
|
+
seen = true;
|
|
103
|
+
}
|
|
104
|
+
if (seen) paint();
|
|
105
|
+
};
|
|
106
|
+
const attach = () => {
|
|
107
|
+
const found = group?.closest(".gallerist") ?? null;
|
|
108
|
+
if (!found || found === root) return;
|
|
109
|
+
detach();
|
|
110
|
+
root = found;
|
|
111
|
+
if (opts.keys !== false) root.addEventListener("keydown", onKey, true);
|
|
112
|
+
if (typeof MutationObserver !== "undefined") {
|
|
113
|
+
watch = new MutationObserver(onMutate);
|
|
114
|
+
watch.observe(root, { subtree: true, attributeFilter: ["style"] });
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const detach = () => {
|
|
118
|
+
root?.removeEventListener("keydown", onKey, true);
|
|
119
|
+
watch?.disconnect();
|
|
120
|
+
watch = null;
|
|
121
|
+
root = null;
|
|
122
|
+
};
|
|
123
|
+
const button = (className, label, html, onClick) => {
|
|
124
|
+
const btn = document.createElement("button");
|
|
125
|
+
btn.type = "button";
|
|
126
|
+
btn.className = className;
|
|
127
|
+
btn.setAttribute("aria-label", label);
|
|
128
|
+
btn.innerHTML = html;
|
|
129
|
+
btn.addEventListener("click", () => {
|
|
130
|
+
if (btn.getAttribute("aria-disabled") !== "true") onClick();
|
|
131
|
+
});
|
|
132
|
+
return btn;
|
|
133
|
+
};
|
|
134
|
+
const build = () => {
|
|
135
|
+
group = document.createElement("div");
|
|
136
|
+
group.className = "gallerist-zoom-tool";
|
|
137
|
+
group.setAttribute("role", "group");
|
|
138
|
+
group.setAttribute("aria-label", t.zoom);
|
|
139
|
+
out = button("gallerist-zoom-out", t.zoomOut, MINUS, () => step(-1));
|
|
140
|
+
level = button("gallerist-zoom-level", "", "", fit);
|
|
141
|
+
into = button("gallerist-zoom-in", t.zoomIn, PLUS, () => step(1));
|
|
142
|
+
group.append(out, level, into);
|
|
143
|
+
paint();
|
|
144
|
+
queueMicrotask(attach);
|
|
145
|
+
return group;
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
name: "zoom",
|
|
149
|
+
zoomable,
|
|
150
|
+
// plugin-shortcuts lists the zoom keys while an item can zoom
|
|
151
|
+
init() {
|
|
152
|
+
unsub = viewer.subscribe(() => {
|
|
153
|
+
live = null;
|
|
154
|
+
if (!root) attach();
|
|
155
|
+
paint();
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
destroy() {
|
|
159
|
+
unsub?.();
|
|
160
|
+
unsub = null;
|
|
161
|
+
detach();
|
|
162
|
+
group = out = level = into = null;
|
|
163
|
+
},
|
|
164
|
+
regions: { toolbar: build }
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
exports.DEFAULT_STOPS = DEFAULT_STOPS;
|
|
170
|
+
exports.DEFAULT_ZOOM_STRINGS = DEFAULT_ZOOM_STRINGS;
|
|
171
|
+
exports.nextStop = nextStop;
|
|
172
|
+
exports.zoom = zoom;
|
|
173
|
+
//# sourceMappingURL=index.cjs.map
|
|
174
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["live"],"mappings":"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","file":"index.cjs","sourcesContent":["// @gallerist/plugin-zoom — a zoom tool in the viewer's toolbar: \"− 100% +\".\n//\n// Zoom out, the current zoom (pressing it fits the image again), zoom in. The steps are stops of\n// the zoom range (fit, 150%, 200%, 300%, 400% by default; 100% is the image fitted to the stage), and\n// every step pivots on the stage's centre (`viewer.zoomTo`, which keeps the view inside the image).\n// While the viewer has focus, `+` / `=`, `-` and `0` take the same steps: the renderer's own zoom\n// keys step by a fixed factor, so the tool takes those keys first and the two never disagree.\n//\n// The percentage follows every zoom: the committed one from the viewer's state, and the live\n// transform while a pinch, a wheel or a double click is still moving (the renderer applies those to\n// the current slide's content before it commits them, so the plugin reads that transform).\n// `− +` are unavailable (aria-disabled, still focusable, so focus never drops) at either end of the\n// range and while the current item can't zoom: a video or another non-image, one still loading, one\n// that failed. The percentage is unavailable at fit, where there's nothing to reset.\n\nimport type { Item, Plugin, PluginInstance, ViewerApi } from '@gallerist/core'\n\nexport interface ZoomStrings {\n /** the tool's group name */\n zoom: string\n zoomIn: string\n zoomOut: string\n /** the percentage button's name, with the zoom it shows (it fits the image again) */\n reset: (percent: string) => string\n /** how a zoom reads (`scale` 1 is the image fitted to the stage) */\n percent: (scale: number) => string\n}\n\nexport const DEFAULT_ZOOM_STRINGS: ZoomStrings = {\n zoom: 'Zoom',\n zoomIn: 'Zoom in',\n zoomOut: 'Zoom out',\n reset: (percent) => `${percent}, fit to screen`,\n percent: (scale) => `${Math.round(scale * 100)}%`,\n}\n\nexport interface ZoomOptions {\n /** The zoom levels − and + step through, as scales (1 = fit). Default `[1, 1.5, 2, 3, 4]`, the DOM\n * renderer's range (fit to 400%); the first and last are the ends of the range. A stop outside\n * that range is brought into it (the renderer can't zoom past it). */\n stops?: readonly number[]\n /** Whether an item can zoom (when it has loaded). Default: images (`type: 'image'`). */\n zoomable?: (item: Item) => boolean\n /** `+` / `=`, `-` and `0` step through the stops while the viewer has focus. Default true; `false`\n * leaves those keys to the renderer (which steps by its own factor). While it's on, no plugin's\n * keymap gets them: the tool takes them while the item can zoom, and marks them taken\n * (`preventDefault()`) while it can't, so the renderer runs nothing for them. */\n keys?: boolean\n /** UI text overrides, for translation; merged over English. */\n strings?: Partial<ZoomStrings>\n}\n\n/** The plugin's instance: `zoomable` is the option's predicate (or the default), which\n * plugin-shortcuts reads to list the zoom keys only while the collection has an item that zooms. */\nexport interface ZoomInstance extends PluginInstance {\n zoomable: (item: Item) => boolean\n}\n\nexport const DEFAULT_STOPS: readonly number[] = [1, 1.5, 2, 3, 4]\n\n// the shared icon family's look (core's ICONS)\nconst icon = (body: string) =>\n `<svg class=\"gallerist-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">${body}</svg>`\nconst MINUS = icon('<path d=\"M5 12h14\"/>')\nconst PLUS = icon('<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>')\n\n/** Within a hair of a stop counts as on it (zooms are floats; a pinch rarely lands exactly). */\nconst EPS = 0.01\n\n/** The stop after `scale` going `dir` (+1 in, −1 out), or `scale` itself at that end. */\nexport function nextStop(stops: readonly number[], scale: number, dir: 1 | -1): number {\n const sorted = [...stops].sort((a, b) => a - b)\n if (dir > 0) return sorted.find((s) => s > scale + EPS) ?? scale\n return [...sorted].reverse().find((s) => s < scale - EPS) ?? scale\n}\n\nconst SCALE = /scale\\(\\s*([\\d.]+)/\n/** The DOM renderer's zoom range: fit to 400%. */\nconst RANGE = [1, 4] as const\n// Where the keys are someone else's: a field, a dialog or menu in the viewer (the View popover), or a\n// widget whose own keys these are. A node inside a shadow root counts by itself (the event's first\n// target). The viewer's root (an overlay is a dialog) and what holds it don't count.\nconst OWNED =\n 'input,textarea,select,[contenteditable]:not([contenteditable=\"false\"]),[role=dialog],[role=menu],[role=spinbutton],[role=slider],[role=tree],[role=grid]'\nconst theirs = (e: KeyboardEvent, root: HTMLElement) => {\n const at = (e.composedPath()[0] as Element).closest?.(OWNED)\n return !!at && !at.contains(root)\n}\n\nexport function zoom(opts: ZoomOptions = {}): Plugin {\n const t: ZoomStrings = { ...DEFAULT_ZOOM_STRINGS, ...opts.strings }\n const stops = (opts.stops?.length ? opts.stops : DEFAULT_STOPS)\n .map((s) => Math.min(RANGE[1], Math.max(RANGE[0], s)))\n .sort((a, b) => a - b)\n const min = stops[0] ?? 1\n const max = stops[stops.length - 1] ?? 1\n const zoomable = opts.zoomable ?? ((item: Item) => item.type === 'image')\n\n return (viewer: ViewerApi): ZoomInstance => {\n let group: HTMLElement | null = null\n let out: HTMLButtonElement | null = null\n let level: HTMLButtonElement | null = null\n let into: HTMLButtonElement | null = null\n let root: HTMLElement | null = null\n let unsub: (() => void) | null = null\n let watch: MutationObserver | null = null\n let live: number | null = null // the transform a gesture shows before it commits\n\n // whether the current item can zoom now: an image (by default) that has loaded\n const canZoom = () => {\n const { cursor, loaded } = viewer.getState()\n const item = viewer.getCollection().groups[cursor.g]?.items[cursor.i]\n return !!item && zoomable(item) && loaded[item.id] === 'loaded'\n }\n const scale = () => live ?? viewer.getState().zoom.scale\n\n const off = (btn: HTMLButtonElement | null, disabled: boolean) => {\n if (!btn) return\n if (disabled) btn.setAttribute('aria-disabled', 'true')\n else btn.removeAttribute('aria-disabled')\n }\n const paint = () => {\n if (!level) return\n const s = Math.max(min, scale())\n const percent = t.percent(s)\n const ok = canZoom()\n if (level.textContent !== percent) level.textContent = percent\n level.setAttribute('aria-label', t.reset(percent))\n off(out, !ok || s <= min + EPS)\n off(into, !ok || s >= max - EPS)\n off(level, s <= 1 + EPS)\n }\n\n // a zoom from the tool or its keys says its percentage (a pinch or a wheel shows it only)\n const say = () => {\n const live = root?.querySelector('.gallerist-live')\n if (live) live.textContent = t.percent(viewer.getState().zoom.scale)\n }\n const step = (dir: 1 | -1) => {\n if (!canZoom()) return\n const s = scale()\n const to = nextStop(stops, s, dir)\n if (to === s) return\n viewer.zoomTo(to)\n say()\n }\n const fit = () => {\n if (viewer.getState().zoom.scale <= 1 + EPS) return\n viewer.resetZoom()\n say()\n }\n\n // the renderer's zoom keys step by its own factor: take them first (capture on the viewer's\n // root), unless they're someone else's (typing, a dialog, a slider: OWNED) or a chord (the\n // browser's zoom). While the item can't zoom they do nothing here, as the tool shows, and go on\n // to the page (marked taken, so the renderer doesn't zoom it either).\n const onKey = (e: KeyboardEvent) => {\n const dir = e.key === '+' || e.key === '=' ? 1 : e.key === '-' ? -1 : e.key === '0' ? 0 : null\n if (dir === null || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return\n if (theirs(e, e.currentTarget as HTMLElement)) return\n e.preventDefault()\n if (!canZoom()) return\n e.stopImmediatePropagation()\n if (dir === 0) fit()\n else step(dir)\n }\n\n // A gesture's live transform on the current slide's content (the renderer commits it at the\n // end): show it while it moves. A drag to dismiss shrinks the content too; that isn't a zoom.\n const onMutate = (records: MutationRecord[]) => {\n let seen = false\n for (const r of records) {\n const el = r.target as HTMLElement\n if (!el.classList?.contains('gallerist-content')) continue\n if (!el.hasAttribute('data-gallerist-current')) continue\n const m = SCALE.exec(el.style.transform)\n const s = m ? Number(m[1]) : Number.NaN\n if (!Number.isFinite(s)) continue\n live = Math.abs(s - viewer.getState().zoom.scale) > EPS && s >= min ? s : null\n seen = true\n }\n if (seen) paint()\n }\n\n const attach = () => {\n const found = group?.closest<HTMLElement>('.gallerist') ?? null\n if (!found || found === root) return\n detach()\n root = found\n if (opts.keys !== false) root.addEventListener('keydown', onKey, true)\n if (typeof MutationObserver !== 'undefined') {\n watch = new MutationObserver(onMutate)\n watch.observe(root, { subtree: true, attributeFilter: ['style'] })\n }\n }\n const detach = () => {\n root?.removeEventListener('keydown', onKey, true)\n watch?.disconnect()\n watch = null\n root = null\n }\n\n const button = (className: string, label: string, html: string, onClick: () => void) => {\n const btn = document.createElement('button')\n btn.type = 'button'\n btn.className = className\n btn.setAttribute('aria-label', label)\n btn.innerHTML = html\n // aria-disabled, not disabled: focus stays on a tool that reaches the end of the range\n btn.addEventListener('click', () => {\n if (btn.getAttribute('aria-disabled') !== 'true') onClick()\n })\n return btn\n }\n\n const build = () => {\n group = document.createElement('div')\n group.className = 'gallerist-zoom-tool'\n group.setAttribute('role', 'group')\n group.setAttribute('aria-label', t.zoom)\n out = button('gallerist-zoom-out', t.zoomOut, MINUS, () => step(-1))\n level = button('gallerist-zoom-level', '', '', fit)\n into = button('gallerist-zoom-in', t.zoomIn, PLUS, () => step(1))\n group.append(out, level, into)\n paint()\n queueMicrotask(attach) // once the renderer has put the tool in the viewer\n return group\n }\n\n return {\n name: 'zoom',\n zoomable, // plugin-shortcuts lists the zoom keys while an item can zoom\n init() {\n unsub = viewer.subscribe(() => {\n live = null // a commit (or a new item) settles the live transform\n if (!root) attach()\n paint()\n })\n },\n destroy() {\n unsub?.()\n unsub = null\n detach()\n group = out = level = into = null\n },\n regions: { toolbar: build },\n }\n }\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { PluginInstance, Item, Plugin } from '@gallerist/core';
|
|
2
|
+
|
|
3
|
+
interface ZoomStrings {
|
|
4
|
+
/** the tool's group name */
|
|
5
|
+
zoom: string;
|
|
6
|
+
zoomIn: string;
|
|
7
|
+
zoomOut: string;
|
|
8
|
+
/** the percentage button's name, with the zoom it shows (it fits the image again) */
|
|
9
|
+
reset: (percent: string) => string;
|
|
10
|
+
/** how a zoom reads (`scale` 1 is the image fitted to the stage) */
|
|
11
|
+
percent: (scale: number) => string;
|
|
12
|
+
}
|
|
13
|
+
declare const DEFAULT_ZOOM_STRINGS: ZoomStrings;
|
|
14
|
+
interface ZoomOptions {
|
|
15
|
+
/** The zoom levels − and + step through, as scales (1 = fit). Default `[1, 1.5, 2, 3, 4]`, the DOM
|
|
16
|
+
* renderer's range (fit to 400%); the first and last are the ends of the range. A stop outside
|
|
17
|
+
* that range is brought into it (the renderer can't zoom past it). */
|
|
18
|
+
stops?: readonly number[];
|
|
19
|
+
/** Whether an item can zoom (when it has loaded). Default: images (`type: 'image'`). */
|
|
20
|
+
zoomable?: (item: Item) => boolean;
|
|
21
|
+
/** `+` / `=`, `-` and `0` step through the stops while the viewer has focus. Default true; `false`
|
|
22
|
+
* leaves those keys to the renderer (which steps by its own factor). While it's on, no plugin's
|
|
23
|
+
* keymap gets them: the tool takes them while the item can zoom, and marks them taken
|
|
24
|
+
* (`preventDefault()`) while it can't, so the renderer runs nothing for them. */
|
|
25
|
+
keys?: boolean;
|
|
26
|
+
/** UI text overrides, for translation; merged over English. */
|
|
27
|
+
strings?: Partial<ZoomStrings>;
|
|
28
|
+
}
|
|
29
|
+
/** The plugin's instance: `zoomable` is the option's predicate (or the default), which
|
|
30
|
+
* plugin-shortcuts reads to list the zoom keys only while the collection has an item that zooms. */
|
|
31
|
+
interface ZoomInstance extends PluginInstance {
|
|
32
|
+
zoomable: (item: Item) => boolean;
|
|
33
|
+
}
|
|
34
|
+
declare const DEFAULT_STOPS: readonly number[];
|
|
35
|
+
/** The stop after `scale` going `dir` (+1 in, −1 out), or `scale` itself at that end. */
|
|
36
|
+
declare function nextStop(stops: readonly number[], scale: number, dir: 1 | -1): number;
|
|
37
|
+
declare function zoom(opts?: ZoomOptions): Plugin;
|
|
38
|
+
|
|
39
|
+
export { DEFAULT_STOPS, DEFAULT_ZOOM_STRINGS, type ZoomInstance, type ZoomOptions, type ZoomStrings, nextStop, zoom };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { PluginInstance, Item, Plugin } from '@gallerist/core';
|
|
2
|
+
|
|
3
|
+
interface ZoomStrings {
|
|
4
|
+
/** the tool's group name */
|
|
5
|
+
zoom: string;
|
|
6
|
+
zoomIn: string;
|
|
7
|
+
zoomOut: string;
|
|
8
|
+
/** the percentage button's name, with the zoom it shows (it fits the image again) */
|
|
9
|
+
reset: (percent: string) => string;
|
|
10
|
+
/** how a zoom reads (`scale` 1 is the image fitted to the stage) */
|
|
11
|
+
percent: (scale: number) => string;
|
|
12
|
+
}
|
|
13
|
+
declare const DEFAULT_ZOOM_STRINGS: ZoomStrings;
|
|
14
|
+
interface ZoomOptions {
|
|
15
|
+
/** The zoom levels − and + step through, as scales (1 = fit). Default `[1, 1.5, 2, 3, 4]`, the DOM
|
|
16
|
+
* renderer's range (fit to 400%); the first and last are the ends of the range. A stop outside
|
|
17
|
+
* that range is brought into it (the renderer can't zoom past it). */
|
|
18
|
+
stops?: readonly number[];
|
|
19
|
+
/** Whether an item can zoom (when it has loaded). Default: images (`type: 'image'`). */
|
|
20
|
+
zoomable?: (item: Item) => boolean;
|
|
21
|
+
/** `+` / `=`, `-` and `0` step through the stops while the viewer has focus. Default true; `false`
|
|
22
|
+
* leaves those keys to the renderer (which steps by its own factor). While it's on, no plugin's
|
|
23
|
+
* keymap gets them: the tool takes them while the item can zoom, and marks them taken
|
|
24
|
+
* (`preventDefault()`) while it can't, so the renderer runs nothing for them. */
|
|
25
|
+
keys?: boolean;
|
|
26
|
+
/** UI text overrides, for translation; merged over English. */
|
|
27
|
+
strings?: Partial<ZoomStrings>;
|
|
28
|
+
}
|
|
29
|
+
/** The plugin's instance: `zoomable` is the option's predicate (or the default), which
|
|
30
|
+
* plugin-shortcuts reads to list the zoom keys only while the collection has an item that zooms. */
|
|
31
|
+
interface ZoomInstance extends PluginInstance {
|
|
32
|
+
zoomable: (item: Item) => boolean;
|
|
33
|
+
}
|
|
34
|
+
declare const DEFAULT_STOPS: readonly number[];
|
|
35
|
+
/** The stop after `scale` going `dir` (+1 in, −1 out), or `scale` itself at that end. */
|
|
36
|
+
declare function nextStop(stops: readonly number[], scale: number, dir: 1 | -1): number;
|
|
37
|
+
declare function zoom(opts?: ZoomOptions): Plugin;
|
|
38
|
+
|
|
39
|
+
export { DEFAULT_STOPS, DEFAULT_ZOOM_STRINGS, type ZoomInstance, type ZoomOptions, type ZoomStrings, nextStop, zoom };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var DEFAULT_ZOOM_STRINGS = {
|
|
3
|
+
zoom: "Zoom",
|
|
4
|
+
zoomIn: "Zoom in",
|
|
5
|
+
zoomOut: "Zoom out",
|
|
6
|
+
reset: (percent) => `${percent}, fit to screen`,
|
|
7
|
+
percent: (scale) => `${Math.round(scale * 100)}%`
|
|
8
|
+
};
|
|
9
|
+
var DEFAULT_STOPS = [1, 1.5, 2, 3, 4];
|
|
10
|
+
var icon = (body) => `<svg class="gallerist-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${body}</svg>`;
|
|
11
|
+
var MINUS = icon('<path d="M5 12h14"/>');
|
|
12
|
+
var PLUS = icon('<path d="M5 12h14"/><path d="M12 5v14"/>');
|
|
13
|
+
var EPS = 0.01;
|
|
14
|
+
function nextStop(stops, scale, dir) {
|
|
15
|
+
const sorted = [...stops].sort((a, b) => a - b);
|
|
16
|
+
if (dir > 0) return sorted.find((s) => s > scale + EPS) ?? scale;
|
|
17
|
+
return [...sorted].reverse().find((s) => s < scale - EPS) ?? scale;
|
|
18
|
+
}
|
|
19
|
+
var SCALE = /scale\(\s*([\d.]+)/;
|
|
20
|
+
var RANGE = [1, 4];
|
|
21
|
+
var OWNED = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role=dialog],[role=menu],[role=spinbutton],[role=slider],[role=tree],[role=grid]';
|
|
22
|
+
var theirs = (e, root) => {
|
|
23
|
+
const at = e.composedPath()[0].closest?.(OWNED);
|
|
24
|
+
return !!at && !at.contains(root);
|
|
25
|
+
};
|
|
26
|
+
function zoom(opts = {}) {
|
|
27
|
+
const t = { ...DEFAULT_ZOOM_STRINGS, ...opts.strings };
|
|
28
|
+
const stops = (opts.stops?.length ? opts.stops : DEFAULT_STOPS).map((s) => Math.min(RANGE[1], Math.max(RANGE[0], s))).sort((a, b) => a - b);
|
|
29
|
+
const min = stops[0] ?? 1;
|
|
30
|
+
const max = stops[stops.length - 1] ?? 1;
|
|
31
|
+
const zoomable = opts.zoomable ?? ((item) => item.type === "image");
|
|
32
|
+
return (viewer) => {
|
|
33
|
+
let group = null;
|
|
34
|
+
let out = null;
|
|
35
|
+
let level = null;
|
|
36
|
+
let into = null;
|
|
37
|
+
let root = null;
|
|
38
|
+
let unsub = null;
|
|
39
|
+
let watch = null;
|
|
40
|
+
let live = null;
|
|
41
|
+
const canZoom = () => {
|
|
42
|
+
const { cursor, loaded } = viewer.getState();
|
|
43
|
+
const item = viewer.getCollection().groups[cursor.g]?.items[cursor.i];
|
|
44
|
+
return !!item && zoomable(item) && loaded[item.id] === "loaded";
|
|
45
|
+
};
|
|
46
|
+
const scale = () => live ?? viewer.getState().zoom.scale;
|
|
47
|
+
const off = (btn, disabled) => {
|
|
48
|
+
if (!btn) return;
|
|
49
|
+
if (disabled) btn.setAttribute("aria-disabled", "true");
|
|
50
|
+
else btn.removeAttribute("aria-disabled");
|
|
51
|
+
};
|
|
52
|
+
const paint = () => {
|
|
53
|
+
if (!level) return;
|
|
54
|
+
const s = Math.max(min, scale());
|
|
55
|
+
const percent = t.percent(s);
|
|
56
|
+
const ok = canZoom();
|
|
57
|
+
if (level.textContent !== percent) level.textContent = percent;
|
|
58
|
+
level.setAttribute("aria-label", t.reset(percent));
|
|
59
|
+
off(out, !ok || s <= min + EPS);
|
|
60
|
+
off(into, !ok || s >= max - EPS);
|
|
61
|
+
off(level, s <= 1 + EPS);
|
|
62
|
+
};
|
|
63
|
+
const say = () => {
|
|
64
|
+
const live2 = root?.querySelector(".gallerist-live");
|
|
65
|
+
if (live2) live2.textContent = t.percent(viewer.getState().zoom.scale);
|
|
66
|
+
};
|
|
67
|
+
const step = (dir) => {
|
|
68
|
+
if (!canZoom()) return;
|
|
69
|
+
const s = scale();
|
|
70
|
+
const to = nextStop(stops, s, dir);
|
|
71
|
+
if (to === s) return;
|
|
72
|
+
viewer.zoomTo(to);
|
|
73
|
+
say();
|
|
74
|
+
};
|
|
75
|
+
const fit = () => {
|
|
76
|
+
if (viewer.getState().zoom.scale <= 1 + EPS) return;
|
|
77
|
+
viewer.resetZoom();
|
|
78
|
+
say();
|
|
79
|
+
};
|
|
80
|
+
const onKey = (e) => {
|
|
81
|
+
const dir = e.key === "+" || e.key === "=" ? 1 : e.key === "-" ? -1 : e.key === "0" ? 0 : null;
|
|
82
|
+
if (dir === null || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
83
|
+
if (theirs(e, e.currentTarget)) return;
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
if (!canZoom()) return;
|
|
86
|
+
e.stopImmediatePropagation();
|
|
87
|
+
if (dir === 0) fit();
|
|
88
|
+
else step(dir);
|
|
89
|
+
};
|
|
90
|
+
const onMutate = (records) => {
|
|
91
|
+
let seen = false;
|
|
92
|
+
for (const r of records) {
|
|
93
|
+
const el = r.target;
|
|
94
|
+
if (!el.classList?.contains("gallerist-content")) continue;
|
|
95
|
+
if (!el.hasAttribute("data-gallerist-current")) continue;
|
|
96
|
+
const m = SCALE.exec(el.style.transform);
|
|
97
|
+
const s = m ? Number(m[1]) : Number.NaN;
|
|
98
|
+
if (!Number.isFinite(s)) continue;
|
|
99
|
+
live = Math.abs(s - viewer.getState().zoom.scale) > EPS && s >= min ? s : null;
|
|
100
|
+
seen = true;
|
|
101
|
+
}
|
|
102
|
+
if (seen) paint();
|
|
103
|
+
};
|
|
104
|
+
const attach = () => {
|
|
105
|
+
const found = group?.closest(".gallerist") ?? null;
|
|
106
|
+
if (!found || found === root) return;
|
|
107
|
+
detach();
|
|
108
|
+
root = found;
|
|
109
|
+
if (opts.keys !== false) root.addEventListener("keydown", onKey, true);
|
|
110
|
+
if (typeof MutationObserver !== "undefined") {
|
|
111
|
+
watch = new MutationObserver(onMutate);
|
|
112
|
+
watch.observe(root, { subtree: true, attributeFilter: ["style"] });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const detach = () => {
|
|
116
|
+
root?.removeEventListener("keydown", onKey, true);
|
|
117
|
+
watch?.disconnect();
|
|
118
|
+
watch = null;
|
|
119
|
+
root = null;
|
|
120
|
+
};
|
|
121
|
+
const button = (className, label, html, onClick) => {
|
|
122
|
+
const btn = document.createElement("button");
|
|
123
|
+
btn.type = "button";
|
|
124
|
+
btn.className = className;
|
|
125
|
+
btn.setAttribute("aria-label", label);
|
|
126
|
+
btn.innerHTML = html;
|
|
127
|
+
btn.addEventListener("click", () => {
|
|
128
|
+
if (btn.getAttribute("aria-disabled") !== "true") onClick();
|
|
129
|
+
});
|
|
130
|
+
return btn;
|
|
131
|
+
};
|
|
132
|
+
const build = () => {
|
|
133
|
+
group = document.createElement("div");
|
|
134
|
+
group.className = "gallerist-zoom-tool";
|
|
135
|
+
group.setAttribute("role", "group");
|
|
136
|
+
group.setAttribute("aria-label", t.zoom);
|
|
137
|
+
out = button("gallerist-zoom-out", t.zoomOut, MINUS, () => step(-1));
|
|
138
|
+
level = button("gallerist-zoom-level", "", "", fit);
|
|
139
|
+
into = button("gallerist-zoom-in", t.zoomIn, PLUS, () => step(1));
|
|
140
|
+
group.append(out, level, into);
|
|
141
|
+
paint();
|
|
142
|
+
queueMicrotask(attach);
|
|
143
|
+
return group;
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
name: "zoom",
|
|
147
|
+
zoomable,
|
|
148
|
+
// plugin-shortcuts lists the zoom keys while an item can zoom
|
|
149
|
+
init() {
|
|
150
|
+
unsub = viewer.subscribe(() => {
|
|
151
|
+
live = null;
|
|
152
|
+
if (!root) attach();
|
|
153
|
+
paint();
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
destroy() {
|
|
157
|
+
unsub?.();
|
|
158
|
+
unsub = null;
|
|
159
|
+
detach();
|
|
160
|
+
group = out = level = into = null;
|
|
161
|
+
},
|
|
162
|
+
regions: { toolbar: build }
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { DEFAULT_STOPS, DEFAULT_ZOOM_STRINGS, nextStop, zoom };
|
|
168
|
+
//# sourceMappingURL=index.js.map
|
|
169
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["live"],"mappings":"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","file":"index.js","sourcesContent":["// @gallerist/plugin-zoom — a zoom tool in the viewer's toolbar: \"− 100% +\".\n//\n// Zoom out, the current zoom (pressing it fits the image again), zoom in. The steps are stops of\n// the zoom range (fit, 150%, 200%, 300%, 400% by default; 100% is the image fitted to the stage), and\n// every step pivots on the stage's centre (`viewer.zoomTo`, which keeps the view inside the image).\n// While the viewer has focus, `+` / `=`, `-` and `0` take the same steps: the renderer's own zoom\n// keys step by a fixed factor, so the tool takes those keys first and the two never disagree.\n//\n// The percentage follows every zoom: the committed one from the viewer's state, and the live\n// transform while a pinch, a wheel or a double click is still moving (the renderer applies those to\n// the current slide's content before it commits them, so the plugin reads that transform).\n// `− +` are unavailable (aria-disabled, still focusable, so focus never drops) at either end of the\n// range and while the current item can't zoom: a video or another non-image, one still loading, one\n// that failed. The percentage is unavailable at fit, where there's nothing to reset.\n\nimport type { Item, Plugin, PluginInstance, ViewerApi } from '@gallerist/core'\n\nexport interface ZoomStrings {\n /** the tool's group name */\n zoom: string\n zoomIn: string\n zoomOut: string\n /** the percentage button's name, with the zoom it shows (it fits the image again) */\n reset: (percent: string) => string\n /** how a zoom reads (`scale` 1 is the image fitted to the stage) */\n percent: (scale: number) => string\n}\n\nexport const DEFAULT_ZOOM_STRINGS: ZoomStrings = {\n zoom: 'Zoom',\n zoomIn: 'Zoom in',\n zoomOut: 'Zoom out',\n reset: (percent) => `${percent}, fit to screen`,\n percent: (scale) => `${Math.round(scale * 100)}%`,\n}\n\nexport interface ZoomOptions {\n /** The zoom levels − and + step through, as scales (1 = fit). Default `[1, 1.5, 2, 3, 4]`, the DOM\n * renderer's range (fit to 400%); the first and last are the ends of the range. A stop outside\n * that range is brought into it (the renderer can't zoom past it). */\n stops?: readonly number[]\n /** Whether an item can zoom (when it has loaded). Default: images (`type: 'image'`). */\n zoomable?: (item: Item) => boolean\n /** `+` / `=`, `-` and `0` step through the stops while the viewer has focus. Default true; `false`\n * leaves those keys to the renderer (which steps by its own factor). While it's on, no plugin's\n * keymap gets them: the tool takes them while the item can zoom, and marks them taken\n * (`preventDefault()`) while it can't, so the renderer runs nothing for them. */\n keys?: boolean\n /** UI text overrides, for translation; merged over English. */\n strings?: Partial<ZoomStrings>\n}\n\n/** The plugin's instance: `zoomable` is the option's predicate (or the default), which\n * plugin-shortcuts reads to list the zoom keys only while the collection has an item that zooms. */\nexport interface ZoomInstance extends PluginInstance {\n zoomable: (item: Item) => boolean\n}\n\nexport const DEFAULT_STOPS: readonly number[] = [1, 1.5, 2, 3, 4]\n\n// the shared icon family's look (core's ICONS)\nconst icon = (body: string) =>\n `<svg class=\"gallerist-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">${body}</svg>`\nconst MINUS = icon('<path d=\"M5 12h14\"/>')\nconst PLUS = icon('<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>')\n\n/** Within a hair of a stop counts as on it (zooms are floats; a pinch rarely lands exactly). */\nconst EPS = 0.01\n\n/** The stop after `scale` going `dir` (+1 in, −1 out), or `scale` itself at that end. */\nexport function nextStop(stops: readonly number[], scale: number, dir: 1 | -1): number {\n const sorted = [...stops].sort((a, b) => a - b)\n if (dir > 0) return sorted.find((s) => s > scale + EPS) ?? scale\n return [...sorted].reverse().find((s) => s < scale - EPS) ?? scale\n}\n\nconst SCALE = /scale\\(\\s*([\\d.]+)/\n/** The DOM renderer's zoom range: fit to 400%. */\nconst RANGE = [1, 4] as const\n// Where the keys are someone else's: a field, a dialog or menu in the viewer (the View popover), or a\n// widget whose own keys these are. A node inside a shadow root counts by itself (the event's first\n// target). The viewer's root (an overlay is a dialog) and what holds it don't count.\nconst OWNED =\n 'input,textarea,select,[contenteditable]:not([contenteditable=\"false\"]),[role=dialog],[role=menu],[role=spinbutton],[role=slider],[role=tree],[role=grid]'\nconst theirs = (e: KeyboardEvent, root: HTMLElement) => {\n const at = (e.composedPath()[0] as Element).closest?.(OWNED)\n return !!at && !at.contains(root)\n}\n\nexport function zoom(opts: ZoomOptions = {}): Plugin {\n const t: ZoomStrings = { ...DEFAULT_ZOOM_STRINGS, ...opts.strings }\n const stops = (opts.stops?.length ? opts.stops : DEFAULT_STOPS)\n .map((s) => Math.min(RANGE[1], Math.max(RANGE[0], s)))\n .sort((a, b) => a - b)\n const min = stops[0] ?? 1\n const max = stops[stops.length - 1] ?? 1\n const zoomable = opts.zoomable ?? ((item: Item) => item.type === 'image')\n\n return (viewer: ViewerApi): ZoomInstance => {\n let group: HTMLElement | null = null\n let out: HTMLButtonElement | null = null\n let level: HTMLButtonElement | null = null\n let into: HTMLButtonElement | null = null\n let root: HTMLElement | null = null\n let unsub: (() => void) | null = null\n let watch: MutationObserver | null = null\n let live: number | null = null // the transform a gesture shows before it commits\n\n // whether the current item can zoom now: an image (by default) that has loaded\n const canZoom = () => {\n const { cursor, loaded } = viewer.getState()\n const item = viewer.getCollection().groups[cursor.g]?.items[cursor.i]\n return !!item && zoomable(item) && loaded[item.id] === 'loaded'\n }\n const scale = () => live ?? viewer.getState().zoom.scale\n\n const off = (btn: HTMLButtonElement | null, disabled: boolean) => {\n if (!btn) return\n if (disabled) btn.setAttribute('aria-disabled', 'true')\n else btn.removeAttribute('aria-disabled')\n }\n const paint = () => {\n if (!level) return\n const s = Math.max(min, scale())\n const percent = t.percent(s)\n const ok = canZoom()\n if (level.textContent !== percent) level.textContent = percent\n level.setAttribute('aria-label', t.reset(percent))\n off(out, !ok || s <= min + EPS)\n off(into, !ok || s >= max - EPS)\n off(level, s <= 1 + EPS)\n }\n\n // a zoom from the tool or its keys says its percentage (a pinch or a wheel shows it only)\n const say = () => {\n const live = root?.querySelector('.gallerist-live')\n if (live) live.textContent = t.percent(viewer.getState().zoom.scale)\n }\n const step = (dir: 1 | -1) => {\n if (!canZoom()) return\n const s = scale()\n const to = nextStop(stops, s, dir)\n if (to === s) return\n viewer.zoomTo(to)\n say()\n }\n const fit = () => {\n if (viewer.getState().zoom.scale <= 1 + EPS) return\n viewer.resetZoom()\n say()\n }\n\n // the renderer's zoom keys step by its own factor: take them first (capture on the viewer's\n // root), unless they're someone else's (typing, a dialog, a slider: OWNED) or a chord (the\n // browser's zoom). While the item can't zoom they do nothing here, as the tool shows, and go on\n // to the page (marked taken, so the renderer doesn't zoom it either).\n const onKey = (e: KeyboardEvent) => {\n const dir = e.key === '+' || e.key === '=' ? 1 : e.key === '-' ? -1 : e.key === '0' ? 0 : null\n if (dir === null || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return\n if (theirs(e, e.currentTarget as HTMLElement)) return\n e.preventDefault()\n if (!canZoom()) return\n e.stopImmediatePropagation()\n if (dir === 0) fit()\n else step(dir)\n }\n\n // A gesture's live transform on the current slide's content (the renderer commits it at the\n // end): show it while it moves. A drag to dismiss shrinks the content too; that isn't a zoom.\n const onMutate = (records: MutationRecord[]) => {\n let seen = false\n for (const r of records) {\n const el = r.target as HTMLElement\n if (!el.classList?.contains('gallerist-content')) continue\n if (!el.hasAttribute('data-gallerist-current')) continue\n const m = SCALE.exec(el.style.transform)\n const s = m ? Number(m[1]) : Number.NaN\n if (!Number.isFinite(s)) continue\n live = Math.abs(s - viewer.getState().zoom.scale) > EPS && s >= min ? s : null\n seen = true\n }\n if (seen) paint()\n }\n\n const attach = () => {\n const found = group?.closest<HTMLElement>('.gallerist') ?? null\n if (!found || found === root) return\n detach()\n root = found\n if (opts.keys !== false) root.addEventListener('keydown', onKey, true)\n if (typeof MutationObserver !== 'undefined') {\n watch = new MutationObserver(onMutate)\n watch.observe(root, { subtree: true, attributeFilter: ['style'] })\n }\n }\n const detach = () => {\n root?.removeEventListener('keydown', onKey, true)\n watch?.disconnect()\n watch = null\n root = null\n }\n\n const button = (className: string, label: string, html: string, onClick: () => void) => {\n const btn = document.createElement('button')\n btn.type = 'button'\n btn.className = className\n btn.setAttribute('aria-label', label)\n btn.innerHTML = html\n // aria-disabled, not disabled: focus stays on a tool that reaches the end of the range\n btn.addEventListener('click', () => {\n if (btn.getAttribute('aria-disabled') !== 'true') onClick()\n })\n return btn\n }\n\n const build = () => {\n group = document.createElement('div')\n group.className = 'gallerist-zoom-tool'\n group.setAttribute('role', 'group')\n group.setAttribute('aria-label', t.zoom)\n out = button('gallerist-zoom-out', t.zoomOut, MINUS, () => step(-1))\n level = button('gallerist-zoom-level', '', '', fit)\n into = button('gallerist-zoom-in', t.zoomIn, PLUS, () => step(1))\n group.append(out, level, into)\n paint()\n queueMicrotask(attach) // once the renderer has put the tool in the viewer\n return group\n }\n\n return {\n name: 'zoom',\n zoomable, // plugin-shortcuts lists the zoom keys while an item can zoom\n init() {\n unsub = viewer.subscribe(() => {\n live = null // a commit (or a new item) settles the live transform\n if (!root) attach()\n paint()\n })\n },\n destroy() {\n unsub?.()\n unsub = null\n detach()\n group = out = level = into = null\n },\n regions: { toolbar: build },\n }\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gallerist/plugin-zoom",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Gallerist plugin — a zoom tool in the toolbar: zoom out, the current zoom (press to fit), zoom in, with the + / - / 0 keys and a live percentage during pinch and wheel zoom.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"main": "./dist/index.cjs",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"require": {
|
|
23
|
+
"types": "./dist/index.d.cts",
|
|
24
|
+
"default": "./dist/index.cjs"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@gallerist/core": ">=0.3.0 <1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"tsup": "^8.3.5",
|
|
33
|
+
"typescript": "^5.7.2",
|
|
34
|
+
"@gallerist/core": "0.4.1"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/shafayet9780/gallerist.git",
|
|
39
|
+
"directory": "packages/plugin-zoom"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/shafayet9780/gallerist#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/shafayet9780/gallerist/issues"
|
|
44
|
+
},
|
|
45
|
+
"author": "Shafayet Ahmmed",
|
|
46
|
+
"keywords": [
|
|
47
|
+
"lightbox",
|
|
48
|
+
"gallery",
|
|
49
|
+
"image-viewer",
|
|
50
|
+
"media-viewer",
|
|
51
|
+
"photo-gallery",
|
|
52
|
+
"carousel",
|
|
53
|
+
"zoom",
|
|
54
|
+
"pan",
|
|
55
|
+
"overlays",
|
|
56
|
+
"headless",
|
|
57
|
+
"framework-agnostic",
|
|
58
|
+
"virtualized",
|
|
59
|
+
"nested-gallery"
|
|
60
|
+
],
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "tsup",
|
|
66
|
+
"typecheck": "tsc --noEmit"
|
|
67
|
+
}
|
|
6
68
|
}
|