refactor: enhance image upload handling in message editors with centralized logic
This commit is contained in:
@@ -85,6 +85,7 @@ export function ImMessageEditor({
|
|||||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||||
const shouldRestoreFocusRef = useRef(false)
|
const shouldRestoreFocusRef = useRef(false)
|
||||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||||
|
const uploadedImagesRef = useRef(new Map<string, UploadedImage>())
|
||||||
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)
|
||||||
@@ -198,16 +199,17 @@ export function ImMessageEditor({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rawHTML = editor.getHTML()
|
const rawHTML = editor.getHTML()
|
||||||
if (hasUploadingEditorImages(rawHTML)) {
|
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const html = buildSendableEditorHTML(rawHTML)
|
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
|
||||||
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)
|
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||||
|
uploadedImagesRef.current.clear()
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
editor.commands.focus("end")
|
editor.commands.focus("end")
|
||||||
})
|
})
|
||||||
@@ -252,7 +254,12 @@ export function ImMessageEditor({
|
|||||||
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
|
markEditorImageUploadedByTitle(
|
||||||
|
editor,
|
||||||
|
placeholderId,
|
||||||
|
uploaded,
|
||||||
|
uploadedImagesRef.current
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!disabled && shouldRestoreFocusRef.current) {
|
if (!disabled && shouldRestoreFocusRef.current) {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function KefuMessageEditor({
|
|||||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||||
const shouldRestoreFocusRef = useRef(false)
|
const shouldRestoreFocusRef = useRef(false)
|
||||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||||
|
const uploadedImagesRef = useRef(new Map<string, UploadedImage>())
|
||||||
const isUploading = uploadingAsset || localUploading
|
const isUploading = uploadingAsset || localUploading
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -162,16 +163,17 @@ export function KefuMessageEditor({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rawHTML = editor.getHTML()
|
const rawHTML = editor.getHTML()
|
||||||
if (hasUploadingEditorImages(rawHTML)) {
|
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const html = buildSendableEditorHTML(rawHTML)
|
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
|
||||||
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)
|
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||||
|
uploadedImagesRef.current.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
|
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -215,7 +217,12 @@ export function KefuMessageEditor({
|
|||||||
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
markEditorImageUploadedByTitle(editor, placeholderId, uploaded)
|
markEditorImageUploadedByTitle(
|
||||||
|
editor,
|
||||||
|
placeholderId,
|
||||||
|
uploaded,
|
||||||
|
uploadedImagesRef.current
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setLocalUploading(false)
|
setLocalUploading(false)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|||||||
+48
-24
@@ -7,6 +7,8 @@ export type UploadedEditorImage = {
|
|||||||
filename?: string
|
filename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UploadedEditorImageMap = Map<string, UploadedEditorImage>
|
||||||
|
|
||||||
export function removeEditorImageByTitle(editor: Editor, title: string) {
|
export function removeEditorImageByTitle(editor: Editor, title: string) {
|
||||||
const { state } = editor
|
const { state } = editor
|
||||||
let targetPos: number | null = null
|
let targetPos: number | null = null
|
||||||
@@ -26,33 +28,25 @@ export function removeEditorImageByTitle(editor: Editor, title: string) {
|
|||||||
export function markEditorImageUploadedByTitle(
|
export function markEditorImageUploadedByTitle(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
title: string,
|
title: string,
|
||||||
uploaded: UploadedEditorImage
|
uploaded: UploadedEditorImage,
|
||||||
|
uploadedImages: UploadedEditorImageMap
|
||||||
) {
|
) {
|
||||||
const { state, view } = editor
|
uploadedImages.set(title, uploaded)
|
||||||
let targetPos: number | null = null
|
const image = findEditorImageElementByTitle(editor, title)
|
||||||
state.doc.descendants((node, pos) => {
|
if (!image) {
|
||||||
if (node.type.name === "image" && node.attrs.title === title) {
|
|
||||||
targetPos = pos
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
if (targetPos === null) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const attrs = view.state.doc.nodeAt(targetPos)?.attrs
|
image.setAttribute("data-asset-id", uploaded.assetId)
|
||||||
const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, {
|
image.setAttribute("data-provider", uploaded.provider)
|
||||||
...attrs,
|
image.setAttribute("data-storage-key", uploaded.storageKey)
|
||||||
alt: uploaded.filename || attrs?.alt || "image",
|
image.setAttribute("alt", uploaded.filename || image.getAttribute("alt") || "image")
|
||||||
dataAssetId: uploaded.assetId,
|
image.removeAttribute("title")
|
||||||
dataProvider: uploaded.provider,
|
|
||||||
dataStorageKey: uploaded.storageKey,
|
|
||||||
title: "",
|
|
||||||
})
|
|
||||||
view.dispatch(transaction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSendableEditorHTML(html: string) {
|
export function buildSendableEditorHTML(
|
||||||
|
html: string,
|
||||||
|
uploadedImages?: UploadedEditorImageMap
|
||||||
|
) {
|
||||||
if (typeof document === "undefined" || !html.includes("<img")) {
|
if (typeof document === "undefined" || !html.includes("<img")) {
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
@@ -60,6 +54,15 @@ export function buildSendableEditorHTML(html: string) {
|
|||||||
const template = document.createElement("template")
|
const template = document.createElement("template")
|
||||||
template.innerHTML = html
|
template.innerHTML = html
|
||||||
for (const image of Array.from(template.content.querySelectorAll("img"))) {
|
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 (
|
if (
|
||||||
image.getAttribute("data-asset-id") &&
|
image.getAttribute("data-asset-id") &&
|
||||||
image.getAttribute("src")?.startsWith("blob:")
|
image.getAttribute("src")?.startsWith("blob:")
|
||||||
@@ -70,7 +73,10 @@ export function buildSendableEditorHTML(html: string) {
|
|||||||
return template.innerHTML
|
return template.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasUploadingEditorImages(html: string) {
|
export function hasUploadingEditorImages(
|
||||||
|
html: string,
|
||||||
|
uploadedImages?: UploadedEditorImageMap
|
||||||
|
) {
|
||||||
if (typeof document === "undefined" || !html.includes("<img")) {
|
if (typeof document === "undefined" || !html.includes("<img")) {
|
||||||
return /<img\b[^>]*\btitle=(["'])uploading-[^"']+\1/i.test(html)
|
return /<img\b[^>]*\btitle=(["'])uploading-[^"']+\1/i.test(html)
|
||||||
}
|
}
|
||||||
@@ -78,7 +84,7 @@ export function hasUploadingEditorImages(html: string) {
|
|||||||
const template = document.createElement("template")
|
const template = document.createElement("template")
|
||||||
template.innerHTML = html
|
template.innerHTML = html
|
||||||
return Array.from(template.content.querySelectorAll("img")).some((image) =>
|
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()
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user