refactor: enhance image handling in message editor with centralized functions
This commit is contained in:
@@ -19,6 +19,14 @@ import {
|
|||||||
} from "@/components/ui/command"
|
} from "@/components/ui/command"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
|
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"
|
import { generateUUID } from "@/lib/utils"
|
||||||
|
|
||||||
type UploadedImage = {
|
type UploadedImage = {
|
||||||
@@ -76,6 +84,7 @@ export function ImMessageEditor({
|
|||||||
const onUploadImageRef = useRef(onUploadImage)
|
const onUploadImageRef = useRef(onUploadImage)
|
||||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||||
const shouldRestoreFocusRef = useRef(false)
|
const shouldRestoreFocusRef = useRef(false)
|
||||||
|
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||||
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
|
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
|
||||||
const [loadingQuickReplies, setLoadingQuickReplies] = useState(false)
|
const [loadingQuickReplies, setLoadingQuickReplies] = useState(false)
|
||||||
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
|
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
|
||||||
@@ -92,6 +101,13 @@ export function ImMessageEditor({
|
|||||||
onSendAttachmentRef.current = onSendAttachment
|
onSendAttachmentRef.current = onSendAttachment
|
||||||
}, [onSendAttachment])
|
}, [onSendAttachment])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const objectUrls = objectUrlsRef.current
|
||||||
|
return () => {
|
||||||
|
revokeEditorObjectUrls(objectUrls)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoadingQuickReplies(true)
|
setLoadingQuickReplies(true)
|
||||||
@@ -181,12 +197,17 @@ export function ImMessageEditor({
|
|||||||
if (!editor || disabled || uploadingAsset) {
|
if (!editor || disabled || uploadingAsset) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const html = editor.getHTML()
|
const rawHTML = editor.getHTML()
|
||||||
|
if (hasUploadingEditorImages(rawHTML)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const html = buildSendableEditorHTML(rawHTML)
|
||||||
if (!isMeaningfulHTML(html)) {
|
if (!isMeaningfulHTML(html)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await onSendRef.current(html)
|
await onSendRef.current(html)
|
||||||
editor.commands.clearContent(true)
|
editor.commands.clearContent(true)
|
||||||
|
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
editor.commands.focus("end")
|
editor.commands.focus("end")
|
||||||
})
|
})
|
||||||
@@ -212,6 +233,7 @@ export function ImMessageEditor({
|
|||||||
}
|
}
|
||||||
shouldRestoreFocusRef.current = true
|
shouldRestoreFocusRef.current = true
|
||||||
const objectUrl = URL.createObjectURL(file)
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
objectUrlsRef.current.add(objectUrl)
|
||||||
const placeholderId = `uploading-${generateUUID()}`
|
const placeholderId = `uploading-${generateUUID()}`
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
@@ -225,13 +247,13 @@ export function ImMessageEditor({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const uploaded = await onUploadImageRef.current(file)
|
const uploaded = await onUploadImageRef.current(file)
|
||||||
if (!uploaded?.url) {
|
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
|
||||||
removeImageByTitle(editor, placeholderId)
|
removeEditorImageByTitle(editor, placeholderId)
|
||||||
|
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
replaceImageSourceByTitle(editor, placeholderId, uploaded)
|
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
|
||||||
} finally {
|
} finally {
|
||||||
URL.revokeObjectURL(objectUrl)
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!disabled && shouldRestoreFocusRef.current) {
|
if (!disabled && shouldRestoreFocusRef.current) {
|
||||||
editor.commands.focus()
|
editor.commands.focus()
|
||||||
@@ -396,48 +418,3 @@ function getClipboardImageFile(clipboardData: DataTransfer | null) {
|
|||||||
}
|
}
|
||||||
return 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 { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
buildSendableEditorHTML,
|
||||||
|
hasUploadingEditorImages,
|
||||||
|
markEditorImageUploadedByTitle,
|
||||||
|
removeEditorImageByTitle,
|
||||||
|
revokeEditorObjectUrl,
|
||||||
|
revokeEditorObjectUrls,
|
||||||
|
} from "@/lib/im-editor-image"
|
||||||
import { generateUUID } from "@/lib/utils"
|
import { generateUUID } from "@/lib/utils"
|
||||||
|
|
||||||
type UploadedImage = {
|
type UploadedImage = {
|
||||||
@@ -66,8 +74,16 @@ export function KefuMessageEditor({
|
|||||||
const onUploadImageRef = useRef(onUploadImage)
|
const onUploadImageRef = useRef(onUploadImage)
|
||||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||||
const shouldRestoreFocusRef = useRef(false)
|
const shouldRestoreFocusRef = useRef(false)
|
||||||
|
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||||
const isUploading = uploadingAsset || localUploading
|
const isUploading = uploadingAsset || localUploading
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const objectUrls = objectUrlsRef.current
|
||||||
|
return () => {
|
||||||
|
revokeEditorObjectUrls(objectUrls)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSendRef.current = onSend
|
onSendRef.current = onSend
|
||||||
}, [onSend])
|
}, [onSend])
|
||||||
@@ -145,12 +161,17 @@ export function KefuMessageEditor({
|
|||||||
if (!editor || disabled || isUploading) {
|
if (!editor || disabled || isUploading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const html = editor.getHTML()
|
const rawHTML = editor.getHTML()
|
||||||
|
if (hasUploadingEditorImages(rawHTML)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const html = buildSendableEditorHTML(rawHTML)
|
||||||
if (!isMeaningfulHTML(html)) {
|
if (!isMeaningfulHTML(html)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await onSendRef.current(html)
|
await onSendRef.current(html)
|
||||||
editor.commands.clearContent(true)
|
editor.commands.clearContent(true)
|
||||||
|
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
|
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -174,6 +195,7 @@ export function KefuMessageEditor({
|
|||||||
|
|
||||||
shouldRestoreFocusRef.current = true
|
shouldRestoreFocusRef.current = true
|
||||||
const objectUrl = URL.createObjectURL(file)
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
objectUrlsRef.current.add(objectUrl)
|
||||||
const placeholderId = `uploading-${generateUUID()}`
|
const placeholderId = `uploading-${generateUUID()}`
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
@@ -188,14 +210,14 @@ export function KefuMessageEditor({
|
|||||||
try {
|
try {
|
||||||
setLocalUploading(true)
|
setLocalUploading(true)
|
||||||
const uploaded = await onUploadImageRef.current(file)
|
const uploaded = await onUploadImageRef.current(file)
|
||||||
if (!uploaded?.url) {
|
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
|
||||||
removeImageByTitle(editor, placeholderId)
|
removeEditorImageByTitle(editor, placeholderId)
|
||||||
|
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
replaceImageSourceByTitle(editor, placeholderId, uploaded)
|
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
|
||||||
} finally {
|
} finally {
|
||||||
setLocalUploading(false)
|
setLocalUploading(false)
|
||||||
URL.revokeObjectURL(objectUrl)
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!disabled && shouldRestoreFocusRef.current) {
|
if (!disabled && shouldRestoreFocusRef.current) {
|
||||||
editor.commands.focus()
|
editor.commands.focus()
|
||||||
@@ -331,48 +353,3 @@ function getClipboardImageFile(data: DataTransfer | null) {
|
|||||||
}
|
}
|
||||||
return 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