update web to dashboard
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
import MarkdownIt from "markdown-it"
|
||||
import TurndownService from "turndown"
|
||||
|
||||
const markdownIt = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
bulletListMarker: "-",
|
||||
emDelimiter: "*",
|
||||
strongDelimiter: "**",
|
||||
})
|
||||
|
||||
turndownService.keep(["table", "thead", "tbody", "tr", "th", "td"])
|
||||
|
||||
export function markdownToHtml(markdown: string) {
|
||||
return markdownIt.render(markdown ?? "")
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(html: string) {
|
||||
return turndownService.turndown(html ?? "")
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import type { ContentMode } from "./types"
|
||||
|
||||
type EditorModeSwitchProps = {
|
||||
value: ContentMode
|
||||
disabled?: boolean
|
||||
onChange: (nextMode: ContentMode) => void
|
||||
}
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: ContentMode; label: string }> = [
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]
|
||||
|
||||
export function EditorModeSwitch({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: EditorModeSwitchProps) {
|
||||
return (
|
||||
<div className="mx-0.5 rounded-[3px] border border-border/80 bg-transparent p-0">
|
||||
<div className="flex items-center">
|
||||
{MODE_OPTIONS.map((option) => {
|
||||
const active = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"px-1.5 py-0 text-[12px] leading-6 whitespace-nowrap transition-colors",
|
||||
"hover:bg-[#f2f2f2] disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-[#333]",
|
||||
active
|
||||
? "bg-[#f2f2f2] text-[#3f4a54] dark:bg-[#333] dark:text-[#999]"
|
||||
: "bg-transparent text-[#3f4a54] dark:text-[#999]"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
"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
|
||||
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,
|
||||
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}
|
||||
disabled={disabled}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "separator-mode", type: "separator" },
|
||||
{
|
||||
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 (!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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
"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 type { ContentMode, ContentValue, UploadImageHandler } from "./types"
|
||||
|
||||
type ContentEditorProps = {
|
||||
value: ContentValue
|
||||
onChange: (next: ContentValue) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
onUploadImage?: UploadImageHandler
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
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,
|
||||
}: ContentEditorProps) {
|
||||
const editorHeight = normalizeHeight(height)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
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 || nextMode === value.mode) {
|
||||
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(value.mode, value.raw),
|
||||
})
|
||||
},
|
||||
[disabled, onChange, value.mode, value.raw]
|
||||
)
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
fullscreen && "fixed inset-0 z-[10000] overflow-hidden bg-background p-4"
|
||||
)}
|
||||
>
|
||||
{value.mode === "markdown" ? (
|
||||
<MarkdownEditor
|
||||
value={value.raw}
|
||||
onChange={(nextRaw) => onChange({ mode: "markdown", raw: nextRaw })}
|
||||
mode={value.mode}
|
||||
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={value.mode}
|
||||
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"
|
||||
@@ -1,48 +0,0 @@
|
||||
.content-editor-markdown {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-editor-markdown .md-editor {
|
||||
height: 100%;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-editor-markdown .md-editor-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content-editor-markdown .md-editor-toolbar {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.content-editor-markdown .md-editor-content {
|
||||
height: calc(100% - 37px);
|
||||
}
|
||||
|
||||
.content-editor-markdown .md-editor-content-wrapper,
|
||||
.content-editor-markdown .md-editor-input-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-editor-markdown .md-editor-input-wrapper {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.content-editor-markdown .cm-editor {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-editor-markdown .cm-scroller {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content-editor-markdown .cm-content,
|
||||
.content-editor-markdown .cm-line {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react"
|
||||
import { Maximize2Icon, Minimize2Icon } from "lucide-react"
|
||||
import { MdEditor, NormalToolbar, type ExposeParam } from "md-editor-rt"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import "./markdown-editor.css"
|
||||
|
||||
import { EditorModeSwitch } from "./editor-mode-switch"
|
||||
import type { ContentMode, UploadImageHandler } from "./types"
|
||||
|
||||
export type MarkdownEditorRef = {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
type MarkdownEditorProps = {
|
||||
value: string
|
||||
onChange: (nextValue: string) => void
|
||||
mode: ContentMode
|
||||
onModeChange: (nextMode: ContentMode) => void
|
||||
fullscreen: boolean
|
||||
onToggleFullscreen: () => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
onUploadImage?: UploadImageHandler
|
||||
height: string
|
||||
}
|
||||
|
||||
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
|
||||
function MarkdownEditor(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
placeholder = "",
|
||||
disabled = false,
|
||||
onUploadImage,
|
||||
height,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const editorId = useId()
|
||||
const editorRef = useRef<ExposeParam>(null)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const defToolbars = useMemo(
|
||||
() => [
|
||||
<EditorModeSwitch
|
||||
key="mode-switch"
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
onChange={onModeChange}
|
||||
/>,
|
||||
<NormalToolbar
|
||||
key="toggle-fullscreen"
|
||||
title={fullscreen ? "退出全屏" : "全屏"}
|
||||
disabled={disabled}
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<Minimize2Icon className="h-[16px] w-[16px]" />
|
||||
) : (
|
||||
<Maximize2Icon className="h-[16px] w-[16px]" />
|
||||
)}
|
||||
</NormalToolbar>,
|
||||
],
|
||||
[disabled, fullscreen, mode, onModeChange, onToggleFullscreen]
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus() {
|
||||
editorRef.current?.focus()
|
||||
},
|
||||
}))
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-lg border bg-background"
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="content-editor-markdown h-full">
|
||||
<MdEditor
|
||||
ref={editorRef}
|
||||
id={editorId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
preview={false}
|
||||
toolbars={[
|
||||
0,
|
||||
"-",
|
||||
"bold",
|
||||
"underline",
|
||||
"italic",
|
||||
"strikeThrough",
|
||||
"-",
|
||||
"title",
|
||||
"quote",
|
||||
"unorderedList",
|
||||
"orderedList",
|
||||
"-",
|
||||
"codeRow",
|
||||
"code",
|
||||
"link",
|
||||
"image",
|
||||
"-",
|
||||
"revoke",
|
||||
"next",
|
||||
1,
|
||||
"=",
|
||||
"preview",
|
||||
"previewOnly",
|
||||
]}
|
||||
defToolbars={defToolbars}
|
||||
footers={[]}
|
||||
noMermaid
|
||||
noKatex
|
||||
noHighlight
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
style={{ height: "100%" }}
|
||||
onUploadImg={
|
||||
onUploadImage
|
||||
? async (files, callback) => {
|
||||
const uploadedUrls: string[] = []
|
||||
for (const file of files) {
|
||||
const uploaded = await onUploadImage(file)
|
||||
if (uploaded?.url) {
|
||||
uploadedUrls.push(uploaded.url)
|
||||
}
|
||||
}
|
||||
callback(uploadedUrls)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
.content-editor-toolbar {
|
||||
scrollbar-width: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.content-editor-toolbar::-webkit-scrollbar {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.content-editor-toolbar:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import "./toolbar.css"
|
||||
|
||||
import { useRef, type PointerEvent as ReactPointerEvent } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import type { EditorToolbarAction } from "./types"
|
||||
|
||||
type EditorToolbarProps = {
|
||||
actions: ReadonlyArray<EditorToolbarAction>
|
||||
}
|
||||
|
||||
function isSeparatorAction(
|
||||
action: EditorToolbarAction
|
||||
): action is Extract<EditorToolbarAction, { type: "separator" }> {
|
||||
return "type" in action && action.type === "separator"
|
||||
}
|
||||
|
||||
function isCustomAction(
|
||||
action: EditorToolbarAction
|
||||
): action is Extract<EditorToolbarAction, { type: "custom" }> {
|
||||
return "type" in action && action.type === "custom"
|
||||
}
|
||||
|
||||
export function EditorToolbar({ actions }: EditorToolbarProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const dragStateRef = useRef<{
|
||||
pointerId: number
|
||||
startX: number
|
||||
startScrollLeft: number
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||
return
|
||||
}
|
||||
const container = containerRef.current
|
||||
if (!container || container.scrollWidth <= container.clientWidth) {
|
||||
return
|
||||
}
|
||||
dragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startScrollLeft: container.scrollLeft,
|
||||
moved: false,
|
||||
}
|
||||
container.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const container = containerRef.current
|
||||
const dragState = dragStateRef.current
|
||||
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
const deltaX = event.clientX - dragState.startX
|
||||
if (Math.abs(deltaX) > 3) {
|
||||
dragState.moved = true
|
||||
}
|
||||
container.scrollLeft = dragState.startScrollLeft - deltaX
|
||||
}
|
||||
|
||||
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const container = containerRef.current
|
||||
const dragState = dragStateRef.current
|
||||
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
if (container.hasPointerCapture(event.pointerId)) {
|
||||
container.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
dragStateRef.current = null
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="content-editor-toolbar overflow-x-auto overflow-y-hidden border-b border-border px-1 py-1 h-9.25"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
>
|
||||
<div className="flex min-w-max items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{actions.map((action) => {
|
||||
if (isSeparatorAction(action)) {
|
||||
return (
|
||||
<span
|
||||
key={action.key}
|
||||
aria-hidden="true"
|
||||
className="relative mx-2 inline-block h-[0.9em] w-px self-center bg-border"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isCustomAction(action)) {
|
||||
return <div key={action.key}>{action.content}</div>
|
||||
}
|
||||
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
aria-label={action.label}
|
||||
title={action.label}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
onClickCapture={(event) => {
|
||||
if (dragStateRef.current?.moved) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
data-pressed={action.pressed ? "true" : "false"}
|
||||
className={cn(
|
||||
"mx-[2px] cursor-pointer list-none rounded-[3px] border-none bg-transparent text-[#3f4a54] transition-all duration-300 select-none hover:bg-[#f2f2f2] disabled:cursor-not-allowed disabled:opacity-60 data-[pressed=true]:bg-[#f2f2f2] dark:text-[#999] dark:hover:bg-[#333] dark:data-[pressed=true]:bg-[#333]",
|
||||
action.icon && !action.content
|
||||
? "flex flex-col items-center px-[2px] py-0"
|
||||
: "flex flex-col items-center px-[6px] py-0"
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="box-content size-4 p-1" /> : null}
|
||||
{action.content ? (
|
||||
<span className="text-[12px] leading-none whitespace-nowrap">
|
||||
{action.content}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ComponentType, ReactNode } from "react"
|
||||
|
||||
export type ContentMode = "markdown" | "html"
|
||||
|
||||
export type ContentValue = {
|
||||
mode: ContentMode
|
||||
raw: string
|
||||
}
|
||||
|
||||
export type UploadImageResult = {
|
||||
url: string
|
||||
alt?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type UploadImageHandler = (file: File) => Promise<UploadImageResult | null>
|
||||
|
||||
export type EditorToolbarButtonAction = {
|
||||
key: string
|
||||
label: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
content?: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
pressed?: boolean
|
||||
}
|
||||
|
||||
export type EditorToolbarSeparatorAction = {
|
||||
key: string
|
||||
type: "separator"
|
||||
}
|
||||
|
||||
export type EditorToolbarCustomAction = {
|
||||
key: string
|
||||
type: "custom"
|
||||
content: ReactNode
|
||||
}
|
||||
|
||||
export type EditorToolbarAction =
|
||||
| EditorToolbarButtonAction
|
||||
| EditorToolbarSeparatorAction
|
||||
| EditorToolbarCustomAction
|
||||
Reference in New Issue
Block a user