diff --git a/package-lock.json b/package-lock.json index 2b6b677c2..2cddccb59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@codemirror/view": "^6.22.2", "@lexical/history": "^0.15.0", "@lexical/html": "^0.15.0", + "@lexical/link": "^0.15.0", "@lexical/rich-text": "^0.15.0", "@lexical/selection": "^0.15.0", "@lexical/utils": "^0.15.0", @@ -729,6 +730,15 @@ "lexical": "0.15.0" } }, + "node_modules/@lexical/link": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.15.0.tgz", + "integrity": "sha512-KBV/zWk5FxqZGNcq3IKGBDCcS4t0uteU1osAIG+pefo4waTkOOgibxxEJDop2QR5wtjkYva3Qp0D8ZyJDMMMlw==", + "dependencies": { + "@lexical/utils": "0.15.0", + "lexical": "0.15.0" + } + }, "node_modules/@lexical/list": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.15.0.tgz", diff --git a/package.json b/package.json index ca0f01f17..d9fa89c18 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@codemirror/view": "^6.22.2", "@lexical/history": "^0.15.0", "@lexical/html": "^0.15.0", + "@lexical/link": "^0.15.0", "@lexical/rich-text": "^0.15.0", "@lexical/selection": "^0.15.0", "@lexical/utils": "^0.15.0", diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 737666ffa..7218f1ae6 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -3,13 +3,29 @@ import { $getSelection, $isTextNode, BaseSelection, - ElementFormatType, LexicalEditor, TextFormatType } from "lexical"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$setBlocksType} from "@lexical/selection"; -import {TextNodeThemeClasses} from "lexical/LexicalEditor"; + +export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { + const el = document.createElement(tag); + const attrKeys = Object.keys(attrs); + for (const attr of attrKeys) { + el.setAttribute(attr, attrs[attr]); + } + + for (const child of children) { + if (typeof child === 'string') { + el.append(document.createTextNode(child)); + } else { + el.append(child); + } + } + + return el; +} export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { if (!selection) { diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index ffe1b027f..9f772df1e 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -2,6 +2,7 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; import {CustomParagraphNode} from "./custom-paragraph"; +import {LinkNode} from "@lexical/link"; /** * Load the nodes for lexical. @@ -17,7 +18,8 @@ export function getNodesForPageEditor(): (KlassConstructor | with: (node: ParagraphNode) => { return new CustomParagraphNode(); } - } + }, + LinkNode, ]; } diff --git a/resources/js/wysiwyg/ui/buttons.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts similarity index 57% rename from resources/js/wysiwyg/ui/buttons.ts rename to resources/js/wysiwyg/ui/defaults/button-definitions.ts index cf5660ef0..874f632fe 100644 --- a/resources/js/wysiwyg/ui/buttons.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,4 +1,4 @@ -import {EditorButtonDefinition} from "./editor-button"; +import {EditorButtonDefinition} from "../framework/buttons"; import { $createParagraphNode, $isParagraphNode, @@ -8,8 +8,8 @@ import { REDO_COMMAND, TextFormatType, UNDO_COMMAND } from "lexical"; -import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../helpers"; -import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../nodes/callout"; +import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../../helpers"; +import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout"; import { $createHeadingNode, $createQuoteNode, @@ -18,21 +18,22 @@ import { HeadingNode, HeadingTagType } from "@lexical/rich-text"; +import {$isLinkNode, $toggleLink} from "@lexical/link"; -export const undoButton: EditorButtonDefinition = { +export const undo: EditorButtonDefinition = { label: 'Undo', action(editor: LexicalEditor) { - editor.dispatchCommand(UNDO_COMMAND); + editor.dispatchCommand(UNDO_COMMAND, undefined); }, isActive(selection: BaseSelection|null): boolean { return false; } } -export const redoButton: EditorButtonDefinition = { +export const redo: EditorButtonDefinition = { label: 'Redo', action(editor: LexicalEditor) { - editor.dispatchCommand(REDO_COMMAND); + editor.dispatchCommand(REDO_COMMAND, undefined); }, isActive(selection: BaseSelection|null): boolean { return false; @@ -55,10 +56,10 @@ function buildCalloutButton(category: CalloutCategory, name: string): EditorButt }; } -export const infoCalloutButton: EditorButtonDefinition = buildCalloutButton('info', 'Info'); -export const dangerCalloutButton: EditorButtonDefinition = buildCalloutButton('danger', 'Danger'); -export const warningCalloutButton: EditorButtonDefinition = buildCalloutButton('warning', 'Warning'); -export const successCalloutButton: EditorButtonDefinition = buildCalloutButton('success', 'Success'); +export const infoCallout: EditorButtonDefinition = buildCalloutButton('info', 'Info'); +export const dangerCallout: EditorButtonDefinition = buildCalloutButton('danger', 'Danger'); +export const warningCallout: EditorButtonDefinition = buildCalloutButton('warning', 'Warning'); +export const successCallout: EditorButtonDefinition = buildCalloutButton('success', 'Success'); const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { return $isHeadingNode(node) && (node as HeadingNode).getTag() === tag; @@ -80,12 +81,12 @@ function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefin }; } -export const h2Button: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header'); -export const h3Button: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header'); -export const h4Button: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header'); -export const h5Button: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header'); +export const h2: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header'); +export const h3: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header'); +export const h4: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header'); +export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header'); -export const blockquoteButton: EditorButtonDefinition = { +export const blockquote: EditorButtonDefinition = { label: 'Blockquote', action(editor: LexicalEditor) { toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode); @@ -95,7 +96,7 @@ export const blockquoteButton: EditorButtonDefinition = { } }; -export const paragraphButton: EditorButtonDefinition = { +export const paragraph: EditorButtonDefinition = { label: 'Paragraph', action(editor: LexicalEditor) { toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode); @@ -117,13 +118,27 @@ function buildFormatButton(label: string, format: TextFormatType): EditorButtonD }; } -export const boldButton: EditorButtonDefinition = buildFormatButton('Bold', 'bold'); -export const italicButton: EditorButtonDefinition = buildFormatButton('Italic', 'italic'); -export const underlineButton: EditorButtonDefinition = buildFormatButton('Underline', 'underline'); +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 strikethroughButton: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough'); -export const superscriptButton: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript'); -export const subscriptButton: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript'); -export const codeButton: EditorButtonDefinition = buildFormatButton('Inline Code', 'code'); -// Todo - Clear formatting \ No newline at end of file +export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough'); +export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript'); +export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript'); +export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code'); +// Todo - Clear formatting + + +export const link: EditorButtonDefinition = { + label: 'Insert/edit link', + action(editor: LexicalEditor) { + editor.update(() => { + $toggleLink('http://example.com'); + }) + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, $isLinkNode); + } +}; + diff --git a/resources/js/wysiwyg/ui/editor-button.ts b/resources/js/wysiwyg/ui/editor-button.ts deleted file mode 100644 index 2ce272fce..000000000 --- a/resources/js/wysiwyg/ui/editor-button.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {BaseSelection, LexicalEditor} from "lexical"; - -export interface EditorButtonDefinition { - label: string; - action: (editor: LexicalEditor) => void; - isActive: (selection: BaseSelection|null) => boolean; -} - -export class EditorButton { - #definition: EditorButtonDefinition; - #editor: LexicalEditor; - #dom: HTMLButtonElement; - - constructor(definition: EditorButtonDefinition, editor: LexicalEditor) { - this.#definition = definition; - this.#editor = editor; - this.#dom = this.buildDOM(); - } - - private buildDOM(): HTMLButtonElement { - const button = document.createElement("button"); - button.setAttribute('type', 'button'); - button.textContent = this.#definition.label; - button.classList.add('editor-toolbar-button'); - - button.addEventListener('click', event => { - this.runAction(); - }); - - return button; - } - - getDOMElement(): HTMLButtonElement { - return this.#dom; - } - - runAction() { - this.#definition.action(this.#editor); - } - - updateActiveState(selection: BaseSelection|null) { - const isActive = this.#definition.isActive(selection); - this.#dom.classList.toggle('editor-toolbar-button-active', isActive); - } -} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/base-elements.ts b/resources/js/wysiwyg/ui/framework/base-elements.ts new file mode 100644 index 000000000..665011782 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/base-elements.ts @@ -0,0 +1,39 @@ +import {BaseSelection, LexicalEditor} from "lexical"; + +export type EditorUiStateUpdate = { + editor: LexicalEditor, + selection: BaseSelection|null, +}; + +export type EditorUiContext = { + editor: LexicalEditor, +}; + +export abstract class EditorUiElement { + protected dom: HTMLElement|null = null; + private context: EditorUiContext|null = null; + + protected abstract buildDOM(): HTMLElement; + + setContext(context: EditorUiContext): void { + this.context = context; + } + + getContext(): EditorUiContext { + if (this.context === null) { + throw new Error('Attempted to use EditorUIContext before it has been set'); + } + + return this.context; + } + + getDOMElement(): HTMLElement { + if (!this.dom) { + this.dom = this.buildDOM(); + } + + return this.dom; + } + + abstract updateState(state: EditorUiStateUpdate): void; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts new file mode 100644 index 000000000..51c7d294d --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -0,0 +1,40 @@ +import {BaseSelection, LexicalEditor} from "lexical"; +import {EditorUiElement, EditorUiStateUpdate} from "./base-elements"; +import {el} from "../../helpers"; + +export interface EditorButtonDefinition { + label: string; + action: (editor: LexicalEditor) => void; + isActive: (selection: BaseSelection|null) => boolean; +} + +export class EditorButton extends EditorUiElement { + protected definition: EditorButtonDefinition; + + constructor(definition: EditorButtonDefinition) { + super(); + this.definition = definition; + } + + protected buildDOM(): HTMLButtonElement { + const button = el('button', { + type: 'button', + class: 'editor-toolbar-button', + }, [this.definition.label]) as HTMLButtonElement; + + button.addEventListener('click', event => { + this.definition.action(this.getContext().editor); + }); + + return button; + } + + updateActiveState(selection: BaseSelection|null) { + const isActive = this.definition.isActive(selection); + this.dom?.classList.toggle('editor-toolbar-button-active', isActive); + } + + updateState(state: EditorUiStateUpdate): void { + this.updateActiveState(state.selection); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/containers.ts b/resources/js/wysiwyg/ui/framework/containers.ts new file mode 100644 index 000000000..9ef59c72f --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/containers.ts @@ -0,0 +1,40 @@ +import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./base-elements"; +import {el} from "../../helpers"; + +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) { + for (const child of this.getChildren()) { + child.setContext(context); + } + } +} + +export class EditorFormatMenu extends EditorContainerUiElement { + buildDOM(): HTMLElement { + return el('div', { + class: 'editor-format-menu' + }, this.getChildren().map(child => child.getDOMElement())); + } + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index d04808fae..56ae9354a 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -4,49 +4,17 @@ import { LexicalEditor, SELECTION_CHANGE_COMMAND } from "lexical"; -import {EditorButton, EditorButtonDefinition} from "./editor-button"; -import { - blockquoteButton, boldButton, codeButton, - dangerCalloutButton, - h2Button, - h3Button, h4Button, h5Button, - infoCalloutButton, italicButton, paragraphButton, redoButton, strikethroughButton, subscriptButton, - successCalloutButton, superscriptButton, underlineButton, undoButton, - warningCalloutButton -} from "./buttons"; - - - -const toolbarButtonDefinitions: EditorButtonDefinition[] = [ - undoButton, redoButton, - - infoCalloutButton, warningCalloutButton, dangerCalloutButton, successCalloutButton, - h2Button, h3Button, h4Button, h5Button, - blockquoteButton, paragraphButton, - - boldButton, italicButton, underlineButton, strikethroughButton, - superscriptButton, subscriptButton, codeButton, -]; +import {getMainEditorFullToolbar} from "./toolbars"; export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { - const toolbarContainer = document.createElement('div'); - toolbarContainer.classList.add('editor-toolbar-container'); - - const buttons = toolbarButtonDefinitions.map(definition => { - return new EditorButton(definition, editor); - }); - - const buttonElements = buttons.map(button => button.getDOMElement()); - - toolbarContainer.append(...buttonElements); - element.before(toolbarContainer); + const toolbar = getMainEditorFullToolbar(); + toolbar.setContext({editor}); + element.before(toolbar.getDOMElement()); // Update button states on editor selection change editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { const selection = $getSelection(); - for (const button of buttons) { - button.updateActiveState(selection); - } + toolbar.updateState({editor, selection}); return false; }, COMMAND_PRIORITY_LOW); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts new file mode 100644 index 000000000..0f46f5b2a --- /dev/null +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -0,0 +1,43 @@ +import {EditorButton} from "./framework/buttons"; +import { + blockquote, bold, code, + dangerCallout, + h2, h3, h4, h5, + infoCallout, italic, link, paragraph, + redo, strikethrough, subscript, + successCallout, superscript, underline, + undo, + warningCallout +} from "./defaults/button-definitions"; +import {EditorContainerUiElement, EditorFormatMenu} from "./framework/containers"; + + +export function getMainEditorFullToolbar(): EditorContainerUiElement { + return new EditorContainerUiElement([ + new EditorButton(undo), + new EditorButton(redo), + + new EditorFormatMenu([ + new EditorButton(h2), + new EditorButton(h3), + new EditorButton(h4), + new EditorButton(h5), + new EditorButton(blockquote), + new EditorButton(paragraph), + new EditorButton(infoCallout), + new EditorButton(successCallout), + new EditorButton(warningCallout), + new EditorButton(dangerCallout), + ]), + + new EditorButton(bold), + new EditorButton(italic), + new EditorButton(underline), + new EditorButton(strikethrough), + new EditorButton(superscript), + new EditorButton(subscript), + new EditorButton(code), + + new EditorButton(link), + ]); +} \ No newline at end of file diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index 90e42e576..b48e10570 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -14,6 +14,7 @@

Some content here

+

This has a link in it

List below this h2 header

  • Hello