diff --git a/app/assets/stylesheets/pageflow/ui/input/color_input.scss b/app/assets/stylesheets/pageflow/ui/input/color_input.scss index 79a13a0d6c..f359937238 100644 --- a/app/assets/stylesheets/pageflow/ui/input/color_input.scss +++ b/app/assets/stylesheets/pageflow/ui/input/color_input.scss @@ -13,6 +13,10 @@ .minicolors-input-swatch { top: 5px; left: 5px; + + .minicolors-swatch-color { + background-color: var(--placeholder-color); + } } &.is_default input { @@ -22,4 +26,8 @@ .minicolors-focus input { color: var(--ui-on-surface-color); } + + .minicolors-swatches .minicolors-swatch { + margin: 2px; + } } diff --git a/entry_types/scrolled/config/locales/new/cards_colors.de.yml b/entry_types/scrolled/config/locales/new/cards_colors.de.yml new file mode 100644 index 0000000000..0ca01b9d31 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/cards_colors.de.yml @@ -0,0 +1,8 @@ +de: + pageflow_scrolled: + editor: + edit_section: + attributes: + cardSurfaceColor: + label: Kartenhintergrundfarbe + auto: "(Automatisch)" diff --git a/entry_types/scrolled/config/locales/new/cards_colors.en.yml b/entry_types/scrolled/config/locales/new/cards_colors.en.yml new file mode 100644 index 0000000000..1eb66767a1 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/cards_colors.en.yml @@ -0,0 +1,8 @@ +en: + pageflow_scrolled: + editor: + edit_section: + attributes: + cardSurfaceColor: + label: Cards background color + auto: "(Auto)" diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 4251d305b5..d318ad2f61 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -48,6 +48,7 @@ def configure(config) c.features.register('frontend_v2') c.features.register('scrolled_entry_fragment_caching') c.features.register('backdrop_content_elements') + c.features.register('custom_palette_colors') c.additional_frontend_seed_data.register( 'frontendVersion', diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js new file mode 100644 index 0000000000..3a73a5b517 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedSectionBackgroundColors-spec.js @@ -0,0 +1,103 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories} from 'pageflow/testHelpers'; +import {normalizeSeed} from 'support'; + +describe('ScrolledEntry', () => { + describe('#getUsedSectionBackgroundColors', () => { + it('returns unique sorted list of used background colors', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + { + configuration: { + backdropType: 'color', + backdropColor: '#400'} + }, + { + configuration: { + backdropType: 'color', + backdropColor: '#040' + } + }, + { + configuration: { + backdropType: 'color', + backdropColor: '#400' + } + }, + { + configuration: { + appearance: 'cards', + cardSurfaceColor: '#500' + } + }, + { + configuration: { + appearance: 'cards', + cardSurfaceColor: '#400' + } + } + ] + }) + } + ); + + const colors = entry.getUsedSectionBackgroundColors(); + + expect(colors).toEqual(['#400', '#500', '#040']); + }); + + it('ignores blank colors', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + { + configuration: { + backdropType: 'color' + } + } + ] + }) + } + ); + + const colors = entry.getUsedSectionBackgroundColors(); + + expect(colors).toEqual([]); + }); + + it('ignores invisible config options', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + { + configuration: { + backdropType: 'image', + backdropColor: '#400'} + }, + { + configuration: { + appearance: 'shadow', + cardSurfaceColor: '#500' + } + } + ] + }) + } + ); + + const colors = entry.getUsedSectionBackgroundColors(); + + expect(colors).toEqual([]); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/ColorSelectOrCustomColorInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/ColorSelectOrCustomColorInputView-spec.js new file mode 100644 index 0000000000..1564fd9863 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/ColorSelectOrCustomColorInputView-spec.js @@ -0,0 +1,162 @@ +import { + ColorSelectOrCustomColorInputView +} from 'editor/views/inputs/ColorSelectOrCustomColorInputView'; +import Backbone from 'backbone'; + +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; +import {useReactBasedBackboneViews} from 'support'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +describe('ColorSelectOrCustomColorInputView', () => { + const {render} = useReactBasedBackboneViews(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.blank': 'Auto', + 'pageflow_scrolled.editor.custom_color': 'Custom Color' + }); + + it('loads theme palette color value', async () => { + const model = new Backbone.Model({ + color: 'brand-red' + }); + + const inputView = new ColorSelectOrCustomColorInputView({ + model, + label: 'Background Color', + customColorTranslationKey: 'pageflow_scrolled.editor.custom_color', + values: ['brand-red', 'brand-green'], + texts: ['Red', 'Green'], + propertyName: 'color' + }); + + const {getByRole, queryByRole} = render(inputView); + + expect(getByRole('button', {name: 'Red'})).not.toBeNull(); + expect(queryByRole('textbox')).toBeNull(); + }); + + it('loads custom color value', async () => { + const model = new Backbone.Model({ + color: '#ff0000' + }); + + const inputView = new ColorSelectOrCustomColorInputView({ + model, + label: 'Background Color', + customColorTranslationKey: 'pageflow_scrolled.editor.custom_color', + values: ['brand-red', 'brand-green'], + texts: ['Red', 'Green'], + propertyName: 'color' + }); + + const {getByRole} = render(inputView); + + expect(getByRole('button', {name: 'Custom Color'})).not.toBeNull(); + expect(getByRole('textbox')).not.toBeNull(); + expect(getByRole('textbox')).toHaveValue('#ff0000'); + }); + + it('updates model when selecting theme palette color', async () => { + const model = new Backbone.Model({ + color: '#ff0000' + }); + + const inputView = new ColorSelectOrCustomColorInputView({ + model, + label: 'Background Color', + customColorTranslationKey: 'pageflow_scrolled.editor.custom_color', + values: ['brand-red', 'brand-green'], + texts: ['Red', 'Green'], + propertyName: 'color' + }); + + const user = userEvent.setup(); + const {getByRole} = render(inputView); + await user.click(getByRole('button', {name: 'Custom Color'})); + await user.click(getByRole('option', {name: 'Red'})); + + expect(model.get('color')).toBe('brand-red'); + }); + + it('updates model when selecting custom color', async () => { + const model = new Backbone.Model({ + color: 'brand-red' + }); + + const inputView = new ColorSelectOrCustomColorInputView({ + model, + label: 'Background Color', + customColorTranslationKey: 'pageflow_scrolled.editor.custom_color', + values: ['brand-red', 'brand-green'], + texts: ['Red', 'Green'], + propertyName: 'color' + }); + + const user = userEvent.setup(); + const {getByRole} = render(inputView); + await user.click(getByRole('button', {name: 'Red'})); + await user.click(getByRole('option', {name: 'Custom Color'})); + + const input = getByRole('textbox'); + await user.clear(input); + await user.type(input, '#ff0000'); + await new Promise(resolve => setTimeout(resolve, 300)); + + expect(model.get('color')).toBe('#ff0000'); + }); + + it('shows custom color input only when custom is selected', async () => { + const model = new Backbone.Model({ + color: 'brand-red' + }); + + const inputView = new ColorSelectOrCustomColorInputView({ + model, + label: 'Background Color', + customColorTranslationKey: 'pageflow_scrolled.editor.custom_color', + values: ['brand-red', 'brand-green'], + texts: ['Red', 'Green'], + propertyName: 'color' + }); + + const user = userEvent.setup(); + const {getByRole, queryByRole} = render(inputView); + + expect(queryByRole('textbox')).toBeNull(); + + await user.click(getByRole('button', {name: 'Red'})); + await user.click(getByRole('option', {name: 'Custom Color'})); + + expect(getByRole('textbox')).not.toBeNull(); + + await user.click(getByRole('button', {name: 'Custom Color'})); + await user.click(getByRole('option', {name: 'Red'})); + + expect(queryByRole('textbox')).toBeNull(); + }); + + it('uses provided translations', async () => { + const model = new Backbone.Model(); + + const inputView = new ColorSelectOrCustomColorInputView({ + model, + label: 'Background Color', + includeBlank: true, + blankTranslationKey: 'pageflow_scrolled.editor.blank', + customColorTranslationKey: 'pageflow_scrolled.editor.custom_color', + values: ['brand-red', 'brand-green'], + texts: ['Red', 'Green'], + propertyName: 'color' + }); + + const {getByText, getByRole} = render(inputView); + + expect(getByText('Background Color')).not.toBeNull(); + + // Click the button to open the dropdown + await userEvent.click(getByRole('button')); + + expect(getByRole('option', {name: 'Auto'})).not.toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js index 6f2ec8c20e..ab5386d3c8 100644 --- a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js @@ -1,5 +1,4 @@ import {editor} from 'pageflow-scrolled/editor'; -import {features} from 'pageflow/frontend'; import {SelectInputView, SliderInputView, SeparatorView, CheckBoxInputView} from 'pageflow/ui'; import {SidebarRouter} from './SidebarRouter'; diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js index be91fc2829..9548aaa7f2 100644 --- a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js +++ b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js @@ -11,9 +11,35 @@ editor.contentElementTypes.register('textBlock', { supportedPositions: ['inline'], configurationEditor({entry, contentElement}) { - this.listenTo(contentElement.transientState, - 'change:exampleNode', - () => this.refresh()); + let pendingRefresh; + + this.listenTo( + contentElement.transientState, + 'change:exampleNode', + () => { + // This is a terrible hack to prevent closing the minicolors + // dropdown while adjusting colors. Calling refresh is needed + // to update typography drop downs. Delay until color picker + // is closed. + if (document.activeElement && + document.activeElement.tagName === 'INPUT' && + document.activeElement.className === 'minicolors-input') { + + if (!pendingRefresh) { + document.activeElement.addEventListener('blur', () => { + pendingRefresh = false; + this.refresh() + }, {once: true}); + + pendingRefresh = true; + } + + return; + } + + this.refresh() + } + ); this.tab('general', function() { const exampleNode = ensureTextContent( 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 29ca35035d..991cae6589 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -10,6 +10,8 @@ import {insertContentElement} from './insertContentElement'; import {moveContentElement} from './moveContentElement'; import {deleteContentElement} from './deleteContentElement'; +import {sortColors} from './sortColors'; + const typographySizeSuffixes = ['xl', 'lg', 'md', 'sm', 'xs']; export const ScrolledEntry = Entry.extend({ @@ -276,6 +278,22 @@ export const ScrolledEntry = Entry.extend({ return [values, texts]; }, + getUsedSectionBackgroundColors() { + const colors = new Set(); + + this.sections.map(section => { + if (section.configuration.get('backdropType') === 'color') { + colors.add(section.configuration.get('backdropColor')); + } + + if (section.configuration.get('appearance') === 'cards') { + colors.add(section.configuration.get('cardSurfaceColor')); + } + }); + + return sortColors([...colors].filter(Boolean)); + }, + supportsSectionWidths() { const theme = this.scrolledSeed.config.theme; diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/sortColors.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/sortColors.js new file mode 100644 index 0000000000..6e0a3e5853 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/sortColors.js @@ -0,0 +1,60 @@ +export function sortColors(colors) { + return colors.sort((hex1, hex2) => { + const [h1, s1, l1] = hexToHSL(hex1); + const [h2, s2, l2] = hexToHSL(hex2); + + return (h1 - h2) || (s1 - s2) || (l1 - l2); + }); +} + +function hexToHSL(hex) { + let [r, g, b] = hexToRGB(hex); + + r /= 255; + g /= 255; + b /= 255; + + let cmin = Math.min(r,g,b), + cmax = Math.max(r,g,b), + delta = cmax - cmin, + h = 0, + s = 0, + l = 0; + + if (delta === 0) + h = 0; + else if (cmax === r) + h = ((g - b) / delta) % 6; + else if (cmax === g) + h = (b - r) / delta + 2; + else + h = (r - g) / delta + 4; + + h = Math.round(h * 60); + + if (h < 0) + h += 360; + + l = (cmax + cmin) / 2; + s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + + return [h, s, l]; +} + +function hexToRGB(hex) { + let r = 0, g = 0, b = 0; + + if (hex.length === 4) { + r = "0x" + hex[1] + hex[1]; + g = "0x" + hex[2] + hex[2]; + b = "0x" + hex[3] + hex[3]; + } else if (hex.length === 7) { + r = "0x" + hex[1] + hex[2]; + g = "0x" + hex[3] + hex[4]; + b = "0x" + hex[5] + hex[6]; + } + + return [r, g, b]; +} diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 335a35a874..4c6fd5c829 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -101,7 +101,8 @@ export const EditSectionView = EditConfigurationView.extend({ }); this.input('backdropColor', ColorInputView, { visibleBinding: 'backdropType', - visibleBindingValue: 'color' + visibleBindingValue: 'color', + swatches: entry.getUsedSectionBackgroundColors() }); this.input('backdropContentElement', BackdropContentElementInputView, { @@ -151,6 +152,17 @@ export const EditSectionView = EditConfigurationView.extend({ (!exposeMotifArea || motifAreaDisabled(motifAreaDisabledBindingValues)) && backdropType !== 'contentElement' }); + if (features.isEnabled('custom_palette_colors')) { + this.input('cardSurfaceColor', ColorInputView, { + visibleBinding: 'appearance', + visibleBindingValue: 'cards', + placeholder: I18n.t('pageflow_scrolled.editor.edit_section.attributes.cardSurfaceColor.auto'), + placeholderColorBinding: 'invert', + placeholderColor: invert => invert ? '#101010' : '#ffffff', + swatches: entry.getUsedSectionBackgroundColors() + }); + } + this.view(SeparatorView); this.input('atmoAudioFileId', FileInputView, { diff --git a/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js b/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js index 08a88cbc18..120fb19973 100644 --- a/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js +++ b/entry_types/scrolled/package/src/editor/views/configurationEditors/groups/CommonContentElementAttributes.js @@ -1,9 +1,20 @@ -import {ConfigurationEditorTabView, CheckBoxInputView, SelectInputView, SliderInputView} from 'pageflow/ui'; +import {features} from 'pageflow/frontend'; + +import { + ConfigurationEditorTabView, + CheckBoxInputView, + SelectInputView, + SliderInputView +} from 'pageflow/ui'; import { TypographyVariantSelectInputView } from '../../inputs/TypographyVariantSelectInputView'; +import { + ColorSelectOrCustomColorInputView +} from '../../inputs/ColorSelectOrCustomColorInputView'; + import { ColorSelectInputView } from '../../inputs/ColorSelectInputView'; @@ -144,14 +155,20 @@ ConfigurationEditorTabView.groups.define( 'PaletteColor', function({propertyName, entry, model}) { const [values, texts] = entry.getPaletteColors(); + const inputView = features.isEnabled('custom_palette_colors') ? + ColorSelectOrCustomColorInputView : + ColorSelectInputView; if (values.length) { - this.input(propertyName, ColorSelectInputView, { + this.input(propertyName, inputView, { model: model || this.model, includeBlank: true, blankTranslationKey: 'pageflow_scrolled.editor.' + 'common_content_element_attributes.' + 'palette_color.blank', + customColorTranslationKey: 'pageflow_scrolled.editor.' + + 'common_content_element_attributes.' + + 'palette_color.custom', values, texts, }); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/ColorSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/ColorSelectInputView.js index fcc02546af..5aa34c4f1b 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/ColorSelectInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/ColorSelectInputView.js @@ -16,7 +16,7 @@ function renderItem(item) { [{cssColorPropertyPrefix: '--theme-palette-color'}]; return ( -