Lexical: Started drop handling, handled templates

This commit is contained in:
Dan Brown 2024-07-29 15:27:41 +01:00
parent ce8c9dd079
commit 9a7edc6e52
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
5 changed files with 141 additions and 40 deletions

View File

@ -1,30 +1,10 @@
import {$getRoot, $getSelection, $isTextNode, LexicalEditor, LexicalNode, RootNode} from "lexical";
import {$generateHtmlFromNodes, $generateNodesFromDOM} from "@lexical/html";
import {$createCustomParagraphNode} from "./nodes/custom-paragraph";
import {$getRoot, $getSelection, LexicalEditor} from "lexical";
import {$generateHtmlFromNodes} from "@lexical/html";
import {$htmlToBlockNodes} from "./helpers";
function htmlToDom(html: string): Document {
const parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
}
function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
return nodes.map(node => {
if ($isTextNode(node)) {
const paragraph = $createCustomParagraphNode();
paragraph.append(node);
return paragraph;
}
return node;
});
}
function appendNodesToRoot(root: RootNode, nodes: LexicalNode[]) {
root.append(...wrapTextNodes(nodes));
}
export function setEditorContentFromHtml(editor: LexicalEditor, html: string) {
const dom = htmlToDom(html);
editor.update(() => {
// Empty existing
const root = $getRoot();
@ -32,27 +12,23 @@ export function setEditorContentFromHtml(editor: LexicalEditor, html: string) {
child.remove(true);
}
const nodes = $generateNodesFromDOM(editor, dom);
root.append(...wrapTextNodes(nodes));
const nodes = $htmlToBlockNodes(editor, html);
root.append(...nodes);
});
}
export function appendHtmlToEditor(editor: LexicalEditor, html: string) {
const dom = htmlToDom(html);
editor.update(() => {
const root = $getRoot();
const nodes = $generateNodesFromDOM(editor, dom);
root.append(...wrapTextNodes(nodes));
const nodes = $htmlToBlockNodes(editor, html);
root.append(...nodes);
});
}
export function prependHtmlToEditor(editor: LexicalEditor, html: string) {
const dom = htmlToDom(html);
editor.update(() => {
const root = $getRoot();
const nodes = wrapTextNodes($generateNodesFromDOM(editor, dom));
const nodes = $htmlToBlockNodes(editor, html);
let reference = root.getChildren()[0];
for (let i = nodes.length - 1; i >= 0; i--) {
if (reference) {
@ -66,10 +42,9 @@ export function prependHtmlToEditor(editor: LexicalEditor, html: string) {
}
export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) {
const dom = htmlToDom(html);
editor.update(() => {
const selection = $getSelection();
const nodes = wrapTextNodes($generateNodesFromDOM(editor, dom));
const nodes = $htmlToBlockNodes(editor, html);
const reference = selection?.getNodes()[0];
const referencesParents = reference?.getParents() || [];

View File

@ -0,0 +1,71 @@
import {
$getNearestNodeFromDOMNode,
$getRoot,
$insertNodes,
$isDecoratorNode,
LexicalEditor,
LexicalNode
} from "lexical";
import {
$getNearestBlockNodeForCoords,
$htmlToBlockNodes,
$insertNewBlockNodeAtSelection, $insertNewBlockNodesAtSelection,
$selectSingleNode
} from "./helpers";
function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null {
const x = event.clientX;
const y = event.clientY;
const dom = document.elementFromPoint(x, y);
if (!dom) {
return null;
}
return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY);
}
function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) {
const positionNode = $getNodeFromMouseEvent(event, editor);
if (positionNode) {
$selectSingleNode(positionNode);
}
$insertNewBlockNodesAtSelection(nodes, true);
if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) {
positionNode?.remove();
}
}
async function insertTemplateToEditor(editor: LexicalEditor, templateId: string, event: DragEvent) {
const resp = await window.$http.get(`/templates/${templateId}`);
const data = (resp.data || {html: ''}) as {html: string}
const html: string = data.html || '';
editor.update(() => {
const newNodes = $htmlToBlockNodes(editor, html);
$insertNodesAtEvent(newNodes, event, editor);
});
}
function createDropListener(editor: LexicalEditor): (event: DragEvent) => void {
return (event: DragEvent) => {
// Template handling
const templateId = event.dataTransfer?.getData('bookstack/template') || '';
if (templateId) {
event.preventDefault();
insertTemplateToEditor(editor, templateId, event);
return;
}
};
}
export function handleDropEvents(editor: LexicalEditor) {
const dropListener = createDropListener(editor);
editor.registerRootListener((rootElement, prevRootElement) => {
rootElement?.addEventListener('drop', dropListener);
prevRootElement?.removeEventListener('drop', dropListener);
});
}

View File

@ -3,12 +3,14 @@ import {
$createParagraphNode, $getRoot,
$getSelection, $isElementNode,
$isTextNode, $setSelection,
BaseSelection, ElementFormatType, ElementNode,
BaseSelection, ElementFormatType, ElementNode, LexicalEditor,
LexicalNode, TextFormatType
} from "lexical";
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes";
import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
import {$setBlocksType} from "@lexical/selection";
import {$createCustomParagraphNode} from "./nodes/custom-paragraph";
import {$generateNodesFromDOM} from "@lexical/html";
export function el(tag: string, attrs: Record<string, string|null> = {}, children: (string|HTMLElement)[] = []): HTMLElement {
const el = document.createElement(tag);
@ -30,6 +32,28 @@ export function el(tag: string, attrs: Record<string, string|null> = {}, childre
return el;
}
function htmlToDom(html: string): Document {
const parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
}
function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
return nodes.map(node => {
if ($isTextNode(node)) {
const paragraph = $createCustomParagraphNode();
paragraph.append(node);
return paragraph;
}
return node;
});
}
export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
const dom = htmlToDom(html);
const nodes = $generateNodesFromDOM(editor, dom);
return wrapTextNodes(nodes);
}
export function $selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean {
return $getNodeFromSelection(selection, matcher) !== null;
}
@ -88,17 +112,25 @@ export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creat
}
export function $insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: boolean = true) {
$insertNewBlockNodesAtSelection([node], insertAfter);
}
export function $insertNewBlockNodesAtSelection(nodes: LexicalNode[], insertAfter: boolean = true) {
const selection = $getSelection();
const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null;
if (blockElement) {
if (insertAfter) {
blockElement.insertAfter(node);
} else {
blockElement.insertBefore(node);
for (let i = nodes.length - 1; i >= 0; i--) {
blockElement.insertAfter(nodes[i]);
}
} else {
$getRoot().append(node);
for (const node of nodes) {
blockElement.insertBefore(node);
}
}
} else {
$getRoot().append(...nodes);
}
}
@ -152,3 +184,24 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection|null):
return Array.from(blockNodes.values());
}
/**
* Get the nearest root/block level node for the given position.
*/
export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, y: number): LexicalNode|null {
// TODO - Take into account x for floated blocks?
const rootNodes = $getRoot().getChildren();
for (const node of rootNodes) {
const nodeDom = editor.getElementByKey(node.__key);
if (!nodeDom) {
continue;
}
const bounds = nodeDom.getBoundingClientRect();
if (y <= bounds.bottom) {
return node;
}
}
return null;
}

View File

@ -9,6 +9,7 @@ import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
import {el} from "./helpers";
import {EditorUiContext} from "./ui/framework/core";
import {listen as listenToCommonEvents} from "./common-events";
import {handleDropEvents} from "./drop-handling";
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = {
@ -49,6 +50,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
);
listenToCommonEvents(editor);
handleDropEvents(editor);
setEditorContentFromHtml(editor, htmlContent);

View File

@ -13,7 +13,6 @@
- Keyboard shortcuts support
- Draft/change management (connect with page editor component)
- Add ID support to all block types
- Template drag & drop / insert
- Video attachment drop / insert
- Task list render/import from existing format
- Link popup menu for cross-content reference
@ -29,3 +28,4 @@
- Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node.
- Removing link around image via button deletes image, not just link
- `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect.
- Template drag/drop not handled when outside core editor area (ignored in margin area).