From 9a7edc6e52467501832202732dec2e61a515aae0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 Jul 2024 15:27:41 +0100 Subject: [PATCH] Lexical: Started drop handling, handled templates --- resources/js/wysiwyg/actions.ts | 43 ++++------------ resources/js/wysiwyg/drop-handling.ts | 71 +++++++++++++++++++++++++++ resources/js/wysiwyg/helpers.ts | 61 +++++++++++++++++++++-- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/todo.md | 4 +- 5 files changed, 141 insertions(+), 40 deletions(-) create mode 100644 resources/js/wysiwyg/drop-handling.ts diff --git a/resources/js/wysiwyg/actions.ts b/resources/js/wysiwyg/actions.ts index 0e2202525..a3d2f0ef6 100644 --- a/resources/js/wysiwyg/actions.ts +++ b/resources/js/wysiwyg/actions.ts @@ -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() || []; diff --git a/resources/js/wysiwyg/drop-handling.ts b/resources/js/wysiwyg/drop-handling.ts new file mode 100644 index 000000000..92dc758d8 --- /dev/null +++ b/resources/js/wysiwyg/drop-handling.ts @@ -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); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 6a55c429c..07755f449 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -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 = {}, children: (string|HTMLElement)[] = []): HTMLElement { const el = document.createElement(tag); @@ -30,6 +32,28 @@ export function el(tag: string, attrs: Record = {}, 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); + for (let i = nodes.length - 1; i >= 0; i--) { + blockElement.insertAfter(nodes[i]); + } } else { - blockElement.insertBefore(node); + for (const node of nodes) { + blockElement.insertBefore(node); + } } } else { - $getRoot().append(node); + $getRoot().append(...nodes); } } @@ -151,4 +183,25 @@ 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; } \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index e53b9b057..fee536572 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -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 = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -49,6 +50,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st ); listenToCommonEvents(editor); + handleDropEvents(editor); setEditorContentFromHtml(editor, htmlContent); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 49f685bea..5d495e7d8 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -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 @@ -28,4 +27,5 @@ - 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. \ No newline at end of file +- `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). \ No newline at end of file