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/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
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/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..90745d17fc 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:
@@ -761,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 2b036e2aa3..ba71870733 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:
@@ -746,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/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..1497fc01e1 100644
--- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb
+++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb
@@ -204,6 +204,15 @@ def configure(config)
)
end
+ 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')
c.features.enable_by_default('datawrapper_chart_embed_opt_in')
c.features.register('iframe_embed_content_element')
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 5be037db52..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
@@ -49,6 +67,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/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/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..709accea4a
--- /dev/null
+++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/LottieAnimation-spec.js
@@ -0,0 +1,241 @@
+import React from 'react';
+import {act} from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+
+import {renderInContentElement, useContentElementMatchers} 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', () => {
+ const {players, setAnimationSize} = fakeDotLottiePlayers({act});
+
+ useContentElementMatchers();
+
+ 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('loads WebAssembly module from own bundle instead of a CDN', () => {
+ expect(DotLottie.setWasmUrl).toHaveBeenCalledWith('wasm-url-stub');
+ });
+
+ 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', () => {
+ setAnimationSize({width: 200, height: 100});
+ const {container} = renderLottieAnimation();
+
+ players[0].emit('load');
+
+ 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/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..8b6fb15693
--- /dev/null
+++ b/entry_types/scrolled/package/spec/contentElements/lottieAnimation/editor/index-spec.js
@@ -0,0 +1,101 @@
+import {editor} from 'pageflow-scrolled/editor';
+import {SelectInput, useFakeFeatures} from 'pageflow/testHelpers';
+
+import {renderContentElementConfigurationEditor, useEditorGlobals} from 'support';
+
+import 'contentElements/lottieAnimation/editor';
+import {LottieFile} from 'contentElements/lottieAnimation/editor/models/LottieFile';
+
+describe('lottieAnimation/editor', () => {
+ 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('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', () => {
+ 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/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/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/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,
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..19f1c18765
--- /dev/null
+++ b/entry_types/scrolled/package/spec/support/fakeDotLottiePlayers.js
@@ -0,0 +1,64 @@
+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
+// 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;
+ 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),
+
+ addEventListener(type, listener) {
+ listeners[type] = listener;
+ },
+
+ emit(type) {
+ act(() => listeners[type] && listeners[type]());
+ }
+ });
+
+ players.push(this);
+ });
+ });
+
+ return {
+ players,
+
+ setAnimationSize(size) {
+ animationSize = size;
+ },
+
+ setTotalFrames(count) {
+ totalFrames = count;
+ }
+ };
+}
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/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/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/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
+ };
+}
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/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/LottieAnimation.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js
new file mode 100644
index 0000000000..f0694e7ec2
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/LottieAnimation.js
@@ -0,0 +1,111 @@
+import React, {useEffect, useRef, useState} from 'react';
+
+import {
+ ContentElementBox,
+ ContentElementFigure,
+ FilePlaceholder,
+ FitViewport,
+ InlineFileRights,
+ processImageModifiers,
+ useContentElementLifecycle,
+ useFileWithInlineRights
+} from 'pageflow-scrolled/frontend';
+
+import {DotLottie} from './dotLottie';
+
+import styles from './LottieAnimation.module.css';
+
+export function LottieAnimation({configuration}) {
+ const lottieFile = useFileWithInlineRights({
+ configuration, collectionName: 'lottieFiles', propertyName: 'id'
+ });
+
+ const {shouldLoad, isVisible} = useContentElementLifecycle();
+ const [animationAspectRatio, setAnimationAspectRatio] = useState();
+
+ const {aspectRatio, rounded} = processImageModifiers(configuration.imageModifiers);
+ const isCircleCrop = rounded === 'circle';
+
+ return (
+
+
+
+
+
+
+ {lottieFile && shouldLoad &&
+ }
+
+
+
+
+
+
+
+ );
+}
+
+function Player({lottieFile, loop, play, fit, 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,
+ layout: {fit},
+ 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, fit, 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/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
new file mode 100644
index 0000000000..191fad8f26
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/index.js
@@ -0,0 +1,64 @@
+import {editor, ImageModifierListInputView, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor';
+import {FileInputView} from 'pageflow/editor';
+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
+ // 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('imageModifiers', ImageModifierListInputView, {
+ entry,
+ visibleBinding: 'id',
+ visible: () => this.model.getReference('id', 'lottie_files')
+ });
+ 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/models/LottieFile.js b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.js
new file mode 100644
index 0000000000..234a246a32
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/lottieAnimation/editor/models/LottieFile.js
@@ -0,0 +1,6 @@
+import {UploadableFile} from 'pageflow/editor';
+
+// Registering a file type sets model naming on the prototype of its
+// model, which requires a class of its own even though the thumbnail
+// and preview views cover everything specific to lottie files.
+export const LottieFile = UploadableFile.extend({});
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 @@
+
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);
+}
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%;
+}
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/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'}}
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 0000000000..f2b8673c44
Binary files /dev/null and b/entry_types/scrolled/spec/fixtures/animation.lottie differ
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
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/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/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/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/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/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/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');
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({
-
+
(
{
input: `${pageflowScrolledPackageRoot}/src/contentElements/${name}/frontend.js`,
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)
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"