refactor: implement MessageImageExtension for enhanced image handling in message editors
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { EditorContent, useEditor } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import Image from "@tiptap/extension-image"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { ImageIcon, MessageSquareTextIcon, PaperclipIcon, SendIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
buildSendableEditorHTML,
|
||||
hasUploadingEditorImages,
|
||||
markEditorImageUploadedByTitle,
|
||||
MessageImageExtension,
|
||||
removeEditorImageByTitle,
|
||||
revokeEditorObjectUrl,
|
||||
revokeEditorObjectUrls,
|
||||
@@ -38,32 +38,6 @@ type UploadedImage = {
|
||||
filename?: string
|
||||
}
|
||||
|
||||
const MessageImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
dataAssetId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-asset-id"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {},
|
||||
},
|
||||
dataProvider: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {},
|
||||
},
|
||||
dataStorageKey: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-storage-key"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type ImMessageEditorProps = {
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
@@ -145,7 +119,7 @@ export function ImMessageEditor({
|
||||
orderedList: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
MessageImage,
|
||||
MessageImageExtension,
|
||||
Placeholder.configure({
|
||||
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
|
||||
}),
|
||||
@@ -154,7 +128,7 @@ export function ImMessageEditor({
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+img]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-md [&_img]:object-contain [&_img.cs-agent-editor-image-uploading]:animate-pulse [&_img.cs-agent-editor-image-uploading]:opacity-55 [&_img.cs-agent-editor-image-uploading]:ring-2 [&_img.cs-agent-editor-image-uploading]:ring-primary/35 [&_p.is-editor-empty:first-child]:before:text-muted-foreground",
|
||||
"h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+.cs-agent-editor-image-wrap]:mt-2 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-md [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground",
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { EditorContent, useEditor } from "@tiptap/react"
|
||||
import Image from "@tiptap/extension-image"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react"
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
buildSendableEditorHTML,
|
||||
hasUploadingEditorImages,
|
||||
markEditorImageUploadedByTitle,
|
||||
MessageImageExtension,
|
||||
removeEditorImageByTitle,
|
||||
revokeEditorObjectUrl,
|
||||
revokeEditorObjectUrls,
|
||||
@@ -27,32 +27,6 @@ type UploadedImage = {
|
||||
filename?: string
|
||||
}
|
||||
|
||||
const MessageImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
dataAssetId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-asset-id"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {},
|
||||
},
|
||||
dataProvider: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {},
|
||||
},
|
||||
dataStorageKey: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-storage-key"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type KefuMessageEditorProps = {
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
@@ -109,7 +83,7 @@ export function KefuMessageEditor({
|
||||
orderedList: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
MessageImage,
|
||||
MessageImageExtension,
|
||||
Placeholder.configure({
|
||||
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
|
||||
}),
|
||||
@@ -118,7 +92,7 @@ export function KefuMessageEditor({
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"cs-agent-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-lg [&_img]:object-contain [&_img.cs-agent-editor-image-uploading]:animate-pulse [&_img.cs-agent-editor-image-uploading]:opacity-55 [&_img.cs-agent-editor-image-uploading]:ring-2 [&_img.cs-agent-editor-image-uploading]:ring-primary/35",
|
||||
"cs-agent-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-lg [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-editor-image]:opacity-55",
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
|
||||
@@ -1,4 +1,79 @@
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import Image from "@tiptap/extension-image"
|
||||
|
||||
export const MessageImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
dataAssetId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-asset-id"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {},
|
||||
},
|
||||
dataProvider: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {},
|
||||
},
|
||||
dataStorageKey: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-storage-key"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const wrapper = document.createElement("span")
|
||||
wrapper.className =
|
||||
"cs-agent-editor-image-wrap relative inline-block max-w-full align-middle"
|
||||
|
||||
const image = document.createElement("img")
|
||||
image.className = "cs-agent-editor-image"
|
||||
image.draggable = true
|
||||
|
||||
const overlay = document.createElement("span")
|
||||
overlay.className =
|
||||
"cs-agent-editor-image-loading pointer-events-none absolute inset-0 hidden items-center justify-center rounded-lg bg-background/55 backdrop-blur-[1px]"
|
||||
|
||||
const spinner = document.createElement("span")
|
||||
spinner.className =
|
||||
"size-6 animate-spin rounded-full border-2 border-primary/25 border-t-primary shadow-sm"
|
||||
|
||||
overlay.appendChild(spinner)
|
||||
wrapper.appendChild(image)
|
||||
wrapper.appendChild(overlay)
|
||||
|
||||
const applyAttrs = (attrs: Record<string, unknown>) => {
|
||||
setImageAttr(image, "src", attrs.src)
|
||||
setImageAttr(image, "alt", attrs.alt)
|
||||
setImageAttr(image, "title", attrs.title)
|
||||
setImageAttr(image, "data-asset-id", attrs.dataAssetId)
|
||||
setImageAttr(image, "data-provider", attrs.dataProvider)
|
||||
setImageAttr(image, "data-storage-key", attrs.dataStorageKey)
|
||||
setImageUploading(wrapper, Boolean(String(attrs.title ?? "").startsWith("uploading-")))
|
||||
}
|
||||
|
||||
applyAttrs(node.attrs)
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== node.type.name) {
|
||||
return false
|
||||
}
|
||||
applyAttrs(updatedNode.attrs)
|
||||
return true
|
||||
},
|
||||
ignoreMutation: () => true,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export type UploadedEditorImage = {
|
||||
assetId: string
|
||||
@@ -43,6 +118,7 @@ export function markEditorImageUploadedByTitle(
|
||||
image.classList.remove("cs-agent-editor-image-uploading")
|
||||
image.removeAttribute("data-uploading")
|
||||
image.removeAttribute("title")
|
||||
setImageUploading(image.closest(".cs-agent-editor-image-wrap"), false)
|
||||
}
|
||||
|
||||
export function setEditorImageUploadingByTitle(editor: Editor, title: string) {
|
||||
@@ -52,6 +128,7 @@ export function setEditorImageUploadingByTitle(editor: Editor, title: string) {
|
||||
}
|
||||
image.classList.add("cs-agent-editor-image-uploading")
|
||||
image.setAttribute("data-uploading", "true")
|
||||
setImageUploading(image.closest(".cs-agent-editor-image-wrap"), true)
|
||||
}
|
||||
|
||||
export function buildSendableEditorHTML(
|
||||
@@ -130,3 +207,26 @@ function isUnfinishedUploadingImage(
|
||||
const title = image.getAttribute("title") ?? ""
|
||||
return title.startsWith("uploading-") && !uploadedImages?.has(title)
|
||||
}
|
||||
|
||||
function setImageAttr(image: HTMLImageElement, name: string, value: unknown) {
|
||||
const normalized = typeof value === "string" ? value : ""
|
||||
if (!normalized) {
|
||||
image.removeAttribute(name)
|
||||
return
|
||||
}
|
||||
if (image.getAttribute(name) !== normalized) {
|
||||
image.setAttribute(name, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function setImageUploading(wrapper: Element | null, uploading: boolean) {
|
||||
if (!(wrapper instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
wrapper.classList.toggle("cs-agent-editor-image-wrap-uploading", uploading)
|
||||
const overlay = wrapper.querySelector<HTMLElement>(".cs-agent-editor-image-loading")
|
||||
if (overlay) {
|
||||
overlay.classList.toggle("hidden", !uploading)
|
||||
overlay.classList.toggle("flex", uploading)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user