diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 2181f72fd0..ad1e5209da 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -616,6 +616,9 @@ de: mute: Ausblenden play: Weiterspielen turnDown: Leiser weiterspielen + autoplay: + inline_help: Automatisch abspielen, wenn das Element in den sichtbaren Bereich gescrollt wird. + label: Autoplay id: label: Audio playerControlVariant: @@ -1457,24 +1460,46 @@ de: edit_defaults: back: Erscheinungsbild sections_info: Änderungen an diesen Einstellungen haben keine Auswirkungen auf existierende Abschnitte. + content_elements_info: |- + Änderungen an diesen Einstellungen haben keine Auswirkungen auf existierende Elemente. + Einstellungen können später für einzelne Elemente überschrieben werden. + all_elements: Alle Elemente tabs: sections: Neue Abschnitte + content_elements: Neue Elemente attributes: defaultSectionLayout: label: Vordergrund-Positionierung + inline_help: |- + Standardposition der scrollenden Vordergrund-Ebene + neuer Abschnitte in Desktop-Darstellung. values: center: Mitte centerRagged: Zentriert left: Links right: Rechts + defaultSectionAppearance: + label: Abblendung + inline_help: Standard-Erscheinung neuer Abschnitte. + values: + cards: Karte + shadow: Schatten + transparent: Keine topPaddingVisualization: label: Abstand oben defaultSectionPaddingTop: label: Abstand oben + inline_help: Standardabstand oberhalb des Inhalts neuer Abschnitte. bottomPaddingVisualization: label: Abstand unten defaultSectionPaddingBottom: label: Abstand unten + inline_help: Standardabstand unterhalb des Inhalts neuer Abschnitte. + defaultContentElementFullWidthInPhoneLayout: + label: Volle Breite im Phone-Layout + inline_help: |- + Standardmäßig Elemente auf kleineren Bildschirmen + die gesamte Breite des Viewports nutzen lassen. typography_sizes: xl: Sehr groß lg: Groß diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 8684142885..8cdc148cf7 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -601,14 +601,17 @@ en: mute: Mute play: Keep playing turnDown: Keep playing at lower volume + autoplay: + inline_help: Start playing automatically when the element is scrolled into view. + label: Autoplay id: label: Audio playerControlVariant: inline_help: Choose the style of player controls. - label: Waveform Style + label: Player Controls values: classic: Classic - waveform: Waveform (Fein) + waveform: Waveform (Fine) waveformBars: Waveform (Bars) waveformLines: Waveform (Lines) posterId: @@ -1440,24 +1443,46 @@ en: edit_defaults: back: Appearance sections_info: Changes to these settings have no effect on existing sections. + content_elements_info: |- + Changes to these settings have no effect on existing elements. + Settings can later be changed for individual elements. + all_elements: All elements tabs: sections: New sections + content_elements: New elements attributes: defaultSectionLayout: label: Content alignment + inline_help: |- + Default position of the scrolling foreground layer of + new sections on desktop devices. values: center: Centered centerRagged: Centered (Ragged) left: Left right: Right + defaultSectionAppearance: + label: Text background + inline_help: Default appearance of new sections. + values: + cards: Card + shadow: Shadow + transparent: Transparent topPaddingVisualization: label: Top padding defaultSectionPaddingTop: label: Top padding + inline_help: Default vertical spacing above the content of new sections. bottomPaddingVisualization: label: Bottom padding defaultSectionPaddingBottom: label: Bottom padding + inline_help: Default vertical spacing below the content of new sections. + defaultContentElementFullWidthInPhoneLayout: + label: Full width in phone layout + inline_help: |- + By default, make elements span the full width of the + viewport on smaller screens. typography_sizes: xl: Very large lg: Large diff --git a/entry_types/scrolled/doc/creating_content_element_types.md b/entry_types/scrolled/doc/creating_content_element_types.md index c43eddde33..6f1393a7b1 100644 --- a/entry_types/scrolled/doc/creating_content_element_types.md +++ b/entry_types/scrolled/doc/creating_content_element_types.md @@ -379,6 +379,57 @@ pageflow_scrolled: inline_help: "..." ``` +## Defaults Inputs + +Content element types can contribute inputs to the entry defaults +settings. These inputs allow editors to configure default values that +are applied when creating new content elements of that type. + +To define defaults inputs, provide a `defaultsInputs` function when +registering your content element type: + +```javascript +import {editor} from 'pageflow-scrolled/editor'; +import {CheckBoxInputView} from 'pageflow/ui'; + +editor.contentElementTypes.register('inlineImage', { + configurationEditor({entry}) { + // ... + }, + + defaultsInputs() { + this.input('enableFullscreen', CheckBoxInputView); + } +}); +``` + +The `defaultsInputs` function is called in a similar context as the +`this.tab` callback in `configurationEditor`. Property names are +automatically prefixed with `default-{typeName}-` when stored in the +entry metadata configuration. + +For translations, Pageflow first looks for keys under a `defaults` +namespace, then falls back to normal attribute translations: + +``` +pageflow_scrolled: + editor: + content_elements: + inlineImage: + defaults: + attributes: + enableFullscreen: + label: "..." + inline_help: "..." + attributes: + enableFullscreen: + label: "..." + inline_help: "..." +``` + +If the `defaults.attributes` translation is not found, the normal +`attributes` translation is used as a fallback. + ## Using Palette Colors [Palette diff --git a/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js b/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js index f6f902b42c..779defeb3a 100644 --- a/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js +++ b/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js @@ -146,6 +146,114 @@ describe('ContentElementTypeRegistry', () => { }); }); + describe('#getDefaultsInputsMapping', () => { + it('returns empty object for type without defaultsInputs', () => { + const registry = new ContentElementTypeRegistry({features: new Features()}); + registry.register('textBlock', {}); + + const mapping = registry.getDefaultsInputsMapping('textBlock'); + + expect(mapping).toEqual({}); + }); + + it('returns mapping from metadata keys to property names', () => { + const registry = new ContentElementTypeRegistry({features: new Features()}); + registry.register('inlineImage', { + defaultsInputs() { + this.input('enableFullscreen'); + this.input('autoplay'); + } + }); + + const mapping = registry.getDefaultsInputsMapping('inlineImage'); + + expect(mapping).toEqual({ + 'default-inlineImage-enableFullscreen': 'enableFullscreen', + 'default-inlineImage-autoplay': 'autoplay' + }); + }); + }); + + describe('#createDefaultsInputContext', () => { + it('prefixes property names passed to input', () => { + const registry = new ContentElementTypeRegistry({features: new Features()}); + const tabView = { + input: jest.fn(), + view: jest.fn() + }; + + const context = registry.createDefaultsInputContext(tabView, 'inlineImage'); + context.input('enableFullscreen', 'CheckBoxInputView', {some: 'option'}); + + expect(tabView.input).toHaveBeenCalledWith( + 'default-inlineImage-enableFullscreen', + 'CheckBoxInputView', + expect.objectContaining({some: 'option'}) + ); + }); + + it('adds attributeTranslationKeyPrefixes with fallback to normal attributes', () => { + const registry = new ContentElementTypeRegistry({features: new Features()}); + const tabView = { + input: jest.fn(), + view: jest.fn() + }; + + const context = registry.createDefaultsInputContext(tabView, 'inlineImage'); + context.input('enableFullscreen', 'CheckBoxInputView'); + + expect(tabView.input).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + attributeTranslationKeyPrefixes: [ + 'pageflow_scrolled.editor.content_elements.inlineImage.defaults.attributes', + 'pageflow_scrolled.editor.content_elements.inlineImage.attributes' + ], + attributeTranslationPropertyName: 'enableFullscreen' + }) + ); + }); + + it('preserves existing attributeTranslationKeyPrefixes', () => { + const registry = new ContentElementTypeRegistry({features: new Features()}); + const tabView = { + input: jest.fn(), + view: jest.fn() + }; + + const context = registry.createDefaultsInputContext(tabView, 'inlineImage'); + context.input('enableFullscreen', 'CheckBoxInputView', { + attributeTranslationKeyPrefixes: ['custom.prefix'] + }); + + expect(tabView.input).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + attributeTranslationKeyPrefixes: [ + 'pageflow_scrolled.editor.content_elements.inlineImage.defaults.attributes', + 'pageflow_scrolled.editor.content_elements.inlineImage.attributes', + 'custom.prefix' + ] + }) + ); + }); + + it('passes through view calls', () => { + const registry = new ContentElementTypeRegistry({features: new Features()}); + const tabView = { + input: jest.fn(), + view: jest.fn() + }; + + const context = registry.createDefaultsInputContext(tabView, 'inlineImage'); + context.view('SomeView', {some: 'option'}); + + expect(tabView.view).toHaveBeenCalledWith('SomeView', {some: 'option'}); + }); + }); + describe('#toArray', () => { it('returns array of with options passed to register', () => { const registry = new ContentElementTypeRegistry({features: new Features()}); diff --git a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js index fa24dad7b0..082751eb65 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -59,6 +59,15 @@ describe('Chapter', () => { expect(section.configuration.get('layout')).toEqual('right'); }); + it('uses default appearance from entry metadata configuration', () => { + const {entry} = testContext; + + entry.metadata.configuration.set('defaultSectionAppearance', 'cards'); + const section = entry.chapters.first().addSection(); + + expect(section.configuration.get('appearance')).toEqual('cards'); + }); + it('handles sparse positions correctly', () => { const {entry} = testContext; diff --git a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js index 540b3f21c3..cb8f17e410 100644 --- a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js @@ -515,6 +515,133 @@ describe('ContentElement', () => { }); }); + describe('#applyDefaultConfiguration', () => { + describe('with defaultsInputs', () => { + beforeEach(() => { + editor.contentElementTypes.register('contentElementWithDefaultsInputs', { + defaultsInputs() { + this.input('enableFullscreen'); + this.input('autoplay'); + } + }); + }); + + it('copies defaults from entry metadata to configuration', () => { + const entry = factories.entry( + ScrolledEntry, + { + metadata: { + configuration: { + 'default-contentElementWithDefaultsInputs-enableFullscreen': true, + 'default-contentElementWithDefaultsInputs-autoplay': false + } + } + }, + { + entryTypeSeed: normalizeSeed({ + contentElements: [] + }) + } + ); + + const contentElement = new entry.contentElements.model({ + typeName: 'contentElementWithDefaultsInputs' + }); + contentElement.applyDefaultConfiguration({entry}); + + expect(contentElement.configuration.get('enableFullscreen')).toEqual(true); + expect(contentElement.configuration.get('autoplay')).toEqual(false); + }); + + it('ignores undefined values in entry metadata', () => { + const entry = factories.entry( + ScrolledEntry, + { + metadata: { + configuration: { + 'default-contentElementWithDefaultsInputs-enableFullscreen': true + } + } + }, + { + entryTypeSeed: normalizeSeed({ + contentElements: [] + }) + } + ); + + const contentElement = new entry.contentElements.model({ + typeName: 'contentElementWithDefaultsInputs' + }); + contentElement.applyDefaultConfiguration({entry}); + + expect(contentElement.configuration.get('enableFullscreen')).toEqual(true); + expect(contentElement.configuration.has('autoplay')).toEqual(false); + }); + }); + + describe('with defaultContentElementFullWidthInPhoneLayout', () => { + beforeEach(() => { + editor.contentElementTypes.register('elementSupportingFullWidth', { + supportedWidthRange: ['md', 'full'] + }); + editor.contentElementTypes.register('elementNotSupportingFullWidth', { + supportedWidthRange: ['md', 'xl'] + }); + }); + + it('sets fullWidthInPhoneLayout if element supports it', () => { + const entry = factories.entry( + ScrolledEntry, + { + metadata: { + configuration: { + defaultContentElementFullWidthInPhoneLayout: true + } + } + }, + { + entryTypeSeed: normalizeSeed({ + contentElements: [] + }) + } + ); + + const contentElement = new entry.contentElements.model({ + typeName: 'elementSupportingFullWidth' + }); + contentElement.applyDefaultConfiguration({entry}); + + expect(contentElement.configuration.get('fullWidthInPhoneLayout')).toEqual(true); + }); + + it('does not set fullWidthInPhoneLayout if element does not support it', () => { + const entry = factories.entry( + ScrolledEntry, + { + metadata: { + configuration: { + defaultContentElementFullWidthInPhoneLayout: true + } + } + }, + { + entryTypeSeed: normalizeSeed({ + contentElements: [] + }) + } + ); + + const contentElement = new entry.contentElements.model({ + typeName: 'elementNotSupportingFullWidth' + }); + contentElement.applyDefaultConfiguration({entry}); + + expect(contentElement.configuration.has('fullWidthInPhoneLayout')).toEqual(false); + }); + }); + }); + describe('#getEditorPath', () => { it('returns content element path by default', () => { const entry = factories.entry( diff --git a/entry_types/scrolled/package/spec/editor/views/ContentElementTypeSeparatorView-spec.js b/entry_types/scrolled/package/spec/editor/views/ContentElementTypeSeparatorView-spec.js new file mode 100644 index 0000000000..03b2d97311 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/ContentElementTypeSeparatorView-spec.js @@ -0,0 +1,26 @@ +import '@testing-library/jest-dom/extend-expect'; + +import {ContentElementTypeSeparatorView} from 'editor/views/ContentElementTypeSeparatorView'; +import {renderBackboneView} from 'pageflow/testHelpers'; + +describe('ContentElementTypeSeparatorView', () => { + it('renders pictogram as mask', () => { + const view = new ContentElementTypeSeparatorView({ + pictogram: 'path/to/pictogram.svg', + typeName: 'Some Element' + }); + + renderBackboneView(view); + + expect(view.el.querySelector('[style*="mask-image"]')).toBeInTheDocument(); + }); + + it('renders type name', () => { + const {getByText} = renderBackboneView(new ContentElementTypeSeparatorView({ + pictogram: 'path/to/pictogram.svg', + typeName: 'Some Element' + })); + + expect(getByText('Some Element')).toBeInTheDocument(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/EditDefaultsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditDefaultsView-spec.js index 5f56bba2f9..155feb055c 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditDefaultsView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditDefaultsView-spec.js @@ -1,16 +1,21 @@ import '@testing-library/jest-dom/extend-expect'; +import {fireEvent} from '@testing-library/react'; import {EditDefaultsView} from 'editor/views/EditDefaultsView'; import {features} from 'pageflow/frontend'; +import {CheckBoxInputView} from 'pageflow/ui'; import {ConfigurationEditor, useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; +import {editor} from 'editor/api'; + describe('EditDefaultsView', () => { const {createEntry} = useEditorGlobals(); useFakeTranslations({ 'pageflow_scrolled.editor.edit_defaults.tabs.sections': 'Sections', + 'pageflow_scrolled.editor.edit_defaults.tabs.content_elements': 'New Elements', 'pageflow_scrolled.editor.edit_defaults.attributes.defaultSectionLayout.label': 'Default layout', 'pageflow_scrolled.editor.edit_defaults.attributes.defaultSectionLayout.values.left': 'Left', 'pageflow_scrolled.editor.edit_defaults.attributes.defaultSectionLayout.values.right': 'Right', @@ -18,6 +23,9 @@ describe('EditDefaultsView', () => { 'pageflow_scrolled.editor.edit_defaults.attributes.defaultSectionLayout.values.centerRagged': 'Center ragged', 'pageflow_scrolled.editor.edit_defaults.attributes.defaultSectionPaddingTop.label': 'Default top padding', 'pageflow_scrolled.editor.edit_defaults.attributes.defaultSectionPaddingBottom.label': 'Default bottom padding', + 'pageflow_scrolled.editor.edit_defaults.attributes.defaultContentElementEnableFullscreen.label': 'Enable fullscreen', + 'pageflow_scrolled.editor.edit_defaults.attributes.defaultContentElementFullWidthInPhoneLayout.label': 'Full width in phone layout', + 'pageflow_scrolled.editor.edit_defaults.content_elements_info': 'Changes to these settings have no effect on existing elements.', 'pageflow_scrolled.editor.section_padding_visualization.top_padding': 'TopPadding', 'pageflow_scrolled.editor.section_padding_visualization.bottom_padding': 'Bottom' }); @@ -35,6 +43,19 @@ describe('EditDefaultsView', () => { expect(getByRole('tab', {name: 'Sections'})).toBeInTheDocument(); }); + it('renders with content elements tab', () => { + const entry = createEntry({}); + + const view = new EditDefaultsView({ + model: entry.metadata, + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('tab', {name: 'New Elements'})).toBeInTheDocument(); + }); + it('contains defaultSectionLayout select input', () => { const entry = createEntry({}); @@ -49,6 +70,35 @@ describe('EditDefaultsView', () => { expect(configurationEditor.inputPropertyNames()).toContain('defaultSectionLayout'); }); + it('contains defaultContentElementFullWidthInPhoneLayout checkbox input', () => { + const entry = createEntry({}); + + const view = new EditDefaultsView({ + model: entry.metadata, + entry + }); + + const {getByRole} = renderBackboneView(view); + fireEvent.click(getByRole('tab', {name: 'New Elements'})); + const configurationEditor = ConfigurationEditor.find(view); + + expect(configurationEditor.inputPropertyNames()).toContain('defaultContentElementFullWidthInPhoneLayout'); + }); + + it('shows info box in content elements tab', () => { + const entry = createEntry({}); + + const view = new EditDefaultsView({ + model: entry.metadata, + entry + }); + + const {getByRole, getByText} = renderBackboneView(view); + fireEvent.click(getByRole('tab', {name: 'New Elements'})); + + expect(getByText('Changes to these settings have no effect on existing elements.')).toBeInTheDocument(); + }); + describe('with section_paddings feature flag', () => { beforeEach(() => features.enable('frontend', ['section_paddings'])); @@ -94,4 +144,106 @@ describe('EditDefaultsView', () => { expect(getByRole('img', {name: 'Bottom'})).toBeInTheDocument(); }); }); + + describe('with content element type defaultsInputs', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.edit_defaults.tabs.sections': 'Sections', + 'pageflow_scrolled.editor.edit_defaults.tabs.content_elements': 'New Elements', + 'pageflow_scrolled.editor.edit_defaults.content_elements_info': 'Info', + 'pageflow_scrolled.editor.content_elements.testElement.name': 'Test Element', + 'pageflow_scrolled.editor.content_elements.testElement.description': 'A test element', + 'pageflow_scrolled.editor.content_elements.testElement.defaults.attributes.enableFullscreen.label': + 'Test Fullscreen' + }); + + beforeEach(() => { + editor.contentElementTypes.register('testElement', { + defaultsInputs() { + this.input('enableFullscreen', CheckBoxInputView); + } + }); + }); + + it('renders separator with type name above inputs', () => { + const entry = createEntry({}); + + const view = new EditDefaultsView({ + model: entry.metadata, + entry + }); + + const {getByRole, getByText} = renderBackboneView(view); + fireEvent.click(getByRole('tab', {name: 'New Elements'})); + + expect(getByText('Test Element')).toBeInTheDocument(); + }); + + it('renders inputs from content element defaultsInputs with prefixed property name', () => { + const entry = createEntry({}); + + const view = new EditDefaultsView({ + model: entry.metadata, + entry + }); + + const {getByRole} = renderBackboneView(view); + fireEvent.click(getByRole('tab', {name: 'New Elements'})); + const configurationEditor = ConfigurationEditor.find(view); + + expect(configurationEditor.inputPropertyNames()).toContain('default-testElement-enableFullscreen'); + }); + + it('applies attributeTranslationKeyPrefixes to inputs', () => { + const entry = createEntry({}); + + const view = new EditDefaultsView({ + model: entry.metadata, + entry + }); + + const {getByRole, getByText} = renderBackboneView(view); + fireEvent.click(getByRole('tab', {name: 'New Elements'})); + + expect(getByText('Test Fullscreen')).toBeInTheDocument(); + }); + }); + + describe('with multiple content element types with defaultsInputs', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.edit_defaults.tabs.sections': 'Sections', + 'pageflow_scrolled.editor.edit_defaults.tabs.content_elements': 'New Elements', + 'pageflow_scrolled.editor.edit_defaults.content_elements_info': 'Info', + 'pageflow_scrolled.editor.content_elements.elementA.defaults.attributes.optionA.label': 'Option A', + 'pageflow_scrolled.editor.content_elements.elementB.defaults.attributes.optionB.label': 'Option B' + }); + + beforeEach(() => { + editor.contentElementTypes.register('elementA', { + defaultsInputs() { + this.input('optionA', CheckBoxInputView); + } + }); + editor.contentElementTypes.register('elementB', { + defaultsInputs() { + this.input('optionB', CheckBoxInputView); + } + }); + }); + + it('renders inputs from all content element types', () => { + const entry = createEntry({}); + + const view = new EditDefaultsView({ + model: entry.metadata, + entry + }); + + const {getByRole} = renderBackboneView(view); + fireEvent.click(getByRole('tab', {name: 'New Elements'})); + const configurationEditor = ConfigurationEditor.find(view); + + expect(configurationEditor.inputPropertyNames()).toContain('default-elementA-optionA'); + expect(configurationEditor.inputPropertyNames()).toContain('default-elementB-optionB'); + }); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js index 0803588566..ba58fd831e 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js @@ -12,6 +12,13 @@ editor.contentElementTypes.register('inlineAudio', { defaultConfig: {playerControlVariant: 'waveformBars'}, + defaultsInputs() { + this.input('autoplay', CheckBoxInputView); + this.input('playerControlVariant', SelectInputView, { + values: ['waveformBars', 'waveformLines', 'waveform', 'classic'] + }); + }, + configurationEditor({entry}) { const themeOptions = entry.getTheme().get('options'); diff --git a/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js b/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js index 96678efc2b..ff8c308c56 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js @@ -11,6 +11,10 @@ editor.contentElementTypes.register('inlineImage', { supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right'], supportedWidthRange: ['xxs', 'full'], + defaultsInputs() { + this.input('enableFullscreen', CheckBoxInputView); + }, + configurationEditor({entry, contentElement}) { this.tab('general', function() { this.input('id', FileInputView, { diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js index 7959cad2a2..fa683984a9 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js @@ -38,6 +38,12 @@ editor.contentElementTypes.register('inlineVideo', { supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + defaultsInputs() { + this.input('playbackMode', SelectInputView, { + values: ['manual', 'autoplay', 'autoplayIfUnmuted', 'loop'] + }); + }, + configurationEditor({entry}) { migrateLegacyAutoplay(this.model); diff --git a/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js b/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js index 2e09c3c5e2..cbf6b01515 100644 --- a/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js +++ b/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js @@ -115,4 +115,46 @@ export class ContentElementTypeRegistry { !contentElement.featureName || this.features.isEnabled(contentElement.featureName) ); } + + getDefaultsInputsMapping(typeName) { + const type = this.contentElementTypes[typeName]; + + if (!type?.defaultsInputs) { + return {}; + } + + const mapping = {}; + + type.defaultsInputs.call({ + input(propertyName) { + mapping[`default-${typeName}-${propertyName}`] = propertyName; + }, + view() {} + }); + + return mapping; + } + + createDefaultsInputContext(tabView, typeName) { + return { + input(propertyName, View, options = {}) { + tabView.input( + `default-${typeName}-${propertyName}`, + View, + { + ...options, + attributeTranslationKeyPrefixes: [ + `pageflow_scrolled.editor.content_elements.${typeName}.defaults.attributes`, + `pageflow_scrolled.editor.content_elements.${typeName}.attributes`, + ...(options.attributeTranslationKeyPrefixes || []) + ], + attributeTranslationPropertyName: propertyName + } + ); + }, + view(View, options) { + tabView.view(View, options); + } + }; + } } diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index b28b32a5c1..07e4dca9f5 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -65,6 +65,7 @@ export const Chapter = Backbone.Model.extend({ const defaultConfiguration = options.skipDefaults ? {} : { transition: this.entry.metadata.configuration.get('defaultTransition'), layout: this.entry.metadata.configuration.get('defaultSectionLayout'), + appearance: this.entry.metadata.configuration.get('defaultSectionAppearance'), paddingTop: this.entry.metadata.configuration.get('defaultSectionPaddingTop'), paddingBottom: this.entry.metadata.configuration.get('defaultSectionPaddingBottom') }; diff --git a/entry_types/scrolled/package/src/editor/models/ContentElement.js b/entry_types/scrolled/package/src/editor/models/ContentElement.js index 1c2d11f19b..971dc37e74 100644 --- a/entry_types/scrolled/package/src/editor/models/ContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ContentElement.js @@ -70,8 +70,11 @@ export const ContentElement = Backbone.Model.extend({ ]; }, - applyDefaultConfiguration(sibling) { - const defaultConfig = {...this.getType().defaultConfig}; + applyDefaultConfiguration({entry, sibling}) { + const defaultConfig = { + ...this.getType().defaultConfig, + ...this.getDefaultsFromEntryMetadata(entry) + }; const defaultPosition = sibling?.getPosition(); const supportedPositions = this.getType().supportedPositions || []; @@ -87,6 +90,32 @@ export const ContentElement = Backbone.Model.extend({ this.configuration.set(defaultConfig); }, + getDefaultsFromEntryMetadata(entry) { + const defaults = {}; + + Object.entries(this.getEntryMetadataDefaultsMapping()).forEach(([metadataKey, propertyName]) => { + const value = entry.metadata.configuration.get(metadataKey); + + if (value !== undefined) { + defaults[propertyName] = value; + } + }); + + return defaults; + }, + + getEntryMetadataDefaultsMapping() { + const mapping = { + ...editor.contentElementTypes.getDefaultsInputsMapping(this.get('typeName')) + }; + + if (this.supportsFullWidthInPhoneLayout()) { + mapping.defaultContentElementFullWidthInPhoneLayout = 'fullWidthInPhoneLayout'; + } + + return mapping; + }, + getPosition() { return this.configuration.get('position'); }, diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index f650b3ca91..c4c1782780 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -102,7 +102,7 @@ export const ScrolledEntry = Entry.extend({ position: this.contentElements.length, ...attributes }); - contentElement.applyDefaultConfiguration(); + contentElement.applyDefaultConfiguration({entry: this}); this.sections.get(id).contentElements.add(contentElement); contentElement.save(); diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js index 5d6e6ef262..627eb4c3ea 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js @@ -12,7 +12,7 @@ export function insertContentElement(entry, sibling, attributes, {at, splitPoint } let contentElement = new ContentElement(attributes); - contentElement.applyDefaultConfiguration(sibling); + contentElement.applyDefaultConfiguration({entry, sibling}); if (at === 'before') { batch.insertBefore(sibling, contentElement); diff --git a/entry_types/scrolled/package/src/editor/views/ContentElementTypeSeparatorView.js b/entry_types/scrolled/package/src/editor/views/ContentElementTypeSeparatorView.js new file mode 100644 index 0000000000..bffa59c0d9 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/ContentElementTypeSeparatorView.js @@ -0,0 +1,25 @@ +import Marionette from 'backbone.marionette'; + +import styles from './ContentElementTypeSeparatorView.module.css'; + +export const ContentElementTypeSeparatorView = Marionette.ItemView.extend({ + className: styles.separator, + + template: (data) => ` + + ${data.typeName} + ${data.pictogram ? `` : ''} + + `, + + serializeData() { + return { + pictogram: this.options.pictogram, + typeName: this.options.typeName + }; + } +}); + +function escapeCssUrl(url) { + return url.replace(/'/g, "\\'").replace(/\n/g, ''); +} diff --git a/entry_types/scrolled/package/src/editor/views/ContentElementTypeSeparatorView.module.css b/entry_types/scrolled/package/src/editor/views/ContentElementTypeSeparatorView.module.css new file mode 100644 index 0000000000..fc3be301a0 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/ContentElementTypeSeparatorView.module.css @@ -0,0 +1,24 @@ +.separator { + display: flex; + align-items: center; + gap: space(2); + padding: space(4) 0 space(2); +} + +.typeName { + font-weight: 500; +} + +.pictogram { + width: 16px; + height: 16px; + background: var(--ui-primary-color-light); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; +} + +.rule { + flex: 1; + border-bottom: solid 1px var(--ui-on-surface-color-lightest); +} diff --git a/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js b/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js index 65b5000f7a..1a4ff82a73 100644 --- a/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js @@ -1,10 +1,13 @@ import I18n from 'i18n-js'; import {EditConfigurationView, InfoBoxView} from 'pageflow/editor'; -import {SelectInputView, SliderInputView} from 'pageflow/ui'; +import {CheckBoxInputView, SelectInputView, SliderInputView} from 'pageflow/ui'; import {features} from 'pageflow/frontend'; +import {editor} from '../api'; +import {ContentElementTypeSeparatorView} from './ContentElementTypeSeparatorView'; import {SectionPaddingVisualizationView} from './inputs/SectionPaddingVisualizationView'; +import defaultPictogram from './images/defaultPictogram.svg'; import paddingTopIcon from './images/paddingTop.svg'; import paddingBottomIcon from './images/paddingBottom.svg'; @@ -25,6 +28,10 @@ export const EditDefaultsView = EditConfigurationView.extend({ values: ['left', 'right', 'center', 'centerRagged'] }); + this.input('defaultSectionAppearance', SelectInputView, { + values: ['shadow', 'cards', 'transparent'] + }); + if (features.isEnabled('section_paddings')) { const paddingTopScale = entry.getScale('sectionPaddingTop'); const paddingBottomScale = entry.getScale('sectionPaddingBottom'); @@ -52,5 +59,34 @@ export const EditDefaultsView = EditConfigurationView.extend({ }); } }); + + configurationEditor.tab('content_elements', function() { + this.view(InfoBoxView, { + text: I18n.t('pageflow_scrolled.editor.edit_defaults.content_elements_info'), + level: 'info' + }); + + this.view(ContentElementTypeSeparatorView, { + typeName: I18n.t('pageflow_scrolled.editor.edit_defaults.all_elements') + }); + + this.input('defaultContentElementFullWidthInPhoneLayout', CheckBoxInputView); + + const tabView = this; + editor.contentElementTypes.toArray().forEach(contentElementType => { + if (contentElementType.defaultsInputs) { + tabView.view(ContentElementTypeSeparatorView, { + pictogram: contentElementType.pictogram || defaultPictogram, + typeName: contentElementType.displayName + }); + + const context = editor.contentElementTypes.createDefaultsInputContext( + tabView, + contentElementType.typeName + ); + contentElementType.defaultsInputs.call(context); + } + }); + }); } }); diff --git a/package/spec/ui/views/mixins/inputView-spec.js b/package/spec/ui/views/mixins/inputView-spec.js index 158f981d33..8213f8f1f8 100644 --- a/package/spec/ui/views/mixins/inputView-spec.js +++ b/package/spec/ui/views/mixins/inputView-spec.js @@ -47,6 +47,29 @@ describe('pageflow.inputView', () => { } ); }); + + describe('with attributeTranslationPropertyName', () => { + it( + 'uses attributeTranslationPropertyName instead of propertyName for translation keys', + () => { + var view = createInputView({ + attributeTranslationKeyPrefixes: [ + 'pageflow.rainbows.page_attributes' + ], + model: createModel({}, {i18nKey: 'page'}), + propertyName: 'prefixed_title', + attributeTranslationPropertyName: 'title' + }); + + var result = view.attributeTranslationKeys('label', {fallbackPrefix: 'activerecord.attributes'}); + + expect(result).toEqual([ + 'pageflow.rainbows.page_attributes.title.label', + 'activerecord.attributes.page.title' + ]); + } + ); + }); }); describe('#labelText', () => { @@ -119,6 +142,27 @@ describe('pageflow.inputView', () => { expect(result).toBe('AR Text'); }); }); + + describe('with attributeTranslationPropertyName', () => { + support.useFakeTranslations({ + 'pageflow.rainbows.page_attributes.title.label': 'Rainbow Text' + }); + + it('uses attributeTranslationPropertyName for prefixed translation lookup', () => { + var view = createInputView({ + attributeTranslationKeyPrefixes: [ + 'pageflow.rainbows.page_attributes' + ], + model: createModel({}, {i18nKey: 'page'}), + propertyName: 'prefixed_title', + attributeTranslationPropertyName: 'title' + }); + + var result = view.labelText(); + + expect(result).toBe('Rainbow Text'); + }); + }); }); describe('#inlineHelpText', () => { diff --git a/package/src/ui/views/mixins/inputView.js b/package/src/ui/views/mixins/inputView.js index e66e6b5da3..0b7837b74d 100644 --- a/package/src/ui/views/mixins/inputView.js +++ b/package/src/ui/views/mixins/inputView.js @@ -92,6 +92,11 @@ import {attributeBinding} from './attributeBinding'; * An array of prefixes to lookup translations for labels and * inline help texts based on attribute names. * + * @param {string} [options.attributeTranslationPropertyName] + * Use this property name instead of `propertyName` for looking up + * translations. Useful when the property name has been transformed + * but translations should use the original name. + * * @param {string} [options.additionalInlineHelpText] * A text that will be appended to the translation based inline * text. @@ -170,7 +175,7 @@ export const inputView = { */ attributeTranslationKeys: function(keyName, options) { return attributeTranslationKeys( - this.options.propertyName, + this.options.attributeTranslationPropertyName || this.options.propertyName, keyName, _.extend({ prefixes: this.options.attributeTranslationKeyPrefixes,