diff --git a/app/assets/stylesheets/pageflow/editor/background_positioning.scss b/app/assets/stylesheets/pageflow/editor/background_positioning.scss index 4239d585b3..a4acf5a12f 100644 --- a/app/assets/stylesheets/pageflow/editor/background_positioning.scss +++ b/app/assets/stylesheets/pageflow/editor/background_positioning.scss @@ -43,7 +43,6 @@ .image { outline: solid 1px var(--ui-on-surface-color-light); - background-size: cover; } .label { @@ -58,15 +57,32 @@ position: relative; height: 100%; - img { + // Shrinks to the size of whatever renders the file, so the sliders + // line up with its edges. + .file { + --positioning-max-width: 600px; + --positioning-max-height: 300px; + + display: inline-block; outline: solid 1px var(--ui-on-surface-color-light); - display: block; - max-height: 300px; - max-width: 600px; + line-height: 0; cursor: crosshair; } } + &-image-contain { + display: block; + max-width: var(--positioning-max-width, 100%); + max-height: var(--positioning-max-height, none); + } + + &-image-cover { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + .slider { position: absolute; border: none; diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js index 709accea4a..94583d903a 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js @@ -124,7 +124,27 @@ describe('LottieAnimation', () => { it('contains animation inside its intrinsic aspect ratio by default', () => { renderLottieAnimation(); - expect(players[0].config.layout).toEqual({fit: 'contain'}); + expect(players[0].config.layout.fit).toEqual('contain'); + }); + + it('centers animation by default', () => { + renderLottieAnimation(); + + expect(players[0].config.layout.align).toEqual([0.5, 0.5]); + }); + + it('aligns animation according to crop position', () => { + renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'crop', value: 'wide'} + ], + cropPosition: {x: 20, y: 30} + } + }); + + expect(players[0].config.layout.align).toEqual([0.2, 0.3]); }); describe('crop image modifier', () => { @@ -166,7 +186,7 @@ describe('LottieAnimation', () => { } }); - expect(players[0].config.layout).toEqual({fit: 'cover'}); + expect(players[0].config.layout.fit).toEqual('cover'); }); it('forces 1:1 aspect ratio for circle crop', () => { diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js index 8b6fb15693..9aaa757b00 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js @@ -1,5 +1,5 @@ import {editor} from 'pageflow-scrolled/editor'; -import {SelectInput, useFakeFeatures} from 'pageflow/testHelpers'; +import {FileInput, SelectInput, useFakeFeatures} from 'pageflow/testHelpers'; import {renderContentElementConfigurationEditor, useEditorGlobals} from 'support'; @@ -77,6 +77,26 @@ describe('lottieAnimation/editor', () => { expect(configurationEditor.visibleInputPropertyNames()) .not.toContain('imageModifiers'); }); + + it('offers to position animation inside crop', () => { + const configurationEditor = renderConfigurationEditor({ + lottieFiles: [{perma_id: 100, state: 'uploaded'}], + configuration: {id: 100, imageModifiers: [{name: 'crop', value: 'wide'}]} + }); + + expect(FileInput.findByPropertyName('id', {inView: configurationEditor}).menuItemNames()) + .toContain('edit_background_positioning'); + }); + + it('does not offer to position animation that is not cropped', () => { + const configurationEditor = renderConfigurationEditor({ + lottieFiles: [{perma_id: 100, state: 'uploaded'}], + configuration: {id: 100} + }); + + expect(FileInput.findByPropertyName('id', {inView: configurationEditor}).menuItemNames()) + .not.toContain('edit_background_positioning'); + }); }); it('registers lottie file type for lottie_files collection', () => { diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePlayerView-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePlayerView-spec.js new file mode 100644 index 0000000000..23d8fc22ba --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePlayerView-spec.js @@ -0,0 +1,84 @@ +import '@testing-library/jest-dom/extend-expect'; + +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile'; +import { + LottieFilePlayerView +} from 'contentElements/lottieAnimation/editor/views/LottieFilePlayerView'; + +import {fakeDotLottiePlayers} from 'support/fakeDotLottiePlayers'; + +jest.mock('@lottiefiles/dotlottie-web', () => ({ + DotLottie: Object.assign(jest.fn(), {setWasmUrl: jest.fn()}) +})); + +describe('LottieFilePlayerView', () => { + const {players} = fakeDotLottiePlayers(); + + const TestView = LottieFilePlayerView.extend({ + template: () => '' + }); + + function playerView(View = TestView) { + return new View({ + model: new LottieFile({ + state: 'uploaded', + original_url: '/animation.lottie' + }) + }); + } + + it('hides view while the animation is loading', () => { + const view = playerView(); + + render(view); + + expect(view.el).not.toBeVisible(); + }); + + it('keeps view in the layout while the animation is loading', () => { + const view = playerView(); + + render(view); + + expect(window.getComputedStyle(view.el).display).not.toEqual('none'); + }); + + it('displays view once the animation has loaded', () => { + const view = playerView(); + + render(view); + players[0].emit('load'); + + expect(view.el).toBeVisible(); + }); + + it('resizes the player between sizing and displaying the view', () => { + const steps = []; + const view = playerView(TestView.extend({ + onAnimationLoad: () => steps.push('size') + })); + + render(view); + players[0].resize.mockImplementation(() => { + steps.push('resize'); + expect(view.el).not.toBeVisible(); + }); + players[0].emit('load'); + + expect(steps).toEqual(['size', 'resize']); + }); + + it('lets sub classes size the view before it is displayed', () => { + const onAnimationLoad = jest.fn(function() { + expect(this.el).not.toBeVisible(); + }); + const view = playerView(TestView.extend({onAnimationLoad})); + + render(view); + players[0].emit('load'); + + expect(onAnimationLoad).toHaveBeenCalled(); + }); +}); diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePositioningView-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePositioningView-spec.js new file mode 100644 index 0000000000..f1d75b74d9 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePositioningView-spec.js @@ -0,0 +1,91 @@ +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile'; +import { + LottieFilePositioningView +} from 'contentElements/lottieAnimation/editor/views/LottieFilePositioningView'; + +import {fakeDotLottiePlayers} from 'support/fakeDotLottiePlayers'; + +jest.mock('@lottiefiles/dotlottie-web', () => ({ + DotLottie: Object.assign(jest.fn(), {setWasmUrl: jest.fn()}) +})); + +describe('LottieFilePositioningView', () => { + const {players, setAnimationSize} = fakeDotLottiePlayers(); + + function positioningView(options) { + return new LottieFilePositioningView({ + model: new LottieFile({ + state: 'uploaded', + original_url: '/animation.lottie' + }), + fit: 'contain', + ...options + }); + } + + it('renders animation of the file in a canvas', () => { + const view = positioningView(); + + render(view); + + expect(players).toHaveLength(1); + expect(players[0].config.src).toEqual('/animation.lottie'); + expect(players[0].config.canvas).toBe(view.el.querySelector('canvas')); + }); + + it('plays the animation in a loop', () => { + render(positioningView()); + + expect(players[0].config.autoplay).toBe(true); + expect(players[0].config.loop).toBe(true); + }); + + it('applies the given fit', () => { + render(positioningView({fit: 'cover'})); + + expect(players[0].config.layout.fit).toEqual('cover'); + }); + + it('aligns the animation according to the position', () => { + const view = positioningView({fit: 'cover'}); + + render(view); + players[0].emit('load'); + view.setPosition(20, 30); + + expect(players[0].setLayout).toHaveBeenLastCalledWith({fit: 'cover', align: [0.2, 0.3]}); + }); + + it('applies position set while the animation was still loading', () => { + const view = positioningView({fit: 'cover'}); + + render(view); + view.setPosition(20, 30); + players[0].setLayout.mockClear(); + players[0].emit('load'); + + expect(players[0].setLayout).toHaveBeenCalledWith({fit: 'cover', align: [0.2, 0.3]}); + }); + + it('sizes itself to the animation once it has loaded', () => { + setAnimationSize({width: 200, height: 100}); + const view = positioningView(); + + render(view); + players[0].emit('load'); + + expect(view.el.style.getPropertyValue('--positioning-aspect-ratio')).toEqual('2'); + expect(view.el.style.getPropertyValue('--positioning-width')).toEqual('200px'); + }); + + it('destroys player when closed', () => { + const view = positioningView(); + render(view); + + view.close(); + + expect(players[0].destroy).toHaveBeenCalled(); + }); +}); diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView-spec.js index 128bf7cfce..4900fe0bd0 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView-spec.js @@ -44,6 +44,14 @@ describe('LottieFileThumbnailView', () => { expect(players[0].setFrame).toHaveBeenCalledWith(59); }); + it('crops the animation to fill the thumbnail', () => { + const view = thumbnailView(); + + render(view); + + expect(players[0].config.layout).toEqual({fit: 'cover'}); + }); + it('does not play the animation', () => { const view = thumbnailView(); diff --git a/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js index 19f1c18765..bdfa595a69 100644 --- a/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js +++ b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js @@ -34,6 +34,8 @@ export function fakeDotLottiePlayers({act = fn => fn()} = {}) { play: jest.fn(), pause: jest.fn(), setFrame: jest.fn(), + setLayout: jest.fn(), + resize: jest.fn(), destroy: jest.fn(), animationSize: jest.fn(() => animationSize), diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js index f0694e7ec2..4fae924b02 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js @@ -42,6 +42,8 @@ export function LottieAnimation({configuration}) { loop={configuration.playbackMode !== 'playOnce'} play={isVisible} fit={aspectRatio ? 'cover' : 'contain'} + cropPositionX={configuration.cropPosition?.x} + cropPositionY={configuration.cropPosition?.y} onAspectRatioChange={setAnimationAspectRatio} />} { if (play) { diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js index 191fad8f26..3b78b3692d 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -1,8 +1,10 @@ import {editor, ImageModifierListInputView, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; +import {processImageModifiers} from 'pageflow-scrolled/frontend'; import {FileInputView} from 'pageflow/editor'; import {SelectInputView, SeparatorView} from 'pageflow/ui'; import {LottieFile} from './models/LottieFile'; +import {LottieFilePositioningView} from './views/LottieFilePositioningView'; import {LottieFilePreviewView} from './views/LottieFilePreviewView'; import {LottieFileThumbnailView} from './views/LottieFileThumbnailView'; @@ -10,6 +12,7 @@ import pictogram from './pictogram.svg'; editor.fileTypes.register('lottie_files', { model: LottieFile, + positioningView: LottieFilePositioningView, previewView: LottieFilePreviewView, thumbnailView: LottieFileThumbnailView, @@ -41,7 +44,15 @@ editor.contentElementTypes.register('lottieAnimation', { this.input('id', FileInputView, { collection: 'lottie_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: false, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, + positioningBinding: 'imageModifiers', + positioningOptions: () => { + const {aspectRatio} = processImageModifiers(this.model.get('imageModifiers')); + + return { + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) + }; + }, dropDownMenuItems: [InlineFileRightsMenuItem] }); this.input('imageModifiers', ImageModifierListInputView, { diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.js index 234a246a32..9ded43bf66 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.js @@ -1,6 +1,7 @@ import {UploadableFile} from 'pageflow/editor'; -// Registering a file type sets model naming on the prototype of its -// model, which requires a class of its own even though the thumbnail -// and preview views cover everything specific to lottie files. -export const LottieFile = UploadableFile.extend({}); +export const LottieFile = UploadableFile.extend({ + isPositionable: function() { + return this.isReady(); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js new file mode 100644 index 0000000000..d49655f055 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js @@ -0,0 +1,48 @@ +import Marionette from 'backbone.marionette'; + +import {DotLottie} from '../../dotLottie'; + +// Base class for views which play a lottie file. Sub classes render a +// canvas, return the options the player shall be created with from +// `playerOptions` and use `onAnimationLoad` for everything that +// requires the player to have read the file. +export const LottieFilePlayerView = Marionette.ItemView.extend({ + ui: {canvas: 'canvas'}, + + onRender: function() { + // Sub classes size themselves based on dimensions which only the + // animation provides. The canvas stays invisible until the player + // has read them. Taking it out of the layout instead would let the + // surrounding box collapse and jump back open. + this.$el.css('visibility', 'hidden'); + + this.player = new DotLottie({ + canvas: this.ui.canvas[0], + src: this.model.get('original_url'), + renderConfig: {autoResize: true}, + ...this.playerOptions() + }); + + this.player.addEventListener('load', () => { + this.onAnimationLoad(); + + // The player has already drawn a frame into a canvas of the size + // the view had before. It only notices the new size via a resize + // observer, which fires too late to keep the frame from being + // stretched into the new box. + this.player.resize(); + + this.$el.css('visibility', ''); + }); + }, + + playerOptions: function() { + return {}; + }, + + onAnimationLoad: function() {}, + + onClose: function() { + this.player.destroy(); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.js new file mode 100644 index 0000000000..c2767ca8bb --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.js @@ -0,0 +1,44 @@ +import {LottieFilePlayerView} from './LottieFilePlayerView'; + +import styles from './LottieFilePositioningView.module.css'; + +export const LottieFilePositioningView = LottieFilePlayerView.extend({ + template: () => ``, + + className: function() { + return styles[this.options.fit]; + }, + + playerOptions: function() { + return {autoplay: true, loop: true, layout: this.layout()}; + }, + + setPosition: function(x, y) { + this.align = [x / 100, y / 100]; + this.player.setLayout(this.layout()); + }, + + // The player ignores layout changes as long as it is still reading + // the file. + onAnimationLoad: function() { + this.player.setLayout(this.layout()); + this.applyDimensions(); + }, + + layout: function() { + return {fit: this.options.fit, align: this.align}; + }, + + // Unlike images and videos, lottie files have no dimensions stored on + // the server. The box can thus only be sized once the player has read + // them from the file. + applyDimensions: function() { + var size = this.player.animationSize(); + + if (size.width && size.height) { + this.el.style.setProperty('--positioning-aspect-ratio', + `${size.width / size.height}`); + this.el.style.setProperty('--positioning-width', `${size.width}px`); + } + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.module.css b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.module.css new file mode 100644 index 0000000000..af5b19dcff --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.module.css @@ -0,0 +1,23 @@ +.cover { + display: block; + width: 100%; + height: 100%; +} + +/* A max height would squash the canvas instead of scaling it down, + since the aspect ratio only determines the height before it is + clamped. Limiting the width covers both directions. */ +.contain { + display: block; + width: var(--positioning-width, 100%); + max-width: min(var(--positioning-max-width, 100%), + var(--positioning-max-height, 100vh) * + var(--positioning-aspect-ratio, 1)); + aspect-ratio: var(--positioning-aspect-ratio, 1); +} + +.canvas { + display: block; + width: 100%; + height: 100%; +} diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.js index 69481be3b7..5813bc056d 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.js @@ -1,33 +1,19 @@ -import Marionette from 'backbone.marionette'; - -import {cssModulesUtils} from 'pageflow/ui'; - -import {DotLottie} from '../../dotLottie'; +import {LottieFilePlayerView} from './LottieFilePlayerView'; import styles from './LottieFilePreviewView.module.css'; -export const LottieFilePreviewView = Marionette.ItemView.extend({ +export const LottieFilePreviewView = LottieFilePlayerView.extend({ template: () => ``, className: 'file_preview', - ui: cssModulesUtils.ui(styles, 'canvas'), - - onRender: function() { - this.player = new DotLottie({ - canvas: this.ui.canvas[0], - src: this.model.get('original_url'), - autoplay: true, - loop: true, - renderConfig: {autoResize: true} - }); - - this.player.addEventListener('load', this.applyDimensions.bind(this)); + playerOptions: function() { + return {autoplay: true, loop: true}; }, // Unlike images and videos, lottie files have no dimensions stored on // the server. The box can thus only be sized once the player has read // them from the file. - applyDimensions: function() { + onAnimationLoad: function() { var size = this.player.animationSize(); if (size.width && size.height) { @@ -35,9 +21,5 @@ export const LottieFilePreviewView = Marionette.ItemView.extend({ `${size.width} / ${size.height}`); this.ui.canvas[0].style.setProperty('--preview-width', `${size.width}px`); } - }, - - onClose: function() { - this.player.destroy(); } }); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js index 42549f76c6..cc9fa69d52 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js @@ -1,36 +1,19 @@ -import Marionette from 'backbone.marionette'; - -import {cssModulesUtils} from 'pageflow/ui'; - -import {DotLottie} from '../../dotLottie'; +import {LottieFilePlayerView} from './LottieFilePlayerView'; import styles from './LottieFileThumbnailView.module.css'; -export const LottieFileThumbnailView = Marionette.ItemView.extend({ +export const LottieFileThumbnailView = LottieFilePlayerView.extend({ template: () => ``, className: styles.thumbnail, - ui: cssModulesUtils.ui(styles, 'canvas'), - - onRender: function() { - this.player = new DotLottie({ - canvas: this.ui.canvas[0], - src: this.model.get('original_url'), - autoplay: false, - renderConfig: {autoResize: true} - }); - - this.player.addEventListener('load', this.seekToLastFrame.bind(this)); + playerOptions: function() { + return {autoplay: false, layout: {fit: 'cover'}}; }, // Animations commonly build up their scene over time, which would // leave the thumbnail close to blank on the first frame the player // draws once it has loaded the file. - seekToLastFrame: function() { + onAnimationLoad: function() { this.player.setFrame(this.player.totalFrames - 1); - }, - - onClose: function() { - this.player.destroy(); } }); diff --git a/package/spec/editor/views/BackgroundPositioningPreviewView-spec.js b/package/spec/editor/views/BackgroundPositioningPreviewView-spec.js new file mode 100644 index 0000000000..9e92e52458 --- /dev/null +++ b/package/spec/editor/views/BackgroundPositioningPreviewView-spec.js @@ -0,0 +1,91 @@ +import Marionette from 'backbone.marionette'; + +import {BackgroundPositioningPreviewView, Configuration} from 'pageflow/editor'; + +import * as support from '$support'; +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +describe('BackgroundPositioningPreviewView', () => { + const PositioningView = Marionette.ItemView.extend({ + template: () => '', + setPosition: function() {} + }); + + function previewView({positioningView, ...attributes} = {}) { + const fixture = support.factories.imageFilesFixture({ + fileTypeOptions: {positioningView}, + imageFileAttributes: {perma_id: 5, url: '/image.jpg'} + }); + + return new BackgroundPositioningPreviewView({ + model: new Configuration({file_id: 5, ...attributes}), + propertyName: 'file_id', + filesCollection: fixture.imageFiles, + ratio: 16 / 9, + maxSize: 200, + label: 'Preview' + }); + } + + it('renders image of the file cropped to the given position', () => { + const view = previewView({file_x: 20, file_y: 30}); + + render(view); + + const image = view.el.querySelector('.image img'); + + expect(image.getAttribute('src')).toEqual('/image.jpg'); + expect(image.style.objectPosition).toEqual('20% 30%'); + }); + + it('renders the positioning view of the file type instead', () => { + const view = previewView({positioningView: PositioningView}); + + render(view); + + expect(view.el.querySelectorAll('.file_stand_in')).toHaveLength(1); + expect(view.el.querySelector('img')).toBeNull(); + }); + + it('lets the positioning view crop the file', () => { + const initialize = jest.fn(); + const view = previewView({positioningView: PositioningView.extend({initialize})}); + + render(view); + + expect(initialize).toHaveBeenCalledWith(expect.objectContaining({fit: 'cover'})); + }); + + it('passes position to the positioning view', () => { + const setPosition = jest.fn(); + const view = previewView({ + positioningView: PositioningView.extend({setPosition}), + file_x: 20, + file_y: 30 + }); + + render(view); + + expect(setPosition).toHaveBeenCalledWith(20, 30); + }); + + it('updates position of the positioning view when the model changes', () => { + const setPosition = jest.fn(); + const view = previewView({positioningView: PositioningView.extend({setPosition})}); + + render(view); + view.model.setFilePosition('file_id', 'x', 80); + + expect(setPosition).toHaveBeenLastCalledWith(80, 50); + }); + + it('closes positioning view of the file type when closed', () => { + const onClose = jest.fn(); + const view = previewView({positioningView: PositioningView.extend({onClose})}); + + render(view); + view.close(); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/package/spec/editor/views/BackgroundPositioningSlidersView-spec.js b/package/spec/editor/views/BackgroundPositioningSlidersView-spec.js new file mode 100644 index 0000000000..f3d056b084 --- /dev/null +++ b/package/spec/editor/views/BackgroundPositioningSlidersView-spec.js @@ -0,0 +1,61 @@ +import Marionette from 'backbone.marionette'; + +import {BackgroundPositioningSlidersView, Configuration} from 'pageflow/editor'; + +import * as support from '$support'; +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +describe('BackgroundPositioningSlidersView', () => { + const PositioningView = Marionette.ItemView.extend({ + template: () => '' + }); + + function slidersView(fileTypeOptions) { + const fixture = support.factories.imageFilesFixture({ + fileTypeOptions, + imageFileAttributes: {perma_id: 5, url: '/image.jpg'} + }); + + return new BackgroundPositioningSlidersView({ + model: new Configuration({file_id: 5}), + propertyName: 'file_id', + filesCollection: fixture.imageFiles + }); + } + + it('renders image of the file', () => { + const view = slidersView(); + + render(view); + + expect(view.el.querySelector('img').getAttribute('src')).toEqual('/image.jpg'); + }); + + it('renders the positioning view of the file type instead', () => { + const view = slidersView({positioningView: PositioningView}); + + render(view); + + expect(view.el.querySelectorAll('.file_stand_in')).toHaveLength(1); + expect(view.el.querySelector('img')).toBeNull(); + }); + + it('lets the positioning view render the complete file', () => { + const initialize = jest.fn(); + const view = slidersView({positioningView: PositioningView.extend({initialize})}); + + render(view); + + expect(initialize).toHaveBeenCalledWith(expect.objectContaining({fit: 'contain'})); + }); + + it('closes positioning view of the file type when closed', () => { + const onClose = jest.fn(); + const view = slidersView({positioningView: PositioningView.extend({onClose})}); + + render(view); + view.close(); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/package/src/editor/api/FileType.js b/package/src/editor/api/FileType.js index 4f9d46e191..cd1a5240d1 100644 --- a/package/src/editor/api/FileType.js +++ b/package/src/editor/api/FileType.js @@ -2,6 +2,8 @@ import _ from 'underscore'; import {Object} from 'pageflow/ui'; +import {BackgroundPositioningImageView} from '../views/BackgroundPositioningImageView'; + export const FileType = Object.extend({ initialize: function(options) { this.model = options.model; @@ -23,6 +25,7 @@ export const FileType = Object.extend({ this.metaDataAttributes = options.metaDataAttributes || []; this.previewView = options.previewView; this.thumbnailView = options.thumbnailView; + this.positioningView = options.positioningView || BackgroundPositioningImageView; if (typeof options.matchUpload === 'function') { this.matchUpload = options.matchUpload; diff --git a/package/src/editor/index.js b/package/src/editor/index.js index a366ab346c..552b00ebd6 100644 --- a/package/src/editor/index.js +++ b/package/src/editor/index.js @@ -96,6 +96,7 @@ export * from './controllers/SidebarController'; export * from './views/UploaderView'; export * from './views/BackgroundPositioningView'; +export * from './views/BackgroundPositioningImageView'; export * from './views/ExplorerFileItemView'; export * from './views/ConfirmableFileItemView'; export * from './views/ScrollingView'; diff --git a/package/src/editor/models/ReusableFile.js b/package/src/editor/models/ReusableFile.js index 22baa9c956..1ad71ac76a 100644 --- a/package/src/editor/models/ReusableFile.js +++ b/package/src/editor/models/ReusableFile.js @@ -96,6 +96,12 @@ export const ReusableFile = Backbone.Model.extend({ return new ThumbnailView({model: this}); }, + createPositioningView: function(options) { + var PositioningView = this.fileType().positioningView; + + return new PositioningView({model: this, ...options}); + }, + title: function() { return this.get('display_name') || this.get('file_name'); }, diff --git a/package/src/editor/templates/backgroundPositioningSliders.jst b/package/src/editor/templates/backgroundPositioningSliders.jst index 3d1e68f0a1..c0258f785b 100644 --- a/package/src/editor/templates/backgroundPositioningSliders.jst +++ b/package/src/editor/templates/backgroundPositioningSliders.jst @@ -1,4 +1,5 @@
+
diff --git a/package/src/editor/views/BackgroundPositioningImageView.js b/package/src/editor/views/BackgroundPositioningImageView.js new file mode 100644 index 0000000000..e38844c7db --- /dev/null +++ b/package/src/editor/views/BackgroundPositioningImageView.js @@ -0,0 +1,21 @@ +import Marionette from 'backbone.marionette'; + +// Renders the image which files point at via +// `getBackgroundPositioningImageUrl`. Used for all file types which do +// not bring a positioning view of their own. +export const BackgroundPositioningImageView = Marionette.ItemView.extend({ + tagName: 'img', + template: () => '', + + className: function() { + return 'background_positioning-image-' + this.options.fit; + }, + + onRender: function() { + this.$el.attr('src', this.model.getBackgroundPositioningImageUrl()); + }, + + setPosition: function(x, y) { + this.$el.css('object-position', x + '% ' + y + '%'); + } +}); diff --git a/package/src/editor/views/BackgroundPositioningPreviewView.js b/package/src/editor/views/BackgroundPositioningPreviewView.js index 05770878d3..273ecc2e3d 100644 --- a/package/src/editor/views/BackgroundPositioningPreviewView.js +++ b/package/src/editor/views/BackgroundPositioningPreviewView.js @@ -16,9 +16,22 @@ export const BackgroundPositioningPreviewView = Marionette.ItemView.extend({ }, onRender: function() { + this.renderFile(); this.update(); }, + // File types can crop the file themselves, which is the only way to + // preview files that have no image to position on the server. + renderFile: function() { + var file = this.file(); + + this.positioningView = file && file.createPositioningView({fit: 'cover'}); + + if (this.positioningView) { + this.appendSubview(this.positioningView, {to: this.ui.image}); + } + }, + update: function() { var ratio = this.options.ratio; var max = this.options.maxSize; @@ -27,17 +40,20 @@ export const BackgroundPositioningPreviewView = Marionette.ItemView.extend({ this.ui.image.css({ width: width + 'px', - height: height + 'px', - backgroundImage: this.imageValue(), - backgroundPosition: this.model.getFilePosition(this.options.propertyName, 'x') + '% ' + - this.model.getFilePosition(this.options.propertyName, 'y') + '%' + height: height + 'px' }); + if (this.positioningView) { + this.positioningView.setPosition( + this.model.getFilePosition(this.options.propertyName, 'x'), + this.model.getFilePosition(this.options.propertyName, 'y') + ); + } + this.ui.label.text(this.options.label); }, - imageValue: function() { - var file = this.model.getReference(this.options.propertyName, this.options.filesCollection); - return file ? 'url("' + file.getBackgroundPositioningImageUrl() + '")' : 'none'; + file: function() { + return this.model.getReference(this.options.propertyName, this.options.filesCollection); } }); diff --git a/package/src/editor/views/BackgroundPositioningSlidersView.js b/package/src/editor/views/BackgroundPositioningSlidersView.js index caae8a3af1..129961c511 100644 --- a/package/src/editor/views/BackgroundPositioningSlidersView.js +++ b/package/src/editor/views/BackgroundPositioningSlidersView.js @@ -9,6 +9,7 @@ export const BackgroundPositioningSlidersView = Marionette.ItemView.extend({ ui: { container: '.container', + file: '.file', sliderHorizontal: '.horizontal.slider', sliderVertical: '.vertical.slider', @@ -18,7 +19,7 @@ export const BackgroundPositioningSlidersView = Marionette.ItemView.extend({ }, events: { - 'mousedown img': function(event) { + 'mousedown .file': function(event) { var view = this; view.saveFromEvent(event); @@ -37,7 +38,7 @@ export const BackgroundPositioningSlidersView = Marionette.ItemView.extend({ .on('mouseup', onUp); }, - 'dragstart img': function(event) { + 'dragstart .file': function(event) { event.preventDefault(); } }, @@ -48,10 +49,8 @@ export const BackgroundPositioningSlidersView = Marionette.ItemView.extend({ onRender: function() { var view = this; - var file = this.model.getReference(this.options.propertyName, this.options.filesCollection), - image = $('').attr('src', file.getBackgroundPositioningImageUrl()); - this.ui.container.append(image); + this.renderFile(); this.ui.sliderVertical.slider({ orientation: 'vertical', @@ -88,6 +87,15 @@ export const BackgroundPositioningSlidersView = Marionette.ItemView.extend({ this.update(); }, + // File types can render the file themselves, which is the only way + // to display files that do not have an image to position on the + // server. + renderFile: function() { + var file = this.model.getReference(this.options.propertyName, this.options.filesCollection); + + this.appendSubview(file.createPositioningView({fit: 'contain'}), {to: this.ui.file}); + }, + update: function() { var x = this.model.getFilePosition(this.options.propertyName, 'x'); var y = this.model.getFilePosition(this.options.propertyName, 'y'); diff --git a/package/src/testHelpers/dominos/editor/index.js b/package/src/testHelpers/dominos/editor/index.js index 1b2257b1a5..7b0ffe8e08 100644 --- a/package/src/testHelpers/dominos/editor/index.js +++ b/package/src/testHelpers/dominos/editor/index.js @@ -2,6 +2,7 @@ export * from './DropDownButton' export * from './FileMetaDataTable' export * from './FileStageItem' export * from './FileThumbnail' +export * from './inputs/FileInput' export * from './inputs/ReferenceInput' export * from './StaticThumbnail' export * from './ThemeItem' diff --git a/package/src/testHelpers/dominos/editor/inputs/FileInput.js b/package/src/testHelpers/dominos/editor/inputs/FileInput.js new file mode 100644 index 0000000000..8a285cfc78 --- /dev/null +++ b/package/src/testHelpers/dominos/editor/inputs/FileInput.js @@ -0,0 +1,12 @@ +import {Base} from '../../ui/inputs/Base'; +import {DropDownButton} from '../DropDownButton'; + +export const FileInput = Base.extend({ + menuItemNames: function() { + return DropDownButton.find(this.$el).menuItemNames(); + }, + + selectMenuItemByName: function(name) { + DropDownButton.find(this.$el).selectMenuItemByName(name); + } +});