diff --git a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss index f5f5e7e77a..9ba5dc2801 100644 --- a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss +++ b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss @@ -160,7 +160,7 @@ } } - ul { + &:not(:last-child) ul { border-bottom: solid 1px var(--ui-on-surface-color-lighter); padding-bottom: 1px; margin-bottom: 1px; diff --git a/config/locales/de.yml b/config/locales/de.yml index 2a2caebf0c..c4a887c2fa 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1959,6 +1959,9 @@ de: feature_name: "'Titel und Bild'-Lade-Ansicht" widget_type_name: Titel und Bild ui: + color_picker: + hue: "Farbton" + opacity: "Deckkraft" configuration_editor: tabs: consent_bar: Consent-Leiste diff --git a/config/locales/en.yml b/config/locales/en.yml index 85e3d2d473..3a1b9a05a1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1946,6 +1946,9 @@ en: feature_name: "'Title and image' loading view" widget_type_name: Title and Image ui: + color_picker: + hue: "Hue" + opacity: "Opacity" configuration_editor: tabs: consent_bar: Consent bar diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index ee82ff23b3..0afa3c57b3 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -720,7 +720,7 @@ de: turnDown: Leiser weiterspielen hideControlBar: inline_help: Für kurze Videos, bei denen der Benutzer nicht an bestimmte Stellen springen will. - inline_help_disabled: Für Videos mit Wiedergabe-Modus "Loop" sind die Controls immer ausgeblendet. + inline_help_disabled: Für Videos mit Wiedergabe-Modus "Loop" oder kreisförmigem Zuschnitt sind die Controls immer ausgeblendet. label: Controls ausblenden id: label: Video @@ -1427,6 +1427,11 @@ de: lg: L xl: XL xxl: XXL + contentElementBoxShadow: + sm: S + md: M + lg: L + xl: XL content_element_text_inline_file_rights_attributes: showTextInlineFileRightsBackdrop: label: "Abblendung hinter Rechteangabe" @@ -1467,6 +1472,8 @@ de: remove: "Stil entfernen" marginTop: "Abstand oben" marginBottom: "Abstand unten" + boxShadow: "Schatten" + outlineColor: "Umrandung" image_modifier_list_input: add: "Stil hinzufügen..." remove: "Stil entfernen" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 08eb63adb2..d67631e39b 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -705,7 +705,7 @@ en: turnDown: Keep playing at lower volume hideControlBar: inline_help: For short videos where there is no need to seek. - inline_help_disabled: Controls are always hidden for videos with playback mode "Loop". + inline_help_disabled: Controls are always hidden for videos with playback mode "Loop" or circle crop. label: Hide controls id: label: Video @@ -1411,6 +1411,11 @@ en: lg: L xl: XL xxl: XXL + contentElementBoxShadow: + sm: S + md: M + lg: L + xl: XL content_element_text_inline_file_rights_attributes: showTextInlineFileRightsBackdrop: label: "Backdrop behind inline file rights" @@ -1451,6 +1456,8 @@ en: remove: "Remove style" marginTop: "Margin top" marginBottom: "Margin bottom" + boxShadow: "Box shadow" + outlineColor: "Outline" image_modifier_list_input: add: "Add style..." remove: "Remove style" diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index b7ab879b37..87964c9ba1 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -30,6 +30,13 @@ def configure(config) 'xxl' => '16em' } + box_shadow_scale = { + 'sm' => '0 1px 3px 0 rgb(0 0 0 / 0.2), 0 1px 2px -1px rgb(0 0 0 / 0.15)', + 'md' => '0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.15)', + 'lg' => '0 10px 15px -3px rgb(0 0 0 / 0.2), 0 4px 6px -4px rgb(0 0 0 / 0.12)', + 'xl' => '0 20px 25px -5px rgb(0 0 0 / 0.2), 0 8px 10px -6px rgb(0 0 0 / 0.12)' + } + c.themes.register_options_transform( ThemeOptionsDefaultScale.new( prefix: 'section_padding_top', @@ -51,12 +58,22 @@ def configure(config) ) ) + c.themes.register_options_transform( + ThemeOptionsDefaultScale.new( + prefix: 'content_element_box_shadow', + values: box_shadow_scale + ) + ) + c.themes.register_default_options( properties: { root: { 'section_default_padding_top' => 'max(10em, 20svh)', 'section_default_padding_bottom' => 'max(10em, 20svh)', - 'content_element_margin_style_default' => '2em' + 'content_element_margin_style_default' => '2em', + 'content_element_box_shadow_style_default' => '0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.15)', + 'box_shadow_clip_margin_bottom' => '3rem', + 'outline_color' => '#a0a0a080' }, cards_appearance_section: { 'section_default_padding_top' => 'max(10em, 20svh)', diff --git a/entry_types/scrolled/package/spec/contentElements/externalLinkList/frontend/ExternalLinkList/boxStyles-spec.js b/entry_types/scrolled/package/spec/contentElements/externalLinkList/frontend/ExternalLinkList/boxStyles-spec.js new file mode 100644 index 0000000000..037b5873a5 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/externalLinkList/frontend/ExternalLinkList/boxStyles-spec.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import {ExternalLinkList} from 'contentElements/externalLinkList/frontend/ExternalLinkList'; + +import {renderInContentElement} from 'pageflow-scrolled/testHelpers'; +import '@testing-library/jest-dom/extend-expect' + +describe('ExternalLinkList box styles', () => { + it('sets box shadow custom property from configuration', () => { + const {container} = renderInContentElement( + + ); + + const list = container.querySelector('ul'); + expect(list.style.getPropertyValue('--content-element-box-shadow')) + .toEqual('var(--theme-content-element-box-shadow-md)'); + }); + + it('sets outline color custom property from configuration', () => { + const {container} = renderInContentElement( + + ); + + const list = container.querySelector('ul'); + expect(list.style.getPropertyValue('--content-element-box-outline-color')) + .toEqual('#ff0000'); + }); + + it('does not set custom properties when not configured', () => { + const {container} = renderInContentElement( + + ); + + const list = container.querySelector('ul'); + expect(list.style.getPropertyValue('--content-element-box-shadow')).toEqual(''); + expect(list.style.getPropertyValue('--content-element-box-outline-color')).toEqual(''); + }); +}); diff --git a/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js b/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js index 2c563fae9c..8c5d8b7bbb 100644 --- a/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/inlineImage/InlineImage-spec.js @@ -34,7 +34,7 @@ describe('InlineImage', () => { }); const contentElement = getContentElement(); - expect(contentElement.getFitViewportAspectRatio()).toEqual('1'); + expect(contentElement.getFitViewportAspectRatio()).toEqual('square'); }); it('applies circle border radius', () => { @@ -57,6 +57,28 @@ describe('InlineImage', () => { expect(contentElement.getBoxBorderRadius()).toEqual('circle'); }); + it('applies box shadow on circle box', () => { + const {getContentElement} = renderContentElement({ + typeName: 'inlineImage', + configuration: { + id: 100, + boxShadow: 'md', + imageModifiers: [ + {name: 'crop', value: 'circle'} + ] + }, + imageFiles: [{ + permaId: 100, + width: 200, + height: 100 + }] + }); + + const contentElement = getContentElement(); + expect(contentElement.getBoxBorderRadius()).toEqual('circle'); + expect(contentElement.hasBoxShadow('md')).toBe(true); + }); + it('overrides rounded styles', () => { const {getContentElement} = renderContentElement({ typeName: 'inlineImage', @@ -100,6 +122,28 @@ describe('InlineImage', () => { expect(contentElement.getFitViewportAspectRatio()).toEqual('square'); }); + it('applies box shadow on outer box with rounded styles', () => { + const {getContentElement} = renderContentElement({ + typeName: 'inlineImage', + configuration: { + id: 100, + boxShadow: 'lg', + imageModifiers: [ + {name: 'rounded', value: 'md'} + ] + }, + imageFiles: [{ + permaId: 100, + width: 200, + height: 100 + }] + }); + + const contentElement = getContentElement(); + expect(contentElement.getBoxBorderRadius()).toEqual('md'); + expect(contentElement.hasBoxShadow('lg')).toBe(true); + }); + it('applies rounded styles independently', () => { const {getContentElement} = renderContentElement({ typeName: 'inlineImage', @@ -154,7 +198,7 @@ describe('InlineImage', () => { }); const contentElement = getContentElement(); - expect(contentElement.getFitViewportAspectRatio()).toEqual('1'); + expect(contentElement.getFitViewportAspectRatio()).toEqual('square'); expect(contentElement.getBoxBorderRadius()).toEqual('circle'); }); diff --git a/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js index 7b486970df..9d82271ad6 100644 --- a/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/StylesCollection-spec.js @@ -1,8 +1,8 @@ import {StylesCollection} from 'editor/collections/StylesCollection'; import {Style} from 'editor/models/Style'; +import Backbone from 'backbone'; import {useFakeTranslations} from 'pageflow/testHelpers'; -import {features} from 'pageflow/frontend'; describe('StylesCollection', () => { const exampleTypes = { @@ -37,8 +37,6 @@ describe('StylesCollection', () => { } }; - beforeEach(() => features.enabledFeatureNames = []); - describe('#getUnusedStyles', () => { useFakeTranslations({ 'pageflow_scrolled.editor.backdrop_effects.blur.label': 'Blur', @@ -63,23 +61,6 @@ describe('StylesCollection', () => { expect(unusedStyles.findWhere({name: 'brightness'}).get('hidden')).toEqual(true); }); - it('does not include decoration styles by default', () => { - const styles = new StylesCollection([], {types: exampleTypes}); - - const unusedStyles = styles.getUnusedStyles(); - - expect(unusedStyles.pluck('name')).not.toContain('frame'); - }); - - it('includes decoration styles if feature is enabled', () => { - const styles = new StylesCollection([], {types: exampleTypes}); - features.enable('frontend', ['decoration_effects']); - - const unusedStyles = styles.getUnusedStyles(); - - expect(unusedStyles.pluck('name')).toContain('frame'); - }); - it('selecting an unused style adds it to the collection', () => { const styles = new StylesCollection([], {types: exampleTypes}); @@ -422,9 +403,71 @@ describe('StylesCollection', () => { }); }); + describe('conditional styles', () => { + it('hides unused style when condition is not met', () => { + const bindingModel = new Backbone.Model({posterId: null}); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['S', 'M', 'L'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const styles = new StylesCollection([], {types, bindingModel}); + const unusedStyles = styles.getUnusedStyles(); + + expect(unusedStyles.findWhere({name: 'boxShadow'}).get('hidden')).toEqual(true); + }); + + it('shows unused style when condition becomes met', () => { + const bindingModel = new Backbone.Model({posterId: null}); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['S', 'M', 'L'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const styles = new StylesCollection([], {types, bindingModel}); + const unusedStyles = styles.getUnusedStyles(); + + bindingModel.set('posterId', 5); + + expect(unusedStyles.findWhere({name: 'boxShadow'}).get('hidden')).toEqual(false); + }); + + it('does not hide unused style without condition', () => { + const bindingModel = new Backbone.Model(); + const types = { + outlineColor: { + label: 'Outline', + kind: 'decoration', + inputType: 'color' + } + }; + + const styles = new StylesCollection([], {types, bindingModel}); + const unusedStyles = styles.getUnusedStyles(); + + expect(unusedStyles.findWhere({name: 'outlineColor'}).get('hidden')).toEqual(false); + }); + }); + describe('with effect types', () => { it('creates collection with effect style types', () => { - const styles = new StylesCollection([{name: 'blur', value: 50}], {types: Style.effectTypes}); + const styles = new StylesCollection([{name: 'blur', value: 50}], {types: Style.getEffectTypes()}); expect(styles.pluck('name')).toEqual(['blur']); expect(styles.first().minValue()).toEqual(0); diff --git a/entry_types/scrolled/package/spec/editor/models/Style-spec.js b/entry_types/scrolled/package/spec/editor/models/Style-spec.js index f461454007..320a3b165c 100644 --- a/entry_types/scrolled/package/spec/editor/models/Style-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Style-spec.js @@ -3,6 +3,7 @@ import {Style} from 'editor/models/Style'; import {editor} from 'pageflow-scrolled/editor'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {factories, useFakeTranslations} from 'pageflow/testHelpers'; +import {features} from 'pageflow/frontend'; import {normalizeSeed} from 'support'; describe('Style', () => { @@ -22,7 +23,9 @@ describe('Style', () => { }; useFakeTranslations({ - 'pageflow_scrolled.editor.backdrop_effects.blur.label': 'Blur' + 'pageflow_scrolled.editor.backdrop_effects.blur.label': 'Blur', + 'pageflow_scrolled.editor.content_element_style_list_input.boxShadow': 'Box shadow', + 'pageflow_scrolled.editor.content_element_style_list_input.outlineColor': 'Outline' }); it('has label based on translation', () => { @@ -78,6 +81,34 @@ describe('Style', () => { expect(style.inputType()).toEqual('color'); }); + describe('.getEffectTypes', () => { + beforeEach(() => features.enabledFeatureNames = []); + + it('includes filter and animation effects', () => { + const types = Style.getEffectTypes(); + + expect(types).toHaveProperty('blur'); + expect(types).toHaveProperty('autoZoom'); + expect(types.blur.kind).toEqual('filter'); + expect(types.autoZoom.kind).toEqual('animation'); + }); + + it('excludes decoration effects by default', () => { + const types = Style.getEffectTypes(); + + expect(types).not.toHaveProperty('frame'); + }); + + it('includes decoration effects when feature is enabled', () => { + features.enable('frontend', ['decoration_effects']); + + const types = Style.getEffectTypes(); + + expect(types).toHaveProperty('frame'); + expect(types.frame.kind).toEqual('decoration'); + }); + }); + describe('.getTypesForContentElement', () => { it('returns empty object when no margin scale is defined', () => { editor.contentElementTypes.register('textBlock', {}); @@ -138,11 +169,13 @@ describe('Style', () => { expect(result).toMatchObject({ marginTop: { + kind: 'spacing', inputType: 'slider', values: ['sm', 'md', 'lg'], texts: ['Small', 'Medium', 'Large'] }, marginBottom: { + kind: 'spacing', inputType: 'slider', values: ['sm', 'md', 'lg'], texts: ['Small', 'Medium', 'Large'] @@ -231,6 +264,266 @@ describe('Style', () => { expect(result.marginTop.defaultValue).toEqual('sm'); expect(result.marginBottom.defaultValue).toEqual('sm'); }); + + it('returns boxShadow type when supportedStyles includes boxShadow and scale is defined', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['boxShadow'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ], + themeOptions: { + properties: { + root: { + 'contentElementBoxShadow-sm': '0 1px 3px rgba(0,0,0,0.12)', + 'contentElementBoxShadow-md': '0 4px 6px rgba(0,0,0,0.1)', + 'contentElementBoxShadow-lg': '0 10px 15px rgba(0,0,0,0.1)' + } + } + }, + themeTranslations: { + scales: { + contentElementBoxShadow: { + sm: 'Small', + md: 'Medium', + lg: 'Large' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).toMatchObject({ + boxShadow: { + kind: 'decoration', + inputType: 'slider', + propertyName: 'boxShadow', + values: ['sm', 'md', 'lg'], + texts: ['Small', 'Medium', 'Large'] + } + }); + }); + + it('does not return boxShadow when type does not have supportedStyles', () => { + editor.contentElementTypes.register('textBlock', {}); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'textBlock'} + ], + themeOptions: { + properties: { + root: { + 'contentElementBoxShadow-sm': '0 1px 3px rgba(0,0,0,0.12)', + 'contentElementBoxShadow-md': '0 4px 6px rgba(0,0,0,0.1)' + } + } + }, + themeTranslations: { + scales: { + contentElementBoxShadow: { + sm: 'Small', + md: 'Medium' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).not.toHaveProperty('boxShadow'); + }); + + it('does not return boxShadow when no scale is defined', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['boxShadow'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).not.toHaveProperty('boxShadow'); + }); + + it('returns outlineColor type when supportedStyles includes outline', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['outline'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).toMatchObject({ + outlineColor: { + kind: 'decoration', + inputType: 'color', + propertyName: 'outlineColor' + } + }); + }); + + it('uses outlineColor theme property as default for outlineColor', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['outline'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage'} + ], + themeOptions: { + properties: { + root: { + outlineColor: '#cc0000' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result.outlineColor.defaultValue).toEqual('#cc0000'); + }); + + it('includes used outline colors as swatches in outlineColor inputOptions', () => { + editor.contentElementTypes.register('inlineImage', { + supportedStyles: ['outline'] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineImage', configuration: {outlineColor: '#ff0000'}}, + {id: 2, typeName: 'inlineImage', configuration: {outlineColor: '#00ff00'}}, + {id: 3, typeName: 'inlineImage'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result.outlineColor.inputOptions.swatches).toEqual( + expect.arrayContaining(['#ff0000', '#00ff00']) + ); + }); + + it('passes binding condition from supportedStyles object to type definition', () => { + const whenFn = posterId => !!posterId; + + editor.contentElementTypes.register('inlineAudio', { + supportedStyles: [ + {name: 'boxShadow', binding: 'posterId', when: whenFn} + ] + }); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'inlineAudio'} + ], + themeOptions: { + properties: { + root: { + 'contentElementBoxShadow-sm': '0 1px 3px rgba(0,0,0,0.12)', + 'contentElementBoxShadow-md': '0 4px 6px rgba(0,0,0,0.1)' + } + } + }, + themeTranslations: { + scales: { + contentElementBoxShadow: { + sm: 'Small', + md: 'Medium' + } + } + } + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result.boxShadow.binding).toEqual('posterId'); + expect(result.boxShadow.when).toBe(whenFn); + }); + + it('does not return outlineColor when type does not have supportedStyles', () => { + editor.contentElementTypes.register('textBlock', {}); + + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, typeName: 'textBlock'} + ] + }) + } + ); + + const contentElement = entry.contentElements.get(1); + const result = Style.getTypesForContentElement({entry, contentElement}); + + expect(result).not.toHaveProperty('outlineColor'); + }); }); describe('.getImageModifierTypes', () => { diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js index e74b9ba784..ca8d5360f3 100644 --- a/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/inputs/StyleListInputView-spec.js @@ -11,7 +11,9 @@ import styles from 'editor/views/inputs/StyleListInputView.module.css'; describe('StyleListInputView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.style_list_input.add': 'Add style', - 'pageflow_scrolled.editor.style_list_input.remove': 'Remove style' + 'pageflow_scrolled.editor.style_list_input.remove': 'Remove style', + 'pageflow.ui.color_picker.hue': 'Hue', + 'pageflow.ui.color_picker.opacity': 'Opacity' }); it('displays styles', () => { @@ -419,6 +421,124 @@ describe('StyleListInputView', () => { expect(view.el).not.toHaveClass(styles.allUsed); }); + it('renders hue and opacity sliders with aria-labels for color input with alpha', () => { + const types = { + outlineColor: { + label: 'Outline', + inputType: 'color', + propertyName: 'outlineColor', + inputOptions: {alpha: true} + } + }; + const model = new Backbone.Model({outlineColor: '#ff000080'}); + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + const {getByRole} = render(view); + + expect(getByRole('slider', {name: 'Hue'})).toBeDefined(); + expect(getByRole('slider', {name: 'Opacity'})).toBeDefined(); + }); + + it('renders hue slider with aria-label for color input without alpha', () => { + const types = { + frame: { + label: 'Frame', + inputType: 'color', + defaultValue: '#ffffff' + } + }; + const model = new Backbone.Model({styles: [ + {name: 'frame', value: '#ffffff'} + ]}); + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + const {getByRole, queryByRole} = render(view); + + expect(getByRole('slider', {name: 'Hue'})).toBeDefined(); + expect(queryByRole('slider', {name: 'Opacity'})).toBeNull(); + }); + + it('makes controls inert when binding condition becomes false', () => { + const model = new Backbone.Model({ + posterId: 5, + boxShadow: 'md' + }); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + propertyName: 'boxShadow', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['Small', 'Medium', 'Large'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + render(view); + + const controls = view.el.querySelector(`.${styles.controls}`); + expect(controls).not.toHaveAttribute('inert'); + + model.unset('posterId'); + + expect(controls).toHaveAttribute('inert'); + expect(view.el.querySelector(`.${styles.item}`)).toHaveClass(styles.unavailable); + }); + + it('removes inert from controls when binding condition becomes true', () => { + const model = new Backbone.Model({ + boxShadow: 'md' + }); + const types = { + boxShadow: { + label: 'Box shadow', + kind: 'decoration', + propertyName: 'boxShadow', + inputType: 'slider', + values: ['sm', 'md', 'lg'], + texts: ['Small', 'Medium', 'Large'], + defaultValue: 'md', + binding: 'posterId', + when: posterId => !!posterId + } + }; + + const view = new StyleListInputView({ + model, + propertyName: 'styles', + types, + translationKeyPrefix: 'pageflow_scrolled.editor.style_list_input' + }); + render(view); + + const controls = view.el.querySelector(`.${styles.controls}`); + expect(controls).toHaveAttribute('inert'); + + model.set('posterId', 5); + + expect(controls).not.toHaveAttribute('inert'); + expect(view.el.querySelector(`.${styles.item}`)).not.toHaveClass(styles.unavailable); + }); + it('allows removing styles', async () => { const types = { blur: { diff --git a/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js b/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js index ddc2ded947..20c02f91ad 100644 --- a/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js +++ b/entry_types/scrolled/package/spec/frontend/MediaPlayer-spec.js @@ -9,6 +9,7 @@ import {media} from 'pageflow/frontend'; import {MediaPlayer} from 'frontend/MediaPlayer'; import {EventContext} from 'frontend/useEventContextData'; import {StaticPreview} from 'frontend/useScrollPositionLifecycle'; +import styles from 'frontend/MediaPlayer.module.css'; describe('MediaPlayer', () => { useFakeMedia(); @@ -116,6 +117,91 @@ describe('MediaPlayer', () => { expect(getByRole('img')).toHaveAttribute('src', 'poster.jpg'); }); + it('has posterVisible class when video has poster and has not started playing', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('does not have posterVisible class when video has no poster', () => { + const {container} = render(); + + expect(container.firstChild).not.toHaveClass(styles.posterVisible); + }); + + it('does not have posterVisible class when video data loaded and played', () => { + const playerState = { + ...getInitialPlayerState(), + dataLoaded: true, + unplayed: false + }; + + const {container} = render(); + + expect(container.firstChild).not.toHaveClass(styles.posterVisible); + }); + + it('has posterVisible class when video data loaded but still unplayed', () => { + const playerState = { + ...getInitialPlayerState(), + dataLoaded: true, + unplayed: true + }; + + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('has posterVisible class for audio type with poster', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('keeps posterVisible class for audio type during playback', () => { + const playerState = { + ...getInitialPlayerState(), + dataLoaded: true, + unplayed: false + }; + + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.posterVisible); + }); + + it('applies contentElementBox class when applyContentElementBoxStyles is set', () => { + const {container} = render(); + + expect(container.firstChild).toHaveClass(styles.contentElementBox); + }); + + it('does not apply contentElementBox class by default', () => { + const {container} = render(); + + expect(container.firstChild).not.toHaveClass(styles.contentElementBox); + }); + it('renders audio player when sources are present', () => { const {queryPlayer} = render( { ) } }); + + frontend.contentElementTypes.register('testBoxShadow', { + component: function Component({configuration}) { + return ( +
+ + Some content with box shadow + +
+ ) + } + }); + + frontend.contentElementTypes.register('testOutline', { + component: function Component({configuration}) { + return ( +
+ + Some content with outline + +
+ ) + } + }); + + frontend.contentElementTypes.register('testBoxShadowNoBorderRadius', { + component: function Component({configuration}) { + return ( +
+ + Box shadow without border radius + +
+ ) + } + }); }); it('renders box', () => { @@ -105,4 +141,44 @@ describe('content element box', () => { expect(getContentElementByTestId('testNone').containsBox()).toEqual(false); }); + + it('applies box shadow CSS custom property from configuration', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'testBoxShadow', + configuration: {boxShadow: 'md'} + }] + } + }); + + expect(getContentElementByTestId('testBoxShadow').hasBoxShadow('md')).toBe(true); + }); + + it('applies outline color CSS custom property from configuration', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'testOutline', + configuration: {outlineColor: '#ff0000'} + }] + } + }); + + expect(getContentElementByTestId('testOutline').hasOutlineColor('#ff0000')).toBe(true); + }); + + it('renders box when borderRadius is "none" but configuration has boxShadow', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'testBoxShadowNoBorderRadius', + configuration: {boxShadow: 'md'} + }] + } + }); + + expect(getContentElementByTestId('testBoxShadowNoBorderRadius').containsBox()).toEqual(true); + expect(getContentElementByTestId('testBoxShadowNoBorderRadius').hasBoxShadow('md')).toBe(true); + }); }); diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index 9cc51aa54d..cd9c909c24 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -278,6 +278,16 @@ function createContentElementPageObject(el) { return match ? match[1] : cssValue; }, + hasBoxShadow(value) { + const wrapper = el.querySelector(`.${contentElementBoxStyles.wrapper}`); + return wrapper && wrapper.style.getPropertyValue('--content-element-box-shadow') === `var(--theme-content-element-box-shadow-${value})`; + }, + + hasOutlineColor(value) { + const wrapper = el.querySelector(`.${contentElementBoxStyles.wrapper}`); + return wrapper && wrapper.style.getPropertyValue('--content-element-box-outline-color') === value; + }, + hasMargin() { return !!el.closest(`.${contentElementMarginStyles.wrapper}`); }, diff --git a/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js b/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js index bdd8c06683..8cede05946 100644 --- a/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js +++ b/entry_types/scrolled/package/src/contentElements/dataWrapperChart/DataWrapperChart.js @@ -30,7 +30,7 @@ export function DataWrapperChart({configuration}) { const backgroundColor = configuration.backgroundColor || '#323d4d'; return ( - +
{linkList.map((link, index) => {children => - +
{children} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js index 617aed1d29..9226ae913b 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -20,6 +20,7 @@ editor.contentElementTypes.register('hotspots', { category: 'interactive', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], editorPath(contentElement) { const activeAreaId = contentElement.transientState.get('activeAreaId'); diff --git a/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js b/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js index 5135f7bf80..f27f893361 100644 --- a/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js +++ b/entry_types/scrolled/package/src/contentElements/iframeEmbed/IframeEmbed.js @@ -57,7 +57,7 @@ export function IframeEmbed({configuration}) { style={{pointerEvents: isEditable && !isSelected ? 'none' : undefined}}> - + {renderSpanningWrapper( diff --git a/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js b/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js index 6caf1cdc94..b53c0a6bbb 100644 --- a/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js +++ b/entry_types/scrolled/package/src/contentElements/iframeEmbed/editor.js @@ -13,6 +13,7 @@ editor.contentElementTypes.register('iframeEmbed', { featureName: 'iframe_embed_content_element', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], configurationEditor({entry}) { this.tab('general', function() { diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js index e9ddad542d..d5524618d6 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/ImageGallery.js @@ -148,7 +148,7 @@ function Scroller({ contentElementWidth === contentElementWidths.xl}, {[styles.full]: contentElementWidth === contentElementWidths.full}, - {[styles.clip]: configuration.hidePeeks}, + {[styles.hidePeeks]: configuration.hidePeeks}, {[styles.customMargin]: customMargin})}>
- +
- + !!posterId, + binding: 'posterId' + }, + 'outline' + ], defaultConfig: {playerControlVariant: 'waveformBars'}, diff --git a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js index 39e8683200..369b105cc2 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js +++ b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js @@ -66,7 +66,7 @@ export function BeforeAfter(configuration) { return ( - + diff --git a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js index f928db6f5a..cdd38c2be0 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js @@ -9,6 +9,7 @@ editor.contentElementTypes.register('inlineBeforeAfter', { category: 'interactive', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], configurationEditor({entry}) { this.tab('general', function() { diff --git a/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js b/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js index fd13dd2b3e..385ad88caf 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js +++ b/entry_types/scrolled/package/src/contentElements/inlineImage/InlineImage.js @@ -10,7 +10,9 @@ import { useFileWithInlineRights, usePortraitOrientation, ExpandableImage, - InlineFileRights + InlineFileRights, + processImageModifiers, + useFileWithCropPosition } from 'pageflow-scrolled/frontend'; import {features} from 'pageflow/frontend'; @@ -92,10 +94,12 @@ function ImageWithCaption({ - + imageModifier.name === name)?.value; -} - -function useFileWithCropPosition(file, cropPosition) { - return file && {...file, cropPosition}; -} diff --git a/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js b/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js index ff8c308c56..3bf23028a2 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineImage/editor.js @@ -1,5 +1,5 @@ import {editor, InlineFileRightsMenuItem, ImageModifierListInputView} from 'pageflow-scrolled/editor'; -import {contentElementWidths} from 'pageflow-scrolled/frontend'; +import {contentElementWidths, processImageModifiers} from 'pageflow-scrolled/frontend'; import {FileInputView} from 'pageflow/editor'; import {SeparatorView, CheckBoxInputView} from 'pageflow/ui'; @@ -10,6 +10,7 @@ editor.contentElementTypes.register('inlineImage', { category: 'media', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], defaultsInputs() { this.input('enableFullscreen', CheckBoxInputView); @@ -20,12 +21,12 @@ editor.contentElementTypes.register('inlineImage', { this.input('id', FileInputView, { collection: 'image_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: imageModifiers => imageModifiers?.length, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, positioningBinding: 'imageModifiers', positioningOptions: () => { - const aspectRatio = entry.getAspectRatio(this.model.get('imageModifiers')?.[0]?.value); + const {aspectRatio} = processImageModifiers(this.model.get('imageModifiers')); return { - preview: aspectRatio && (1 / aspectRatio) + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) }; }, dropDownMenuItems: [InlineFileRightsMenuItem] @@ -38,13 +39,13 @@ editor.contentElementTypes.register('inlineImage', { this.input('portraitId', FileInputView, { collection: 'image_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: imageModifiers => imageModifiers?.length, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, positioningBinding: 'portraitImageModifiers', positioningOptions: () => { - const aspectRatio = entry.getAspectRatio(this.model.get('portraitImageModifiers')?.[0]?.value); + const {aspectRatio} = processImageModifiers(this.model.get('portraitImageModifiers')); return { - preview: aspectRatio && (1 / aspectRatio) - } + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) + }; } }); this.input('portraitImageModifiers', ImageModifierListInputView, { diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js index 738fafe9c6..fc1439a127 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js @@ -10,6 +10,9 @@ import { InlineFileRights, FitViewport, PlayerEventContextDataProvider, + contentElementBoxProps, + processImageModifiers, + useFileWithCropPosition, useBackgroundFile, useContentElementEditorState, useFileWithInlineRights, @@ -27,38 +30,38 @@ import { } from './handlers'; export function InlineVideo({contentElementId, configuration, sectionProps}) { - const videoFile = useFileWithInlineRights({ - configuration, - collectionName: 'videoFiles', - propertyName: 'id' - }); + const videoFile = useFileWithCropPosition( + useFileWithInlineRights({ + configuration, collectionName: 'videoFiles', propertyName: 'id' + }), + configuration.cropPosition + ); const posterImageFile = useFileWithInlineRights({ configuration, collectionName: 'imageFiles', propertyName: 'posterId' }); - const portraitVideoFile = useFileWithInlineRights({ - configuration, - collectionName: 'videoFiles', - propertyName: 'portraitId' - }); + const portraitVideoFile = useFileWithCropPosition( + useFileWithInlineRights({ + configuration, collectionName: 'videoFiles', propertyName: 'portraitId' + }), + configuration.portraitCropPosition + ); const portraitPosterImageFile = useFileWithInlineRights({ configuration, collectionName: 'imageFiles', propertyName: 'portraitPosterId' }); - // Only render OrientationAwareInlineImage if a portrait image has - // been selected. This prevents having the component rerender on - // orientation changes even if it does not depend on orientation at - // all. if (portraitVideoFile) { return ( - - - - - - - - + {renderContentElementBox({rounded, configuration}, + + + + + + + )} @@ -154,6 +169,25 @@ function OrientationUnawareInlineVideo({ ) } +function renderContentElementBox({rounded, configuration}, children) { + if (rounded === 'circle') { + const {style, className} = contentElementBoxProps(configuration, {borderRadius: 'circle'}); + + return ( +
+ {children} +
+ ); + } + + return ( + + {children} + + ); +} + function CropPositionComputingPlayer({videoFile, motifArea, ...props}) { const videoFileWithMotifArea = useBackgroundFile({ file: videoFile, @@ -172,7 +206,8 @@ function PlayerWithControlBar({ inlineFileRightsItems, playerState, playerActions, contentElementId, configuration, - sectionProps + sectionProps, fit, hideControlBar, + applyContentElementBoxStyles }) { const {isEditable, isSelected} = useContentElementEditorState(); @@ -225,8 +260,7 @@ function PlayerWithControlBar({ defaultTextTrackFilePermaId={configuration.defaultTextTrackFileId} playerState={playerState} playerActions={playerActions} - hideControlBar={configuration.hideControlBar || - configuration.playbackMode === 'loop'} + hideControlBar={hideControlBar} hideBigPlayButton={configuration.playbackMode === 'loop'} inlineFileRightsItems={inlineFileRightsItems} configuration={configuration} @@ -238,7 +272,7 @@ function PlayerWithControlBar({ shouldLoad ? 'poster' : 'none'} loop={configuration.playbackMode === 'loop'} - fit={configuration.position === 'backdrop' ? 'cover' : 'contain'} + fit={fit} playerState={playerState} playerActions={playerActions} videoFile={videoFile} @@ -246,7 +280,8 @@ function PlayerWithControlBar({ defaultTextTrackFilePermaId={configuration.defaultTextTrackFileId} quality={'high'} playsInline={true} - atmoDuringPlayback={configuration.atmoDuringPlayback} /> + atmoDuringPlayback={configuration.atmoDuringPlayback} + applyContentElementBoxStyles={applyContentElementBoxStyles} /> ); diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js index fa683984a9..034889b8db 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/editor.js @@ -1,7 +1,8 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {editor, InlineFileRightsMenuItem, EditMotifAreaDialogView} from 'pageflow-scrolled/editor'; +import {editor, InlineFileRightsMenuItem, EditMotifAreaDialogView, ImageModifierListInputView} from 'pageflow-scrolled/editor'; +import {processImageModifiers} from 'pageflow-scrolled/frontend'; import {FileInputView, CheckBoxInputView} from 'pageflow/editor'; import {SelectInputView, SeparatorView, LabelOnlyView} from 'pageflow/ui'; @@ -37,6 +38,7 @@ editor.contentElementTypes.register('inlineVideo', { category: 'media', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], defaultsInputs() { this.input('playbackMode', SelectInputView, { @@ -51,7 +53,14 @@ editor.contentElementTypes.register('inlineVideo', { this.input('id', FileInputView, { collection: 'video_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: false, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, + positioningBinding: 'imageModifiers', + positioningOptions: () => { + const {aspectRatio} = processImageModifiers(this.model.get('imageModifiers')); + return { + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) + }; + }, defaultTextTrackFilePropertyName: 'defaultTextTrackFileId', dropDownMenuItems: [EditMotifAreaMenuItem, InlineFileRightsMenuItem] }); @@ -61,11 +70,23 @@ editor.contentElementTypes.register('inlineVideo', { positioning: false, dropDownMenuItems: [InlineFileRightsMenuItem] }); + this.input('imageModifiers', ImageModifierListInputView, { + entry, + visibleBinding: 'id', + visible: () => this.model.getReference('id', 'video_files') + }); this.input('portraitId', FileInputView, { collection: 'video_files', fileSelectionHandler: 'contentElementConfiguration', - positioning: false, + positioning: imageModifiers => !!processImageModifiers(imageModifiers).aspectRatio, + positioningBinding: 'portraitImageModifiers', + positioningOptions: () => { + const {aspectRatio} = processImageModifiers(this.model.get('portraitImageModifiers')); + return { + preview: aspectRatio && (1 / entry.getAspectRatio(aspectRatio)) + }; + }, defaultTextTrackFilePropertyName: 'defaultTextTrackFileId', dropDownMenuItems: [EditMotifAreaMenuItem, InlineFileRightsMenuItem] }); @@ -77,6 +98,11 @@ editor.contentElementTypes.register('inlineVideo', { visible: () => this.model.getReference('portraitId', 'video_files'), dropDownMenuItems: [InlineFileRightsMenuItem] }); + this.input('portraitImageModifiers', ImageModifierListInputView, { + entry, + visibleBinding: 'portraitId', + visible: () => this.model.getReference('portraitId', 'video_files') + }); this.view(SeparatorView); @@ -85,8 +111,10 @@ editor.contentElementTypes.register('inlineVideo', { }); this.input('hideControlBar', CheckBoxInputView, { - disabledBinding: 'playbackMode', - disabled: playbackMode => playbackMode === 'loop', + disabledBinding: ['playbackMode', 'imageModifiers'], + disabled: ([playbackMode, imageModifiers]) => + playbackMode === 'loop' || + processImageModifiers(imageModifiers).rounded === 'circle', displayCheckedIfDisabled: true }); diff --git a/entry_types/scrolled/package/src/contentElements/videoEmbed/VideoEmbed.js b/entry_types/scrolled/package/src/contentElements/videoEmbed/VideoEmbed.js index b9347e525a..4cd90b0a5e 100644 --- a/entry_types/scrolled/package/src/contentElements/videoEmbed/VideoEmbed.js +++ b/entry_types/scrolled/package/src/contentElements/videoEmbed/VideoEmbed.js @@ -35,7 +35,7 @@ export function VideoEmbed({contentElementId, configuration}) {
- + {shouldLoad && - + {renderLazyPanorama(configuration, imageFile, shouldLoad, aspectRatio)} diff --git a/entry_types/scrolled/package/src/contentElements/vrImage/editor.js b/entry_types/scrolled/package/src/contentElements/vrImage/editor.js index c6f7b3f490..c4e6cae051 100644 --- a/entry_types/scrolled/package/src/contentElements/vrImage/editor.js +++ b/entry_types/scrolled/package/src/contentElements/vrImage/editor.js @@ -10,6 +10,7 @@ editor.contentElementTypes.register('vrImage', { category: 'interactive', supportedPositions: ['inline', 'side', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], + supportedStyles: ['boxShadow', 'outline'], configurationEditor({entry}) { this.tab('general', function() { diff --git a/entry_types/scrolled/package/src/editor/collections/StylesCollection.js b/entry_types/scrolled/package/src/editor/collections/StylesCollection.js index d86f89e2ae..aee40bb619 100644 --- a/entry_types/scrolled/package/src/editor/collections/StylesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/StylesCollection.js @@ -1,6 +1,6 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {features} from 'pageflow/frontend'; +import {attributeBindingUtils} from 'pageflow/ui'; import {Style} from '../models/Style'; export const StylesCollection = Backbone.Collection.extend({ @@ -8,22 +8,18 @@ export const StylesCollection = Backbone.Collection.extend({ initialize(models, options = {}) { this.types = options.types || {}; + this.bindingModel = options.bindingModel; }, getUnusedStyles() { const unusedStyles = new Backbone.Collection( Object - .entries(this.types) - .filter( - ([name, styleType]) => ( - features.isEnabled('decoration_effects') || - Style.getKind(name, this.types) !== 'decoration' - ) - ) - .map(([name]) => ({name})), + .keys(this.types) + .map(name => ({name})), { comparator: style => Object.keys(this.types).indexOf(style.get('name')), styles: this, + bindingModel: this.bindingModel, model: UnusedStyle } ); @@ -48,8 +44,9 @@ function updateSeparation(styles, types) { } const UnusedStyle = Backbone.Model.extend({ - initialize({name}, {styles}) { - const {items} = styles.types[name]; + initialize({name}, {styles, bindingModel}) { + const type = styles.types[name]; + const {items} = type; this.set('label', Style.getLabel(name, styles.types)); @@ -62,18 +59,34 @@ const UnusedStyle = Backbone.Model.extend({ } else { this.selected = () => { - styles.add({name: this.get('name')}, {types: styles.types}); + styles.add({name: this.get('name')}, {types: styles.types, bindingModel: styles.bindingModel}); } } - const update = () => { - this.set({ - hidden: !!styles.findWhere({name: this.get('name')}) && !items - }); + const updateHidden = () => { + const inUse = !!styles.findWhere({name: this.get('name')}) && !items; + this.set({hidden: inUse || !this.get('available')}); }; - this.listenTo(styles, 'add remove', update); - update(); + this.listenTo(styles, 'add remove', updateHidden); + + if (type.binding && bindingModel) { + attributeBindingUtils.setup({ + binding: type.binding, + model: bindingModel, + listener: this, + option: type.when, + callback: available => { + this.set({available}); + updateHidden(); + } + }); + } + else { + this.set({available: true}); + } + + updateHidden(); } }); @@ -138,7 +151,7 @@ const UnusedStyleItem = Backbone.Model.extend({ _applyStyle(currentStyle) { this.styles.remove(currentStyle); this._removeIncompatibleStyles(); - this.styles.add({name: this.styleName, value: this.get(('value'))}, {types: this.styles.types}); + this.styles.add({name: this.styleName, value: this.get(('value'))}, {types: this.styles.types, bindingModel: this.styles.bindingModel}); }, _removeIncompatibleStyles() { 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 f4dbce3229..f608e996e9 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -28,7 +28,8 @@ const scaleDefaultPropertyNames = { sectionPaddingTop: 'sectionDefaultPaddingTop', sectionPaddingBottom: 'sectionDefaultPaddingBottom', contentElementBoxBorderRadius: 'contentElementBoxBorderRadius', - contentElementMargin: 'contentElementMarginStyleDefault' + contentElementMargin: 'contentElementMarginStyleDefault', + contentElementBoxShadow: 'contentElementBoxShadowStyleDefault' }; const defaultAspectRatios = [{ diff --git a/entry_types/scrolled/package/src/editor/models/Style.js b/entry_types/scrolled/package/src/editor/models/Style.js index 2d650210d4..e506269062 100644 --- a/entry_types/scrolled/package/src/editor/models/Style.js +++ b/entry_types/scrolled/package/src/editor/models/Style.js @@ -1,13 +1,27 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; +import {features} from 'pageflow/frontend'; +import {attributeBindingUtils} from 'pageflow/ui'; export const Style = Backbone.Model.extend({ - initialize({name}, {types}) { + initialize({name}, {types, bindingModel}) { this.types = types; if (!this.has('value')) { this.set('value', this.defaultValue()); } + + const type = types[name]; + + if (type?.binding && bindingModel) { + attributeBindingUtils.setup({ + binding: type.binding, + model: bindingModel, + listener: this, + option: type.when, + callback: available => this.set({available}) + }); + } }, label() { @@ -49,6 +63,10 @@ export const Style = Backbone.Model.extend({ inputType() { return this.types[this.get('name')].inputType || 'none'; + }, + + inputOptions() { + return this.types[this.get('name')].inputOptions || {}; } }); @@ -61,7 +79,7 @@ Style.getKind = function(name, types) { return types[name].kind; }; -Style.effectTypes = { +const allEffectTypes = { blur: { inputType: 'slider', minValue: 0, @@ -125,13 +143,41 @@ Style.effectTypes = { } }; +Style.getEffectTypes = function() { + if (features.isEnabled('decoration_effects')) { + return allEffectTypes; + } + + return Object.fromEntries( + Object.entries(allEffectTypes).filter( + ([, type]) => type.kind !== 'decoration' + ) + ); +}; + Style.getTypesForContentElement = function({entry, contentElement}) { const marginScale = entry.getScale('contentElementMargin'); const defaultConfig = contentElement.getType().defaultConfig || {}; const result = {}; + const supportedStyles = contentElement.getType().supportedStyles || []; + + function findSupportedStyle(name) { + return supportedStyles.find(s => s === name || s.name === name); + } + + function bindingOptions(name) { + const style = findSupportedStyle(name); + if (!style || typeof style === 'string') return {}; + const {binding, when} = style; + return { + ...(binding && {binding, when}) + }; + } + if (marginScale.values.length > 0) { result.marginTop = { + kind: 'spacing', label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.marginTop'), propertyName: 'marginTop', inputType: 'slider', @@ -142,6 +188,7 @@ Style.getTypesForContentElement = function({entry, contentElement}) { }; result.marginBottom = { + kind: 'spacing', label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.marginBottom'), propertyName: 'marginBottom', inputType: 'slider', @@ -152,6 +199,40 @@ Style.getTypesForContentElement = function({entry, contentElement}) { }; } + if (findSupportedStyle('boxShadow')) { + const boxShadowScale = entry.getScale('contentElementBoxShadow'); + + if (boxShadowScale.values.length > 0) { + result.boxShadow = { + kind: 'decoration', + label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.boxShadow'), + propertyName: 'boxShadow', + inputType: 'slider', + values: boxShadowScale.values, + texts: boxShadowScale.texts, + defaultValue: boxShadowScale.defaultValue, + ...bindingOptions('boxShadow') + }; + } + } + + if (findSupportedStyle('outline')) { + const themeProperties = entry.getThemeProperties(); + + result.outlineColor = { + kind: 'decoration', + label: I18n.t('pageflow_scrolled.editor.content_element_style_list_input.outlineColor'), + propertyName: 'outlineColor', + inputType: 'color', + defaultValue: themeProperties.root?.outlineColor, + inputOptions: { + alpha: true, + swatches: entry.getUsedContentElementColors('outlineColor') + }, + ...bindingOptions('outline') + }; + } + return result; } diff --git a/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js index e42d67e2ad..5e9c2d8430 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/EffectListInputView.js @@ -5,7 +5,7 @@ export const EffectListInputView = function(options) { return new StyleListInputView({ ...options, hideLabel: true, - types: Style.effectTypes, + types: Style.getEffectTypes(), translationKeyPrefix: 'pageflow_scrolled.editor.effect_list_input' }); }; diff --git a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js index 8ca561d442..f7c458df6c 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js @@ -22,7 +22,7 @@ export const StyleListInputView = Marionette.ItemView.extend({ initialize() { this.styles = new StylesCollection( this.readFromModel(), - {types: this.options.types} + {types: this.options.types, bindingModel: this.model} ); this.listenTo(this.styles, 'add remove change', () => { @@ -112,13 +112,19 @@ const StyleListItemView = Marionette.ItemView.extend({ className: styles.item, template: (data) => ` -
${data.label}
- ${renderInput(data.inputType)} +
+
${data.label}
+ ${renderInput(data.inputType)} +
`, + modelEvents: { + 'change:available': 'updateAvailability' + }, + serializeData() { return { label: this.model.label(), @@ -127,7 +133,7 @@ const StyleListItemView = Marionette.ItemView.extend({ }; }, - ui: cssModulesUtils.ui(styles, 'widget', 'value', 'colorInput'), + ui: cssModulesUtils.ui(styles, 'controls', 'widget', 'value', 'colorInput'), events: cssModulesUtils.events(styles, { 'click remove': function() { @@ -153,7 +159,14 @@ const StyleListItemView = Marionette.ItemView.extend({ } }), + updateAvailability() { + const unavailable = this.model.get('available') === false; + this.$el.toggleClass(styles.unavailable, unavailable); + this.ui.controls.attr('inert', unavailable ? '' : null); + }, + onRender() { + this.updateAvailability(); this.$el.addClass(styles[`input-${this.model.inputType()}`]); const values = this.model.values(); @@ -190,6 +203,7 @@ const StyleListItemView = Marionette.ItemView.extend({ this._colorPicker = new ColorPicker(colorInput, { defaultValue: this.model.defaultValue(), + ...this.model.inputOptions(), onChange: (color) => { this.model.set('value', color || ''); } diff --git a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css index c617336e05..1b5e211ba4 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css +++ b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.module.css @@ -119,6 +119,17 @@ height: size(4); } +.controls { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} + +.unavailable .controls { + opacity: 0.4; +} + .remove { composes: cancel from '../icons.module.css'; padding: 0 space(2); diff --git a/entry_types/scrolled/package/src/frontend/ContentElementBox.js b/entry_types/scrolled/package/src/frontend/ContentElementBox.js index 287c9a7256..67b17a0f96 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementBox.js +++ b/entry_types/scrolled/package/src/frontend/ContentElementBox.js @@ -2,6 +2,7 @@ import React from 'react'; import classNames from 'classnames'; import {useContentElementAttributes} from './useContentElementAttributes'; +import {contentElementBoxProps} from './contentElementBoxStyle'; import {widths} from './layouts/widths'; import styles from './ContentElementBox.module.css'; @@ -12,21 +13,25 @@ import styles from './ContentElementBox.module.css'; * * @param {Object} props * @param {string} props.children - Content of box. - * @param {string} props.borderRadius - Border radius value from theme scale, or "none" to render no wrapper. + * @param {Object} [props.configuration] - Content element configuration. Used to read box shadow and outline styles. + * @param {string} [props.borderRadius] - Border radius value from theme scale, or "none" to render no wrapper. */ -export function ContentElementBox({children, borderRadius, positioned}) { +export function ContentElementBox({children, configuration, borderRadius, positioned}) { const {position, width} = useContentElementAttributes(); - if (position === 'backdrop' || borderRadius === 'none') { + const {style} = contentElementBoxProps(configuration, {borderRadius}); + + if (position === 'backdrop') { return children; } - const style = borderRadius ? { - '--content-element-box-border-radius': `var(--theme-content-element-box-border-radius-${borderRadius})` - } : {}; + if (borderRadius === 'none' && !Object.keys(style).length) { + return children; + } return (
+ {[styles.contentElementBox]: props.applyContentElementBoxStyles, + [styles.posterVisible]: posterVisible, + [textTrackStyles.inset]: props.textTracksInset})}> {load === 'auto' && } {load !== 'none' && } + objectPosition={props.objectPosition} />}
); } @@ -40,7 +44,7 @@ MediaPlayer.defaultProps = { load: 'auto' }; -function Poster({imageUrl, objectPosition, hide}) { +function Poster({imageUrl, objectPosition}) { if (!imageUrl) { return null; } @@ -49,7 +53,6 @@ function Poster({imageUrl, objectPosition, hide}) { ); diff --git a/entry_types/scrolled/package/src/frontend/contentElementBoxStyle.js b/entry_types/scrolled/package/src/frontend/contentElementBoxStyle.js new file mode 100644 index 0000000000..715ce72da7 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/contentElementBoxStyle.js @@ -0,0 +1,18 @@ +import styles from './ContentElementBox.module.css'; + +export function contentElementBoxProps(configuration, {borderRadius} = {}) { + return { + className: styles.properties, + style: { + ...(borderRadius && borderRadius !== 'none' && { + '--content-element-box-border-radius': `var(--theme-content-element-box-border-radius-${borderRadius})` + }), + ...(configuration?.boxShadow && { + '--content-element-box-shadow': `var(--theme-content-element-box-shadow-${configuration.boxShadow})` + }), + ...(configuration?.outlineColor && { + '--content-element-box-outline-color': configuration.outlineColor + }) + } + }; +} diff --git a/entry_types/scrolled/package/src/frontend/imageModifiers.js b/entry_types/scrolled/package/src/frontend/imageModifiers.js new file mode 100644 index 0000000000..49e3e4fd33 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/imageModifiers.js @@ -0,0 +1,13 @@ +export function processImageModifiers(imageModifiers) { + const cropValue = getModifierValue(imageModifiers, 'crop'); + const isCircleCrop = cropValue === 'circle'; + + return { + aspectRatio: isCircleCrop ? 'square' : cropValue, + rounded: isCircleCrop ? 'circle' : getModifierValue(imageModifiers, 'rounded') + }; +} + +function getModifierValue(imageModifiers, name) { + return (imageModifiers || []).find(imageModifier => imageModifier.name === name)?.value; +} diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 9dbb16ce51..f474d0599b 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -38,7 +38,10 @@ export * from './Atmo'; export * from './useAtmo'; export {ContentElementBox} from './ContentElementBox'; +export {contentElementBoxProps} from './contentElementBoxStyle'; export {ContentElementFigure} from './ContentElementFigure'; +export {processImageModifiers} from './imageModifiers'; +export {useFileWithCropPosition} from './useFileWithCropPosition'; export {MediaInteractionTracking} from './MediaInteractionTracking'; export {MediaPlayerControls} from './MediaPlayerControls'; export {VideoPlayerControls} from './VideoPlayerControls'; diff --git a/entry_types/scrolled/package/src/frontend/useFileWithCropPosition.js b/entry_types/scrolled/package/src/frontend/useFileWithCropPosition.js new file mode 100644 index 0000000000..5503294386 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/useFileWithCropPosition.js @@ -0,0 +1,3 @@ +export function useFileWithCropPosition(file, cropPosition) { + return file && {...file, cropPosition}; +} diff --git a/package/documentation.yml b/package/documentation.yml index 921e695e2a..046c35915f 100644 --- a/package/documentation.yml +++ b/package/documentation.yml @@ -122,8 +122,9 @@ toc: description: | Utility functions exported by `pageflow/ui`. children: - - i18nUtils + - attributeBindingUtils - cssModulesUtils + - i18nUtils - name: Test Helpers description: | diff --git a/package/src/ui/index.js b/package/src/ui/index.js index cb902cce8d..1bd379ef48 100644 --- a/package/src/ui/index.js +++ b/package/src/ui/index.js @@ -14,7 +14,14 @@ import * as i18nUtils from './utils/i18nUtils'; */ import * as cssModulesUtils from './utils/cssModulesUtils'; -export { i18nUtils, cssModulesUtils }; +/** + * Helpers for setting up attribute bindings. + * + * @alias attributeBindingUtils + */ +import * as attributeBindingUtils from './utils/attributeBindingUtils'; + +export { i18nUtils, cssModulesUtils, attributeBindingUtils }; export {default as Object} from '../Object'; export * from './models/mixins/serverSideValidation'; diff --git a/package/src/ui/utils/attributeBindingUtils.js b/package/src/ui/utils/attributeBindingUtils.js new file mode 100644 index 0000000000..2433e01561 --- /dev/null +++ b/package/src/ui/utils/attributeBindingUtils.js @@ -0,0 +1,42 @@ +import _ from 'underscore'; + +export function setup({ + binding, model, listener, callback, normalize = value => value, + option, bindingValue +}) { + if (binding) { + _.flatten([binding]).forEach(attribute => { + listener.listenTo(model, 'change:' + attribute, update); + }); + } + + update(); + + function update() { + callback(resolve({ + binding, model, normalize, option, bindingValue + })); + } +} + +export function resolve({ + binding, model, normalize = value => value, + option, bindingValue +}) { + const boundValue = Array.isArray(binding) ? + binding.map(attribute => model.get(attribute)) : + model.get(binding); + + if (bindingValue !== undefined) { + return boundValue === bindingValue; + } + else if (typeof option === 'function') { + return normalize(option(boundValue)); + } + else if (option !== undefined) { + return normalize(option); + } + else if (binding) { + return normalize(boundValue); + } +} diff --git a/package/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js index e7563a13f0..5f0185349e 100644 --- a/package/src/ui/views/ColorPicker.js +++ b/package/src/ui/views/ColorPicker.js @@ -1,4 +1,5 @@ // Inspired by https://github.com/mdbassit/Coloris +import I18n from 'i18n-js'; const ctx = typeof OffscreenCanvas !== 'undefined' && new OffscreenCanvas(1, 1).getContext('2d'); @@ -81,6 +82,12 @@ export default class ColorPicker { this._alphaSlider = this._picker.querySelector('.color_picker-alpha input'); this._alphaMarker = this._picker.querySelector('.color_picker-alpha div'); this._swatchesContainer = this._picker.querySelector('.color_picker-swatches'); + + this._hueSlider.setAttribute('aria-label', I18n.t('pageflow.ui.color_picker.hue')); + + if (this._alpha) { + this._alphaSlider.setAttribute('aria-label', I18n.t('pageflow.ui.color_picker.opacity')); + } } _wrapInput() { diff --git a/package/src/ui/views/mixins/attributeBinding.js b/package/src/ui/views/mixins/attributeBinding.js index 6fd377b7bd..cbb920dfcf 100644 --- a/package/src/ui/views/mixins/attributeBinding.js +++ b/package/src/ui/views/mixins/attributeBinding.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import {setup, resolve} from '../../utils/attributeBindingUtils'; export const attributeBinding = { setupBooleanAttributeBinding(optionName, updateMethod) { @@ -9,44 +9,31 @@ export const attributeBinding = { return this.getAttributeBoundOption(optionName, Boolean); }, - setupAttributeBinding: function(optionName, updateMethod, normalize = value => value) { + setupAttributeBinding(optionName, updateMethod, normalize = value => value) { const binding = this.options[`${optionName}Binding`]; const model = this.options[`${optionName}BindingModel`] || this.model; - const view = this; - if (binding) { - _.flatten([binding]).forEach(attribute => { - this.listenTo(model, 'change:' + attribute, update); - }); - } - - update(); - - function update() { - updateMethod.call(view, view.getAttributeBoundOption(optionName, normalize)); - } + setup({ + binding, + model, + listener: this, + normalize, + option: this.options[optionName], + bindingValue: this.options[`${optionName}BindingValue`], + callback: value => updateMethod.call(this, value) + }); }, getAttributeBoundOption(optionName, normalize = value => value) { const binding = this.options[`${optionName}Binding`]; const model = this.options[`${optionName}BindingModel`] || this.model; - const bindingValueOptionName = `${optionName}BindingValue`; - - const value = Array.isArray(binding) ? - binding.map(attribute => model.get(attribute)) : - model.get(binding); - if (bindingValueOptionName in this.options) { - return value === this.options[bindingValueOptionName]; - } - else if (typeof this.options[optionName] === 'function') { - return normalize(this.options[optionName](value)); - } - else if (optionName in this.options) { - return normalize(this.options[optionName]); - } - else if (binding) { - return normalize(value); - } + return resolve({ + binding, + model, + normalize, + option: this.options[optionName], + bindingValue: this.options[`${optionName}BindingValue`] + }); } };