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: + 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: + 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"