diff --git a/web/components/im-message-editor.tsx b/web/components/im-message-editor.tsx index 21d9a14..246b4ea 100644 --- a/web/components/im-message-editor.tsx +++ b/web/components/im-message-editor.tsx @@ -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>(new Set()) const [quickReplies, setQuickReplies] = useState([]) 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>, 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>, - 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) -} diff --git a/web/components/kefu/message-editor.tsx b/web/components/kefu/message-editor.tsx index f380503..6786221 100644 --- a/web/components/kefu/message-editor.tsx +++ b/web/components/kefu/message-editor.tsx @@ -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>(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) { @@ -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>, 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>, - 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) -} diff --git a/web/lib/im-editor-image.ts b/web/lib/im-editor-image.ts new file mode 100644 index 0000000..2dd6a8a --- /dev/null +++ b/web/lib/im-editor-image.ts @@ -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("]*\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, objectUrl: string) { + if (!urls.delete(objectUrl)) { + return + } + URL.revokeObjectURL(objectUrl) +} + +export function revokeEditorObjectUrls(urls: Set) { + for (const objectUrl of urls) { + URL.revokeObjectURL(objectUrl) + } + urls.clear() +}