diff --git a/app/assets/javascripts/pageflow/editor/vendor.js b/app/assets/javascripts/pageflow/editor/vendor.js index 58669d8cf8..cdc5518ae9 100644 --- a/app/assets/javascripts/pageflow/editor/vendor.js +++ b/app/assets/javascripts/pageflow/editor/vendor.js @@ -3,7 +3,6 @@ //= require i18n/translations //= require wysihtml-toolbar -//= require jquery.minicolors //= require cocktail //= require backbone.marionette diff --git a/app/assets/javascripts/pageflow/ui.js b/app/assets/javascripts/pageflow/ui.js index fb92fc2a2e..cc0f7389e5 100644 --- a/app/assets/javascripts/pageflow/ui.js +++ b/app/assets/javascripts/pageflow/ui.js @@ -1,5 +1,4 @@ //= require wysihtml-toolbar -//= require jquery.minicolors //= require i18n //= require i18n/translations diff --git a/app/assets/stylesheets/pageflow/ui.scss b/app/assets/stylesheets/pageflow/ui.scss index 18ad56a5b7..6176ba71ec 100644 --- a/app/assets/stylesheets/pageflow/ui.scss +++ b/app/assets/stylesheets/pageflow/ui.scss @@ -1,5 +1,4 @@ @import "bourbon"; -@import "jquery.minicolors"; @import "pageflow/mixins"; @import "./ui/properties"; @@ -7,8 +6,9 @@ %pageflow-ui { @import "./ui/forms"; - @import "./ui/input/file_name_input"; + @import "./ui/color_picker"; @import "./ui/input/color_input"; + @import "./ui/input/file_name_input"; @import "./ui/table_view"; @import "./ui/table_cells/presence_table_cell"; @import "./ui/table_cells/delete_row_table_cell"; diff --git a/app/assets/stylesheets/pageflow/ui/color_picker.scss b/app/assets/stylesheets/pageflow/ui/color_picker.scss new file mode 100644 index 0000000000..c4caf48ad8 --- /dev/null +++ b/app/assets/stylesheets/pageflow/ui/color_picker.scss @@ -0,0 +1,223 @@ +.color_picker { + --thumb: #{size(4)}; + + display: none; + position: absolute; + width: size(48); + z-index: 1000; + border-radius: rounded("base"); + background-color: var(--ui-surface-color); + box-shadow: 0 0 0 1px var(--ui-on-surface-color-lightest), var(--ui-box-shadow-lg); + user-select: none; + + &-open { + display: block; + } + + &-gradient { + position: relative; + width: 100%; + height: size(24); + margin-bottom: size(3); + border-radius: rounded("base") rounded("base") 0 0; + background-image: #{"linear-gradient(rgba(0,0,0,0), #000), linear-gradient(90deg, #fff, currentColor)"}; + cursor: pointer; + } + + &-marker { + position: absolute; + width: size(3); + height: size(3); + margin: size(-1.5) 0 0 size(-1.5); + border: 1px solid #fff; + border-radius: 50%; + background-color: currentColor; + cursor: pointer; + + &:focus { + outline: none; + } + } + + input[type="range"] { + &::-webkit-slider-runnable-track { + width: 100%; + height: var(--thumb); + } + + &::-webkit-slider-thumb { + width: var(--thumb); + height: var(--thumb); + -webkit-appearance: none; + } + + &::-moz-range-track { + width: 100%; + height: var(--thumb); + border: 0; + } + + &::-moz-range-thumb { + width: var(--thumb); + height: var(--thumb); + border: 0; + } + } + + &-hue { + background-image: #{"linear-gradient(to right, #f00 0%, #ff0 16.66%, #0f0 33.33%, #0ff 50%, #00f 66.66%, #f0f 83.33%, #f00 100%)"}; + } + + &-hue, + &-alpha { + position: relative; + height: size(2); + margin: size(3) size(4); + border-radius: rounded("base"); + } + + &-alpha span { + display: block; + height: 100%; + width: 100%; + border-radius: inherit; + background-image: #{"linear-gradient(90deg, rgba(0,0,0,0), currentColor)"}; + } + + &-hue input[type="range"], + &-alpha input[type="range"] { + position: absolute; + width: calc(100% + 2 * var(--thumb)); + height: var(--thumb); + left: calc(-1 * var(--thumb)); + top: calc((#{size(2)} - var(--thumb)) / 2); + margin: 0; + background-color: transparent; + opacity: 0; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + } + + &-hue div, + &-alpha div { + position: absolute; + width: var(--thumb); + height: var(--thumb); + left: 0; + top: 50%; + margin-left: calc(var(--thumb) / -2); + transform: translateY(-50%); + border: 2px solid #fff; + border-radius: 50%; + background-color: currentColor; + pointer-events: none; + } + + &-swatches { + margin: 0 size(4); + border-top: 1px solid var(--ui-on-surface-color-lightest); + display: flex; + flex-wrap: wrap; + gap: size(2); + padding: size(3) 0; + + &.color_picker-empty { + display: none; + } + + button { + position: relative; + width: size(5); + height: size(5); + padding: 0; + border: 0; + border-radius: 50%; + color: inherit; + text-indent: -1000px; + white-space: nowrap; + overflow: hidden; + cursor: pointer; + + &:after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + background-color: currentColor; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.1); + } + } + } + + &-marker, + &-hue div, + &-alpha div { + box-sizing: border-box; + box-shadow: 0 0 0 1px inset var(--ui-on-surface-color-lighter), 0 0 0 1px var(--ui-on-surface-color-lighter); + } + + &-field { + display: inline-block; + position: relative; + color: transparent; + + > input { + margin: 0; + direction: ltr; + } + + > span { + position: absolute; + width: size(8); + height: 100%; + right: 0; + top: 50%; + transform: translateY(-50%); + margin: 0; + padding: 0; + border: 0; + color: inherit; + text-indent: -1000px; + white-space: nowrap; + overflow: hidden; + pointer-events: none; + + &:after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + background-color: currentColor; + box-shadow: inset 0 0 0 1px var(--ui-on-surface-color-lighter); + } + } + } + + &-alpha, + &-swatches button, + &-field > span { + background-image: #{"repeating-linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%, #aaa), repeating-linear-gradient(45deg, #aaa 25%, #fff 25%, #fff 75%, #aaa 75%, #aaa)"}; + background-position: 0 0, size(1) size(1); + background-size: size(2) size(2); + } + + &-keyboard_nav .color_picker-marker:focus, + &-keyboard_nav .color_picker-hue input:focus + div, + &-keyboard_nav .color_picker-alpha input:focus + div { + outline: none; + box-shadow: 0 0 0 2px var(--ui-selection-color), 0 0 2px 2px #fff; + } + + &-no_alpha .color_picker-alpha { + display: none; + } +} diff --git a/app/assets/stylesheets/pageflow/ui/functions.scss b/app/assets/stylesheets/pageflow/ui/functions.scss index 5a01c6c1dd..acf57aad5e 100644 --- a/app/assets/stylesheets/pageflow/ui/functions.scss +++ b/app/assets/stylesheets/pageflow/ui/functions.scss @@ -1,9 +1,19 @@ @function space($name) { - @return map-get($size-scale, "#{$name}"); + @return _size-lookup($name); } @function size($name) { - @return map-get($size-scale, "#{$name}"); + @return _size-lookup($name); +} + +@function _size-lookup($name) { + $key: "#{$name}"; + + @if str-slice($key, 1, 1) == "-" { + @return -1 * map-get($size-scale, str-slice($key, 2)); + } + + @return map-get($size-scale, $key); } $size-scale: ( diff --git a/app/assets/stylesheets/pageflow/ui/input/color_input.scss b/app/assets/stylesheets/pageflow/ui/input/color_input.scss index f359937238..f7849bd7ce 100644 --- a/app/assets/stylesheets/pageflow/ui/input/color_input.scss +++ b/app/assets/stylesheets/pageflow/ui/input/color_input.scss @@ -1,33 +1,27 @@ .color_input { - .minicolors { + .color_picker-field { + color: var(--placeholder-color, transparent); box-sizing: border-box; width: 100%; } - input { - padding-left: 30px; + .color_picker-field > input { + padding-left: size(8); width: 100%; height: auto; } - .minicolors-input-swatch { - top: 5px; - left: 5px; - - .minicolors-swatch-color { - background-color: var(--placeholder-color); - } + .color_picker-field > span { + left: size(1.5); + width: size(5); + height: size(5); } - &.is_default input { + &.is_default .color_picker-field > input { color: var(--ui-on-surface-color-light); } - .minicolors-focus input { + .color_picker-field:focus-within > input { color: var(--ui-on-surface-color); } - - .minicolors-swatches .minicolors-swatch { - margin: 2px; - } } diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 88f24e323f..91d329f13a 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -648,6 +648,9 @@ de: label: Autoplay id: label: Audio + invertPlayButton: + inline_help: Dunklen Play-Button statt des standardmäßig weißen verwenden. + label: Play-Button invertieren playerControlVariant: inline_help: Wähle den Stil der Player-Steuerelemente. label: Player Controls @@ -658,9 +661,17 @@ de: waveformLines: Waveform (Linien) posterId: label: Poster + remainingWaveformColor: + auto: (Automatisch) + inline_help: Farbe des noch nicht abgespielten Bereichs der Waveform. + label: Verbleibende Waveform waveformColor: inline_help: Farbe des Bereichs der Waveform, der den bereits abgespielten Teil der Audio-Datei repräsentiert. - label: Waveform Farbe + label: Abgespielte Waveform + waveformCursorColor: + auto: (Automatisch) + inline_help: Farbe des Cursors, der die aktuelle Wiedergabeposition markiert. + label: Waveform Cursor description: Wiedergabe einer Audiodatei mit Steuerelementen name: Audio tabs: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index fda0304d7c..f440e8a39e 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -633,6 +633,9 @@ en: label: Autoplay id: label: Audio + invertPlayButton: + inline_help: Use a dark play button instead of the default white one. + label: Invert Play Button playerControlVariant: inline_help: Choose the style of player controls. label: Player Controls @@ -643,9 +646,17 @@ en: waveformLines: Waveform (Lines) posterId: label: Poster + remainingWaveformColor: + auto: (Auto) + inline_help: Color of the remaining waveform that has not been played yet. + label: Remaining Waveform waveformColor: - inline_help: Color of the waveform's parts that represents the already played part of the audio. - label: Waveform Color + inline_help: Color of the waveform that represents the already played part of the audio. + label: Played Waveform + waveformCursorColor: + auto: (Auto) + inline_help: Color of the cursor marking the current playback position. + label: Waveform Cursor description: Player or waveform with controls name: Audio tabs: diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedContentElementColors-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedContentElementColors-spec.js new file mode 100644 index 0000000000..f4ea25bac4 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedContentElementColors-spec.js @@ -0,0 +1,46 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories} from 'pageflow/testHelpers'; +import {normalizeSeed} from 'support'; + +describe('ScrolledEntry', () => { + describe('#getUsedContentElementColors', () => { + it('returns unique sorted list of used colors for given property', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {configuration: {waveformColor: '#400'}}, + {configuration: {waveformColor: '#040'}}, + {configuration: {waveformColor: '#400'}} + ] + }) + } + ); + + const colors = entry.getUsedContentElementColors('waveformColor'); + + expect(colors).toEqual(['#400', '#040']); + }); + + it('ignores blank values', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {configuration: {}}, + {configuration: {waveformColor: undefined}} + ] + }) + } + ); + + const colors = entry.getUsedContentElementColors('waveformColor'); + + expect(colors).toEqual([]); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/TypographyVariantSelectInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/TypographyVariantSelectInputView-spec.js index ca0bfd8391..9b344f7117 100644 --- a/entry_types/scrolled/package/spec/editor/views/inputs/TypographyVariantSelectInputView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/inputs/TypographyVariantSelectInputView-spec.js @@ -1,9 +1,12 @@ +import Backbone from 'backbone'; + import { TypographyVariantSelectInputView } from 'editor/views/inputs/TypographyVariantSelectInputView'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {frontend} from 'frontend'; +import {act} from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom/extend-expect'; import { @@ -67,4 +70,69 @@ describe('TypographyVariantSelectInputView', () => { expect(getByRole('option', {name: 'Large'})) .toHaveTextContent('This element uses variant large.'); }); + + it('re-renders previews when previewConfigurationBinding attribute changes', async () => { + frontend.contentElementTypes.register('test', { + component: function({configuration}) { + return configuration.value?.[0]?.children?.[0]?.text || 'No text'; + } + }); + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1} + ], + contentElements: [ + { + id: 5, + sectionId: 1, + typeName: 'test', + configuration: { + variant: 'default' + } + } + ] + }) + } + ); + const contentElement = entry.contentElements.get(5); + const bindingModel = new Backbone.Model({exampleNode: 'first'}); + + const inputView = new TypographyVariantSelectInputView({ + entry, + contentElement, + model: contentElement.configuration, + propertyName: 'variant', + values: ['default', 'large'], + texts: ['Default', 'Large'], + previewConfigurationBindingModel: bindingModel, + previewConfigurationBinding: 'exampleNode', + + getPreviewConfiguration(configuration, variant) { + return { + ...configuration, + variant, + value: [{ + type: 'paragraph', + children: [{text: bindingModel.get('exampleNode')}] + }] + } + } + }); + + const user = userEvent.setup(); + const {getByRole} = render(inputView); + await user.click(getByRole('button', {name: 'Default'})); + + expect(getByRole('option', {name: 'Default'})) + .toHaveTextContent('first'); + + act(() => { bindingModel.set('exampleNode', 'second') }); + + expect(getByRole('option', {name: 'Default'})) + .toHaveTextContent('second'); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js index ba58fd831e..edc0432925 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js @@ -1,6 +1,14 @@ import {editor, InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; import {FileInputView, CheckBoxInputView} from 'pageflow/editor'; import {SelectInputView, SeparatorView, ColorInputView} from 'pageflow/ui'; +import I18n from 'i18n-js'; + +import { + defaultRemainingWaveformColor, + defaultRemainingWaveformColorInverted, + defaultWaveformCursorColor, + defaultWaveformCursorColorInverted +} from '../../frontend/PlayerControls/WaveformPlayerControls/defaultColors'; import pictogram from './pictogram.svg'; @@ -19,8 +27,9 @@ editor.contentElementTypes.register('inlineAudio', { }); }, - configurationEditor({entry}) { + configurationEditor({entry, contentElement}) { const themeOptions = entry.getTheme().get('options'); + const invert = contentElement.section.configuration.get('invert'); this.tab('general', function() { this.input('id', FileInputView, { @@ -54,10 +63,37 @@ editor.contentElementTypes.register('inlineAudio', { }); this.input('waveformColor', ColorInputView, { + alpha: true, visibleBinding: 'playerControlVariant', visible: variant => variant?.startsWith('waveform'), defaultValue: themeOptions.properties?.root?.accent_color || - themeOptions.colors?.accent + themeOptions.colors?.accent, + swatches: entry.getUsedContentElementColors('waveformColor') + }); + + this.input('remainingWaveformColor', ColorInputView, { + alpha: true, + visibleBinding: 'playerControlVariant', + visible: variant => variant?.startsWith('waveform'), + placeholder: I18n.t('pageflow_scrolled.editor.content_elements.inlineAudio.attributes.remainingWaveformColor.auto'), + placeholderColor: invert ? defaultRemainingWaveformColorInverted + : defaultRemainingWaveformColor, + swatches: entry.getUsedContentElementColors('remainingWaveformColor') + }); + + this.input('waveformCursorColor', ColorInputView, { + alpha: true, + visibleBinding: 'playerControlVariant', + visible: variant => variant?.startsWith('waveform'), + placeholder: I18n.t('pageflow_scrolled.editor.content_elements.inlineAudio.attributes.waveformCursorColor.auto'), + placeholderColor: invert ? defaultWaveformCursorColorInverted + : defaultWaveformCursorColor, + swatches: entry.getUsedContentElementColors('waveformCursorColor') + }); + + this.input('invertPlayButton', CheckBoxInputView, { + visibleBinding: 'playerControlVariant', + visible: variant => variant?.startsWith('waveform') }); this.view(SeparatorView); diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js index 9881071139..fa5410cddf 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js @@ -62,6 +62,16 @@ storiesOfContentElement(module, { posterId: filePermaId('imageFiles', 'turtle'), caption: 'Some caption' } + }, + { + name: 'with waveform and custom colors', + configuration: { + playerControlVariant: 'waveform', + waveformColor: '#1963ad', + remainingWaveformColor: '#e2ad1a', + waveformCursorColor: '#ff0000', + invertPlayButton: true + } } ], inlineFileRights: true diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js index e321cc3271..df05930600 100644 --- a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js +++ b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js @@ -11,40 +11,23 @@ editor.contentElementTypes.register('textBlock', { supportedPositions: ['inline'], configurationEditor({entry, contentElement}) { - let pendingRefresh; + let lastExampleNodeType = contentElement.transientState.get('exampleNode')?.type; 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; - } + const exampleNodeType = contentElement.transientState.get('exampleNode')?.type; - this.refresh() + if (exampleNodeType !== lastExampleNodeType) { + lastExampleNodeType = exampleNodeType; + this.refresh(); + } } ); this.tab('general', function() { - const exampleNode = ensureTextContent( - contentElement.transientState.get('exampleNode') - ); + const exampleNode = contentElement.transientState.get('exampleNode'); const modelDelegator = entry.createLegacyTypographyVariantDelegator({ model: contentElement.transientState, @@ -52,11 +35,15 @@ editor.contentElementTypes.register('textBlock', { }) const getPreviewConfiguration = (configuration, properties) => { - return exampleNode ? { + const currentExampleNode = ensureTextContent( + contentElement.transientState.get('exampleNode') + ); + + return currentExampleNode ? { ...configuration, value: [ { - ...exampleNode, + ...currentExampleNode, // Ensure size in preview is not overridden by legacy variant variant: modelDelegator.get('typographyVariant'), ...properties @@ -73,6 +60,8 @@ editor.contentElementTypes.register('textBlock', { entry, model: modelDelegator, prefix: exampleNode ? utils.camelize(exampleNode.type) : 'none', + previewConfigurationBindingModel: contentElement.transientState, + previewConfigurationBinding: 'exampleNode', getPreviewConfiguration(configuration, variant) { return getPreviewConfiguration(configuration, {variant}) } @@ -81,6 +70,8 @@ editor.contentElementTypes.register('textBlock', { entry, model: modelDelegator, prefix: exampleNode ? utils.camelize(exampleNode.type) : 'none', + previewConfigurationBindingModel: contentElement.transientState, + previewConfigurationBinding: 'exampleNode', getPreviewConfiguration(configuration, size) { return getPreviewConfiguration(configuration, {size}) } 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 2fee55f8c0..bff3b3474b 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -377,6 +377,16 @@ export const ScrolledEntry = Entry.extend({ }); }, + getUsedContentElementColors(propertyName) { + const colors = new Set(); + + this.contentElements.forEach(contentElement => { + colors.add(contentElement.configuration.get(propertyName)); + }); + + return sortColors([...colors].filter(Boolean)); + }, + getUsedSectionBackgroundColors() { const colors = new Set(); 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 5b8e04ed27..d73d9ab3ee 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 @@ -95,7 +95,10 @@ ConfigurationEditorTabView.groups.define('ContentElementPosition', function({ent ConfigurationEditorTabView.groups.define( 'ContentElementTypographyVariant', - function({entry, model, prefix, getPreviewConfiguration}) { + function({ + entry, model, prefix, getPreviewConfiguration, + previewConfigurationBindingModel, previewConfigurationBinding + }) { const contentElement = this.model.parent; if (entry.getTypographyVariants({contentElement})[0].length) { @@ -110,6 +113,8 @@ ConfigurationEditorTabView.groups.define( contentElement: contentElement, prefix, getPreviewConfiguration, + previewConfigurationBindingModel, + previewConfigurationBinding, includeBlank: true, blankTranslationKey: 'pageflow_scrolled.editor.' + @@ -129,7 +134,10 @@ ConfigurationEditorTabView.groups.define( ConfigurationEditorTabView.groups.define( 'ContentElementTypographySize', - function({entry, model, prefix, getPreviewConfiguration}) { + function({ + entry, model, prefix, getPreviewConfiguration, + previewConfigurationBindingModel, previewConfigurationBinding + }) { const contentElement = this.model.parent; const [sizes, texts] = entry.getTypographySizes({ @@ -143,6 +151,8 @@ ConfigurationEditorTabView.groups.define( contentElement, prefix, getPreviewConfiguration, + previewConfigurationBindingModel, + previewConfigurationBinding, attributeTranslationKeyPrefixes: [ 'pageflow_scrolled.editor.common_content_element_attributes' 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 95c678fe07..ff1548512a 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/StyleListInputView.js @@ -2,7 +2,7 @@ import Marionette from 'backbone.marionette'; import I18n from 'i18n-js'; import 'jquery-ui'; -import {CollectionView, cssModulesUtils, inputView} from 'pageflow/ui'; +import {CollectionView, ColorPicker, cssModulesUtils, inputView} from 'pageflow/ui'; import {DropDownButtonView} from 'pageflow/editor'; import {StylesCollection} from '../../collections/StylesCollection'; @@ -99,16 +99,24 @@ const StyleListItemView = Marionette.ItemView.extend({ this.ui.widget.slider('option', 'value', this.model.get('value') || 50); - this.ui.colorInput.minicolors({ - defaultValue: this.model.defaultValue(), - position: 'bottom right', - changeDelay: 200, - change: color => { - this.model.set('value', color); - } - }); + const colorInput = this.ui.colorInput[0]; + + if (colorInput) { + colorInput.value = this.model.get('value') || this.model.defaultValue() || ''; + + this._colorPicker = new ColorPicker(colorInput, { + defaultValue: this.model.defaultValue(), + onChange: (color) => { + this.model.set('value', color || ''); + } + }); + } + }, - this.ui.colorInput.minicolors('value', this.model.get('value')); + onBeforeClose() { + if (this._colorPicker) { + this._colorPicker.destroy(); + } } }); 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 b784a86ff9..5d3c2c7437 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 @@ -92,30 +92,28 @@ outline-width: 2px; } -.item :global(.minicolors) { +.item :global(.color_picker-field) { flex: 1; - margin: -4px 0 -4px 2.6rem; + margin: size(-1) 0 size(-1) 2.7rem; } .colorInput { border: 0; width: 100% !important; - height: 22px !important; + height: size(6) !important; box-sizing: border-box; - font-size: 12px; - padding-right: 0 !important; - padding-left: 20px !important; + font-size: 0.75rem; + padding-left: size(6) !important; } .colorInput:focus { outline: solid 1px transparent; } -.item :global(.minicolors-input-swatch) { - top: 3px !important; - left: 0px !important; - width: 14px; - height: 14px; +.item :global(.color_picker-field) > :global(span) { + left: 0; + width: size(4); + height: size(4); } .remove { diff --git a/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js index 9ae5882391..d684447ce9 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js @@ -9,9 +9,25 @@ import {watchCollections} from '../../../entryState'; import {StandaloneSectionThumbnail} from 'pageflow-scrolled/frontend' export const TypographyVariantSelectInputView = ListboxInputView.extend({ + onRender() { + ListboxInputView.prototype.onRender.call(this); + + this.previewVersion = 0; + this.setupAttributeBinding( + 'previewConfiguration', + () => { + if (!this.isClosed) { + this.previewVersion++; + this.renderDropdown(); + } + } + ); + }, + renderItem(item) { return ( - diff --git a/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js index e25436e8f4..229a31930d 100644 --- a/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js +++ b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js @@ -23,6 +23,9 @@ export function MediaPlayerControls(props) { variant={props.configuration.playerControlVariant} waveformColor={props.configuration.waveformColor} + remainingWaveformColor={props.configuration.remainingWaveformColor} + waveformCursorColor={props.configuration.waveformCursorColor} + invertPlayButton={props.configuration.invertPlayButton} mediaElementId={playerState.mediaElementId} currentTime={playerState.scrubbingAt !== undefined ? diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.js b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.js index a228da8a10..e157bfb4b1 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.js @@ -3,13 +3,14 @@ import Measure from 'react-measure'; import {RemotePeakData} from './RemotePeakData'; -import styles from './Waveform.module.css'; - -const waveColor = '#828282ed'; -const waveColorInverted = 'rgba(0, 0, 0, 0.5)'; +import { + defaultRemainingWaveformColor, + defaultRemainingWaveformColorInverted, + defaultWaveformCursorColor, + defaultWaveformCursorColorInverted +} from './defaultColors'; -const cursorColor = '#fff'; -const cursorColorInverted = '#888'; +import styles from './Waveform.module.css'; const Wavesurfer = React.lazy(() => import('./Wavesurfer')); @@ -44,14 +45,16 @@ export function Waveform(props) { normalize: true, removeMediaElementOnDestroy: false, hideScrollbar: true, - progressColor: props.waveformColor || + progressColor: props.progressWaveformColor || props.mainColor, - waveColor: props.inverted ? - waveColorInverted : - waveColor, - cursorColor: props.inverted ? - cursorColorInverted : - cursorColor, + waveColor: props.remainingWaveformColor || + (props.inverted ? + defaultRemainingWaveformColorInverted : + defaultRemainingWaveformColor), + cursorColor: props.waveformCursorColor || + (props.inverted ? + defaultWaveformCursorColorInverted : + defaultWaveformCursorColor), height, }} /> } diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.module.css b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.module.css index bd06b7ee90..cd30d379a6 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.module.css +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Waveform.module.css @@ -49,6 +49,21 @@ .playControl svg { transform: scale(2); + filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5)); +} + +.invertPlayButton { + color: #000; +} + +.invertPlayButton svg { + filter: none; +} + +.invertPlayButton svg path { + filter: none; + stroke: rgb(255 255 255 / .5); + paint-order: stroke; } .waveWrapper { diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Wavesurfer.js b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Wavesurfer.js index 93c30dee51..a438242d47 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Wavesurfer.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Wavesurfer.js @@ -69,6 +69,11 @@ class Wavesurfer extends Component { this._wavesurfer = WaveSurfer.create(options); + this._wavesurfer.drawer.updateProgress = function(position) { + this.style(this.progressWave, { width: position + 'px' }); + this.style(this.wrapper.lastChild, { clipPath: 'rect(auto auto auto ' + position + 'px)' }); + } + // file was loaded, wave was drawn this._wavesurfer.on('ready', () => { this.setState({ diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/defaultColors.js b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/defaultColors.js new file mode 100644 index 0000000000..84885beb8c --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/defaultColors.js @@ -0,0 +1,4 @@ +export const defaultRemainingWaveformColor = '#828282ed'; +export const defaultRemainingWaveformColorInverted = 'rgba(0, 0, 0, 0.5)'; +export const defaultWaveformCursorColor = '#fff'; +export const defaultWaveformCursorColorInverted = '#888'; diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js index 3d17558b63..6ca00fbe29 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js @@ -29,13 +29,16 @@ export function WaveformPlayerControls(props) { isPlaying={props.isPlaying} inverted={!darkBackground} variant={props.variant} - waveformColor={props.waveformColor} + progressWaveformColor={props.waveformColor} + remainingWaveformColor={props.remainingWaveformColor} + waveformCursorColor={props.waveformCursorColor} mainColor={theme.options.properties?.root?.accentColor || theme.options.colors?.accent} play={props.play} pause={props.pause} mediaElementId={props.mediaElementId} /> -
+
diff --git a/lib/pageflow/engine.rb b/lib/pageflow/engine.rb index 90f8661212..61068cdb49 100644 --- a/lib/pageflow/engine.rb +++ b/lib/pageflow/engine.rb @@ -22,7 +22,6 @@ require 'backbone-rails' require 'marionette-rails' require 'jquery-fileupload-rails' -require 'jquery-minicolors-rails' require 'i18n-js' require 'http_accept_language' require 'pageflow-public-i18n' diff --git a/package/config/jest/index.js b/package/config/jest/index.js index c1db0b160c..607dba5b38 100644 --- a/package/config/jest/index.js +++ b/package/config/jest/index.js @@ -13,7 +13,6 @@ module.exports = { moduleNameMapper: { '^jquery$': resolve('../../vendor/jquery'), '^jquery-ui$': resolve('../../vendor/jquery-ui'), - '^jquery.minicolors$': resolve('../../vendor/jquery.minicolors'), '^backbone.marionette$': resolve('../../vendor/backbone.marionette'), '^backbone.babysitter$': resolve('../../vendor/backbone.babysitter'), '^backbone$': resolve('../../vendor/backbone'), diff --git a/package/config/webpack.js b/package/config/webpack.js index c7347d1b65..d2f938891d 100644 --- a/package/config/webpack.js +++ b/package/config/webpack.js @@ -5,7 +5,6 @@ module.exports = { 'cocktail': 'Cocktail', 'jquery': 'jQuery', 'jquery-ui': 'jQuery', - 'jquery.minicolors': 'jQuery', 'underscore': '_', 'backbone.marionette': 'Backbone.Marionette', 'iscroll': 'IScroll', diff --git a/package/config/webpack5.js b/package/config/webpack5.js index 3c82a2511f..a55ba2b60c 100644 --- a/package/config/webpack5.js +++ b/package/config/webpack5.js @@ -5,7 +5,6 @@ module.exports = { 'cocktail': 'Cocktail', 'jquery': 'jQuery', 'jquery-ui': 'jQuery', - 'jquery.minicolors': 'jQuery', 'underscore': '_', 'backbone.marionette': 'Backbone.Marionette', 'iscroll': 'IScroll', diff --git a/package/spec/ui/views/ColorPicker-spec.js b/package/spec/ui/views/ColorPicker-spec.js new file mode 100644 index 0000000000..e4af63d03b --- /dev/null +++ b/package/spec/ui/views/ColorPicker-spec.js @@ -0,0 +1,511 @@ +import '@testing-library/jest-dom/extend-expect'; + +import ColorPicker from 'ui/views/ColorPicker'; + +describe('ColorPicker', () => { + let container, input, colorPicker; + + beforeAll(() => { + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = jest.fn(); + } + }); + + afterEach(() => { + if (colorPicker) { + colorPicker.destroy(); + colorPicker = null; + } + + if (container && container.parentNode) { + container.remove(); + } + }); + + function createColorPicker({value, ...options} = {}) { + container = document.createElement('div'); + input = document.createElement('input'); + input.type = 'text'; + if (value) input.value = value; + container.appendChild(input); + document.body.appendChild(container); + + colorPicker = new ColorPicker(input, options); + return colorPicker; + } + + function picker() { + return colorPicker._picker; + } + + function open() { + input.dispatchEvent(new Event('click', {bubbles: true})); + } + + function blur() { + input.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: document.body})); + } + + describe('setup', () => { + it('wraps input in .color_picker-field div', () => { + createColorPicker(); + + expect(input.parentNode).toHaveClass('color_picker-field'); + }); + + it('adds preview swatch to wrapper', () => { + createColorPicker(); + + expect(input.parentNode.querySelector('span')).not.toBeNull(); + }); + + it('creates picker element inside wrapper', () => { + createColorPicker(); + + expect(input.parentNode.querySelector('.color_picker')).not.toBeNull(); + }); + + it('sets wrapper color from initial input value', () => { + createColorPicker({value: '#ff0000'}); + + expect(input.parentNode).toHaveStyle('color: #ff0000'); + }); + + it('does not set wrapper color for empty initial value', () => { + createColorPicker(); + + expect(input.parentNode.style.color).toBe(''); + }); + + it('renders swatch buttons', () => { + createColorPicker({swatches: ['#aabbcc', '#ddeeff']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + + expect(buttons).toHaveLength(2); + expect(buttons[0].textContent).toBe('#aabbcc'); + expect(buttons[1].textContent).toBe('#ddeeff'); + }); + }); + + describe('open and close', () => { + it('opens picker on input click', () => { + createColorPicker(); + + open(); + + expect(picker()).toHaveClass('color_picker-open'); + }); + + it('opens picker on Enter key', () => { + createColorPicker(); + + input.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}) + ); + + expect(picker()).toHaveClass('color_picker-open'); + }); + + it('sets currentColor to null when opened with invalid input value', () => { + createColorPicker(); + input.value = '#ttt'; + + open(); + + expect(colorPicker._currentColor).toBeNull(); + }); + + it('opens picker on Alt+ArrowDown key', () => { + createColorPicker(); + + input.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowDown', altKey: true, bubbles: true}) + ); + + expect(picker()).toHaveClass('color_picker-open'); + }); + + it('does not open picker on input focus', () => { + createColorPicker(); + + input.dispatchEvent(new Event('focus')); + + expect(picker()).not.toHaveClass('color_picker-open'); + }); + + it('closes picker on outside mousedown', () => { + createColorPicker(); + open(); + + document.body.dispatchEvent(new Event('mousedown', {bubbles: true})); + + expect(picker()).not.toHaveClass('color_picker-open'); + }); + + it('does not close picker on mousedown inside input', () => { + createColorPicker(); + open(); + + input.dispatchEvent(new Event('mousedown', {bubbles: true})); + + expect(picker()).toHaveClass('color_picker-open'); + }); + + it('closes on Escape and keeps value', () => { + createColorPicker({value: '#aabbcc', swatches: ['#112233']}); + open(); + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + document.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}) + ); + + expect(input.value).toBe('#112233'); + expect(picker()).not.toHaveClass('color_picker-open'); + }); + + it('closes on Enter from non-button element', () => { + createColorPicker(); + open(); + + input.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}) + ); + + expect(picker()).not.toHaveClass('color_picker-open'); + }); + + it('closes when focus moves to element outside wrapper', () => { + createColorPicker(); + var outside = document.createElement('button'); + document.body.appendChild(outside); + open(); + + input.dispatchEvent( + new FocusEvent('focusout', {bubbles: true, relatedTarget: outside}) + ); + + expect(picker()).not.toHaveClass('color_picker-open'); + outside.remove(); + }); + + it('does not close on focusout with null relatedTarget', () => { + createColorPicker(); + open(); + + input.dispatchEvent( + new FocusEvent('focusout', {bubbles: true, relatedTarget: null}) + ); + + expect(picker()).toHaveClass('color_picker-open'); + }); + }); + + describe('swatch interaction', () => { + it('updates input value when swatch is clicked', () => { + createColorPicker({swatches: ['#aabbcc']}); + open(); + + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input.value).toBe('#aabbcc'); + }); + + it('updates wrapper color when swatch is clicked', () => { + createColorPicker({swatches: ['#aabbcc']}); + open(); + + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input.parentNode).toHaveStyle('color: #aabbcc'); + }); + }); + + describe('input sync', () => { + it('updates wrapper color on input event', () => { + createColorPicker(); + + input.value = '#ff0000'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(input.parentNode).toHaveStyle('color: #ff0000'); + }); + + it('keeps input empty on close when no color was picked', () => { + createColorPicker(); + open(); + + document.body.dispatchEvent(new Event('mousedown', {bubbles: true})); + + expect(input.value).toBe(''); + }); + + it('sets currentColor to null when input changes to invalid color', () => { + createColorPicker({value: '#ff0000'}); + open(); + + input.value = '#ttt'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(colorPicker._currentColor).toBeNull(); + }); + + it('clears wrapper color when input is empty', () => { + createColorPicker({value: '#ff0000'}); + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(input.parentNode.style.color).toBe(''); + }); + + it('clears wrapper color when input contains an invalid color', () => { + createColorPicker({value: '#ff0000'}); + input.value = '#ttt'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(input.parentNode.style.color).toBe(''); + }); + }); + + describe('blur normalization', () => { + it('converts rgb to hex on blur when picker is closed', () => { + createColorPicker(); + input.value = 'rgb(255, 0, 0)'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + blur(); + + expect(input.value).toBe('#ff0000'); + }); + + it('keeps input empty on blur', () => { + createColorPicker(); + + blur(); + + expect(input.value).toBe(''); + }); + + it('clears input on blur with invalid value', () => { + createColorPicker({value: '#ff0000'}); + input.value = '#ttt'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + blur(); + + expect(input.value).toBe(''); + }); + + it('normalizes on blur even when picker is open', () => { + createColorPicker(); + open(); + input.value = 'rgb(255, 0, 0)'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + input.dispatchEvent( + new FocusEvent('focusout', {bubbles: true, relatedTarget: null}) + ); + + expect(input.value).toBe('#ff0000'); + }); + + it('normalizes on Enter when picker is closed', () => { + createColorPicker(); + input.value = 'rgb(255, 0, 0)'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + input.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}) + ); + + expect(input.value).toBe('#ff0000'); + }); + + it('normalizes on Enter when picker is open', () => { + createColorPicker(); + open(); + input.value = 'rgb(255, 0, 0)'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + input.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}) + ); + + expect(input.value).toBe('#ff0000'); + }); + }); + + describe('alpha option', () => { + it('hides alpha slider by default', () => { + createColorPicker(); + + expect(picker()).toHaveClass('color_picker-no_alpha'); + }); + + it('shows alpha slider when enabled', () => { + createColorPicker({alpha: true}); + + expect(picker()).not.toHaveClass('color_picker-no_alpha'); + }); + + it('outputs #rrggbb format without alpha', () => { + createColorPicker({swatches: ['#aabbcc']}); + open(); + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input.value).toBe('#aabbcc'); + }); + + it('outputs #rrggbb for fully opaque color with alpha enabled', () => { + createColorPicker({alpha: true, swatches: ['#aabbcc']}); + open(); + picker().querySelector('.color_picker-swatches button') + .dispatchEvent(new Event('click', {bubbles: true})); + + expect(input.value).toBe('#aabbcc'); + }); + + it('outputs #rrggbbaa for translucent color with alpha enabled', () => { + createColorPicker({alpha: true, value: '#ff000080'}); + open(); + + expect(input.value).toBe('#ff000080'); + }); + }); + + describe('onChange', () => { + it('does not call onChange during construction', () => { + var handler = jest.fn(); + createColorPicker({value: '#ff0000', onChange: handler}); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('calls onChange with formatted hex on valid input', () => { + var handler = jest.fn(); + createColorPicker({onChange: handler}); + + input.value = '#ff0000'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(handler).toHaveBeenCalledWith('#ff0000'); + }); + + it('calls onChange with null on invalid input', () => { + var handler = jest.fn(); + createColorPicker({value: '#ff0000', onChange: handler}); + + input.value = '#ttt'; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(handler).toHaveBeenCalledWith(null); + }); + + it('calls onChange with null on empty input', () => { + var handler = jest.fn(); + createColorPicker({value: '#ff0000', onChange: handler}); + + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(handler).toHaveBeenCalledWith(null); + }); + }); + + describe('defaultValue option', () => { + it('resets currentColor to default when input is cleared', () => { + createColorPicker({value: '#ff0000', defaultValue: '#aabbcc'}); + + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(colorPicker._currentColor).toEqual({r: 170, g: 187, b: 204, a: 1}); + }); + + it('shows default color in wrapper when input is cleared', () => { + createColorPicker({value: '#ff0000', defaultValue: '#aabbcc'}); + + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(input.parentNode).toHaveStyle('color: #aabbcc'); + }); + + it('calls onChange with default value when input is cleared', () => { + var handler = jest.fn(); + createColorPicker({value: '#ff0000', defaultValue: '#aabbcc', onChange: handler}); + + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(handler).toHaveBeenCalledWith('#aabbcc'); + }); + + it('normalizes input to default value on blur', () => { + createColorPicker({value: '#ff0000', defaultValue: '#aabbcc'}); + + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + blur(); + + expect(input.value).toBe('#aabbcc'); + }); + + it('uses updated default value', () => { + createColorPicker({value: '#ff0000', defaultValue: '#aabbcc'}); + + colorPicker.update({defaultValue: '#112233'}); + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + + expect(colorPicker._currentColor).toEqual({r: 17, g: 34, b: 51, a: 1}); + }); + }); + + describe('update', () => { + it('re-renders swatches', () => { + createColorPicker({swatches: ['#aabbcc']}); + + colorPicker.update({swatches: ['#112233', '#445566', '#778899']}); + + var buttons = picker().querySelectorAll('.color_picker-swatches button'); + expect(buttons).toHaveLength(3); + }); + }); + + describe('destroy', () => { + it('removes picker element from DOM', () => { + createColorPicker(); + + colorPicker.destroy(); + colorPicker = null; + + expect(container.querySelector('.color_picker')).toBeNull(); + }); + + it('unwraps input', () => { + createColorPicker(); + + colorPicker.destroy(); + colorPicker = null; + + expect(input.parentNode).toBe(container); + }); + + it('does not respond to input click after destroy', () => { + createColorPicker(); + + colorPicker.destroy(); + colorPicker = null; + + input.dispatchEvent(new Event('click', {bubbles: true})); + + expect(document.querySelector('.color_picker.color_picker-open')).toBeNull(); + }); + }); +}); diff --git a/package/spec/ui/views/inputs/ColorInputView-spec.js b/package/spec/ui/views/inputs/ColorInputView-spec.js index f218c50aa0..a2cac9650a 100644 --- a/package/spec/ui/views/inputs/ColorInputView-spec.js +++ b/package/spec/ui/views/inputs/ColorInputView-spec.js @@ -4,7 +4,6 @@ import '@testing-library/jest-dom/extend-expect'; import {ColorInputView} from 'pageflow/ui'; -import * as support from '$support'; import {ColorInput} from '$support/dominos/ui' describe('pageflow.ColorInputView', () => { @@ -479,6 +478,22 @@ describe('pageflow.ColorInputView', () => { }); }); + it('removes picker element when view is closed', () => { + var pickersBefore = document.querySelectorAll('.color_picker').length; + var colorInputView = new ColorInputView({ + model: new Backbone.Model(), + propertyName: 'color' + }); + + ColorInput.render(colorInputView); + + expect(document.querySelectorAll('.color_picker').length).toBe(pickersBefore + 1); + + colorInputView.close(); + + expect(document.querySelectorAll('.color_picker').length).toBe(pickersBefore); + }); + describe('with placeholderColor option', () => { it('sets custom property', () => { var colorInputView = new ColorInputView({ diff --git a/package/src/testHelpers/dominos/ui/inputs/ColorInput.js b/package/src/testHelpers/dominos/ui/inputs/ColorInput.js index c28e1f6267..80cd1859e4 100644 --- a/package/src/testHelpers/dominos/ui/inputs/ColorInput.js +++ b/package/src/testHelpers/dominos/ui/inputs/ColorInput.js @@ -6,16 +6,20 @@ export const ColorInput = Base.extend({ }, fillIn: function(value, clock) { - this._input().val(value); - this._input().trigger('keyup'); + var input = this._input()[0]; + input.value = value; + input.dispatchEvent(new Event('input', {bubbles: true})); clock.tick(500); }, swatches: function() { - return this.$el.find('.minicolors-swatches span').map(function() { - return window.getComputedStyle(this)['background-color']; - }).get(); + var picker = this._input()[0].parentNode.querySelector('.color_picker'); + var buttons = picker.querySelectorAll('.color_picker-swatches button'); + + return Array.from(buttons).map(function(button) { + return window.getComputedStyle(button).color; + }); }, _input: function() { diff --git a/package/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js new file mode 100644 index 0000000000..7aa0913f19 --- /dev/null +++ b/package/src/ui/views/ColorPicker.js @@ -0,0 +1,610 @@ +// Inspired by https://github.com/mdbassit/Coloris + +const ctx = typeof OffscreenCanvas !== 'undefined' && + new OffscreenCanvas(1, 1).getContext('2d'); + +const FALLBACK_COLOR = {r: 255, g: 255, b: 255, a: 1}; + +const PICKER_HTML = + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '' + + '
' + + '
'; + +export default class ColorPicker { + constructor(input, options = {}) { + this._input = input; + this._colorAreaDims = {}; + this._alpha = options.alpha || false; + this._onChange = options.onChange; + this._defaultColor = strToRGBA(options.defaultValue); + this._swatches = options.swatches || []; + + this._wrapInput(); + this._createPicker(); + this._renderSwatches(); + this._bindEvents(); + this._updateColor(strToRGBA(this._input.value), {silent: true}); + } + + update(options) { + if ('defaultValue' in options) { + this._defaultColor = strToRGBA(options.defaultValue); + } + if (options.swatches) { + this._swatches = options.swatches; + this._renderSwatches(); + } + } + + destroy() { + this._close(); + this._unbindEvents(); + this._picker.remove(); + this._unwrapInput(); + } + + // Setup + + _createPicker() { + this._picker = document.createElement('div'); + this._picker.className = 'color_picker'; + this._picker.classList.toggle('color_picker-no_alpha', !this._alpha); + this._picker.innerHTML = PICKER_HTML; + this._input.parentNode.appendChild(this._picker); + + this._colorArea = this._picker.querySelector('.color_picker-gradient'); + this._colorMarker = this._picker.querySelector('.color_picker-marker'); + this._hueSlider = this._picker.querySelector('.color_picker-hue input'); + this._hueMarker = this._picker.querySelector('.color_picker-hue div'); + 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'); + } + + _wrapInput() { + const parent = this._input.parentNode; + + if (!parent.classList.contains('color_picker-field')) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = ''; + parent.insertBefore(wrapper, this._input); + wrapper.className = 'color_picker-field'; + wrapper.appendChild(this._input); + } + } + + _unwrapInput() { + const wrapper = this._input.parentNode; + + if (wrapper && wrapper.classList.contains('color_picker-field')) { + const parent = wrapper.parentNode; + parent.insertBefore(this._input, wrapper); + parent.removeChild(wrapper); + } + } + + _renderSwatches() { + this._swatchesContainer.textContent = ''; + this._swatchesContainer.classList.toggle('color_picker-empty', !this._swatches.length); + + if (!this._swatches.length) { + return; + } + + this._swatches.forEach(swatch => { + const button = document.createElement('button'); + button.setAttribute('type', 'button'); + button.title = swatch; + button.style.color = swatch; + button.textContent = swatch; + this._swatchesContainer.appendChild(button); + }); + } + + // Lifecycle + + _open() { + if (this._picker.classList.contains('color_picker-open')) { + return; + } + + this._picker.classList.add('color_picker-open'); + this._updatePosition(); + this._setColorFromStr(this._input.value); + + this._addDocListeners(); + } + + _close() { + if (!this._picker.classList.contains('color_picker-open')) { + return; + } + + this._picker.classList.remove('color_picker-open'); + this._removeDocListeners(); + } + + _updatePosition() { + const margin = 2; + const pickerHeight = this._picker.offsetHeight; + const pickerWidth = this._picker.offsetWidth; + const inputRect = this._input.getBoundingClientRect(); + const clipRect = getClipRect(this._input); + + const spaceBelow = clipRect.bottom - inputRect.bottom; + const flipTop = pickerHeight + margin > spaceBelow && + pickerHeight + margin <= inputRect.top - clipRect.top; + + this._picker.classList.toggle('color_picker-top', flipTop); + + if (flipTop) { + this._picker.style.top = 'auto'; + this._picker.style.bottom = `calc(100% + ${margin}px)`; + } else { + this._picker.style.top = `calc(100% + ${margin}px)`; + this._picker.style.bottom = ''; + } + + if (inputRect.left + pickerWidth > clipRect.right) { + this._picker.style.left = 'auto'; + this._picker.style.right = '0'; + } else { + this._picker.style.left = '0'; + this._picker.style.right = ''; + } + + const areaRect = this._colorArea.getBoundingClientRect(); + this._colorAreaDims = { + width: this._colorArea.offsetWidth, + height: this._colorArea.offsetHeight, + x: areaRect.x + window.scrollX, + y: areaRect.y + window.scrollY + }; + } + + // Color interaction + + _setColorFromStr(str, options) { + this._updateColor(strToRGBA(str), options); + + const {h, s, v, a} = rgbaToHSVA(this._displayColor); + + this._hueSlider.value = h; + this._picker.style.color = `hsl(${h}, 100%, 50%)`; + this._hueMarker.style.left = `${h / 360 * 100}%`; + + this._colorMarker.style.left = `${this._colorAreaDims.width * s / 100}px`; + this._colorMarker.style.top = `${this._colorAreaDims.height - (this._colorAreaDims.height * v / 100)}px`; + + this._alphaSlider.value = a * 100; + this._alphaMarker.style.left = `${a * 100}%`; + } + + _moveMarker(event) { + let x = event.pageX - this._colorAreaDims.x; + let y = event.pageY - this._colorAreaDims.y; + + this._setMarkerPosition(x, y); + + event.preventDefault(); + event.stopPropagation(); + } + + _moveMarkerOnKeydown(dx, dy) { + let x = this._colorMarker.style.left.replace('px', '') * 1 + dx; + let y = this._colorMarker.style.top.replace('px', '') * 1 + dy; + + this._setMarkerPosition(x, y); + } + + _setHue() { + const hue = this._hueSlider.value * 1; + const x = this._colorMarker.style.left.replace('px', '') * 1; + const y = this._colorMarker.style.top.replace('px', '') * 1; + + this._picker.style.color = `hsl(${hue}, 100%, 50%)`; + this._hueMarker.style.left = `${hue / 360 * 100}%`; + + this._setColorAtPosition(x, y); + } + + _setAlpha() { + const alpha = this._alphaSlider.value / 100; + + this._alphaMarker.style.left = `${alpha * 100}%`; + this._updateColor({a: alpha}); + this._syncInput(); + } + + _setMarkerPosition(x, y) { + x = (x < 0) ? 0 : (x > this._colorAreaDims.width) ? this._colorAreaDims.width : x; + y = (y < 0) ? 0 : (y > this._colorAreaDims.height) ? this._colorAreaDims.height : y; + + this._colorMarker.style.left = `${x}px`; + this._colorMarker.style.top = `${y}px`; + + this._setColorAtPosition(x, y); + this._colorMarker.focus(); + } + + _setColorAtPosition(x, y) { + const hsva = { + h: this._hueSlider.value * 1, + s: x / this._colorAreaDims.width * 100, + v: 100 - (y / this._colorAreaDims.height * 100), + a: this._alphaSlider.value / 100 + }; + const rgba = hsvaToRGBA(hsva); + + this._updateColor(rgba); + this._syncInput(); + } + + _updateColor(rgba, {silent} = {}) { + rgba = rgba || this._defaultColor; + if (rgba && !this._alpha) rgba.a = 1; + + this._currentColor = rgba && {...this._displayColor, ...rgba}; + this._displayColor = this._currentColor || FALLBACK_COLOR; + + const hex = rgbaToHex(this._displayColor); + const opaqueHex = hex.substring(0, 7); + + this._colorMarker.style.color = opaqueHex; + this._alphaMarker.parentNode.style.color = opaqueHex; + this._alphaMarker.style.color = hex; + + const formatted = this._formatHex(this._currentColor); + const wrapper = this._input.parentNode; + if (wrapper && wrapper.classList.contains('color_picker-field')) { + wrapper.style.color = formatted || ''; + } + + // Force repaint the color and alpha gradients (Chrome workaround) + this._colorArea.style.display = 'none'; + this._colorArea.offsetHeight; + this._colorArea.style.display = ''; + this._alphaMarker.nextElementSibling.style.display = 'none'; + this._alphaMarker.nextElementSibling.offsetHeight; + this._alphaMarker.nextElementSibling.style.display = ''; + + if (!silent && this._onChange) this._onChange(formatted); + } + + _syncInput() { + this._input.value = this._formatHex(this._currentColor) || ''; + } + + _formatHex(rgba) { + if (!rgba) return null; + const hex = rgbaToHex(rgba); + return rgba.a < 1 ? hex : hex.substring(0, 7); + } + + _normalizeInputValue() { + const hex = this._formatHex(this._currentColor) || ''; + if (hex !== this._input.value) { + this._input.value = hex; + } + } + + // Event wiring + + _bindEvents() { + this._onInputClick = () => { + this._open(); + }; + + this._onInputKeydown = (event) => { + if (event.key === 'Enter') { + this._normalizeInputValue(); + } + + if (this._picker.classList.contains('color_picker-open')) { + return; + } + + if (event.key === 'Enter' || (event.key === 'ArrowDown' && event.altKey)) { + this._open(); + event.stopPropagation(); + } + }; + + this._onPickerMousedown = (event) => { + this._picker.classList.remove('color_picker-keyboard_nav'); + event.stopPropagation(); + }; + + this._onAreaPointerdown = (event) => { + event.preventDefault(); + this._colorArea.setPointerCapture(event.pointerId); + this._dragging = true; + }; + this._onAreaPointermove = (event) => { + if (this._dragging) { + this._moveMarker(event); + } + }; + this._onAreaPointerup = () => { + this._dragging = false; + }; + + this._onInputEvent = () => { + if (this._picker.classList.contains('color_picker-open')) { + this._setColorFromStr(this._input.value); + } else { + this._updateColor(strToRGBA(this._input.value)); + } + }; + + this._onSwatchClick = (event) => { + if (event.target.closest('.color_picker-swatches button')) { + this._setColorFromStr(event.target.closest('.color_picker-swatches button').textContent); + this._syncInput(); + } + }; + + this._onMarkerKeydown = (event) => { + const movements = { + ArrowUp: [0, -1], + ArrowDown: [0, 1], + ArrowLeft: [-1, 0], + ArrowRight: [1, 0] + }; + + if (movements[event.key]) { + this._moveMarkerOnKeydown(...movements[event.key]); + event.preventDefault(); + } + }; + + this._onAreaClick = (event) => this._moveMarker(event); + this._onHueInput = () => this._setHue(); + this._onAlphaInput = () => this._setAlpha(); + + const wrapper = this._input.parentNode; + this._onFocusout = (event) => { + if (event.target === this._input) { + this._normalizeInputValue(); + } + + if (this._picker.classList.contains('color_picker-open')) { + if (event.relatedTarget && !wrapper.contains(event.relatedTarget)) { + this._close(); + } + } + }; + + this._input.addEventListener('click', this._onInputClick); + this._input.addEventListener('keydown', this._onInputKeydown); + this._input.addEventListener('input', this._onInputEvent); + wrapper.addEventListener('focusout', this._onFocusout); + + this._picker.addEventListener('mousedown', this._onPickerMousedown); + this._colorArea.addEventListener('pointerdown', this._onAreaPointerdown); + this._colorArea.addEventListener('pointermove', this._onAreaPointermove); + this._colorArea.addEventListener('pointerup', this._onAreaPointerup); + this._swatchesContainer.addEventListener('click', this._onSwatchClick); + this._colorMarker.addEventListener('keydown', this._onMarkerKeydown); + this._colorArea.addEventListener('click', this._onAreaClick); + this._hueSlider.addEventListener('input', this._onHueInput); + this._alphaSlider.addEventListener('input', this._onAlphaInput); + } + + _unbindEvents() { + this._input.removeEventListener('click', this._onInputClick); + this._input.removeEventListener('keydown', this._onInputKeydown); + this._input.removeEventListener('input', this._onInputEvent); + + const wrapper = this._input.parentNode; + if (wrapper && wrapper.classList.contains('color_picker-field')) { + wrapper.removeEventListener('focusout', this._onFocusout); + } + + this._picker.removeEventListener('mousedown', this._onPickerMousedown); + this._colorArea.removeEventListener('pointerdown', this._onAreaPointerdown); + this._colorArea.removeEventListener('pointermove', this._onAreaPointermove); + this._colorArea.removeEventListener('pointerup', this._onAreaPointerup); + this._swatchesContainer.removeEventListener('click', this._onSwatchClick); + this._colorMarker.removeEventListener('keydown', this._onMarkerKeydown); + this._colorArea.removeEventListener('click', this._onAreaClick); + this._hueSlider.removeEventListener('input', this._onHueInput); + this._alphaSlider.removeEventListener('input', this._onAlphaInput); + + this._removeDocListeners(); + } + + _addDocListeners() { + this._onDocMousedown = (event) => { + this._picker.classList.remove('color_picker-keyboard_nav'); + + if (!this._input.parentNode.contains(event.target)) { + this._close(); + } + }; + + this._onDocKeydown = (event) => { + if (event.key === 'Escape' || + (event.key === 'Enter' && event.target.tagName !== 'BUTTON')) { + this._close(); + this._input.focus({preventScroll: true}); + return; + } + + const navKeys = ['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + if (navKeys.includes(event.key)) { + this._picker.classList.add('color_picker-keyboard_nav'); + } + }; + + document.addEventListener('mousedown', this._onDocMousedown); + document.addEventListener('keydown', this._onDocKeydown); + } + + _removeDocListeners() { + if (this._onDocMousedown) { + document.removeEventListener('mousedown', this._onDocMousedown); + document.removeEventListener('keydown', this._onDocKeydown); + } + } +} + +function strToRGBA(str) { + if (!str) return null; + + const regex = /^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i; + let match, rgba; + + if (!ctx) { + match = regex.exec(str); + if (match) { + return {r: match[3] * 1, g: match[4] * 1, b: match[5] * 1, a: match[6] * 1 || 1}; + } + + if (!/^#[0-9a-f]{3,8}$/i.test(str)) return null; + + let hex = str.replace('#', ''); + if (hex.length === 3 || hex.length === 4) { + hex = hex.split('').map(c => c + c).join(''); + } + if (hex.length !== 6 && hex.length !== 8) return null; + match = hex.match(/.{2}/g).map(h => parseInt(h, 16)); + return {r: match[0], g: match[1], b: match[2], a: match[3] !== undefined ? match[3] / 255 : 1}; + } + + ctx.fillStyle = '#010101'; + ctx.fillStyle = str; + + if (ctx.fillStyle === '#010101') { + ctx.fillStyle = '#020202'; + ctx.fillStyle = str; + if (ctx.fillStyle === '#020202') return null; + } + match = regex.exec(ctx.fillStyle); + + if (match) { + rgba = { + r: match[3] * 1, + g: match[4] * 1, + b: match[5] * 1, + a: match[6] * 1 + }; + } else { + match = ctx.fillStyle.replace('#', '').match(/.{2}/g).map(h => parseInt(h, 16)); + rgba = { + r: match[0], + g: match[1], + b: match[2], + a: 1 + }; + } + + return rgba; +} + +function rgbaToHSVA(rgba) { + const red = rgba.r / 255; + + const green = rgba.g / 255; + const blue = rgba.b / 255; + const xmax = Math.max(red, green, blue); + const xmin = Math.min(red, green, blue); + const chroma = xmax - xmin; + const value = xmax; + let hue = 0; + let saturation = 0; + + if (chroma) { + if (xmax === red) { hue = ((green - blue) / chroma); } + if (xmax === green) { hue = 2 + (blue - red) / chroma; } + if (xmax === blue) { hue = 4 + (red - green) / chroma; } + if (xmax) { saturation = chroma / xmax; } + } + + hue = Math.floor(hue * 60); + + return { + h: hue < 0 ? hue + 360 : hue, + s: Math.round(saturation * 100), + v: Math.round(value * 100), + a: rgba.a + }; +} + +function hsvaToRGBA(hsva) { + const saturation = hsva.s / 100; + const value = hsva.v / 100; + let chroma = saturation * value; + let hueBy60 = hsva.h / 60; + let x = chroma * (1 - Math.abs(hueBy60 % 2 - 1)); + let m = value - chroma; + + chroma = (chroma + m); + x = (x + m); + + const index = Math.floor(hueBy60) % 6; + const red = [chroma, x, m, m, x, chroma][index]; + const green = [x, chroma, chroma, x, m, m][index]; + const blue = [m, m, x, chroma, chroma, x][index]; + + return { + r: Math.round(red * 255), + g: Math.round(green * 255), + b: Math.round(blue * 255), + a: hsva.a + }; +} + +function rgbaToHex(rgba) { + let R = rgba.r.toString(16); + let G = rgba.g.toString(16); + let B = rgba.b.toString(16); + let A = Math.round(rgba.a * 255).toString(16); + + if (rgba.r < 16) { R = '0' + R; } + if (rgba.g < 16) { G = '0' + G; } + if (rgba.b < 16) { B = '0' + B; } + if (rgba.a * 255 < 16) { A = '0' + A; } + + return '#' + R + G + B + A; +} + +function getClipRect(element) { + const viewport = { + top: 0, + left: 0, + right: document.documentElement.clientWidth, + bottom: document.documentElement.clientHeight + }; + + let ancestor = element.parentElement; + + while (ancestor && ancestor !== document.documentElement) { + const overflow = getComputedStyle(ancestor).overflow; + + if (overflow !== 'visible') { + const rect = ancestor.getBoundingClientRect(); + + return { + top: Math.max(viewport.top, rect.top), + left: Math.max(viewport.left, rect.left), + right: Math.min(viewport.right, rect.right), + bottom: Math.min(viewport.bottom, rect.bottom) + }; + } + + ancestor = ancestor.parentElement; + } + + return viewport; +} diff --git a/package/src/ui/views/inputs/ColorInputView.js b/package/src/ui/views/inputs/ColorInputView.js index 8f452e7169..09ed2f6727 100644 --- a/package/src/ui/views/inputs/ColorInputView.js +++ b/package/src/ui/views/inputs/ColorInputView.js @@ -1,6 +1,8 @@ import Marionette from 'backbone.marionette'; import _ from 'underscore'; -import 'jquery.minicolors'; +import ColorPicker from '../ColorPicker'; + +export {ColorPicker}; import {inputView} from '../mixins/inputView'; import {inputWithPlaceholderText} from '../mixins/inputWithPlaceholderText'; @@ -35,6 +37,11 @@ import template from '../../templates/inputs/colorInput.jst'; * is used as placeholderColor option, it will be passed the value of the * placeholderColorBinding attribute each time it changes. * + * @param {boolean} [options.alpha] + * Allow picking colors with alpha channel. When enabled, translucent + * colors are stored in `#rrggbbaa` format. Fully opaque colors still + * use `#rrggbb`. + * * @param {string[]} [options.swatches] * Preset color values to be displayed inside the picker drop * down. The default value, if present, is always used as the @@ -52,27 +59,14 @@ export const ColorInputView = Marionette.ItemView.extend({ input: 'input' }, - events: { - 'mousedown': 'refreshPicker' - }, - onRender: function() { this.setupAttributeBinding('placeholderColor', this.updatePlaceholderColor); - this.ui.input.minicolors({ - changeDelay: 200, - change: _.bind(function(color) { - this._saving = true; - - if (color === this.defaultValue()) { - this.model.unset(this.options.propertyName); - } - else { - this.model.set(this.options.propertyName, color); - } - - this._saving = false; - }, this) + this._colorPicker = new ColorPicker(this.ui.input[0], { + alpha: this.options.alpha, + defaultValue: this.defaultValue(), + swatches: this.getSwatches(), + onChange: _.debounce(_.bind(this._onChange, this), 200) }); this.listenTo(this.model, 'change:' + this.options.propertyName, this.load); @@ -81,17 +75,20 @@ export const ColorInputView = Marionette.ItemView.extend({ this.listenTo(this.model, 'change:' + this.options.defaultValueBinding, this.updateSettings); } - this.updateSettings(); + this.load(); }, updatePlaceholderColor(value) { - this.el.style.setProperty('--placeholder-color', value); + if (value) { + this.el.style.setProperty('--placeholder-color', value); + } + else { + this.el.style.removeProperty('--placeholder-color'); + } }, updateSettings: function() { - this.resetSwatchesInStoredSettings(); - - this.ui.input.minicolors('settings', { + this._colorPicker.update({ defaultValue: this.defaultValue(), swatches: this.getSwatches() }); @@ -99,27 +96,23 @@ export const ColorInputView = Marionette.ItemView.extend({ this.load(); }, - // see https://github.com/claviska/jquery-minicolors/issues/287 - resetSwatchesInStoredSettings: function() { - const settings = this.ui.input.data('minicolors-settings'); - - if (settings) { - delete settings.swatches; - this.ui.input.data('minicolors-settings', settings); - } - }, - load: function() { + var color = this.model.get(this.options.propertyName) || this.defaultValue() || ''; + if (!this._saving) { - this.ui.input.minicolors('value', - this.model.get(this.options.propertyName) || this.defaultValue()); + this.ui.input[0].value = color; + + var wrapper = this.ui.input[0].parentNode; + if (wrapper && wrapper.classList.contains('color_picker-field')) { + wrapper.style.color = color; + } } this.$el.toggleClass('is_default', !this.model.has(this.options.propertyName)); }, - refreshPicker: function() { - this.ui.input.minicolors('value', {}); + onBeforeClose: function() { + this._colorPicker.destroy(); }, getSwatches: function() { @@ -146,5 +139,18 @@ export const ColorInputView = Marionette.ItemView.extend({ else { return bindingValue; } + }, + + _onChange: function(color) { + this._saving = true; + + if (!color || color === this.defaultValue()) { + this.model.unset(this.options.propertyName); + } + else { + this.model.set(this.options.propertyName, color); + } + + this._saving = false; } }); diff --git a/package/vendor/jquery.minicolors.js b/package/vendor/jquery.minicolors.js deleted file mode 100644 index 6c1add1d9a..0000000000 --- a/package/vendor/jquery.minicolors.js +++ /dev/null @@ -1,1108 +0,0 @@ -// -// jQuery MiniColors: A tiny color picker built on jQuery -// -// Developed by Cory LaViska for A Beautiful Site, LLC -// -// Licensed under the MIT license: http://opensource.org/licenses/MIT -// -(function (factory) { - if(typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(['jquery'], factory); - } else if(typeof exports === 'object') { - // Node/CommonJS - module.exports = factory(require('jquery')); - } else { - // Browser globals - factory(jQuery); - } -}(function ($) { - 'use strict'; - - // Defaults - $.minicolors = { - defaults: { - animationSpeed: 50, - animationEasing: 'swing', - change: null, - changeDelay: 0, - control: 'hue', - defaultValue: '', - format: 'hex', - hide: null, - hideSpeed: 100, - inline: false, - keywords: '', - letterCase: 'lowercase', - opacity: false, - position: 'bottom left', - show: null, - showSpeed: 100, - theme: 'default', - swatches: [] - } - }; - - // Public methods - $.extend($.fn, { - minicolors: function(method, data) { - - switch(method) { - // Destroy the control - case 'destroy': - $(this).each(function() { - destroy($(this)); - }); - return $(this); - - // Hide the color picker - case 'hide': - hide(); - return $(this); - - // Get/set opacity - case 'opacity': - // Getter - if(data === undefined) { - // Getter - return $(this).attr('data-opacity'); - } else { - // Setter - $(this).each(function() { - updateFromInput($(this).attr('data-opacity', data)); - }); - } - return $(this); - - // Get an RGB(A) object based on the current color/opacity - case 'rgbObject': - return rgbObject($(this), method === 'rgbaObject'); - - // Get an RGB(A) string based on the current color/opacity - case 'rgbString': - case 'rgbaString': - return rgbString($(this), method === 'rgbaString'); - - // Get/set settings on the fly - case 'settings': - if(data === undefined) { - return $(this).data('minicolors-settings'); - } else { - // Setter - $(this).each(function() { - var settings = $(this).data('minicolors-settings') || {}; - destroy($(this)); - $(this).minicolors($.extend(true, settings, data)); - }); - } - return $(this); - - // Show the color picker - case 'show': - show($(this).eq(0)); - return $(this); - - // Get/set the hex color value - case 'value': - if(data === undefined) { - // Getter - return $(this).val(); - } else { - // Setter - $(this).each(function() { - if(typeof(data) === 'object' && data !== 'null') { - if(data.opacity) { - $(this).attr('data-opacity', keepWithin(data.opacity, 0, 1)); - } - if(data.color) { - $(this).val(data.color); - } - } else { - $(this).val(data); - } - updateFromInput($(this)); - }); - } - return $(this); - - // Initializes the control - default: - if(method !== 'create') data = method; - $(this).each(function() { - init($(this), data); - }); - return $(this); - - } - - } - }); - - // Initialize input elements - function init(input, settings) { - var minicolors = $('
'); - var defaults = $.minicolors.defaults; - var size; - var swatches; - var swatch; - var panel; - var i; - - // Do nothing if already initialized - if(input.data('minicolors-initialized')) return; - - // Handle settings - settings = $.extend(true, {}, defaults, settings); - - // The wrapper - minicolors - .addClass('minicolors-theme-' + settings.theme) - .toggleClass('minicolors-with-opacity', settings.opacity); - - // Custom positioning - if(settings.position !== undefined) { - $.each(settings.position.split(' '), function() { - minicolors.addClass('minicolors-position-' + this); - }); - } - - // Input size - if(settings.format === 'rgb') { - size = settings.opacity ? '25' : '20'; - } else { - size = settings.keywords ? '11' : '7'; - } - - // The input - input - .addClass('minicolors-input') - .data('minicolors-initialized', false) - .data('minicolors-settings', settings) - .prop('size', size) - .wrap(minicolors) - .after( - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' - ); - - // The swatch - if(!settings.inline) { - input.after(''); - input.next('.minicolors-input-swatch').on('click', function(event) { - event.preventDefault(); - input.focus(); - }); - } - - // Prevent text selection in IE - panel = input.parent().find('.minicolors-panel'); - panel.on('selectstart', function() { return false; }).end(); - - // Swatches - if(settings.swatches && settings.swatches.length !== 0) { - panel.addClass('minicolors-with-swatches'); - swatches = $('
    ') - .appendTo(panel); - for(i = 0; i < settings.swatches.length; ++i) { - swatch = settings.swatches[i]; - swatch = isRgb(swatch) ? parseRgb(swatch, true) : hex2rgb(parseHex(swatch, true)); - $('
  • ') - .appendTo(swatches) - .data('swatch-color', settings.swatches[i]) - .find('.minicolors-swatch-color') - .css({ - backgroundColor: rgb2hex(swatch), - opacity: swatch.a - }); - settings.swatches[i] = swatch; - } - } - - // Inline controls - if(settings.inline) input.parent().addClass('minicolors-inline'); - - updateFromInput(input, false); - - input.data('minicolors-initialized', true); - } - - // Returns the input back to its original state - function destroy(input) { - var minicolors = input.parent(); - - // Revert the input element - input - .removeData('minicolors-initialized') - .removeData('minicolors-settings') - .removeProp('size') - .removeClass('minicolors-input'); - - // Remove the wrap and destroy whatever remains - minicolors.before(input).remove(); - } - - // Shows the specified dropdown panel - function show(input) { - var minicolors = input.parent(); - var panel = minicolors.find('.minicolors-panel'); - var settings = input.data('minicolors-settings'); - - // Do nothing if uninitialized, disabled, inline, or already open - if( - !input.data('minicolors-initialized') || - input.prop('disabled') || - minicolors.hasClass('minicolors-inline') || - minicolors.hasClass('minicolors-focus') - ) return; - - hide(); - - minicolors.addClass('minicolors-focus'); - panel - .stop(true, true) - .fadeIn(settings.showSpeed, function() { - if(settings.show) settings.show.call(input.get(0)); - }); - } - - // Hides all dropdown panels - function hide() { - $('.minicolors-focus').each(function() { - var minicolors = $(this); - var input = minicolors.find('.minicolors-input'); - var panel = minicolors.find('.minicolors-panel'); - var settings = input.data('minicolors-settings'); - - panel.fadeOut(settings.hideSpeed, function() { - if(settings.hide) settings.hide.call(input.get(0)); - minicolors.removeClass('minicolors-focus'); - }); - - }); - } - - // Moves the selected picker - function move(target, event, animate) { - var input = target.parents('.minicolors').find('.minicolors-input'); - var settings = input.data('minicolors-settings'); - var picker = target.find('[class$=-picker]'); - var offsetX = target.offset().left; - var offsetY = target.offset().top; - var x = Math.round(event.pageX - offsetX); - var y = Math.round(event.pageY - offsetY); - var duration = animate ? settings.animationSpeed : 0; - var wx, wy, r, phi; - - // Touch support - if(event.originalEvent.changedTouches) { - x = event.originalEvent.changedTouches[0].pageX - offsetX; - y = event.originalEvent.changedTouches[0].pageY - offsetY; - } - - // Constrain picker to its container - if(x < 0) x = 0; - if(y < 0) y = 0; - if(x > target.width()) x = target.width(); - if(y > target.height()) y = target.height(); - - // Constrain color wheel values to the wheel - if(target.parent().is('.minicolors-slider-wheel') && picker.parent().is('.minicolors-grid')) { - wx = 75 - x; - wy = 75 - y; - r = Math.sqrt(wx * wx + wy * wy); - phi = Math.atan2(wy, wx); - if(phi < 0) phi += Math.PI * 2; - if(r > 75) { - r = 75; - x = 75 - (75 * Math.cos(phi)); - y = 75 - (75 * Math.sin(phi)); - } - x = Math.round(x); - y = Math.round(y); - } - - // Move the picker - if(target.is('.minicolors-grid')) { - picker - .stop(true) - .animate({ - top: y + 'px', - left: x + 'px' - }, duration, settings.animationEasing, function() { - updateFromControl(input, target); - }); - } else { - picker - .stop(true) - .animate({ - top: y + 'px' - }, duration, settings.animationEasing, function() { - updateFromControl(input, target); - }); - } - } - - // Sets the input based on the color picker values - function updateFromControl(input, target) { - - function getCoords(picker, container) { - var left, top; - if(!picker.length || !container) return null; - left = picker.offset().left; - top = picker.offset().top; - - return { - x: left - container.offset().left + (picker.outerWidth() / 2), - y: top - container.offset().top + (picker.outerHeight() / 2) - }; - } - - var hue, saturation, brightness, x, y, r, phi; - var hex = input.val(); - var opacity = input.attr('data-opacity'); - - // Helpful references - var minicolors = input.parent(); - var settings = input.data('minicolors-settings'); - var swatch = minicolors.find('.minicolors-input-swatch'); - - // Panel objects - var grid = minicolors.find('.minicolors-grid'); - var slider = minicolors.find('.minicolors-slider'); - var opacitySlider = minicolors.find('.minicolors-opacity-slider'); - - // Picker objects - var gridPicker = grid.find('[class$=-picker]'); - var sliderPicker = slider.find('[class$=-picker]'); - var opacityPicker = opacitySlider.find('[class$=-picker]'); - - // Picker positions - var gridPos = getCoords(gridPicker, grid); - var sliderPos = getCoords(sliderPicker, slider); - var opacityPos = getCoords(opacityPicker, opacitySlider); - - // Handle colors - if(target.is('.minicolors-grid, .minicolors-slider, .minicolors-opacity-slider')) { - - // Determine HSB values - switch(settings.control) { - case 'wheel': - // Calculate hue, saturation, and brightness - x = (grid.width() / 2) - gridPos.x; - y = (grid.height() / 2) - gridPos.y; - r = Math.sqrt(x * x + y * y); - phi = Math.atan2(y, x); - if(phi < 0) phi += Math.PI * 2; - if(r > 75) { - r = 75; - gridPos.x = 69 - (75 * Math.cos(phi)); - gridPos.y = 69 - (75 * Math.sin(phi)); - } - saturation = keepWithin(r / 0.75, 0, 100); - hue = keepWithin(phi * 180 / Math.PI, 0, 360); - brightness = keepWithin(100 - Math.floor(sliderPos.y * (100 / slider.height())), 0, 100); - hex = hsb2hex({ - h: hue, - s: saturation, - b: brightness - }); - - // Update UI - slider.css('backgroundColor', hsb2hex({ h: hue, s: saturation, b: 100 })); - break; - - case 'saturation': - // Calculate hue, saturation, and brightness - hue = keepWithin(parseInt(gridPos.x * (360 / grid.width()), 10), 0, 360); - saturation = keepWithin(100 - Math.floor(sliderPos.y * (100 / slider.height())), 0, 100); - brightness = keepWithin(100 - Math.floor(gridPos.y * (100 / grid.height())), 0, 100); - hex = hsb2hex({ - h: hue, - s: saturation, - b: brightness - }); - - // Update UI - slider.css('backgroundColor', hsb2hex({ h: hue, s: 100, b: brightness })); - minicolors.find('.minicolors-grid-inner').css('opacity', saturation / 100); - break; - - case 'brightness': - // Calculate hue, saturation, and brightness - hue = keepWithin(parseInt(gridPos.x * (360 / grid.width()), 10), 0, 360); - saturation = keepWithin(100 - Math.floor(gridPos.y * (100 / grid.height())), 0, 100); - brightness = keepWithin(100 - Math.floor(sliderPos.y * (100 / slider.height())), 0, 100); - hex = hsb2hex({ - h: hue, - s: saturation, - b: brightness - }); - - // Update UI - slider.css('backgroundColor', hsb2hex({ h: hue, s: saturation, b: 100 })); - minicolors.find('.minicolors-grid-inner').css('opacity', 1 - (brightness / 100)); - break; - - default: - // Calculate hue, saturation, and brightness - hue = keepWithin(360 - parseInt(sliderPos.y * (360 / slider.height()), 10), 0, 360); - saturation = keepWithin(Math.floor(gridPos.x * (100 / grid.width())), 0, 100); - brightness = keepWithin(100 - Math.floor(gridPos.y * (100 / grid.height())), 0, 100); - hex = hsb2hex({ - h: hue, - s: saturation, - b: brightness - }); - - // Update UI - grid.css('backgroundColor', hsb2hex({ h: hue, s: 100, b: 100 })); - break; - } - - // Handle opacity - if(settings.opacity) { - opacity = parseFloat(1 - (opacityPos.y / opacitySlider.height())).toFixed(2); - } else { - opacity = 1; - } - - updateInput(input, hex, opacity); - } - else { - // Set swatch color - swatch.find('span').css({ - backgroundColor: hex, - opacity: opacity - }); - - // Handle change event - doChange(input, hex, opacity); - } - } - - // Sets the value of the input and does the appropriate conversions - // to respect settings, also updates the swatch - function updateInput(input, value, opacity) { - var rgb; - - // Helpful references - var minicolors = input.parent(); - var settings = input.data('minicolors-settings'); - var swatch = minicolors.find('.minicolors-input-swatch'); - - if(settings.opacity) input.attr('data-opacity', opacity); - - // Set color string - if(settings.format === 'rgb') { - // Returns RGB(A) string - - // Checks for input format and does the conversion - if(isRgb(value)) { - rgb = parseRgb(value, true); - } - else { - rgb = hex2rgb(parseHex(value, true)); - } - - opacity = input.attr('data-opacity') === '' ? 1 : keepWithin(parseFloat(input.attr('data-opacity')).toFixed(2), 0, 1); - if(isNaN(opacity) || !settings.opacity) opacity = 1; - - if(input.minicolors('rgbObject').a <= 1 && rgb && settings.opacity) { - // Set RGBA string if alpha - value = 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + parseFloat(opacity) + ')'; - } else { - // Set RGB string (alpha = 1) - value = 'rgb(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ')'; - } - } else { - // Returns hex color - - // Checks for input format and does the conversion - if(isRgb(value)) { - value = rgbString2hex(value); - } - - value = convertCase(value, settings.letterCase); - } - - // Update value from picker - input.val(value); - - // Set swatch color - swatch.find('span').css({ - backgroundColor: value, - opacity: opacity - }); - - // Handle change event - doChange(input, value, opacity); - } - - // Sets the color picker values from the input - function updateFromInput(input, preserveInputValue) { - var hex, hsb, opacity, keywords, alpha, value, x, y, r, phi; - - // Helpful references - var minicolors = input.parent(); - var settings = input.data('minicolors-settings'); - var swatch = minicolors.find('.minicolors-input-swatch'); - - // Panel objects - var grid = minicolors.find('.minicolors-grid'); - var slider = minicolors.find('.minicolors-slider'); - var opacitySlider = minicolors.find('.minicolors-opacity-slider'); - - // Picker objects - var gridPicker = grid.find('[class$=-picker]'); - var sliderPicker = slider.find('[class$=-picker]'); - var opacityPicker = opacitySlider.find('[class$=-picker]'); - - // Determine hex/HSB values - if(isRgb(input.val())) { - // If input value is a rgb(a) string, convert it to hex color and update opacity - hex = rgbString2hex(input.val()); - alpha = keepWithin(parseFloat(getAlpha(input.val())).toFixed(2), 0, 1); - if(alpha) { - input.attr('data-opacity', alpha); - } - } else { - hex = convertCase(parseHex(input.val(), true), settings.letterCase); - } - - if(!hex){ - hex = convertCase(parseInput(settings.defaultValue, true), settings.letterCase); - } - hsb = hex2hsb(hex); - - // Get array of lowercase keywords - keywords = !settings.keywords ? [] : $.map(settings.keywords.split(','), function(a) { - return $.trim(a.toLowerCase()); - }); - - // Set color string - if(input.val() !== '' && $.inArray(input.val().toLowerCase(), keywords) > -1) { - value = convertCase(input.val()); - } else { - value = isRgb(input.val()) ? parseRgb(input.val()) : hex; - } - - // Update input value - if(!preserveInputValue) input.val(value); - - // Determine opacity value - if(settings.opacity) { - // Get from data-opacity attribute and keep within 0-1 range - opacity = input.attr('data-opacity') === '' ? 1 : keepWithin(parseFloat(input.attr('data-opacity')).toFixed(2), 0, 1); - if(isNaN(opacity)) opacity = 1; - input.attr('data-opacity', opacity); - swatch.find('span').css('opacity', opacity); - - // Set opacity picker position - y = keepWithin(opacitySlider.height() - (opacitySlider.height() * opacity), 0, opacitySlider.height()); - opacityPicker.css('top', y + 'px'); - } - - // Set opacity to zero if input value is transparent - if(input.val().toLowerCase() === 'transparent') { - swatch.find('span').css('opacity', 0); - } - - // Update swatch - swatch.find('span').css('backgroundColor', hex); - - // Determine picker locations - switch(settings.control) { - case 'wheel': - // Set grid position - r = keepWithin(Math.ceil(hsb.s * 0.75), 0, grid.height() / 2); - phi = hsb.h * Math.PI / 180; - x = keepWithin(75 - Math.cos(phi) * r, 0, grid.width()); - y = keepWithin(75 - Math.sin(phi) * r, 0, grid.height()); - gridPicker.css({ - top: y + 'px', - left: x + 'px' - }); - - // Set slider position - y = 150 - (hsb.b / (100 / grid.height())); - if(hex === '') y = 0; - sliderPicker.css('top', y + 'px'); - - // Update panel color - slider.css('backgroundColor', hsb2hex({ h: hsb.h, s: hsb.s, b: 100 })); - break; - - case 'saturation': - // Set grid position - x = keepWithin((5 * hsb.h) / 12, 0, 150); - y = keepWithin(grid.height() - Math.ceil(hsb.b / (100 / grid.height())), 0, grid.height()); - gridPicker.css({ - top: y + 'px', - left: x + 'px' - }); - - // Set slider position - y = keepWithin(slider.height() - (hsb.s * (slider.height() / 100)), 0, slider.height()); - sliderPicker.css('top', y + 'px'); - - // Update UI - slider.css('backgroundColor', hsb2hex({ h: hsb.h, s: 100, b: hsb.b })); - minicolors.find('.minicolors-grid-inner').css('opacity', hsb.s / 100); - break; - - case 'brightness': - // Set grid position - x = keepWithin((5 * hsb.h) / 12, 0, 150); - y = keepWithin(grid.height() - Math.ceil(hsb.s / (100 / grid.height())), 0, grid.height()); - gridPicker.css({ - top: y + 'px', - left: x + 'px' - }); - - // Set slider position - y = keepWithin(slider.height() - (hsb.b * (slider.height() / 100)), 0, slider.height()); - sliderPicker.css('top', y + 'px'); - - // Update UI - slider.css('backgroundColor', hsb2hex({ h: hsb.h, s: hsb.s, b: 100 })); - minicolors.find('.minicolors-grid-inner').css('opacity', 1 - (hsb.b / 100)); - break; - - default: - // Set grid position - x = keepWithin(Math.ceil(hsb.s / (100 / grid.width())), 0, grid.width()); - y = keepWithin(grid.height() - Math.ceil(hsb.b / (100 / grid.height())), 0, grid.height()); - gridPicker.css({ - top: y + 'px', - left: x + 'px' - }); - - // Set slider position - y = keepWithin(slider.height() - (hsb.h / (360 / slider.height())), 0, slider.height()); - sliderPicker.css('top', y + 'px'); - - // Update panel color - grid.css('backgroundColor', hsb2hex({ h: hsb.h, s: 100, b: 100 })); - break; - } - - // Fire change event, but only if minicolors is fully initialized - if(input.data('minicolors-initialized')) { - doChange(input, value, opacity); - } - } - - // Runs the change and changeDelay callbacks - function doChange(input, value, opacity) { - var settings = input.data('minicolors-settings'); - var lastChange = input.data('minicolors-lastChange'); - var obj, sel, i; - - // Only run if it actually changed - if(!lastChange || lastChange.value !== value || lastChange.opacity !== opacity) { - - // Remember last-changed value - input.data('minicolors-lastChange', { - value: value, - opacity: opacity - }); - - // Check and select applicable swatch - if(settings.swatches && settings.swatches.length !== 0) { - if(!isRgb(value)) { - obj = hex2rgb(value); - } - else { - obj = parseRgb(value, true); - } - sel = -1; - for(i = 0; i < settings.swatches.length; ++i) { - if(obj.r === settings.swatches[i].r && obj.g === settings.swatches[i].g && obj.b === settings.swatches[i].b && obj.a === settings.swatches[i].a) { - sel = i; - break; - } - } - - input.parent().find('.minicolors-swatches .minicolors-swatch').removeClass('selected'); - if(sel !== -1) { - input.parent().find('.minicolors-swatches .minicolors-swatch').eq(i).addClass('selected'); - } - } - - // Fire change event - if(settings.change) { - if(settings.changeDelay) { - // Call after a delay - clearTimeout(input.data('minicolors-changeTimeout')); - input.data('minicolors-changeTimeout', setTimeout(function() { - settings.change.call(input.get(0), value, opacity); - }, settings.changeDelay)); - } else { - // Call immediately - settings.change.call(input.get(0), value, opacity); - } - } - input.trigger('change').trigger('input'); - } - } - - // Generates an RGB(A) object based on the input's value - function rgbObject(input) { - var rgb, - opacity = $(input).attr('data-opacity'); - if( isRgb($(input).val()) ) { - rgb = parseRgb($(input).val(), true); - } else { - var hex = parseHex($(input).val(), true); - rgb = hex2rgb(hex); - } - if( !rgb ) return null; - if( opacity !== undefined ) $.extend(rgb, { a: parseFloat(opacity) }); - return rgb; - } - - // Generates an RGB(A) string based on the input's value - function rgbString(input, alpha) { - var rgb, - opacity = $(input).attr('data-opacity'); - if( isRgb($(input).val()) ) { - rgb = parseRgb($(input).val(), true); - } else { - var hex = parseHex($(input).val(), true); - rgb = hex2rgb(hex); - } - if( !rgb ) return null; - if( opacity === undefined ) opacity = 1; - if( alpha ) { - return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + parseFloat(opacity) + ')'; - } else { - return 'rgb(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ')'; - } - } - - // Converts to the letter case specified in settings - function convertCase(string, letterCase) { - return letterCase === 'uppercase' ? string.toUpperCase() : string.toLowerCase(); - } - - // Parses a string and returns a valid hex string when possible - function parseHex(string, expand) { - string = string.replace(/^#/g, ''); - if(!string.match(/^[A-F0-9]{3,6}/ig)) return ''; - if(string.length !== 3 && string.length !== 6) return ''; - if(string.length === 3 && expand) { - string = string[0] + string[0] + string[1] + string[1] + string[2] + string[2]; - } - return '#' + string; - } - - // Parses a string and returns a valid RGB(A) string when possible - function parseRgb(string, obj) { - var values = string.replace(/[^\d,.]/g, ''); - var rgba = values.split(','); - - rgba[0] = keepWithin(parseInt(rgba[0], 10), 0, 255); - rgba[1] = keepWithin(parseInt(rgba[1], 10), 0, 255); - rgba[2] = keepWithin(parseInt(rgba[2], 10), 0, 255); - if(rgba[3]) { - rgba[3] = keepWithin(parseFloat(rgba[3], 10), 0, 1); - } - - // Return RGBA object - if( obj ) { - if (rgba[3]) { - return { - r: rgba[0], - g: rgba[1], - b: rgba[2], - a: rgba[3] - }; - } else { - return { - r: rgba[0], - g: rgba[1], - b: rgba[2] - }; - } - } - - // Return RGBA string - if(typeof(rgba[3]) !== 'undefined' && rgba[3] <= 1) { - return 'rgba(' + rgba[0] + ', ' + rgba[1] + ', ' + rgba[2] + ', ' + rgba[3] + ')'; - } else { - return 'rgb(' + rgba[0] + ', ' + rgba[1] + ', ' + rgba[2] + ')'; - } - - } - - // Parses a string and returns a valid color string when possible - function parseInput(string, expand) { - if(isRgb(string)) { - // Returns a valid rgb(a) string - return parseRgb(string); - } else { - return parseHex(string, expand); - } - } - - // Keeps value within min and max - function keepWithin(value, min, max) { - if(value < min) value = min; - if(value > max) value = max; - return value; - } - - // Checks if a string is a valid RGB(A) string - function isRgb(string) { - var rgb = string.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i); - return (rgb && rgb.length === 4) ? true : false; - } - - // Function to get alpha from a RGB(A) string - function getAlpha(rgba) { - rgba = rgba.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+(\.\d{1,2})?|\.\d{1,2})[\s+]?/i); - return (rgba && rgba.length === 6) ? rgba[4] : '1'; - } - - // Converts an HSB object to an RGB object - function hsb2rgb(hsb) { - var rgb = {}; - var h = Math.round(hsb.h); - var s = Math.round(hsb.s * 255 / 100); - var v = Math.round(hsb.b * 255 / 100); - if(s === 0) { - rgb.r = rgb.g = rgb.b = v; - } else { - var t1 = v; - var t2 = (255 - s) * v / 255; - var t3 = (t1 - t2) * (h % 60) / 60; - if(h === 360) h = 0; - if(h < 60) { rgb.r = t1; rgb.b = t2; rgb.g = t2 + t3; } - else if(h < 120) {rgb.g = t1; rgb.b = t2; rgb.r = t1 - t3; } - else if(h < 180) {rgb.g = t1; rgb.r = t2; rgb.b = t2 + t3; } - else if(h < 240) {rgb.b = t1; rgb.r = t2; rgb.g = t1 - t3; } - else if(h < 300) {rgb.b = t1; rgb.g = t2; rgb.r = t2 + t3; } - else if(h < 360) {rgb.r = t1; rgb.g = t2; rgb.b = t1 - t3; } - else { rgb.r = 0; rgb.g = 0; rgb.b = 0; } - } - return { - r: Math.round(rgb.r), - g: Math.round(rgb.g), - b: Math.round(rgb.b) - }; - } - - // Converts an RGB string to a hex string - function rgbString2hex(rgb){ - rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i); - return (rgb && rgb.length === 4) ? '#' + - ('0' + parseInt(rgb[1],10).toString(16)).slice(-2) + - ('0' + parseInt(rgb[2],10).toString(16)).slice(-2) + - ('0' + parseInt(rgb[3],10).toString(16)).slice(-2) : ''; - } - - // Converts an RGB object to a hex string - function rgb2hex(rgb) { - var hex = [ - rgb.r.toString(16), - rgb.g.toString(16), - rgb.b.toString(16) - ]; - $.each(hex, function(nr, val) { - if(val.length === 1) hex[nr] = '0' + val; - }); - return '#' + hex.join(''); - } - - // Converts an HSB object to a hex string - function hsb2hex(hsb) { - return rgb2hex(hsb2rgb(hsb)); - } - - // Converts a hex string to an HSB object - function hex2hsb(hex) { - var hsb = rgb2hsb(hex2rgb(hex)); - if(hsb.s === 0) hsb.h = 360; - return hsb; - } - - // Converts an RGB object to an HSB object - function rgb2hsb(rgb) { - var hsb = { h: 0, s: 0, b: 0 }; - var min = Math.min(rgb.r, rgb.g, rgb.b); - var max = Math.max(rgb.r, rgb.g, rgb.b); - var delta = max - min; - hsb.b = max; - hsb.s = max !== 0 ? 255 * delta / max : 0; - if(hsb.s !== 0) { - if(rgb.r === max) { - hsb.h = (rgb.g - rgb.b) / delta; - } else if(rgb.g === max) { - hsb.h = 2 + (rgb.b - rgb.r) / delta; - } else { - hsb.h = 4 + (rgb.r - rgb.g) / delta; - } - } else { - hsb.h = -1; - } - hsb.h *= 60; - if(hsb.h < 0) { - hsb.h += 360; - } - hsb.s *= 100/255; - hsb.b *= 100/255; - return hsb; - } - - // Converts a hex string to an RGB object - function hex2rgb(hex) { - hex = parseInt(((hex.indexOf('#') > -1) ? hex.substring(1) : hex), 16); - return { - r: hex >> 16, - g: (hex & 0x00FF00) >> 8, - b: (hex & 0x0000FF) - }; - } - - // Handle events - $([document]) - // Hide on clicks outside of the control - .on('mousedown.minicolors touchstart.minicolors', function(event) { - if(!$(event.target).parents().add(event.target).hasClass('minicolors')) { - hide(); - } - }) - // Start moving - .on('mousedown.minicolors touchstart.minicolors', '.minicolors-grid, .minicolors-slider, .minicolors-opacity-slider', function(event) { - var target = $(this); - event.preventDefault(); - $(event.delegateTarget).data('minicolors-target', target); - move(target, event, true); - }) - // Move pickers - .on('mousemove.minicolors touchmove.minicolors', function(event) { - var target = $(event.delegateTarget).data('minicolors-target'); - if(target) move(target, event); - }) - // Stop moving - .on('mouseup.minicolors touchend.minicolors', function() { - $(this).removeData('minicolors-target'); - }) - // Selected a swatch - .on('click.minicolors', '.minicolors-swatches li', function(event) { - event.preventDefault(); - var target = $(this), input = target.parents('.minicolors').find('.minicolors-input'), color = target.data('swatch-color'); - updateInput(input, color, getAlpha(color)); - updateFromInput(input); - }) - // Show panel when swatch is clicked - .on('mousedown.minicolors touchstart.minicolors', '.minicolors-input-swatch', function(event) { - var input = $(this).parent().find('.minicolors-input'); - event.preventDefault(); - show(input); - }) - // Show on focus - .on('focus.minicolors', '.minicolors-input', function() { - var input = $(this); - if(!input.data('minicolors-initialized')) return; - show(input); - }) - // Update value on blur - .on('blur.minicolors', '.minicolors-input', function() { - var input = $(this); - var settings = input.data('minicolors-settings'); - var keywords; - var hex; - var rgba; - var swatchOpacity; - var value; - - if(!input.data('minicolors-initialized')) return; - - // Get array of lowercase keywords - keywords = !settings.keywords ? [] : $.map(settings.keywords.split(','), function(a) { - return $.trim(a.toLowerCase()); - }); - - // Set color string - if(input.val() !== '' && $.inArray(input.val().toLowerCase(), keywords) > -1) { - value = input.val(); - } else { - // Get RGBA values for easy conversion - if(isRgb(input.val())) { - rgba = parseRgb(input.val(), true); - } else { - hex = parseHex(input.val(), true); - rgba = hex ? hex2rgb(hex) : null; - } - - // Convert to format - if(rgba === null) { - value = settings.defaultValue; - } else if(settings.format === 'rgb') { - value = settings.opacity ? - parseRgb('rgba(' + rgba.r + ',' + rgba.g + ',' + rgba.b + ',' + input.attr('data-opacity') + ')') : - parseRgb('rgb(' + rgba.r + ',' + rgba.g + ',' + rgba.b + ')'); - } else { - value = rgb2hex(rgba); - } - } - - // Update swatch opacity - swatchOpacity = settings.opacity ? input.attr('data-opacity') : 1; - if(value.toLowerCase() === 'transparent') swatchOpacity = 0; - input - .closest('.minicolors') - .find('.minicolors-input-swatch > span') - .css('opacity', swatchOpacity); - - // Set input value - input.val(value); - - // Is it blank? - if(input.val() === '') input.val(parseInput(settings.defaultValue, true)); - - // Adjust case - input.val(convertCase(input.val(), settings.letterCase)); - - }) - // Handle keypresses - .on('keydown.minicolors', '.minicolors-input', function(event) { - var input = $(this); - if(!input.data('minicolors-initialized')) return; - switch(event.keyCode) { - case 9: // tab - hide(); - break; - case 13: // enter - case 27: // esc - hide(); - input.blur(); - break; - } - }) - // Update on keyup - .on('keyup.minicolors', '.minicolors-input', function() { - var input = $(this); - if(!input.data('minicolors-initialized')) return; - updateFromInput(input, true); - }) - // Update on paste - .on('paste.minicolors', '.minicolors-input', function() { - var input = $(this); - if(!input.data('minicolors-initialized')) return; - setTimeout(function() { - updateFromInput(input, true); - }, 1); - }); -})); diff --git a/pageflow.gemspec b/pageflow.gemspec index 3c33243d81..47dcd39ff0 100644 --- a/pageflow.gemspec +++ b/pageflow.gemspec @@ -94,9 +94,6 @@ Gem::Specification.new do |s| # Editor file upload helper s.add_dependency 'jquery-fileupload-rails', '0.4.1' - # Color picker - s.add_dependency 'jquery-minicolors-rails', '~> 2.2' - s.add_dependency 'backbone-rails', '~> 1.0.0' # Further helpers and conventions on top of Backbone diff --git a/rollup.config.js b/rollup.config.js index 9e908816f5..50f53fda3a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -29,7 +29,6 @@ const editorGlobals = { ...frontendGlobals, 'backbone.babysitter': 'Backbone.ChildViewContainer', 'cocktail': 'Cocktail', - 'jquery.minicolors': 'jQuery', 'backbone.marionette': 'Backbone.Marionette', 'wysihtml5': 'wysihtml5' };