@kenjura/ursa 0.89.1 → 0.90.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/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ # 0.90.1
2
+ 2026-08-29
3
+
4
+ Upgraded `sharp` to the current 0.34 line and updated install-script allowlisting for pnpm.
5
+
6
+ - `sharp` is now `^0.34.5`.
7
+ - Added `sharp` to `pnpm.onlyBuiltDependencies` so its native install step runs consistently in pnpm-managed environments.
8
+
9
+ # 0.90.0
10
+ 2026-08-26
11
+
12
+ Article images get a zoom and a download button on hover, and a full-screen viewer behind the zoom.
13
+
14
+ Images in a built site are downscaled WebP previews — 800px at quality 80 — wrapped in a link to the original. That is right for page weight and wrong for looking at anything: the only way to see the pixels that were actually shot was to click through to a raw image file in a new tab, losing the page, and the only way to keep a copy was the browser's context menu, which on that page would have saved the preview rather than the original.
15
+
16
+ - **Hover controls** on every article image large enough to be a picture rather than an icon (80px in both axes). Zoom opens the viewer; download saves the **original** — not the preview — under its own filename. On touch devices, where there is no hover, they are simply always visible.
17
+ - **The viewer** opens the original at native resolution when it fits the viewport, and contained within it when it does not, over the standard translucent black backdrop. It loads the preview first as a placeholder, so a multi-megabyte original arrives into a full-size blurred image rather than an empty box.
18
+ - **Zoom in/out appear only when there is resolution left to see** — an image already showing every pixel it has gets no zoom controls. Steps of 1.5× between contain-fit and 100%, keeping the centre of the view fixed; drag to pan, double-click to toggle fit and 100%.
19
+ - **Closing**: the backdrop, the letterboxing around the image, the X, or Escape. Tab stays inside the dialog while it is open, and focus returns to where it was on close.
20
+ - `meta/templates/default-template/lightbox.js` and `lightbox.css`, picked up by the existing asset bundler; `data-no-lightbox` on an image or any ancestor opts out.
21
+
22
+ Clicking the image itself is unchanged — it still opens the original in a new tab.
23
+
1
24
  # 0.89.0
2
25
  2026-08-20
3
26
 
@@ -6,6 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
7
  <title>${title}</title>
8
8
  <link rel="stylesheet" href="/public/default.css" />
9
+ <link rel="stylesheet" href="/public/lightbox.css" />
9
10
  ${styleLink}
10
11
  </head>
11
12
 
@@ -122,6 +123,7 @@
122
123
  <script src="/public/widgets.js"></script>
123
124
  <script src="/public/sectionify.js"></script>
124
125
  <script src="/public/sticky.js"></script>
126
+ <script src="/public/lightbox.js"></script>
125
127
  ${customScript}
126
128
  </body>
127
129
 
@@ -0,0 +1,248 @@
1
+ /* Image hover controls and the full-screen viewer (see lightbox.js). */
2
+
3
+ /* --- hover controls --- */
4
+
5
+ .ursa-image-frame {
6
+ position: relative;
7
+ display: inline-block;
8
+ max-width: 100%;
9
+ line-height: 0;
10
+ }
11
+
12
+ .ursa-image-controls {
13
+ position: absolute;
14
+ top: 8px;
15
+ right: 8px;
16
+ display: flex;
17
+ gap: 6px;
18
+ opacity: 0;
19
+ transition: opacity 0.15s ease;
20
+ line-height: 0;
21
+ }
22
+
23
+ .ursa-image-frame:hover .ursa-image-controls,
24
+ .ursa-image-frame:focus-within .ursa-image-controls {
25
+ opacity: 1;
26
+ }
27
+
28
+ /* No hover to reveal them on touch devices, so keep them visible but quiet. */
29
+ @media (hover: none) {
30
+ .ursa-image-controls {
31
+ opacity: 0.65;
32
+ }
33
+ }
34
+
35
+ .ursa-image-btn {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ width: 30px;
40
+ height: 30px;
41
+ padding: 0;
42
+ border: none;
43
+ border-radius: 50%;
44
+ background: rgba(0, 0, 0, 0.55);
45
+ color: white;
46
+ cursor: pointer;
47
+ backdrop-filter: blur(2px);
48
+ transition: background-color 0.15s ease;
49
+ }
50
+
51
+ .ursa-image-btn:hover,
52
+ .ursa-image-btn:focus-visible {
53
+ background: rgba(0, 0, 0, 0.8);
54
+ }
55
+
56
+ .ursa-image-btn svg {
57
+ width: 17px;
58
+ height: 17px;
59
+ fill: none;
60
+ stroke: currentColor;
61
+ stroke-width: 2;
62
+ stroke-linecap: round;
63
+ stroke-linejoin: round;
64
+ }
65
+
66
+ /* --- full-screen viewer --- */
67
+
68
+ body.ursa-lightbox-open {
69
+ overflow: hidden;
70
+ }
71
+
72
+ .ursa-lightbox {
73
+ position: fixed;
74
+ inset: 0;
75
+ z-index: 2000;
76
+ display: flex;
77
+ }
78
+
79
+ .ursa-lightbox[hidden] {
80
+ display: none;
81
+ }
82
+
83
+ .ursa-lightbox-backdrop {
84
+ position: absolute;
85
+ inset: 0;
86
+ background: rgba(0, 0, 0, 0.85);
87
+ }
88
+
89
+ .ursa-lightbox-stage {
90
+ position: relative;
91
+ flex: 1;
92
+ display: flex;
93
+ overflow: auto;
94
+ padding: 32px;
95
+ box-sizing: border-box;
96
+ outline: none;
97
+ overscroll-behavior: contain;
98
+ }
99
+
100
+ /* margin:auto centres the image while it fits, and collapses to 0 once it is
101
+ larger than the stage, so the overflow stays scrollable in both directions. */
102
+ .ursa-lightbox-image {
103
+ margin: auto;
104
+ display: block;
105
+ user-select: none;
106
+ -webkit-user-drag: none;
107
+ }
108
+
109
+ .ursa-lightbox-image.is-placeholder {
110
+ filter: blur(1px);
111
+ }
112
+
113
+ .ursa-lightbox-stage.is-pannable {
114
+ cursor: grab;
115
+ }
116
+
117
+ .ursa-lightbox-stage.is-panning {
118
+ cursor: grabbing;
119
+ }
120
+
121
+ /* Centred over the blurred placeholder, clear of the toolbar. */
122
+ .ursa-lightbox-loading {
123
+ position: absolute;
124
+ left: 50%;
125
+ top: 50%;
126
+ transform: translate(-50%, -50%);
127
+ display: flex;
128
+ padding: 10px;
129
+ border-radius: 50%;
130
+ background: rgba(0, 0, 0, 0.55);
131
+ pointer-events: none;
132
+ }
133
+
134
+ .ursa-lightbox-loading[hidden] {
135
+ display: none;
136
+ }
137
+
138
+ .ursa-lightbox-loading .ursa-spinner {
139
+ width: 18px;
140
+ height: 18px;
141
+ border-color: rgba(255, 255, 255, 0.3);
142
+ border-top-color: rgba(255, 255, 255, 0.9);
143
+ }
144
+
145
+ .ursa-lightbox-toolbar {
146
+ position: absolute;
147
+ left: 50%;
148
+ bottom: 20px;
149
+ transform: translateX(-50%);
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 4px;
153
+ padding: 4px;
154
+ border-radius: 999px;
155
+ background: rgba(0, 0, 0, 0.6);
156
+ backdrop-filter: blur(2px);
157
+ }
158
+
159
+ .ursa-lightbox-zoom {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 4px;
163
+ }
164
+
165
+ .ursa-lightbox-zoom[hidden] {
166
+ display: none;
167
+ }
168
+
169
+ .ursa-lightbox-level {
170
+ min-width: 3.5em;
171
+ text-align: center;
172
+ color: white;
173
+ font-family: sans-serif;
174
+ font-size: 0.8rem;
175
+ font-variant-numeric: tabular-nums;
176
+ }
177
+
178
+ .ursa-lightbox-btn {
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ width: 34px;
183
+ height: 34px;
184
+ padding: 0;
185
+ border: none;
186
+ border-radius: 50%;
187
+ background: transparent;
188
+ color: white;
189
+ font-size: 1.2rem;
190
+ font-family: sans-serif;
191
+ line-height: 1;
192
+ cursor: pointer;
193
+ transition: background-color 0.15s ease, opacity 0.15s ease;
194
+ }
195
+
196
+ .ursa-lightbox-btn:hover,
197
+ .ursa-lightbox-btn:focus-visible {
198
+ background: rgba(255, 255, 255, 0.18);
199
+ }
200
+
201
+ .ursa-lightbox-btn:disabled {
202
+ opacity: 0.35;
203
+ cursor: default;
204
+ background: transparent;
205
+ }
206
+
207
+ .ursa-lightbox-btn svg {
208
+ width: 18px;
209
+ height: 18px;
210
+ fill: none;
211
+ stroke: currentColor;
212
+ stroke-width: 2;
213
+ stroke-linecap: round;
214
+ stroke-linejoin: round;
215
+ }
216
+
217
+ .ursa-lightbox-close {
218
+ position: absolute;
219
+ top: 16px;
220
+ right: 16px;
221
+ display: flex;
222
+ align-items: center;
223
+ justify-content: center;
224
+ width: 44px;
225
+ height: 44px;
226
+ padding: 0;
227
+ border: none;
228
+ border-radius: 50%;
229
+ background: rgba(0, 0, 0, 0.6);
230
+ color: white;
231
+ cursor: pointer;
232
+ backdrop-filter: blur(2px);
233
+ transition: background-color 0.15s ease;
234
+ }
235
+
236
+ .ursa-lightbox-close:hover,
237
+ .ursa-lightbox-close:focus-visible {
238
+ background: rgba(255, 255, 255, 0.22);
239
+ }
240
+
241
+ .ursa-lightbox-close svg {
242
+ width: 22px;
243
+ height: 22px;
244
+ fill: none;
245
+ stroke: currentColor;
246
+ stroke-width: 2;
247
+ stroke-linecap: round;
248
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Image lightbox.
3
+ *
4
+ * Adds a zoom and a download button to every article image on hover, and a
5
+ * full-screen viewer behind the zoom button.
6
+ *
7
+ * Article images are served as downscaled WebP previews (see
8
+ * helper/imageProcessor.js), wrapped in <a class="image-link" href="original">.
9
+ * The viewer therefore loads the anchor's href, not the img's own src — the
10
+ * preview is only used as an instant placeholder while the original arrives.
11
+ */
12
+ (() => {
13
+ // Rendered smaller than this in either axis and it is an icon, not a picture.
14
+ const MIN_RENDERED_SIZE = 80;
15
+ const ZOOM_STEP = 1.5;
16
+ const IMAGE_HREF = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(?:[?#]|$)/i;
17
+
18
+ const ICON_ZOOM =
19
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
20
+ '<circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5 L21 21" />' +
21
+ '<path d="M7.5 10.5h6M10.5 7.5v6" /></svg>';
22
+ const ICON_DOWNLOAD =
23
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
24
+ '<path d="M12 3.5v11" /><path d="M7.5 10.5 12 15l4.5-4.5" />' +
25
+ '<path d="M4.5 18.5h15" /></svg>';
26
+ const ICON_CLOSE =
27
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
28
+ '<path d="M5.5 5.5l13 13M18.5 5.5l-13 13" /></svg>';
29
+
30
+ let viewer = null; // built on first use
31
+
32
+ /** Filename to offer the download as, taken from the URL. */
33
+ function fileNameFor(url) {
34
+ try {
35
+ const name = new URL(url, window.location.href).pathname.split('/').pop();
36
+ return decodeURIComponent(name) || 'image';
37
+ } catch {
38
+ return 'image';
39
+ }
40
+ }
41
+
42
+ /** Same file, ignoring cache-busting query strings? */
43
+ function samePath(a, b) {
44
+ try {
45
+ const ua = new URL(a, window.location.href);
46
+ const ub = new URL(b, window.location.href);
47
+ return ua.origin === ub.origin && ua.pathname === ub.pathname;
48
+ } catch {
49
+ return a === b;
50
+ }
51
+ }
52
+
53
+ /** The full-resolution URL for an article image. */
54
+ function fullSizeUrl(img) {
55
+ const link = img.closest('a');
56
+ const href = link && link.getAttribute('href');
57
+ if (link && href && (link.classList.contains('image-link') || IMAGE_HREF.test(href))) {
58
+ return link.href;
59
+ }
60
+ return img.currentSrc || img.src;
61
+ }
62
+
63
+ function runWhenSized(img, fn) {
64
+ if (img.complete && img.naturalWidth) fn();
65
+ else img.addEventListener('load', fn, { once: true });
66
+ }
67
+
68
+ // --- hover controls -------------------------------------------------------
69
+
70
+ function buildControls(img, url) {
71
+ const bar = document.createElement('span');
72
+ bar.className = 'ursa-image-controls';
73
+
74
+ const zoom = document.createElement('button');
75
+ zoom.type = 'button';
76
+ zoom.className = 'ursa-image-btn';
77
+ zoom.title = 'View full size';
78
+ zoom.setAttribute('aria-label', 'View full size');
79
+ zoom.innerHTML = ICON_ZOOM;
80
+ zoom.addEventListener('click', (e) => {
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ open(img, url);
84
+ });
85
+
86
+ const download = document.createElement('a');
87
+ download.className = 'ursa-image-btn';
88
+ download.href = url;
89
+ download.download = fileNameFor(url);
90
+ download.title = 'Download image';
91
+ download.setAttribute('aria-label', 'Download image');
92
+ download.innerHTML = ICON_DOWNLOAD;
93
+ // Stop the click reaching an enclosing <a class="image-link">.
94
+ download.addEventListener('click', (e) => e.stopPropagation());
95
+
96
+ bar.appendChild(zoom);
97
+ bar.appendChild(download);
98
+ return bar;
99
+ }
100
+
101
+ function decorate(img) {
102
+ if (img.dataset.ursaLightbox) return;
103
+ if (img.closest('[data-no-lightbox]')) return;
104
+
105
+ const url = fullSizeUrl(img);
106
+ if (!url) return;
107
+
108
+ runWhenSized(img, () => {
109
+ const rect = img.getBoundingClientRect();
110
+ // Fall back to the intrinsic size for images that are not laid out yet.
111
+ const w = rect.width || img.naturalWidth;
112
+ const h = rect.height || img.naturalHeight;
113
+ if (Math.min(w, h) < MIN_RENDERED_SIZE) return;
114
+ if (img.dataset.ursaLightbox) return;
115
+ img.dataset.ursaLightbox = 'on';
116
+
117
+ // Wrap the anchor rather than the image when there is one, so the
118
+ // controls sit outside it and their clicks are not swallowed by the link.
119
+ const link = img.closest('a.image-link');
120
+ const wrapped = link && link.parentNode ? link : img;
121
+ const frame = document.createElement('span');
122
+ frame.className = 'ursa-image-frame';
123
+ wrapped.parentNode.insertBefore(frame, wrapped);
124
+ frame.appendChild(wrapped);
125
+ frame.appendChild(buildControls(img, url));
126
+ });
127
+ }
128
+
129
+ // --- viewer ---------------------------------------------------------------
130
+
131
+ function ensureViewer() {
132
+ if (viewer) return viewer;
133
+
134
+ const el = document.createElement('div');
135
+ el.className = 'ursa-lightbox';
136
+ el.setAttribute('role', 'dialog');
137
+ el.setAttribute('aria-modal', 'true');
138
+ el.setAttribute('aria-label', 'Image viewer');
139
+ el.hidden = true;
140
+ el.innerHTML =
141
+ '<div class="ursa-lightbox-backdrop"></div>' +
142
+ '<div class="ursa-lightbox-stage" tabindex="-1">' +
143
+ '<img class="ursa-lightbox-image" alt="">' +
144
+ '</div>' +
145
+ '<div class="ursa-lightbox-loading" hidden><span class="ursa-spinner"></span></div>' +
146
+ '<div class="ursa-lightbox-toolbar">' +
147
+ '<span class="ursa-lightbox-zoom" hidden>' +
148
+ '<button type="button" class="ursa-lightbox-btn" data-zoom="out" title="Zoom out" aria-label="Zoom out">&minus;</button>' +
149
+ '<span class="ursa-lightbox-level">100%</span>' +
150
+ '<button type="button" class="ursa-lightbox-btn" data-zoom="in" title="Zoom in" aria-label="Zoom in">+</button>' +
151
+ '</span>' +
152
+ '<a class="ursa-lightbox-btn ursa-lightbox-download" download title="Download image" aria-label="Download image">' + ICON_DOWNLOAD + '</a>' +
153
+ '</div>' +
154
+ '<button type="button" class="ursa-lightbox-close" title="Close (Esc)" aria-label="Close">' + ICON_CLOSE + '</button>';
155
+ document.body.appendChild(el);
156
+
157
+ viewer = {
158
+ el,
159
+ stage: el.querySelector('.ursa-lightbox-stage'),
160
+ img: el.querySelector('.ursa-lightbox-image'),
161
+ loading: el.querySelector('.ursa-lightbox-loading'),
162
+ zoomGroup: el.querySelector('.ursa-lightbox-zoom'),
163
+ level: el.querySelector('.ursa-lightbox-level'),
164
+ zoomIn: el.querySelector('[data-zoom="in"]'),
165
+ zoomOut: el.querySelector('[data-zoom="out"]'),
166
+ download: el.querySelector('.ursa-lightbox-download'),
167
+ natural: { w: 0, h: 0 },
168
+ scale: 1,
169
+ fitScale: 1,
170
+ ready: false,
171
+ zoomable: false,
172
+ atFit: true,
173
+ lastFocus: null,
174
+ token: 0,
175
+ dragged: false,
176
+ };
177
+
178
+ el.querySelector('.ursa-lightbox-backdrop').addEventListener('click', close);
179
+ el.querySelector('.ursa-lightbox-close').addEventListener('click', close);
180
+ // Letterbox area around the image closes too; a drag that ends there does not.
181
+ viewer.stage.addEventListener('click', (e) => {
182
+ if (e.target === viewer.stage && !viewer.dragged) close();
183
+ });
184
+ viewer.zoomIn.addEventListener('click', () => zoomBy(ZOOM_STEP));
185
+ viewer.zoomOut.addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
186
+ viewer.img.addEventListener('dblclick', () => {
187
+ if (!viewer.zoomable) return;
188
+ zoomTo(viewer.atFit ? 1 : viewer.fitScale);
189
+ });
190
+
191
+ enablePanning(viewer.stage);
192
+ window.addEventListener('resize', () => {
193
+ if (!el.hidden && viewer.ready) relayout();
194
+ });
195
+
196
+ return viewer;
197
+ }
198
+
199
+ /** Drag anywhere on the stage to pan a zoomed-in image. */
200
+ function enablePanning(stage) {
201
+ let panning = false;
202
+ let start = null;
203
+
204
+ stage.addEventListener('pointerdown', (e) => {
205
+ if (e.button !== 0) return;
206
+ // Cleared on every press, not just pannable ones: a stale flag from an
207
+ // earlier pan would otherwise swallow the next click-to-close.
208
+ viewer.dragged = false;
209
+ if (stage.scrollWidth <= stage.clientWidth && stage.scrollHeight <= stage.clientHeight) return;
210
+ panning = true;
211
+ start = { x: e.clientX, y: e.clientY, left: stage.scrollLeft, top: stage.scrollTop };
212
+ stage.setPointerCapture(e.pointerId);
213
+ stage.classList.add('is-panning');
214
+ });
215
+
216
+ stage.addEventListener('pointermove', (e) => {
217
+ if (!panning) return;
218
+ const dx = e.clientX - start.x;
219
+ const dy = e.clientY - start.y;
220
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) viewer.dragged = true;
221
+ stage.scrollLeft = start.left - dx;
222
+ stage.scrollTop = start.top - dy;
223
+ });
224
+
225
+ const end = (e) => {
226
+ if (!panning) return;
227
+ panning = false;
228
+ stage.classList.remove('is-panning');
229
+ try { stage.releasePointerCapture(e.pointerId); } catch {}
230
+ };
231
+ stage.addEventListener('pointerup', end);
232
+ stage.addEventListener('pointercancel', end);
233
+ }
234
+
235
+ /**
236
+ * Run a layout pass now and again on the next frame. A dialog that has just
237
+ * been unhidden can still report a flex-unresolved stage width in the same
238
+ * task, which would size the image to nothing.
239
+ */
240
+ function layoutTwice(fn) {
241
+ fn();
242
+ requestAnimationFrame(fn);
243
+ }
244
+
245
+ /** Space available for the image, inside the stage's padding. */
246
+ function stageSize() {
247
+ const style = getComputedStyle(viewer.stage);
248
+ const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
249
+ const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
250
+ return {
251
+ w: Math.max(1, viewer.stage.clientWidth - padX),
252
+ h: Math.max(1, viewer.stage.clientHeight - padY),
253
+ };
254
+ }
255
+
256
+ function containScale(w, h) {
257
+ const stage = stageSize();
258
+ return Math.min(stage.w / w, stage.h / h);
259
+ }
260
+
261
+ function applyScale(scale) {
262
+ viewer.scale = scale;
263
+ viewer.img.style.width = Math.round(viewer.natural.w * scale) + 'px';
264
+ viewer.img.style.height = Math.round(viewer.natural.h * scale) + 'px';
265
+ viewer.atFit = Math.abs(scale - viewer.fitScale) < 0.001;
266
+ updateZoomControls();
267
+ }
268
+
269
+ function updateZoomControls() {
270
+ viewer.zoomGroup.hidden = !viewer.zoomable;
271
+ viewer.level.textContent = Math.round(viewer.scale * 100) + '%';
272
+ viewer.zoomIn.disabled = viewer.scale >= 1 - 0.001;
273
+ viewer.zoomOut.disabled = viewer.scale <= viewer.fitScale + 0.001;
274
+ viewer.stage.classList.toggle('is-pannable', viewer.scale > viewer.fitScale + 0.001);
275
+ }
276
+
277
+ /** Zoom, keeping whatever is at the centre of the stage at the centre. */
278
+ function zoomTo(next) {
279
+ const stage = viewer.stage;
280
+ const clamped = Math.min(1, Math.max(viewer.fitScale, next));
281
+ if (Math.abs(clamped - viewer.scale) < 0.001) return;
282
+
283
+ const before = viewer.img.getBoundingClientRect();
284
+ const stageRect = stage.getBoundingClientRect();
285
+ const centreX = stageRect.left + stageRect.width / 2;
286
+ const centreY = stageRect.top + stageRect.height / 2;
287
+ const pointX = (centreX - before.left) / viewer.scale;
288
+ const pointY = (centreY - before.top) / viewer.scale;
289
+
290
+ applyScale(clamped);
291
+
292
+ const after = viewer.img.getBoundingClientRect();
293
+ stage.scrollLeft += after.left + pointX * clamped - centreX;
294
+ stage.scrollTop += after.top + pointY * clamped - centreY;
295
+ }
296
+
297
+ function zoomBy(factor) {
298
+ if (!viewer.ready || !viewer.zoomable) return;
299
+ zoomTo(viewer.scale * factor);
300
+ }
301
+
302
+ /** Fit the loaded original: native resolution if it fits, contained if not. */
303
+ function relayout() {
304
+ const wasAtFit = viewer.atFit;
305
+ viewer.fitScale = containScale(viewer.natural.w, viewer.natural.h);
306
+ // Zoom controls are only meaningful while there is unseen resolution left.
307
+ viewer.zoomable = viewer.fitScale < 1 - 0.001;
308
+ const target = wasAtFit
309
+ ? Math.min(1, viewer.fitScale)
310
+ : Math.min(1, Math.max(viewer.fitScale, viewer.scale));
311
+ applyScale(target);
312
+ viewer.atFit = Math.abs(target - Math.min(1, viewer.fitScale)) < 0.001;
313
+ }
314
+
315
+ function open(img, url) {
316
+ const v = ensureViewer();
317
+ const token = ++v.token;
318
+
319
+ v.lastFocus = document.activeElement;
320
+ v.img.alt = img.alt || '';
321
+ v.download.href = url;
322
+ v.download.download = fileNameFor(url);
323
+ v.ready = false;
324
+ v.zoomable = false;
325
+ v.zoomGroup.hidden = true;
326
+ v.stage.scrollTop = 0;
327
+ v.stage.scrollLeft = 0;
328
+
329
+ // Reveal first: the stage cannot be measured while the dialog is hidden.
330
+ v.img.classList.add('is-placeholder');
331
+ v.img.removeAttribute('style');
332
+ v.loading.hidden = false;
333
+ v.el.hidden = false;
334
+ document.body.classList.add('ursa-lightbox-open');
335
+ document.addEventListener('keydown', onKeydown);
336
+ v.stage.focus({ preventScroll: true });
337
+
338
+ // Show the already-loaded preview stretched to fill the stage, so there is
339
+ // something on screen while the (potentially large) original downloads.
340
+ const placeholder = img.currentSrc || img.src;
341
+ if (placeholder && !samePath(placeholder, url) && img.naturalWidth) {
342
+ v.img.src = placeholder;
343
+ v.natural = { w: img.naturalWidth, h: img.naturalHeight };
344
+ layoutTwice(() => {
345
+ if (token !== v.token || v.ready) return; // superseded by the original
346
+ v.fitScale = containScale(v.natural.w, v.natural.h);
347
+ applyScale(v.fitScale);
348
+ });
349
+ }
350
+
351
+ const full = new Image();
352
+ full.onload = () => {
353
+ if (token !== v.token) return; // a later image won the race
354
+ v.img.classList.remove('is-placeholder');
355
+ v.loading.hidden = true;
356
+ v.img.src = url;
357
+ v.natural = { w: full.naturalWidth, h: full.naturalHeight };
358
+ v.ready = true;
359
+ v.atFit = true;
360
+ layoutTwice(() => {
361
+ if (token !== v.token) return;
362
+ relayout();
363
+ });
364
+ };
365
+ full.onerror = () => {
366
+ if (token !== v.token) return;
367
+ v.loading.hidden = true;
368
+ };
369
+ full.src = url;
370
+ }
371
+
372
+ function close() {
373
+ if (!viewer || viewer.el.hidden) return;
374
+ viewer.token++; // abandon any in-flight load
375
+ viewer.el.hidden = true;
376
+ viewer.img.removeAttribute('src');
377
+ viewer.loading.hidden = true;
378
+ document.body.classList.remove('ursa-lightbox-open');
379
+ document.removeEventListener('keydown', onKeydown);
380
+ if (viewer.lastFocus && viewer.lastFocus.focus) viewer.lastFocus.focus();
381
+ }
382
+
383
+ function onKeydown(e) {
384
+ if (e.key === 'Escape') {
385
+ e.preventDefault();
386
+ close();
387
+ } else if (e.key === '+' || e.key === '=') {
388
+ zoomBy(ZOOM_STEP);
389
+ } else if (e.key === '-' || e.key === '_') {
390
+ zoomBy(1 / ZOOM_STEP);
391
+ } else if (e.key === 'Tab') {
392
+ trapFocus(e);
393
+ }
394
+ }
395
+
396
+ /** Keep Tab inside the dialog while it is open. */
397
+ function trapFocus(e) {
398
+ const focusable = Array.from(viewer.el.querySelectorAll('button, a[href]'))
399
+ .filter((el) => !el.disabled && el.getClientRects().length > 0);
400
+ if (focusable.length === 0) return;
401
+ const first = focusable[0];
402
+ const last = focusable[focusable.length - 1];
403
+ const active = document.activeElement;
404
+ // Anywhere but a control of the dialog — the stage, or the page behind it.
405
+ if (focusable.indexOf(active) === -1) {
406
+ e.preventDefault();
407
+ (e.shiftKey ? last : first).focus();
408
+ } else if (e.shiftKey && active === first) {
409
+ e.preventDefault();
410
+ last.focus();
411
+ } else if (!e.shiftKey && active === last) {
412
+ e.preventDefault();
413
+ first.focus();
414
+ }
415
+ }
416
+
417
+ // --- init -----------------------------------------------------------------
418
+
419
+ function init() {
420
+ const article = document.querySelector('article#main-content');
421
+ if (!article) return;
422
+ article.querySelectorAll('img').forEach(decorate);
423
+ // Build the dialog up front so the first open measures a laid-out stage.
424
+ ensureViewer();
425
+ }
426
+
427
+ if (document.readyState === 'loading') {
428
+ document.addEventListener('DOMContentLoaded', init);
429
+ } else {
430
+ init();
431
+ }
432
+ })();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.89.1",
5
+ "version": "0.90.1",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -44,7 +44,7 @@
44
44
  "remark-directive": "^4.0.0",
45
45
  "remark-gfm": "^4.0.1",
46
46
  "remark-supersub": "^1.0.0",
47
- "sharp": "^0.33.2",
47
+ "sharp": "^0.34.5",
48
48
  "unist-util-visit": "^5.1.0",
49
49
  "ws": "^8.19.0",
50
50
  "yaml": "^2.1.3",
@@ -79,7 +79,8 @@
79
79
  ],
80
80
  "pnpm": {
81
81
  "onlyBuiltDependencies": [
82
- "esbuild"
82
+ "esbuild",
83
+ "sharp"
83
84
  ]
84
85
  },
85
86
  "publishConfig": {