diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index d7f873ea5..fdcfa5b7e 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -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); diff --git a/resources/js/wysiwyg/services/drop-handling.ts b/resources/js/wysiwyg/services/drop-handling.ts deleted file mode 100644 index 7c9bb2713..000000000 --- a/resources/js/wysiwyg/services/drop-handling.ts +++ /dev/null @@ -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); - }); -} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts new file mode 100644 index 000000000..85d0235d8 --- /dev/null +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -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(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index f05e79baa..f339a6ed4 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -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 diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts index a83d55418..2c13427d9 100644 --- a/resources/js/wysiwyg/utils/images.ts +++ b/resources/js/wysiwyg/utils/images.ts @@ -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 { + 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; } \ No newline at end of file