Skip to content

Commit

Permalink
Add ability to zoom in/out on all images (#38538)
Browse files Browse the repository at this point in the history
* Add ability to zoom in on small images

* Update image viewer to allow pinch or click to zoom

Images are now always centered in the window. They initially start at
their native size, unless they would be larger than the window, in which
case they are contained within the window. Clicking increases the
zoom, and alt+click decreases it. Pinch to zoom and ctrl+scroll are also
supported.

* Update resourceViewer to improve image viewing experience

ResourceViewer now holds a cache of image scales so they stay the same
while flipping between editor tabs. Right clicking now returns the image
to its original scale. Pixelation only triggers for images 64x64 or
smaller, and only after the first zoom. Editor risizing is handled
thorugh the layout call to the binary editor, passed down to the
resource viewer.
  • Loading branch information
bschlenk authored and mjbvz committed Jan 23, 2018
1 parent fc691f7 commit 9081507
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 20 deletions.
129 changes: 118 additions & 11 deletions src/vs/base/browser/ui/resourceviewer/resourceViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import nls = require('vs/nls');
import mimes = require('vs/base/common/mime');
import URI from 'vs/base/common/uri';
import paths = require('vs/base/common/paths');
import { Builder, $ } from 'vs/base/browser/builder';
import { Builder, $, Dimension } from 'vs/base/browser/builder';
import DOM = require('vs/base/browser/dom');
import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement';
import { LRUCache } from 'vs/base/common/map';
import { Schemas } from 'vs/base/common/network';
import { clamp } from 'vs/base/common/numbers';

interface MapExtToMediaMimes {
[index: string]: string;
Expand Down Expand Up @@ -78,6 +79,10 @@ export interface IResourceDescriptor {
mime: string;
}

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,
Expand All @@ -104,6 +109,13 @@ function imageSrc(descriptor: IResourceDescriptor): string {
return cached.src;
}

// store the scale of an image so it can be restored when changing editor tabs
const IMAGE_SCALE_CACHE = new LRUCache<string, number>(100);

export interface ResourceViewerContext {
layout(dimension: Dimension);
}

/**
* Helper to actually render the given resource into the provided container. Will adjust scrollbar (if provided) automatically based on loading
* progress of the binary resource.
Expand All @@ -117,13 +129,19 @@ export class ResourceViewer {

private static readonly MAX_IMAGE_SIZE = ResourceViewer.MB; // showing images inline is memory intense, so we have a limit

private static SCALE_PINCH_FACTOR = 0.1;
private static SCALE_FACTOR = 1.5;
private static MAX_SCALE = 20;
private static MIN_SCALE = 0.1;
private static 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
): void {
): ResourceViewerContext {

// Ensure CSS class
$(container).setClass('monaco-resource-viewer');
Expand All @@ -144,28 +162,115 @@ export class ResourceViewer {
// 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')
.addClass('image', 'zoom-in')
.img({ src: imageSrc(descriptor) })
.addClass('untouched')
.on(DOM.EventType.LOAD, (e, img) => {
const imgElement = <HTMLImageElement>img.getHTMLElement();
if (imgElement.naturalWidth > imgElement.width || imgElement.naturalHeight > imgElement.height) {
$(container).addClass('oversized');
const cacheKey = descriptor.resource.toString();
let scaleDirection = ScaleDirection.IN;
let scale = IMAGE_SCALE_CACHE.get(cacheKey) || null;
if (scale) {
img.removeClass('untouched');
updateScale(scale);
}

img.on(DOM.EventType.CLICK, (e, img) => {
$(container).toggleClass('full-size');
function setImageWidth(width) {
img.style('width', `${width}px`);
img.style('height', 'auto');
}

scrollbar.scanDomNode();
});
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)));
}
}

if (metadataClb) {
metadataClb(nls.localize('imgMeta', "{0}x{1} {2}", imgElement.naturalWidth, imgElement.naturalHeight, ResourceViewer.formatSize(descriptor.size)));
context.layout = updateMetadata;

function firstZoom() {
const { clientWidth, naturalWidth } = imgElement;
setImageWidth(clientWidth);
img.removeClass('untouched');
if (imgElement.naturalWidth < ResourceViewer.PIXELATION_THRESHOLD
|| imgElement.naturalHeight < ResourceViewer.PIXELATION_THRESHOLD) {
img.addClass('pixelated');
}
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()
Expand Down Expand Up @@ -199,6 +304,8 @@ export class ResourceViewer {

scrollbar.scanDomNode();
}

return null;
}

private static inlineImage(descriptor: IResourceDescriptor): boolean {
Expand Down
19 changes: 13 additions & 6 deletions src/vs/base/browser/ui/resourceviewer/resourceviewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
padding: 10px 10px 0 10px;
background-position: 0 0, 8px 8px;
background-size: 16px 16px;
display: grid;
}

.monaco-resource-viewer.image.full-size {
Expand All @@ -34,18 +35,24 @@
linear-gradient(45deg, rgb(20, 20, 20) 25%, transparent 25%, transparent 75%, rgb(20, 20, 20) 75%, rgb(20, 20, 20));
}

.monaco-resource-viewer img {
.monaco-resource-viewer img.untouched {
max-width: 100%;
max-height: calc(100% - 10px); /* somehow this prevents scrollbars from showing up */
object-fit: contain;
}

.monaco-resource-viewer img.pixelated {
image-rendering: pixelated;
}

.monaco-resource-viewer img {
margin: auto; /* centers the image */
}

.monaco-resource-viewer.oversized img {
.monaco-resource-viewer.zoom-in {
cursor: zoom-in;
}

.monaco-resource-viewer.full-size img {
max-width: initial;
max-height: initial;
.monaco-resource-viewer.zoom-out {
cursor: zoom-out;
}

Expand Down
10 changes: 7 additions & 3 deletions src/vs/workbench/browser/parts/editor/binaryEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Event, { Emitter } from 'vs/base/common/event';
import URI from 'vs/base/common/uri';
import { TPromise } from 'vs/base/common/winjs.base';
import { Dimension, Builder, $ } from 'vs/base/browser/builder';
import { ResourceViewer } from 'vs/base/browser/ui/resourceviewer/resourceViewer';
import { ResourceViewer, ResourceViewerContext } from 'vs/base/browser/ui/resourceviewer/resourceViewer';
import { EditorModel, EditorInput, EditorOptions } from 'vs/workbench/common/editor';
import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor';
import { BinaryEditorModel } from 'vs/workbench/common/editor/binaryEditorModel';
Expand All @@ -29,6 +29,7 @@ export abstract class BaseBinaryResourceEditor extends BaseEditor {

private binaryContainer: Builder;
private scrollbar: DomScrollableElement;
private resourceViewerContext: ResourceViewerContext;

constructor(
id: string,
Expand Down Expand Up @@ -87,7 +88,7 @@ export abstract class BaseBinaryResourceEditor extends BaseEditor {

// Render Input
const model = <BinaryEditorModel>resolvedModel;
ResourceViewer.show(
this.resourceViewerContext = ResourceViewer.show(
{ name: model.getName(), resource: model.getResource(), size: model.getSize(), etag: model.getETag(), mime: model.getMime() },
this.binaryContainer,
this.scrollbar,
Expand Down Expand Up @@ -132,6 +133,9 @@ export abstract class BaseBinaryResourceEditor extends BaseEditor {
// Pass on to Binary Container
this.binaryContainer.size(dimension.width, dimension.height);
this.scrollbar.scanDomNode();
if (this.resourceViewerContext) {
this.resourceViewerContext.layout(dimension);
}
}

public focus(): void {
Expand All @@ -146,4 +150,4 @@ export abstract class BaseBinaryResourceEditor extends BaseEditor {

super.dispose();
}
}
}

1 comment on commit 9081507

@jozanza
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just recently added some image snapshotting tests to a project to diff pixel art output. I am very excited for this update 💯

Please sign in to comment.