diff --git a/src/vs/base/browser/ui/resourceviewer/resourceViewer.ts b/src/vs/base/browser/ui/resourceviewer/resourceViewer.ts index e1901ab07480fcc66bb1d5c199d3707cae874995..86ac9fbeee4833798e4555bcae5747bcb4fc2f2b 100644 --- a/src/vs/base/browser/ui/resourceviewer/resourceViewer.ts +++ b/src/vs/base/browser/ui/resourceviewer/resourceViewer.ts @@ -83,35 +83,33 @@ enum ScaleDirection { IN, OUT, } -// Chrome is caching images very aggressively and so we use the ETag information to find out if -// we need to bypass the cache or not. We could always bypass the cache everytime we show the image -// however that has very bad impact on memory consumption because each time the image gets shown, -// memory grows (see also https://github.com/electron/electron/issues/6275) -const IMAGE_RESOURCE_ETAG_CACHE = new LRUCache(100); -function imageSrc(descriptor: IResourceDescriptor): string { - if (descriptor.resource.scheme === Schemas.data) { - return descriptor.resource.toString(true /* skip encoding */); - } +class BinarySize { + public static readonly KB = 1024; + public static readonly MB = BinarySize.KB * BinarySize.KB; + public static readonly GB = BinarySize.MB * BinarySize.KB; + public static readonly TB = BinarySize.GB * BinarySize.KB; + + public static formatSize(size: number): string { + if (size < BinarySize.KB) { + return nls.localize('sizeB', "{0}B", size); + } - const src = descriptor.resource.toString(); + if (size < BinarySize.MB) { + return nls.localize('sizeKB', "{0}KB", (size / BinarySize.KB).toFixed(2)); + } - let cached = IMAGE_RESOURCE_ETAG_CACHE.get(src); - if (!cached) { - cached = { etag: descriptor.etag, src }; - IMAGE_RESOURCE_ETAG_CACHE.set(src, cached); - } + if (size < BinarySize.GB) { + return nls.localize('sizeMB', "{0}MB", (size / BinarySize.MB).toFixed(2)); + } - if (cached.etag !== descriptor.etag) { - cached.etag = descriptor.etag; - cached.src = `${src}?${Date.now()}`; // bypass cache with this trick - } + if (size < BinarySize.TB) { + return nls.localize('sizeGB', "{0}GB", (size / BinarySize.GB).toFixed(2)); + } - return cached.src; + return nls.localize('sizeTB', "{0}TB", (size / BinarySize.TB).toFixed(2)); + } } -// store the scale of an image so it can be restored when changing editor tabs -const IMAGE_SCALE_CACHE = new LRUCache(100); - export interface ResourceViewerContext { layout(dimension: Dimension); } @@ -121,32 +119,30 @@ export interface ResourceViewerContext { * progress of the binary resource. */ export class ResourceViewer { - - private static readonly KB = 1024; - private static readonly MB = ResourceViewer.KB * ResourceViewer.KB; - private static readonly GB = ResourceViewer.MB * ResourceViewer.KB; - private static readonly TB = ResourceViewer.GB * ResourceViewer.KB; - - private static readonly MAX_IMAGE_SIZE = ResourceViewer.MB; // showing images inline is memory intense, so we have a limit - - private static readonly SCALE_PINCH_FACTOR = 0.1; - private static readonly SCALE_FACTOR = 1.5; - private static readonly MAX_SCALE = 20; - private static readonly MIN_SCALE = 0.1; - private static readonly PIXELATION_THRESHOLD = 64; // enable image-rendering: pixelated for images less than this - public static show( descriptor: IResourceDescriptor, container: Builder, scrollbar: DomScrollableElement, openExternal: (uri: URI) => void, - metadataClb?: (meta: string) => void + metadataClb: (meta: string) => void ): ResourceViewerContext { - // Ensure CSS class $(container).setClass('monaco-resource-viewer'); - // Lookup media mime if any + if (ResourceViewer.isImageResource(descriptor)) { + return ImageView.create(container, descriptor, scrollbar, openExternal, metadataClb); + } + + GenericBinaryFileView.create(container, metadataClb, descriptor, scrollbar); + return null; + } + + private static isImageResource(descriptor: IResourceDescriptor) { + const mime = ResourceViewer.getMime(descriptor); + return mime.indexOf('image/') >= 0; + } + + private static getMime(descriptor: IResourceDescriptor): string { let mime = descriptor.mime; if (!mime && descriptor.resource.scheme === Schemas.file) { const ext = paths.extname(descriptor.resource.toString()); @@ -154,163 +150,29 @@ export class ResourceViewer { mime = mapExtToMediaMimes[ext.toLowerCase()]; } } + return mime || mimes.MIME_BINARY; + } +} - if (!mime) { - mime = mimes.MIME_BINARY; - } - - // Show Image inline unless they are large - if (mime.indexOf('image/') >= 0) { - if (ResourceViewer.inlineImage(descriptor)) { - const context = { - layout(dimension: Dimension) { } - }; - $(container) - .empty() - .addClass('image', 'zoom-in') - .img({ src: imageSrc(descriptor) }) - .addClass('untouched') - .on(DOM.EventType.LOAD, (e, img) => { - const imgElement = img.getHTMLElement(); - const cacheKey = descriptor.resource.toString(); - let scaleDirection = ScaleDirection.IN; - let scale = IMAGE_SCALE_CACHE.get(cacheKey) || null; - if (scale) { - img.removeClass('untouched'); - updateScale(scale); - } - - if (imgElement.naturalWidth < ResourceViewer.PIXELATION_THRESHOLD - || imgElement.naturalHeight < ResourceViewer.PIXELATION_THRESHOLD - ) { - img.addClass('pixelated'); - } - - function setImageWidth(width) { - img.style('width', `${width}px`); - img.style('height', 'auto'); - } - - function updateScale(newScale) { - scale = clamp(newScale, ResourceViewer.MIN_SCALE, ResourceViewer.MAX_SCALE); - setImageWidth(Math.floor(imgElement.naturalWidth * scale)); - IMAGE_SCALE_CACHE.set(cacheKey, scale); - - scrollbar.scanDomNode(); - - updateMetadata(); - } - - function updateMetadata() { - if (metadataClb) { - const scale = Math.round((imgElement.width / imgElement.naturalWidth) * 10000) / 100; - metadataClb(nls.localize('imgMeta', '{0}% {1}x{2} {3}', - scale, - imgElement.naturalWidth, - imgElement.naturalHeight, - ResourceViewer.formatSize(descriptor.size))); - } - } - - context.layout = updateMetadata; - - function firstZoom() { - const { clientWidth, naturalWidth } = imgElement; - setImageWidth(clientWidth); - img.removeClass('untouched'); - scale = clientWidth / naturalWidth; - } - - $(container) - .on(DOM.EventType.KEY_DOWN, (e: KeyboardEvent, c) => { - if (e.altKey) { - scaleDirection = ScaleDirection.OUT; - c.removeClass('zoom-in').addClass('zoom-out'); - } - }) - .on(DOM.EventType.KEY_UP, (e: KeyboardEvent, c) => { - if (!e.altKey) { - scaleDirection = ScaleDirection.IN; - c.removeClass('zoom-out').addClass('zoom-in'); - } - }); - - $(container).on(DOM.EventType.MOUSE_DOWN, (e: MouseEvent) => { - if (scale === null) { - firstZoom(); - } - - // right click - if (e.button === 2) { - updateScale(1); - } else { - const scaleFactor = scaleDirection === ScaleDirection.IN - ? ResourceViewer.SCALE_FACTOR - : 1 / ResourceViewer.SCALE_FACTOR; - - updateScale(scale * scaleFactor); - } - }); - - $(container).on(DOM.EventType.WHEEL, (e: WheelEvent) => { - // pinching is reported as scroll wheel + ctrl - if (!e.ctrlKey) { - return; - } - - if (scale === null) { - firstZoom(); - } - - // scrolling up, pinching out should increase the scale - const delta = -e.deltaY; - updateScale(scale + delta * ResourceViewer.SCALE_PINCH_FACTOR); - }); - - updateMetadata(); - - scrollbar.scanDomNode(); - }); - - return context; - } else { - const imageContainer = $(container) - .empty() - .p({ - text: nls.localize('largeImageError', "The image is too large to display in the editor. ") - }); - - if (descriptor.resource.scheme !== Schemas.data) { - imageContainer.append($('a', { - role: 'button', - class: 'open-external', - text: nls.localize('resourceOpenExternalButton', "Open image using external program?") - }).on(DOM.EventType.CLICK, (e) => { - openExternal(descriptor.resource); - })); - } - } - } - - // Handle generic Binary Files - else { - $(container) - .empty() - .span({ - text: nls.localize('nativeBinaryError', "The file will not be displayed in the editor because it is either binary, very large or uses an unsupported text encoding.") - }); +class ImageView { + private static readonly MAX_IMAGE_SIZE = BinarySize.MB; // showing images inline is memory intense, so we have a limit - if (metadataClb) { - metadataClb(ResourceViewer.formatSize(descriptor.size)); - } - - scrollbar.scanDomNode(); + public static create( + container: Builder, + descriptor: IResourceDescriptor, + scrollbar: DomScrollableElement, + openExternal: (uri: URI) => void, + metadataClb: (meta: string) => void + ): ResourceViewerContext | null { + if (ImageView.shouldShowImageInline(descriptor)) { + return InlineImageView.create(container, descriptor, scrollbar, metadataClb); } + LargeImageView.create(container, descriptor, openExternal); return null; } - private static inlineImage(descriptor: IResourceDescriptor): boolean { + private static shouldShowImageInline(descriptor: IResourceDescriptor): boolean { let skipInlineImage: boolean; // Data URI @@ -319,34 +181,196 @@ export class ResourceViewer { const base64MarkerIndex = descriptor.resource.path.indexOf(BASE64_MARKER); const hasData = base64MarkerIndex >= 0 && descriptor.resource.path.substring(base64MarkerIndex + BASE64_MARKER.length).length > 0; - skipInlineImage = !hasData || descriptor.size > ResourceViewer.MAX_IMAGE_SIZE || descriptor.resource.path.length > ResourceViewer.MAX_IMAGE_SIZE; + skipInlineImage = !hasData || descriptor.size > ImageView.MAX_IMAGE_SIZE || descriptor.resource.path.length > ImageView.MAX_IMAGE_SIZE; } // File URI else { - skipInlineImage = typeof descriptor.size !== 'number' || descriptor.size > ResourceViewer.MAX_IMAGE_SIZE; + skipInlineImage = typeof descriptor.size !== 'number' || descriptor.size > ImageView.MAX_IMAGE_SIZE; } return !skipInlineImage; } +} - private static formatSize(size: number): string { - if (size < ResourceViewer.KB) { - return nls.localize('sizeB', "{0}B", size); +class LargeImageView { + public static create( + container: Builder, + descriptor: IResourceDescriptor, + openExternal: (uri: URI) => void + ) { + const imageContainer = $(container) + .empty() + .p({ + text: nls.localize('largeImageError', "The image is too large to display in the editor. ") + }); + + if (descriptor.resource.scheme !== Schemas.data) { + imageContainer.append($('a', { + role: 'button', + class: 'open-external', + text: nls.localize('resourceOpenExternalButton', "Open image using external program?") + }).on(DOM.EventType.CLICK, (e) => { + openExternal(descriptor.resource); + })); } + } +} - if (size < ResourceViewer.MB) { - return nls.localize('sizeKB', "{0}KB", (size / ResourceViewer.KB).toFixed(2)); +class GenericBinaryFileView { + public static create( + container: Builder, + metadataClb: (meta: string) => void, + descriptor: IResourceDescriptor, + scrollbar: DomScrollableElement + ) { + $(container) + .empty() + .span({ + text: nls.localize('nativeBinaryError', "The file will not be displayed in the editor because it is either binary, very large or uses an unsupported text encoding.") + }); + if (metadataClb) { + metadataClb(BinarySize.formatSize(descriptor.size)); } + scrollbar.scanDomNode(); + } +} + +class InlineImageView { + private static readonly SCALE_PINCH_FACTOR = 0.1; + private static readonly SCALE_FACTOR = 1.5; + private static readonly MAX_SCALE = 20; + private static readonly MIN_SCALE = 0.1; + private static readonly PIXELATION_THRESHOLD = 64; // enable image-rendering: pixelated for images less than this + + /** + * Chrome is caching images very aggressively and so we use the ETag information to find out if + * we need to bypass the cache or not. We could always bypass the cache everytime we show the image + * however that has very bad impact on memory consumption because each time the image gets shown, + * memory grows (see also https://github.com/electron/electron/issues/6275) + */ + private static IMAGE_RESOURCE_ETAG_CACHE = new LRUCache(100); + + /** + * Store the scale of an image so it can be restored when changing editor tabs + */ + private static readonly IMAGE_SCALE_CACHE = new LRUCache(100); + + public static create( + container: Builder, + descriptor: IResourceDescriptor, + scrollbar: DomScrollableElement, + metadataClb: (meta: string) => void + ) { + const context = { + layout(dimension: Dimension) { } + }; + $(container) + .empty() + .addClass('image', 'zoom-in') + .img({ src: InlineImageView.imageSrc(descriptor) }) + .addClass('untouched') + .on(DOM.EventType.LOAD, (e, img) => { + const imgElement = img.getHTMLElement(); + const cacheKey = descriptor.resource.toString(); + let scaleDirection = ScaleDirection.IN; + let scale = InlineImageView.IMAGE_SCALE_CACHE.get(cacheKey) || null; + if (scale) { + img.removeClass('untouched'); + updateScale(scale); + } + if (imgElement.naturalWidth < InlineImageView.PIXELATION_THRESHOLD + || imgElement.naturalHeight < InlineImageView.PIXELATION_THRESHOLD) { + img.addClass('pixelated'); + } + function setImageWidth(width) { + img.style('width', `${width}px`); + img.style('height', 'auto'); + } + function updateScale(newScale) { + scale = clamp(newScale, InlineImageView.MIN_SCALE, InlineImageView.MAX_SCALE); + setImageWidth(Math.floor(imgElement.naturalWidth * scale)); + InlineImageView.IMAGE_SCALE_CACHE.set(cacheKey, scale); + scrollbar.scanDomNode(); + updateMetadata(); + } + function updateMetadata() { + if (metadataClb) { + const scale = Math.round((imgElement.width / imgElement.naturalWidth) * 10000) / 100; + metadataClb(nls.localize('imgMeta', '{0}% {1}x{2} {3}', scale, imgElement.naturalWidth, imgElement.naturalHeight, BinarySize.formatSize(descriptor.size))); + } + } + context.layout = updateMetadata; + function firstZoom() { + const { clientWidth, naturalWidth } = imgElement; + setImageWidth(clientWidth); + img.removeClass('untouched'); + scale = clientWidth / naturalWidth; + } + $(container) + .on(DOM.EventType.KEY_DOWN, (e: KeyboardEvent, c) => { + if (e.altKey) { + scaleDirection = ScaleDirection.OUT; + c.removeClass('zoom-in').addClass('zoom-out'); + } + }) + .on(DOM.EventType.KEY_UP, (e: KeyboardEvent, c) => { + if (!e.altKey) { + scaleDirection = ScaleDirection.IN; + c.removeClass('zoom-out').addClass('zoom-in'); + } + }); + $(container).on(DOM.EventType.MOUSE_DOWN, (e: MouseEvent) => { + if (scale === null) { + firstZoom(); + } + // right click + if (e.button === 2) { + updateScale(1); + } + else { + const scaleFactor = scaleDirection === ScaleDirection.IN + ? InlineImageView.SCALE_FACTOR + : 1 / InlineImageView.SCALE_FACTOR; + updateScale(scale * scaleFactor); + } + }); + $(container).on(DOM.EventType.WHEEL, (e: WheelEvent) => { + // pinching is reported as scroll wheel + ctrl + if (!e.ctrlKey) { + return; + } + if (scale === null) { + firstZoom(); + } + // scrolling up, pinching out should increase the scale + const delta = -e.deltaY; + updateScale(scale + delta * InlineImageView.SCALE_PINCH_FACTOR); + }); + updateMetadata(); + scrollbar.scanDomNode(); + }); + return context; + } + + private static imageSrc(descriptor: IResourceDescriptor): string { + if (descriptor.resource.scheme === Schemas.data) { + return descriptor.resource.toString(true /* skip encoding */); + } + + const src = descriptor.resource.toString(); - if (size < ResourceViewer.GB) { - return nls.localize('sizeMB', "{0}MB", (size / ResourceViewer.MB).toFixed(2)); + let cached = InlineImageView.IMAGE_RESOURCE_ETAG_CACHE.get(src); + if (!cached) { + cached = { etag: descriptor.etag, src }; + InlineImageView.IMAGE_RESOURCE_ETAG_CACHE.set(src, cached); } - if (size < ResourceViewer.TB) { - return nls.localize('sizeGB', "{0}GB", (size / ResourceViewer.GB).toFixed(2)); + if (cached.etag !== descriptor.etag) { + cached.etag = descriptor.etag; + cached.src = `${src}?${Date.now()}`; // bypass cache with this trick } - return nls.localize('sizeTB', "{0}TB", (size / ResourceViewer.TB).toFixed(2)); + return cached.src; } }