From 6d94e9848a75baebe02a85d385aef89477160845 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 08:49:25 +0200 Subject: [PATCH 01/17] Register editor file types on import Registrations do not depend on the editor seed and can thus happen before the app is initialized. Only setting up the file types requires the server rendered config. This allows tests to set up file types registered by plugins together with the built in ones without having to build a separate registry. Options of the text track file type that read the seed are passed as functions since the registration now happens before the editor has booted. Icon table cells and select inputs support this just like text table cells already support functions as default value. --- .../ui/views/inputs/SelectInputView-spec.js | 13 + .../tableCells/IconTableCellView-spec.js | 18 + .../src/editor/initializers/setupFileTypes.js | 332 +++++++++--------- .../src/ui/views/inputs/SelectInputView.js | 7 +- .../ui/views/tableCells/IconTableCellView.js | 9 +- 5 files changed, 208 insertions(+), 171 deletions(-) diff --git a/package/spec/ui/views/inputs/SelectInputView-spec.js b/package/spec/ui/views/inputs/SelectInputView-spec.js index 6f4c9810ba..b3ba530a40 100644 --- a/package/spec/ui/views/inputs/SelectInputView-spec.js +++ b/package/spec/ui/views/inputs/SelectInputView-spec.js @@ -23,6 +23,19 @@ describe('pageflow.SelectInputView', () => { expect($('select', selectInputView.el).val()).toEqual('second'); }); + it('supports passing function returning values', () => { + var model = new Model({value: 'second'}); + var selectInputView = new SelectInputView({ + model: model, + propertyName: 'value', + values: () => ['first', 'second'] + }); + + selectInputView.render(); + + expect($('select', selectInputView.el).val()).toEqual('second'); + }); + it('saves value on change', () => { var model = new Model(); var selectInputView = new SelectInputView({ diff --git a/package/spec/ui/views/tableCells/IconTableCellView-spec.js b/package/spec/ui/views/tableCells/IconTableCellView-spec.js index 1846387bc2..d0f915b3ef 100644 --- a/package/spec/ui/views/tableCells/IconTableCellView-spec.js +++ b/package/spec/ui/views/tableCells/IconTableCellView-spec.js @@ -62,6 +62,24 @@ describe('IconTableCellView', () => { expect(cell.$el).toHaveClass('caustic'); }); + it('supports passing function returning icons', () => { + var toxin = new Backbone.Model({warning: 'caustic'}); + var cell = new IconTableCellView({ + column: { + name: 'warning' + }, + model: toxin, + icons: () => icons + }); + + cell.render(); + toxin.set('warning', 'radioactive'); + cell.render(); + + expect(cell.$el).not.toHaveClass('caustic'); + expect(cell.$el).toHaveClass('radioactive'); + }); + it( 'removes previous class when changing column attribute value', () => { diff --git a/package/src/editor/initializers/setupFileTypes.js b/package/src/editor/initializers/setupFileTypes.js index 8a71aae94f..11e0f6602f 100644 --- a/package/src/editor/initializers/setupFileTypes.js +++ b/package/src/editor/initializers/setupFileTypes.js @@ -20,6 +20,172 @@ import {TextTracksView} from '../views/TextTracksView'; import {state} from '$state'; +var textTracksMetaDataAttribute = { + name: 'text_tracks', + valueView: TextTracksFileMetaDataItemValueView, + valueViewOptions: { + settingsDialogTabLink: 'text_tracks', + } +}; + +var textTracksSettingsDialogTab = { + name: 'text_tracks', + view: TextTracksView, + viewOptions: { + supersetCollection: function() { + return state.textTrackFiles; + } + } +}; + +var altMetaDataAttribute = { + name: 'alt', + valueView: TextFileMetaDataItemValueView, + valueViewOptions: { + fromConfiguration: true, + settingsDialogTabLink: 'general' + } +}; + +var altConfigurationEditorInput = { + name: 'alt', + inputView: TextInputView, + inputViewOptions: { + maxLength: 5000 + } +}; + +editor.fileTypes.register('image_files', { + model: ImageFile, + previewView: ImageFilePreviewView, + metaDataAttributes: [ + 'dimensions', + altMetaDataAttribute + ], + matchUpload: /^image/, + configurationEditorInputs: [ + altConfigurationEditorInput + ] +}); + +editor.fileTypes.register('video_files', { + model: VideoFile, + previewView: VideoFilePreviewView, + metaDataAttributes: [ + 'format', + 'dimensions', + 'duration', + textTracksMetaDataAttribute, + altMetaDataAttribute + ], + matchUpload: /^video/, + configurationEditorInputs: [ + altConfigurationEditorInput + ], + settingsDialogTabs: [ + textTracksSettingsDialogTab + ] +}); + +editor.fileTypes.register('audio_files', { + model: AudioFile, + previewView: AudioFilePreviewView, + metaDataAttributes: [ + 'format', + 'duration', + textTracksMetaDataAttribute, + altMetaDataAttribute + ], + matchUpload: /^audio/, + configurationEditorInputs: [ + altConfigurationEditorInput + ], + settingsDialogTabs: [ + textTracksSettingsDialogTab + ] +}); + +editor.fileTypes.register('text_track_files', { + model: TextTrackFile, + matchUpload: function(upload) { + return upload.name.match(/\.vtt$/) || + upload.name.match(/\.srt$/); + }, + skipUploadConfirmation: true, + noExtendedFileRights: true, + configurationEditorInputs: [ + { + name: 'label', + inputView: TextInputView, + inputViewOptions: { + placeholder: function(configuration) { + var textTrackFile = configuration.parent; + return textTrackFile.inferredLabel(); + }, + placeholderBinding: TextTrackFile.displayLabelBinding + } + }, + { + name: 'kind', + inputView: SelectInputView, + inputViewOptions: { + values: () => state.config.availableTextTrackKinds, + translationKeyPrefix: 'pageflow.config.text_track_kind' + } + }, + { + name: 'srclang', + inputView: TextInputView, + inputViewOptions: { + required: true + } + } + ], + nestedFileTableColumns: [ + { + name: 'label', + cellView: TextTableCellView, + value: function(textTrackFile) { + return textTrackFile.displayLabel(); + }, + contentBinding: TextTrackFile.displayLabelBinding + }, + { + name: 'srclang', + cellView: TextTableCellView, + default: () => I18n.t('pageflow.editor.text_track_files.srclang_missing') + }, + { + name: 'kind', + cellView: IconTableCellView, + cellViewOptions: { + icons: () => state.config.availableTextTrackKinds + } + }, + ], + nestedFilesOrder: { + comparator: function(textTrackFile) { + return textTrackFile.displayLabel().toLowerCase(); + }, + binding: 'label' + } +}); + +editor.fileTypes.register('other_files', { + model: OtherFile, + metaDataAttributes: [ + altMetaDataAttribute + ], + matchUpload: () => true, + priority: 100, + configurationEditorInputs: [ + { + name: 'alt', + inputView: TextInputView + } + ] +}); + app.addInitializer(function(options) { editor.fileTypes.commonMetaDataAttributes = [ { @@ -61,171 +227,5 @@ app.addInitializer(function(options) { } ]; - var textTracksMetaDataAttribute = { - name: 'text_tracks', - valueView: TextTracksFileMetaDataItemValueView, - valueViewOptions: { - settingsDialogTabLink: 'text_tracks', - } - }; - - var textTracksSettingsDialogTab = { - name: 'text_tracks', - view: TextTracksView, - viewOptions: { - supersetCollection: function() { - return state.textTrackFiles; - } - } - }; - - var altMetaDataAttribute = { - name: 'alt', - valueView: TextFileMetaDataItemValueView, - valueViewOptions: { - fromConfiguration: true, - settingsDialogTabLink: 'general' - } - }; - - var altConfigurationEditorInput = { - name: 'alt', - inputView: TextInputView, - inputViewOptions: { - maxLength: 5000 - } - }; - - editor.fileTypes.register('image_files', { - model: ImageFile, - previewView: ImageFilePreviewView, - metaDataAttributes: [ - 'dimensions', - altMetaDataAttribute - ], - matchUpload: /^image/, - configurationEditorInputs: [ - altConfigurationEditorInput - ] - }); - - editor.fileTypes.register('video_files', { - model: VideoFile, - previewView: VideoFilePreviewView, - metaDataAttributes: [ - 'format', - 'dimensions', - 'duration', - textTracksMetaDataAttribute, - altMetaDataAttribute - ], - matchUpload: /^video/, - configurationEditorInputs: [ - altConfigurationEditorInput - ], - settingsDialogTabs: [ - textTracksSettingsDialogTab - ] - }); - - editor.fileTypes.register('audio_files', { - model: AudioFile, - previewView: AudioFilePreviewView, - metaDataAttributes: [ - 'format', - 'duration', - textTracksMetaDataAttribute, - altMetaDataAttribute - ], - matchUpload: /^audio/, - configurationEditorInputs: [ - altConfigurationEditorInput - ], - settingsDialogTabs: [ - textTracksSettingsDialogTab - ] - }); - - editor.fileTypes.register('text_track_files', { - model: TextTrackFile, - matchUpload: function(upload) { - return upload.name.match(/\.vtt$/) || - upload.name.match(/\.srt$/); - }, - skipUploadConfirmation: true, - noExtendedFileRights: true, - configurationEditorInputs: [ - { - name: 'label', - inputView: TextInputView, - inputViewOptions: { - placeholder: function(configuration) { - var textTrackFile = configuration.parent; - return textTrackFile.inferredLabel(); - }, - placeholderBinding: TextTrackFile.displayLabelBinding - } - }, - { - name: 'kind', - inputView: SelectInputView, - inputViewOptions: { - values: state.config.availableTextTrackKinds, - translationKeyPrefix: 'pageflow.config.text_track_kind' - } - }, - { - name: 'srclang', - inputView: TextInputView, - inputViewOptions: { - required: true - } - } - ], - nestedFileTableColumns: [ - { - name: 'label', - cellView: TextTableCellView, - value: function(textTrackFile) { - return textTrackFile.displayLabel(); - }, - contentBinding: TextTrackFile.displayLabelBinding - }, - { - name: 'srclang', - cellView: TextTableCellView, - default: I18n.t('pageflow.editor.text_track_files.srclang_missing') - }, - { - name: 'kind', - cellView: IconTableCellView, - cellViewOptions: { - icons: state.config.availableTextTrackKinds - } - }, - ], - nestedFilesOrder: { - comparator: function(textTrackFile) { - return textTrackFile.displayLabel().toLowerCase(); - }, - binding: 'label' - } - }); - - editor.fileTypes.register('other_files', { - model: OtherFile, - metaDataAttributes: [ - altMetaDataAttribute - ], - matchUpload: () => true, - priority: 100, - configurationEditorInputs: [ - { - name: 'alt', - inputView: TextInputView - } - ] - }); - editor.fileTypes.setup(options.config.fileTypes); }); diff --git a/package/src/ui/views/inputs/SelectInputView.js b/package/src/ui/views/inputs/SelectInputView.js index c3f78c6a38..069299eff5 100644 --- a/package/src/ui/views/inputs/SelectInputView.js +++ b/package/src/ui/views/inputs/SelectInputView.js @@ -14,8 +14,9 @@ import template from '../../templates/inputs/selectInput.jst'; * * @param {Object} [options] * - * @param {string[]} [options.values] - * List of possible values to persist in the attribute. + * @param {string[]|function} [options.values] + * List of possible values to persist in the attribute. Pass a + * function returning the list to defer reading the values. * * @param {number} [options.defaultValue] * Default value to display if property is not set. @@ -107,6 +108,8 @@ export const SelectInputView = Marionette.ItemView.extend({ }, initialize: function() { + this.options.values = _.result(this.options, 'values'); + if (this.options.collection) { this.options.values = _.pluck(this.options.collection, this.options.valueProperty); diff --git a/package/src/ui/views/tableCells/IconTableCellView.js b/package/src/ui/views/tableCells/IconTableCellView.js index adcfd0c814..a2868bbee7 100644 --- a/package/src/ui/views/tableCells/IconTableCellView.js +++ b/package/src/ui/views/tableCells/IconTableCellView.js @@ -1,3 +1,5 @@ +import _ from 'underscore'; + import {TableCellView} from './TableCellView'; /** @@ -12,11 +14,12 @@ import {TableCellView} from './TableCellView'; * * @param {Object} [options] * - * @param {string[]} [options.icons] + * @param {string[]|function} [options.icons] * An array of all possible attribute values to be mapped to HTML * classes of the same name. A global mapping from those classes to * icon mixins is provided in - * pageflow/ui/table_cells/icon_table_cell.scss. + * pageflow/ui/table_cells/icon_table_cell.scss. Pass a function + * returning the array to defer reading the values. * * @since 12.0 */ @@ -39,6 +42,6 @@ export const IconTableCellView = TableCellView.extend({ }, removeExistingIcons: function() { - this.$el.removeClass(this.options.icons.join(' ')); + this.$el.removeClass(_.result(this.options, 'icons').join(' ')); } }); From c914b89daf54acf5b45f73c11321d51e4cce336f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 09:44:06 +0200 Subject: [PATCH 02/17] Set up registered file types in editor specs Instead of building a separate registry of test doubles, set up the global registry from fake server side configs. File types registered by content element editors are thus available in specs and plugins only need to provide the config the server would render. --- .../package/spec/support/useEditorGlobals.js | 89 ++++++++++++++----- 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/entry_types/scrolled/package/spec/support/useEditorGlobals.js b/entry_types/scrolled/package/spec/support/useEditorGlobals.js index 684cc2af3c..df915cada5 100644 --- a/entry_types/scrolled/package/spec/support/useEditorGlobals.js +++ b/entry_types/scrolled/package/spec/support/useEditorGlobals.js @@ -1,4 +1,4 @@ -import {editor, FilesCollection, Site} from 'pageflow/editor'; +import {editor, Site} from 'pageflow/editor'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {setupGlobals} from 'pageflow/testHelpers'; @@ -8,18 +8,28 @@ import I18n from 'i18n-js'; // Required to define editor.entryType global import 'editor/config'; -export function useEditorGlobals() { +// Pass server side configs of file types registered by the content +// element editor under test to make them available in created entries: +// +// useEditorGlobals({ +// fileTypes: [{ +// collectionName: 'lottie_files', +// typeName: 'PageflowScrolled::LottieFile' +// }] +// }); +// +// The client side config is taken from the registration of the imported +// module. +export function useEditorGlobals({fileTypes = []} = {}) { const {setGlobals} = setupGlobals(); - beforeEach(() => { - editor.fileTypes = factories.fileTypes( - builder => builder - .withImageFileType() - .withVideoFileType() - .withAudioFileType() - .withTextTrackFileType() + beforeAll(() => { + editor.fileTypes.setup( + [...builtInFileTypes, ...fileTypes].map(completeServerSideConfig) ); + }); + beforeEach(() => { window.I18n = I18n; }); @@ -28,6 +38,7 @@ export function useEditorGlobals() { const { metadata, imageFiles, videoFiles, audioFiles, textTrackFiles, + filesAttributes, site, ...seedOptions } = options; @@ -35,20 +46,14 @@ export function useEditorGlobals() { const {entry} = setGlobals({ entry: factories.entry(ScrolledEntry, {metadata}, { site: new Site(site), - files: FilesCollection.createForFileTypes( - [ - editor.fileTypes.findByCollectionName('image_files'), - editor.fileTypes.findByCollectionName('video_files'), - editor.fileTypes.findByCollectionName('audio_files'), - editor.fileTypes.findByCollectionName('text_track_files') - ], - { - image_files: imageFiles, - video_files: videoFiles, - audio_files: audioFiles, - text_track_files: textTrackFiles - } - ), + fileTypes: editor.fileTypes, + filesAttributes: { + image_files: imageFiles, + video_files: videoFiles, + audio_files: audioFiles, + text_track_files: textTrackFiles, + ...filesAttributes + }, entryTypeSeed: normalizeSeed(seedOptions) }) }); @@ -57,3 +62,41 @@ export function useEditorGlobals() { } }; } + +const builtInFileTypes = [ + {collectionName: 'image_files', typeName: 'Pageflow::ImageFile'}, + { + collectionName: 'video_files', + typeName: 'Pageflow::VideoFile', + nestedFileTypes: [{collectionName: 'text_track_files'}] + }, + { + collectionName: 'audio_files', + typeName: 'Pageflow::AudioFile', + nestedFileTypes: [{collectionName: 'text_track_files'}] + }, + { + collectionName: 'text_track_files', + typeName: 'Pageflow::TextTrackFile', + topLevelType: false + }, + {collectionName: 'other_files', typeName: 'Pageflow::OtherFile'} +]; + +// Mirrors how Pageflow::FileType derives these values from the model +// name. +function completeServerSideConfig({collectionName, typeName, topLevelType = true, ...rest}) { + const underscored = typeName + .replace(/::/g, '/') + .replace(/([a-z\d])([A-Z])/g, '$1_$2') + .toLowerCase(); + + return { + collectionName, + typeName, + topLevelType, + i18nKey: underscored, + paramKey: underscored.replace(/\//g, '_'), + ...rest + }; +} From c4d7215baff1a07fb860eb2f72bd19e5dce2e792 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 10:21:02 +0200 Subject: [PATCH 03/17] Support file types of plugins in normalizeSeed Turn any option ending in "Files" into a file collection of the seed. Specs of content elements that come with their own file type can thus pass files without the test helper having to know about the collection. Since model types are only compared to associate nested files, a placeholder derived from the collection name is used unless the spec passes one. --- .../spec/testHelpers/normalizeSeed-spec.js | 46 ++++++++++++++++++ .../package/src/testHelpers/normalizeSeed.js | 47 ++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js b/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js index e480e49e25..7a701eeb2b 100644 --- a/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js +++ b/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js @@ -102,6 +102,52 @@ describe('normalizeSeed', () => { }); }); + it('supports files of custom collections', () => { + const result = normalizeSeed({ + lottieFiles: [{basename: 'animation'}] + }); + + expect(result).toMatchObject({ + config: { + fileModelTypes: { + lottieFiles: 'LottieFile' + }, + fileUrlTemplates: { + lottieFiles: {} + } + }, + collections: { + lottieFiles: [ + { + id: expect.any(Number), + permaId: expect.any(Number), + isReady: true, + basename: 'animation', + configuration: {} + } + ] + } + }); + }); + + it('supports passing model type of custom collections', () => { + const result = normalizeSeed({ + fileModelTypes: {lottieFiles: 'PageflowScrolled::LottieFile'}, + lottieFiles: [{}] + }); + + expect(result).toMatchObject({ + config: { + fileModelTypes: { + lottieFiles: 'PageflowScrolled::LottieFile' + } + }, + collections: { + lottieFiles: [{id: expect.any(Number)}] + } + }); + }); + it('ensures required audio file properties are present', () => { const result = normalizeSeed({ audioFiles: [{}] diff --git a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js index 2829db9ad9..9bc9eab78f 100644 --- a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js +++ b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js @@ -15,6 +15,11 @@ * @param {Array} [options.consentVendors] - Server rendered consent vendor data. * @param {Object} [options.contentElementConsentVendors] - Consent vendor name by content element id. * @param {Object} [options.entry] - attributes of entry. + * @param {Object} [options.fileModelTypes] - Mapping of file collection names to model types. + * Only needed to associate nested files with their parent. Collections of file types + * registered by plugins get a placeholder model type. + * @param {Object} [options.fileUrlTemplates] - Mapping of file collection names to mappings of + * url template names to url templates. * @param {Array} [options.imageFiles] - Array of objects with image file attributes of entry. * @param {Array} [options.videoFiles] - Array of objects with video file attributes of entry. * @param {Array} [options.audioFiles] - Array of objects with audio file attributes of entry. @@ -54,8 +59,14 @@ export function normalizeSeed({ embed, originUrl, fileLicenses, - entryTranslations + entryTranslations, + ...customFiles } = {}) { + const customFileCollectionNames = [...new Set([ + ...Object.keys(customFiles).filter(name => name.endsWith('Files')), + ...Object.keys(fileModelTypes || {}) + ])].filter(name => !builtInFileCollectionNames.includes(name)); + const entries = entry ? [entry] : [{}]; const normalizedEntries = normalizeCollection(entries, { locale: 'en', @@ -80,6 +91,7 @@ export function normalizeSeed({ videoFiles: {}, audioFiles: {}, textTrackFiles: {}, + ...emptyUrlTemplates(customFileCollectionNames), ...fileUrlTemplates }, fileModelTypes: { @@ -87,6 +99,7 @@ export function normalizeSeed({ imageFiles: 'Pageflow::ImageFile', textTrackFiles: 'Pageflow::TextTrackFile', videoFiles: 'Pageflow::VideoFile', + ...placeholderModelTypes(customFileCollectionNames), ...fileModelTypes }, prettyUrl: prettyUrl, @@ -128,6 +141,7 @@ export function normalizeSeed({ parentFileType: null, configuration: {} }), + ...customFileCollections(customFileCollectionNames, customFiles), storylines: normalizedStorylines, chapters: normalizedChapters, sections: normalizedSections, @@ -137,6 +151,37 @@ export function normalizeSeed({ } } +const builtInFileCollectionNames = [ + 'imageFiles', + 'videoFiles', + 'audioFiles', + 'textTrackFiles' +]; + +// Model types are only compared to associate nested files with their +// parent. Specs that seed nested files need to pass the real model type +// via the fileModelTypes option. +function placeholderModelTypes(collectionNames) { + return Object.fromEntries(collectionNames.map(name => [ + name, + name.replace(/Files$/, 'File').replace(/^./, letter => letter.toUpperCase()) + ])); +} + +function emptyUrlTemplates(collectionNames) { + return Object.fromEntries(collectionNames.map(name => [name, {}])); +} + +function customFileCollections(collectionNames, customFiles) { + return Object.fromEntries(collectionNames.map(name => [ + name, + normalizeCollection(customFiles[name], { + isReady: true, + configuration: {} + }) + ])); +} + function normalizeSections(sections = [], contentElements) { const sectionDefaults = { configuration: {transition: 'scroll', backdrop: {image: '#000'}} From d84fd8435e7594897ff6479024a8325f9d319b49 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 14 Aug 2026 10:40:18 +0200 Subject: [PATCH 04/17] Remove redundant file model types from specs Only pass the fileModelTypes seed option in specs that either assert on the resulting model type or associate nested files with their parent. Everywhere else the option merely restated the defaults of the normalizeSeed test helper. --- .../spec/entryState/useDownloadableFile-spec.js | 3 --- .../package/spec/entryState/useFile-spec.js | 15 --------------- 2 files changed, 18 deletions(-) diff --git a/entry_types/scrolled/package/spec/entryState/useDownloadableFile-spec.js b/entry_types/scrolled/package/spec/entryState/useDownloadableFile-spec.js index ca97d35d2e..dda45884a8 100644 --- a/entry_types/scrolled/package/spec/entryState/useDownloadableFile-spec.js +++ b/entry_types/scrolled/package/spec/entryState/useDownloadableFile-spec.js @@ -65,9 +65,6 @@ describe('useDownloadableFile', () => { large: '/image_files/:id_partition/large/:basename.:processed_extension', } }, - fileModelTypes: { - imageFiles: 'Pageflow::ImageFile' - }, imageFiles: [] } } diff --git a/entry_types/scrolled/package/spec/entryState/useFile-spec.js b/entry_types/scrolled/package/spec/entryState/useFile-spec.js index a750927652..8659876991 100644 --- a/entry_types/scrolled/package/spec/entryState/useFile-spec.js +++ b/entry_types/scrolled/package/spec/entryState/useFile-spec.js @@ -185,9 +185,6 @@ describe('useFile', () => { 'hls-playlist': 'http://example.com/,:pageflow_hls_qualities,.mp4.csmil/master.m3u8' } }, - fileModelTypes: { - videoFiles: 'Pageflow::VideoFile' - }, videoFiles: [ { id: 100, @@ -222,9 +219,6 @@ describe('useFile', () => { large: '/image_files/:id_partition/large/:basename.:processed_extension', } }, - fileModelTypes: { - imageFiles: 'Pageflow::ImageFile' - }, imageFiles: [ { id: 100, @@ -257,9 +251,6 @@ describe('useFile', () => { large: '/image_files/:id_partition/large/:basename.:processed_extension', } }, - fileModelTypes: { - imageFiles: 'Pageflow::ImageFile' - }, imageFiles: [ { id: 100, @@ -293,9 +284,6 @@ describe('useFile', () => { ultra: '/image_files/:id_partition/ultra/:basename.:processed_extension', } }, - fileModelTypes: { - imageFiles: 'Pageflow::ImageFile' - }, imageFiles: [ { id: 100, @@ -327,9 +315,6 @@ describe('useFile', () => { high: '/video_files/:id_partition/high.mp4', }, }, - fileModelTypes: { - videoFiles: 'Pageflow::VideoFile' - }, videoFiles: [ { id: 100, From d399c78fe378148b899d8aad6183f71f77cfe846 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 10 Aug 2026 11:17:09 +0200 Subject: [PATCH 05/17] Allow file name extensions of up to six characters Uploadable file names are generated from the extension of the uploaded file. Extensions like 'lottie' were rejected by the file name validation. --- app/models/concerns/pageflow/uploadable_file.rb | 2 +- .../models/concerns/pageflow/uploadable_file_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/pageflow/uploadable_file.rb b/app/models/concerns/pageflow/uploadable_file.rb index 5c0d9b043b..5bb403b87b 100644 --- a/app/models/concerns/pageflow/uploadable_file.rb +++ b/app/models/concerns/pageflow/uploadable_file.rb @@ -20,7 +20,7 @@ module UploadableFile # rubocop:todo Style/Documentation )) validates_attachment_presence :attachment_on_s3 - validates_attachment_file_name :attachment_on_s3, matches: %r{^[^/\\]+\.\w{3,4}$} + validates_attachment_file_name :attachment_on_s3, matches: %r{^[^/\\]+\.\w{3,6}$} do_not_validate_attachment_file_type :attachment_on_s3 state_machine initial: 'uploading' do diff --git a/spec/models/concerns/pageflow/uploadable_file_spec.rb b/spec/models/concerns/pageflow/uploadable_file_spec.rb index 179440d44f..506c3d1b3d 100644 --- a/spec/models/concerns/pageflow/uploadable_file_spec.rb +++ b/spec/models/concerns/pageflow/uploadable_file_spec.rb @@ -16,6 +16,18 @@ module Pageflow expect(uploadable_file).to be_valid end + it 'is valid if file name has extension longer than four characters' do + uploadable_file = build(:uploadable_file, attachment: nil, file_name: 'animation.lottie') + + expect(uploadable_file).to be_valid + end + + it 'is invalid if file name does not have an extension' do + uploadable_file = build(:uploadable_file, attachment: nil, file_name: 'animation') + + expect(uploadable_file).not_to be_valid + end + describe '#publish' do it 'transitions to uploaded state' do uploadable_file = create(:uploadable_file, :uploading) From 1c0dd1ca1e2bc5004ca6cb956f4c9842815e082a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 10 Aug 2026 11:28:09 +0200 Subject: [PATCH 06/17] Let file models outside Pageflow namespace resolve entry The entry association of ReusableFile was resolved relative to the namespace of the including model. File types defined by plugins that do not live inside the Pageflow module thus failed to load. --- app/models/concerns/pageflow/reusable_file.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/pageflow/reusable_file.rb b/app/models/concerns/pageflow/reusable_file.rb index 756017486f..26f5183fc0 100644 --- a/app/models/concerns/pageflow/reusable_file.rb +++ b/app/models/concerns/pageflow/reusable_file.rb @@ -4,7 +4,7 @@ module ReusableFile # rubocop:todo Style/Documentation included do belongs_to :uploader, class_name: 'User', optional: true - belongs_to :entry, optional: true + belongs_to :entry, class_name: 'Pageflow::Entry', optional: true belongs_to :parent_file, polymorphic: true, foreign_type: :parent_file_model_type, optional: true From edec52ca5378f3f0c454b48f72a294245f9537ae Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 10 Aug 2026 11:23:16 +0200 Subject: [PATCH 07/17] Add lottie file type to scrolled entry type Registers a PageflowScrolled::LottieFile file type behind the new lottie_animation_content_element feature flag, allowing dotLottie files to be uploaded and managed in the editor. --- ...0_create_pageflow_scrolled_lottie_files.rb | 24 ++++++++++ .../models/pageflow_scrolled/lottie_file.rb | 6 +++ .../lottie_file_url_templates.rb | 16 +++++++ entry_types/scrolled/config/locales/de.yml | 9 ++++ entry_types/scrolled/config/locales/en.yml | 9 ++++ entry_types/scrolled/lib/pageflow_scrolled.rb | 7 +++ .../scrolled/lib/pageflow_scrolled/plugin.rb | 4 ++ .../scrolled/spec/factories/lottie_files.rb | 29 +++++++++++++ .../scrolled/spec/fixtures/animation.lottie | Bin 0 -> 629 bytes .../pageflow_scrolled/lottie_file_spec.rb | 26 +++++++++++ .../lottie_file_type_spec.rb | 41 ++++++++++++++++++ 11 files changed, 171 insertions(+) create mode 100644 db/migrate/20260810000000_create_pageflow_scrolled_lottie_files.rb create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/lottie_file.rb create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/lottie_file_url_templates.rb create mode 100644 entry_types/scrolled/spec/factories/lottie_files.rb create mode 100644 entry_types/scrolled/spec/fixtures/animation.lottie create mode 100644 entry_types/scrolled/spec/models/pageflow_scrolled/lottie_file_spec.rb create mode 100644 entry_types/scrolled/spec/pageflow_scrolled/lottie_file_type_spec.rb diff --git a/db/migrate/20260810000000_create_pageflow_scrolled_lottie_files.rb b/db/migrate/20260810000000_create_pageflow_scrolled_lottie_files.rb new file mode 100644 index 0000000000..4be9c20b43 --- /dev/null +++ b/db/migrate/20260810000000_create_pageflow_scrolled_lottie_files.rb @@ -0,0 +1,24 @@ +class CreatePageflowScrolledLottieFiles < ActiveRecord::Migration[7.1] + def change + create_table :pageflow_scrolled_lottie_files do |t| + t.belongs_to :entry, index: true + t.belongs_to :uploader, index: true + + t.bigint 'parent_file_id' + t.string 'parent_file_model_type' + + t.string 'state' + t.string 'rights' + + t.string 'attachment_on_s3_file_name' + t.string 'attachment_on_s3_content_type' + t.bigint 'attachment_on_s3_file_size' + t.datetime 'attachment_on_s3_updated_at' + + t.timestamps + + t.index ['parent_file_id', 'parent_file_model_type'], + name: 'index_lottie_files_on_parent_id_and_parent_model_type' + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/lottie_file.rb b/entry_types/scrolled/app/models/pageflow_scrolled/lottie_file.rb new file mode 100644 index 0000000000..7ef932a54e --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/lottie_file.rb @@ -0,0 +1,6 @@ +module PageflowScrolled + # @api private + class LottieFile < Pageflow::ApplicationRecord + include Pageflow::UploadableFile + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/lottie_file_url_templates.rb b/entry_types/scrolled/app/models/pageflow_scrolled/lottie_file_url_templates.rb new file mode 100644 index 0000000000..91870ccc8b --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/lottie_file_url_templates.rb @@ -0,0 +1,16 @@ +module PageflowScrolled + # @api private + class LottieFileUrlTemplates + def call + { + original: Pageflow::UrlTemplate.from_attachment(example_file.attachment, :original) + } + end + + private + + def example_file + @example_file ||= LottieFile.new(id: 0, file_name: ':basename.:extension') + end + end +end diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 592a0f6f40..78b2588584 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -18,6 +18,10 @@ de: name: one: Bild other: Bilder + lottie_files: + name: + one: Lottie-Animation + other: Lottie-Animationen other_files: name: one: Andere @@ -26,6 +30,9 @@ de: name: one: Video other: Videos + files: + tabs: + lottie_files: Lottie widgets: attributes: defaultNavigation: @@ -77,6 +84,8 @@ de: feature_name: iframe-Embed Inhaltselement legacy_social_embed_content_elements: feature_name: Legacy Twitter und TikTok Inhaltselemente + lottie_animation_content_element: + feature_name: Lottie-Animation-Inhaltselement scrolled_entry_fragment_caching: feature_name: Pageflow-Next-Fragment-Caching social_embed_content_element: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 2b036e2aa3..c7e18c7a02 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -18,6 +18,10 @@ en: name: one: Image other: Images + lottie_files: + name: + one: Lottie animation + other: Lottie animations other_files: name: one: Other @@ -26,6 +30,9 @@ en: name: one: Video other: Videos + files: + tabs: + lottie_files: Lottie widgets: attributes: defaultNavigation: @@ -77,6 +84,8 @@ en: feature_name: iframe embed content element legacy_social_embed_content_elements: feature_name: Legacy Twitter and TikTok content elements + lottie_animation_content_element: + feature_name: Lottie animation content element scrolled_entry_fragment_caching: feature_name: Pageflow Next Fragment Caching social_embed_content_element: diff --git a/entry_types/scrolled/lib/pageflow_scrolled.rb b/entry_types/scrolled/lib/pageflow_scrolled.rb index 86a844171b..9142612b8d 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled.rb @@ -7,6 +7,13 @@ def plugin PageflowScrolled::Plugin.new end + def lottie_file_type + Pageflow::FileType.new(model: 'PageflowScrolled::LottieFile', + collection_name: 'lottie_files', + url_templates: LottieFileUrlTemplates.new, + top_level_type: true) + end + def entry_type Pageflow::EntryType.new(name: 'scrolled', frontend_app: PageflowScrolled::EntriesController.action(:show), diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 064126ea81..1661e328b0 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -204,6 +204,10 @@ def configure(config) ) end + c.features.register('lottie_animation_content_element') do |feature_config| + feature_config.file_types.register(PageflowScrolled.lottie_file_type) + end + c.features.register('datawrapper_chart_embed_opt_in') c.features.enable_by_default('datawrapper_chart_embed_opt_in') c.features.register('iframe_embed_content_element') diff --git a/entry_types/scrolled/spec/factories/lottie_files.rb b/entry_types/scrolled/spec/factories/lottie_files.rb new file mode 100644 index 0000000000..ac783fbaec --- /dev/null +++ b/entry_types/scrolled/spec/factories/lottie_files.rb @@ -0,0 +1,29 @@ +module PageflowScrolled + FactoryBot.define do + factory :lottie_file, class: LottieFile do + entry + uploader { create(:user) } + + attachment { File.open(Engine.root.join('spec', 'fixtures', 'animation.lottie')) } + state { 'uploaded' } + + transient do + used_in { nil } + with_configuration { nil } + end + + before(:create) do |file, evaluator| + file.entry = evaluator.used_in.entry if evaluator.used_in + end + + after(:create) do |file, evaluator| + if evaluator.used_in + create(:file_usage, + file:, + revision: evaluator.used_in, + configuration: evaluator.with_configuration) + end + end + end + end +end diff --git a/entry_types/scrolled/spec/fixtures/animation.lottie b/entry_types/scrolled/spec/fixtures/animation.lottie new file mode 100644 index 0000000000000000000000000000000000000000..f2b8673c4411e15393c2fdce833c0351910f77ca GIT binary patch literal 629 zcmWIWW@Zs#U|`^22#Df}ee_>^W*m@L3&gxYoST@JnU-2yqL)>ipJ#j8kgLIf$Km4Y zDQB#9REZXHNG%nZJt@TX$nM(>JM8}Lf2gokgYy@ItO;Y7*OCaASfh1YHq2S;t98bN z{j|x<@)?iXmd*=1{IJd9_lMQ$I+L`;?w)Ml>yfs{uJYJ()&LZT>`||qD9gyekj>1% zAPIB`&>gvnC7Jno#rkk2*io&)xAU3}c>Ye+Iv+fxXG7+1(Id`bm&CS;OiP*0_3{1I zyDCmfH|09|Y9Bpr<~9DE_TD<|bNIR&m-}ROTBb5w@oy053HJHRFoEf!=EBzvU*CQ9 zj&5d(z20i|cFsq>re&=e7ry1>aOwXNTT~f(DPGXi;UAkr>X*5^=3gYb&S@BLp6Dk# znZKFaVK#>($BUPTZ8s@gnCKX?SK!^sC(|wBlG)yxY;xUe=-*Yw_-$c~YNklcikw?N zva=k1aO_&JU3^0KmfM%MG*$#uKl7_HN?d2+T(-qx+r9P0W&W$Vluv4CJ9tg0QTH!9 z+-bm)RlFl%cCA~3eKSjV#h%9BS7W%jr)Wp|=YN^L{*cE1*~uU0cD+m3n)@%l@FVwc z36Djw?*hCTnM9azM=vm7!Jq*c(rA&5t`$8(AQ~AM8W=l)On982M^At^D;r2D6A(55 I>APT?0P6$m-v9sr literal 0 HcmV?d00001 diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/lottie_file_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/lottie_file_spec.rb new file mode 100644 index 0000000000..57b08467e4 --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/lottie_file_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe LottieFile do + it 'can be created for uploads with lottie extension' do + entry = Pageflow::DraftEntry.new(create(:entry, type_name: 'scrolled')) + + file = entry.create_file!(PageflowScrolled.lottie_file_type, + display_name: 'animation.lottie') + + expect(file.file_name).to end_with('.lottie') + end + + it 'exposes basename of attachment for url template interpolation' do + lottie_file = create(:lottie_file) + + expect(lottie_file.basename).to eq('animation') + end + + it 'exposes extension of attachment for url template interpolation' do + lottie_file = create(:lottie_file) + + expect(lottie_file.extension).to eq('lottie') + end + end +end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/lottie_file_type_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/lottie_file_type_spec.rb new file mode 100644 index 0000000000..b28359dda4 --- /dev/null +++ b/entry_types/scrolled/spec/pageflow_scrolled/lottie_file_type_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +require 'pageflow/lint' + +module PageflowScrolled + RSpec.describe 'lottie file type' do + it 'is available under lottie_files collection name' do + expect(PageflowScrolled.lottie_file_type.collection_name).to eq('lottie_files') + end + + it 'provides url template for original attachment' do + templates = PageflowScrolled.lottie_file_type.url_templates.call + + expect(templates[:original]) + .to include('lottie_files/attachment_on_s3s/' \ + ':id_partition/original/:basename.:extension') + end + + it 'is registered for scrolled entries with lottie_animation_content_element feature' do + entry = create(:published_entry, + type_name: 'scrolled', + with_feature: 'lottie_animation_content_element') + + collection_names = Pageflow.config_for(entry).file_types.map(&:collection_name) + + expect(collection_names).to include('lottie_files') + end + + it 'is not registered for scrolled entries without the feature' do + entry = create(:published_entry, type_name: 'scrolled') + + collection_names = Pageflow.config_for(entry).file_types.map(&:collection_name) + + expect(collection_names).not_to include('lottie_files') + end + end + + Pageflow::Lint.file_type('lottie_file', + create_file_type: -> { PageflowScrolled.lottie_file_type }, + create_file: -> { create(:lottie_file) }) +end From 24a9dd11f9b315574e821196080ca56f23abdc77 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 10 Aug 2026 11:36:44 +0200 Subject: [PATCH 08/17] Register lottie file type in scrolled editor Uploads of dotLottie files are matched by file name since browsers do not detect a content type for them. --- .../lottieAnimation/editor/index-spec.js | 35 +++++++++++++++++++ .../package/src/contentElements/editor.js | 1 + .../lottieAnimation/editor/index.js | 12 +++++++ .../editor/models/LottieFile.js | 5 +++ 4 files changed, 53 insertions(+) create mode 100644 entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.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 new file mode 100644 index 0000000000..02609e772b --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js @@ -0,0 +1,35 @@ +import {editor} from 'pageflow-scrolled/editor'; + +import {useEditorGlobals} from 'support'; + +import 'contentElements/lottieAnimation/editor'; +import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile'; + +describe('lottieAnimation/editor', () => { + useEditorGlobals({ + fileTypes: [{ + collectionName: 'lottie_files', + typeName: 'PageflowScrolled::LottieFile' + }] + }); + + it('registers lottie file type for lottie_files collection', () => { + expect(editor.fileTypes.findByCollectionName('lottie_files').model).toBe(LottieFile); + }); + + it('matches uploads of dotLottie files', () => { + const fileType = editor.fileTypes.findByUpload({name: 'animation.lottie', type: ''}); + + expect(fileType.collectionName).toEqual('lottie_files'); + }); + + it('does not match uploads of other files', () => { + expect(editor.fileTypes.findByUpload({ + name: 'animation.json', type: 'application/json' + }).collectionName).toEqual('other_files'); + + expect(editor.fileTypes.findByUpload({ + name: 'image.jpg', type: 'image/jpeg' + }).collectionName).toEqual('image_files'); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/editor.js b/entry_types/scrolled/package/src/contentElements/editor.js index 608f0c9057..f83f3a3d90 100644 --- a/entry_types/scrolled/package/src/contentElements/editor.js +++ b/entry_types/scrolled/package/src/contentElements/editor.js @@ -12,6 +12,7 @@ import './hotspots/editor' import './vrImage/editor'; import './iframeEmbed/editor'; import './imageGallery/editor' +import './lottieAnimation/editor' import './socialEmbed/editor' import './twitterEmbed/editor' import './tikTokEmbed/editor' diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js new file mode 100644 index 0000000000..eb61eec0dd --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -0,0 +1,12 @@ +import {editor} from 'pageflow-scrolled/editor'; + +import {LottieFile} from './models/LottieFile'; + +editor.fileTypes.register('lottie_files', { + model: LottieFile, + + // Browsers derive the content type of uploads from the file + // extension. Since dotLottie is missing from their mappings, uploads + // have an empty content type and matching by name is the only option. + matchUpload: upload => /\.lottie$/i.test(upload.name) +}); 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 new file mode 100644 index 0000000000..62ad5206c1 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.js @@ -0,0 +1,5 @@ +import {UploadableFile} from 'pageflow/editor'; + +export const LottieFile = UploadableFile.extend({ + thumbnailPictogram: 'other' +}); From b4aa12cb0186382be2d884e2bda2679f96ea4795 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 10 Aug 2026 11:37:31 +0200 Subject: [PATCH 09/17] Add lottie animation content element frontend Renders dotLottie animations via a canvas based player. The player is loaded in a separate frontend pack registered behind the lottie_animation_content_element feature flag since it depends on a WebAssembly module. --- .../scrolled/lib/pageflow_scrolled/plugin.rb | 5 + .../scrolled/package/config/webpack.js | 6 + .../package/contentElements-server.js | 1 + entry_types/scrolled/package/package.json | 1 + .../lottieAnimation/LottieAnimation-spec.js | 138 ++++++++++++++++++ .../lottieAnimation/LottieAnimation.js | 98 +++++++++++++ .../LottieAnimation.module.css | 5 + .../lottieAnimation/frontend.js | 8 + .../spec/pageflow_scrolled/plugin_spec.rb | 24 +++ rollup.config.js | 2 +- yarn.lock | 5 + 11 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.module.css create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 1661e328b0..1497fc01e1 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -206,6 +206,11 @@ def configure(config) c.features.register('lottie_animation_content_element') do |feature_config| feature_config.file_types.register(PageflowScrolled.lottie_file_type) + + feature_config.additional_frontend_packs.register( + 'pageflow-scrolled/contentElements/lottieAnimation-frontend', + content_element_type_names: ['lottieAnimation'] + ) end c.features.register('datawrapper_chart_embed_opt_in') diff --git a/entry_types/scrolled/package/config/webpack.js b/entry_types/scrolled/package/config/webpack.js index 5be037db52..bd65fb0e9c 100644 --- a/entry_types/scrolled/package/config/webpack.js +++ b/entry_types/scrolled/package/config/webpack.js @@ -49,6 +49,12 @@ module.exports = { 'pageflow-scrolled/contentElements/inlineBeforeAfter-frontend.css' ] }, + 'pageflow-scrolled/contentElements/lottieAnimation-frontend': { + import: [ + 'pageflow-scrolled/contentElements/lottieAnimation-frontend', + 'pageflow-scrolled/contentElements/lottieAnimation-frontend.css' + ] + }, 'pageflow-scrolled/widgets/defaultNavigation': { import: [ 'pageflow-scrolled/widgets/defaultNavigation', diff --git a/entry_types/scrolled/package/contentElements-server.js b/entry_types/scrolled/package/contentElements-server.js index a669c903c3..2a8f42fb71 100644 --- a/entry_types/scrolled/package/contentElements-server.js +++ b/entry_types/scrolled/package/contentElements-server.js @@ -1,6 +1,7 @@ import 'pageflow-scrolled/contentElements-frontend'; import 'pageflow-scrolled/contentElements/hotspots-frontend'; import 'pageflow-scrolled/contentElements/inlineBeforeAfter-frontend'; +import 'pageflow-scrolled/contentElements/lottieAnimation-frontend'; import 'pageflow-scrolled/contentElements/socialEmbed-frontend'; import 'pageflow-scrolled/contentElements/tikTokEmbed-frontend'; import 'pageflow-scrolled/contentElements/twitterEmbed-frontend'; diff --git a/entry_types/scrolled/package/package.json b/entry_types/scrolled/package/package.json index a8ed87d87e..b7475b3d78 100644 --- a/entry_types/scrolled/package/package.json +++ b/entry_types/scrolled/package/package.json @@ -10,6 +10,7 @@ "@egjs/view360": "^3.4.3", "@floating-ui/react": "https://github.com/tf/floating-ui-react#react-16-focus-fix", "@headlessui/react": "^1.6.6", + "@lottiefiles/dotlottie-web": "^0.79.0", "classnames": "^2.3.2", "core-js": "^3.6.5", "core-js-pure": "^3.0.0", diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js new file mode 100644 index 0000000000..620b56cf7f --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js @@ -0,0 +1,138 @@ +import React from 'react'; +import {act} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; + +import {LottieAnimation} from 'contentElements/lottieAnimation/LottieAnimation'; +import {DotLottie} from '@lottiefiles/dotlottie-web'; + +jest.mock('@lottiefiles/dotlottie-web', () => ({DotLottie: jest.fn()})); + +describe('LottieAnimation', () => { + let players; + + beforeEach(() => { + players = []; + + DotLottie.mockImplementation(function(config) { + const listeners = {}; + + Object.assign(this, { + config, + play: jest.fn(), + pause: jest.fn(), + destroy: jest.fn(), + animationSize: jest.fn(() => ({width: 200, height: 100})), + + addEventListener(type, listener) { + listeners[type] = listener; + }, + + emit(type) { + act(() => listeners[type] && listeners[type]()); + } + }); + + players.push(this); + }); + }); + + function renderLottieAnimation({ + configuration = {id: 100}, + scrollPosition = 'in viewport', + ...seedOptions + } = {}) { + const result = renderInContentElement( + , + { + seed: { + fileUrlTemplates: { + lottieFiles: {original: ':id_partition/:basename.:extension'} + }, + lottieFiles: [ + {id: 1, permaId: 100, basename: 'animation', extension: 'lottie'} + ], + ...seedOptions + } + } + ); + + result.simulateScrollPosition(scrollPosition); + + return result; + } + + it('renders animation of selected file', () => { + renderLottieAnimation(); + + expect(players).toHaveLength(1); + expect(players[0].config.src).toEqual('000/000/001/animation.lottie'); + }); + + it('does not create player before element is near viewport', () => { + renderLottieAnimation({scrollPosition: 'outside viewport'}); + + expect(players).toHaveLength(0); + }); + + it('does not create player if no file is selected', () => { + renderLottieAnimation({configuration: {}}); + + expect(players).toHaveLength(0); + }); + + it('loops by default', () => { + renderLottieAnimation(); + + expect(players[0].config.loop).toBe(true); + }); + + it('does not loop in playOnce playback mode', () => { + renderLottieAnimation({configuration: {id: 100, playbackMode: 'playOnce'}}); + + expect(players[0].config.loop).toBe(false); + }); + + it('plays animation once it has loaded while element is visible', () => { + renderLottieAnimation({scrollPosition: 'in viewport'}); + + players[0].emit('load'); + + expect(players[0].play).toHaveBeenCalled(); + }); + + it('does not play animation while element is not visible', () => { + renderLottieAnimation({scrollPosition: 'near viewport'}); + + players[0].emit('load'); + + expect(players[0].play).not.toHaveBeenCalled(); + }); + + it('pauses animation when element leaves viewport', () => { + const {simulateScrollPosition} = renderLottieAnimation({scrollPosition: 'in viewport'}); + players[0].emit('load'); + + simulateScrollPosition('near viewport'); + + expect(players[0].pause).toHaveBeenCalled(); + }); + + it('destroys player on unmount', () => { + const {unmount} = renderLottieAnimation(); + + unmount(); + + expect(players[0].destroy).toHaveBeenCalled(); + }); + + it('applies aspect ratio of animation once it has loaded', () => { + const {container} = renderLottieAnimation(); + + players[0].emit('load'); + + expect(container.querySelector('[style*="--fit-viewport-aspect-ratio: 0.5"]')) + .not.toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js new file mode 100644 index 0000000000..46609aafa4 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js @@ -0,0 +1,98 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {DotLottie} from '@lottiefiles/dotlottie-web'; + +import { + ContentElementBox, + ContentElementFigure, + FilePlaceholder, + FitViewport, + InlineFileRights, + useContentElementLifecycle, + useFileWithInlineRights +} from 'pageflow-scrolled/frontend'; + +import styles from './LottieAnimation.module.css'; + +export function LottieAnimation({configuration}) { + const lottieFile = useFileWithInlineRights({ + configuration, collectionName: 'lottieFiles', propertyName: 'id' + }); + + const {shouldLoad, isVisible} = useContentElementLifecycle(); + const [aspectRatio, setAspectRatio] = useState(); + + return ( + + + + + + {lottieFile && shouldLoad && + } + + + + + + + ); +} + +function Player({lottieFile, loop, play, onAspectRatioChange}) { + const canvasRef = useRef(); + const dotLottieRef = useRef(); + + const playRef = useRef(play); + playRef.current = play; + + useEffect(() => { + const dotLottie = new DotLottie({ + canvas: canvasRef.current, + src: lottieFile.urls.original, + loop, + autoplay: false, + renderConfig: {autoResize: true} + }); + + // Playback is only started here since the animation cannot be + // played before it has been loaded. + dotLottie.addEventListener('load', () => { + const {width, height} = dotLottie.animationSize(); + + if (width && height) { + onAspectRatioChange(height / width); + } + + if (playRef.current) { + dotLottie.play(); + } + }); + + dotLottieRef.current = dotLottie; + + return () => { + dotLottieRef.current = null; + dotLottie.destroy(); + }; + }, [lottieFile.urls.original, loop, onAspectRatioChange]); + + useEffect(() => { + if (play) { + dotLottieRef.current.play(); + } + else { + dotLottieRef.current.pause(); + } + }, [play]); + + return ( + + ); +} diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.module.css b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.module.css new file mode 100644 index 0000000000..4417997dd4 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.module.css @@ -0,0 +1,5 @@ +.canvas { + display: block; + width: 100%; + height: 100%; +} diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js new file mode 100644 index 0000000000..db6b8aafdf --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/frontend.js @@ -0,0 +1,8 @@ +import {frontend} from 'pageflow-scrolled/frontend'; + +import {LottieAnimation} from './LottieAnimation'; + +frontend.contentElementTypes.register('lottieAnimation', { + component: LottieAnimation, + lifecycle: true +}); diff --git a/entry_types/scrolled/spec/pageflow_scrolled/plugin_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/plugin_spec.rb index 595a86426f..9f6e66ba07 100644 --- a/entry_types/scrolled/spec/pageflow_scrolled/plugin_spec.rb +++ b/entry_types/scrolled/spec/pageflow_scrolled/plugin_spec.rb @@ -2,6 +2,30 @@ module PageflowScrolled RSpec.describe Plugin do + describe 'lottie_animation_content_element feature', type: :helper do + before { helper.extend(PacksHelper) } + + it 'registers frontend pack for entries using lottie animations' do + entry = create(:published_entry, + type_name: 'scrolled', + with_feature: 'lottie_animation_content_element') + create(:content_element, revision: entry.revision, type_name: 'lottieAnimation') + + result = helper.scrolled_frontend_packs(entry, entry_mode: :published) + + expect(result).to include('pageflow-scrolled/contentElements/lottieAnimation-frontend') + end + + it 'does not register frontend pack for entries without the feature' do + entry = create(:published_entry, type_name: 'scrolled') + create(:content_element, revision: entry.revision, type_name: 'lottieAnimation') + + result = helper.scrolled_frontend_packs(entry, entry_mode: :published) + + expect(result).not_to include('pageflow-scrolled/contentElements/lottieAnimation-frontend') + end + end + describe 'IFRAME_EMBED_CONSENT_VENDOR' do it 'returns nil if consent not required' do pageflow_configure do |config| diff --git a/rollup.config.js b/rollup.config.js index 37c3084141..1b8c8d3cd6 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -376,7 +376,7 @@ const pageflowScrolled = [ ...([ 'tikTokEmbed', 'twitterEmbed', 'hotspots', 'socialEmbed', - 'videoEmbed', 'inlineBeforeAfter' + 'videoEmbed', 'inlineBeforeAfter', 'lottieAnimation' ].map(name => ( { input: `${pageflowScrolledPackageRoot}/src/contentElements/${name}/frontend.js`, diff --git a/yarn.lock b/yarn.lock index b17e304ed7..c154c95335 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1854,6 +1854,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@lottiefiles/dotlottie-web@^0.79.0": + version "0.79.0" + resolved "https://registry.yarnpkg.com/@lottiefiles/dotlottie-web/-/dotlottie-web-0.79.0.tgz#0db617bebb9931f3413098cb74d75fb6ffaaef42" + integrity sha512-i2OCChZvfsoKCnilkI1t72sR/jDLoLpX2q0nm2KpOXYQZvONMooCXcGy+H9J8ioh66d61/tfHUzP78YxZ73ypg== + "@ndelangen/get-tarball@^3.0.7": version "3.0.9" resolved "https://registry.yarnpkg.com/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz#727ff4454e65f34707e742a59e5e6b1f525d8964" From f3ff54ed20fa732f7fe7c7398866f1815984f3d6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 10 Aug 2026 11:52:46 +0200 Subject: [PATCH 10/17] Add lottie animation content element to editor Lets editors select an uploaded dotLottie file and choose whether the animation shall loop. Only available if the lottie_animation_content_element feature is enabled. --- entry_types/scrolled/config/locales/de.yml | 14 +++++ entry_types/scrolled/config/locales/en.yml | 14 +++++ .../lottieAnimation/editor/index-spec.js | 53 ++++++++++++++++++- .../scrolled/package/spec/support/index.js | 1 + ...renderContentElementConfigurationEditor.js | 23 ++++++++ .../lottieAnimation/editor/index.js | 45 +++++++++++++++- .../lottieAnimation/editor/pictogram.svg | 1 + 7 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 entry_types/scrolled/package/spec/support/renderContentElementConfigurationEditor.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/pictogram.svg diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 78b2588584..90745d17fc 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -770,6 +770,20 @@ de: name: Video tabs: general: Video + lottieAnimation: + attributes: + id: + label: Animation + playbackMode: + inline_help_html: Bestimmt, wie die Animation abgespielt wird, sobald sie sichtbar wird:
  • Endlosschleife: Immer wieder, solange sie sichtbar ist.
  • Einmal abspielen: Einmal von Anfang bis Ende.
+ label: Wiedergabe-Modus + values: + loop: Endlosschleife + playOnce: Einmal abspielen + description: Eine als dotLottie-Datei exportierte Animation einbinden + name: Lottie-Animation + tabs: + general: Lottie-Animation question: attributes: expandByDefault: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index c7e18c7a02..ba71870733 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -755,6 +755,20 @@ en: name: Video tabs: general: Video + lottieAnimation: + attributes: + id: + label: Animation + playbackMode: + inline_help_html: Determines how the animation is played once it becomes visible:
  • Loop: Again and again as long as it is visible.
  • Play once: Once from start to end.
+ label: Playback Mode + values: + loop: Loop + playOnce: Play once + description: Embed an animation exported as dotLottie file + name: Lottie animation + tabs: + general: Lottie animation question: attributes: expandByDefault: 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 02609e772b..1be4b67a5c 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,18 +1,67 @@ import {editor} from 'pageflow-scrolled/editor'; +import {SelectInput, useFakeFeatures} from 'pageflow/testHelpers'; -import {useEditorGlobals} from 'support'; +import {renderContentElementConfigurationEditor, useEditorGlobals} from 'support'; import 'contentElements/lottieAnimation/editor'; import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile'; describe('lottieAnimation/editor', () => { - useEditorGlobals({ + const {createEntry} = useEditorGlobals({ fileTypes: [{ collectionName: 'lottie_files', typeName: 'PageflowScrolled::LottieFile' }] }); + describe('content element type', () => { + function availableTypeNames() { + return editor.contentElementTypes.toArray().map(type => type.typeName); + } + + it('is not available by default', () => { + expect(availableTypeNames()).not.toContain('lottieAnimation'); + }); + + describe('with lottie_animation_content_element feature', () => { + useFakeFeatures('editor', ['lottie_animation_content_element']); + + it('is available', () => { + expect(availableTypeNames()).toContain('lottieAnimation'); + }); + + it('loops by default', () => { + const type = editor.contentElementTypes.findByTypeName('lottieAnimation'); + + expect(type.defaultConfig).toEqual({playbackMode: 'loop'}); + }); + }); + }); + + describe('configuration editor', () => { + function renderConfigurationEditor({configuration, lottieFiles = []}) { + const entry = createEntry({ + filesAttributes: {lottie_files: lottieFiles}, + contentElements: [{id: 1, typeName: 'lottieAnimation', configuration}] + }); + + return renderContentElementConfigurationEditor({ + entry, + contentElement: entry.contentElements.get(1) + }); + } + + it('displays select to choose playback mode', () => { + const configurationEditor = renderConfigurationEditor({configuration: {}}); + + const input = SelectInput.findByPropertyName('playbackMode', { + inView: configurationEditor + }); + + expect(input.values()).toEqual(['loop', 'playOnce']); + }); + }); + it('registers lottie file type for lottie_files collection', () => { expect(editor.fileTypes.findByCollectionName('lottie_files').model).toBe(LottieFile); }); diff --git a/entry_types/scrolled/package/spec/support/index.js b/entry_types/scrolled/package/spec/support/index.js index 3ecd2de9ec..563b3386de 100644 --- a/entry_types/scrolled/package/spec/support/index.js +++ b/entry_types/scrolled/package/spec/support/index.js @@ -2,6 +2,7 @@ export * from 'pageflow-scrolled/testHelpers'; export * from './factories'; export * from './fakeWindows'; +export * from './renderContentElementConfigurationEditor'; export * from './scrollPositionLifecycle'; export * from './tick'; export * from './useFakeXhr'; diff --git a/entry_types/scrolled/package/spec/support/renderContentElementConfigurationEditor.js b/entry_types/scrolled/package/spec/support/renderContentElementConfigurationEditor.js new file mode 100644 index 0000000000..bda4f55920 --- /dev/null +++ b/entry_types/scrolled/package/spec/support/renderContentElementConfigurationEditor.js @@ -0,0 +1,23 @@ +import {editor} from 'pageflow-scrolled/editor'; +import {ConfigurationEditorTabView} from 'pageflow/ui'; +import {ConfigurationEditor, renderBackboneView} from 'pageflow/testHelpers'; + +import {EditContentElementView} from 'editor/views/EditContentElementView'; + +export function renderContentElementConfigurationEditor({entry, contentElement}) { + // Normally contributed by the text inline file rights widget type + // once the widgets of the entry have been set up. + ConfigurationEditorTabView.groups.define( + 'ContentElementInlineFileRightsSettings', () => {} + ); + + const view = new EditContentElementView({ + model: contentElement, + editor, + entry + }); + + renderBackboneView(view); + + return ConfigurationEditor.find(view); +} 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 eb61eec0dd..22750ef636 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -1,7 +1,11 @@ -import {editor} from 'pageflow-scrolled/editor'; +import {editor, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; +import {FileInputView} from 'pageflow/editor'; +import {SelectInputView, SeparatorView} from 'pageflow/ui'; import {LottieFile} from './models/LottieFile'; +import pictogram from './pictogram.svg'; + editor.fileTypes.register('lottie_files', { model: LottieFile, @@ -10,3 +14,42 @@ editor.fileTypes.register('lottie_files', { // have an empty content type and matching by name is the only option. matchUpload: upload => /\.lottie$/i.test(upload.name) }); + +const playbackModes = ['loop', 'playOnce']; + +editor.contentElementTypes.register('lottieAnimation', { + pictogram, + category: 'media', + featureName: 'lottie_animation_content_element', + supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right'], + supportedWidthRange: ['xxs', 'full'], + supportedCaptions: true, + supportedStyles: ['boxShadow', 'outline'], + + defaultConfig: {playbackMode: 'loop'}, + + defaultsInputs() { + this.input('playbackMode', SelectInputView, {values: playbackModes}); + }, + + configurationEditor({entry}) { + this.tab('general', function() { + this.input('id', FileInputView, { + collection: 'lottie_files', + fileSelectionHandler: 'contentElementConfiguration', + positioning: false, + dropDownMenuItems: [InlineFileRightsMenuItem] + }); + this.input('playbackMode', SelectInputView, {values: playbackModes}); + + this.view(SeparatorView); + + this.group('ContentElementPosition', {entry}); + + this.view(SeparatorView); + + this.group('ContentElementCaption', {entry}); + this.group('ContentElementInlineFileRightsSettings'); + }); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/pictogram.svg b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/pictogram.svg new file mode 100644 index 0000000000..55967aa99a --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/pictogram.svg @@ -0,0 +1 @@ + From 15f56025922b23167d784b029f3e78a31e49a497 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 10 Aug 2026 11:58:58 +0200 Subject: [PATCH 11/17] Load dotLottie WebAssembly module from own bundle By default the player fetches its WebAssembly module from a CDN, which would leak visitor IPs to a third party. --- entry_types/scrolled/package/.eslintrc.js | 4 ++++ entry_types/scrolled/package/config/webpack.js | 18 ++++++++++++++++++ entry_types/scrolled/package/jest.config.js | 2 ++ .../lottieAnimation/LottieAnimation-spec.js | 8 +++++++- .../package/spec/support/jest/wasm-url-stub.js | 1 + .../lottieAnimation/LottieAnimation.js | 4 ++++ 6 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/spec/support/jest/wasm-url-stub.js diff --git a/entry_types/scrolled/package/.eslintrc.js b/entry_types/scrolled/package/.eslintrc.js index d128c399f3..52e75ca2cb 100644 --- a/entry_types/scrolled/package/.eslintrc.js +++ b/entry_types/scrolled/package/.eslintrc.js @@ -7,6 +7,10 @@ module.exports = { ], "rules": { "no-trailing-spaces": "error", + // The import resolver predates package exports maps and thus + // cannot resolve subpaths like + // '@lottiefiles/dotlottie-web/dotlottie-player.wasm'. + "import/no-unresolved": ["error", {"ignore": ["\\.wasm$"]}], // react-app enables no-unused-expressions, but ESLint 6 predates // ChainExpression and flags optional-chaining call statements like // `node?.focus()` as unused. The other packages do not enable the rule. diff --git a/entry_types/scrolled/package/config/webpack.js b/entry_types/scrolled/package/config/webpack.js index bd65fb0e9c..f11f86a92a 100644 --- a/entry_types/scrolled/package/config/webpack.js +++ b/entry_types/scrolled/package/config/webpack.js @@ -1,4 +1,22 @@ module.exports = { + module: { + rules: [ + { + // Nested in oneOf to keep the rule free of a top level `type` + // property. Host application webpack configs generated by the + // install generator use `mergeWithRules` to match rules by + // `type` and would otherwise replace the `test` below. + oneOf: [ + { + // Emit the WebAssembly module of the dotLottie player as + // asset, so that it does not have to be loaded from a CDN. + test: /\.wasm$/, + type: 'asset/resource' + } + ] + } + ] + }, resolve: { alias: { // By default Video.js now includes the http-streaming diff --git a/entry_types/scrolled/package/jest.config.js b/entry_types/scrolled/package/jest.config.js index 2f3123288f..3d112247aa 100644 --- a/entry_types/scrolled/package/jest.config.js +++ b/entry_types/scrolled/package/jest.config.js @@ -23,6 +23,8 @@ module.exports = { testURL: 'https://story.example.com', moduleNameMapper: { + '\\.wasm$': '/spec/support/jest/wasm-url-stub', + '^pageflow-scrolled/contentElements-frontend$': '/src/contentElements/frontend', "^pageflow-scrolled/editor\\.css$": "/spec/support/jest/editor-css-stub", "^pageflow-scrolled/review\\.css$": "/spec/support/jest/review-css-stub", 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 620b56cf7f..4bffc220dd 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js @@ -7,7 +7,9 @@ import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; import {LottieAnimation} from 'contentElements/lottieAnimation/LottieAnimation'; import {DotLottie} from '@lottiefiles/dotlottie-web'; -jest.mock('@lottiefiles/dotlottie-web', () => ({DotLottie: jest.fn()})); +jest.mock('@lottiefiles/dotlottie-web', () => ({ + DotLottie: Object.assign(jest.fn(), {setWasmUrl: jest.fn()}) +})); describe('LottieAnimation', () => { let players; @@ -63,6 +65,10 @@ describe('LottieAnimation', () => { return result; } + it('loads WebAssembly module from own bundle instead of a CDN', () => { + expect(DotLottie.setWasmUrl).toHaveBeenCalledWith('wasm-url-stub'); + }); + it('renders animation of selected file', () => { renderLottieAnimation(); diff --git a/entry_types/scrolled/package/spec/support/jest/wasm-url-stub.js b/entry_types/scrolled/package/spec/support/jest/wasm-url-stub.js new file mode 100644 index 0000000000..2cb44760d9 --- /dev/null +++ b/entry_types/scrolled/package/spec/support/jest/wasm-url-stub.js @@ -0,0 +1 @@ +module.exports = 'wasm-url-stub'; diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js index 46609aafa4..f531c51d35 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js @@ -1,5 +1,6 @@ import React, {useEffect, useRef, useState} from 'react'; import {DotLottie} from '@lottiefiles/dotlottie-web'; +import wasmUrl from '@lottiefiles/dotlottie-web/dotlottie-player.wasm'; import { ContentElementBox, @@ -13,6 +14,9 @@ import { import styles from './LottieAnimation.module.css'; +// Prevent the player from fetching its WebAssembly module from a CDN. +DotLottie.setWasmUrl(wasmUrl); + export function LottieAnimation({configuration}) { const lottieFile = useFileWithInlineRights({ configuration, collectionName: 'lottieFiles', propertyName: 'id' From 6e8826d348e60467ca5799de1096f1da8bfc9080 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 12 Aug 2026 10:02:30 +0200 Subject: [PATCH 12/17] Let file types render custom thumbnails Analogous to the preview view rendered in the file meta data overlay, file types can now register a Backbone view which the file thumbnail renders on top of its box once the file is ready. --- .../pageflow/editor/file_thumbnails.scss | 8 ++ .../editor/views/FileThumbnailView-spec.js | 92 +++++++++++++++++++ package/src/editor/api/FileType.js | 1 + package/src/editor/models/ReusableFile.js | 10 ++ .../src/editor/templates/fileThumbnail.jst | 1 + package/src/editor/views/FileThumbnailView.js | 20 +++- 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 package/spec/editor/views/FileThumbnailView-spec.js diff --git a/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss b/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss index 865f863a2a..8b7bd67657 100644 --- a/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss +++ b/app/assets/stylesheets/pageflow/editor/file_thumbnails.scss @@ -1,6 +1,14 @@ .file_thumbnail { + position: relative; background-size: cover; + // Fills the box just like the background image would, so file types + // can render a thumbnail of their own. + &-custom { + position: absolute; + inset: 0; + } + .pictogram { width: 100%; height: 100%; diff --git a/package/spec/editor/views/FileThumbnailView-spec.js b/package/spec/editor/views/FileThumbnailView-spec.js new file mode 100644 index 0000000000..323a1c6bce --- /dev/null +++ b/package/spec/editor/views/FileThumbnailView-spec.js @@ -0,0 +1,92 @@ +import Marionette from 'backbone.marionette'; + +import {FileThumbnailView} from 'pageflow/editor'; + +import * as support from '$support'; +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +describe('FileThumbnailView', () => { + const ThumbnailView = Marionette.ItemView.extend({ + template: () => '' + }); + + function fileWithThumbnailView(attributes) { + return support.factories.file({id: 123, state: 'processed', ...attributes}, { + fileType: support.factories.fileType({thumbnailView: ThumbnailView}) + }); + } + + it('renders background image from thumbnail url', () => { + const view = new FileThumbnailView({ + model: support.factories.file({thumbnail_url: '/image_thumbnail.jpg'}) + }); + + render(view); + + expect(view.$el.css('background-image')).toBe('url(/image_thumbnail.jpg)'); + }); + + it('renders the thumbnail view of the file type', () => { + const view = new FileThumbnailView({model: fileWithThumbnailView()}); + + render(view); + + expect(view.$el.find('.thumbnail_stand_in').length).toBe(1); + }); + + it('does not render thumbnail view of file type while file is processing', () => { + const view = new FileThumbnailView({ + model: fileWithThumbnailView({state: 'processing'}) + }); + + render(view); + + expect(view.$el.find('.thumbnail_stand_in').length).toBe(0); + }); + + it('renders thumbnail view of file type once the file has been processed', () => { + const file = fileWithThumbnailView({state: 'processing'}); + const view = new FileThumbnailView({model: file}); + + render(view); + file.set('state', 'processed'); + + expect(view.$el.find('.thumbnail_stand_in').length).toBe(1); + }); + + it('does not render thumbnail view of file type twice', () => { + const file = fileWithThumbnailView(); + const view = new FileThumbnailView({model: file}); + + render(view); + file.set('state', 'processing'); + file.set('state', 'processed'); + + expect(view.$el.find('.thumbnail_stand_in').length).toBe(1); + }); + + it('does not render thumbnail view for file types without one', () => { + const view = new FileThumbnailView({ + model: support.factories.file({id: 123, state: 'processed'}) + }); + + render(view); + + expect(view.$el.find('.thumbnail_stand_in').length).toBe(0); + }); + + it('closes thumbnail view of file type when closed', () => { + const onClose = jest.fn(); + const file = support.factories.file({id: 123, state: 'processed'}, { + fileType: support.factories.fileType({ + thumbnailView: ThumbnailView.extend({onClose}) + }) + }); + const view = new FileThumbnailView({model: file}); + + render(view); + view.close(); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/package/src/editor/api/FileType.js b/package/src/editor/api/FileType.js index b06ce2175c..4f9d46e191 100644 --- a/package/src/editor/api/FileType.js +++ b/package/src/editor/api/FileType.js @@ -22,6 +22,7 @@ export const FileType = Object.extend({ this.noExtendedFileRights = options.noExtendedFileRights; this.metaDataAttributes = options.metaDataAttributes || []; this.previewView = options.previewView; + this.thumbnailView = options.thumbnailView; if (typeof options.matchUpload === 'function') { this.matchUpload = options.matchUpload; diff --git a/package/src/editor/models/ReusableFile.js b/package/src/editor/models/ReusableFile.js index 04a553acd4..22baa9c956 100644 --- a/package/src/editor/models/ReusableFile.js +++ b/package/src/editor/models/ReusableFile.js @@ -86,6 +86,16 @@ export const ReusableFile = Backbone.Model.extend({ return new PreviewView({model: this}); }, + createThumbnailView: function() { + var ThumbnailView = this.fileType().thumbnailView; + + if (!ThumbnailView || !this.isReady()) { + return; + } + + return new ThumbnailView({model: this}); + }, + title: function() { return this.get('display_name') || this.get('file_name'); }, diff --git a/package/src/editor/templates/fileThumbnail.jst b/package/src/editor/templates/fileThumbnail.jst index 8e0a86e5c1..c30ccb9380 100644 --- a/package/src/editor/templates/fileThumbnail.jst +++ b/package/src/editor/templates/fileThumbnail.jst @@ -1 +1,2 @@
+
diff --git a/package/src/editor/views/FileThumbnailView.js b/package/src/editor/views/FileThumbnailView.js index 721a36f309..55427a108f 100644 --- a/package/src/editor/views/FileThumbnailView.js +++ b/package/src/editor/views/FileThumbnailView.js @@ -11,7 +11,8 @@ export const FileThumbnailView = Marionette.ItemView.extend({ }, ui: { - pictogram: '.pictogram' + pictogram: '.pictogram', + custom: '.file_thumbnail-custom' }, onRender: function() { @@ -37,6 +38,8 @@ export const FileThumbnailView = Marionette.ItemView.extend({ .removeClass('empty') .toggleClass('always_picogram', !!this.model.thumbnailPictogram) .toggleClass('ready', this.model.isReady()); + + this.renderCustomThumbnail(); } else { this.$el.css('background-image', ''); @@ -45,6 +48,21 @@ export const FileThumbnailView = Marionette.ItemView.extend({ } }, + // File types can render their own thumbnail instead of the image + // pointed at by the thumbnail url. Only created once the file is + // ready, which is why this is retried on state changes. + renderCustomThumbnail: function() { + if (this.customThumbnailView) { + return; + } + + this.customThumbnailView = this.model.createThumbnailView(); + + if (this.customThumbnailView) { + this.appendSubview(this.customThumbnailView, {to: this.ui.custom}); + } + }, + setStageClassName: function(name) { if (!this.$el.hasClass(name)) { this.ui.pictogram.removeClass('empty'); From 59209294c8198ef3c74219a71027df7ebc1653e4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 12 Aug 2026 10:08:11 +0200 Subject: [PATCH 13/17] Preview lottie animations in the file meta data overlay Plays the animation in a loop next to the files list. Since the editor runs in its own bundle, the setup pointing the player at the bundled WebAssembly module moves into a module shared with the frontend. --- .../lottieAnimation/LottieAnimation-spec.js | 31 ++------- .../views/LottieFilePreviewView-spec.js | 65 +++++++++++++++++++ .../spec/support/fakeDotLottiePlayers.js | 55 ++++++++++++++++ .../lottieAnimation/LottieAnimation.js | 7 +- .../lottieAnimation/dotLottie.js | 9 +++ .../lottieAnimation/editor/index.js | 2 + .../editor/views/LottieFilePreviewView.js | 43 ++++++++++++ .../views/LottieFilePreviewView.module.css | 12 ++++ 8 files changed, 192 insertions(+), 32 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePreviewView-spec.js create mode 100644 entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/dotLottie.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.module.css 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 4bffc220dd..719b5bf662 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js @@ -7,38 +7,14 @@ import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; import {LottieAnimation} from 'contentElements/lottieAnimation/LottieAnimation'; import {DotLottie} from '@lottiefiles/dotlottie-web'; +import {fakeDotLottiePlayers} from 'support/fakeDotLottiePlayers'; + jest.mock('@lottiefiles/dotlottie-web', () => ({ DotLottie: Object.assign(jest.fn(), {setWasmUrl: jest.fn()}) })); describe('LottieAnimation', () => { - let players; - - beforeEach(() => { - players = []; - - DotLottie.mockImplementation(function(config) { - const listeners = {}; - - Object.assign(this, { - config, - play: jest.fn(), - pause: jest.fn(), - destroy: jest.fn(), - animationSize: jest.fn(() => ({width: 200, height: 100})), - - addEventListener(type, listener) { - listeners[type] = listener; - }, - - emit(type) { - act(() => listeners[type] && listeners[type]()); - } - }); - - players.push(this); - }); - }); + const {players, setAnimationSize} = fakeDotLottiePlayers({act}); function renderLottieAnimation({ configuration = {id: 100}, @@ -134,6 +110,7 @@ describe('LottieAnimation', () => { }); it('applies aspect ratio of animation once it has loaded', () => { + setAnimationSize({width: 200, height: 100}); const {container} = renderLottieAnimation(); players[0].emit('load'); diff --git a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePreviewView-spec.js b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePreviewView-spec.js new file mode 100644 index 0000000000..377306d2aa --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFilePreviewView-spec.js @@ -0,0 +1,65 @@ +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile'; +import { + LottieFilePreviewView +} from 'contentElements/lottieAnimation/editor/views/LottieFilePreviewView'; + +import {fakeDotLottiePlayers} from 'support/fakeDotLottiePlayers'; + +jest.mock('@lottiefiles/dotlottie-web', () => ({ + DotLottie: Object.assign(jest.fn(), {setWasmUrl: jest.fn()}) +})); + +describe('LottieFilePreviewView', () => { + const {players, setAnimationSize} = fakeDotLottiePlayers(); + + function previewView(attributes) { + return new LottieFilePreviewView({ + model: new LottieFile({ + state: 'uploaded', + original_url: '/animation.lottie', + ...attributes + }) + }); + } + + it('renders animation of the file in a canvas', () => { + const view = previewView(); + + 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(previewView()); + + expect(players[0].config.autoplay).toBe(true); + expect(players[0].config.loop).toBe(true); + }); + + it('applies aspect ratio of animation once it has loaded', () => { + setAnimationSize({width: 200, height: 100}); + const view = previewView(); + render(view); + + players[0].emit('load'); + + const canvas = view.el.querySelector('canvas'); + + expect(canvas.style.getPropertyValue('--preview-aspect-ratio')).toEqual('200 / 100'); + expect(canvas.style.getPropertyValue('--preview-width')).toEqual('200px'); + }); + + it('destroys player when closed', () => { + const view = previewView(); + 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 new file mode 100644 index 0000000000..a50de88519 --- /dev/null +++ b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js @@ -0,0 +1,55 @@ +import {DotLottie} from '@lottiefiles/dotlottie-web'; + +const defaultAnimationSize = {width: 100, height: 100}; + +// Records the players created by the code under test in the returned +// `players` array and provides setters to control what those players +// report about the animation. Since the module factory of `jest.mock` +// is hoisted above imports, spec files have to mock the player module +// themselves: +// +// jest.mock('@lottiefiles/dotlottie-web', () => ({ +// DotLottie: Object.assign(jest.fn(), {setWasmUrl: jest.fn()}) +// })); +// +// Pass React's `act` to wrap emitting player events when rendering +// components. +export function fakeDotLottiePlayers({act = fn => fn()} = {}) { + const players = []; + let animationSize; + + beforeEach(() => { + players.length = 0; + animationSize = defaultAnimationSize; + + DotLottie.mockImplementation(function(config) { + const listeners = {}; + + Object.assign(this, { + config, + play: jest.fn(), + pause: jest.fn(), + destroy: jest.fn(), + animationSize: jest.fn(() => animationSize), + + addEventListener(type, listener) { + listeners[type] = listener; + }, + + emit(type) { + act(() => listeners[type] && listeners[type]()); + } + }); + + players.push(this); + }); + }); + + return { + players, + + setAnimationSize(size) { + animationSize = size; + } + }; +} diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js index f531c51d35..bce9a70902 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js @@ -1,6 +1,4 @@ import React, {useEffect, useRef, useState} from 'react'; -import {DotLottie} from '@lottiefiles/dotlottie-web'; -import wasmUrl from '@lottiefiles/dotlottie-web/dotlottie-player.wasm'; import { ContentElementBox, @@ -12,10 +10,9 @@ import { useFileWithInlineRights } from 'pageflow-scrolled/frontend'; -import styles from './LottieAnimation.module.css'; +import {DotLottie} from './dotLottie'; -// Prevent the player from fetching its WebAssembly module from a CDN. -DotLottie.setWasmUrl(wasmUrl); +import styles from './LottieAnimation.module.css'; export function LottieAnimation({configuration}) { const lottieFile = useFileWithInlineRights({ diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/dotLottie.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/dotLottie.js new file mode 100644 index 0000000000..1372583551 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/dotLottie.js @@ -0,0 +1,9 @@ +import {DotLottie} from '@lottiefiles/dotlottie-web'; +import wasmUrl from '@lottiefiles/dotlottie-web/dotlottie-player.wasm'; + +// Prevent the player from fetching its WebAssembly module from a CDN. +// Frontend and editor run in separate bundles and thus each need to +// point the player at the module emitted by their own build. +DotLottie.setWasmUrl(wasmUrl); + +export {DotLottie}; 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 22750ef636..27f11c6e84 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -3,11 +3,13 @@ import {FileInputView} from 'pageflow/editor'; import {SelectInputView, SeparatorView} from 'pageflow/ui'; import {LottieFile} from './models/LottieFile'; +import {LottieFilePreviewView} from './views/LottieFilePreviewView'; import pictogram from './pictogram.svg'; editor.fileTypes.register('lottie_files', { model: LottieFile, + previewView: LottieFilePreviewView, // Browsers derive the content type of uploads from the file // extension. Since dotLottie is missing from their mappings, uploads 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 new file mode 100644 index 0000000000..69481be3b7 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.js @@ -0,0 +1,43 @@ +import Marionette from 'backbone.marionette'; + +import {cssModulesUtils} from 'pageflow/ui'; + +import {DotLottie} from '../../dotLottie'; + +import styles from './LottieFilePreviewView.module.css'; + +export const LottieFilePreviewView = Marionette.ItemView.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)); + }, + + // 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.ui.canvas[0].style.setProperty('--preview-aspect-ratio', + `${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/LottieFilePreviewView.module.css b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.module.css new file mode 100644 index 0000000000..be37108726 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFilePreviewView.module.css @@ -0,0 +1,12 @@ +/* Scaled down to whatever height the overlay has left, just like the + image and video previews. The player keeps the animation centered + inside the canvas, so a clamped height only adds transparent space + left and right. */ +.canvas { + display: block; + width: 100%; + max-width: var(--preview-width, 100%); + max-height: var(--preview-max-height, none); + margin: 0 auto; + aspect-ratio: var(--preview-aspect-ratio, 1); +} From 5a5c1dc3ec768b88ca9a722badfd72465e58ee7c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 12 Aug 2026 10:12:09 +0200 Subject: [PATCH 14/17] Show last frame of lottie animations in file thumbnails Animations commonly build up their scene over time, so the frame the player draws right after loading the file tends to be close to blank. The placeholder pictogram is no longer forced, since it would shine through transparent parts of the animation. --- .../views/LottieFileThumbnailView-spec.js | 65 +++++++++++++++++++ .../spec/support/fakeDotLottiePlayers.js | 9 +++ .../lottieAnimation/editor/index.js | 2 + .../editor/models/LottieFile.js | 7 +- .../editor/views/LottieFileThumbnailView.js | 36 ++++++++++ .../views/LottieFileThumbnailView.module.css | 6 ++ 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView-spec.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js create mode 100644 entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.module.css 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 new file mode 100644 index 0000000000..128bf7cfce --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView-spec.js @@ -0,0 +1,65 @@ +import {renderBackboneView as render} from 'pageflow/testHelpers'; + +import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile'; +import { + LottieFileThumbnailView +} from 'contentElements/lottieAnimation/editor/views/LottieFileThumbnailView'; + +import {fakeDotLottiePlayers} from 'support/fakeDotLottiePlayers'; + +jest.mock('@lottiefiles/dotlottie-web', () => ({ + DotLottie: Object.assign(jest.fn(), {setWasmUrl: jest.fn()}) +})); + +describe('LottieFileThumbnailView', () => { + const {players, setTotalFrames} = fakeDotLottiePlayers(); + + function thumbnailView(attributes) { + return new LottieFileThumbnailView({ + model: new LottieFile({ + state: 'uploaded', + original_url: '/animation.lottie', + ...attributes + }) + }); + } + + it('renders animation of the file in a canvas', () => { + const view = thumbnailView(); + + 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('seeks to the last frame of the animation once it has loaded', () => { + setTotalFrames(60); + const view = thumbnailView(); + + render(view); + players[0].emit('load'); + + expect(players[0].setFrame).toHaveBeenCalledWith(59); + }); + + it('does not play the animation', () => { + const view = thumbnailView(); + + render(view); + players[0].emit('load'); + + expect(players[0].config.autoplay).toBe(false); + expect(players[0].play).not.toHaveBeenCalled(); + }); + + it('destroys player when closed', () => { + const view = thumbnailView(); + 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 a50de88519..19f1c18765 100644 --- a/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js +++ b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js @@ -1,6 +1,7 @@ import {DotLottie} from '@lottiefiles/dotlottie-web'; const defaultAnimationSize = {width: 100, height: 100}; +const defaultTotalFrames = 10; // Records the players created by the code under test in the returned // `players` array and provides setters to control what those players @@ -17,18 +18,22 @@ const defaultAnimationSize = {width: 100, height: 100}; export function fakeDotLottiePlayers({act = fn => fn()} = {}) { const players = []; let animationSize; + let totalFrames; beforeEach(() => { players.length = 0; animationSize = defaultAnimationSize; + totalFrames = defaultTotalFrames; DotLottie.mockImplementation(function(config) { const listeners = {}; Object.assign(this, { config, + totalFrames, play: jest.fn(), pause: jest.fn(), + setFrame: jest.fn(), destroy: jest.fn(), animationSize: jest.fn(() => animationSize), @@ -50,6 +55,10 @@ export function fakeDotLottiePlayers({act = fn => fn()} = {}) { setAnimationSize(size) { animationSize = size; + }, + + setTotalFrames(count) { + totalFrames = count; } }; } 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 27f11c6e84..fa0789df5b 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js @@ -4,12 +4,14 @@ import {SelectInputView, SeparatorView} from 'pageflow/ui'; import {LottieFile} from './models/LottieFile'; import {LottieFilePreviewView} from './views/LottieFilePreviewView'; +import {LottieFileThumbnailView} from './views/LottieFileThumbnailView'; import pictogram from './pictogram.svg'; editor.fileTypes.register('lottie_files', { model: LottieFile, previewView: LottieFilePreviewView, + thumbnailView: LottieFileThumbnailView, // Browsers derive the content type of uploads from the file // extension. Since dotLottie is missing from their mappings, uploads 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 62ad5206c1..234a246a32 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,5 +1,6 @@ import {UploadableFile} from 'pageflow/editor'; -export const LottieFile = UploadableFile.extend({ - thumbnailPictogram: 'other' -}); +// 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({}); 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 new file mode 100644 index 0000000000..42549f76c6 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.js @@ -0,0 +1,36 @@ +import Marionette from 'backbone.marionette'; + +import {cssModulesUtils} from 'pageflow/ui'; + +import {DotLottie} from '../../dotLottie'; + +import styles from './LottieFileThumbnailView.module.css'; + +export const LottieFileThumbnailView = Marionette.ItemView.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)); + }, + + // 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() { + this.player.setFrame(this.player.totalFrames - 1); + }, + + onClose: function() { + this.player.destroy(); + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.module.css b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.module.css new file mode 100644 index 0000000000..aff34ee0f2 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/views/LottieFileThumbnailView.module.css @@ -0,0 +1,6 @@ +.thumbnail, +.canvas { + display: block; + width: 100%; + height: 100%; +} From d7ed4d334014934460fa884794877ac73ecfb03c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 12 Aug 2026 10:28:10 +0200 Subject: [PATCH 15/17] Close file input thumbnails that are replaced Selecting another file left the previous thumbnail view behind with its model bindings intact. File types can now render thumbnail views which hold on to resources of their file, so the view has to be closed. Since closing removes the element of a view, the thumbnail moves into a container of its own. --- .../editor/views/inputs/FileInputView-spec.js | 50 +++++++++++++++++++ .../src/editor/views/inputs/FileInputView.js | 26 +++++++--- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/package/spec/editor/views/inputs/FileInputView-spec.js b/package/spec/editor/views/inputs/FileInputView-spec.js index c08c841c6e..c67c9e11ce 100644 --- a/package/spec/editor/views/inputs/FileInputView-spec.js +++ b/package/spec/editor/views/inputs/FileInputView-spec.js @@ -1,5 +1,6 @@ import {Configuration, FileInputView, BackgroundPositioningView, editor} from 'pageflow/editor'; import Backbone from 'backbone'; +import Marionette from 'backbone.marionette'; import * as support from '$support'; import {DropDownButton} from '$support/dominos/editor'; @@ -12,6 +13,55 @@ describe('FileInputView', () => { testContext = {}; }); + describe('thumbnail', () => { + function fileInputView({thumbnailView, ...options}) { + const fileTypes = support.factories.fileTypesWithImageFileType({thumbnailView}); + const entry = support.factories.entry({}, { + fileTypes, + filesAttributes: { + image_files: [ + {id: 1, perma_id: 5, state: 'processed'}, + {id: 2, perma_id: 6, state: 'processed'} + ] + } + }); + + return new FileInputView({ + collection: entry.getFileCollection(fileTypes.first()), + propertyName: 'file_id', + ...options + }); + } + + it('closes previous thumbnail when another file is selected', () => { + const onClose = jest.fn(); + const model = new Configuration({file_id: 5}); + const view = fileInputView({ + model, + thumbnailView: Marionette.ItemView.extend({ + template: () => '', + onClose + }) + }); + + render(view); + model.set('file_id', 6); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(view.el.querySelectorAll('.thumbnail_stand_in')).toHaveLength(1); + }); + + it('keeps thumbnail in the document when another file is selected', () => { + const model = new Configuration({file_id: 5}); + const view = fileInputView({model}); + + render(view); + model.set('file_id', 6); + + expect(view.el.querySelectorAll('.file_thumbnail')).toHaveLength(1); + }); + }); + it('displays file title', () => { const fixture = support.factories.imageFilesFixture({ imageFileAttributes: {perma_id: 5, file_name: 'image.png'} diff --git a/package/src/editor/views/inputs/FileInputView.js b/package/src/editor/views/inputs/FileInputView.js index 7761c18dac..05ac244100 100644 --- a/package/src/editor/views/inputs/FileInputView.js +++ b/package/src/editor/views/inputs/FileInputView.js @@ -27,7 +27,7 @@ export const FileInputView = Marionette.ItemView.extend({ -
+
Date: Wed, 12 Aug 2026 11:17:46 +0200 Subject: [PATCH 16/17] Apply image modifiers to lottie animations Let entries crop lottie animations to a theme aspect ratio and round their corners just like inline images. Since the animation is vector based, cropping only means giving the canvas a different box and telling the player to cover it instead of fitting the animation in. --- .../lottieAnimation/LottieAnimation-spec.js | 122 +++++++++++++++++- .../lottieAnimation/LottieAnimation.js | 34 +++-- 2 files changed, 144 insertions(+), 12 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 719b5bf662..709accea4a 100644 --- a/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js @@ -2,7 +2,7 @@ import React from 'react'; import {act} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; +import {renderInContentElement, useContentElementMatchers} from 'pageflow-scrolled/testHelpers'; import {LottieAnimation} from 'contentElements/lottieAnimation/LottieAnimation'; import {DotLottie} from '@lottiefiles/dotlottie-web'; @@ -16,6 +16,8 @@ jest.mock('@lottiefiles/dotlottie-web', () => ({ describe('LottieAnimation', () => { const {players, setAnimationSize} = fakeDotLottiePlayers({act}); + useContentElementMatchers(); + function renderLottieAnimation({ configuration = {id: 100}, scrollPosition = 'in viewport', @@ -118,4 +120,122 @@ describe('LottieAnimation', () => { expect(container.querySelector('[style*="--fit-viewport-aspect-ratio: 0.5"]')) .not.toBeNull(); }); + + it('contains animation inside its intrinsic aspect ratio by default', () => { + renderLottieAnimation(); + + expect(players[0].config.layout).toEqual({fit: 'contain'}); + }); + + describe('crop image modifier', () => { + it('applies aspect ratio from crop value', () => { + const {container} = renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'crop', value: 'wide'} + ] + } + }); + + expect(container).toContainFitViewport({aspectRatio: 'wide'}); + }); + + it('keeps aspect ratio from crop value once animation has loaded', () => { + const {container} = renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'crop', value: 'wide'} + ] + } + }); + + players[0].emit('load'); + + expect(container).toContainFitViewport({aspectRatio: 'wide'}); + }); + + it('lets animation fill the cropped box', () => { + renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'crop', value: 'wide'} + ] + } + }); + + expect(players[0].config.layout).toEqual({fit: 'cover'}); + }); + + it('forces 1:1 aspect ratio for circle crop', () => { + const {container} = renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'crop', value: 'circle'} + ] + } + }); + + expect(container).toContainFitViewport({aspectRatio: 'square'}); + }); + }); + + describe('rounded image modifier', () => { + it('applies border radius from rounded value', () => { + const {container} = renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'rounded', value: 'md'} + ] + } + }); + + expect(container).toContainContentElementBox({borderRadius: 'md'}); + }); + + it('applies circle border radius for circle crop', () => { + const {container} = renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'crop', value: 'circle'} + ] + } + }); + + expect(container).toContainContentElementBox({borderRadius: 'circle'}); + }); + + it('applies box shadow on circle box', () => { + const {container} = renderLottieAnimation({ + configuration: { + id: 100, + boxShadow: 'md', + imageModifiers: [ + {name: 'crop', value: 'circle'} + ] + } + }); + + expect(container).toContainContentElementBox({borderRadius: 'circle', boxShadow: 'md'}); + }); + + it('overrides rounded styles for circle crop', () => { + const {container} = renderLottieAnimation({ + configuration: { + id: 100, + imageModifiers: [ + {name: 'crop', value: 'circle'}, + {name: 'rounded', value: 'lg'} + ] + } + }); + + expect(container).toContainContentElementBox({borderRadius: 'circle'}); + }); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js index bce9a70902..f0694e7ec2 100644 --- a/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js +++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js @@ -6,6 +6,7 @@ import { FilePlaceholder, FitViewport, InlineFileRights, + processImageModifiers, useContentElementLifecycle, useFileWithInlineRights } from 'pageflow-scrolled/frontend'; @@ -20,19 +21,29 @@ export function LottieAnimation({configuration}) { }); const {shouldLoad, isVisible} = useContentElementLifecycle(); - const [aspectRatio, setAspectRatio] = useState(); + const [animationAspectRatio, setAnimationAspectRatio] = useState(); + + const {aspectRatio, rounded} = processImageModifiers(configuration.imageModifiers); + const isCircleCrop = rounded === 'circle'; return ( - - + + - - {lottieFile && shouldLoad && - } + + + {lottieFile && shouldLoad && + } + @@ -46,7 +57,7 @@ export function LottieAnimation({configuration}) { ); } -function Player({lottieFile, loop, play, onAspectRatioChange}) { +function Player({lottieFile, loop, play, fit, onAspectRatioChange}) { const canvasRef = useRef(); const dotLottieRef = useRef(); @@ -58,6 +69,7 @@ function Player({lottieFile, loop, play, onAspectRatioChange}) { canvas: canvasRef.current, src: lottieFile.urls.original, loop, + layout: {fit}, autoplay: false, renderConfig: {autoResize: true} }); @@ -82,7 +94,7 @@ function Player({lottieFile, loop, play, onAspectRatioChange}) { dotLottieRef.current = null; dotLottie.destroy(); }; - }, [lottieFile.urls.original, loop, onAspectRatioChange]); + }, [lottieFile.urls.original, loop, fit, onAspectRatioChange]); useEffect(() => { if (play) { From c48ea31635e734fe209ae7c98f0de6db2ef05d8f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 12 Aug 2026 11:18:33 +0200 Subject: [PATCH 17/17] Add image modifiers input to lottie animation element Crop positioning stays disabled for the file input: The positioning dialog displays the file via a CSS background image, which lottie files do not provide a derivative for. Animations are therefore always cropped around their center. --- .../lottieAnimation/editor/index-spec.js | 17 +++++++++++++++++ .../lottieAnimation/editor/index.js | 7 ++++++- 2 files changed, 23 insertions(+), 1 deletion(-) 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 1be4b67a5c..8b6fb15693 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 @@ -60,6 +60,23 @@ describe('lottieAnimation/editor', () => { expect(input.values()).toEqual(['loop', 'playOnce']); }); + + it('displays image modifiers input if animation is present', () => { + const configurationEditor = renderConfigurationEditor({ + lottieFiles: [{perma_id: 100}], + configuration: {id: 100} + }); + + expect(configurationEditor.visibleInputPropertyNames()) + .toContain('imageModifiers'); + }); + + it('does not display image modifiers input by default', () => { + const configurationEditor = renderConfigurationEditor({configuration: {}}); + + expect(configurationEditor.visibleInputPropertyNames()) + .not.toContain('imageModifiers'); + }); }); 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 fa0789df5b..191fad8f26 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,4 @@ -import {editor, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; +import {editor, ImageModifierListInputView, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; import {FileInputView} from 'pageflow/editor'; import {SelectInputView, SeparatorView} from 'pageflow/ui'; @@ -44,6 +44,11 @@ editor.contentElementTypes.register('lottieAnimation', { positioning: false, dropDownMenuItems: [InlineFileRightsMenuItem] }); + this.input('imageModifiers', ImageModifierListInputView, { + entry, + visibleBinding: 'id', + visible: () => this.model.getReference('id', 'lottie_files') + }); this.input('playbackMode', SelectInputView, {values: playbackModes}); this.view(SeparatorView);