From 9e43e03db4fbbb95f0219124162c9318c9c31531 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 12 Jun 2024 19:51:42 +0100 Subject: [PATCH] Lexical: Added color picker controls --- .../wysiwyg/ui/defaults/button-definitions.ts | 11 +- .../ui/framework/blocks/color-picker.ts | 90 ++++++++++++++ .../ui/framework/blocks/dropdown-button.ts | 51 ++++++++ .../ui/framework/blocks/format-menu.ts | 47 ++++++++ .../framework/blocks/format-preview-button.ts | 47 ++++++++ resources/js/wysiwyg/ui/framework/buttons.ts | 50 +------- .../js/wysiwyg/ui/framework/containers.ts | 113 ------------------ resources/js/wysiwyg/ui/framework/core.ts | 49 +++++++- resources/js/wysiwyg/ui/framework/forms.ts | 3 +- .../wysiwyg/ui/framework/helpers/dropdowns.ts | 34 ++++++ resources/js/wysiwyg/ui/framework/manager.ts | 9 +- resources/js/wysiwyg/ui/framework/modals.ts | 2 +- resources/js/wysiwyg/ui/toolbars.ts | 18 ++- resources/sass/_editor.scss | 16 +++ 14 files changed, 367 insertions(+), 173 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/color-picker.ts create mode 100644 resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts create mode 100644 resources/js/wysiwyg/ui/framework/blocks/format-menu.ts create mode 100644 resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts delete mode 100644 resources/js/wysiwyg/ui/framework/containers.ts create mode 100644 resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 2e7cc6821..d8c7f515c 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,11 +1,11 @@ -import {EditorButtonDefinition} from "../framework/buttons"; +import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, $createParagraphNode, $getRoot, $getSelection, $insertNodes, $isParagraphNode, $isTextNode, $setSelection, BaseSelection, ElementNode, FORMAT_TEXT_COMMAND, LexicalNode, - REDO_COMMAND, TextFormatType, + REDO_COMMAND, TextFormatType, TextNode, UNDO_COMMAND } from "lexical"; import { @@ -131,8 +131,9 @@ function buildFormatButton(label: string, format: TextFormatType): EditorButtonD export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold'); export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic'); export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline'); -// Todo - Text color -// Todo - Highlight color +export const textColor: EditorBasicButtonDefinition = {label: 'Text color'}; +export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color'}; + export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough'); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript'); export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript'); @@ -256,4 +257,4 @@ export const source: EditorButtonDefinition = { isActive() { return false; } -}; +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts new file mode 100644 index 000000000..6972d7a8e --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -0,0 +1,90 @@ +import {el} from "../../../helpers"; +import {EditorUiElement} from "../core"; +import {$getSelection} from "lexical"; +import {$patchStyleText} from "@lexical/selection"; + +const colorChoices = [ + '#000000', + '#ffffff', + + '#BFEDD2', + '#FBEEB8', + '#F8CAC6', + '#ECCAFA', + '#C2E0F4', + + '#2DC26B', + '#F1C40F', + '#E03E2D', + '#B96AD9', + '#3598DB', + + '#169179', + '#E67E23', + '#BA372A', + '#843FA1', + '#236FA1', + + '#ECF0F1', + '#CED4D9', + '#95A5A6', + '#7E8C8D', + '#34495E', +]; + +export class EditorColorPicker extends EditorUiElement { + + protected styleProperty: string; + + constructor(styleProperty: string) { + super(); + this.styleProperty = styleProperty; + } + + buildDOM(): HTMLElement { + + const colorOptions = colorChoices.map(choice => { + return el('div', { + class: 'editor-color-select-option', + style: `background-color: ${choice}`, + 'data-color': choice, + 'aria-label': choice, + }); + }); + + colorOptions.push(el('div', { + class: 'editor-color-select-option', + 'data-color': '', + title: 'Clear color', + }, ['x'])); + + const colorRows = []; + for (let i = 0; i < colorOptions.length; i+=5) { + const options = colorOptions.slice(i, i + 5); + colorRows.push(el('div', { + class: 'editor-color-select-row', + }, options)); + } + + const wrapper = el('div', { + class: 'editor-color-select', + }, colorRows); + + wrapper.addEventListener('click', this.onClick.bind(this)); + + return wrapper; + } + + onClick(event: MouseEvent) { + const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement; + if (!colorEl) return; + + const color = colorEl.dataset.color as string; + this.getContext().editor.update(() => { + const selection = $getSelection(); + if (selection) { + $patchStyleText(selection, {[this.styleProperty]: color || null}); + } + }); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts new file mode 100644 index 000000000..199c7728d --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -0,0 +1,51 @@ +import {el} from "../../../helpers"; +import {handleDropdown} from "../helpers/dropdowns"; +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; + +export class EditorDropdownButton extends EditorContainerUiElement { + protected button: EditorButton; + protected childItems: EditorUiElement[]; + protected open: boolean = false; + + constructor(buttonDefinition: EditorBasicButtonDefinition, children: EditorUiElement[]) { + super(children); + this.childItems = children + + this.button = new EditorButton({ + ...buttonDefinition, + action() { + return false; + }, + isActive: () => { + return this.open; + } + }); + + this.children.push(this.button); + } + + protected buildDOM(): HTMLElement { + const button = this.button.getDOMElement(); + + const childElements: HTMLElement[] = this.childItems.map(child => child.getDOMElement()); + const menu = el('div', { + class: 'editor-dropdown-menu', + hidden: 'true', + }, childElements); + + const wrapper = el('div', { + class: 'editor-dropdown-menu-container', + }, [button, menu]); + + handleDropdown(button, menu, () => { + this.open = true; + this.getContext().manager.triggerStateUpdate(this.button); + }, () => { + this.open = false; + this.getContext().manager.triggerStateUpdate(this.button); + }); + + return wrapper; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts new file mode 100644 index 000000000..bcd61e45c --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts @@ -0,0 +1,47 @@ +import {el} from "../../../helpers"; +import {EditorUiStateUpdate, EditorContainerUiElement} from "../core"; +import {EditorButton} from "../buttons"; +import {handleDropdown} from "../helpers/dropdowns"; + +export class EditorFormatMenu extends EditorContainerUiElement { + buildDOM(): HTMLElement { + const childElements: HTMLElement[] = this.getChildren().map(child => child.getDOMElement()); + const menu = el('div', { + class: 'editor-format-menu-dropdown editor-dropdown-menu editor-menu-list', + hidden: 'true', + }, childElements); + + const toggle = el('button', { + class: 'editor-format-menu-toggle editor-button', + type: 'button', + }, [this.trans('Formats')]); + + const wrapper = el('div', { + class: 'editor-format-menu editor-dropdown-menu-container', + }, [toggle, menu]); + + handleDropdown(toggle, menu); + + return wrapper; + } + + updateState(state: EditorUiStateUpdate) { + super.updateState(state); + + for (const child of this.children) { + if (child instanceof EditorButton && child.isActive()) { + this.updateToggleLabel(child.getLabel()); + return; + } + } + + this.updateToggleLabel(this.trans('Formats')); + } + + protected updateToggleLabel(text: string): void { + const button = this.getDOMElement().querySelector('button'); + if (button) { + button.innerText = text; + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts new file mode 100644 index 000000000..f83035aa6 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts @@ -0,0 +1,47 @@ +import {el} from "../../../helpers"; +import {EditorButton, EditorButtonDefinition} from "../buttons"; + +export class FormatPreviewButton extends EditorButton { + protected previewSampleElement: HTMLElement; + + constructor(previewSampleElement: HTMLElement,definition: EditorButtonDefinition) { + super(definition); + this.previewSampleElement = previewSampleElement; + } + + protected buildDOM(): HTMLButtonElement { + const button = super.buildDOM(); + button.innerHTML = ''; + + const preview = el('span', { + class: 'editor-button-format-preview' + }, [this.getLabel()]); + + const stylesToApply = this.getStylesFromPreview(); + for (const style of Object.keys(stylesToApply)) { + preview.style.setProperty(style, stylesToApply[style]); + } + + button.append(preview); + return button; + } + + protected getStylesFromPreview(): Record { + const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); + const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; + sampleClone.textContent = this.getLabel(); + wrap.append(sampleClone); + document.body.append(wrap); + + const propertiesToFetch = ['color', 'font-size', 'background-color', 'border-inline-start']; + const propertiesToReturn: Record = {}; + + const computed = window.getComputedStyle(sampleClone); + for (const property of propertiesToFetch) { + propertiesToReturn[property] = computed.getPropertyValue(property); + } + wrap.remove(); + + return propertiesToReturn; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 367a39330..c3ba533b3 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -2,8 +2,11 @@ import {BaseSelection} from "lexical"; import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; -export interface EditorButtonDefinition { +export interface EditorBasicButtonDefinition { label: string; +} + +export interface EditorButtonDefinition extends EditorBasicButtonDefinition { action: (context: EditorUiContext) => void; isActive: (selection: BaseSelection|null) => boolean; } @@ -49,48 +52,3 @@ export class EditorButton extends EditorUiElement { return this.trans(this.definition.label); } } - -export class FormatPreviewButton extends EditorButton { - protected previewSampleElement: HTMLElement; - - constructor(previewSampleElement: HTMLElement,definition: EditorButtonDefinition) { - super(definition); - this.previewSampleElement = previewSampleElement; - } - - protected buildDOM(): HTMLButtonElement { - const button = super.buildDOM(); - button.innerHTML = ''; - - const preview = el('span', { - class: 'editor-button-format-preview' - }, [this.getLabel()]); - - const stylesToApply = this.getStylesFromPreview(); - for (const style of Object.keys(stylesToApply)) { - preview.style.setProperty(style, stylesToApply[style]); - } - - button.append(preview); - return button; - } - - protected getStylesFromPreview(): Record { - const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); - const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; - sampleClone.textContent = this.getLabel(); - wrap.append(sampleClone); - document.body.append(wrap); - - const propertiesToFetch = ['color', 'font-size', 'background-color', 'border-inline-start']; - const propertiesToReturn: Record = {}; - - const computed = window.getComputedStyle(sampleClone); - for (const property of propertiesToFetch) { - propertiesToReturn[property] = computed.getPropertyValue(property); - } - wrap.remove(); - - return propertiesToReturn; - } -} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/containers.ts b/resources/js/wysiwyg/ui/framework/containers.ts deleted file mode 100644 index ed191a882..000000000 --- a/resources/js/wysiwyg/ui/framework/containers.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; -import {el} from "../../helpers"; -import {EditorButton} from "./buttons"; - -export class EditorContainerUiElement extends EditorUiElement { - protected children : EditorUiElement[]; - - constructor(children: EditorUiElement[]) { - super(); - this.children = children; - } - - protected buildDOM(): HTMLElement { - return el('div', {}, this.getChildren().map(child => child.getDOMElement())); - } - - getChildren(): EditorUiElement[] { - return this.children; - } - - updateState(state: EditorUiStateUpdate): void { - for (const child of this.children) { - child.updateState(state); - } - } - - setContext(context: EditorUiContext) { - super.setContext(context); - for (const child of this.getChildren()) { - child.setContext(context); - } - } -} - -export class EditorSimpleClassContainer extends EditorContainerUiElement { - protected className; - - constructor(className: string, children: EditorUiElement[]) { - super(children); - this.className = className; - } - - protected buildDOM(): HTMLElement { - return el('div', { - class: this.className, - }, this.getChildren().map(child => child.getDOMElement())); - } -} - -export class EditorFormatMenu extends EditorContainerUiElement { - buildDOM(): HTMLElement { - const childElements: HTMLElement[] = this.getChildren().map(child => child.getDOMElement()); - const menu = el('div', { - class: 'editor-format-menu-dropdown editor-dropdown-menu editor-menu-list', - hidden: 'true', - }, childElements); - - const toggle = el('button', { - class: 'editor-format-menu-toggle editor-button', - type: 'button', - }, [this.trans('Formats')]); - - const wrapper = el('div', { - class: 'editor-format-menu editor-dropdown-menu-container', - }, [toggle, menu]); - - let clickListener: Function|null = null; - - const hide = () => { - menu.hidden = true; - if (clickListener) { - window.removeEventListener('click', clickListener as EventListener); - } - }; - - const show = () => { - menu.hidden = false - clickListener = (event: MouseEvent) => { - if (!wrapper.contains(event.target as HTMLElement)) { - hide(); - } - } - window.addEventListener('click', clickListener as EventListener); - }; - - toggle.addEventListener('click', event => { - menu.hasAttribute('hidden') ? show() : hide(); - }); - menu.addEventListener('mouseleave', hide); - - return wrapper; - } - - updateState(state: EditorUiStateUpdate) { - super.updateState(state); - - for (const child of this.children) { - if (child instanceof EditorButton && child.isActive()) { - this.updateToggleLabel(child.getLabel()); - return; - } - } - - this.updateToggleLabel(this.trans('Formats')); - } - - protected updateToggleLabel(text: string): void { - const button = this.getDOMElement().querySelector('button'); - if (button) { - button.innerText = text; - } - } -} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 2fdadcb40..d437b36bd 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -1,5 +1,6 @@ import {BaseSelection, LexicalEditor} from "lexical"; import {EditorUIManager} from "./manager"; +import {el} from "../../helpers"; export type EditorUiStateUpdate = { editor: LexicalEditor, @@ -46,4 +47,50 @@ export abstract class EditorUiElement { updateState(state: EditorUiStateUpdate): void { return; } -} \ No newline at end of file +} + +export class EditorContainerUiElement extends EditorUiElement { + protected children : EditorUiElement[]; + + constructor(children: EditorUiElement[]) { + super(); + this.children = children; + } + + protected buildDOM(): HTMLElement { + return el('div', {}, this.getChildren().map(child => child.getDOMElement())); + } + + getChildren(): EditorUiElement[] { + return this.children; + } + + updateState(state: EditorUiStateUpdate): void { + for (const child of this.children) { + child.updateState(state); + } + } + + setContext(context: EditorUiContext) { + super.setContext(context); + for (const child of this.getChildren()) { + child.setContext(context); + } + } +} + +export class EditorSimpleClassContainer extends EditorContainerUiElement { + protected className; + + constructor(className: string, children: EditorUiElement[]) { + super(children); + this.className = className; + } + + protected buildDOM(): HTMLElement { + return el('div', { + class: this.className, + }, this.getChildren().map(child => child.getDOMElement())); + } +} + diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index a7fcb45ba..4fee787d3 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -1,5 +1,4 @@ -import {EditorUiContext, EditorUiElement} from "./core"; -import {EditorContainerUiElement} from "./containers"; +import {EditorUiContext, EditorUiElement, EditorContainerUiElement} from "./core"; import {el} from "../../helpers"; export interface EditorFormFieldDefinition { diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts new file mode 100644 index 000000000..35886d2f9 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -0,0 +1,34 @@ + + + +export function handleDropdown(toggle: HTMLElement, menu: HTMLElement, onOpen: Function|undefined = undefined, onClose: Function|undefined = undefined) { + let clickListener: Function|null = null; + + const hide = () => { + menu.hidden = true; + if (clickListener) { + window.removeEventListener('click', clickListener as EventListener); + } + if (onClose) { + onClose(); + } + }; + + const show = () => { + menu.hidden = false + clickListener = (event: MouseEvent) => { + if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { + hide(); + } + } + window.addEventListener('click', clickListener as EventListener); + if (onOpen) { + onOpen(); + } + }; + + toggle.addEventListener('click', event => { + menu.hasAttribute('hidden') ? show() : hide(); + }); + menu.addEventListener('mouseleave', hide); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 1684b6628..78ddc8ce3 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,5 +1,5 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; -import {EditorUiContext} from "./core"; +import {EditorUiContext, EditorUiElement} from "./core"; import {EditorDecorator} from "./decorator"; @@ -22,6 +22,13 @@ export class EditorUIManager { return this.context; } + triggerStateUpdate(element: EditorUiElement) { + element.updateState({ + selection: null, + editor: this.getContext().editor + }); + } + registerModal(key: string, modalDefinition: EditorFormModalDefinition) { this.modalDefinitionsByKey[key] = modalDefinition; } diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index e2a6b3f33..bfc5fc619 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -1,6 +1,6 @@ import {EditorForm, EditorFormDefinition} from "./forms"; import {el} from "../../helpers"; -import {EditorContainerUiElement} from "./containers"; +import {EditorContainerUiElement} from "./core"; export interface EditorModalDefinition { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 337266617..de90a1d70 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,16 +1,20 @@ -import {EditorButton, FormatPreviewButton} from "./framework/buttons"; +import {EditorButton} from "./framework/buttons"; import { blockquote, bold, clearFormating, code, dangerCallout, details, - h2, h3, h4, h5, image, + h2, h3, h4, h5, highlightColor, image, infoCallout, italic, link, paragraph, redo, source, strikethrough, subscript, - successCallout, superscript, underline, + successCallout, superscript, textColor, underline, undo, warningCallout } from "./defaults/button-definitions"; -import {EditorContainerUiElement, EditorFormatMenu, EditorSimpleClassContainer} from "./framework/containers"; +import {EditorContainerUiElement, EditorSimpleClassContainer} from "./framework/core"; import {el} from "../helpers"; +import {EditorFormatMenu} from "./framework/blocks/format-menu"; +import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; +import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; +import {EditorColorPicker} from "./framework/blocks/color-picker"; export function getMainEditorFullToolbar(): EditorContainerUiElement { @@ -37,6 +41,12 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(bold), new EditorButton(italic), new EditorButton(underline), + new EditorDropdownButton(textColor, [ + new EditorColorPicker('color'), + ]), + new EditorDropdownButton(highlightColor, [ + new EditorColorPicker('background-color'), + ]), new EditorButton(strikethrough), new EditorButton(superscript), new EditorButton(subscript), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 87cc70c9b..b98e624bd 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -79,6 +79,22 @@ font-weight: 700; } +// Specific UI elements +.editor-color-select-row { + display: flex; +} +.editor-color-select-option { + width: 28px; + height: 28px; + cursor: pointer; +} +.editor-color-select-option:hover { + border-radius: 3px; + box-sizing: border-box; + z-index: 3; + box-shadow: 0 0 4px 1px rgba(0, 0, 0, 0.25); +} + // In-editor elements .editor-image-wrap { position: relative;