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:
parent
1ef4044419
commit
ad6b26ba97
|
@ -84,6 +84,17 @@ export function uniqueId() {
|
||||||
return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`);
|
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.
|
* Create a promise that resolves after the given time.
|
||||||
* @param {int} timeMs
|
* @param {int} timeMs
|
||||||
|
|
|
@ -30,9 +30,7 @@ export class CustomHeadingNode extends HeadingNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
static clone(node: CustomHeadingNode) {
|
static clone(node: CustomHeadingNode) {
|
||||||
const newNode = new CustomHeadingNode(node.__tag, node.__key);
|
return new CustomHeadingNode(node.__tag, node.__key);
|
||||||
newNode.__id = node.__id;
|
|
||||||
return newNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createDOM(config: EditorConfig): HTMLElement {
|
createDOM(config: EditorConfig): HTMLElement {
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## In progress
|
## In progress
|
||||||
|
|
||||||
- Link heading-based ID reference menu
|
//
|
||||||
|
|
||||||
## Main Todo
|
## Main Todo
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {showImageManager} from "../../../utils/images";
|
||||||
import searchImageIcon from "@icons/editor/image-search.svg";
|
import searchImageIcon from "@icons/editor/image-search.svg";
|
||||||
import searchIcon from "@icons/search.svg";
|
import searchIcon from "@icons/search.svg";
|
||||||
import {showLinkSelector} from "../../../utils/links";
|
import {showLinkSelector} from "../../../utils/links";
|
||||||
|
import {LinkField} from "../../framework/blocks/link-field";
|
||||||
|
|
||||||
export function $showImageForm(image: ImageNode, context: EditorUiContext) {
|
export function $showImageForm(image: ImageNode, context: EditorUiContext) {
|
||||||
const imageModal: EditorFormModal = context.manager.createModal('image');
|
const imageModal: EditorFormModal = context.manager.createModal('image');
|
||||||
|
@ -132,11 +133,11 @@ export const link: EditorFormDefinition = {
|
||||||
{
|
{
|
||||||
build() {
|
build() {
|
||||||
return new EditorActionField(
|
return new EditorActionField(
|
||||||
new EditorFormField({
|
new LinkField(new EditorFormField({
|
||||||
label: 'URL',
|
label: 'URL',
|
||||||
name: 'url',
|
name: 'url',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
}),
|
})),
|
||||||
new EditorButton({
|
new EditorButton({
|
||||||
label: 'Browse links',
|
label: 'Browse links',
|
||||||
icon: searchIcon,
|
icon: searchIcon,
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import {EditorContainerUiElement, EditorUiElement} from "../core";
|
import {EditorContainerUiElement, EditorUiElement} from "../core";
|
||||||
import {el} from "../../../utils/dom";
|
import {el} from "../../../utils/dom";
|
||||||
import {EditorFormField} from "../forms";
|
|
||||||
import {EditorButton} from "../buttons";
|
import {EditorButton} from "../buttons";
|
||||||
|
|
||||||
|
|
||||||
export class EditorActionField extends EditorContainerUiElement {
|
export class EditorActionField extends EditorContainerUiElement {
|
||||||
protected input: EditorFormField;
|
protected input: EditorUiElement;
|
||||||
protected action: EditorButton;
|
protected action: EditorButton;
|
||||||
|
|
||||||
constructor(input: EditorFormField, action: EditorButton) {
|
constructor(input: EditorUiElement, action: EditorButton) {
|
||||||
super([input, action]);
|
super([input, action]);
|
||||||
|
|
||||||
this.input = input;
|
this.input = input;
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {LexicalNodeMatcher} from "../nodes";
|
||||||
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
||||||
import {$generateNodesFromDOM} from "@lexical/html";
|
import {$generateNodesFromDOM} from "@lexical/html";
|
||||||
|
@ -31,6 +31,26 @@ export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher)
|
||||||
return null;
|
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.
|
* Get the nearest root/block level node for the given position.
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Reference in New Issue