Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { addDisposableListener, clearNode, Dimension, EventType, h } from '../../../../base/browser/dom.js';
7
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
8
import { KeyCode } from '../../../../base/common/keyCodes.js';
9
import { CancellationToken } from '../../../../base/common/cancellation.js';
10
import { DisposableStore } from '../../../../base/common/lifecycle.js';
11
import { clamp } from '../../../../base/common/numbers.js';
12
import { isMacintosh } from '../../../../base/common/platform.js';
13
import { generateUuid } from '../../../../base/common/uuid.js';
14
import { localize } from '../../../../nls.js';
15
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
16
import { IFileService } from '../../../../platform/files/common/files.js';
17
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
18
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
19
import { IEditorOpenContext } from '../../../common/editor.js';
20
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
21
import { IStorageService } from '../../../../platform/storage/common/storage.js';
22
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
23
import { IWebviewElement, IWebviewService } from '../../webview/browser/webview.js';
24
import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js';
25
import { ICarouselImage, ICarouselSection, isVideoMimeType } from './imageCarouselTypes.js';
26
27
/**
28
* A flat entry referencing a specific image within a section, used
29
* for global index-based navigation across all sections.
30
*/
31
interface IFlatImageEntry {
32
readonly sectionIndex: number;
33
readonly imageIndexInSection: number;
34
readonly image: ICarouselImage;
35
}
36
37
type ZoomScale = number | 'fit';
38
39
const SCALE_PINCH_FACTOR = 0.075;
40
const MAX_SCALE = 20;
41
const MIN_SCALE = 0.1;
42
const PIXELATION_THRESHOLD = 3;
43
const ZOOM_LEVELS = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.5, 2, 3, 5, 7, 10, 15, 20];
44
45
export class ImageCarouselEditor extends EditorPane {
46
static readonly ID = 'workbench.editor.imageCarousel';
47
48
private _container: HTMLElement | undefined;
49
private _currentIndex: number = 0;
50
private _zoomScale: ZoomScale = 'fit';
51
private _sections: ReadonlyArray<ICarouselSection> = [];
52
private _flatImages: IFlatImageEntry[] = [];
53
private readonly _contentDisposables = this._register(new DisposableStore());
54
private readonly _imageDisposables = this._register(new DisposableStore());
55
private readonly _blobUrlCache = new Map<string, string>();
56
57
private _videoWebview: IWebviewElement | undefined;
58
private _elements: {
59
root: HTMLElement;
60
imageArea: HTMLElement;
61
mainImageContainer: HTMLElement;
62
mainImage: HTMLImageElement;
63
videoContainer: HTMLElement;
64
captionText: HTMLElement;
65
captionSeparator: HTMLElement;
66
counter: HTMLElement;
67
ariaStatus: HTMLElement;
68
prevBtn: HTMLButtonElement;
69
nextBtn: HTMLButtonElement;
70
sectionsContainer: HTMLElement;
71
} | undefined;
72
private _thumbnailElements: HTMLElement[] = [];
73
74
constructor(
75
group: IEditorGroup,
76
@ITelemetryService telemetryService: ITelemetryService,
77
@IThemeService themeService: IThemeService,
78
@IStorageService storageService: IStorageService,
79
@IFileService private readonly _fileService: IFileService,
80
@IWebviewService private readonly _webviewService: IWebviewService
81
) {
82
super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService);
83
}
84
85
protected override createEditor(parent: HTMLElement): void {
86
this._container = h('div.image-carousel-editor').root;
87
parent.appendChild(this._container);
88
}
89
90
override async setInput(input: ImageCarouselEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
91
await super.setInput(input, options, context, token);
92
93
this._sections = input.collection.sections;
94
this._flatImages = [];
95
for (let s = 0; s < this._sections.length; s++) {
96
for (let i = 0; i < this._sections[s].images.length; i++) {
97
this._flatImages.push({ sectionIndex: s, imageIndexInSection: i, image: this._sections[s].images[i] });
98
}
99
}
100
this._currentIndex = Math.min(input.startIndex, Math.max(0, this._flatImages.length - 1));
101
this.buildSlideshow();
102
}
103
104
override clearInput(): void {
105
this._videoWebview?.dispose();
106
this._videoWebview = undefined;
107
this._contentDisposables.clear();
108
this._imageDisposables.clear();
109
this._revokeCachedBlobUrls();
110
this._zoomScale = 'fit';
111
if (this._container) {
112
clearNode(this._container);
113
}
114
this._elements = undefined;
115
this._thumbnailElements = [];
116
super.clearInput();
117
}
118
119
private _isCurrentVideo(): boolean {
120
const entry = this._flatImages[this._currentIndex];
121
return !!entry && isVideoMimeType(entry.image.mimeType);
122
}
123
124
/**
125
* Build the full DOM skeleton. Called once per setInput.
126
*/
127
private buildSlideshow(): void {
128
if (!this._container) {
129
return;
130
}
131
132
this._contentDisposables.clear();
133
this._imageDisposables.clear();
134
this._revokeCachedBlobUrls();
135
clearNode(this._container);
136
137
if (this._flatImages.length === 0) {
138
const empty = h('div.empty-message');
139
empty.root.textContent = localize('imageCarousel.noImages', "No images to display");
140
this._container.appendChild(empty.root);
141
return;
142
}
143
144
const elements = h('div.slideshow-container', [
145
h('div.image-area@imageArea', [
146
h('div.main-image-container@mainImageContainer', [
147
h('img.main-image@mainImage'),
148
h('div.video-container@videoContainer'),
149
]),
150
h('button.nav-arrow.prev-arrow@prevBtn', { ariaLabel: localize('imageCarousel.previousImage', "Previous image") }, [
151
h('span.codicon.codicon-chevron-left', { ariaHidden: 'true' }),
152
]),
153
h('button.nav-arrow.next-arrow@nextBtn', { ariaLabel: localize('imageCarousel.nextImage', "Next image") }, [
154
h('span.codicon.codicon-chevron-right', { ariaHidden: 'true' }),
155
]),
156
]),
157
h('div.bottom-bar@bottomBar', [
158
h('div.image-info-bar', [
159
h('span.caption-text@captionText'),
160
h('span.caption-separator@captionSeparator'),
161
h('span.image-counter@counter'),
162
]),
163
h('div.sections-container@sectionsContainer'),
164
h('span.sr-only@ariaStatus'),
165
]),
166
]);
167
168
// ARIA: set up slideshow container for screen readers
169
elements.root.setAttribute('role', 'group');
170
elements.root.setAttribute('aria-label', localize('imageCarousel.ariaLabel', "Images Preview"));
171
elements.captionSeparator.setAttribute('aria-hidden', 'true');
172
elements.ariaStatus.setAttribute('aria-live', 'polite');
173
elements.ariaStatus.setAttribute('aria-atomic', 'true');
174
elements.sectionsContainer.setAttribute('role', 'group');
175
elements.sectionsContainer.setAttribute('aria-label', localize('imageCarousel.thumbnails', "Image thumbnails"));
176
177
this._elements = {
178
root: elements.root,
179
imageArea: elements.imageArea,
180
mainImageContainer: elements.mainImageContainer,
181
mainImage: elements.mainImage as HTMLImageElement,
182
videoContainer: elements.videoContainer,
183
captionText: elements.captionText,
184
captionSeparator: elements.captionSeparator,
185
counter: elements.counter,
186
ariaStatus: elements.ariaStatus,
187
prevBtn: elements.prevBtn as HTMLButtonElement,
188
nextBtn: elements.nextBtn as HTMLButtonElement,
189
sectionsContainer: elements.sectionsContainer,
190
};
191
192
// Initialize image in fit mode
193
this._elements.mainImage.classList.add('scale-to-fit');
194
this._elements.mainImage.alt = '';
195
196
// Hide video container initially
197
this._elements.videoContainer.style.display = 'none';
198
199
// Navigation listeners
200
this._contentDisposables.add(addDisposableListener(this._elements.prevBtn, 'click', () => {
201
if (this._currentIndex > 0) {
202
this._currentIndex--;
203
this.updateCurrentImage();
204
}
205
}));
206
this._contentDisposables.add(addDisposableListener(this._elements.nextBtn, 'click', () => {
207
if (this._currentIndex < this._flatImages.length - 1) {
208
this._currentIndex++;
209
this.updateCurrentImage();
210
}
211
}));
212
213
// Keyboard navigation
214
this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_DOWN, e => {
215
const event = new StandardKeyboardEvent(e);
216
if (event.keyCode === KeyCode.LeftArrow) {
217
this.previous();
218
event.stopPropagation();
219
event.preventDefault();
220
} else if (event.keyCode === KeyCode.RightArrow) {
221
this.next();
222
event.stopPropagation();
223
event.preventDefault();
224
}
225
}));
226
elements.root.tabIndex = 0;
227
228
// Zoom: scroll wheel + modifier key (Ctrl on Win/Linux, Alt on Mac) or pinch
229
this._contentDisposables.add(addDisposableListener(this._elements.imageArea, EventType.MOUSE_WHEEL, (e: WheelEvent) => {
230
if (this._isCurrentVideo()) {
231
return;
232
}
233
const isZoomModifier = isMacintosh ? e.altKey : e.ctrlKey;
234
if (!isZoomModifier && !e.ctrlKey) {
235
return;
236
}
237
e.preventDefault();
238
239
if (e.deltaY === 0) {
240
return;
241
}
242
243
if (this._zoomScale === 'fit') {
244
this._initZoomFromFit();
245
}
246
247
const delta = e.deltaY > 0 ? 1 : -1;
248
this._applyZoom((this._zoomScale as number) * (1 - delta * SCALE_PINCH_FACTOR));
249
}, { passive: false }));
250
251
// Zoom: single click to zoom in/out (like image preview)
252
// Track modifier keys at mousedown time
253
let clickCtrlPressed = false;
254
let clickAltPressed = false;
255
this._contentDisposables.add(addDisposableListener(this._elements.mainImageContainer, EventType.MOUSE_DOWN, (e: MouseEvent) => {
256
if (e.button !== 0) {
257
return;
258
}
259
clickCtrlPressed = e.ctrlKey;
260
clickAltPressed = e.altKey;
261
}));
262
this._contentDisposables.add(addDisposableListener(this._elements.mainImageContainer, EventType.CLICK, (e: MouseEvent) => {
263
if (e.button !== 0 || this._isCurrentVideo()) {
264
return;
265
}
266
const isZoomOut = isMacintosh ? clickAltPressed : clickCtrlPressed;
267
if (isZoomOut) {
268
this._zoomOut();
269
} else {
270
this._zoomIn();
271
}
272
}));
273
274
// Update zoom-out cursor class when modifier key is held
275
const updateZoomCursor = (e: KeyboardEvent) => {
276
const isZoomOut = isMacintosh ? e.altKey : e.ctrlKey;
277
this._elements!.mainImageContainer.classList.toggle('zoom-out', isZoomOut);
278
};
279
this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_DOWN, updateZoomCursor));
280
this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_UP, updateZoomCursor));
281
282
// Build section thumbnails
283
this._thumbnailElements = [];
284
let flatIndex = 0;
285
for (let s = 0; s < this._sections.length; s++) {
286
const section = this._sections[s];
287
288
// Add separator between sections (not before the first)
289
if (s > 0 && this._sections.length > 1) {
290
const separator = h('div.thumbnail-separator').root;
291
separator.setAttribute('aria-hidden', 'true');
292
this._elements.sectionsContainer.appendChild(separator);
293
}
294
295
for (let i = 0; i < section.images.length; i++) {
296
const image = section.images[i];
297
const currentFlatIndex = flatIndex;
298
const isItemVideo = isVideoMimeType(image.mimeType);
299
300
const btn = document.createElement('button');
301
btn.className = isItemVideo ? 'thumbnail video-thumbnail' : 'thumbnail';
302
btn.ariaLabel = isItemVideo
303
? localize('imageCarousel.thumbnailLabelVideo', "Video {0} of {1}", currentFlatIndex + 1, this._flatImages.length)
304
: localize('imageCarousel.thumbnailLabelImage', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length);
305
306
if (isItemVideo) {
307
const icon = h('span.codicon.codicon-play.thumbnail-play-icon');
308
icon.root.setAttribute('aria-hidden', 'true');
309
btn.appendChild(icon.root);
310
} else {
311
const img = document.createElement('img');
312
img.className = 'thumbnail-image';
313
img.alt = image.name;
314
const thumbnailDisposables = this._contentDisposables.add(new DisposableStore());
315
316
const markBroken = () => {
317
if (thumbnailDisposables.isDisposed) {
318
return;
319
}
320
321
if (!btn.classList.contains('broken')) {
322
btn.classList.add('broken');
323
img.removeAttribute('src');
324
img.alt = '';
325
img.remove();
326
const fallback = h('span.codicon.codicon-warning.thumbnail-broken-icon');
327
fallback.root.setAttribute('aria-hidden', 'true');
328
btn.appendChild(fallback.root);
329
}
330
};
331
332
this._loadBlobUrl(image).then(url => {
333
if (thumbnailDisposables.isDisposed) {
334
return;
335
}
336
337
if (url) {
338
const preloader = new Image();
339
thumbnailDisposables.add(addDisposableListener(preloader, 'load', () => {
340
if (btn.classList.contains('broken')) {
341
return;
342
}
343
img.src = url;
344
if (!img.parentElement) {
345
btn.appendChild(img);
346
}
347
}));
348
thumbnailDisposables.add(addDisposableListener(preloader, 'error', () => {
349
markBroken();
350
}));
351
preloader.src = url;
352
} else {
353
markBroken();
354
}
355
}, () => {
356
markBroken();
357
});
358
thumbnailDisposables.add(addDisposableListener(img, 'error', () => {
359
markBroken();
360
}));
361
}
362
363
this._contentDisposables.add(addDisposableListener(btn, 'click', () => {
364
this._currentIndex = currentFlatIndex;
365
this.updateCurrentImage();
366
}));
367
368
this._elements.sectionsContainer.appendChild(btn);
369
this._thumbnailElements.push(btn);
370
flatIndex++;
371
}
372
}
373
374
this._container.appendChild(elements.root);
375
376
// Set initial image
377
this.updateCurrentImage();
378
}
379
380
/**
381
* Update only the changing parts: main image src, caption, button states, thumbnail selection.
382
* No DOM teardown/rebuild — eliminates the blank flash.
383
*/
384
private async updateCurrentImage(): Promise<void> {
385
if (!this._elements) {
386
return;
387
}
388
389
// Capture the navigation index before starting async work so that
390
// we can discard stale results if the user navigates while loading/decoding.
391
const navigationIndex = this._currentIndex;
392
393
// Swap main image using cached/lazy-loaded blob URL.
394
// Pre-decode via decode() before assigning to <img> so the browser
395
// decodes on a worker thread, avoiding main-thread stalls during commit.
396
const entry = this._flatImages[navigationIndex];
397
const currentImage = entry.image;
398
const isVideo = isVideoMimeType(currentImage.mimeType);
399
400
if (isVideo) {
401
// Show video container, hide image
402
this._elements.mainImage.style.display = 'none';
403
this._elements.videoContainer.style.display = '';
404
this._elements.mainImageContainer.classList.remove('zoomed');
405
this._elements.mainImageContainer.style.cursor = 'default';
406
407
// Load raw data to send via postMessage
408
const rawData = await this._loadRawData(currentImage);
409
if (this._currentIndex !== navigationIndex) {
410
return;
411
}
412
413
const nonce = generateUuid();
414
const videoHtml = `<!DOCTYPE html>
415
<html><head>
416
<meta charset="utf-8">
417
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; media-src blob: data:; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}';">
418
<style nonce="${nonce}">html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:transparent}
419
video{width:100%;height:100%;object-fit:contain;outline:none}</style>
420
</head><body>
421
<video id="v" controls></video>
422
<script nonce="${nonce}">
423
window.addEventListener("message",function(e){var m=e.data;if(m.type==="loadVideo"){var b=new Blob([m.data],{type:m.mimeType});document.getElementById("v").src=URL.createObjectURL(b);}});
424
</script>
425
</body></html>`;
426
427
// Reuse existing webview or create one on first video navigation
428
let webview: IWebviewElement;
429
if (!this._videoWebview) {
430
webview = this._contentDisposables.add(this._webviewService.createWebviewElement({
431
title: currentImage.name,
432
options: { disableServiceWorker: true },
433
contentOptions: { allowScripts: true },
434
extension: undefined,
435
}));
436
webview.mountTo(this._elements.videoContainer, this.window);
437
this._videoWebview = webview;
438
} else {
439
webview = this._videoWebview;
440
}
441
442
webview.setHtml(videoHtml);
443
444
// Send the video data to the webview via postMessage
445
const buffer = (rawData as Uint8Array<ArrayBuffer>).buffer;
446
webview.postMessage({ type: 'loadVideo', data: buffer, mimeType: currentImage.mimeType }, [buffer]);
447
} else {
448
// Show image, hide video container
449
this._elements.videoContainer.style.display = 'none';
450
this._elements.mainImage.style.display = '';
451
this._elements.mainImageContainer.style.cursor = '';
452
453
const url = await this._loadBlobUrl(currentImage);
454
455
// If the user navigated while loading the blob URL, discard this result.
456
if (this._currentIndex !== navigationIndex) {
457
return;
458
}
459
460
const tmp = new Image();
461
tmp.src = url;
462
tmp.decode().then(() => {
463
// Only apply if user hasn't navigated away during decode
464
if (this._currentIndex === navigationIndex && this._elements) {
465
this._elements.mainImage.src = url;
466
this._elements.mainImage.alt = currentImage.name;
467
}
468
}, () => {
469
// Decode failed (invalid image) — still show src for browser fallback
470
if (this._currentIndex === navigationIndex && this._elements) {
471
this._elements.mainImage.src = url;
472
this._elements.mainImage.alt = currentImage.name;
473
}
474
});
475
}
476
477
// Reset zoom when switching images
478
this._applyZoom('fit');
479
480
// Update info bar: caption + separator + counter
481
if (currentImage.caption) {
482
this._elements.captionText.textContent = currentImage.caption;
483
this._elements.captionText.style.display = '';
484
this._elements.captionSeparator.style.display = '';
485
} else {
486
this._elements.captionText.textContent = '';
487
this._elements.captionText.style.display = 'none';
488
this._elements.captionSeparator.style.display = 'none';
489
}
490
this._elements.counter.textContent = localize('imageCarousel.counter', "{0} / {1}", navigationIndex + 1, this._flatImages.length);
491
492
// Announce to screen readers with full context (position + caption/name)
493
const itemKind = isVideo
494
? localize('imageCarousel.kindVideo', "Video")
495
: localize('imageCarousel.kindImage', "Image");
496
this._elements.ariaStatus.textContent = currentImage.caption
497
? localize('imageCarousel.statusWithCaption', "{0} {1} of {2}: {3}", itemKind, navigationIndex + 1, this._flatImages.length, currentImage.caption)
498
: localize('imageCarousel.statusWithName', "{0} {1} of {2}: {3}", itemKind, navigationIndex + 1, this._flatImages.length, currentImage.name);
499
500
// Update button states
501
this._elements.prevBtn.disabled = navigationIndex === 0;
502
this._elements.nextBtn.disabled = navigationIndex === this._flatImages.length - 1;
503
504
// Update thumbnail selection — only toggle active class and
505
// call getBoundingClientRect on the active thumbnail to avoid
506
// layout thrashing across all thumbnails on every navigation.
507
for (let i = 0; i < this._thumbnailElements.length; i++) {
508
const isActive = i === navigationIndex;
509
const thumbnail = this._thumbnailElements[i];
510
thumbnail.classList.toggle('active', isActive);
511
if (isActive) {
512
thumbnail.setAttribute('aria-current', 'page');
513
} else {
514
thumbnail.removeAttribute('aria-current');
515
}
516
}
517
518
// Scroll the active thumbnail into view without blocking the main thread.
519
// Using scrollIntoView with 'nearest' avoids forced layout from
520
// getBoundingClientRect + scrollLeft and is handled efficiently by
521
// the browser's scroll machinery.
522
const activeThumbnail = this._thumbnailElements[navigationIndex];
523
if (activeThumbnail) {
524
activeThumbnail.scrollIntoView({ block: 'nearest', inline: 'nearest' });
525
}
526
527
// Update editor title to reflect current section
528
if (this.input instanceof ImageCarouselEditorInput) {
529
const currentSection = this._sections[entry.sectionIndex];
530
this.input.setName(currentSection.title || this.input.collection.title);
531
}
532
533
// Preload adjacent images for smoother navigation
534
this._preloadAdjacentImages();
535
}
536
537
private async _loadBlobUrl(image: ICarouselImage): Promise<string> {
538
const cached = this._blobUrlCache.get(image.id);
539
if (cached) {
540
return cached;
541
}
542
543
let buffer: Uint8Array;
544
if (image.data) {
545
// Handle both VSBuffer (has .buffer property) and raw Uint8Array from chat attachments
546
buffer = image.data instanceof Uint8Array ? image.data : image.data.buffer;
547
} else if (image.uri) {
548
const content = await this._fileService.readFile(image.uri);
549
buffer = content.value.buffer;
550
} else {
551
return '';
552
}
553
554
const blob = new Blob([buffer as Uint8Array<ArrayBuffer>], { type: image.mimeType });
555
const url = URL.createObjectURL(blob);
556
this._blobUrlCache.set(image.id, url);
557
return url;
558
}
559
560
private _revokeCachedBlobUrls(): void {
561
for (const url of this._blobUrlCache.values()) {
562
URL.revokeObjectURL(url);
563
}
564
this._blobUrlCache.clear();
565
}
566
567
private async _loadRawData(image: ICarouselImage): Promise<Uint8Array> {
568
if (image.data) {
569
return image.data instanceof Uint8Array ? image.data : image.data.buffer;
570
} else if (image.uri) {
571
const content = await this._fileService.readFile(image.uri);
572
return content.value.buffer;
573
}
574
return new Uint8Array(0);
575
}
576
577
private _preloadAdjacentImages(): void {
578
for (const idx of [this._currentIndex - 1, this._currentIndex + 1]) {
579
if (idx >= 0 && idx < this._flatImages.length) {
580
const adjacentImage = this._flatImages[idx].image;
581
if (isVideoMimeType(adjacentImage.mimeType)) {
582
// For video, preload raw data into the file service cache
583
this._loadRawData(adjacentImage).catch(() => { /* ignore */ });
584
} else {
585
this._loadBlobUrl(adjacentImage).then(url => {
586
// Pre-decode via decode() so the compositor doesn't block
587
// the main thread decoding this image during commit.
588
const img = new Image();
589
img.src = url;
590
img.decode().catch(() => { /* invalid image */ });
591
});
592
}
593
}
594
}
595
}
596
597
previous(): void {
598
if (this._currentIndex > 0) {
599
this._currentIndex--;
600
this.updateCurrentImage();
601
}
602
}
603
604
next(): void {
605
if (this._currentIndex < this._flatImages.length - 1) {
606
this._currentIndex++;
607
this.updateCurrentImage();
608
}
609
}
610
611
/**
612
* Compute the current display scale when transitioning from 'fit' to numeric zoom.
613
*/
614
private _initZoomFromFit(): void {
615
if (!this._elements) {
616
return;
617
}
618
const img = this._elements.mainImage;
619
if (img.naturalWidth > 0) {
620
this._zoomScale = img.clientWidth / img.naturalWidth;
621
} else {
622
this._zoomScale = 1;
623
}
624
}
625
626
/**
627
* Zoom in to the next predefined zoom level.
628
*/
629
private _zoomIn(): void {
630
if (this._zoomScale === 'fit') {
631
this._initZoomFromFit();
632
}
633
const scale = this._zoomScale as number;
634
let i = 0;
635
for (; i < ZOOM_LEVELS.length; ++i) {
636
if (ZOOM_LEVELS[i] > scale) {
637
break;
638
}
639
}
640
this._applyZoom(ZOOM_LEVELS[i] ?? MAX_SCALE);
641
}
642
643
/**
644
* Zoom out to the previous predefined zoom level.
645
*/
646
private _zoomOut(): void {
647
if (this._zoomScale === 'fit') {
648
this._initZoomFromFit();
649
}
650
const scale = this._zoomScale as number;
651
let i = ZOOM_LEVELS.length - 1;
652
for (; i >= 0; --i) {
653
if (ZOOM_LEVELS[i] < scale) {
654
break;
655
}
656
}
657
this._applyZoom(ZOOM_LEVELS[i] ?? MIN_SCALE);
658
}
659
660
/**
661
* Apply fit-to-container or numeric zoom with scroll-center preservation.
662
*/
663
private _applyZoom(newScale: ZoomScale): void {
664
if (!this._elements) {
665
return;
666
}
667
668
const container = this._elements.mainImageContainer;
669
const img = this._elements.mainImage;
670
671
if (newScale === 'fit') {
672
this._zoomScale = 'fit';
673
img.classList.add('scale-to-fit');
674
img.classList.remove('pixelated');
675
img.style.zoom = '';
676
// Remove zoomed/overflow before scrollTo to avoid an expensive
677
// synchronous ScrollLayer that blocks the main thread.
678
const wasZoomed = container.classList.contains('zoomed');
679
container.classList.remove('zoomed');
680
container.classList.remove('zoom-out');
681
if (wasZoomed) {
682
container.scrollTo(0, 0);
683
}
684
} else {
685
const scale = clamp(newScale, MIN_SCALE, MAX_SCALE);
686
this._zoomScale = scale;
687
688
// Capture scroll center ratio before changing zoom.
689
const dx = container.scrollWidth > 0
690
? (container.scrollLeft + container.clientWidth / 2) / container.scrollWidth
691
: 0.5;
692
const dy = container.scrollHeight > 0
693
? (container.scrollTop + container.clientHeight / 2) / container.scrollHeight
694
: 0.5;
695
696
img.classList.remove('scale-to-fit');
697
img.classList.toggle('pixelated', scale >= PIXELATION_THRESHOLD);
698
img.style.zoom = String(scale);
699
container.classList.add('zoomed');
700
701
// Restore scroll center — works because setting img.style.zoom triggers
702
// synchronous layout, so scrollWidth/scrollHeight reflect the new size.
703
const newScrollX = container.scrollWidth * dx - container.clientWidth / 2;
704
const newScrollY = container.scrollHeight * dy - container.clientHeight / 2;
705
container.scrollTo(newScrollX, newScrollY);
706
}
707
}
708
709
override focus(): void {
710
super.focus();
711
this._elements?.root.focus();
712
}
713
714
override layout(dimension: Dimension): void {
715
if (this._container) {
716
this._container.style.width = `${dimension.width}px`;
717
this._container.style.height = `${dimension.height}px`;
718
}
719
}
720
}
721
722