nuxt-tiptap-editor
Version:
Essentials to Quickly Integrate TipTap Editor into your Nuxt App
231 lines (230 loc) • 7.32 kB
JavaScript
import { Fragment, Slice } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
export const imageUploaderPluginKey = new PluginKey("imageUploader");
export function createPlaceholderNode(ctx, schema, uploadId, attrs = {}) {
const src = attrs.src ?? attrs["data-src"] ?? "";
ctx.storage.cache.set(uploadId, src);
const placeholderType = schema.nodes.imagePlaceholder;
if (!placeholderType) {
throw new Error(
"[nuxt-tiptap-editor] imagePlaceholder node not found in schema. Add TiptapImagePlaceholder to the editor extensions."
);
}
return placeholderType.create({ ...attrs, src: "", uploadId });
}
export async function uploadAndReplaceById(ctx, view, id, fileOrUrl) {
await uploadAndReplace(ctx, view, fileOrUrl, id);
}
export function imageUploader(ctx) {
return new Plugin({
key: imageUploaderPluginKey,
props: {
handlePaste(view, event) {
return handlePaste(ctx, view, event);
},
transformPasted(slice, view) {
return transformPasted(ctx, view, slice);
},
handleDrop(view, event) {
return handleDrop(ctx, view, event);
}
}
});
}
function handleDrop(ctx, view, event) {
if (!event.dataTransfer?.files.length) return false;
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY
});
if (!coordinates) return false;
const imageFiles = Array.from(event.dataTransfer.files).filter(
(file) => ctx.options.acceptMimes.includes(file.type)
);
if (!imageFiles.length) return false;
imageFiles.forEach((file, i) => {
insertPlaceholderAndUpload(ctx, view, file, coordinates.pos + i);
});
return true;
}
function handlePaste(ctx, view, event) {
const items = Array.from(event.clipboardData?.items || []);
if (items.some((x) => x.type === "text/html")) {
return false;
}
const image = items.find((item) => ctx.options.acceptMimes.includes(item.type));
if (!image) return false;
const file = image.getAsFile();
if (!file) return false;
insertPlaceholderAndUpload(ctx, view, file, view.state.selection.from);
return true;
}
function transformPasted(ctx, view, slice) {
const queued = [];
const children = [];
slice.content.forEach((child) => {
let newChild = child;
if (child.type.name === "image" && !isOurOwnPic(ctx, child.attrs)) {
newChild = newPlaceholderNode(ctx, view, child.attrs);
queued.push({
id: newChild.attrs.uploadId,
url: child.attrs.src || child.attrs["data-src"]
});
} else {
child.descendants((node, pos) => {
if (node.type.name === "image" && !isOurOwnPic(ctx, node.attrs)) {
const placeholder = newPlaceholderNode(ctx, view, node.attrs);
newChild = newChild.replace(
pos,
pos + 1,
new Slice(Fragment.from(placeholder), 0, 0)
);
queued.push({
id: placeholder.attrs.uploadId,
url: node.attrs.src || node.attrs["data-src"]
});
}
});
}
children.push(newChild);
});
queued.forEach(({ url, id }) => {
void uploadAndReplace(ctx, view, url, id);
});
return new Slice(
Fragment.fromArray(children),
slice.openStart,
slice.openEnd
);
}
export function insertPlaceholderAndUpload(ctx, view, fileOrUrl, at) {
let pos = at;
const tr = view.state.tr;
if (!tr.selection.empty) {
tr.deleteSelection();
}
if (pos < 0) {
pos = view.state.selection.from;
}
const node = newPlaceholderNode(ctx, view, { src: fileOrUrl });
tr.replaceWith(pos, pos, node);
view.dispatch(tr);
void uploadAndReplace(ctx, view, fileOrUrl, node.attrs.uploadId);
}
function newPlaceholderNode(ctx, view, attrs) {
const uploadId = ctx.options.id();
return createPlaceholderNode(ctx, view.state.schema, uploadId, attrs);
}
async function uploadAndReplace(ctx, view, fileOrUrl, id) {
let file = fileOrUrl;
if (typeof file === "string") {
try {
const fetched = await webImg2File(file);
if (!fetched) {
removePlaceholder(view, id);
ctx.storage.cache.delete(id);
return;
}
file = fetched;
} catch (err) {
console.warn(
"[nuxt-tiptap-editor] failed to fetch pasted image URL:",
err
);
removePlaceholder(view, id);
ctx.storage.cache.delete(id);
return;
}
}
let url;
try {
url = await ctx.options.upload(file, id);
} catch (err) {
console.warn("[nuxt-tiptap-editor] image upload failed:", err);
}
replaceOrRemovePlaceholder(view, id, url);
ctx.storage.cache.delete(id);
}
function findPlaceholderPositions(view, id) {
const positions = [];
view.state.doc.descendants((node, pos) => {
if (node.type.name === "imagePlaceholder" && node.attrs.uploadId === id) {
positions.push({ node, pos });
}
});
return positions;
}
function replaceOrRemovePlaceholder(view, id, url) {
const positions = findPlaceholderPositions(view, id);
if (!positions.length) return;
const tr = view.state.tr;
positions.forEach(({ node, pos }) => {
if (url) {
const imageType = view.state.schema.nodes.image;
if (!imageType) {
throw new Error(
"[nuxt-tiptap-editor] image node not found in schema. Add TiptapImage to the editor extensions."
);
}
const imageNode = imageType.create({ ...node.attrs, src: url });
tr.replaceWith(pos, pos + 1, imageNode);
} else {
tr.delete(pos, pos + 1);
}
});
view.dispatch(tr);
}
function removePlaceholder(view, id) {
replaceOrRemovePlaceholder(view, id, void 0);
}
function isOurOwnPic(ctx, attrs) {
const src = attrs.src || attrs["data-src"] || "";
return (ctx.options.ignoreDomains || []).some((domain) => src.includes(domain));
}
async function webImg2File(imgUrl) {
const base = await imgToBase64(imgUrl);
return base64ToFile(base, "Web Image");
}
function imgToBase64(url) {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.crossOrigin = "Anonymous";
img.setAttribute("referrerpolicy", "no-referrer");
img.onload = () => {
try {
canvas.height = img.height;
canvas.width = img.width;
ctx?.drawImage(img, 0, 0);
resolve(canvas.toDataURL("image/png"));
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
};
img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
img.src = url;
});
}
function base64ToFile(base, filename) {
const arr = base.split(",");
const prefix = arr[0];
if (!prefix) {
throw new Error("Invalid base64 format: data prefix not found");
}
const mimeMatch = prefix.match(/:(.*?);/);
if (!mimeMatch?.[1]) {
throw new Error("Invalid base64 format: mime type not found");
}
const mime = mimeMatch[1];
const suffix = mime.split("/")[1] ?? "bin";
const data = arr[1];
if (!data) {
throw new Error("Invalid base64 format: data not found");
}
const bstr = atob(data);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) u8arr[n] = bstr.charCodeAt(n);
return new File([u8arr], `${filename}.${suffix}`, { type: mime });
}