集成 Tiptap Markdown 编辑器到文章撰写页面

- 新增 libs/tiptap-editor/ 打包子项目(Tiptap Core + StarterKit + Markdown)
- 构建产物输出到 public/tiptap/
- 替换原有的 textarea + pulldown_cmark 预览为 WYSIWYG 编辑器
- Makefile 新增 build-editor target
- Dioxus.toml 引入 editor.js 和 editor.css

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-26 17:46:04 +08:00
parent 91d9c04a3d
commit f6d60520eb
14 changed files with 2192 additions and 42 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
/.dioxus
/.omc
/node_modules
**/**/node_modules
/package-lock.json
others/
public/style.css

1
Cargo.lock generated
View File

@ -4629,6 +4629,7 @@ dependencies = [
"dioxus",
"dotenvy",
"getrandom 0.2.17",
"js-sys",
"pulldown-cmark",
"rand 0.8.6",
"regex",

View File

@ -19,8 +19,9 @@ rand = { version = "0.8", features = ["getrandom"] }
getrandom = { version = "0.2", features = ["js"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "HtmlDocument", "Storage", "Element", "DomTokenList", "MediaQueryList"] }
web-sys = { version = "0.3", features = ["Document", "Window", "HtmlDocument", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlScriptElement"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
[profile.release]
debug = false

View File

@ -10,9 +10,9 @@ title = "Yggdrasil - Dioxus SSR"
watch_path = ["src", "Cargo.toml"]
[web.resource]
style = ["/style.css"]
script = []
style = ["/style.css", "/tiptap/editor.css"]
script = ["/tiptap/editor.js"]
[web.resource.dev]
style = ["/style.css"]
style = []
script = []

View File

@ -1,9 +1,17 @@
.PHONY: dev build css css-watch clean
.PHONY: dev build css css-watch clean build-editor
build:
@$(MAKE) build-editor
@tailwindcss -i input.css -o public/style.css --minify
@dx build --release
build-editor:
@echo "Building Tiptap editor..."
@cd libs/tiptap-editor && npm install && npx vite build
@mv public/tiptap/editor.iife.js public/tiptap/editor.js 2>/dev/null || true
@mv public/tiptap/editor.iife.js.map public/tiptap/editor.js.map 2>/dev/null || true
@echo "Tiptap editor built."
dev:
@echo "Starting tailwindcss watch and dx serve..."
@tailwindcss -i input.css -o public/style.css --watch & \

1542
libs/tiptap-editor/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
{
"name": "@yggdrasil/tiptap-editor",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"@tiptap/core": "^3.0.0",
"@tiptap/markdown": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"@tiptap/starter-kit": "^3.0.0"
},
"devDependencies": {
"vite": "^5.4.20"
}
}

View File

@ -0,0 +1,116 @@
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import './style.css'
export interface EditorOptions {
content?: string
placeholder?: string
onUpdate?: (markdown: string) => void
onFocus?: () => void
onBlur?: () => void
editable?: boolean
}
class TiptapEditorInstance {
private editor: Editor | null = null
private container: HTMLElement
private options: EditorOptions
constructor(container: HTMLElement, options: EditorOptions = {}) {
this.container = container
this.options = options
this.init()
}
private init() {
const el = document.createElement('div')
el.className = 'tiptap-editor'
this.container.appendChild(el)
this.editor = new Editor({
element: el,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Markdown.configure({
html: false,
}),
],
content: this.options.content || '',
editable: this.options.editable !== false,
autofocus: false,
onUpdate: ({ editor }) => {
if (this.options.onUpdate) {
this.options.onUpdate(editor.getMarkdown())
}
},
onFocus: () => {
if (this.options.onFocus) {
this.options.onFocus()
}
},
onBlur: () => {
if (this.options.onBlur) {
this.options.onBlur()
}
},
})
}
getMarkdown(): string {
return this.editor?.getMarkdown() || ''
}
setMarkdown(content: string): void {
this.editor?.commands.setContent(content, false, { contentType: 'markdown' })
}
getHTML(): string {
return this.editor?.getHTML() || ''
}
focus(): void {
this.editor?.commands.focus()
}
blur(): void {
this.editor?.commands.blur()
}
isEmpty(): boolean {
return this.editor?.isEmpty ?? true
}
destroy(): void {
this.editor?.destroy()
this.editor = null
this.container.innerHTML = ''
}
}
const TiptapEditor = {
_instances: new Map<string, TiptapEditorInstance>(),
create(containerId: string, options: EditorOptions = {}): TiptapEditorInstance | null {
const container = document.getElementById(containerId)
if (!container) {
console.error(`[TiptapEditor] Container not found: #${containerId}`)
return null
}
const existing = this._instances.get(containerId)
if (existing) {
existing.destroy()
}
const instance = new TiptapEditorInstance(container, options)
this._instances.set(containerId, instance)
return instance
},
}
export default TiptapEditor

View File

@ -0,0 +1,219 @@
/* Tiptap Editor - Typora-inspired, theme-aware */
.tiptap-editor {
position: relative;
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.75;
color: #2c2c2c;
outline: none;
}
.tiptap-editor .ProseMirror {
width: 100%;
height: 100%;
padding: 24px 32px;
outline: none;
overflow-y: auto;
}
.tiptap-editor .ProseMirror-focused {
outline: none;
}
/* Placeholder */
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #999;
pointer-events: none;
height: 0;
}
/* Typography */
.tiptap-editor h1 {
font-size: 2em;
font-weight: 700;
margin: 1.2em 0 0.6em;
line-height: 1.3;
color: #1a1a1a;
}
.tiptap-editor h2 {
font-size: 1.5em;
font-weight: 600;
margin: 1em 0 0.5em;
line-height: 1.35;
color: #1a1a1a;
}
.tiptap-editor h3 {
font-size: 1.25em;
font-weight: 600;
margin: 0.8em 0 0.4em;
color: #1a1a1a;
}
.tiptap-editor p {
margin: 0.6em 0;
}
.tiptap-editor p:first-child {
margin-top: 0;
}
.tiptap-editor strong {
font-weight: 700;
color: #1a1a1a;
}
.tiptap-editor em {
font-style: italic;
}
.tiptap-editor s {
text-decoration: line-through;
opacity: 0.7;
}
.tiptap-editor code {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
font-size: 0.875em;
background: #f3f3f3;
padding: 0.15em 0.4em;
border-radius: 3px;
color: #d73a49;
}
.tiptap-editor pre {
background: #f6f8fa;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
margin: 1em 0;
}
.tiptap-editor pre code {
background: none;
padding: 0;
color: #24292e;
font-size: 0.85em;
line-height: 1.6;
}
.tiptap-editor blockquote {
border-left: 4px solid #dfe2e5;
padding-left: 16px;
margin: 1em 0;
color: #6a737d;
font-style: italic;
}
.tiptap-editor ul,
.tiptap-editor ol {
margin: 0.6em 0;
padding-left: 2em;
}
.tiptap-editor li {
margin: 0.2em 0;
}
.tiptap-editor li > p {
margin: 0.2em 0;
}
.tiptap-editor hr {
border: none;
border-top: 1px solid #e1e4e8;
margin: 1.5em 0;
}
.tiptap-editor a {
color: #0366d6;
text-decoration: none;
}
.tiptap-editor a:hover {
text-decoration: underline;
}
/* Task list */
.tiptap-editor ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.tiptap-editor ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
}
.tiptap-editor ul[data-type="taskList"] li > label {
flex-shrink: 0;
margin-top: 0.3em;
}
.tiptap-editor ul[data-type="taskList"] li > div {
flex: 1;
}
/* Selection */
.tiptap-editor .ProseMirror ::selection {
background: #b4d7ff;
}
/* Cursor */
.tiptap-editor .ProseMirror-focused .ProseMirror-gapcursor {
display: none;
}
/* ========== Dark Theme ========== */
[data-theme="dark"] .tiptap-editor {
color: #dadadb;
}
[data-theme="dark"] .tiptap-editor h1,
[data-theme="dark"] .tiptap-editor h2,
[data-theme="dark"] .tiptap-editor h3,
[data-theme="dark"] .tiptap-editor strong {
color: #f0f0f0;
}
[data-theme="dark"] .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: #666;
}
[data-theme="dark"] .tiptap-editor code {
background: #2e2e33;
color: #ff7b72;
}
[data-theme="dark"] .tiptap-editor pre {
background: #2e2e33;
}
[data-theme="dark"] .tiptap-editor pre code {
color: #dadadb;
}
[data-theme="dark"] .tiptap-editor blockquote {
border-left-color: #444;
color: #9b9c9d;
}
[data-theme="dark"] .tiptap-editor hr {
border-top-color: #333;
}
[data-theme="dark"] .tiptap-editor a {
color: #58a6ff;
}
[data-theme="dark"] .tiptap-editor .ProseMirror ::selection {
background: #1c4e80;
}

View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
outDir: resolve(__dirname, '../../public/tiptap'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'TiptapEditor',
fileName: 'editor',
formats: ['iife'],
},
rollupOptions: {
output: {
exports: 'default',
assetFileNames: 'editor.[ext]',
inlineDynamicImports: true,
},
},
cssCodeSplit: false,
minify: true,
sourcemap: true,
},
});

1
public/tiptap/editor.css Normal file
View File

@ -0,0 +1 @@
.tiptap-editor{position:relative;width:100%;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:16px;line-height:1.75;color:#2c2c2c;outline:none}.tiptap-editor .ProseMirror{width:100%;height:100%;padding:24px 32px;outline:none;overflow-y:auto}.tiptap-editor .ProseMirror-focused{outline:none}.tiptap-editor .ProseMirror p.is-editor-empty:first-child:before{content:attr(data-placeholder);float:left;color:#999;pointer-events:none;height:0}.tiptap-editor h1{font-size:2em;font-weight:700;margin:1.2em 0 .6em;line-height:1.3;color:#1a1a1a}.tiptap-editor h2{font-size:1.5em;font-weight:600;margin:1em 0 .5em;line-height:1.35;color:#1a1a1a}.tiptap-editor h3{font-size:1.25em;font-weight:600;margin:.8em 0 .4em;color:#1a1a1a}.tiptap-editor p{margin:.6em 0}.tiptap-editor p:first-child{margin-top:0}.tiptap-editor strong{font-weight:700;color:#1a1a1a}.tiptap-editor em{font-style:italic}.tiptap-editor s{text-decoration:line-through;opacity:.7}.tiptap-editor code{font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,Consolas,monospace;font-size:.875em;background:#f3f3f3;padding:.15em .4em;border-radius:3px;color:#d73a49}.tiptap-editor pre{background:#f6f8fa;border-radius:6px;padding:16px;overflow-x:auto;margin:1em 0}.tiptap-editor pre code{background:none;padding:0;color:#24292e;font-size:.85em;line-height:1.6}.tiptap-editor blockquote{border-left:4px solid #dfe2e5;padding-left:16px;margin:1em 0;color:#6a737d;font-style:italic}.tiptap-editor ul,.tiptap-editor ol{margin:.6em 0;padding-left:2em}.tiptap-editor li{margin:.2em 0}.tiptap-editor li>p{margin:.2em 0}.tiptap-editor hr{border:none;border-top:1px solid #e1e4e8;margin:1.5em 0}.tiptap-editor a{color:#0366d6;text-decoration:none}.tiptap-editor a:hover{text-decoration:underline}.tiptap-editor ul[data-type=taskList]{list-style:none;padding-left:0}.tiptap-editor ul[data-type=taskList] li{display:flex;align-items:flex-start;gap:.5em}.tiptap-editor ul[data-type=taskList] li>label{flex-shrink:0;margin-top:.3em}.tiptap-editor ul[data-type=taskList] li>div{flex:1}.tiptap-editor .ProseMirror ::selection{background:#b4d7ff}.tiptap-editor .ProseMirror-focused .ProseMirror-gapcursor{display:none}[data-theme=dark] .tiptap-editor{color:#dadadb}[data-theme=dark] .tiptap-editor h1,[data-theme=dark] .tiptap-editor h2,[data-theme=dark] .tiptap-editor h3,[data-theme=dark] .tiptap-editor strong{color:#f0f0f0}[data-theme=dark] .tiptap-editor .ProseMirror p.is-editor-empty:first-child:before{color:#666}[data-theme=dark] .tiptap-editor code{background:#2e2e33;color:#ff7b72}[data-theme=dark] .tiptap-editor pre{background:#2e2e33}[data-theme=dark] .tiptap-editor pre code{color:#dadadb}[data-theme=dark] .tiptap-editor blockquote{border-left-color:#444;color:#9b9c9d}[data-theme=dark] .tiptap-editor hr{border-top-color:#333}[data-theme=dark] .tiptap-editor a{color:#58a6ff}[data-theme=dark] .tiptap-editor .ProseMirror ::selection{background:#1c4e80}

207
public/tiptap/editor.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,19 +2,37 @@ use dioxus::prelude::*;
use crate::components::admin_layout::AdminLayout;
fn markdown_to_html(input: &str) -> String {
let parser = pulldown_cmark::Parser::new(input);
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, parser);
html
}
#[component]
pub fn WritePage() -> Element {
let mut title = use_signal(|| "".to_string());
let mut content = use_signal(|| "".to_string());
let preview_html = use_memo(move || {
markdown_to_html(&content())
use_effect(move || {
#[cfg(target_arch = "wasm32")]
{
let _ = js_sys::eval(
r#"
(function initEditor() {
var container = document.getElementById('tiptap-editor');
if (!container) {
setTimeout(initEditor, 50);
return;
}
if (typeof window.TiptapEditor !== 'undefined' && window.TiptapEditor) {
window.TiptapEditor.create('tiptap-editor', {
content: '',
placeholder: '...',
onUpdate: function(markdown) {
window.__tiptap_content = markdown;
}
});
return;
}
setTimeout(initEditor, 50);
})();
"#,
);
}
});
rsx! {
@ -28,40 +46,31 @@ pub fn WritePage() -> Element {
oninput: move |evt| title.set(evt.value()),
}
// 两栏布局
div { class: "grid grid-cols-1 md:grid-cols-2 gap-6",
// 编辑区
div { class: "space-y-2",
label { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
"Markdown"
}
textarea {
class: "w-full h-[500px] bg-gray-50 dark:bg-[#2e2e33] rounded-lg p-4 font-mono text-sm text-gray-800 dark:text-[#dadadb] placeholder-gray-400 dark:placeholder-[#9b9c9d] border border-gray-200 dark:border-[#333] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600 resize-none",
placeholder: "在此输入 Markdown...",
value: "{content}",
oninput: move |evt| content.set(evt.value()),
}
}
// 预览区
div { class: "space-y-2",
label { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
"预览"
}
div {
class: "w-full h-[500px] overflow-y-auto bg-white dark:bg-[#2e2e33] rounded-lg p-4 border border-gray-200 dark:border-[#333] prose dark:prose-invert max-w-none",
dangerous_inner_html: "{preview_html}",
}
}
// Tiptap 编辑器容器
div {
class: "w-full h-[600px] border border-gray-200 dark:border-[#333] rounded-lg overflow-hidden bg-white dark:bg-[#1e1e1e]",
id: "tiptap-editor",
}
// 保存按钮
button {
class: "mt-4 px-6 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full font-medium hover:opacity-80 transition-opacity",
onclick: move |_| {
let t = title();
let c = content();
println!("保存文章: title={}, content_len={}", t, c.len());
#[cfg(target_arch = "wasm32")]
{
let md = js_sys::eval(r#"
(function() {
var editor = window.TiptapEditor && window.TiptapEditor._instances && window.TiptapEditor._instances.get('tiptap-editor');
return editor ? editor.getMarkdown() : (window.__tiptap_content || '');
})()
"#).ok().and_then(|v| v.as_string()).unwrap_or_default();
content.set(md.clone());
println!("保存文章: title={}, content_len={}", title(), md.len());
}
#[cfg(not(target_arch = "wasm32"))]
{
println!("保存文章: title={}, content_len={}", title(), content().len());
}
},
"保存草稿"
}