@file-viewer/renderer-image 2.2.4 → 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');
@@ -94,38 +109,101 @@ const waitForImageReady = async (image) => {
94
109
  const roundImageScale = (value) => {
95
110
  return Number(value.toFixed(3));
96
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
+ };
97
126
  const createLightbox = (documentRef, src, t) => {
98
127
  const lightbox = documentRef.createElement('div');
99
128
  lightbox.className = 'image-lightbox';
100
- lightbox.hidden = true;
129
+ lightbox.dataset.open = 'false';
101
130
  lightbox.setAttribute('role', 'dialog');
102
131
  lightbox.setAttribute('aria-modal', 'true');
132
+ lightbox.setAttribute('aria-hidden', 'true');
103
133
  const image = documentRef.createElement('img');
104
134
  image.alt = t('image.lightbox.alt');
105
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);
106
157
  const closeButton = documentRef.createElement('button');
107
158
  closeButton.type = 'button';
108
159
  closeButton.setAttribute('aria-label', t('image.lightbox.close'));
109
- closeButton.textContent = 'x';
160
+ closeButton.textContent = '×';
161
+ let previousFocus = null;
110
162
  const close = () => {
111
- 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
+ }
112
177
  };
113
178
  closeButton.addEventListener('click', close);
114
- image.addEventListener('click', close);
115
179
  lightbox.addEventListener('click', event => {
116
180
  if (event.target === lightbox) {
117
181
  close();
118
182
  }
119
183
  });
184
+ documentRef.addEventListener('keydown', onKeyDown);
120
185
  lightbox.append(image, closeButton);
121
186
  return {
122
187
  element: lightbox,
123
- open() {
124
- 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 });
125
201
  },
126
202
  destroy() {
127
203
  closeButton.removeEventListener('click', close);
128
- 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);
129
207
  lightbox.remove();
130
208
  },
131
209
  };
@@ -138,16 +216,44 @@ export default async function renderImage(buffer, target, type, context) {
138
216
  let userZoom = 1;
139
217
  let fitScale = 1;
140
218
  let currentScale = 1;
219
+ let currentRotation = 0;
141
220
  let viewportHeight = 0;
221
+ let scrollStateFrame = 0;
222
+ let destroyed = false;
142
223
  const zoomEmitter = createZoomChangeEmitter();
224
+ const viewStateEmitter = createFileViewerViewStateChangeEmitter();
143
225
  const root = documentRef.createElement('div');
144
226
  root.className = 'image-viewer';
145
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);
146
247
  const stage = documentRef.createElement('div');
147
248
  stage.className = 'image-stage';
249
+ const frame = documentRef.createElement('div');
250
+ frame.className = 'image-frame';
148
251
  const image = documentRef.createElement('img');
149
252
  image.alt = t('image.alt');
150
253
  image.src = src;
254
+ image.tabIndex = 0;
255
+ image.setAttribute('role', 'button');
256
+ image.setAttribute('aria-haspopup', 'dialog');
151
257
  const normalizedType = (type || '').trim().toLowerCase();
152
258
  (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, {
153
259
  capture: () => normalizedType === 'heic' || normalizedType === 'heif'
@@ -155,11 +261,19 @@ export default async function renderImage(buffer, target, type, context) {
155
261
  : new Blob([buffer], { type: getImageBlobType(normalizedType) }),
156
262
  getTarget: () => image,
157
263
  });
158
- stage.append(image);
159
- root.append(stage);
264
+ frame.append(image);
265
+ stage.append(frame);
266
+ root.append(toolbar, stage);
160
267
  const lightbox = createLightbox(documentRef, src, t);
161
- 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
+ };
162
275
  image.addEventListener('click', openLightbox);
276
+ image.addEventListener('keydown', openLightboxFromKeyboard);
163
277
  const getMinScale = () => Math.min(0.1, fitScale || 0.1);
164
278
  const clampScale = (value) => {
165
279
  const minScale = getMinScale();
@@ -173,20 +287,30 @@ export default async function renderImage(buffer, target, type, context) {
173
287
  }
174
288
  const availableWidth = Math.max((root.clientWidth || 0) - 48, 1);
175
289
  const availableHeight = Math.max((root.clientHeight || viewportHeight || 0) - 48, 1);
176
- 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);
177
294
  };
178
295
  const applyImageZoom = () => {
179
296
  fitScale = computeFitScale();
180
297
  currentScale = clampScale(fitScale * userZoom);
181
298
  if (image.naturalWidth && image.naturalHeight) {
182
- image.style.width = `${Math.max(1, Math.round(image.naturalWidth * currentScale))}px`;
183
- 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`);
184
305
  return;
185
306
  }
186
307
  image.style.width = 'auto';
187
308
  image.style.height = viewportHeight > 0
188
309
  ? `${Math.max(1, Math.round(viewportHeight * userZoom))}px`
189
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`);
190
314
  };
191
315
  const updateViewportSize = () => {
192
316
  viewportHeight = root.clientHeight || 0;
@@ -205,13 +329,58 @@ export default async function renderImage(buffer, target, type, context) {
205
329
  minScale: getMinScale(),
206
330
  maxScale: 5,
207
331
  });
208
- 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) => {
209
361
  const nextScale = clampScale(scale);
210
362
  userZoom = nextScale / Math.max(fitScale, 0.001);
211
363
  applyImageZoom();
212
364
  zoomEmitter.emit();
365
+ if (notifyViewState) {
366
+ emitViewStateChange(action, source);
367
+ }
213
368
  return getZoomState();
214
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
+ };
215
384
  const fitImage = (request) => {
216
385
  var _a, _b;
217
386
  const naturalWidth = image.naturalWidth || 0;
@@ -227,12 +396,13 @@ export default async function renderImage(buffer, target, type, context) {
227
396
  };
228
397
  }
229
398
  const mode = request.mode === 'auto' ? 'scale-down' : request.mode;
399
+ const swapsAxes = currentRotation % 180 !== 0;
230
400
  const scale = resolveFileViewerFitScale({
231
401
  mode,
232
402
  viewportWidth: Math.max(1, request.viewportWidth || root.clientWidth || 0),
233
403
  viewportHeight: Math.max(1, request.viewportHeight || root.clientHeight || viewportHeight || 0),
234
- contentWidth: naturalWidth,
235
- contentHeight: naturalHeight,
404
+ contentWidth: swapsAxes ? naturalHeight : naturalWidth,
405
+ contentHeight: swapsAxes ? naturalWidth : naturalHeight,
236
406
  currentScale,
237
407
  minScale: (_a = request.minScale) !== null && _a !== void 0 ? _a : getMinScale(),
238
408
  maxScale: (_b = request.maxScale) !== null && _b !== void 0 ? _b : 5,
@@ -247,7 +417,7 @@ export default async function renderImage(buffer, target, type, context) {
247
417
  provider: 'zoom',
248
418
  };
249
419
  }
250
- const state = setZoom(scale);
420
+ const state = setZoom(scale, 'fit', request.source);
251
421
  return {
252
422
  applied: true,
253
423
  mode: request.mode,
@@ -257,13 +427,64 @@ export default async function renderImage(buffer, target, type, context) {
257
427
  provider: 'zoom',
258
428
  };
259
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
+ };
260
480
  registerFileViewerZoomProvider(root, {
261
- zoomIn: () => setZoom(currentScale + 0.15),
262
- zoomOut: () => setZoom(currentScale - 0.15),
481
+ zoomIn: () => setZoom(currentScale + 0.15, 'zoom-in', 'api'),
482
+ zoomOut: () => setZoom(currentScale - 0.15, 'zoom-out', 'api'),
263
483
  resetZoom: () => {
264
484
  userZoom = 1;
265
485
  applyImageZoom();
266
486
  zoomEmitter.emit();
487
+ emitViewStateChange('zoom-reset', 'api');
267
488
  return getZoomState();
268
489
  },
269
490
  setZoom,
@@ -271,22 +492,48 @@ export default async function renderImage(buffer, target, type, context) {
271
492
  getState: getZoomState,
272
493
  subscribe: zoomEmitter.subscribe,
273
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 });
274
510
  target.replaceChildren(createStyle(documentRef), root);
275
511
  (((_b = context === null || context === void 0 ? void 0 : context.surface) === null || _b === void 0 ? void 0 : _b.shadowRoot) || target).append(lightbox.element);
276
512
  updateViewportSize();
277
513
  const cleanup = () => {
278
514
  var _a;
515
+ destroyed = true;
279
516
  (_a = context === null || context === void 0 ? void 0 : context.registerThumbnailAdapter) === null || _a === void 0 ? void 0 : _a.call(context, null);
517
+ unregisterFileViewerViewStateProvider(root);
280
518
  unregisterFileViewerZoomProvider(root);
519
+ if (scrollStateFrame && (targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.cancelAnimationFrame)) {
520
+ targetWindow.cancelAnimationFrame(scrollStateFrame);
521
+ scrollStateFrame = 0;
522
+ }
281
523
  resizeObserver.disconnect();
524
+ root.removeEventListener('scroll', scheduleScrollViewStateChange);
525
+ rotateLeftButton.removeEventListener('click', rotateLeft);
526
+ rotateRightButton.removeEventListener('click', rotateRight);
282
527
  image.removeEventListener('load', updateViewportSize);
283
528
  image.removeEventListener('click', openLightbox);
529
+ image.removeEventListener('keydown', openLightboxFromKeyboard);
284
530
  lightbox.destroy();
285
531
  target.replaceChildren();
286
532
  };
287
533
  try {
288
534
  await waitForImageReady(image);
289
535
  updateViewportSize();
536
+ targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.requestAnimationFrame(() => emitViewStateChange('init', 'viewer'));
290
537
  }
291
538
  catch (error) {
292
539
  cleanup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/renderer-image",
3
- "version": "2.2.4",
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.4",
57
+ "@file-viewer/core": "2.2.5",
58
58
  "heic2any": "^0.0.4"
59
59
  },
60
60
  "devDependencies": {