Files
ai-agent/web/lib/im-editor-image.ts
T
mlogclub eb04c344a5 refactor: rename cs-ai-agent to agent-desk in SDK and related files
- Updated message types in support-host-bridge.ts to use agent-desk prefix.
- Added new agent-desk-sdk.min.js file with updated functionality.
- Removed old cs-ai-agent-sdk.min.js file.
- Modified build-sdk script to generate agent-desk-sdk.min.js.
- Changed scrollbar class names in main.scss from cs-ai-agent to agent-desk.
2026-05-31 18:46:05 +08:00

233 lines
7.3 KiB
TypeScript

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 =
"agent-desk-editor-image-wrap relative inline-block max-w-full align-middle"
const image = document.createElement("img")
image.className = "agent-desk-editor-image"
image.draggable = true
const overlay = document.createElement("span")
overlay.className =
"agent-desk-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
provider: string
storageKey: string
filename?: string
}
export type UploadedEditorImageMap = Map<string, UploadedEditorImage>
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,
uploadedImages: UploadedEditorImageMap
) {
uploadedImages.set(title, uploaded)
const image = findEditorImageElementByTitle(editor, title)
if (!image) {
return
}
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.classList.remove("agent-desk-editor-image-uploading")
image.removeAttribute("data-uploading")
image.removeAttribute("title")
setImageUploading(image.closest(".agent-desk-editor-image-wrap"), false)
}
export function setEditorImageUploadingByTitle(editor: Editor, title: string) {
const image = findEditorImageElementByTitle(editor, title)
if (!image) {
return
}
image.classList.add("agent-desk-editor-image-uploading")
image.setAttribute("data-uploading", "true")
setImageUploading(image.closest(".agent-desk-editor-image-wrap"), true)
}
export function buildSendableEditorHTML(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
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"))) {
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 (
image.getAttribute("data-asset-id") &&
image.getAttribute("src")?.startsWith("blob:")
) {
image.removeAttribute("src")
}
}
return template.innerHTML
}
export function hasUploadingEditorImages(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
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) =>
isUnfinishedUploadingImage(image, uploadedImages)
)
}
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()
}
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)
}
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("agent-desk-editor-image-wrap-uploading", uploading)
const overlay = wrapper.querySelector<HTMLElement>(".agent-desk-editor-image-loading")
if (overlay) {
overlay.classList.toggle("hidden", !uploading)
overlay.classList.toggle("flex", uploading)
}
}