From a52bcc452ae4690e7716d0cb583683bb3d0c2786 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 20 Feb 2026 09:45:29 +0100 Subject: [PATCH 1/5] Replace minicolors with custom ColorPicker Replace the jQuery minicolors plugin with a custom per-instance ES6 ColorPicker class. This eliminates the jQuery dependency for color picking and gives us full control over the behavior. Better keyboard and pointer interaction. Better support for colors with alpha component. REDMINE-21228 --- app/assets/stylesheets/pageflow/ui.scss | 3 +- .../stylesheets/pageflow/ui/color_picker.scss | 223 +++++++ .../stylesheets/pageflow/ui/functions.scss | 14 +- .../pageflow/ui/input/color_input.scss | 26 +- .../src/contentElements/inlineAudio/editor.js | 1 + .../editor/views/inputs/StyleListInputView.js | 28 +- .../inputs/StyleListInputView.module.css | 20 +- package/spec/ui/views/ColorPicker-spec.js | 511 +++++++++++++++ .../ui/views/inputs/ColorInputView-spec.js | 17 +- .../dominos/ui/inputs/ColorInput.js | 14 +- package/src/ui/views/ColorPicker.js | 609 ++++++++++++++++++ package/src/ui/views/inputs/ColorInputView.js | 82 +-- 12 files changed, 1464 insertions(+), 84 deletions(-) create mode 100644 app/assets/stylesheets/pageflow/ui/color_picker.scss create mode 100644 package/spec/ui/views/ColorPicker-spec.js create mode 100644 package/src/ui/views/ColorPicker.js diff --git a/app/assets/stylesheets/pageflow/ui.scss b/app/assets/stylesheets/pageflow/ui.scss index 18ad56a5b7..99445f03bf 100644 --- a/app/assets/stylesheets/pageflow/ui.scss +++ b/app/assets/stylesheets/pageflow/ui.scss @@ -7,8 +7,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/package/src/contentElements/inlineAudio/editor.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js index ba58fd831e..183d2633b0 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js @@ -54,6 +54,7 @@ editor.contentElementTypes.register('inlineAudio', { }); this.input('waveformColor', ColorInputView, { + alpha: true, visibleBinding: 'playerControlVariant', visible: variant => variant?.startsWith('waveform'), defaultValue: themeOptions.properties?.root?.accent_color || 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/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..458beda785 --- /dev/null +++ b/package/src/ui/views/ColorPicker.js @@ -0,0 +1,609 @@ +// 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) => { + 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; } }); From b369bec83ea358341aecec915ccf4d70295d5222 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 24 Feb 2026 10:32:41 +0100 Subject: [PATCH 2/5] Re-render typography previews on binding change Avoid full configuration editor refresh when only the preview content (e.g. exampleNode text) changes. Only refresh when the node type changes since that affects which typography variants are available. REDMINE-21228 --- .../TypographyVariantSelectInputView-spec.js | 68 +++++++++++++++++++ .../src/contentElements/textBlock/editor.js | 43 +++++------- .../groups/CommonContentElementAttributes.js | 14 +++- .../TypographyVariantSelectInputView.js | 18 ++++- 4 files changed, 114 insertions(+), 29 deletions(-) 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/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/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/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 ( - From 24625207e3d39382452ef38a8bccdff9a4464606 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 24 Feb 2026 10:32:41 +0100 Subject: [PATCH 3/5] Remove jquery-minicolors dependency No longer needed since the custom ColorPicker replaced it. Also fix color picker closing when clicking the gradient area a second time by preventing default on pointerdown. REDMINE-21228 --- .../javascripts/pageflow/editor/vendor.js | 1 - app/assets/javascripts/pageflow/ui.js | 1 - app/assets/stylesheets/pageflow/ui.scss | 1 - lib/pageflow/engine.rb | 1 - package/config/jest/index.js | 1 - package/config/webpack.js | 1 - package/config/webpack5.js | 1 - package/src/ui/views/ColorPicker.js | 1 + package/vendor/jquery.minicolors.js | 1108 ----------------- pageflow.gemspec | 3 - rollup.config.js | 1 - 11 files changed, 1 insertion(+), 1119 deletions(-) delete mode 100644 package/vendor/jquery.minicolors.js 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 99445f03bf..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"; 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/src/ui/views/ColorPicker.js b/package/src/ui/views/ColorPicker.js index 458beda785..7aa0913f19 100644 --- a/package/src/ui/views/ColorPicker.js +++ b/package/src/ui/views/ColorPicker.js @@ -326,6 +326,7 @@ export default class ColorPicker { }; this._onAreaPointerdown = (event) => { + event.preventDefault(); this._colorArea.setPointerCapture(event.pointerId); this._dragging = true; }; 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' }; From 5a8eafdc0f7f7f19885ef9a7a7dafe97cc47a1a3 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 24 Feb 2026 15:00:01 +0100 Subject: [PATCH 4/5] Customizable waveform colors Let users configure remaining waveform and cursor colors in addition to the existing progress color. Color pickers show swatches of colors already used across content elements. Default colors adapt to the section's invert setting. REDMINE-21228 Waveform --- entry_types/scrolled/config/locales/de.yml | 10 +++- entry_types/scrolled/config/locales/en.yml | 12 ++++- .../getUsedContentElementColors-spec.js | 46 +++++++++++++++++++ .../src/contentElements/inlineAudio/editor.js | 34 +++++++++++++- .../contentElements/inlineAudio/stories.js | 9 ++++ .../src/editor/models/ScrolledEntry/index.js | 10 ++++ .../src/frontend/MediaPlayerControls.js | 2 + .../WaveformPlayerControls/Waveform.js | 29 ++++++------ .../WaveformPlayerControls/Wavesurfer.js | 5 ++ .../WaveformPlayerControls/defaultColors.js | 4 ++ .../WaveformPlayerControls/index.js | 4 +- 11 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/ScrolledEntry/getUsedContentElementColors-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/defaultColors.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 88f24e323f..0f15e83a5c 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -658,9 +658,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..816deae250 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -643,9 +643,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/src/contentElements/inlineAudio/editor.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js index 183d2633b0..ba61e826d7 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, { @@ -58,7 +67,28 @@ editor.contentElementTypes.register('inlineAudio', { 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.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..6dac0443bc 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js @@ -62,6 +62,15 @@ storiesOfContentElement(module, { posterId: filePermaId('imageFiles', 'turtle'), caption: 'Some caption' } + }, + { + name: 'with waveform and custom colors', + configuration: { + playerControlVariant: 'waveform', + waveformColor: '#1963ad', + remainingWaveformColor: '#e2ad1a', + waveformCursorColor: '#ff0000' + } } ], inlineFileRights: true 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/frontend/MediaPlayerControls.js b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js index e25436e8f4..a59f7441fd 100644 --- a/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js +++ b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js @@ -23,6 +23,8 @@ export function MediaPlayerControls(props) { variant={props.configuration.playerControlVariant} waveformColor={props.configuration.waveformColor} + remainingWaveformColor={props.configuration.remainingWaveformColor} + waveformCursorColor={props.configuration.waveformCursorColor} 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/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..1460965530 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js @@ -29,7 +29,9 @@ 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} From a9c43a9d7e3c7db9885121e6970a8bc373522e1c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 24 Feb 2026 15:27:01 +0100 Subject: [PATCH 5/5] Add invert play button option for waveform Let users toggle to a dark play button on waveform player controls. The default white button gets a subtle drop shadow for better visibility. The inverted variant removes the shadow. REDMINE-21228 --- entry_types/scrolled/config/locales/de.yml | 3 +++ entry_types/scrolled/config/locales/en.yml | 3 +++ .../src/contentElements/inlineAudio/editor.js | 5 +++++ .../src/contentElements/inlineAudio/stories.js | 3 ++- .../package/src/frontend/MediaPlayerControls.js | 1 + .../WaveformPlayerControls/Waveform.module.css | 15 +++++++++++++++ .../WaveformPlayerControls/index.js | 3 ++- 7 files changed, 31 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 0f15e83a5c..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 diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 816deae250..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 diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js index ba61e826d7..edc0432925 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/editor.js @@ -91,6 +91,11 @@ editor.contentElementTypes.register('inlineAudio', { swatches: entry.getUsedContentElementColors('waveformCursorColor') }); + this.input('invertPlayButton', CheckBoxInputView, { + visibleBinding: 'playerControlVariant', + visible: variant => variant?.startsWith('waveform') + }); + this.view(SeparatorView); this.group('ContentElementPosition', {entry}); diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js index 6dac0443bc..fa5410cddf 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js @@ -69,7 +69,8 @@ storiesOfContentElement(module, { playerControlVariant: 'waveform', waveformColor: '#1963ad', remainingWaveformColor: '#e2ad1a', - waveformCursorColor: '#ff0000' + waveformCursorColor: '#ff0000', + invertPlayButton: true } } ], diff --git a/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js index a59f7441fd..229a31930d 100644 --- a/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js +++ b/entry_types/scrolled/package/src/frontend/MediaPlayerControls.js @@ -25,6 +25,7 @@ export function MediaPlayerControls(props) { 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.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/index.js b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js index 1460965530..6ca00fbe29 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js @@ -37,7 +37,8 @@ export function WaveformPlayerControls(props) { play={props.play} pause={props.pause} mediaElementId={props.mediaElementId} /> -
    +