@file-viewer/renderer-image 2.2.3 → 2.2.5

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/README.en.md CHANGED
@@ -28,7 +28,8 @@ const options = {
28
28
 
29
29
  - Common image formats use native browser decoding without extra runtime work.
30
30
  - HEIC / HEIF dynamically imports `heic2any` only when those formats are opened.
31
- - Includes lightbox viewing, a unified zoom provider, theme-aware background, and unmount cleanup.
31
+ - Rotates left or right in 90° steps and refits both the main view and lightbox without clipping.
32
+ - Includes lightbox viewing, unified zoom and view-state providers, a theme-aware background, and unmount cleanup.
32
33
 
33
34
  ## Migration Note
34
35
 
package/README.md CHANGED
@@ -28,7 +28,8 @@ const options = {
28
28
 
29
29
  - 普通图片使用浏览器原生解码,不引入额外运行时。
30
30
  - HEIC / HEIF 只在命中格式时动态加载 `heic2any` 并转换为 PNG Data URL。
31
- - 支持图片灯箱、统一缩放 provider、明暗主题背景和组件卸载清理。
31
+ - 支持左右 90° 旋转;主视图和灯箱都会按旋转后的宽高重新适配,不裁切图片。
32
+ - 支持图片灯箱、统一缩放和 view-state provider、明暗主题背景和组件卸载清理。
32
33
 
33
34
  ## 迁移说明
34
35
 
package/dist/image.d.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  import { type FileRenderContext, type FileViewerRenderedInstance } from '@file-viewer/core';
2
+ export declare const normalizeImageRotation: (rotation: number) => number;
3
+ export declare const resolveRotatedImageSize: (naturalWidth: number, naturalHeight: number, scale: number, rotation: number) => {
4
+ imageWidth: number;
5
+ imageHeight: number;
6
+ frameWidth: number;
7
+ frameHeight: number;
8
+ };
2
9
  export default function renderImage(buffer: ArrayBuffer, target: HTMLDivElement, type?: string, context?: FileRenderContext): Promise<FileViewerRenderedInstance>;
package/dist/image.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createFileViewerTranslator, createFileViewerZoomChangeEmitter as createZoomChangeEmitter, resolveFileViewerFitScale, registerFileViewerZoomProvider, unregisterFileViewerZoomProvider, } from '@file-viewer/core';
1
+ import { createFileViewerTranslator, createFileViewerViewStateChange, createFileViewerViewStateChangeEmitter, createFileViewerZoomChangeEmitter as createZoomChangeEmitter, resolveFileViewerFitScale, registerFileViewerViewStateProvider, registerFileViewerZoomProvider, unregisterFileViewerViewStateProvider, unregisterFileViewerZoomProvider, } from '@file-viewer/core';
2
2
  const imageMimeMap = {
3
3
  avif: 'image/avif',
4
4
  bmp: 'image/bmp',
@@ -17,15 +17,30 @@ const imageMimeMap = {
17
17
  };
18
18
  const imageStyle = `
19
19
  .image-viewer{position:relative;width:100%;height:100%;overflow:auto;background:var(--file-viewer-render-surface-background,#eef1f4);box-sizing:border-box}
20
+ .image-toolbar{position:sticky;top:12px;z-index:5;display:flex;align-items:center;gap:6px;width:max-content;height:42px;margin:12px 12px -54px auto;padding:5px 7px;border:1px solid rgba(148,163,184,.34);border-radius:10px;background:rgba(255,255,255,.92);box-shadow:0 10px 28px rgba(15,23,42,.14);backdrop-filter:blur(12px);box-sizing:border-box}
21
+ .image-toolbar button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:1px solid transparent;border-radius:7px;background:transparent;color:#334155;font:600 19px/1 system-ui,sans-serif;cursor:pointer;box-sizing:border-box}
22
+ .image-toolbar button:hover{border-color:#bfd2ea;background:#edf5ff;color:#1769d8}
23
+ .image-toolbar button:focus-visible{outline:3px solid #60a5fa;outline-offset:1px}
24
+ .image-rotation-meter{min-width:38px;color:#64748b;font:700 12px/1 system-ui,sans-serif;text-align:center}
20
25
  .image-stage{min-width:100%;min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}
21
- .image-stage img{display:block;width:auto;max-width:none;margin:0 auto;border:0;box-shadow:0 18px 48px rgba(15,23,42,.16);background:#fff;cursor:zoom-in}
22
- .image-lightbox{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:40px;background:rgba(15,23,42,.88);box-sizing:border-box}
23
- .image-lightbox[hidden]{display:none}
24
- .image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;background:#fff;box-shadow:0 30px 80px rgba(0,0,0,.4);cursor:zoom-out}
25
- .image-lightbox button{position:absolute;top:20px;right:20px;width:40px;height:40px;border:0;border-radius:999px;background:rgba(255,255,255,.92);color:#172033;font-size:24px;line-height:40px;cursor:pointer;box-shadow:0 12px 28px rgba(0,0,0,.18)}
26
+ .image-frame{position:relative;flex:0 0 auto}
27
+ .image-stage img{position:absolute;top:50%;left:50%;display:block;width:auto;max-width:none;margin:0;border:0;box-shadow:0 18px 48px rgba(15,23,42,.16);background:#fff;cursor:zoom-in;transform:translate(-50%,-50%) rotate(var(--image-rotation,0deg));transform-origin:center center;transition:transform .18s ease}
28
+ .image-stage img:focus-visible{outline:3px solid #2563eb;outline-offset:4px}
29
+ .image-lightbox{position:absolute;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:40px;background:rgba(15,23,42,.9);box-sizing:border-box;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .18s ease,visibility 0s linear .18s}
30
+ .image-lightbox[data-open='true']{opacity:1;visibility:visible;pointer-events:auto;transition-delay:0s}
31
+ .image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;background:#fff;box-shadow:0 30px 80px rgba(0,0,0,.4);cursor:default;transform:rotate(var(--image-rotation,0deg)) scale(.985);transition:transform .18s ease}
32
+ .image-lightbox[data-open='true'] img{transform:rotate(var(--image-rotation,0deg)) scale(1)}
33
+ .image-lightbox button{position:absolute;top:16px;right:16px;display:grid;width:40px;height:40px;place-items:center;padding:0;border:1px solid rgba(255,255,255,.7);border-radius:999px;background:rgba(255,255,255,.96);color:#172033;font:400 27px/1 Arial,sans-serif;cursor:pointer;box-shadow:0 12px 28px rgba(0,0,0,.24);transition:background-color .14s ease,transform .14s ease}
34
+ .image-lightbox button:hover{background:#fff;transform:scale(1.04)}
35
+ .image-lightbox button:focus-visible{outline:3px solid #60a5fa;outline-offset:2px}
26
36
  [data-viewer-theme='dark'] .image-viewer{background:var(--file-viewer-render-surface-background,#101820)}
37
+ [data-viewer-theme='dark'] .image-toolbar{border-color:rgba(148,163,184,.28);background:rgba(17,24,39,.88)}
38
+ [data-viewer-theme='dark'] .image-toolbar button{color:#dbe5f2}
39
+ [data-viewer-theme='dark'] .image-rotation-meter{color:#b7c5d7}
27
40
  @media (prefers-color-scheme:dark){[data-viewer-theme='system'] .image-viewer{background:var(--file-viewer-render-surface-background,#101820)}}
28
- @media (max-width:767px){.image-stage{padding:12px}.image-lightbox{padding:16px}.image-lightbox button{top:12px;right:12px}}
41
+ @media (prefers-color-scheme:dark){[data-viewer-theme='system'] .image-toolbar{border-color:rgba(148,163,184,.28);background:rgba(17,24,39,.88)}[data-viewer-theme='system'] .image-toolbar button{color:#dbe5f2}[data-viewer-theme='system'] .image-rotation-meter{color:#b7c5d7}}
42
+ @media (max-width:767px){.image-toolbar{top:8px;margin:8px 8px -50px auto}.image-stage{padding:12px}.image-lightbox{padding:16px}.image-lightbox button{top:12px;right:12px}}
43
+ @media (prefers-reduced-motion:reduce){.image-stage img,.image-lightbox,.image-lightbox img,.image-lightbox button{transition:none}}
29
44
  `;
30
45
  const createStyle = (documentRef) => {
31
46
  const style = documentRef.createElement('style');
@@ -68,41 +83,127 @@ const resolveImageUrl = async (buffer, type) => {
68
83
  }
69
84
  return readBlobDataUrl(new Blob([buffer], { type: getImageBlobType(normalizedType) }));
70
85
  };
86
+ const waitForImageReady = async (image) => {
87
+ if (image.complete) {
88
+ if (image.naturalWidth > 0 && image.naturalHeight > 0)
89
+ return;
90
+ throw new Error('The browser could not decode this image format.');
91
+ }
92
+ await new Promise((resolve, reject) => {
93
+ const cleanup = () => {
94
+ image.removeEventListener('load', onLoad);
95
+ image.removeEventListener('error', onError);
96
+ };
97
+ const onLoad = () => {
98
+ cleanup();
99
+ resolve();
100
+ };
101
+ const onError = () => {
102
+ cleanup();
103
+ reject(new Error('The browser could not decode this image format.'));
104
+ };
105
+ image.addEventListener('load', onLoad, { once: true });
106
+ image.addEventListener('error', onError, { once: true });
107
+ });
108
+ };
71
109
  const roundImageScale = (value) => {
72
110
  return Number(value.toFixed(3));
73
111
  };
112
+ export const normalizeImageRotation = (rotation) => {
113
+ return ((Math.round(rotation / 90) * 90) % 360 + 360) % 360;
114
+ };
115
+ export const resolveRotatedImageSize = (naturalWidth, naturalHeight, scale, rotation) => {
116
+ const width = Math.max(1, Math.round(naturalWidth * scale));
117
+ const height = Math.max(1, Math.round(naturalHeight * scale));
118
+ const swapsAxes = normalizeImageRotation(rotation) % 180 !== 0;
119
+ return {
120
+ imageWidth: width,
121
+ imageHeight: height,
122
+ frameWidth: swapsAxes ? height : width,
123
+ frameHeight: swapsAxes ? width : height,
124
+ };
125
+ };
74
126
  const createLightbox = (documentRef, src, t) => {
75
127
  const lightbox = documentRef.createElement('div');
76
128
  lightbox.className = 'image-lightbox';
77
- lightbox.hidden = true;
129
+ lightbox.dataset.open = 'false';
78
130
  lightbox.setAttribute('role', 'dialog');
79
131
  lightbox.setAttribute('aria-modal', 'true');
132
+ lightbox.setAttribute('aria-hidden', 'true');
80
133
  const image = documentRef.createElement('img');
81
134
  image.alt = t('image.lightbox.alt');
82
135
  image.src = src;
136
+ const ownerWindow = documentRef.defaultView;
137
+ let rotation = 0;
138
+ const syncImageSize = () => {
139
+ if (!image.naturalWidth || !image.naturalHeight)
140
+ return;
141
+ const style = ownerWindow === null || ownerWindow === void 0 ? void 0 : ownerWindow.getComputedStyle(lightbox);
142
+ const horizontalPadding = Number.parseFloat((style === null || style === void 0 ? void 0 : style.paddingLeft) || '0') +
143
+ Number.parseFloat((style === null || style === void 0 ? void 0 : style.paddingRight) || '0');
144
+ const verticalPadding = Number.parseFloat((style === null || style === void 0 ? void 0 : style.paddingTop) || '0') +
145
+ Number.parseFloat((style === null || style === void 0 ? void 0 : style.paddingBottom) || '0');
146
+ const availableWidth = Math.max(1, lightbox.clientWidth - horizontalPadding);
147
+ const availableHeight = Math.max(1, lightbox.clientHeight - verticalPadding);
148
+ const swapsAxes = rotation % 180 !== 0;
149
+ const visualWidth = swapsAxes ? image.naturalHeight : image.naturalWidth;
150
+ const visualHeight = swapsAxes ? image.naturalWidth : image.naturalHeight;
151
+ const scale = Math.min(1, availableWidth / visualWidth, availableHeight / visualHeight);
152
+ image.style.width = `${Math.max(1, Math.round(image.naturalWidth * scale))}px`;
153
+ image.style.height = `${Math.max(1, Math.round(image.naturalHeight * scale))}px`;
154
+ };
155
+ image.addEventListener('load', syncImageSize);
156
+ ownerWindow === null || ownerWindow === void 0 ? void 0 : ownerWindow.addEventListener('resize', syncImageSize);
83
157
  const closeButton = documentRef.createElement('button');
84
158
  closeButton.type = 'button';
85
159
  closeButton.setAttribute('aria-label', t('image.lightbox.close'));
86
- closeButton.textContent = 'x';
160
+ closeButton.textContent = '×';
161
+ let previousFocus = null;
87
162
  const close = () => {
88
- lightbox.hidden = true;
163
+ if (lightbox.dataset.open !== 'true')
164
+ return;
165
+ lightbox.dataset.open = 'false';
166
+ lightbox.setAttribute('aria-hidden', 'true');
167
+ if (previousFocus === null || previousFocus === void 0 ? void 0 : previousFocus.isConnected) {
168
+ previousFocus.focus({ preventScroll: true });
169
+ }
170
+ previousFocus = null;
171
+ };
172
+ const onKeyDown = (event) => {
173
+ if (event.key === 'Escape' && lightbox.dataset.open === 'true') {
174
+ event.preventDefault();
175
+ close();
176
+ }
89
177
  };
90
178
  closeButton.addEventListener('click', close);
91
- image.addEventListener('click', close);
92
179
  lightbox.addEventListener('click', event => {
93
180
  if (event.target === lightbox) {
94
181
  close();
95
182
  }
96
183
  });
184
+ documentRef.addEventListener('keydown', onKeyDown);
97
185
  lightbox.append(image, closeButton);
98
186
  return {
99
187
  element: lightbox,
100
- open() {
101
- lightbox.hidden = false;
188
+ setRotation(nextRotation) {
189
+ rotation = normalizeImageRotation(nextRotation);
190
+ lightbox.style.setProperty('--image-rotation', `${rotation}deg`);
191
+ syncImageSize();
192
+ },
193
+ open(invoker) {
194
+ previousFocus =
195
+ invoker ||
196
+ (documentRef.activeElement instanceof HTMLElement ? documentRef.activeElement : null);
197
+ lightbox.dataset.open = 'true';
198
+ lightbox.setAttribute('aria-hidden', 'false');
199
+ syncImageSize();
200
+ closeButton.focus({ preventScroll: true });
102
201
  },
103
202
  destroy() {
104
203
  closeButton.removeEventListener('click', close);
105
- image.removeEventListener('click', close);
204
+ documentRef.removeEventListener('keydown', onKeyDown);
205
+ image.removeEventListener('load', syncImageSize);
206
+ ownerWindow === null || ownerWindow === void 0 ? void 0 : ownerWindow.removeEventListener('resize', syncImageSize);
106
207
  lightbox.remove();
107
208
  },
108
209
  };
@@ -115,16 +216,44 @@ export default async function renderImage(buffer, target, type, context) {
115
216
  let userZoom = 1;
116
217
  let fitScale = 1;
117
218
  let currentScale = 1;
219
+ let currentRotation = 0;
118
220
  let viewportHeight = 0;
221
+ let scrollStateFrame = 0;
222
+ let destroyed = false;
119
223
  const zoomEmitter = createZoomChangeEmitter();
224
+ const viewStateEmitter = createFileViewerViewStateChangeEmitter();
120
225
  const root = documentRef.createElement('div');
121
226
  root.className = 'image-viewer';
122
227
  root.dataset.viewerZoomProvider = 'image';
228
+ const toolbar = documentRef.createElement('div');
229
+ toolbar.className = 'image-toolbar';
230
+ toolbar.setAttribute('role', 'toolbar');
231
+ toolbar.setAttribute('aria-label', t('image.toolbar.rotation'));
232
+ const createRotateButton = (label, icon) => {
233
+ const button = documentRef.createElement('button');
234
+ button.type = 'button';
235
+ button.setAttribute('aria-label', label);
236
+ button.title = label;
237
+ button.textContent = icon;
238
+ return button;
239
+ };
240
+ const rotateLeftButton = createRotateButton(t('image.toolbar.rotateLeft'), '↺');
241
+ const rotationMeter = documentRef.createElement('span');
242
+ rotationMeter.className = 'image-rotation-meter';
243
+ rotationMeter.setAttribute('aria-live', 'polite');
244
+ rotationMeter.textContent = '0°';
245
+ const rotateRightButton = createRotateButton(t('image.toolbar.rotateRight'), '↻');
246
+ toolbar.append(rotateLeftButton, rotationMeter, rotateRightButton);
123
247
  const stage = documentRef.createElement('div');
124
248
  stage.className = 'image-stage';
249
+ const frame = documentRef.createElement('div');
250
+ frame.className = 'image-frame';
125
251
  const image = documentRef.createElement('img');
126
252
  image.alt = t('image.alt');
127
253
  image.src = src;
254
+ image.tabIndex = 0;
255
+ image.setAttribute('role', 'button');
256
+ image.setAttribute('aria-haspopup', 'dialog');
128
257
  const normalizedType = (type || '').trim().toLowerCase();
129
258
  (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, {
130
259
  capture: () => normalizedType === 'heic' || normalizedType === 'heif'
@@ -132,11 +261,19 @@ export default async function renderImage(buffer, target, type, context) {
132
261
  : new Blob([buffer], { type: getImageBlobType(normalizedType) }),
133
262
  getTarget: () => image,
134
263
  });
135
- stage.append(image);
136
- root.append(stage);
264
+ frame.append(image);
265
+ stage.append(frame);
266
+ root.append(toolbar, stage);
137
267
  const lightbox = createLightbox(documentRef, src, t);
138
- const openLightbox = () => lightbox.open();
268
+ const openLightbox = () => lightbox.open(image);
269
+ const openLightboxFromKeyboard = (event) => {
270
+ if (event.key === 'Enter' || event.key === ' ') {
271
+ event.preventDefault();
272
+ lightbox.open(image);
273
+ }
274
+ };
139
275
  image.addEventListener('click', openLightbox);
276
+ image.addEventListener('keydown', openLightboxFromKeyboard);
140
277
  const getMinScale = () => Math.min(0.1, fitScale || 0.1);
141
278
  const clampScale = (value) => {
142
279
  const minScale = getMinScale();
@@ -150,20 +287,30 @@ export default async function renderImage(buffer, target, type, context) {
150
287
  }
151
288
  const availableWidth = Math.max((root.clientWidth || 0) - 48, 1);
152
289
  const availableHeight = Math.max((root.clientHeight || viewportHeight || 0) - 48, 1);
153
- return Math.min(1, availableWidth / naturalWidth, availableHeight / naturalHeight);
290
+ const swapsAxes = currentRotation % 180 !== 0;
291
+ const visualWidth = swapsAxes ? naturalHeight : naturalWidth;
292
+ const visualHeight = swapsAxes ? naturalWidth : naturalHeight;
293
+ return Math.min(1, availableWidth / visualWidth, availableHeight / visualHeight);
154
294
  };
155
295
  const applyImageZoom = () => {
156
296
  fitScale = computeFitScale();
157
297
  currentScale = clampScale(fitScale * userZoom);
158
298
  if (image.naturalWidth && image.naturalHeight) {
159
- image.style.width = `${Math.max(1, Math.round(image.naturalWidth * currentScale))}px`;
160
- image.style.height = `${Math.max(1, Math.round(image.naturalHeight * currentScale))}px`;
299
+ const size = resolveRotatedImageSize(image.naturalWidth, image.naturalHeight, currentScale, currentRotation);
300
+ image.style.width = `${size.imageWidth}px`;
301
+ image.style.height = `${size.imageHeight}px`;
302
+ frame.style.width = `${size.frameWidth}px`;
303
+ frame.style.height = `${size.frameHeight}px`;
304
+ image.style.setProperty('--image-rotation', `${currentRotation}deg`);
161
305
  return;
162
306
  }
163
307
  image.style.width = 'auto';
164
308
  image.style.height = viewportHeight > 0
165
309
  ? `${Math.max(1, Math.round(viewportHeight * userZoom))}px`
166
310
  : `${userZoom * 100}%`;
311
+ frame.style.width = image.style.width;
312
+ frame.style.height = image.style.height;
313
+ image.style.setProperty('--image-rotation', `${currentRotation}deg`);
167
314
  };
168
315
  const updateViewportSize = () => {
169
316
  viewportHeight = root.clientHeight || 0;
@@ -182,13 +329,58 @@ export default async function renderImage(buffer, target, type, context) {
182
329
  minScale: getMinScale(),
183
330
  maxScale: 5,
184
331
  });
185
- const setZoom = (scale) => {
332
+ const readScrollState = () => {
333
+ const maxTop = Math.max(0, root.scrollHeight - root.clientHeight);
334
+ const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth);
335
+ return {
336
+ top: root.scrollTop || 0,
337
+ left: root.scrollLeft || 0,
338
+ width: root.scrollWidth || 0,
339
+ height: root.scrollHeight || 0,
340
+ clientWidth: root.clientWidth || 0,
341
+ clientHeight: root.clientHeight || 0,
342
+ topRatio: maxTop > 0 ? (root.scrollTop || 0) / maxTop : 0,
343
+ leftRatio: maxLeft > 0 ? (root.scrollLeft || 0) / maxLeft : 0,
344
+ };
345
+ };
346
+ const getImageViewState = () => ({
347
+ renderer: 'image',
348
+ scale: currentScale,
349
+ zoom: getZoomState(),
350
+ rotation: currentRotation,
351
+ scroll: readScrollState(),
352
+ });
353
+ const emitViewStateChange = (action, source = 'viewer') => {
354
+ const state = getImageViewState();
355
+ if (!destroyed) {
356
+ viewStateEmitter.emit(createFileViewerViewStateChange(state, { action, source }));
357
+ }
358
+ return state;
359
+ };
360
+ const setZoom = (scale, action = 'zoom-change', source = 'api', notifyViewState = true) => {
186
361
  const nextScale = clampScale(scale);
187
362
  userZoom = nextScale / Math.max(fitScale, 0.001);
188
363
  applyImageZoom();
189
364
  zoomEmitter.emit();
365
+ if (notifyViewState) {
366
+ emitViewStateChange(action, source);
367
+ }
190
368
  return getZoomState();
191
369
  };
370
+ const applyImageRotation = (rotation, action = 'rotation-change', source = 'viewer', notifyViewState = true) => {
371
+ const normalized = normalizeImageRotation(rotation);
372
+ if (normalized === currentRotation) {
373
+ return getImageViewState();
374
+ }
375
+ currentRotation = normalized;
376
+ rotationMeter.textContent = `${currentRotation}°`;
377
+ lightbox.setRotation(currentRotation);
378
+ applyImageZoom();
379
+ zoomEmitter.emit();
380
+ return notifyViewState
381
+ ? emitViewStateChange(action, source)
382
+ : getImageViewState();
383
+ };
192
384
  const fitImage = (request) => {
193
385
  var _a, _b;
194
386
  const naturalWidth = image.naturalWidth || 0;
@@ -204,12 +396,13 @@ export default async function renderImage(buffer, target, type, context) {
204
396
  };
205
397
  }
206
398
  const mode = request.mode === 'auto' ? 'scale-down' : request.mode;
399
+ const swapsAxes = currentRotation % 180 !== 0;
207
400
  const scale = resolveFileViewerFitScale({
208
401
  mode,
209
402
  viewportWidth: Math.max(1, request.viewportWidth || root.clientWidth || 0),
210
403
  viewportHeight: Math.max(1, request.viewportHeight || root.clientHeight || viewportHeight || 0),
211
- contentWidth: naturalWidth,
212
- contentHeight: naturalHeight,
404
+ contentWidth: swapsAxes ? naturalHeight : naturalWidth,
405
+ contentHeight: swapsAxes ? naturalWidth : naturalHeight,
213
406
  currentScale,
214
407
  minScale: (_a = request.minScale) !== null && _a !== void 0 ? _a : getMinScale(),
215
408
  maxScale: (_b = request.maxScale) !== null && _b !== void 0 ? _b : 5,
@@ -224,7 +417,7 @@ export default async function renderImage(buffer, target, type, context) {
224
417
  provider: 'zoom',
225
418
  };
226
419
  }
227
- const state = setZoom(scale);
420
+ const state = setZoom(scale, 'fit', request.source);
228
421
  return {
229
422
  applied: true,
230
423
  mode: request.mode,
@@ -234,13 +427,64 @@ export default async function renderImage(buffer, target, type, context) {
234
427
  provider: 'zoom',
235
428
  };
236
429
  };
430
+ const restoreScrollState = (scroll) => {
431
+ if (!scroll)
432
+ return;
433
+ const maxTop = Math.max(0, root.scrollHeight - root.clientHeight);
434
+ const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth);
435
+ const top = Number.isFinite(scroll.top)
436
+ ? Number(scroll.top)
437
+ : Number.isFinite(scroll.topRatio)
438
+ ? Number(scroll.topRatio) * maxTop
439
+ : undefined;
440
+ const left = Number.isFinite(scroll.left)
441
+ ? Number(scroll.left)
442
+ : Number.isFinite(scroll.leftRatio)
443
+ ? Number(scroll.leftRatio) * maxLeft
444
+ : undefined;
445
+ if (top !== undefined)
446
+ root.scrollTop = Math.min(Math.max(0, top), maxTop);
447
+ if (left !== undefined)
448
+ root.scrollLeft = Math.min(Math.max(0, left), maxLeft);
449
+ };
450
+ const applyImageViewState = async (state, options = {}) => {
451
+ var _a, _b;
452
+ const source = options.source || 'api';
453
+ const action = options.action || 'restore';
454
+ const nextRotation = Number(state.rotation);
455
+ const nextScale = Number((_a = state.scale) !== null && _a !== void 0 ? _a : (_b = state.zoom) === null || _b === void 0 ? void 0 : _b.scale);
456
+ if (Number.isFinite(nextRotation)) {
457
+ applyImageRotation(nextRotation, 'rotation-change', source, false);
458
+ }
459
+ if (Number.isFinite(nextScale)) {
460
+ setZoom(nextScale, 'zoom-change', source, false);
461
+ }
462
+ restoreScrollState(state.scroll);
463
+ return options.notify === false
464
+ ? getImageViewState()
465
+ : emitViewStateChange(action, source);
466
+ };
467
+ const targetWindow = documentRef.defaultView;
468
+ const scheduleScrollViewStateChange = () => {
469
+ if (destroyed || scrollStateFrame)
470
+ return;
471
+ if (!(targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.requestAnimationFrame)) {
472
+ emitViewStateChange('scroll', 'viewer');
473
+ return;
474
+ }
475
+ scrollStateFrame = targetWindow.requestAnimationFrame(() => {
476
+ scrollStateFrame = 0;
477
+ emitViewStateChange('scroll', 'viewer');
478
+ });
479
+ };
237
480
  registerFileViewerZoomProvider(root, {
238
- zoomIn: () => setZoom(currentScale + 0.15),
239
- zoomOut: () => setZoom(currentScale - 0.15),
481
+ zoomIn: () => setZoom(currentScale + 0.15, 'zoom-in', 'api'),
482
+ zoomOut: () => setZoom(currentScale - 0.15, 'zoom-out', 'api'),
240
483
  resetZoom: () => {
241
484
  userZoom = 1;
242
485
  applyImageZoom();
243
486
  zoomEmitter.emit();
487
+ emitViewStateChange('zoom-reset', 'api');
244
488
  return getZoomState();
245
489
  },
246
490
  setZoom,
@@ -248,20 +492,57 @@ export default async function renderImage(buffer, target, type, context) {
248
492
  getState: getZoomState,
249
493
  subscribe: zoomEmitter.subscribe,
250
494
  });
495
+ registerFileViewerViewStateProvider(root, {
496
+ getState: getImageViewState,
497
+ applyState: applyImageViewState,
498
+ fit: fitImage,
499
+ subscribe: viewStateEmitter.subscribe,
500
+ });
501
+ const rotateLeft = () => {
502
+ applyImageRotation(currentRotation - 90, 'rotate-left', 'user');
503
+ };
504
+ const rotateRight = () => {
505
+ applyImageRotation(currentRotation + 90, 'rotate-right', 'user');
506
+ };
507
+ rotateLeftButton.addEventListener('click', rotateLeft);
508
+ rotateRightButton.addEventListener('click', rotateRight);
509
+ root.addEventListener('scroll', scheduleScrollViewStateChange, { passive: true });
251
510
  target.replaceChildren(createStyle(documentRef), root);
252
511
  (((_b = context === null || context === void 0 ? void 0 : context.surface) === null || _b === void 0 ? void 0 : _b.shadowRoot) || target).append(lightbox.element);
253
512
  updateViewportSize();
513
+ const cleanup = () => {
514
+ var _a;
515
+ destroyed = true;
516
+ (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, null);
517
+ unregisterFileViewerViewStateProvider(root);
518
+ unregisterFileViewerZoomProvider(root);
519
+ if (scrollStateFrame && (targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.cancelAnimationFrame)) {
520
+ targetWindow.cancelAnimationFrame(scrollStateFrame);
521
+ scrollStateFrame = 0;
522
+ }
523
+ resizeObserver.disconnect();
524
+ root.removeEventListener('scroll', scheduleScrollViewStateChange);
525
+ rotateLeftButton.removeEventListener('click', rotateLeft);
526
+ rotateRightButton.removeEventListener('click', rotateRight);
527
+ image.removeEventListener('load', updateViewportSize);
528
+ image.removeEventListener('click', openLightbox);
529
+ image.removeEventListener('keydown', openLightboxFromKeyboard);
530
+ lightbox.destroy();
531
+ target.replaceChildren();
532
+ };
533
+ try {
534
+ await waitForImageReady(image);
535
+ updateViewportSize();
536
+ targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.requestAnimationFrame(() => emitViewStateChange('init', 'viewer'));
537
+ }
538
+ catch (error) {
539
+ cleanup();
540
+ throw error;
541
+ }
254
542
  return {
255
543
  $el: target,
256
544
  unmount() {
257
- var _a;
258
- (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, null);
259
- unregisterFileViewerZoomProvider(root);
260
- resizeObserver.disconnect();
261
- image.removeEventListener('load', updateViewportSize);
262
- image.removeEventListener('click', openLightbox);
263
- lightbox.destroy();
264
- target.replaceChildren();
545
+ cleanup();
265
546
  },
266
547
  };
267
548
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/renderer-image",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone image renderer plugin for File Viewer with native image preview, HEIC/HEIF conversion, lightbox, and unified zoom support.",
@@ -54,7 +54,7 @@
54
54
  "LICENSE"
55
55
  ],
56
56
  "dependencies": {
57
- "@file-viewer/core": "2.2.3",
57
+ "@file-viewer/core": "2.2.5",
58
58
  "heic2any": "^0.0.4"
59
59
  },
60
60
  "devDependencies": {