From ad6b26ba97b32996455d8fd7f3c3c0f4d3f480af Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 16 Aug 2024 12:29:40 +0100 Subject: [PATCH] Lexical: Added basic URL field header option list May show bad option label names on chrome/safari. This was an easy first pass without loads of extra custom UI since we're using native datalists. --- resources/js/services/util.js | 11 +++ resources/js/wysiwyg/nodes/custom-heading.ts | 4 +- resources/js/wysiwyg/todo.md | 2 +- .../js/wysiwyg/ui/defaults/forms/objects.ts | 5 +- .../ui/framework/blocks/action-field.ts | 5 +- .../wysiwyg/ui/framework/blocks/link-field.ts | 96 +++++++++++++++++++ resources/js/wysiwyg/utils/nodes.ts | 22 ++++- 7 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/link-field.ts diff --git a/resources/js/services/util.js b/resources/js/services/util.js index 942456d9d..1264d1058 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.js @@ -84,6 +84,17 @@ export function uniqueId() { return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); } +/** + * Generate a random smaller unique ID. + * + * @returns {string} + */ +export function uniqueIdSmall() { + // eslint-disable-next-line no-bitwise + const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + return S4(); +} + /** * Create a promise that resolves after the given time. * @param {int} timeMs diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts index dba49898c..f069ff160 100644 --- a/resources/js/wysiwyg/nodes/custom-heading.ts +++ b/resources/js/wysiwyg/nodes/custom-heading.ts @@ -30,9 +30,7 @@ export class CustomHeadingNode extends HeadingNode { } static clone(node: CustomHeadingNode) { - const newNode = new CustomHeadingNode(node.__tag, node.__key); - newNode.__id = node.__id; - return newNode; + return new CustomHeadingNode(node.__tag, node.__key); } createDOM(config: EditorConfig): HTMLElement { diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 194832d5f..70a3744f3 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -- Link heading-based ID reference menu +// ## Main Todo diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 6bd265e6c..2ad27f749 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -18,6 +18,7 @@ import {showImageManager} from "../../../utils/images"; import searchImageIcon from "@icons/editor/image-search.svg"; import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; +import {LinkField} from "../../framework/blocks/link-field"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -132,11 +133,11 @@ export const link: EditorFormDefinition = { { build() { return new EditorActionField( - new EditorFormField({ + new LinkField(new EditorFormField({ label: 'URL', name: 'url', type: 'text', - }), + })), new EditorButton({ label: 'Browse links', icon: searchIcon, diff --git a/resources/js/wysiwyg/ui/framework/blocks/action-field.ts b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts index 1f40c2864..b7741321b 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/action-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts @@ -1,14 +1,13 @@ import {EditorContainerUiElement, EditorUiElement} from "../core"; import {el} from "../../../utils/dom"; -import {EditorFormField} from "../forms"; import {EditorButton} from "../buttons"; export class EditorActionField extends EditorContainerUiElement { - protected input: EditorFormField; + protected input: EditorUiElement; protected action: EditorButton; - constructor(input: EditorFormField, action: EditorButton) { + constructor(input: EditorUiElement, action: EditorButton) { super([input, action]); this.input = input; diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts new file mode 100644 index 000000000..5a64cdc30 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -0,0 +1,96 @@ +import {EditorContainerUiElement} from "../core"; +import {el} from "../../../utils/dom"; +import {EditorFormField} from "../forms"; +import {CustomHeadingNode} from "../../../nodes/custom-heading"; +import {$getAllNodesOfType} from "../../../utils/nodes"; +import {$isHeadingNode} from "@lexical/rich-text"; +import {uniqueIdSmall} from "../../../../services/util"; + +export class LinkField extends EditorContainerUiElement { + protected input: EditorFormField; + protected headerMap = new Map(); + + constructor(input: EditorFormField) { + super([input]); + + this.input = input; + } + + buildDOM(): HTMLElement { + const listId = 'editor-form-datalist-' + this.input.getName() + '-' + Date.now(); + const inputOuterDOM = this.input.getDOMElement(); + const inputFieldDOM = inputOuterDOM.querySelector('input'); + inputFieldDOM?.setAttribute('list', listId); + inputFieldDOM?.setAttribute('autocomplete', 'off'); + const datalist = el('datalist', {id: listId}); + + const container = el('div', { + class: 'editor-link-field-container', + }, [inputOuterDOM, datalist]); + + inputFieldDOM?.addEventListener('focusin', () => { + this.updateDataList(datalist); + }); + + inputFieldDOM?.addEventListener('input', () => { + const value = inputFieldDOM.value; + const header = this.headerMap.get(value); + if (header) { + this.updateFormFromHeader(header); + } + }); + + return container; + } + + updateFormFromHeader(header: CustomHeadingNode) { + this.getHeaderIdAndText(header).then(({id, text}) => { + console.log('updating form', id, text); + const modal = this.getContext().manager.getActiveModal('link'); + if (modal) { + modal.getForm().setValues({ + url: `#${id}`, + text: text, + title: text, + }); + } + }); + } + + getHeaderIdAndText(header: CustomHeadingNode): Promise<{id: string, text: string}> { + return new Promise((res) => { + this.getContext().editor.update(() => { + let id = header.getId(); + console.log('header', id, header.__id); + if (!id) { + id = 'header-' + uniqueIdSmall(); + header.setId(id); + } + + const text = header.getTextContent(); + res({id, text}); + }); + }); + } + + updateDataList(listEl: HTMLElement) { + this.getContext().editor.getEditorState().read(() => { + const headers = $getAllNodesOfType($isHeadingNode) as CustomHeadingNode[]; + + this.headerMap.clear(); + const listEls: HTMLElement[] = []; + + for (const header of headers) { + const key = 'header-' + header.getKey(); + this.headerMap.set(key, header); + listEls.push(el('option', { + value: key, + label: header.getTextContent().substring(0, 54), + })); + } + + listEl.innerHTML = ''; + listEl.append(...listEls); + }); + } +} diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 8e6c66610..6278186ca 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -1,4 +1,4 @@ -import {$getRoot, $isTextNode, LexicalEditor, LexicalNode} from "lexical"; +import {$getRoot, $isElementNode, $isTextNode, ElementNode, LexicalEditor, LexicalNode} from "lexical"; import {LexicalNodeMatcher} from "../nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$generateNodesFromDOM} from "@lexical/html"; @@ -31,6 +31,26 @@ export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher) return null; } +export function $getAllNodesOfType(matcher: LexicalNodeMatcher, root?: ElementNode): LexicalNode[] { + if (!root) { + root = $getRoot(); + } + + const matches = []; + + for (const child of root.getChildren()) { + if (matcher(child)) { + matches.push(child); + } + + if ($isElementNode(child)) { + matches.push(...$getAllNodesOfType(matcher, child)); + } + } + + return matches; +} + /** * Get the nearest root/block level node for the given position. */