]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Added color picker controls
authorDan Brown <redacted>
Wed, 12 Jun 2024 18:51:42 +0000 (19:51 +0100)
committerDan Brown <redacted>
Wed, 12 Jun 2024 18:51:42 +0000 (19:51 +0100)
14 files changed:
resources/js/wysiwyg/ui/defaults/button-definitions.ts
resources/js/wysiwyg/ui/framework/blocks/color-picker.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/framework/blocks/format-menu.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/framework/buttons.ts
resources/js/wysiwyg/ui/framework/containers.ts [deleted file]
resources/js/wysiwyg/ui/framework/core.ts
resources/js/wysiwyg/ui/framework/forms.ts
resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/framework/manager.ts
resources/js/wysiwyg/ui/framework/modals.ts
resources/js/wysiwyg/ui/toolbars.ts
resources/sass/_editor.scss

index 2e7cc6821e5494119bf0b3b1e6fdff8bd9bccf84..d8c7f515cca7c2855ed750869f98545bfd67b900 100644 (file)
@@ -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 (file)
index 0000000..6972d7a
--- /dev/null
@@ -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 (file)
index 0000000..199c772
--- /dev/null
@@ -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 (file)
index 0000000..bcd61e4
--- /dev/null
@@ -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 (file)
index 0000000..f83035a
--- /dev/null
@@ -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<string, string> {
+        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<string, string> = {};
+
+        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
index 367a3933063523aa10af4bd7349ade84e18bf5e1..c3ba533b32534e2dccd378d9f64fdc63a0b4f151 100644 (file)
@@ -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<string, string> {
-        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<string, string> = {};
-
-        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 (file)
index ed191a8..0000000
+++ /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
index 2fdadcb40c9835678e747fffa1b828de65dc1f5b..d437b36bd0936d47f1be92507592fb220ae72619 100644 (file)
@@ -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()));
+    }
+}
+
index a7fcb45ba6cf77566ccdbe4b03c34873929a273e..4fee787d35408f84f937d44417f34f35f3eed7ea 100644 (file)
@@ -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 (file)
index 0000000..35886d2
--- /dev/null
@@ -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
index 1684b6628134bbfdaa00edd070bd5d546a1122b6..78ddc8ce3d63329f965b6673fdb6b668a758cd3a 100644 (file)
@@ -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;
     }
index e2a6b3f33eb98733a88c6b0edf6eea23ea3fcfa1..bfc5fc619bf302fc54f59b4d4f90d0cb2bf4a158 100644 (file)
@@ -1,6 +1,6 @@
 import {EditorForm, EditorFormDefinition} from "./forms";
 import {el} from "../../helpers";
-import {EditorContainerUiElement} from "./containers";
+import {EditorContainerUiElement} from "./core";
 
 
 export interface EditorModalDefinition {
index 337266617396fb12524c9c81a4d90d4ebfbf284e..de90a1d70b338bc0bc91bf00a39c4285772c224d 100644 (file)
@@ -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),
index 87cc70c9bf3e2e1610223f06ccd5b09f46219e0d..b98e624bdf3f0fd92ceec1945ce0266a0187ec97 100644 (file)
   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;