diff --git a/packages/image-view/keymaps/image-view.cson b/packages/image-view/keymaps/image-view.cson index ad616eae31..de56e1827b 100644 --- a/packages/image-view/keymaps/image-view.cson +++ b/packages/image-view/keymaps/image-view.cson @@ -3,6 +3,7 @@ 'cmd-=': 'image-view:zoom-in' 'cmd--': 'image-view:zoom-out' 'cmd-_': 'image-view:zoom-out' + 'cmd-8': 'image-view:center' 'cmd-9': 'image-view:zoom-to-fit' 'cmd-0': 'image-view:reset-zoom' @@ -11,6 +12,7 @@ 'ctrl-=': 'image-view:zoom-in' 'ctrl--': 'image-view:zoom-out' 'ctrl-_': 'image-view:zoom-out' + 'ctrl-8': 'image-view:center' 'ctrl-9': 'image-view:zoom-to-fit' 'ctrl-0': 'image-view:reset-zoom' @@ -19,5 +21,6 @@ 'ctrl-=': 'image-view:zoom-in' 'ctrl--': 'image-view:zoom-out' 'ctrl-_': 'image-view:zoom-out' + 'ctrl-8': 'image-view:center' 'ctrl-9': 'image-view:zoom-to-fit' 'ctrl-0': 'image-view:reset-zoom' diff --git a/packages/image-view/lib/image-editor-view.js b/packages/image-view/lib/image-editor-view.js index be9a8c9a6d..44fcc2e8b3 100644 --- a/packages/image-view/lib/image-editor-view.js +++ b/packages/image-view/lib/image-editor-view.js @@ -3,7 +3,6 @@ const {Emitter, CompositeDisposable, Disposable} = require('atom') const etch = require('etch') const $ = etch.dom -// View that renders the image of an {ImageEditor}. module.exports = class ImageEditorView { constructor (editor) { @@ -12,11 +11,13 @@ class ImageEditorView { this.disposables = new CompositeDisposable() this.imageSize = fs.statSync(this.editor.getPath()).size this.loaded = false - this.mode = 'zoom-to-fit' - this.percentageStep = 4 - this.steps = [0.1, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2, 3, 4, 5, 7.5, 10] + this.levels = [0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2, 3, 4, 5, 7.5, 10] + this.zoom = 1.00 ; this.step = null ; this.auto = false etch.initialize(this) + this.defaultBackgroundColor = atom.config.get('image-view.defaultBackgroundColor') + this.refs.imageContainer.setAttribute('background', this.defaultBackgroundColor) + this.refs.image.style.display = 'none' this.updateImageURI() @@ -25,30 +26,24 @@ class ImageEditorView { 'image-view:reload': () => this.updateImageURI(), 'image-view:zoom-in': () => this.zoomIn(), 'image-view:zoom-out': () => this.zoomOut(), - 'image-view:zoom-to-fit': () => this.zoomToFit(), 'image-view:reset-zoom': () => this.resetZoom(), - 'core:move-up': () => { this.scrollUp() }, - 'core:move-down': () => { this.scrollDown() }, - 'core:page-up': () => { this.pageUp() }, - 'core:page-down': () => { this.pageDown() }, - 'core:move-to-top': () => { this.scrollToTop() }, - 'core:move-to-bottom': () => { this.scrollToBottom() } + 'image-view:zoom-to-fit': () => this.zoomToFit(), + 'image-view:zoom-to-100': () => this.zoomTo100(), + 'image-view:center': () => this.centerImage(), + 'core:move-up': () => this.scrollUp(), + 'core:move-down': () => this.scrollDown(), + 'core:move-left': () => this.scrollLeft(), + 'core:move-right': () => this.scrollRight(), + 'core:page-up': () => this.pageUp(), + 'core:page-down': () => this.pageDown(), + 'core:move-to-top': () => this.scrollToTop(), + 'core:move-to-bottom': () => this.scrollToBottom() })) - this.refs.image.onload = () => { - this.refs.image.onload = null - this.originalHeight = this.refs.image.naturalHeight - this.originalWidth = this.refs.image.naturalWidth - this.loaded = true - this.refs.image.style.display = '' - this.defaultBackgroundColor = atom.config.get('image-view.defaultBackgroundColor') - this.refs.imageContainer.setAttribute('background', this.defaultBackgroundColor) - this.emitter.emit('did-load') - } - - this.disposables.add(atom.tooltips.add(this.refs.whiteTransparentBackgroundButton, {title: 'Use white transparent background'})) - this.disposables.add(atom.tooltips.add(this.refs.blackTransparentBackgroundButton, {title: 'Use black transparent background'})) - this.disposables.add(atom.tooltips.add(this.refs.transparentTransparentBackgroundButton, {title: 'Use transparent background'})) + this.disposables.add(atom.tooltips.add(this.refs.whiteTransparentBackgroundButton, { title: 'Use white transparent background' })) + this.disposables.add(atom.tooltips.add(this.refs.blackTransparentBackgroundButton, { title: 'Use black transparent background' })) + this.disposables.add(atom.tooltips.add(this.refs.transparentTransparentBackgroundButton, { title: 'Use transparent background' })) + this.disposables.add(atom.tooltips.add(this.refs.nativeBackgroundButton, { title: 'Use native background' })) const clickHandler = (event) => { event.preventDefault() @@ -62,6 +57,8 @@ class ImageEditorView { this.disposables.add(new Disposable(() => { this.refs.blackTransparentBackgroundButton.removeEventListener('click', clickHandler) })) this.refs.transparentTransparentBackgroundButton.addEventListener('click', clickHandler) this.disposables.add(new Disposable(() => { this.refs.transparentTransparentBackgroundButton.removeEventListener('click', clickHandler) })) + this.refs.nativeBackgroundButton.addEventListener('click', clickHandler) + this.disposables.add(new Disposable(() => { this.refs.nativeBackgroundButton.removeEventListener('click', clickHandler) })) const zoomInClickHandler = () => { this.zoomIn() @@ -81,26 +78,68 @@ class ImageEditorView { this.refs.resetZoomButton.addEventListener('click', resetZoomClickHandler) this.disposables.add(new Disposable(() => { this.refs.resetZoomButton.removeEventListener('click', resetZoomClickHandler) })) + const centerClickHandler = () => { + this.centerImage() + } + this.refs.centerButton.addEventListener('click', centerClickHandler) + this.disposables.add(new Disposable(() => { this.refs.centerButton.removeEventListener('click', centerClickHandler) })) + const zoomToFitClickHandler = () => { this.zoomToFit() } this.refs.zoomToFitButton.addEventListener('click', zoomToFitClickHandler) this.disposables.add(new Disposable(() => { this.refs.zoomToFitButton.removeEventListener('click', zoomToFitClickHandler) })) + + const zoomTo100ClickHandler = () => { + this.zoomTo100() + } + this.refs.zoomTo100Button.addEventListener('click', zoomTo100ClickHandler) + this.disposables.add(new Disposable(() => { this.refs.zoomTo100Button.removeEventListener('click', zoomTo100ClickHandler) })) + + const wheelContainerHandler = (event) => { + if (event.ctrlKey) { + event.stopPropagation() + const factor = event.wheelDeltaY>0 ? 1.2/1 : 1/1.2 + this.zoomToCenterPoint(factor*this.zoom) + } + } + this.refs.imageContainer.addEventListener('wheel', wheelContainerHandler) + this.disposables.add(new Disposable(() => { this.refs.imageContainer.removeEventListener('wheel', wheelContainerHandler) })) + + const wheelImageHandler = (event) => { + if (event.ctrlKey) { + event.stopPropagation() + const factor = event.wheelDeltaY>0 ? 1.2/1 : 1/1.2 + this.zoomToMousePosition(factor*this.zoom, event) + } + } + this.refs.image.addEventListener('wheel', wheelImageHandler) + this.disposables.add(new Disposable(() => { this.refs.image.removeEventListener('wheel', wheelImageHandler) })) + + this.resizeObserver = new ResizeObserver(() => { + if (this.auto===1) { + this.zoomTo100() + } else if (this.auto) { + this.zoomToFit() + } + }) + this.resizeObserver.observe(this.refs.imageContainer) } onDidLoad (callback) { return this.emitter.on('did-load', callback) } - update () {} + update() {} - destroy () { + destroy() { this.disposables.dispose() this.emitter.dispose() + this.resizeObserver.disconnect() etch.destroy(this) } - render () { + render() { return ( $.div({className: 'image-view', tabIndex: -1}, $.div({className: 'image-controls', ref: 'imageControls'}, @@ -113,6 +152,9 @@ class ImageEditorView { ), $.a({className: 'image-controls-color-transparent', value: 'transparent', ref: 'transparentTransparentBackgroundButton'}, 'transparent' + ), + $.a({className: 'image-controls-color-native', value: 'native', ref: 'nativeBackgroundButton'}, + 'native' ) ), $.div({className: 'image-controls-group btn-group'}, @@ -120,33 +162,43 @@ class ImageEditorView { '-' ), $.button({className: 'btn reset-zoom-button', ref: 'resetZoomButton'}, - 'Auto' + '' ), $.button({className: 'btn', ref: 'zoomInButton'}, '+' ) ), $.div({className: 'image-controls-group btn-group'}, - $.button({className: 'btn zoom-to-fit-button selected', ref: 'zoomToFitButton'}, + $.button({className: 'btn center-button', ref: 'centerButton'}, + 'Center' + ), + $.button({className: 'btn zoom-to-fit-button', ref: 'zoomToFitButton'}, 'Zoom to fit' + ), + $.button({className: 'btn zoom-to-100-button', ref: 'zoomTo100Button'}, + 'Zoom to 100' ) ) ), - $.div({className: 'image-container zoom-to-fit', ref: 'imageContainer'}, + $.div({className: 'image-container', ref: 'imageContainer'}, $.img({ref: 'image'}) ) ) ) } - updateImageURI () { + updateImageURI() { this.refs.image.src = `${this.editor.getEncodedURI()}?time=${Date.now()}` this.refs.image.onload = () => { this.refs.image.onload = null this.originalHeight = this.refs.image.naturalHeight this.originalWidth = this.refs.image.naturalWidth this.imageSize = fs.statSync(this.editor.getPath()).size + this.loaded = true + this.zoomTo100() + this.refs.image.style.display = '' this.emitter.emit('did-update') + this.emitter.emit('did-load') } } @@ -154,111 +206,126 @@ class ImageEditorView { return this.emitter.on('did-update', callback) } - // Zooms the image out by 25%. - zoomOut () { - this.percentageStep = Math.max(0, --this.percentageStep) - this.adjustSize(this.percentageStep) + updateSize(zoom) { + if (!this.loaded || this.element.offsetHeight === 0) { + return + } + this.auto = false + this.refs.zoomToFitButton.classList.remove('selected') + this.refs.zoomTo100Button.classList.remove('selected') + const prev = this.zoom + this.zoom = Math.min(Math.max(zoom, 0.001), 100) + this.step = this.zoom/prev + const newWidth = Math.round(this.refs.image.naturalWidth * this.zoom) + const newHeight = Math.round(this.refs.image.naturalHeight * this.zoom) + const percent = Math.round(this.zoom * 1000) / 10 + this.refs.image.style.width = newWidth + 'px' + this.refs.image.style.height = newHeight + 'px' + this.refs.resetZoomButton.textContent = percent + '%' } - // Zooms the image in by 25%. - zoomIn () { - this.percentageStep = Math.min(this.steps.length - 1, ++this.percentageStep) - this.adjustSize(this.percentageStep) + centerImage() { + this.refs.imageContainer.scrollTop = this.zoom * this.refs.image.naturalHeight / 2 - this.refs.imageContainer.offsetHeight / 2 + this.refs.imageContainer.scrollLeft = this.zoom *this.refs.image.naturalWidth / 2 - this.refs.imageContainer.offsetWidth / 2 } - // Zooms the image to its normal width and height. - resetZoom () { - if (!this.loaded || this.element.offsetHeight === 0) { - return - } + zoomToMousePosition(zoom, event) { + this.updateSize(zoom) + const {left, top} = this.refs.imageContainer.getBoundingClientRect() + this.refs.imageContainer.scrollLeft = this.step * event.offsetX - (event.pageX - left) + this.refs.imageContainer.scrollTop = this.step * event.offsetY - (event.pageY - top) + } - this.mode = 'reset-zoom' - this.refs.imageContainer.classList.remove('zoom-to-fit') - this.refs.zoomToFitButton.classList.remove('selected') - this.refs.image.style.width = this.originalWidth + 'px' - this.refs.image.style.height = this.originalHeight + 'px' - this.refs.resetZoomButton.textContent = '100%' - this.percentageStep = 4 + zoomToCenterPoint(zoom) { + const coorX = this.refs.imageContainer.scrollLeft + this.refs.imageContainer.offsetWidth / 2 + const coorY = this.refs.imageContainer.scrollTop + this.refs.imageContainer.offsetHeight / 2 + this.updateSize(zoom) + this.refs.imageContainer.scrollLeft = this.step * coorX - this.refs.imageContainer.offsetWidth / 2 + this.refs.imageContainer.scrollTop = this.step * coorY - this.refs.imageContainer.offsetHeight / 2 } - // Zooms to fit the image, doesn't scale beyond actual size - zoomToFit () { + _zoomToFit(limit, auto, element) { if (!this.loaded || this.element.offsetHeight === 0) { return } + let zoom = Math.min( + this.refs.imageContainer.offsetWidth / this.refs.image.naturalWidth, + this.refs.imageContainer.offsetHeight / this.refs.image.naturalHeight, + ) + if (limit) { zoom = Math.min(zoom, limit) } + this.updateSize(zoom) + this.auto = auto + element.classList.add('selected') + } - this.mode = 'zoom-to-fit' - this.refs.imageContainer.classList.add('zoom-to-fit') - this.refs.zoomToFitButton.classList.add('selected') - this.refs.image.style.width = '' - this.refs.image.style.height = '' - this.refs.resetZoomButton.textContent = 'Auto' - this.percentageStep = 4 + zoomToFit() { + this._zoomToFit(false, true, this.refs.zoomToFitButton) } - // Adjust the size of the image by the given multiplying factor. - // - // factor - A {Number} to multiply against the current size. - adjustSize (percentageStep) { - if (!this.loaded || this.element.offsetHeight === 0) { - return - } + zoomTo100() { + this._zoomToFit(1, 1, this.refs.zoomTo100Button) + } - if (this.mode === 'zoom-to-fit') { - this.mode = 'zoom-manual' - this.refs.imageContainer.classList.remove('zoom-to-fit') - this.refs.zoomToFitButton.classList.remove('selected') - } else if (this.mode === 'reset-zoom') { - this.mode = 'zoom-manual' + zoomOut() { + for (let i = this.levels.length-1; i >= 0; i--) { + if (this.levels[i] (this.originalWidth * 2)) { - this.refs.image.style.imageRendering = 'pixelated' - } else { - this.refs.image.style.imageRendering = '' + zoomIn() { + for (let i = 0; i < this.levels.length; i++) { + if (this.levels[i]>this.zoom) { + this.zoomToCenterPoint(this.levels[i]) + break + } } + } - this.refs.image.style.width = newWidth + 'px' - this.refs.image.style.height = newHeight + 'px' - this.refs.resetZoomButton.textContent = percent + '%' + resetZoom() { + if (!this.loaded || this.element.offsetHeight === 0) { + return + } + this.zoomToCenterPoint(1) } - // Changes the background color of the image view. - // - // color - A {String} that gets used as class name. changeBackground (color) { if (this.loaded && this.element.offsetHeight > 0 && color) { this.refs.imageContainer.setAttribute('background', color) } } - scrollUp () { - this.refs.imageContainer.scrollTop -= document.body.offsetHeight / 20 + scrollUp() { + this.refs.imageContainer.scrollTop -= this.refs.imageContainer.offsetHeight / 10 + } + + scrollDown() { + this.refs.imageContainer.scrollTop += this.refs.imageContainer.offsetHeight / 10 + } + + scrollLeft() { + this.refs.imageContainer.scrollLeft -= this.refs.imageContainer.offsetWidth / 10 } - scrollDown () { - this.refs.imageContainer.scrollTop += document.body.offsetHeight / 20 + scrollRight() { + this.refs.imageContainer.scrollLeft += this.refs.imageContainer.offsetWidth / 10 } - pageUp () { + pageUp() { this.refs.imageContainer.scrollTop -= this.element.offsetHeight } - pageDown () { + pageDown() { this.refs.imageContainer.scrollTop += this.element.offsetHeight } - scrollToTop () { + scrollToTop() { this.refs.imageContainer.scrollTop = 0 } - scrollToBottom () { + scrollToBottom() { this.refs.imageContainer.scrollTop = this.refs.imageContainer.scrollHeight } } diff --git a/packages/image-view/lib/main.js b/packages/image-view/lib/main.js index ba4819008d..0cf3103966 100644 --- a/packages/image-view/lib/main.js +++ b/packages/image-view/lib/main.js @@ -9,7 +9,7 @@ module.exports = { config: { defaultBackgroundColor: { type: 'string', - enum: ['white', 'black', 'transparent'], + enum: ['white', 'black', 'transparent', 'native'], default: 'transparent' } }, diff --git a/packages/image-view/spec/image-editor-view-spec.js b/packages/image-view/spec/image-editor-view-spec.js index 7eed47934e..dd813ea529 100644 --- a/packages/image-view/spec/image-editor-view-spec.js +++ b/packages/image-view/spec/image-editor-view-spec.js @@ -101,10 +101,10 @@ describe('ImageEditorView', () => { }) }) - describe('.adjustSize(factor)', () => { - it('does not allow a zoom percentage lower than 10%', () => { - view.adjustSize(0) - expect(view.refs.resetZoomButton.textContent).toBe('10%') + describe('.updateSize(zoom)', () => { + it('does not allow a zoom percentage lower than 0.1%', () => { + view.updateSize(0) + expect(view.refs.resetZoomButton.textContent).toBe('0.1%') }) }) diff --git a/packages/image-view/styles/image-view.less b/packages/image-view/styles/image-view.less index 68ca307600..860b5a885b 100644 --- a/packages/image-view/styles/image-view.less +++ b/packages/image-view/styles/image-view.less @@ -54,6 +54,10 @@ background-image: url(@transparent-background-image); } + &-color-native { + background-color: @app-background-color; + } + .btn-group { margin: @spacing; } @@ -61,12 +65,6 @@ .reset-zoom-button { min-width: 5em; } - - // disabled once the button is selected - .zoom-to-fit-button.selected { - pointer-events: none; - cursor: default; - } } @@ -78,9 +76,10 @@ overflow: auto; img { - display: block !important; + display: block; flex: none; margin: auto; + -webkit-user-drag: none; } } @@ -93,26 +92,8 @@ background-color: black; background-image: url(@transparent-background-image); } - - - - // Zoom to fit ------------------- - // Scales the image to fit the available space. - - .zoom-to-fit { - &.image-container { - padding: @component-padding; - - img { - flex: 1 1 0; - min-width: 0; - margin: 0; - - // Alternative: object-fit: contain; - // then it would also scale larger than its original size - object-fit: scale-down; - } - } + [background="native"] { + background-color: @app-background-color; } }