Files
ai-agent/dashboard/components/content-editor/index.tsx
T

175 lines
4.6 KiB
TypeScript

"use client"
import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import { htmlToMarkdown, markdownToHtml } from "./convert"
import { HtmlEditor } from "./html-editor"
import { MarkdownEditor } from "./markdown-editor"
import {
CONTENT_MODE_OPTIONS,
type ContentMode,
type ContentValue,
type UploadImageHandler,
} from "./types"
type ContentEditorProps = {
value: ContentValue
onChange: (next: ContentValue) => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height?: number | string
allowedModes?: ReadonlyArray<ContentMode>
}
function normalizeHeight(height?: number | string) {
if (typeof height === "number") {
return `${height}px`
}
if (typeof height === "string" && height.trim()) {
return height
}
return "400px"
}
function getModeLabel(mode: ContentMode) {
return mode === "markdown" ? "Markdown" : "HTML"
}
function convertContent(mode: ContentMode, raw: string) {
if (mode === "markdown") {
return markdownToHtml(raw)
}
return htmlToMarkdown(raw)
}
export function ContentEditor({
value,
onChange,
placeholder,
disabled = false,
onUploadImage,
height,
allowedModes = CONTENT_MODE_OPTIONS,
}: ContentEditorProps) {
const editorHeight = normalizeHeight(height)
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
const normalizedAllowedModes = allowedModes.length > 0 ? allowedModes : CONTENT_MODE_OPTIONS
const activeMode = normalizedAllowedModes.includes(value.mode)
? value.mode
: normalizedAllowedModes[0]
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!fullscreen) {
return
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setFullscreen(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener("keydown", handleKeyDown)
}
}, [fullscreen])
const handleModeChange = useCallback(
(nextMode: ContentMode) => {
if (
disabled ||
normalizedAllowedModes.length <= 1 ||
nextMode === activeMode ||
!normalizedAllowedModes.includes(nextMode)
) {
return
}
const currentText = value.raw.trim()
if (!currentText) {
onChange({ mode: nextMode, raw: "" })
return
}
const confirmed = window.confirm(
`切换到 ${getModeLabel(nextMode)} 模式会尝试自动转换内容,复杂格式可能有损。是否继续?`
)
if (!confirmed) {
return
}
onChange({
mode: nextMode,
raw: convertContent(activeMode, value.raw),
})
},
[activeMode, disabled, normalizedAllowedModes, onChange, value.raw]
)
useEffect(() => {
if (value.mode !== activeMode) {
onChange({ mode: activeMode, raw: value.raw })
}
}, [activeMode, onChange, value.mode, value.raw])
const content = (
<div
className={cn(
"w-full",
fullscreen && "fixed inset-0 z-[10000] overflow-hidden bg-background p-4"
)}
>
{activeMode === "markdown" ? (
<MarkdownEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "markdown", raw: nextRaw })}
mode={activeMode}
allowedModes={normalizedAllowedModes}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
/>
) : (
<HtmlEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "html", raw: nextRaw })}
mode={activeMode}
allowedModes={normalizedAllowedModes}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
/>
)}
</div>
)
if (fullscreen && mounted) {
return createPortal(content, document.body)
}
return content
}
export type { ContentMode, ContentValue, UploadImageHandler, UploadImageResult } from "./types"