369 lines
10 KiB
TypeScript
369 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
} from "react"
|
|
import { EditorContent, useEditor } from "@tiptap/react"
|
|
import Image from "@tiptap/extension-image"
|
|
import Link from "@tiptap/extension-link"
|
|
import Placeholder from "@tiptap/extension-placeholder"
|
|
import StarterKit from "@tiptap/starter-kit"
|
|
import Underline from "@tiptap/extension-underline"
|
|
import {
|
|
BoldIcon,
|
|
Code2Icon,
|
|
Heading1Icon,
|
|
Heading2Icon,
|
|
ImageIcon,
|
|
ItalicIcon,
|
|
LinkIcon,
|
|
ListIcon,
|
|
ListOrderedIcon,
|
|
QuoteIcon,
|
|
RedoIcon,
|
|
RotateCcwIcon,
|
|
StrikethroughIcon,
|
|
EyeIcon,
|
|
Maximize2Icon,
|
|
Minimize2Icon,
|
|
UnderlineIcon,
|
|
} from "lucide-react"
|
|
|
|
import { EditorModeSwitch } from "./editor-mode-switch"
|
|
import { EditorToolbar } from "./toolbar"
|
|
import type { ContentMode, EditorToolbarAction, UploadImageHandler } from "./types"
|
|
|
|
export type HtmlEditorRef = {
|
|
focus: () => void
|
|
}
|
|
|
|
type HtmlEditorProps = {
|
|
value: string
|
|
onChange: (nextValue: string) => void
|
|
mode: ContentMode
|
|
allowedModes: ReadonlyArray<ContentMode>
|
|
onModeChange: (nextMode: ContentMode) => void
|
|
fullscreen: boolean
|
|
onToggleFullscreen: () => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
onUploadImage?: UploadImageHandler
|
|
height: string
|
|
}
|
|
|
|
export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
|
|
function HtmlEditor(
|
|
{
|
|
value,
|
|
onChange,
|
|
mode,
|
|
allowedModes,
|
|
onModeChange,
|
|
fullscreen,
|
|
onToggleFullscreen,
|
|
placeholder = "",
|
|
disabled = false,
|
|
onUploadImage,
|
|
height,
|
|
},
|
|
ref
|
|
) {
|
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
|
const [previewOnly, setPreviewOnly] = useState(false)
|
|
const proseClassName =
|
|
"h-full overflow-y-auto px-4 py-3 text-sm leading-7 text-foreground outline-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p]:mb-2 [&_h1]:mb-3 [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:mb-1 [&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_img]:my-2 [&_img]:max-h-80 [&_img]:rounded-md [&_img]:object-contain [&_p.is-editor-empty:first-child]:before:text-muted-foreground"
|
|
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: {
|
|
levels: [1, 2],
|
|
},
|
|
bulletList: {
|
|
keepMarks: true,
|
|
keepAttributes: false,
|
|
},
|
|
orderedList: {
|
|
keepMarks: true,
|
|
keepAttributes: false,
|
|
},
|
|
}),
|
|
Image,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
autolink: true,
|
|
}),
|
|
Underline,
|
|
Placeholder.configure({
|
|
placeholder,
|
|
}),
|
|
],
|
|
content: value,
|
|
editable: !disabled,
|
|
onUpdate: ({ editor: currentEditor }) => {
|
|
onChange(currentEditor.getHTML())
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: proseClassName,
|
|
},
|
|
},
|
|
})
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
focus() {
|
|
editor?.commands.focus()
|
|
},
|
|
}), [editor])
|
|
|
|
useEffect(() => {
|
|
if (editor && value !== editor.getHTML()) {
|
|
editor.commands.setContent(value, { emitUpdate: false })
|
|
}
|
|
}, [editor, value])
|
|
|
|
useEffect(() => {
|
|
if (editor) {
|
|
editor.setEditable(!disabled)
|
|
}
|
|
}, [disabled, editor])
|
|
|
|
const handleTogglePreviewOnly = () => {
|
|
setPreviewOnly((current: boolean) => !current)
|
|
}
|
|
|
|
const handleInsertLink = () => {
|
|
if (!editor || disabled) {
|
|
return
|
|
}
|
|
const previousUrl = editor.getAttributes("link").href as string | undefined
|
|
const url = window.prompt("输入链接地址", previousUrl || "https://")
|
|
if (url === null) {
|
|
return
|
|
}
|
|
if (!url.trim()) {
|
|
editor.chain().focus().unsetLink().run()
|
|
return
|
|
}
|
|
editor.chain().focus().extendMarkRange("link").setLink({ href: url.trim() }).run()
|
|
}
|
|
|
|
const handleSelectImage = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0]
|
|
event.target.value = ""
|
|
if (!file || !editor || !onUploadImage || disabled) {
|
|
return
|
|
}
|
|
const uploaded = await onUploadImage(file)
|
|
if (!uploaded?.url) {
|
|
return
|
|
}
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.setImage({
|
|
src: uploaded.url,
|
|
alt: uploaded.alt || file.name || "image",
|
|
title: uploaded.title || "",
|
|
})
|
|
.run()
|
|
}
|
|
|
|
const actions: EditorToolbarAction[] = [
|
|
{
|
|
key: "mode-switch",
|
|
type: "custom",
|
|
content: (
|
|
<EditorModeSwitch
|
|
value={mode}
|
|
allowedModes={allowedModes}
|
|
disabled={disabled}
|
|
onChange={onModeChange}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "bold",
|
|
label: "粗体",
|
|
icon: BoldIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("bold"),
|
|
onClick: () => editor?.chain().focus().toggleBold().run(),
|
|
},
|
|
{
|
|
key: "underline",
|
|
label: "下划线",
|
|
icon: UnderlineIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("underline"),
|
|
onClick: () => editor?.chain().focus().toggleUnderline().run(),
|
|
},
|
|
{
|
|
key: "italic",
|
|
label: "斜体",
|
|
icon: ItalicIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("italic"),
|
|
onClick: () => editor?.chain().focus().toggleItalic().run(),
|
|
},
|
|
{
|
|
key: "strike",
|
|
label: "删除线",
|
|
icon: StrikethroughIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("strike"),
|
|
onClick: () => editor?.chain().focus().toggleStrike().run(),
|
|
},
|
|
{ key: "separator-1", type: "separator" },
|
|
{
|
|
key: "h1",
|
|
label: "一级标题",
|
|
icon: Heading1Icon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("heading", { level: 1 }),
|
|
onClick: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
},
|
|
{
|
|
key: "h2",
|
|
label: "二级标题",
|
|
icon: Heading2Icon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("heading", { level: 2 }),
|
|
onClick: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
},
|
|
{
|
|
key: "quote",
|
|
label: "引用",
|
|
icon: QuoteIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("blockquote"),
|
|
onClick: () => editor?.chain().focus().toggleBlockquote().run(),
|
|
},
|
|
{
|
|
key: "bullet-list",
|
|
label: "无序列表",
|
|
icon: ListIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("bulletList"),
|
|
onClick: () => editor?.chain().focus().toggleBulletList().run(),
|
|
},
|
|
{
|
|
key: "ordered-list",
|
|
label: "有序列表",
|
|
icon: ListOrderedIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("orderedList"),
|
|
onClick: () => editor?.chain().focus().toggleOrderedList().run(),
|
|
},
|
|
{ key: "separator-2", type: "separator" },
|
|
{
|
|
key: "code",
|
|
label: "行内代码",
|
|
icon: Code2Icon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("code"),
|
|
onClick: () => editor?.chain().focus().toggleCode().run(),
|
|
},
|
|
{
|
|
key: "code-block",
|
|
label: "代码块",
|
|
icon: Code2Icon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("codeBlock"),
|
|
onClick: () => editor?.chain().focus().toggleCodeBlock().run(),
|
|
},
|
|
{ key: "separator-3", type: "separator" },
|
|
{
|
|
key: "link",
|
|
label: "链接",
|
|
icon: LinkIcon,
|
|
disabled,
|
|
pressed: !!editor?.isActive("link"),
|
|
onClick: handleInsertLink,
|
|
},
|
|
{
|
|
key: "image",
|
|
label: "图片",
|
|
icon: ImageIcon,
|
|
disabled: disabled || !onUploadImage,
|
|
onClick: () => imageInputRef.current?.click(),
|
|
},
|
|
{ key: "separator-4", type: "separator" },
|
|
{
|
|
key: "undo-tail",
|
|
label: "撤销",
|
|
icon: RotateCcwIcon,
|
|
disabled: disabled || !editor?.can().undo(),
|
|
onClick: () => editor?.chain().focus().undo().run(),
|
|
},
|
|
{
|
|
key: "redo-tail",
|
|
label: "重做",
|
|
icon: RedoIcon,
|
|
disabled: disabled || !editor?.can().redo(),
|
|
onClick: () => editor?.chain().focus().redo().run(),
|
|
},
|
|
{ key: "separator-fullscreen", type: "separator" },
|
|
{
|
|
key: "fullscreen",
|
|
label: fullscreen ? "退出全屏" : "全屏",
|
|
icon: fullscreen ? Minimize2Icon : Maximize2Icon,
|
|
disabled,
|
|
pressed: fullscreen,
|
|
onClick: onToggleFullscreen,
|
|
},
|
|
{ key: "separator-preview", type: "separator" },
|
|
{
|
|
key: "preview-only",
|
|
label: "仅预览",
|
|
icon: EyeIcon,
|
|
disabled,
|
|
pressed: previewOnly,
|
|
onClick: handleTogglePreviewOnly,
|
|
},
|
|
]
|
|
|
|
if (allowedModes.length > 1) {
|
|
actions.splice(1, 0, { key: "separator-mode", type: "separator" })
|
|
}
|
|
|
|
if (!editor) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex w-full flex-col rounded-lg border bg-background"
|
|
style={{ height }}
|
|
>
|
|
<input
|
|
ref={imageInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
void handleSelectImage(event)
|
|
}}
|
|
/>
|
|
<EditorToolbar actions={actions} />
|
|
<div className="min-h-0 flex-1 p-2">
|
|
{previewOnly ? (
|
|
<div
|
|
className={proseClassName}
|
|
dangerouslySetInnerHTML={{ __html: value }}
|
|
/>
|
|
) : (
|
|
<EditorContent editor={editor} className="h-full" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
)
|