]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Added basic URL field header option list
authorDan Brown <redacted>
Fri, 16 Aug 2024 11:29:40 +0000 (12:29 +0100)
committerDan Brown <redacted>
Fri, 16 Aug 2024 11:29:40 +0000 (12:29 +0100)
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
resources/js/wysiwyg/nodes/custom-heading.ts
resources/js/wysiwyg/todo.md
resources/js/wysiwyg/ui/defaults/forms/objects.ts
resources/js/wysiwyg/ui/framework/blocks/action-field.ts
resources/js/wysiwyg/ui/framework/blocks/link-field.ts [new file with mode: 0644]
resources/js/wysiwyg/utils/nodes.ts

index 942456d9dedb475ce3e6e05ad0a06ccdf2513ffd..1264d105860e11ae68e9f7d53cbbfd02ea878837 100644 (file)
@@ -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
index dba49898cc5a1a4f76e5765a3cb67b5b8e4580ec..f069ff16048bd84ff08cf4e0ca74b57593848aba 100644 (file)
@@ -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 {
index 194832d5f62a1c7d6a7e490c40db76ed40101e93..70a3744f3bc9668b802a387fb0dfc88d881e2750 100644 (file)
@@ -2,7 +2,7 @@
 
 ## In progress
 
-- Link heading-based ID reference menu
+//
 
 ## Main Todo
 
index 6bd265e6c2a70b81bb2564d335e44f31ffc93df6..2ad27f749c1c3f0d108c6fafe1aaec501b00f9f7 100644 (file)
@@ -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,
index 1f40c2864d1c6e7742cd62ec9865fba3774b87e7..b7741321bae970cf362cba221c91d0c1757043ba 100644 (file)
@@ -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 (file)
index 0000000..5a64cdc
--- /dev/null
@@ -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<string, CustomHeadingNode>();
+
+    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);
+        });
+    }
+}
index 8e6c666109b82c2143bf1008e5b3c7ed7808940e..6278186ca12614fcec9c5a8f2abb91ebf6204e01 100644 (file)
@@ -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.
  */