172 lines
6.5 KiB
JavaScript
172 lines
6.5 KiB
JavaScript
import {Plugin} from "prosemirror-state";
|
|
import ProseMirrorPlugin from "./plugin.mjs";
|
|
import {hasFileExtension, isBase64Data} from "../data/validators.mjs";
|
|
import {dom} from "./_module.mjs";
|
|
|
|
/**
|
|
* A class responsible for handle drag-and-drop and pasting of image content. Ensuring no base64 data is injected
|
|
* directly into the journal content and it is instead uploaded to the user's data directory.
|
|
* @extends {ProseMirrorPlugin}
|
|
*/
|
|
export default class ProseMirrorImagePlugin extends ProseMirrorPlugin {
|
|
/**
|
|
* @param {Schema} schema The ProseMirror schema.
|
|
* @param {object} options Additional options to configure the plugin's behaviour.
|
|
* @param {ClientDocument} options.document A related Document to store extract base64 images for.
|
|
*/
|
|
constructor(schema, {document}={}) {
|
|
super(schema);
|
|
|
|
if ( !document ) {
|
|
throw new Error("The image drop and pasting plugin requires a reference to a related Document to function.");
|
|
}
|
|
|
|
/**
|
|
* The related Document to store extracted base64 images for.
|
|
* @type {ClientDocument}
|
|
*/
|
|
Object.defineProperty(this, "document", {value: document, writable: false});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
static build(schema, options={}) {
|
|
const plugin = new ProseMirrorImagePlugin(schema, options);
|
|
return new Plugin({
|
|
props: {
|
|
handleDrop: plugin._onDrop.bind(plugin),
|
|
handlePaste: plugin._onPaste.bind(plugin)
|
|
}
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle a drop onto the editor.
|
|
* @param {EditorView} view The ProseMirror editor view.
|
|
* @param {DragEvent} event The drop event.
|
|
* @param {Slice} slice A slice of editor content.
|
|
* @param {boolean} moved Whether the slice has been moved from a different part of the editor.
|
|
* @protected
|
|
*/
|
|
_onDrop(view, event, slice, moved) {
|
|
// This is a drag-drop of internal editor content which we do not need to handle specially.
|
|
if ( moved ) return;
|
|
const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
|
|
if ( !pos ) return; // This was somehow dropped outside the editor content.
|
|
|
|
if ( event.dataTransfer.types.some(t => t === "text/uri-list") ) {
|
|
const uri = event.dataTransfer.getData("text/uri-list");
|
|
if ( !isBase64Data(uri) ) return; // This is a direct URL hotlink which we can just embed without issue.
|
|
}
|
|
|
|
// Handle image drops.
|
|
if ( event.dataTransfer.files.length ) {
|
|
this._uploadImages(view, event.dataTransfer.files, pos.pos);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle a paste into the editor.
|
|
* @param {EditorView} view The ProseMirror editor view.
|
|
* @param {ClipboardEvent} event The paste event.
|
|
* @protected
|
|
*/
|
|
_onPaste(view, event) {
|
|
if ( event.clipboardData.files.length ) {
|
|
this._uploadImages(view, event.clipboardData.files);
|
|
return true;
|
|
}
|
|
const html = event.clipboardData.getData("text/html");
|
|
if ( !html ) return; // We only care about handling rich content.
|
|
const images = this._extractBase64Images(html);
|
|
if ( !images.length ) return; // If there were no base64 images, defer to the default paste handler.
|
|
this._replaceBase64Images(view, html, images);
|
|
return true;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Upload any image files encountered in the drop.
|
|
* @param {EditorView} view The ProseMirror editor view.
|
|
* @param {FileList} files The files to upload.
|
|
* @param {number} [pos] The position in the document to insert at. If not provided, the current selection will be
|
|
* replaced instead.
|
|
* @protected
|
|
*/
|
|
async _uploadImages(view, files, pos) {
|
|
const image = this.schema.nodes.image;
|
|
const imageExtensions = Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
|
|
for ( const file of files ) {
|
|
if ( !hasFileExtension(file.name, imageExtensions) ) continue;
|
|
const src = await TextEditor._uploadImage(this.document.uuid, file);
|
|
if ( !src ) continue;
|
|
const node = image.create({src});
|
|
if ( pos === undefined ) {
|
|
pos = view.state.selection.from;
|
|
view.dispatch(view.state.tr.replaceSelectionWith(node));
|
|
} else view.dispatch(view.state.tr.insert(pos, node));
|
|
pos += 2; // Advance the position past the just-inserted image so the next image is inserted below it.
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Capture any base64-encoded images embedded in the rich text paste and upload them.
|
|
* @param {EditorView} view The ProseMirror editor view.
|
|
* @param {string} html The HTML data as a string.
|
|
* @param {[full: string, mime: string, data: string][]} images An array of extracted base64 image data.
|
|
* @protected
|
|
*/
|
|
async _replaceBase64Images(view, html, images) {
|
|
const byMimetype = Object.fromEntries(Object.entries(CONST.IMAGE_FILE_EXTENSIONS).map(([k, v]) => [v, k]));
|
|
let cleaned = html;
|
|
for ( const [full, mime, data] of images ) {
|
|
const file = this.constructor.base64ToFile(data, `pasted-image.${byMimetype[mime]}`, mime);
|
|
const path = await TextEditor._uploadImage(this.document.uuid, file) ?? "";
|
|
cleaned = cleaned.replace(full, path);
|
|
}
|
|
const doc = dom.parseString(cleaned);
|
|
view.dispatch(view.state.tr.replaceSelectionWith(doc));
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Detect base64 image data embedded in an HTML string and extract it.
|
|
* @param {string} html The HTML data as a string.
|
|
* @returns {[full: string, mime: string, data: string][]}
|
|
* @protected
|
|
*/
|
|
_extractBase64Images(html) {
|
|
const images = Object.values(CONST.IMAGE_FILE_EXTENSIONS);
|
|
const rgx = new RegExp(`data:(${images.join("|")});base64,([^"']+)`, "g");
|
|
return [...html.matchAll(rgx)];
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Convert a base64 string into a File object.
|
|
* @param {string} data Base64 encoded data.
|
|
* @param {string} filename The filename.
|
|
* @param {string} mimetype The file's mimetype.
|
|
* @returns {File}
|
|
*/
|
|
static base64ToFile(data, filename, mimetype) {
|
|
const bin = atob(data);
|
|
let n = bin.length;
|
|
const buf = new ArrayBuffer(n);
|
|
const bytes = new Uint8Array(buf);
|
|
while ( n-- ) bytes[n] = bin.charCodeAt(n);
|
|
return new File([bytes], filename, {type: mimetype});
|
|
}
|
|
}
|