@@ -12,6 +12,7 @@ import { clamp } from '../../../../base/common/numbers.js';
1212import { isMacintosh } from '../../../../base/common/platform.js' ;
1313import { localize } from '../../../../nls.js' ;
1414import { IEditorOptions } from '../../../../platform/editor/common/editor.js' ;
15+ import { IFileService } from '../../../../platform/files/common/files.js' ;
1516import { IThemeService } from '../../../../platform/theme/common/themeService.js' ;
1617import { EditorPane } from '../../../browser/parts/editor/editorPane.js' ;
1718import { IEditorOpenContext } from '../../../common/editor.js' ;
@@ -49,6 +50,7 @@ export class ImageCarouselEditor extends EditorPane {
4950 private _flatImages : IFlatImageEntry [ ] = [ ] ;
5051 private readonly _contentDisposables = this . _register ( new DisposableStore ( ) ) ;
5152 private readonly _imageDisposables = this . _register ( new DisposableStore ( ) ) ;
53+ private readonly _blobUrlCache = new Map < string , string > ( ) ;
5254
5355 private _elements : {
5456 root : HTMLElement ;
@@ -68,7 +70,8 @@ export class ImageCarouselEditor extends EditorPane {
6870 group : IEditorGroup ,
6971 @ITelemetryService telemetryService : ITelemetryService ,
7072 @IThemeService themeService : IThemeService ,
71- @IStorageService storageService : IStorageService
73+ @IStorageService storageService : IStorageService ,
74+ @IFileService private readonly _fileService : IFileService
7275 ) {
7376 super ( ImageCarouselEditor . ID , group , telemetryService , themeService , storageService ) ;
7477 }
@@ -95,6 +98,7 @@ export class ImageCarouselEditor extends EditorPane {
9598 override clearInput ( ) : void {
9699 this . _contentDisposables . clear ( ) ;
97100 this . _imageDisposables . clear ( ) ;
101+ this . _revokeCachedBlobUrls ( ) ;
98102 this . _zoomScale = 'fit' ;
99103 if ( this . _container ) {
100104 clearNode ( this . _container ) ;
@@ -114,6 +118,7 @@ export class ImageCarouselEditor extends EditorPane {
114118
115119 this . _contentDisposables . clear ( ) ;
116120 this . _imageDisposables . clear ( ) ;
121+ this . _revokeCachedBlobUrls ( ) ;
117122 clearNode ( this . _container ) ;
118123
119124 if ( this . _flatImages . length === 0 ) {
@@ -263,11 +268,8 @@ export class ImageCarouselEditor extends EditorPane {
263268 btn . ariaLabel = localize ( 'imageCarousel.thumbnailLabel' , "Image {0} of {1}" , currentFlatIndex + 1 , this . _flatImages . length ) ;
264269
265270 const img = thumbnail . img as HTMLImageElement ;
266- const blob = new Blob ( [ image . data . buffer . slice ( 0 ) ] , { type : image . mimeType } ) ;
267- const url = URL . createObjectURL ( blob ) ;
268- img . src = url ;
271+ this . _loadBlobUrl ( image ) . then ( url => { img . src = url ; } ) ;
269272 img . alt = image . name ;
270- this . _contentDisposables . add ( { dispose : ( ) => URL . revokeObjectURL ( url ) } ) ;
271273
272274 this . _contentDisposables . add ( addDisposableListener ( btn , 'click' , ( ) => {
273275 this . _currentIndex = currentFlatIndex ;
@@ -290,20 +292,42 @@ export class ImageCarouselEditor extends EditorPane {
290292 * Update only the changing parts: main image src, caption, button states, thumbnail selection.
291293 * No DOM teardown/rebuild — eliminates the blank flash.
292294 */
293- private updateCurrentImage ( ) : void {
295+ private async updateCurrentImage ( ) : Promise < void > {
294296 if ( ! this . _elements ) {
295297 return ;
296298 }
297299
298- // Swap main image blob URL
299- this . _imageDisposables . clear ( ) ;
300- const entry = this . _flatImages [ this . _currentIndex ] ;
300+ // Capture the navigation index before starting async work so that
301+ // we can discard stale results if the user navigates while loading/decoding.
302+ const navigationIndex = this . _currentIndex ;
303+
304+ // Swap main image using cached/lazy-loaded blob URL.
305+ // Pre-decode via decode() before assigning to <img> so the browser
306+ // decodes on a worker thread, avoiding main-thread stalls during commit.
307+ const entry = this . _flatImages [ navigationIndex ] ;
301308 const currentImage = entry . image ;
302- const blob = new Blob ( [ currentImage . data . buffer . slice ( 0 ) ] , { type : currentImage . mimeType } ) ;
303- const url = URL . createObjectURL ( blob ) ;
304- this . _elements . mainImage . src = url ;
305- this . _elements . mainImage . alt = currentImage . name ;
306- this . _imageDisposables . add ( { dispose : ( ) => URL . revokeObjectURL ( url ) } ) ;
309+ const url = await this . _loadBlobUrl ( currentImage ) ;
310+
311+ // If the user navigated while loading the blob URL, discard this result.
312+ if ( this . _currentIndex !== navigationIndex ) {
313+ return ;
314+ }
315+
316+ const tmp = new Image ( ) ;
317+ tmp . src = url ;
318+ tmp . decode ( ) . then ( ( ) => {
319+ // Only apply if user hasn't navigated away during decode
320+ if ( this . _currentIndex === navigationIndex && this . _elements ) {
321+ this . _elements . mainImage . src = url ;
322+ this . _elements . mainImage . alt = currentImage . name ;
323+ }
324+ } , ( ) => {
325+ // Decode failed (invalid image) — still show src for browser fallback
326+ if ( this . _currentIndex === navigationIndex && this . _elements ) {
327+ this . _elements . mainImage . src = url ;
328+ this . _elements . mainImage . alt = currentImage . name ;
329+ }
330+ } ) ;
307331
308332 // Reset zoom when switching images
309333 this . _applyZoom ( 'fit' ) ;
@@ -324,32 +348,80 @@ export class ImageCarouselEditor extends EditorPane {
324348 this . _elements . prevBtn . disabled = this . _currentIndex === 0 ;
325349 this . _elements . nextBtn . disabled = this . _currentIndex === this . _flatImages . length - 1 ;
326350
327- // Update thumbnail selection
351+ // Update thumbnail selection — only toggle active class and
352+ // call getBoundingClientRect on the active thumbnail to avoid
353+ // layout thrashing across all thumbnails on every navigation.
328354 for ( let i = 0 ; i < this . _thumbnailElements . length ; i ++ ) {
329355 const isActive = i === this . _currentIndex ;
330356 const thumbnail = this . _thumbnailElements [ i ] ;
331357 thumbnail . classList . toggle ( 'active' , isActive ) ;
332358 if ( isActive ) {
333359 thumbnail . setAttribute ( 'aria-current' , 'page' ) ;
334- // Scroll only the thumbnail strip, not the entire editor
335- const container = this . _elements . sectionsContainer ;
336- const containerRect = container . getBoundingClientRect ( ) ;
337- const thumbRect = thumbnail . getBoundingClientRect ( ) ;
338- if ( thumbRect . left < containerRect . left ) {
339- container . scrollLeft += thumbRect . left - containerRect . left ;
340- } else if ( thumbRect . right > containerRect . right ) {
341- container . scrollLeft += thumbRect . right - containerRect . right ;
342- }
343360 } else {
344361 thumbnail . removeAttribute ( 'aria-current' ) ;
345362 }
346363 }
347364
365+ // Scroll the active thumbnail into view without blocking the main thread.
366+ // Using scrollIntoView with 'nearest' avoids forced layout from
367+ // getBoundingClientRect + scrollLeft and is handled efficiently by
368+ // the browser's scroll machinery.
369+ const activeThumbnail = this . _thumbnailElements [ this . _currentIndex ] ;
370+ if ( activeThumbnail ) {
371+ activeThumbnail . scrollIntoView ( { block : 'nearest' , inline : 'nearest' } ) ;
372+ }
373+
348374 // Update editor title to reflect current section
349375 if ( this . input instanceof ImageCarouselEditorInput ) {
350376 const currentSection = this . _sections [ entry . sectionIndex ] ;
351377 this . input . setName ( currentSection . title || this . input . collection . title ) ;
352378 }
379+
380+ // Preload adjacent images for smoother navigation
381+ this . _preloadAdjacentImages ( ) ;
382+ }
383+
384+ private async _loadBlobUrl ( image : ICarouselImage ) : Promise < string > {
385+ const cached = this . _blobUrlCache . get ( image . id ) ;
386+ if ( cached ) {
387+ return cached ;
388+ }
389+
390+ let buffer : Uint8Array ;
391+ if ( image . data ) {
392+ buffer = image . data . buffer ;
393+ } else if ( image . uri ) {
394+ const content = await this . _fileService . readFile ( image . uri ) ;
395+ buffer = content . value . buffer ;
396+ } else {
397+ return '' ;
398+ }
399+
400+ const blob = new Blob ( [ buffer as Uint8Array < ArrayBuffer > ] , { type : image . mimeType } ) ;
401+ const url = URL . createObjectURL ( blob ) ;
402+ this . _blobUrlCache . set ( image . id , url ) ;
403+ return url ;
404+ }
405+
406+ private _revokeCachedBlobUrls ( ) : void {
407+ for ( const url of this . _blobUrlCache . values ( ) ) {
408+ URL . revokeObjectURL ( url ) ;
409+ }
410+ this . _blobUrlCache . clear ( ) ;
411+ }
412+
413+ private _preloadAdjacentImages ( ) : void {
414+ for ( const idx of [ this . _currentIndex - 1 , this . _currentIndex + 1 ] ) {
415+ if ( idx >= 0 && idx < this . _flatImages . length ) {
416+ this . _loadBlobUrl ( this . _flatImages [ idx ] . image ) . then ( url => {
417+ // Pre-decode via decode() so the compositor doesn't block
418+ // the main thread decoding this image during commit.
419+ const img = new Image ( ) ;
420+ img . src = url ;
421+ img . decode ( ) . catch ( ( ) => { /* invalid image */ } ) ;
422+ } ) ;
423+ }
424+ }
353425 }
354426
355427 previous ( ) : void {
@@ -431,9 +503,14 @@ export class ImageCarouselEditor extends EditorPane {
431503 img . classList . add ( 'scale-to-fit' ) ;
432504 img . classList . remove ( 'pixelated' ) ;
433505 img . style . zoom = '' ;
506+ // Remove zoomed/overflow before scrollTo to avoid an expensive
507+ // synchronous ScrollLayer that blocks the main thread.
508+ const wasZoomed = container . classList . contains ( 'zoomed' ) ;
434509 container . classList . remove ( 'zoomed' ) ;
435510 container . classList . remove ( 'zoom-out' ) ;
436- container . scrollTo ( 0 , 0 ) ;
511+ if ( wasZoomed ) {
512+ container . scrollTo ( 0 , 0 ) ;
513+ }
437514 } else {
438515 const scale = clamp ( newScale , MIN_SCALE , MAX_SCALE ) ;
439516 this . _zoomScale = scale ;
0 commit comments