From 1e768ce33f0aebc3af717ada38fa42222c6e6137 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 13 Dec 2025 17:03:48 +0000 Subject: [PATCH] Lexical: Changed mention to be a decorator node Allows better selection. Also updated existing decorator file names to align with classes so they're easier to find. Also aligned/fixed decorator constuctor/setup methods. --- app/Util/HtmlDescriptionFilter.php | 2 +- resources/js/wysiwyg/index.ts | 8 +- .../lexical/link/LexicalMentionNode.ts | 42 +++-- resources/js/wysiwyg/services/mentions.ts | 124 +------------ .../{code-block.ts => CodeBlockDecorator.ts} | 16 +- .../{diagram.ts => DiagramDecorator.ts} | 18 +- .../wysiwyg/ui/decorators/MentionDecorator.ts | 172 ++++++++++++++++++ .../js/wysiwyg/ui/framework/decorator.ts | 2 +- resources/js/wysiwyg/ui/framework/manager.ts | 4 +- resources/sass/_content.scss | 19 ++ 10 files changed, 252 insertions(+), 155 deletions(-) rename resources/js/wysiwyg/ui/decorators/{code-block.ts => CodeBlockDecorator.ts} (84%) rename resources/js/wysiwyg/ui/decorators/{diagram.ts => DiagramDecorator.ts} (70%) create mode 100644 resources/js/wysiwyg/ui/decorators/MentionDecorator.ts diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index d4f7d2c8f..1baa11ffc 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -19,7 +19,7 @@ class HtmlDescriptionFilter */ protected static array $allowedAttrsByElements = [ 'p' => [], - 'a' => ['href', 'title', 'target'], + 'a' => ['href', 'title', 'target', 'data-mention-user-id'], 'ol' => [], 'ul' => [], 'li' => [], diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 13cc350fa..273657c47 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -22,12 +22,13 @@ import {registerKeyboardHandling} from "./services/keyboard-handling"; import {registerAutoLinks} from "./services/auto-links"; import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars"; import {modals} from "./ui/defaults/modals"; -import {CodeBlockDecorator} from "./ui/decorators/code-block"; -import {DiagramDecorator} from "./ui/decorators/diagram"; +import {CodeBlockDecorator} from "./ui/decorators/CodeBlockDecorator"; +import {DiagramDecorator} from "./ui/decorators/DiagramDecorator"; import {registerMouseHandling} from "./services/mouse-handling"; import {registerSelectionHandling} from "./services/selection-handling"; import {EditorApi} from "./api/api"; import {registerMentions} from "./services/mentions"; +import {MentionDecorator} from "./ui/decorators/MentionDecorator"; const theme = { text: { @@ -151,7 +152,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: }); // TODO - Dedupe this with the basic editor instance - // Changed elements: namespace, registerMentions, toolbar, public event usage + // Changed elements: namespace, registerMentions, toolbar, public event usage, mentioned decorator const context: EditorUiContext = buildEditorUI(container, editor, options); editor.setRootElement(context.editorDOM); @@ -168,6 +169,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: context.manager.registerContextToolbar('link', contextToolbars.link); context.manager.registerModal('link', modals.link); context.manager.onTeardown(editorTeardown); + context.manager.registerDecoratorType('mention', MentionDecorator); setEditorContentFromHtml(editor, htmlContent); diff --git a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts index a57173208..62213a42c 100644 --- a/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts +++ b/resources/js/wysiwyg/lexical/link/LexicalMentionNode.ts @@ -1,20 +1,21 @@ import { + DecoratorNode, DOMConversion, - DOMConversionMap, DOMConversionOutput, + DOMConversionMap, DOMConversionOutput, DOMExportOutput, type EditorConfig, - ElementNode, LexicalEditor, LexicalNode, - SerializedElementNode, + SerializedLexicalNode, Spread } from "lexical"; +import {EditorDecoratorAdapter} from "../../ui/framework/decorator"; export type SerializedMentionNode = Spread<{ user_id: number; user_name: string; user_slug: string; -}, SerializedElementNode> +}, SerializedLexicalNode> -export class MentionNode extends ElementNode { +export class MentionNode extends DecoratorNode { __user_id: number = 0; __user_name: string = ''; __user_slug: string = ''; @@ -22,7 +23,6 @@ export class MentionNode extends ElementNode { static getType(): string { return 'mention'; } - static clone(node: MentionNode): MentionNode { const newNode = new MentionNode(node.__key); newNode.__user_id = node.__user_id; @@ -42,12 +42,24 @@ export class MentionNode extends ElementNode { return true; } + isParentRequired(): boolean { + return true; + } + + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + return { + type: 'mention', + getNode: () => this, + }; + } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('a'); element.setAttribute('target', '_blank'); - element.setAttribute('href', window.baseUrl('/users/' + this.__user_slug)); - element.setAttribute('data-user-mention-id', String(this.__user_id)); + element.setAttribute('href', window.baseUrl('/user/' + this.__user_slug)); + element.setAttribute('data-mention-user-id', String(this.__user_id)); element.textContent = '@' + this.__user_name; + // element.setAttribute('contenteditable', 'false'); return element; } @@ -55,21 +67,30 @@ export class MentionNode extends ElementNode { return prevNode.__user_id !== this.__user_id; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config, editor); + // element.removeAttribute('contenteditable'); + return {element}; + } + static importDOM(): DOMConversionMap|null { return { a(node: HTMLElement): DOMConversion|null { - if (node.hasAttribute('data-user-mention-id')) { + if (node.hasAttribute('data-mention-user-id')) { return { conversion: (element: HTMLElement): DOMConversionOutput|null => { const node = new MentionNode(); node.setUserDetails( - Number(element.getAttribute('data-user-mention-id') || '0'), + Number(element.getAttribute('data-mention-user-id') || '0'), element.innerText.replace(/^@/, ''), element.getAttribute('href')?.split('/user/')[1] || '' ); return { node, + after(childNodes): LexicalNode[] { + return []; + } }; }, priority: 4, @@ -82,7 +103,6 @@ export class MentionNode extends ElementNode { exportJSON(): SerializedMentionNode { return { - ...super.exportJSON(), type: 'mention', version: 1, user_id: this.__user_id, diff --git a/resources/js/wysiwyg/services/mentions.ts b/resources/js/wysiwyg/services/mentions.ts index e41457b8a..87a1f4b8b 100644 --- a/resources/js/wysiwyg/services/mentions.ts +++ b/resources/js/wysiwyg/services/mentions.ts @@ -1,14 +1,11 @@ import { - $createTextNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_NORMAL, RangeSelection, TextNode } from "lexical"; import {KEY_AT_COMMAND} from "lexical/LexicalCommands"; import {$createMentionNode} from "@lexical/link/LexicalMentionNode"; -import {el, htmlToDom} from "../utils/dom"; import {EditorUiContext} from "../ui/framework/core"; -import {debounce} from "../../services/util"; -import {removeLoading, showLoading} from "../../services/dom"; +import {MentionDecorator} from "../ui/decorators/MentionDecorator"; function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection) { @@ -32,126 +29,13 @@ function enterUserSelectMode(context: EditorUiContext, selection: RangeSelection const mention = $createMentionNode(0, '', ''); newNode.replace(mention); - mention.select(); - - const revertEditorMention = () => { - context.editor.update(() => { - const text = $createTextNode('@'); - mention.replace(text); - text.selectEnd(); - }); - }; requestAnimationFrame(() => { - const mentionDOM = context.editor.getElementByKey(mention.getKey()); - if (!mentionDOM) { - revertEditorMention(); - return; + const mentionDecorator = context.manager.getDecoratorByNodeKey(mention.getKey()); + if (mentionDecorator instanceof MentionDecorator) { + mentionDecorator.showSelection() } - - const selectList = buildAndShowUserSelectorAtElement(context, mentionDOM); - handleUserListLoading(selectList); - handleUserSelectCancel(context, selectList, revertEditorMention); }); - - - // TODO - On enter, replace with name mention element. -} - -function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, revertEditorMention: () => void) { - const controller = new AbortController(); - - const onCancel = () => { - revertEditorMention(); - selectList.remove(); - controller.abort(); - } - - selectList.addEventListener('keydown', (event) => { - if (event.key === 'Escape') { - onCancel(); - } - }, {signal: controller.signal}); - - const input = selectList.querySelector('input') as HTMLInputElement; - input.addEventListener('keydown', (event) => { - if (event.key === 'Backspace' && input.value === '') { - onCancel(); - event.preventDefault(); - event.stopPropagation(); - } - }, {signal: controller.signal}); - - context.editorDOM.addEventListener('click', (event) => { - onCancel() - }, {signal: controller.signal}); - context.editorDOM.addEventListener('keydown', (event) => { - onCancel(); - }, {signal: controller.signal}); -} - -function handleUserListLoading(selectList: HTMLElement) { - const cache = new Map(); - - const updateUserList = async (searchTerm: string) => { - // Empty list - for (const child of [...selectList.children].slice(1)) { - child.remove(); - } - - // Fetch new content - let responseHtml = ''; - if (cache.has(searchTerm)) { - responseHtml = cache.get(searchTerm) || ''; - } else { - const loadingWrap = el('li'); - showLoading(loadingWrap); - selectList.appendChild(loadingWrap); - - const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`); - responseHtml = resp.data as string; - cache.set(searchTerm, responseHtml); - loadingWrap.remove(); - } - - const doc = htmlToDom(responseHtml); - const toInsert = doc.querySelectorAll('li'); - for (const listEl of toInsert) { - const adopted = window.document.adoptNode(listEl) as HTMLElement; - selectList.appendChild(adopted); - } - - }; - - // Initial load - updateUserList(''); - - const input = selectList.querySelector('input') as HTMLInputElement; - const updateUserListDebounced = debounce(updateUserList, 200, false); - input.addEventListener('input', () => { - const searchTerm = input.value; - updateUserListDebounced(searchTerm); - }); -} - -function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement { - const searchInput = el('input', {type: 'text'}); - const searchItem = el('li', {}, [searchInput]); - const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]); - - context.containerDOM.appendChild(userSelect); - - userSelect.style.display = 'block'; - userSelect.style.top = '0'; - userSelect.style.left = '0'; - const mentionPos = mentionDOM.getBoundingClientRect(); - const userSelectPos = userSelect.getBoundingClientRect(); - userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`; - userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`; - - searchInput.focus(); - - return userSelect; } export function registerMentions(context: EditorUiContext): () => void { diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts similarity index 84% rename from resources/js/wysiwyg/ui/decorators/code-block.ts rename to resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts index daae32e19..d95185e0b 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/CodeBlockDecorator.ts @@ -14,7 +14,7 @@ export class CodeBlockDecorator extends EditorDecorator { // @ts-ignore protected editor: any = null; - setup(context: EditorUiContext, element: HTMLElement) { + setup(element: HTMLElement) { const codeNode = this.getNode() as CodeBlockNode; const preEl = element.querySelector('pre'); if (!preEl) { @@ -35,24 +35,24 @@ export class CodeBlockDecorator extends EditorDecorator { element.addEventListener('click', event => { requestAnimationFrame(() => { - context.editor.update(() => { + this.context.editor.update(() => { $selectSingleNode(this.getNode()); }); }); }); element.addEventListener('dblclick', event => { - context.editor.getEditorState().read(() => { - $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); + this.context.editor.getEditorState().read(() => { + $openCodeEditorForNode(this.context.editor, (this.getNode() as CodeBlockNode)); }); }); const selectionChange = (selection: BaseSelection|null): void => { element.classList.toggle('selected', $selectionContainsNode(selection, codeNode)); }; - context.manager.onSelectionChange(selectionChange); + this.context.manager.onSelectionChange(selectionChange); this.onDestroy(() => { - context.manager.offSelectionChange(selectionChange); + this.context.manager.offSelectionChange(selectionChange); }); // @ts-ignore @@ -89,11 +89,11 @@ export class CodeBlockDecorator extends EditorDecorator { } } - render(context: EditorUiContext, element: HTMLElement): void { + render(element: HTMLElement): void { if (this.completedSetup) { this.update(); } else { - this.setup(context, element); + this.setup(element); } } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts similarity index 70% rename from resources/js/wysiwyg/ui/decorators/diagram.ts rename to resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts index 52a73ad72..e46dcc312 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/DiagramDecorator.ts @@ -9,33 +9,33 @@ import {$openDrawingEditorForNode} from "../../utils/diagrams"; export class DiagramDecorator extends EditorDecorator { protected completedSetup: boolean = false; - setup(context: EditorUiContext, element: HTMLElement) { + setup(element: HTMLElement) { const diagramNode = this.getNode(); element.classList.add('editor-diagram'); - context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => { + this.context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => { if (!element.contains(event.target as HTMLElement)) { return false; } - context.editor.update(() => { + this.context.editor.update(() => { $selectSingleNode(this.getNode()); }); return true; }, COMMAND_PRIORITY_NORMAL); element.addEventListener('dblclick', event => { - context.editor.getEditorState().read(() => { - $openDrawingEditorForNode(context, (this.getNode() as DiagramNode)); + this.context.editor.getEditorState().read(() => { + $openDrawingEditorForNode(this.context, (this.getNode() as DiagramNode)); }); }); const selectionChange = (selection: BaseSelection|null): void => { element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode)); }; - context.manager.onSelectionChange(selectionChange); + this.context.manager.onSelectionChange(selectionChange); this.onDestroy(() => { - context.manager.offSelectionChange(selectionChange); + this.context.manager.offSelectionChange(selectionChange); }); this.completedSetup = true; @@ -45,11 +45,11 @@ export class DiagramDecorator extends EditorDecorator { // } - render(context: EditorUiContext, element: HTMLElement): void { + render(element: HTMLElement): void { if (this.completedSetup) { this.update(); } else { - this.setup(context, element); + this.setup(element); } } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts new file mode 100644 index 000000000..df2d0a227 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/MentionDecorator.ts @@ -0,0 +1,172 @@ +import {EditorDecorator} from "../framework/decorator"; +import {EditorUiContext} from "../framework/core"; +import {el, htmlToDom} from "../../utils/dom"; +import {showLoading} from "../../../services/dom"; +import {MentionNode} from "@lexical/link/LexicalMentionNode"; +import {debounce} from "../../../services/util"; +import {$createTextNode} from "lexical"; + +function userClickHandler(onSelect: (id: number, name: string, slug: string)=>void): (event: PointerEvent) => void { + return (event: PointerEvent) => { + const userItem = (event.target as HTMLElement).closest('a[data-id]') as HTMLAnchorElement | null; + if (!userItem) { + return; + } + + const id = Number(userItem.dataset.id || '0'); + const name = userItem.dataset.name || ''; + const slug = userItem.dataset.slug || ''; + + onSelect(id, name, slug); + event.preventDefault(); + }; +} + +function handleUserSelectCancel(context: EditorUiContext, selectList: HTMLElement, controller: AbortController, onCancel: () => void): void { + selectList.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + onCancel(); + } + }, {signal: controller.signal}); + + const input = selectList.querySelector('input') as HTMLInputElement; + input.addEventListener('keydown', (event) => { + if (event.key === 'Backspace' && input.value === '') { + onCancel(); + event.preventDefault(); + event.stopPropagation(); + } + }, {signal: controller.signal}); + + context.editorDOM.addEventListener('click', (event) => { + onCancel() + }, {signal: controller.signal}); + context.editorDOM.addEventListener('keydown', (event) => { + onCancel(); + }, {signal: controller.signal}); +} + +function handleUserListLoading(selectList: HTMLElement) { + const cache = new Map(); + + const updateUserList = async (searchTerm: string) => { + // Empty list + for (const child of [...selectList.children].slice(1)) { + child.remove(); + } + + // Fetch new content + let responseHtml = ''; + if (cache.has(searchTerm)) { + responseHtml = cache.get(searchTerm) || ''; + } else { + const loadingWrap = el('li'); + showLoading(loadingWrap); + selectList.appendChild(loadingWrap); + + const resp = await window.$http.get(`/search/users/mention?search=${searchTerm}`); + responseHtml = resp.data as string; + cache.set(searchTerm, responseHtml); + loadingWrap.remove(); + } + + const doc = htmlToDom(responseHtml); + const toInsert = doc.querySelectorAll('li'); + for (const listEl of toInsert) { + const adopted = window.document.adoptNode(listEl) as HTMLElement; + selectList.appendChild(adopted); + } + + }; + + // Initial load + updateUserList(''); + + const input = selectList.querySelector('input') as HTMLInputElement; + const updateUserListDebounced = debounce(updateUserList, 200, false); + input.addEventListener('input', () => { + const searchTerm = input.value; + updateUserListDebounced(searchTerm); + }); +} + +function buildAndShowUserSelectorAtElement(context: EditorUiContext, mentionDOM: HTMLElement): HTMLElement { + const searchInput = el('input', {type: 'text'}); + const searchItem = el('li', {}, [searchInput]); + const userSelect = el('ul', {class: 'suggestion-box dropdown-menu'}, [searchItem]); + + context.containerDOM.appendChild(userSelect); + + userSelect.style.display = 'block'; + userSelect.style.top = '0'; + userSelect.style.left = '0'; + const mentionPos = mentionDOM.getBoundingClientRect(); + const userSelectPos = userSelect.getBoundingClientRect(); + userSelect.style.top = `${mentionPos.bottom - userSelectPos.top + 3}px`; + userSelect.style.left = `${mentionPos.left - userSelectPos.left}px`; + + searchInput.focus(); + + return userSelect; +} + +export class MentionDecorator extends EditorDecorator { + protected completedSetup: boolean = false; + protected abortController: AbortController | null = null; + protected selectList: HTMLElement | null = null; + protected mentionElement: HTMLElement | null = null; + + setup(element: HTMLElement) { + this.mentionElement = element; + this.completedSetup = true; + } + + showSelection() { + if (!this.mentionElement) { + return; + } + + this.hideSelection(); + this.abortController = new AbortController(); + + this.selectList = buildAndShowUserSelectorAtElement(this.context, this.mentionElement); + handleUserListLoading(this.selectList); + + this.selectList.addEventListener('click', userClickHandler((id, name, slug) => { + this.context.editor.update(() => { + const mentionNode = this.getNode() as MentionNode; + this.hideSelection(); + mentionNode.setUserDetails(id, name, slug); + mentionNode.selectNext(); + }); + }), {signal: this.abortController.signal}); + + handleUserSelectCancel(this.context, this.selectList, this.abortController, this.revertMention.bind(this)); + } + + hideSelection() { + this.abortController?.abort(); + this.selectList?.remove(); + } + + revertMention() { + this.hideSelection(); + this.context.editor.update(() => { + const text = $createTextNode('@'); + this.getNode().replace(text); + text.selectEnd(); + }); + } + + update() { + // + } + + render(element: HTMLElement): void { + if (this.completedSetup) { + this.update(); + } else { + this.setup(element); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index 6ea0b8b39..2f46a19ef 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -42,7 +42,7 @@ export abstract class EditorDecorator { * If an element is returned, this will be appended to the element * that is being decorated. */ - abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void; + abstract render(decorated: HTMLElement): HTMLElement|void; /** * Destroy this decorator. Used for tear-down operations upon destruction diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 1adc0b619..cbe3cca19 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -90,7 +90,7 @@ export class EditorUIManager { } // @ts-ignore - const decorator = new decoratorClass(nodeKey); + const decorator = new decoratorClass(this.getContext()); this.decoratorInstancesByNodeKey[nodeKey] = decorator; return decorator; } @@ -262,7 +262,7 @@ export class EditorUIManager { const adapter = decorators[key]; const decorator = this.getDecorator(adapter.type, key); decorator.setNode(adapter.getNode()); - const decoratorEl = decorator.render(this.getContext(), decoratedEl); + const decoratorEl = decorator.render(decoratedEl); if (decoratorEl) { decoratedEl.append(decoratorEl); } diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index aba1556a9..98548609e 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -198,4 +198,23 @@ body .page-content img, color: inherit; text-decoration: underline; } +} + +a[data-mention-user-id] { + display: inline-block; + position: relative; + color: var(--color-link); + padding: 0.1em 0.2em; + &:after { + content: ''; + background-color: currentColor; + opacity: 0.2; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + display: block; + border-radius: 0.2em; + } } \ No newline at end of file