refactor: enhance image upload handling in message editors with centralized logic

This commit is contained in:
mlogclub
2026-04-25 17:38:07 +08:00
parent 2af8d831a7
commit 65d8a9267c
3 changed files with 68 additions and 30 deletions
+10 -3
View File
@@ -85,6 +85,7 @@ export function ImMessageEditor({
const onSendAttachmentRef = useRef(onSendAttachment)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedImage>())
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
const [loadingQuickReplies, setLoadingQuickReplies] = useState(false)
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
@@ -198,16 +199,17 @@ export function ImMessageEditor({
return
}
const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML)) {
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
return
}
const html = buildSendableEditorHTML(rawHTML)
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
uploadedImagesRef.current.clear()
requestAnimationFrame(() => {
editor.commands.focus("end")
})
@@ -252,7 +254,12 @@ export function ImMessageEditor({
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
return
}
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
markEditorImageUploadedByTitle(
editor,
placeholderId,
uploaded,
uploadedImagesRef.current
)
} finally {
requestAnimationFrame(() => {
if (!disabled && shouldRestoreFocusRef.current) {
+10 -3
View File
@@ -75,6 +75,7 @@ export function KefuMessageEditor({
const onSendAttachmentRef = useRef(onSendAttachment)
const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedImage>())
const isUploading = uploadingAsset || localUploading
useEffect(() => {
@@ -162,16 +163,17 @@ export function KefuMessageEditor({
return
}
const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML)) {
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
return
}
const html = buildSendableEditorHTML(rawHTML)
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
if (!isMeaningfulHTML(html)) {
return
}
await onSendRef.current(html)
editor.commands.clearContent(true)
revokeEditorObjectUrls(objectUrlsRef.current)
uploadedImagesRef.current.clear()
}
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
@@ -215,7 +217,12 @@ export function KefuMessageEditor({
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
return
}
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
markEditorImageUploadedByTitle(
editor,
placeholderId,
uploaded,
uploadedImagesRef.current
)
} finally {
setLocalUploading(false)
requestAnimationFrame(() => {
+48 -24
View File
@@ -7,6 +7,8 @@ export type UploadedEditorImage = {
filename?: string
}
export type UploadedEditorImageMap = Map<string, UploadedEditorImage>
export function removeEditorImageByTitle(editor: Editor, title: string) {
const { state } = editor
let targetPos: number | null = null
@@ -26,33 +28,25 @@ export function removeEditorImageByTitle(editor: Editor, title: string) {
export function markEditorImageUploadedByTitle(
editor: Editor,
title: string,
uploaded: UploadedEditorImage
uploaded: UploadedEditorImage,
uploadedImages: UploadedEditorImageMap
) {
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) {
uploadedImages.set(title, uploaded)
const image = findEditorImageElementByTitle(editor, title)
if (!image) {
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)
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")
}
export function buildSendableEditorHTML(html: string) {
export function buildSendableEditorHTML(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
if (typeof document === "undefined" || !html.includes("<img")) {
return html
}
@@ -60,6 +54,15 @@ export function buildSendableEditorHTML(html: string) {
const template = document.createElement("template")
template.innerHTML = html
for (const image of Array.from(template.content.querySelectorAll("img"))) {
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")
}
if (
image.getAttribute("data-asset-id") &&
image.getAttribute("src")?.startsWith("blob:")
@@ -70,7 +73,10 @@ export function buildSendableEditorHTML(html: string) {
return template.innerHTML
}
export function hasUploadingEditorImages(html: string) {
export function hasUploadingEditorImages(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
if (typeof document === "undefined" || !html.includes("<img")) {
return /<img\b[^>]*\btitle=(["'])uploading-[^"']+\1/i.test(html)
}
@@ -78,7 +84,7 @@ export function hasUploadingEditorImages(html: string) {
const template = document.createElement("template")
template.innerHTML = html
return Array.from(template.content.querySelectorAll("img")).some((image) =>
image.getAttribute("title")?.startsWith("uploading-")
isUnfinishedUploadingImage(image, uploadedImages)
)
}
@@ -95,3 +101,21 @@ export function revokeEditorObjectUrls(urls: Set<string>) {
}
urls.clear()
}
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)
}