2026-04-25 17:37:34 +08:00
|
|
|
import type { Editor } from "@tiptap/react"
|
|
|
|
|
|
|
|
|
|
export type UploadedEditorImage = {
|
|
|
|
|
assetId: string
|
|
|
|
|
provider: string
|
|
|
|
|
storageKey: string
|
|
|
|
|
filename?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 17:38:07 +08:00
|
|
|
export type UploadedEditorImageMap = Map<string, UploadedEditorImage>
|
|
|
|
|
|
2026-04-25 17:37:34 +08:00
|
|
|
export function removeEditorImageByTitle(editor: Editor, title: string) {
|
|
|
|
|
const { state } = editor
|
|
|
|
|
let targetPos: number | null = null
|
|
|
|
|
state.doc.descendants((node, pos) => {
|
|
|
|
|
if (node.type.name === "image" && node.attrs.title === title) {
|
|
|
|
|
targetPos = pos
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
if (targetPos === null) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function markEditorImageUploadedByTitle(
|
|
|
|
|
editor: Editor,
|
|
|
|
|
title: string,
|
2026-04-25 17:38:07 +08:00
|
|
|
uploaded: UploadedEditorImage,
|
|
|
|
|
uploadedImages: UploadedEditorImageMap
|
2026-04-25 17:37:34 +08:00
|
|
|
) {
|
2026-04-25 17:38:07 +08:00
|
|
|
uploadedImages.set(title, uploaded)
|
|
|
|
|
const image = findEditorImageElementByTitle(editor, title)
|
|
|
|
|
if (!image) {
|
2026-04-25 17:37:34 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-04-25 17:38:07 +08:00
|
|
|
image.setAttribute("data-asset-id", uploaded.assetId)
|
|
|
|
|
image.setAttribute("data-provider", uploaded.provider)
|
|
|
|
|
image.setAttribute("data-storage-key", uploaded.storageKey)
|
|
|
|
|
image.setAttribute("alt", uploaded.filename || image.getAttribute("alt") || "image")
|
|
|
|
|
image.removeAttribute("title")
|
2026-04-25 17:37:34 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 17:38:07 +08:00
|
|
|
export function buildSendableEditorHTML(
|
|
|
|
|
html: string,
|
|
|
|
|
uploadedImages?: UploadedEditorImageMap
|
|
|
|
|
) {
|
2026-04-25 17:37:34 +08:00
|
|
|
if (typeof document === "undefined" || !html.includes("<img")) {
|
|
|
|
|
return html
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const template = document.createElement("template")
|
|
|
|
|
template.innerHTML = html
|
|
|
|
|
for (const image of Array.from(template.content.querySelectorAll("img"))) {
|
2026-04-25 17:38:07 +08:00
|
|
|
const title = image.getAttribute("title") ?? ""
|
|
|
|
|
const uploaded = title ? uploadedImages?.get(title) : undefined
|
|
|
|
|
if (uploaded) {
|
|
|
|
|
image.setAttribute("data-asset-id", uploaded.assetId)
|
|
|
|
|
image.setAttribute("data-provider", uploaded.provider)
|
|
|
|
|
image.setAttribute("data-storage-key", uploaded.storageKey)
|
|
|
|
|
image.setAttribute("alt", uploaded.filename || image.getAttribute("alt") || "image")
|
|
|
|
|
image.removeAttribute("title")
|
|
|
|
|
}
|
2026-04-25 17:37:34 +08:00
|
|
|
if (
|
|
|
|
|
image.getAttribute("data-asset-id") &&
|
|
|
|
|
image.getAttribute("src")?.startsWith("blob:")
|
|
|
|
|
) {
|
|
|
|
|
image.removeAttribute("src")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return template.innerHTML
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 17:38:07 +08:00
|
|
|
export function hasUploadingEditorImages(
|
|
|
|
|
html: string,
|
|
|
|
|
uploadedImages?: UploadedEditorImageMap
|
|
|
|
|
) {
|
2026-04-25 17:37:34 +08:00
|
|
|
if (typeof document === "undefined" || !html.includes("<img")) {
|
|
|
|
|
return /<img\b[^>]*\btitle=(["'])uploading-[^"']+\1/i.test(html)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const template = document.createElement("template")
|
|
|
|
|
template.innerHTML = html
|
|
|
|
|
return Array.from(template.content.querySelectorAll("img")).some((image) =>
|
2026-04-25 17:38:07 +08:00
|
|
|
isUnfinishedUploadingImage(image, uploadedImages)
|
2026-04-25 17:37:34 +08:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function revokeEditorObjectUrl(urls: Set<string>, objectUrl: string) {
|
|
|
|
|
if (!urls.delete(objectUrl)) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
URL.revokeObjectURL(objectUrl)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function revokeEditorObjectUrls(urls: Set<string>) {
|
|
|
|
|
for (const objectUrl of urls) {
|
|
|
|
|
URL.revokeObjectURL(objectUrl)
|
|
|
|
|
}
|
|
|
|
|
urls.clear()
|
|
|
|
|
}
|
2026-04-25 17:38:07 +08:00
|
|
|
|
|
|
|
|
function findEditorImageElementByTitle(editor: Editor, title: string) {
|
|
|
|
|
const escapedTitle =
|
|
|
|
|
typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
|
|
|
|
? CSS.escape(title)
|
|
|
|
|
: title.replace(/["\\]/g, "\\$&")
|
|
|
|
|
return editor.view.dom.querySelector<HTMLImageElement>(
|
|
|
|
|
`img[title="${escapedTitle}"]`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isUnfinishedUploadingImage(
|
|
|
|
|
image: HTMLImageElement,
|
|
|
|
|
uploadedImages?: UploadedEditorImageMap
|
|
|
|
|
) {
|
|
|
|
|
const title = image.getAttribute("title") ?? ""
|
|
|
|
|
return title.startsWith("uploading-") && !uploadedImages?.has(title)
|
|
|
|
|
}
|