Path: blob/main/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { addDisposableListener, clearNode, Dimension, EventType, h } from '../../../../base/browser/dom.js';6import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';7import { KeyCode } from '../../../../base/common/keyCodes.js';8import { CancellationToken } from '../../../../base/common/cancellation.js';9import { DisposableStore } from '../../../../base/common/lifecycle.js';10import { clamp } from '../../../../base/common/numbers.js';11import { isMacintosh } from '../../../../base/common/platform.js';12import { generateUuid } from '../../../../base/common/uuid.js';13import { localize } from '../../../../nls.js';14import { IEditorOptions } from '../../../../platform/editor/common/editor.js';15import { IFileService } from '../../../../platform/files/common/files.js';16import { IThemeService } from '../../../../platform/theme/common/themeService.js';17import { EditorPane } from '../../../browser/parts/editor/editorPane.js';18import { IEditorOpenContext } from '../../../common/editor.js';19import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';20import { IStorageService } from '../../../../platform/storage/common/storage.js';21import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';22import { IWebviewElement, IWebviewService } from '../../webview/browser/webview.js';23import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js';24import { ICarouselImage, ICarouselSection, isVideoMimeType } from './imageCarouselTypes.js';2526/**27* A flat entry referencing a specific image within a section, used28* for global index-based navigation across all sections.29*/30interface IFlatImageEntry {31readonly sectionIndex: number;32readonly imageIndexInSection: number;33readonly image: ICarouselImage;34}3536type ZoomScale = number | 'fit';3738const SCALE_PINCH_FACTOR = 0.075;39const MAX_SCALE = 20;40const MIN_SCALE = 0.1;41const PIXELATION_THRESHOLD = 3;42const 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];4344export class ImageCarouselEditor extends EditorPane {45static readonly ID = 'workbench.editor.imageCarousel';4647private _container: HTMLElement | undefined;48private _currentIndex: number = 0;49private _zoomScale: ZoomScale = 'fit';50private _sections: ReadonlyArray<ICarouselSection> = [];51private _flatImages: IFlatImageEntry[] = [];52private readonly _contentDisposables = this._register(new DisposableStore());53private readonly _imageDisposables = this._register(new DisposableStore());54private readonly _blobUrlCache = new Map<string, string>();5556private _videoWebview: IWebviewElement | undefined;57private _elements: {58root: HTMLElement;59imageArea: HTMLElement;60mainImageContainer: HTMLElement;61mainImage: HTMLImageElement;62videoContainer: HTMLElement;63captionText: HTMLElement;64captionSeparator: HTMLElement;65counter: HTMLElement;66ariaStatus: HTMLElement;67prevBtn: HTMLButtonElement;68nextBtn: HTMLButtonElement;69sectionsContainer: HTMLElement;70} | undefined;71private _thumbnailElements: HTMLElement[] = [];7273constructor(74group: IEditorGroup,75@ITelemetryService telemetryService: ITelemetryService,76@IThemeService themeService: IThemeService,77@IStorageService storageService: IStorageService,78@IFileService private readonly _fileService: IFileService,79@IWebviewService private readonly _webviewService: IWebviewService80) {81super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService);82}8384protected override createEditor(parent: HTMLElement): void {85this._container = h('div.image-carousel-editor').root;86parent.appendChild(this._container);87}8889override async setInput(input: ImageCarouselEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {90await super.setInput(input, options, context, token);9192this._sections = input.collection.sections;93this._flatImages = [];94for (let s = 0; s < this._sections.length; s++) {95for (let i = 0; i < this._sections[s].images.length; i++) {96this._flatImages.push({ sectionIndex: s, imageIndexInSection: i, image: this._sections[s].images[i] });97}98}99this._currentIndex = Math.min(input.startIndex, Math.max(0, this._flatImages.length - 1));100this.buildSlideshow();101}102103override clearInput(): void {104this._videoWebview?.dispose();105this._videoWebview = undefined;106this._contentDisposables.clear();107this._imageDisposables.clear();108this._revokeCachedBlobUrls();109this._zoomScale = 'fit';110if (this._container) {111clearNode(this._container);112}113this._elements = undefined;114this._thumbnailElements = [];115super.clearInput();116}117118private _isCurrentVideo(): boolean {119const entry = this._flatImages[this._currentIndex];120return !!entry && isVideoMimeType(entry.image.mimeType);121}122123/**124* Build the full DOM skeleton. Called once per setInput.125*/126private buildSlideshow(): void {127if (!this._container) {128return;129}130131this._contentDisposables.clear();132this._imageDisposables.clear();133this._revokeCachedBlobUrls();134clearNode(this._container);135136if (this._flatImages.length === 0) {137const empty = h('div.empty-message');138empty.root.textContent = localize('imageCarousel.noImages', "No images to display");139this._container.appendChild(empty.root);140return;141}142143const elements = h('div.slideshow-container', [144h('div.image-area@imageArea', [145h('div.main-image-container@mainImageContainer', [146h('img.main-image@mainImage'),147h('div.video-container@videoContainer'),148]),149h('button.nav-arrow.prev-arrow@prevBtn', { ariaLabel: localize('imageCarousel.previousImage', "Previous image") }, [150h('span.codicon.codicon-chevron-left', { ariaHidden: 'true' }),151]),152h('button.nav-arrow.next-arrow@nextBtn', { ariaLabel: localize('imageCarousel.nextImage', "Next image") }, [153h('span.codicon.codicon-chevron-right', { ariaHidden: 'true' }),154]),155]),156h('div.bottom-bar@bottomBar', [157h('div.image-info-bar', [158h('span.caption-text@captionText'),159h('span.caption-separator@captionSeparator'),160h('span.image-counter@counter'),161]),162h('div.sections-container@sectionsContainer'),163h('span.sr-only@ariaStatus'),164]),165]);166167// ARIA: set up slideshow container for screen readers168elements.root.setAttribute('role', 'group');169elements.root.setAttribute('aria-label', localize('imageCarousel.ariaLabel', "Images Preview"));170elements.captionSeparator.setAttribute('aria-hidden', 'true');171elements.ariaStatus.setAttribute('aria-live', 'polite');172elements.ariaStatus.setAttribute('aria-atomic', 'true');173elements.sectionsContainer.setAttribute('role', 'group');174elements.sectionsContainer.setAttribute('aria-label', localize('imageCarousel.thumbnails', "Image thumbnails"));175176this._elements = {177root: elements.root,178imageArea: elements.imageArea,179mainImageContainer: elements.mainImageContainer,180mainImage: elements.mainImage as HTMLImageElement,181videoContainer: elements.videoContainer,182captionText: elements.captionText,183captionSeparator: elements.captionSeparator,184counter: elements.counter,185ariaStatus: elements.ariaStatus,186prevBtn: elements.prevBtn as HTMLButtonElement,187nextBtn: elements.nextBtn as HTMLButtonElement,188sectionsContainer: elements.sectionsContainer,189};190191// Initialize image in fit mode192this._elements.mainImage.classList.add('scale-to-fit');193this._elements.mainImage.alt = '';194195// Hide video container initially196this._elements.videoContainer.style.display = 'none';197198// Navigation listeners199this._contentDisposables.add(addDisposableListener(this._elements.prevBtn, 'click', () => {200if (this._currentIndex > 0) {201this._currentIndex--;202this.updateCurrentImage();203}204}));205this._contentDisposables.add(addDisposableListener(this._elements.nextBtn, 'click', () => {206if (this._currentIndex < this._flatImages.length - 1) {207this._currentIndex++;208this.updateCurrentImage();209}210}));211212// Keyboard navigation213this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_DOWN, e => {214const event = new StandardKeyboardEvent(e);215if (event.keyCode === KeyCode.LeftArrow) {216this.previous();217event.stopPropagation();218event.preventDefault();219} else if (event.keyCode === KeyCode.RightArrow) {220this.next();221event.stopPropagation();222event.preventDefault();223}224}));225elements.root.tabIndex = 0;226227// Zoom: scroll wheel + modifier key (Ctrl on Win/Linux, Alt on Mac) or pinch228this._contentDisposables.add(addDisposableListener(this._elements.imageArea, EventType.MOUSE_WHEEL, (e: WheelEvent) => {229if (this._isCurrentVideo()) {230return;231}232const isZoomModifier = isMacintosh ? e.altKey : e.ctrlKey;233if (!isZoomModifier && !e.ctrlKey) {234return;235}236e.preventDefault();237238if (e.deltaY === 0) {239return;240}241242if (this._zoomScale === 'fit') {243this._initZoomFromFit();244}245246const delta = e.deltaY > 0 ? 1 : -1;247this._applyZoom((this._zoomScale as number) * (1 - delta * SCALE_PINCH_FACTOR));248}, { passive: false }));249250// Zoom: single click to zoom in/out (like image preview)251// Track modifier keys at mousedown time252let clickCtrlPressed = false;253let clickAltPressed = false;254this._contentDisposables.add(addDisposableListener(this._elements.mainImageContainer, EventType.MOUSE_DOWN, (e: MouseEvent) => {255if (e.button !== 0) {256return;257}258clickCtrlPressed = e.ctrlKey;259clickAltPressed = e.altKey;260}));261this._contentDisposables.add(addDisposableListener(this._elements.mainImageContainer, EventType.CLICK, (e: MouseEvent) => {262if (e.button !== 0 || this._isCurrentVideo()) {263return;264}265const isZoomOut = isMacintosh ? clickAltPressed : clickCtrlPressed;266if (isZoomOut) {267this._zoomOut();268} else {269this._zoomIn();270}271}));272273// Update zoom-out cursor class when modifier key is held274const updateZoomCursor = (e: KeyboardEvent) => {275const isZoomOut = isMacintosh ? e.altKey : e.ctrlKey;276this._elements!.mainImageContainer.classList.toggle('zoom-out', isZoomOut);277};278this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_DOWN, updateZoomCursor));279this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_UP, updateZoomCursor));280281// Build section thumbnails282this._thumbnailElements = [];283let flatIndex = 0;284for (let s = 0; s < this._sections.length; s++) {285const section = this._sections[s];286287// Add separator between sections (not before the first)288if (s > 0 && this._sections.length > 1) {289const separator = h('div.thumbnail-separator').root;290separator.setAttribute('aria-hidden', 'true');291this._elements.sectionsContainer.appendChild(separator);292}293294for (let i = 0; i < section.images.length; i++) {295const image = section.images[i];296const currentFlatIndex = flatIndex;297const isItemVideo = isVideoMimeType(image.mimeType);298299const btn = document.createElement('button');300btn.className = isItemVideo ? 'thumbnail video-thumbnail' : 'thumbnail';301btn.ariaLabel = isItemVideo302? localize('imageCarousel.thumbnailLabelVideo', "Video {0} of {1}", currentFlatIndex + 1, this._flatImages.length)303: localize('imageCarousel.thumbnailLabelImage', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length);304305if (isItemVideo) {306const icon = h('span.codicon.codicon-play.thumbnail-play-icon');307icon.root.setAttribute('aria-hidden', 'true');308btn.appendChild(icon.root);309} else {310const img = document.createElement('img');311img.className = 'thumbnail-image';312img.alt = image.name;313const thumbnailDisposables = this._contentDisposables.add(new DisposableStore());314315const markBroken = () => {316if (thumbnailDisposables.isDisposed) {317return;318}319320if (!btn.classList.contains('broken')) {321btn.classList.add('broken');322img.removeAttribute('src');323img.alt = '';324img.remove();325const fallback = h('span.codicon.codicon-warning.thumbnail-broken-icon');326fallback.root.setAttribute('aria-hidden', 'true');327btn.appendChild(fallback.root);328}329};330331this._loadBlobUrl(image).then(url => {332if (thumbnailDisposables.isDisposed) {333return;334}335336if (url) {337const preloader = new Image();338thumbnailDisposables.add(addDisposableListener(preloader, 'load', () => {339if (btn.classList.contains('broken')) {340return;341}342img.src = url;343if (!img.parentElement) {344btn.appendChild(img);345}346}));347thumbnailDisposables.add(addDisposableListener(preloader, 'error', () => {348markBroken();349}));350preloader.src = url;351} else {352markBroken();353}354}, () => {355markBroken();356});357thumbnailDisposables.add(addDisposableListener(img, 'error', () => {358markBroken();359}));360}361362this._contentDisposables.add(addDisposableListener(btn, 'click', () => {363this._currentIndex = currentFlatIndex;364this.updateCurrentImage();365}));366367this._elements.sectionsContainer.appendChild(btn);368this._thumbnailElements.push(btn);369flatIndex++;370}371}372373this._container.appendChild(elements.root);374375// Set initial image376this.updateCurrentImage();377}378379/**380* Update only the changing parts: main image src, caption, button states, thumbnail selection.381* No DOM teardown/rebuild — eliminates the blank flash.382*/383private async updateCurrentImage(): Promise<void> {384if (!this._elements) {385return;386}387388// Capture the navigation index before starting async work so that389// we can discard stale results if the user navigates while loading/decoding.390const navigationIndex = this._currentIndex;391392// Swap main image using cached/lazy-loaded blob URL.393// Pre-decode via decode() before assigning to <img> so the browser394// decodes on a worker thread, avoiding main-thread stalls during commit.395const entry = this._flatImages[navigationIndex];396const currentImage = entry.image;397const isVideo = isVideoMimeType(currentImage.mimeType);398399if (isVideo) {400// Show video container, hide image401this._elements.mainImage.style.display = 'none';402this._elements.videoContainer.style.display = '';403this._elements.mainImageContainer.classList.remove('zoomed');404this._elements.mainImageContainer.style.cursor = 'default';405406// Load raw data to send via postMessage407const rawData = await this._loadRawData(currentImage);408if (this._currentIndex !== navigationIndex) {409return;410}411412const nonce = generateUuid();413const videoHtml = `<!DOCTYPE html>414<html><head>415<meta charset="utf-8">416<meta http-equiv="Content-Security-Policy" content="default-src 'none'; media-src blob: data:; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}';">417<style nonce="${nonce}">html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:transparent}418video{width:100%;height:100%;object-fit:contain;outline:none}</style>419</head><body>420<video id="v" controls></video>421<script nonce="${nonce}">422window.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);}});423</script>424</body></html>`;425426// Reuse existing webview or create one on first video navigation427let webview: IWebviewElement;428if (!this._videoWebview) {429webview = this._contentDisposables.add(this._webviewService.createWebviewElement({430title: currentImage.name,431options: { disableServiceWorker: true },432contentOptions: { allowScripts: true },433extension: undefined,434}));435webview.mountTo(this._elements.videoContainer, this.window);436this._videoWebview = webview;437} else {438webview = this._videoWebview;439}440441webview.setHtml(videoHtml);442443// Send the video data to the webview via postMessage444const buffer = (rawData as Uint8Array<ArrayBuffer>).buffer;445webview.postMessage({ type: 'loadVideo', data: buffer, mimeType: currentImage.mimeType }, [buffer]);446} else {447// Show image, hide video container448this._elements.videoContainer.style.display = 'none';449this._elements.mainImage.style.display = '';450this._elements.mainImageContainer.style.cursor = '';451452const url = await this._loadBlobUrl(currentImage);453454// If the user navigated while loading the blob URL, discard this result.455if (this._currentIndex !== navigationIndex) {456return;457}458459const tmp = new Image();460tmp.src = url;461tmp.decode().then(() => {462// Only apply if user hasn't navigated away during decode463if (this._currentIndex === navigationIndex && this._elements) {464this._elements.mainImage.src = url;465this._elements.mainImage.alt = currentImage.name;466}467}, () => {468// Decode failed (invalid image) — still show src for browser fallback469if (this._currentIndex === navigationIndex && this._elements) {470this._elements.mainImage.src = url;471this._elements.mainImage.alt = currentImage.name;472}473});474}475476// Reset zoom when switching images477this._applyZoom('fit');478479// Update info bar: caption + separator + counter480if (currentImage.caption) {481this._elements.captionText.textContent = currentImage.caption;482this._elements.captionText.style.display = '';483this._elements.captionSeparator.style.display = '';484} else {485this._elements.captionText.textContent = '';486this._elements.captionText.style.display = 'none';487this._elements.captionSeparator.style.display = 'none';488}489this._elements.counter.textContent = localize('imageCarousel.counter', "{0} / {1}", navigationIndex + 1, this._flatImages.length);490491// Announce to screen readers with full context (position + caption/name)492const itemKind = isVideo493? localize('imageCarousel.kindVideo', "Video")494: localize('imageCarousel.kindImage', "Image");495this._elements.ariaStatus.textContent = currentImage.caption496? localize('imageCarousel.statusWithCaption', "{0} {1} of {2}: {3}", itemKind, navigationIndex + 1, this._flatImages.length, currentImage.caption)497: localize('imageCarousel.statusWithName', "{0} {1} of {2}: {3}", itemKind, navigationIndex + 1, this._flatImages.length, currentImage.name);498499// Update button states500this._elements.prevBtn.disabled = navigationIndex === 0;501this._elements.nextBtn.disabled = navigationIndex === this._flatImages.length - 1;502503// Update thumbnail selection — only toggle active class and504// call getBoundingClientRect on the active thumbnail to avoid505// layout thrashing across all thumbnails on every navigation.506for (let i = 0; i < this._thumbnailElements.length; i++) {507const isActive = i === navigationIndex;508const thumbnail = this._thumbnailElements[i];509thumbnail.classList.toggle('active', isActive);510if (isActive) {511thumbnail.setAttribute('aria-current', 'page');512} else {513thumbnail.removeAttribute('aria-current');514}515}516517// Scroll the active thumbnail into view without blocking the main thread.518// Using scrollIntoView with 'nearest' avoids forced layout from519// getBoundingClientRect + scrollLeft and is handled efficiently by520// the browser's scroll machinery.521const activeThumbnail = this._thumbnailElements[navigationIndex];522if (activeThumbnail) {523activeThumbnail.scrollIntoView({ block: 'nearest', inline: 'nearest' });524}525526// Update editor title to reflect current section527if (this.input instanceof ImageCarouselEditorInput) {528const currentSection = this._sections[entry.sectionIndex];529this.input.setName(currentSection.title || this.input.collection.title);530}531532// Preload adjacent images for smoother navigation533this._preloadAdjacentImages();534}535536private async _loadBlobUrl(image: ICarouselImage): Promise<string> {537const cached = this._blobUrlCache.get(image.id);538if (cached) {539return cached;540}541542let buffer: Uint8Array;543if (image.data) {544// Handle both VSBuffer (has .buffer property) and raw Uint8Array from chat attachments545buffer = image.data instanceof Uint8Array ? image.data : image.data.buffer;546} else if (image.uri) {547const content = await this._fileService.readFile(image.uri);548buffer = content.value.buffer;549} else {550return '';551}552553const blob = new Blob([buffer as Uint8Array<ArrayBuffer>], { type: image.mimeType });554const url = URL.createObjectURL(blob);555this._blobUrlCache.set(image.id, url);556return url;557}558559private _revokeCachedBlobUrls(): void {560for (const url of this._blobUrlCache.values()) {561URL.revokeObjectURL(url);562}563this._blobUrlCache.clear();564}565566private async _loadRawData(image: ICarouselImage): Promise<Uint8Array> {567if (image.data) {568return image.data instanceof Uint8Array ? image.data : image.data.buffer;569} else if (image.uri) {570const content = await this._fileService.readFile(image.uri);571return content.value.buffer;572}573return new Uint8Array(0);574}575576private _preloadAdjacentImages(): void {577for (const idx of [this._currentIndex - 1, this._currentIndex + 1]) {578if (idx >= 0 && idx < this._flatImages.length) {579const adjacentImage = this._flatImages[idx].image;580if (isVideoMimeType(adjacentImage.mimeType)) {581// For video, preload raw data into the file service cache582this._loadRawData(adjacentImage).catch(() => { /* ignore */ });583} else {584this._loadBlobUrl(adjacentImage).then(url => {585// Pre-decode via decode() so the compositor doesn't block586// the main thread decoding this image during commit.587const img = new Image();588img.src = url;589img.decode().catch(() => { /* invalid image */ });590});591}592}593}594}595596previous(): void {597if (this._currentIndex > 0) {598this._currentIndex--;599this.updateCurrentImage();600}601}602603next(): void {604if (this._currentIndex < this._flatImages.length - 1) {605this._currentIndex++;606this.updateCurrentImage();607}608}609610/**611* Compute the current display scale when transitioning from 'fit' to numeric zoom.612*/613private _initZoomFromFit(): void {614if (!this._elements) {615return;616}617const img = this._elements.mainImage;618if (img.naturalWidth > 0) {619this._zoomScale = img.clientWidth / img.naturalWidth;620} else {621this._zoomScale = 1;622}623}624625/**626* Zoom in to the next predefined zoom level.627*/628private _zoomIn(): void {629if (this._zoomScale === 'fit') {630this._initZoomFromFit();631}632const scale = this._zoomScale as number;633let i = 0;634for (; i < ZOOM_LEVELS.length; ++i) {635if (ZOOM_LEVELS[i] > scale) {636break;637}638}639this._applyZoom(ZOOM_LEVELS[i] ?? MAX_SCALE);640}641642/**643* Zoom out to the previous predefined zoom level.644*/645private _zoomOut(): void {646if (this._zoomScale === 'fit') {647this._initZoomFromFit();648}649const scale = this._zoomScale as number;650let i = ZOOM_LEVELS.length - 1;651for (; i >= 0; --i) {652if (ZOOM_LEVELS[i] < scale) {653break;654}655}656this._applyZoom(ZOOM_LEVELS[i] ?? MIN_SCALE);657}658659/**660* Apply fit-to-container or numeric zoom with scroll-center preservation.661*/662private _applyZoom(newScale: ZoomScale): void {663if (!this._elements) {664return;665}666667const container = this._elements.mainImageContainer;668const img = this._elements.mainImage;669670if (newScale === 'fit') {671this._zoomScale = 'fit';672img.classList.add('scale-to-fit');673img.classList.remove('pixelated');674img.style.zoom = '';675// Remove zoomed/overflow before scrollTo to avoid an expensive676// synchronous ScrollLayer that blocks the main thread.677const wasZoomed = container.classList.contains('zoomed');678container.classList.remove('zoomed');679container.classList.remove('zoom-out');680if (wasZoomed) {681container.scrollTo(0, 0);682}683} else {684const scale = clamp(newScale, MIN_SCALE, MAX_SCALE);685this._zoomScale = scale;686687// Capture scroll center ratio before changing zoom.688const dx = container.scrollWidth > 0689? (container.scrollLeft + container.clientWidth / 2) / container.scrollWidth690: 0.5;691const dy = container.scrollHeight > 0692? (container.scrollTop + container.clientHeight / 2) / container.scrollHeight693: 0.5;694695img.classList.remove('scale-to-fit');696img.classList.toggle('pixelated', scale >= PIXELATION_THRESHOLD);697img.style.zoom = String(scale);698container.classList.add('zoomed');699700// Restore scroll center — works because setting img.style.zoom triggers701// synchronous layout, so scrollWidth/scrollHeight reflect the new size.702const newScrollX = container.scrollWidth * dx - container.clientWidth / 2;703const newScrollY = container.scrollHeight * dy - container.clientHeight / 2;704container.scrollTo(newScrollX, newScrollY);705}706}707708override focus(): void {709super.focus();710this._elements?.root.focus();711}712713override layout(dimension: Dimension): void {714if (this._container) {715this._container.style.width = `${dimension.width}px`;716this._container.style.height = `${dimension.height}px`;717}718}719}720721722