Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to zoom in/out on all images #38538

Merged
merged 3 commits into from Jan 23, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
129 changes: 118 additions & 11 deletions src/vs/base/browser/ui/resourceviewer/resourceViewer.ts
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
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
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();
}
}
}