refactor: enhance image handling in message editor with centralized functions
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user