refactor: enhance image handling in message editor with centralized functions

This commit is contained in:
mlogclub
2026-04-25 17:37:34 +08:00
parent 94ac7d613c
commit 2af8d831a7
3 changed files with 151 additions and 100 deletions
+27 -50
View File
@@ -19,6 +19,14 @@ import {
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
import {
buildSendableEditorHTML,
hasUploadingEditorImages,
markEditorImageUploadedByTitle,
removeEditorImageByTitle,
revokeEditorObjectUrl,
revokeEditorObjectUrls,
} from "@/lib/im-editor-image"
import { generateUUID } from "@/lib/utils"
type UploadedImage = {
@@ -76,6 +84,7 @@ export function ImMessageEditor({
const onUploadImageRef = useRef(onUploadImage)
const onSendAttachmentRef = useRef(onSendAttachment)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
const [loadingQuickReplies, setLoadingQuickReplies] = useState(false)
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
@@ -92,6 +101,13 @@ export function ImMessageEditor({
onSendAttachmentRef.current = onSendAttachment
}, [onSendAttachment])
useEffect(() => {
const objectUrls = objectUrlsRef.current
return () => {
revokeEditorObjectUrls(objectUrls)
}
}, [])
useEffect(() => {
let cancelled = false
setLoadingQuickReplies(true)
@@ -181,12 +197,17 @@ export function ImMessageEditor({
if (!editor || disabled || uploadingAsset) {
return
}
const html = editor.getHTML()
const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML)) {
return
}
const html = buildSendableEditorHTML(rawHTML)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
requestAnimationFrame(() => {
editor.commands.focus("end")
})
@@ -212,6 +233,7 @@ export function ImMessageEditor({
}
shouldRestoreFocusRef.current = true
const objectUrl = URL.createObjectURL(file)
objectUrlsRef.current.add(objectUrl)
const placeholderId = `uploading-${generateUUID()}`
editor
.chain()
@@ -225,13 +247,13 @@ export function ImMessageEditor({
try {
const uploaded = await onUploadImageRef.current(file)
if (!uploaded?.url) {
removeImageByTitle(editor, placeholderId)
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
removeEditorImageByTitle(editor, placeholderId)
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
return
}
replaceImageSourceByTitle(editor, placeholderId, uploaded)
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
} finally {
URL.revokeObjectURL(objectUrl)
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
@@ -396,48 +418,3 @@ function getClipboardImageFile(clipboardData: DataTransfer | null) {
}
return null
}
function removeImageByTitle(editor: NonNullable<ReturnType<typeof useEditor>>, 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()
}
function replaceImageSourceByTitle(
editor: NonNullable<ReturnType<typeof useEditor>>,
title: string,
uploaded: UploadedImage
) {
const { state, view } = 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
}
const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, {
...view.state.doc.nodeAt(targetPos)?.attrs,
src: uploaded.url,
alt: uploaded.filename || "image",
dataAssetId: uploaded.assetId,
dataProvider: uploaded.provider,
dataStorageKey: uploaded.storageKey,
title: "",
})
view.dispatch(transaction)
}
+27 -50
View File
@@ -8,6 +8,14 @@ import StarterKit from "@tiptap/starter-kit"
import { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
buildSendableEditorHTML,
hasUploadingEditorImages,
markEditorImageUploadedByTitle,
removeEditorImageByTitle,
revokeEditorObjectUrl,
revokeEditorObjectUrls,
} from "@/lib/im-editor-image"
import { generateUUID } from "@/lib/utils"
type UploadedImage = {
@@ -66,8 +74,16 @@ export function KefuMessageEditor({
const onUploadImageRef = useRef(onUploadImage)
const onSendAttachmentRef = useRef(onSendAttachment)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const isUploading = uploadingAsset || localUploading
useEffect(() => {
const objectUrls = objectUrlsRef.current
return () => {
revokeEditorObjectUrls(objectUrls)
}
}, [])
useEffect(() => {
onSendRef.current = onSend
}, [onSend])
@@ -145,12 +161,17 @@ export function KefuMessageEditor({
if (!editor || disabled || isUploading) {
return
}
const html = editor.getHTML()
const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML)) {
return
}
const html = buildSendableEditorHTML(rawHTML)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
}
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
@@ -174,6 +195,7 @@ export function KefuMessageEditor({
shouldRestoreFocusRef.current = true
const objectUrl = URL.createObjectURL(file)
objectUrlsRef.current.add(objectUrl)
const placeholderId = `uploading-${generateUUID()}`
editor
.chain()
@@ -188,14 +210,14 @@ export function KefuMessageEditor({
try {
setLocalUploading(true)
const uploaded = await onUploadImageRef.current(file)
if (!uploaded?.url) {
removeImageByTitle(editor, placeholderId)
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
removeEditorImageByTitle(editor, placeholderId)
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
return
}
replaceImageSourceByTitle(editor, placeholderId, uploaded)
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
} finally {
setLocalUploading(false)
URL.revokeObjectURL(objectUrl)
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
editor.commands.focus()
@@ -331,48 +353,3 @@ function getClipboardImageFile(data: DataTransfer | null) {
}
return null
}
function removeImageByTitle(editor: NonNullable<ReturnType<typeof useEditor>>, 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()
}
function replaceImageSourceByTitle(
editor: NonNullable<ReturnType<typeof useEditor>>,
title: string,
uploaded: UploadedImage
) {
const { state, view } = 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
}
const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, {
...view.state.doc.nodeAt(targetPos)?.attrs,
src: uploaded.url,
alt: uploaded.filename || "image",
dataAssetId: uploaded.assetId,
dataProvider: uploaded.provider,
dataStorageKey: uploaded.storageKey,
title: "",
})
view.dispatch(transaction)
}
+97
View File
@@ -0,0 +1,97 @@
import type { Editor } from "@tiptap/react"
export type UploadedEditorImage = {
assetId: string
provider: string
storageKey: string
filename?: string
}
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,
uploaded: UploadedEditorImage
) {
const { state, view } = 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
}
const attrs = view.state.doc.nodeAt(targetPos)?.attrs
const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, {
...attrs,
alt: uploaded.filename || attrs?.alt || "image",
dataAssetId: uploaded.assetId,
dataProvider: uploaded.provider,
dataStorageKey: uploaded.storageKey,
title: "",
})
view.dispatch(transaction)
}
export function buildSendableEditorHTML(html: string) {
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"))) {
if (
image.getAttribute("data-asset-id") &&
image.getAttribute("src")?.startsWith("blob:")
) {
image.removeAttribute("src")
}
}
return template.innerHTML
}
export function hasUploadingEditorImages(html: string) {
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) =>
image.getAttribute("title")?.startsWith("uploading-")
)
}
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()
}