Lexical: Added drop/paste image handling

This commit is contained in:
Dan Brown 2024-08-21 12:59:45 +01:00
parent dbb2fe3e59
commit ddf5f2543c
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
5 changed files with 177 additions and 79 deletions

View File

@ -8,7 +8,7 @@ import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"
import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
import {EditorUiContext} from "./ui/framework/core";
import {listen as listenToCommonEvents} from "./services/common-events";
import {handleDropEvents} from "./services/drop-handling";
import {registerDropPasteHandling} from "./services/drop-paste-handling";
import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
import {el} from "./utils/dom";
@ -54,10 +54,10 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerTableResizer(editor, editWrap),
registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea),
registerDropPasteHandling(context),
);
listenToCommonEvents(editor);
handleDropEvents(editor);
setEditorContentFromHtml(editor, htmlContent);

View File

@ -1,75 +0,0 @@
import {
$isDecoratorNode,
LexicalEditor,
LexicalNode
} from "lexical";
import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection";
import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes";
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) {
insertTemplateToEditor(editor, templateId, event);
event.preventDefault();
return;
}
// HTML contents drop
const html = event.dataTransfer?.getData('text/html') || '';
if (html) {
editor.update(() => {
const newNodes = $htmlToBlockNodes(editor, html);
$insertNodesAtEvent(newNodes, event, editor);
});
event.preventDefault();
return;
}
};
}
export function handleDropEvents(editor: LexicalEditor) {
const dropListener = createDropListener(editor);
editor.registerRootListener((rootElement, prevRootElement) => {
rootElement?.addEventListener('drop', dropListener);
prevRootElement?.removeEventListener('drop', dropListener);
});
}

View File

@ -0,0 +1,158 @@
import {
$insertNodes,
$isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND,
LexicalEditor,
LexicalNode, PASTE_COMMAND
} from "lexical";
import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection";
import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes";
import {Clipboard} from "../../services/clipboard";
import {$createImageNode} from "../nodes/image";
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
import {$createLinkNode} from "@lexical/link";
import {EditorImageData, uploadImageFile} from "../utils/images";
import {EditorUiContext} from "../ui/framework/core";
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 handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolean {
const clipboard = new Clipboard(data);
let handled = false;
// Don't handle the event ourselves if no items exist of contains table-looking data
if (!clipboard.hasItems() || clipboard.containsTabularData()) {
return handled;
}
const images = clipboard.getImages();
if (images.length > 0) {
handled = true;
}
context.editor.update(async () => {
for (const imageFile of images) {
const loadingImage = window.baseUrl('/loading.gif');
const loadingNode = $createImageNode(loadingImage);
const imageWrap = $createCustomParagraphNode();
imageWrap.append(loadingNode);
$insertNodes([imageWrap]);
try {
const respData: EditorImageData = await uploadImageFile(imageFile, context.options.pageId);
const safeName = respData.name.replace(/"/g, '');
context.editor.update(() => {
const finalImage = $createImageNode(respData.thumbs?.display || '', {
alt: safeName,
});
const imageLink = $createLinkNode(respData.url, {target: '_blank'});
imageLink.append(finalImage);
loadingNode.replace(imageLink);
});
} catch (err: any) {
context.editor.update(() => {
loadingNode.remove(false);
});
window.$events.error(err?.data?.message || context.options.translations.imageUploadErrorText);
console.error(err);
}
}
});
return handled;
}
function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
const editor = context.editor;
return (event: DragEvent): boolean => {
// Template handling
const templateId = event.dataTransfer?.getData('bookstack/template') || '';
if (templateId) {
insertTemplateToEditor(editor, templateId, event);
event.preventDefault();
return true;
}
// HTML contents drop
const html = event.dataTransfer?.getData('text/html') || '';
if (html) {
editor.update(() => {
const newNodes = $htmlToBlockNodes(editor, html);
$insertNodesAtEvent(newNodes, event, editor);
});
event.preventDefault();
return true;
}
if (event.dataTransfer) {
const handled = handleMediaInsert(event.dataTransfer, context);
if (handled) {
event.preventDefault();
return true;
}
}
return false;
};
}
function createPasteListener(context: EditorUiContext): (event: ClipboardEvent) => boolean {
return (event: ClipboardEvent) => {
if (!event.clipboardData) {
return false;
}
const handled = handleMediaInsert(event.clipboardData, context);
if (handled) {
event.preventDefault();
}
return handled;
};
}
export function registerDropPasteHandling(context: EditorUiContext): () => void {
const dropListener = createDropListener(context);
const pasteListener = createPasteListener(context);
const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH);
const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH);
return () => {
unregisterDrop();
unregisterPaste();
};
}

View File

@ -6,9 +6,7 @@
## Main Todo
- Alignments: Handle inline block content (image, video)
- Image paste upload
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Media resize support (like images)
- Table caption text support

View File

@ -24,4 +24,21 @@ export function $createLinkedImageNodeFromImageData(image: EditorImageData): Lin
});
linkNode.append(imageNode);
return linkNode;
}
/**
* Upload an image file to the server
*/
export async function uploadImageFile(file: File, pageId: string): Promise<EditorImageData> {
if (file === null || file.type.indexOf('image') !== 0) {
throw new Error('Not an image file');
}
const remoteFilename = file.name || `image-${Date.now()}.png`;
const formData = new FormData();
formData.append('file', file, remoteFilename);
formData.append('uploaded_to', pageId);
const resp = await window.$http.post('/images/gallery', formData);
return resp.data as EditorImageData;
}