nuxt-tiptap-editor
Version:
Essentials to Quickly Integrate TipTap Editor into your Nuxt App
235 lines (234 loc) • 6.9 kB
JavaScript
import { Fragment, Slice } from "prosemirror-model";
import "prosemirror-replaceattrs";
import { Plugin } from "prosemirror-state";
let plugin = null;
const fileCache = {};
export function imageUploader(options) {
plugin = new ImageUploaderPlugin(options);
const dummy = {};
return new Plugin({
props: {
handleDOMEvents: {
keydown(view) {
return !plugin?.setView(view);
},
drop(view) {
return !plugin?.setView(view);
},
focus(view) {
return !plugin?.setView(view);
}
},
handlePaste(view, event) {
return plugin?.setView(view).handlePaste(event) || false;
},
transformPasted(slice) {
return plugin?.transformPasted(slice) || slice;
},
handleDrop(view, event) {
return plugin?.setView(view).handleDrop(event) || false;
}
},
state: {
init() {
return dummy;
},
apply(tr, _value, _oldState, newState) {
const filesOrUrls = tr.getMeta("uploadImages");
if (filesOrUrls) {
const arr = typeof filesOrUrls === "string" || filesOrUrls instanceof File ? [filesOrUrls] : Array.from(filesOrUrls);
setTimeout(() => {
arr.forEach(
(item, i) => plugin?.beforeUpload(item, newState.selection.from + i)
);
tr.setMeta("uploadImages", void 0);
}, 10);
}
return dummy;
}
}
});
}
export class ImageUploaderPlugin {
constructor(config) {
this.config = config;
}
view;
handleDrop(event) {
if (!event.dataTransfer?.files.length) return;
const coordinates = this.view.posAtCoords({
left: event.clientX,
top: event.clientY
});
if (!coordinates) return;
const imageFiles = Array.from(event.dataTransfer.files).filter(
(file) => this.config.acceptMimes.includes(file.type)
);
if (!imageFiles.length) return;
imageFiles.forEach((file, i) => {
this.beforeUpload(file, coordinates.pos + i);
});
return true;
}
transformPasted(slice) {
const imageNodes = [];
const children = [];
slice.content.forEach((child) => {
let newChild = child;
if (child.type.name === "image" && !this.isOurOwnPic(child.attrs)) {
newChild = this.newUploadingImageNode(child.attrs);
imageNodes.push({
id: newChild.attrs.uploadId,
url: child.attrs.src || child.attrs["data-src"]
});
} else {
child.descendants((node, pos) => {
if (node.type.name === "image" && !this.isOurOwnPic(node.attrs)) {
const imageNode = this.newUploadingImageNode(node.attrs);
newChild = newChild.replace(
pos,
pos + 1,
new Slice(Fragment.from(imageNode), 0, 0)
);
imageNodes.push({
id: imageNode.attrs.uploadId,
url: node.attrs.src || node.attrs["data-src"]
});
}
});
}
children.push(newChild);
});
imageNodes.forEach(({ url, id }) => this.uploadImageForId(url, id));
return new Slice(
Fragment.fromArray(children),
slice.openStart,
slice.openEnd
);
}
handlePaste(event) {
const items = Array.from(event.clipboardData?.items || []);
if (items.some((x) => x.type === "text/html")) {
return false;
}
const image = items.find(
(item) => this.config.acceptMimes.includes(item.type)
);
if (image) {
this.beforeUpload(image.getAsFile(), this.view.state.selection.from);
return true;
}
return false;
}
beforeUpload(fileOrUrl, at) {
const tr = this.view.state.tr;
if (!tr.selection.empty) {
tr.deleteSelection();
}
if (at < 0) {
at = this.view.state.selection.from;
}
const node = this.newUploadingImageNode({ src: fileOrUrl });
tr.replaceWith(at, at, node);
this.view.dispatch(tr);
this.uploadImageForId(fileOrUrl, node.attrs.uploadId);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
newUploadingImageNode(attrs) {
const uploadId = this.config.id();
fileCache[uploadId] = attrs.src || attrs["data-src"];
return this.view.state.schema.nodes.imagePlaceholder.create({
...attrs,
src: "",
// attrs.src,
uploadId
});
}
async uploadImageForId(fileOrUrl, id) {
const getImagePositions = () => {
const positions = [];
this.view.state.doc.descendants(
(node, pos) => {
if (node.type.name === "imagePlaceholder" && node.attrs.uploadId === id) {
positions.push({ node, pos });
}
}
);
return positions;
};
let file = fileOrUrl;
if (typeof file === "string") {
file = await webImg2File(file);
}
const url = file && await this.config.upload(file, id).catch(console.warn);
const imageNodes = getImagePositions();
if (!imageNodes.length) {
return;
}
const tr = this.view.state.tr;
imageNodes.forEach(({ node, pos }) => {
if (url) {
const newNode = this.view.state.schema.nodes.image.create({
...node.attrs,
width: node.attrs.width,
src: url
});
tr.replaceWith(pos, pos + 1, newNode);
} else {
tr.delete(pos, pos + 1);
}
});
this.view.dispatch(tr);
fileCache[id] = "";
}
setView(view) {
this.view = view;
return this;
}
isOurOwnPic(attrs) {
const src = attrs.src || attrs["data-src"] || "";
return (this.config.ignoreDomains || []).some(
(domain) => src.includes(domain)
);
}
}
async function webImg2File(imgUrl) {
function imgToBase64(url) {
let canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"), img = new Image();
img.crossOrigin = "Anonymous";
img.setAttribute("referrerpolicy", "no-referrer");
img.src = url;
return new Promise((resolve, reject) => {
img.onload = function() {
canvas.height = img.height;
canvas.width = img.width;
ctx?.drawImage(img, 0, 0);
const dataURL = canvas.toDataURL("image/png");
resolve(dataURL);
canvas = null;
};
img.onerror = reject;
});
}
function base64toFile(base, filename) {
const arr = base.split(",");
const mime = arr[0].match(/:(.*?);/)[1];
const suffix = mime.split("/")[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], `${filename}.${suffix}`, { type: mime });
}
return imgToBase64(imgUrl).then((base) => {
return base64toFile(base, "Web Image");
}).catch(() => {
return null;
});
}
export function getFileCache(key) {
return fileCache[key];
}