From 65d8a9267c4fcdae023b9363474a8f89360d6ab6 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 25 Apr 2026 17:38:07 +0800 Subject: [PATCH] refactor: enhance image upload handling in message editors with centralized logic --- web/components/im-message-editor.tsx | 13 +++-- web/components/kefu/message-editor.tsx | 13 +++-- web/lib/im-editor-image.ts | 72 +++++++++++++++++--------- 3 files changed, 68 insertions(+), 30 deletions(-) diff --git a/web/components/im-message-editor.tsx b/web/components/im-message-editor.tsx index 246b4ea..075a4eb 100644 --- a/web/components/im-message-editor.tsx +++ b/web/components/im-message-editor.tsx @@ -85,6 +85,7 @@ export function ImMessageEditor({ const onSendAttachmentRef = useRef(onSendAttachment) const shouldRestoreFocusRef = useRef(false) const objectUrlsRef = useRef>(new Set()) + const uploadedImagesRef = useRef(new Map()) const [quickReplies, setQuickReplies] = useState([]) 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) { diff --git a/web/components/kefu/message-editor.tsx b/web/components/kefu/message-editor.tsx index 6786221..531f14f 100644 --- a/web/components/kefu/message-editor.tsx +++ b/web/components/kefu/message-editor.tsx @@ -75,6 +75,7 @@ export function KefuMessageEditor({ const onSendAttachmentRef = useRef(onSendAttachment) const shouldRestoreFocusRef = useRef(false) const objectUrlsRef = useRef>(new Set()) + const uploadedImagesRef = useRef(new Map()) 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) { @@ -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(() => { diff --git a/web/lib/im-editor-image.ts b/web/lib/im-editor-image.ts index 2dd6a8a..df3caf6 100644 --- a/web/lib/im-editor-image.ts +++ b/web/lib/im-editor-image.ts @@ -7,6 +7,8 @@ export type UploadedEditorImage = { filename?: string } +export type UploadedEditorImageMap = Map + 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("]*\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) { } 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( + `img[title="${escapedTitle}"]` + ) +} + +function isUnfinishedUploadingImage( + image: HTMLImageElement, + uploadedImages?: UploadedEditorImageMap +) { + const title = image.getAttribute("title") ?? "" + return title.startsWith("uploading-") && !uploadedImages?.has(title) +}