From 03d8ba1ab80d29ebe23574dd69b01e95dde8687f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 13:28:44 +0200 Subject: [PATCH 1/8] Let file types render the source image of the positioning dialog Analogous to the preview and thumbnail views, file types can now register a Backbone view which the background positioning dialog renders in place of the image the file points at. Dragging the file to position it happens on a wrapper which shrinks to the size of whatever the view renders. --- .../editor/background_positioning.scss | 18 ++++-- .../BackgroundPositioningSlidersView-spec.js | 61 +++++++++++++++++++ package/src/editor/api/FileType.js | 3 + package/src/editor/index.js | 1 + package/src/editor/models/ReusableFile.js | 6 ++ .../backgroundPositioningSliders.jst | 1 + .../views/BackgroundPositioningImageView.js | 17 ++++++ .../views/BackgroundPositioningSlidersView.js | 18 ++++-- 8 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 package/spec/editor/views/BackgroundPositioningSlidersView-spec.js create mode 100644 package/src/editor/views/BackgroundPositioningImageView.js diff --git a/app/assets/stylesheets/pageflow/editor/background_positioning.scss b/app/assets/stylesheets/pageflow/editor/background_positioning.scss index 4239d585b3..ffd5614660 100644 --- a/app/assets/stylesheets/pageflow/editor/background_positioning.scss +++ b/app/assets/stylesheets/pageflow/editor/background_positioning.scss @@ -58,15 +58,25 @@ 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); + } + .slider { position: absolute; border: none; 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..c6533cb58b --- /dev/null +++ b/package/src/editor/views/BackgroundPositioningImageView.js @@ -0,0 +1,17 @@ +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()); + } +}); 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'); From 003620f5034746af8be84c0e23befff1d692fd67 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 13:31:45 +0200 Subject: [PATCH 2/8] Let file types render the crop previews of the positioning dialog Rendering the positioning view of the file type with cover fit and telling it about position changes replaces the cropped background image. Positions are passed in percent, just like the background position they stand in for. --- .../editor/background_positioning.scss | 8 +- .../BackgroundPositioningPreviewView-spec.js | 91 +++++++++++++++++++ .../views/BackgroundPositioningImageView.js | 4 + .../views/BackgroundPositioningPreviewView.js | 30 ++++-- 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 package/spec/editor/views/BackgroundPositioningPreviewView-spec.js diff --git a/app/assets/stylesheets/pageflow/editor/background_positioning.scss b/app/assets/stylesheets/pageflow/editor/background_positioning.scss index ffd5614660..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 { @@ -77,6 +76,13 @@ 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/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/src/editor/views/BackgroundPositioningImageView.js b/package/src/editor/views/BackgroundPositioningImageView.js index c6533cb58b..e38844c7db 100644 --- a/package/src/editor/views/BackgroundPositioningImageView.js +++ b/package/src/editor/views/BackgroundPositioningImageView.js @@ -13,5 +13,9 @@ export const BackgroundPositioningImageView = Marionette.ItemView.extend({ 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); } }); From 962c6bb2281f6fc61cc40ac0a1de3f42cb5d4ce3 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 13:33:48 +0200 Subject: [PATCH 3/8] Play lottie animations in the background positioning dialog Both the file to drag and the crop previews render the animation in a loop, using the player's own layout options to crop and align it. Since the position may change before the player has read the file, it is applied again once the animation has loaded. --- .../views/LottieFilePositioningView-spec.js | 91 +++++++++++++++++++ .../spec/support/fakeDotLottiePlayers.js | 1 + .../lottieAnimation/editor/index.js | 2 + .../editor/views/LottieFilePositioningView.js | 63 +++++++++++++ .../LottieFilePositioningView.module.css | 23 +++++ 5 files changed, 180 insertions(+) create mode 100644 entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePositioningView-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.module.css 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/support/fakeDotLottiePlayers.js b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js index 19f1c18765..363839e00a 100644 --- a/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js +++ b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js @@ -34,6 +34,7 @@ export function fakeDotLottiePlayers({act = fn => fn()} = {}) { play: jest.fn(), pause: jest.fn(), setFrame: jest.fn(), + setLayout: jest.fn(), destroy: jest.fn(), animationSize: jest.fn(() => animationSize), 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..71215d6df1 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -3,6 +3,7 @@ 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 +11,7 @@ import pictogram from './pictogram.svg'; editor.fileTypes.register('lottie_files', { model: LottieFile, + positioningView: LottieFilePositioningView, previewView: LottieFilePreviewView, thumbnailView: LottieFileThumbnailView, 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..eedd2b8cf3 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.js @@ -0,0 +1,63 @@ +import Marionette from 'backbone.marionette'; + +import {cssModulesUtils} from 'pageflow/ui'; + +import {DotLottie} from '../../dotLottie'; + +import styles from './LottieFilePositioningView.module.css'; + +export const LottieFilePositioningView = Marionette.ItemView.extend({ + template: () => ``, + + className: function() { + return styles[this.options.fit]; + }, + + 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, + layout: this.layout(), + renderConfig: {autoResize: true} + }); + + this.player.addEventListener('load', this.onAnimationLoad.bind(this)); + }, + + 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`); + } + }, + + onClose: function() { + this.player.destroy(); + } +}); 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%; +} From 57a41531975583c2f4da1dedbd33e71da8f1915e Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 13:37:21 +0200 Subject: [PATCH 4/8] Apply crop position to lottie animations Cropping an animation to an aspect ratio leaves the choice which part of it to keep. The player's layout alignment covers the same range as the crop position, only in fractions instead of percent. --- .../lottieAnimation/LottieAnimation-spec.js | 24 +++++++++++++++++-- .../lottieAnimation/LottieAnimation.js | 10 +++++--- 2 files changed, 29 insertions(+), 5 deletions(-) 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/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) { From 310b5bd75903a8dc0cc9c323d78c3fe708784191 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 13:39:53 +0200 Subject: [PATCH 5/8] Allow positioning cropped lottie animations Just like for videos, cropping an animation to an aspect ratio enables the menu item which opens the background positioning dialog. The preview shows the aspect ratio the animation is actually cropped to. --- .../lottieAnimation/editor/index-spec.js | 22 ++++++++++++++++++- .../lottieAnimation/editor/index.js | 11 +++++++++- .../editor/models/LottieFile.js | 9 ++++---- .../src/testHelpers/dominos/editor/index.js | 1 + .../dominos/editor/inputs/FileInput.js | 12 ++++++++++ 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 package/src/testHelpers/dominos/editor/inputs/FileInput.js 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/src/contentElements/lottieAnimation/editor/index.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js index 71215d6df1..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,4 +1,5 @@ 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'; @@ -43,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/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); + } +}); From 1d0bb8ca4a0526f78862d687e1fb756d50abed16 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 15:33:15 +0200 Subject: [PATCH 6/8] Extract base class for lottie player views Creating the player for the file rendered in a canvas and destroying it again is the same for the thumbnail, the file meta data preview and the box of the positioning dialog. What differs is playback and how each of them reacts to the animation having been read. --- .../editor/views/LottieFilePlayerView.js | 32 +++++++++++++++++++ .../editor/views/LottieFilePositioningView.js | 27 +++------------- .../editor/views/LottieFilePreviewView.js | 28 +++------------- .../editor/views/LottieFileThumbnailView.js | 27 +++------------- 4 files changed, 46 insertions(+), 68 deletions(-) create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js 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..c1f976c906 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js @@ -0,0 +1,32 @@ +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() { + 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.bind(this)); + }, + + 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 index eedd2b8cf3..c2767ca8bb 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePositioningView.js @@ -1,31 +1,16 @@ -import Marionette from 'backbone.marionette'; - -import {cssModulesUtils} from 'pageflow/ui'; - -import {DotLottie} from '../../dotLottie'; +import {LottieFilePlayerView} from './LottieFilePlayerView'; import styles from './LottieFilePositioningView.module.css'; -export const LottieFilePositioningView = Marionette.ItemView.extend({ +export const LottieFilePositioningView = LottieFilePlayerView.extend({ template: () => ``, className: function() { return styles[this.options.fit]; }, - 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, - layout: this.layout(), - renderConfig: {autoResize: true} - }); - - this.player.addEventListener('load', this.onAnimationLoad.bind(this)); + playerOptions: function() { + return {autoplay: true, loop: true, layout: this.layout()}; }, setPosition: function(x, y) { @@ -55,9 +40,5 @@ export const LottieFilePositioningView = Marionette.ItemView.extend({ `${size.width / size.height}`); this.el.style.setProperty('--positioning-width', `${size.width}px`); } - }, - - onClose: function() { - this.player.destroy(); } }); 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..bb55623280 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}; }, // 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(); } }); From 2bf224736db50f0a5069d3b57b1c3527f9662ef0 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 15:47:49 +0200 Subject: [PATCH 7/8] Hide lottie player views until the animation has loaded The preview and the box of the positioning dialog size themselves based on dimensions which only the animation provides. The player has drawn its first frame into a canvas of the size the view had before and only picks up the new one via a resize observer, which fires too late to keep that frame from being stretched. Staying invisible until the player has been resized hides this, while keeping the box in the layout so displaying it does not reflow the dialog. --- .../editor/views/LottieFilePlayerView-spec.js | 84 +++++++++++++++++++ .../spec/support/fakeDotLottiePlayers.js | 1 + .../editor/views/LottieFilePlayerView.js | 18 +++- 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePlayerView-spec.js 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/support/fakeDotLottiePlayers.js b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js index 363839e00a..bdfa595a69 100644 --- a/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js +++ b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js @@ -35,6 +35,7 @@ export function fakeDotLottiePlayers({act = fn => 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/editor/views/LottieFilePlayerView.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js index c1f976c906..d49655f055 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePlayerView.js @@ -10,6 +10,12 @@ 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'), @@ -17,7 +23,17 @@ export const LottieFilePlayerView = Marionette.ItemView.extend({ ...this.playerOptions() }); - this.player.addEventListener('load', this.onAnimationLoad.bind(this)); + 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() { From 653c0a58a7ba71225c494585e0415e25ddeeee9f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 17 Aug 2026 16:12:11 +0200 Subject: [PATCH 8/8] Crop lottie animations in file thumbnails Thumbnails of images and videos fill their box just the same, which keeps rows of the file list from mixing letterboxed and filled thumbnails. --- .../editor/views/LottieFileThumbnailView-spec.js | 8 ++++++++ .../editor/views/LottieFileThumbnailView.js | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js index bb55623280..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 @@ -7,7 +7,7 @@ export const LottieFileThumbnailView = LottieFilePlayerView.extend({ className: styles.thumbnail, playerOptions: function() { - return {autoplay: false}; + return {autoplay: false, layout: {fit: 'cover'}}; }, // Animations commonly build up their scene over time, which would