Update WBBR frontend template collection
This commit is contained in:
185
app/markdown/page.tsx
Normal file
185
app/markdown/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Fragment, ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import data from "../data/content-templates.json";
|
||||
import styles from "./MarkdownEditor.module.css";
|
||||
|
||||
type ViewMode = "edit" | "split" | "preview";
|
||||
type DocumentItem = (typeof data.documents)[number];
|
||||
|
||||
export default function MarkdownEditorPage() {
|
||||
const [documents, setDocuments] = useState<DocumentItem[]>(data.documents);
|
||||
const [activeId, setActiveId] = useState(data.documents[0].id);
|
||||
const [contents, setContents] = useState<Record<string, string>>(() => Object.fromEntries(data.documents.map((doc) => [doc.id, doc.content])));
|
||||
const [view, setView] = useState<ViewMode>("split");
|
||||
const [query, setQuery] = useState("");
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [toast, setToast] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cached = window.localStorage.getItem("orbit-markdown-workspace");
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (parsed.contents) setContents((current) => ({ ...current, ...parsed.contents }));
|
||||
if (Array.isArray(parsed.documents)) setDocuments(parsed.documents);
|
||||
if (typeof parsed.activeId === "string") setActiveId(parsed.activeId);
|
||||
}
|
||||
} catch { /* use JSON defaults */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSaved(false);
|
||||
const timer = window.setTimeout(() => {
|
||||
window.localStorage.setItem("orbit-markdown-workspace", JSON.stringify({ documents, contents, activeId }));
|
||||
setSaved(true);
|
||||
}, 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [documents, contents, activeId]);
|
||||
|
||||
const activeDocument = documents.find((doc) => doc.id === activeId) ?? documents[0];
|
||||
const content = contents[activeId] ?? "";
|
||||
const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(query.toLowerCase()));
|
||||
const stats = useMemo(() => ({
|
||||
chars: content.replace(/\s/g, "").length,
|
||||
lines: content.split("\n").length,
|
||||
words: content.trim() ? content.trim().split(/\s+/).length : 0,
|
||||
}), [content]);
|
||||
|
||||
const notify = (message: string) => {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 2000);
|
||||
};
|
||||
|
||||
const updateContent = (value: string) => setContents((current) => ({ ...current, [activeId]: value }));
|
||||
|
||||
const insert = (before: string, placeholder = "文本", after = "") => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = content.slice(start, end) || placeholder;
|
||||
const next = `${content.slice(0, start)}${before}${selected}${after}${content.slice(end)}`;
|
||||
updateContent(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||
});
|
||||
};
|
||||
|
||||
const createDocument = () => {
|
||||
const id = `md-${Date.now()}`;
|
||||
const newDocument: DocumentItem = { id, title: "未命名文档", category: "草稿", updated: "刚刚", content: "# 未命名文档\n\n开始写作……" };
|
||||
setDocuments((current) => [newDocument, ...current]);
|
||||
setContents((current) => ({ ...current, [id]: newDocument.content }));
|
||||
setActiveId(id);
|
||||
notify("已创建新文档");
|
||||
};
|
||||
|
||||
const renameDocument = (title: string) => setDocuments((current) => current.map((doc) => doc.id === activeId ? { ...doc, title, updated: "刚刚" } : doc));
|
||||
|
||||
const downloadDocument = () => {
|
||||
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = window.document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${activeDocument?.title || "document"}.md`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
notify("Markdown 文件已导出");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.topbar}>
|
||||
<Link className={styles.brand} href="/"><span>M↓</span><strong>Paper</strong></Link>
|
||||
<nav><Link href="/simple-list">简单列表</Link><Link href="/character">角色详情</Link><Link className={styles.active} href="/markdown">Markdown 编辑器</Link></nav>
|
||||
<div className={styles.saveState}><i className={saved ? styles.saved : ""} />{saved ? "已自动保存" : "正在保存…"}</div>
|
||||
<div className={styles.headerActions}><button onClick={() => notify("协作成员面板为模板占位")}>+ 协作</button><button className={styles.exportButton} onClick={downloadDocument}>导出 .md</button><span>林</span></div>
|
||||
</header>
|
||||
|
||||
<div className={styles.workspace}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}><div><strong>我的文档</strong><small>{documents.length} 篇</small></div><button onClick={createDocument}>+</button></div>
|
||||
<label className={styles.search}><span>⌕</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索文档" /></label>
|
||||
<div className={styles.documentList}>
|
||||
<p>最近编辑</p>
|
||||
{filteredDocuments.map((doc) => <button key={doc.id} className={activeId === doc.id ? styles.documentActive : ""} onClick={() => setActiveId(doc.id)}><span>{doc.category === "草稿" ? "○" : "M"}</span><div><strong>{doc.title}</strong><small>{doc.category} · {doc.updated}</small></div><i>•••</i></button>)}
|
||||
{filteredDocuments.length === 0 && <div className={styles.noDocument}>没有匹配文档</div>}
|
||||
</div>
|
||||
<div className={styles.sidebarFooter}><Link href="/simple-list">▤ 内容资料库</Link><button onClick={() => notify("设置面板为模板占位")}>⚙ 编辑器设置</button></div>
|
||||
</aside>
|
||||
|
||||
<main className={styles.editorArea}>
|
||||
<div className={styles.documentHeader}>
|
||||
<div><span>{activeDocument?.category || "文档"}</span><input value={activeDocument?.title || ""} onChange={(event) => renameDocument(event.target.value)} aria-label="文档标题" /></div>
|
||||
<div className={styles.viewSwitch}><button className={view === "edit" ? styles.viewActive : ""} onClick={() => setView("edit")}>编辑</button><button className={view === "split" ? styles.viewActive : ""} onClick={() => setView("split")}>分栏</button><button className={view === "preview" ? styles.viewActive : ""} onClick={() => setView("preview")}>预览</button></div>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<div><button title="一级标题" onClick={() => insert("# ", "标题")}>H1</button><button title="二级标题" onClick={() => insert("## ", "标题")}>H2</button><button title="三级标题" onClick={() => insert("### ", "标题")}>H3</button></div>
|
||||
<i />
|
||||
<div><button title="粗体" onClick={() => insert("**", "粗体文本", "**")}><b>B</b></button><button title="斜体" onClick={() => insert("*", "斜体文本", "*")}><em>I</em></button><button title="删除线" onClick={() => insert("~~", "删除文本", "~~")}><s>S</s></button></div>
|
||||
<i />
|
||||
<div><button title="无序列表" onClick={() => insert("- ", "列表项")}>•≡</button><button title="有序列表" onClick={() => insert("1. ", "列表项")}>1≡</button><button title="引用" onClick={() => insert("> ", "引用内容")}>❞</button><button title="行内代码" onClick={() => insert("`", "代码", "`")}></></button><button title="链接" onClick={() => insert("[", "链接文字", "](https://example.com)")}>⌁</button><button title="分割线" onClick={() => insert("\n---\n", "")}>—</button></div>
|
||||
<span className={styles.toolbarHint}>Markdown · UTF-8</span>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.panes} ${styles[`view_${view}`]}`}>
|
||||
{view !== "preview" && <section className={styles.editorPane}>
|
||||
<div className={styles.lineNumbers}>{content.split("\n").map((_, index) => <span key={index}>{index + 1}</span>)}</div>
|
||||
<textarea ref={textareaRef} value={content} onChange={(event) => updateContent(event.target.value)} spellCheck={false} aria-label="Markdown 编辑区" />
|
||||
</section>}
|
||||
{view !== "edit" && <section className={styles.previewPane}><MarkdownPreview content={content} /></section>}
|
||||
</div>
|
||||
|
||||
<footer className={styles.statusbar}><div><span>Ln {textareaRef.current ? content.slice(0, textareaRef.current.selectionStart).split("\n").length : 1}</span><span>{stats.lines} 行</span><span>{stats.chars} 字符</span><span>{stats.words} 词</span></div><div><span>Markdown</span><span>UTF-8</span><button onClick={() => notify("快捷键:Ctrl / Cmd + S 保存")}>⌨ 快捷键</button></div></footer>
|
||||
</main>
|
||||
</div>
|
||||
{toast && <div className={styles.toast}>✓ {toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownPreview({ content }: { content: string }) {
|
||||
const lines = content.split("\n");
|
||||
let codeMode = false;
|
||||
const blocks: ReactNode[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (line.trim().startsWith("```")) {
|
||||
codeMode = !codeMode;
|
||||
blocks.push(<div className={styles.codeFence} key={index}>{codeMode ? "CODE" : ""}</div>);
|
||||
return;
|
||||
}
|
||||
if (codeMode) { blocks.push(<code className={styles.codeLine} key={index}>{line || " "}</code>); return; }
|
||||
if (/^###\s/.test(line)) blocks.push(<h3 key={index}>{renderInline(line.slice(4))}</h3>);
|
||||
else if (/^##\s/.test(line)) blocks.push(<h2 key={index}>{renderInline(line.slice(3))}</h2>);
|
||||
else if (/^#\s/.test(line)) blocks.push(<h1 key={index}>{renderInline(line.slice(2))}</h1>);
|
||||
else if (/^>\s?/.test(line)) blocks.push(<blockquote key={index}>{renderInline(line.replace(/^>\s?/, ""))}</blockquote>);
|
||||
else if (/^---+$/.test(line.trim())) blocks.push(<hr key={index} />);
|
||||
else if (/^-\s/.test(line)) blocks.push(<div className={styles.unordered} key={index}><span>•</span><p>{renderInline(line.slice(2))}</p></div>);
|
||||
else if (/^\d+\.\s/.test(line)) blocks.push(<div className={styles.ordered} key={index}><span>{line.match(/^\d+/)?.[0]}.</span><p>{renderInline(line.replace(/^\d+\.\s/, ""))}</p></div>);
|
||||
else if (!line.trim()) blocks.push(<div className={styles.blank} key={index} />);
|
||||
else blocks.push(<p key={index}>{renderInline(line)}</p>);
|
||||
});
|
||||
return <article className={styles.markdownBody}>{blocks}</article>;
|
||||
}
|
||||
|
||||
function renderInline(text: string): ReactNode {
|
||||
const token = /(\*\*.+?\*\*|~~.+?~~|`.+?`|\*.+?\*|\[[^\]]+\]\([^\)]+\))/g;
|
||||
return text.split(token).map((part, index) => {
|
||||
if (part.startsWith("**") && part.endsWith("**")) return <strong key={index}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith("~~") && part.endsWith("~~")) return <del key={index}>{part.slice(2, -2)}</del>;
|
||||
if (part.startsWith("`") && part.endsWith("`")) return <code key={index}>{part.slice(1, -1)}</code>;
|
||||
if (part.startsWith("*") && part.endsWith("*")) return <em key={index}>{part.slice(1, -1)}</em>;
|
||||
const link = part.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
|
||||
if (link) {
|
||||
const href = /^(https?:\/\/|#)/.test(link[2]) ? link[2] : "#";
|
||||
return <a key={index} href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noreferrer">{link[1]}</a>;
|
||||
}
|
||||
return <Fragment key={index}>{part}</Fragment>;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user