This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/markdown/page.tsx
2026-08-27 20:15:24 +08:00

186 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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("`", "代码", "`")}>&lt;/&gt;</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>;
});
}