UNPKG

nuxt-tiptap-editor

Version:

Essentials to Quickly Integrate TipTap Editor into your Nuxt App

231 lines (230 loc) 7.32 kB
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 }); }