mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Added drop/paste image handling
This commit is contained in:
parent
dbb2fe3e59
commit
ddf5f2543c
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
158
resources/js/wysiwyg/services/drop-paste-handling.ts
Normal file
158
resources/js/wysiwyg/services/drop-paste-handling.ts
Normal 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();
|
||||
};
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user