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.
This commit is contained in:
Dan Brown 2024-08-16 12:29:40 +01:00
parent 1ef4044419
commit ad6b26ba97
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
7 changed files with 135 additions and 10 deletions

View 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

View 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 {

View File

@ -2,7 +2,7 @@
## In progress
- Link heading-based ID reference menu
//
## Main Todo

View 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,

View 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;

View File

@ -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);
});
}
}

View 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.
*/