diff --git a/web/components/im-message-editor.tsx b/web/components/im-message-editor.tsx index 77b40b8..15f0541 100644 --- a/web/components/im-message-editor.tsx +++ b/web/components/im-message-editor.tsx @@ -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) { diff --git a/web/components/kefu/message-editor.tsx b/web/components/kefu/message-editor.tsx index 0c2a6b2..6015473 100644 --- a/web/components/kefu/message-editor.tsx +++ b/web/components/kefu/message-editor.tsx @@ -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) { diff --git a/web/lib/im-editor-image.ts b/web/lib/im-editor-image.ts index 3945358..a5fa744 100644 --- a/web/lib/im-editor-image.ts +++ b/web/lib/im-editor-image.ts @@ -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) => { + 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(".cs-agent-editor-image-loading") + if (overlay) { + overlay.classList.toggle("hidden", !uploading) + overlay.classList.toggle("flex", uploading) + } +}