"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 } 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 = (
{activeMode === "markdown" ? ( 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} /> ) : ( 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} /> )}
) if (fullscreen && mounted) { return createPortal(content, document.body) } return content } export type { ContentMode, ContentValue, UploadImageHandler, UploadImageResult } from "./types"