来源新增修改
This commit is contained in:
1 parent
7652db4601
commit
a01d7123d7
6 files changed
+862
-45
No files matched your search
@@ -273,7 +273,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<WritingToolbar editor={editor} />
|
||||
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
|
||||
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
Quote,
|
||||
Code,
|
||||
Pilcrow,
|
||||
Download,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||
import { exportMarkdownToDocx } from "../../utils/export-docx";
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -60,11 +65,32 @@ function ToolbarButton({ onClick, active, disabled, tooltip, children }: Toolbar
|
||||
|
||||
interface WritingToolbarProps {
|
||||
editor: Editor | null;
|
||||
/** 文档源(markdown),用于导出 Word;空字符串时下载按钮禁用 */
|
||||
markdown?: string;
|
||||
/** 文档标题,用作 .docx 文件名与封面 */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function WritingToolbar({ editor }: WritingToolbarProps) {
|
||||
export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const canExport = !exporting && !!markdown?.trim();
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!canExport) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportMarkdownToDocx(markdown ?? "", title);
|
||||
} catch (err) {
|
||||
console.error("[export-docx] failed", err);
|
||||
toast.error("导出 Word 失败,请稍后重试");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
||||
{/* Block type */}
|
||||
@@ -173,8 +199,37 @@ export function WritingToolbar({ editor }: WritingToolbarProps) {
|
||||
<Strikethrough className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Model selector — pushed to the right */}
|
||||
<div className="ml-auto">
|
||||
{/* Right side: export + model selector */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleExport();
|
||||
}}
|
||||
disabled={!canExport}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
导出 Word
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||
|
||||
<CanvasModelSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Markdown → Word(.docx) 导出工具。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 纯前端,避免新增后端接口;输入是 AI 写作页右侧 artifact 的 fullMarkdown。
|
||||
* - 文档结构:封面标题(无 H1 时补) → 正文。
|
||||
*/
|
||||
import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
LevelFormat,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
} from "docx";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
/** 标题层级(H1~H3) */
|
||||
type HeadingLevelNum = 1 | 2 | 3;
|
||||
|
||||
interface ParsedHeading {
|
||||
level: HeadingLevelNum;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type Block =
|
||||
| { kind: "heading"; level: HeadingLevelNum; text: string }
|
||||
| { kind: "paragraph"; text: string }
|
||||
| { kind: "blockquote"; text: string }
|
||||
| { kind: "code"; code: string }
|
||||
| { kind: "list"; ordered: boolean; items: string[] };
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Markdown 解析(行级)—— 只覆盖 AI 写作场景常见语法
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const RE_HEADING = /^(#{1,3})\s+(.*)$/;
|
||||
const RE_FENCE = /^```/;
|
||||
const RE_QUOTE = /^>\s?/;
|
||||
const RE_UL = /^[-*]\s+/;
|
||||
const RE_OL = /^\d+\.\s+/;
|
||||
const RE_BLOCK_START = /^(#{1,3}\s|[-*]\s|\d+\.\s|>\s|```)/;
|
||||
|
||||
function parseMarkdown(markdown: string): { blocks: Block[]; headings: ParsedHeading[] } {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
const headings: ParsedHeading[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!line.trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_FENCE.test(line)) {
|
||||
const buf: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !RE_FENCE.test(lines[i])) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++; // 跳过闭合 ```
|
||||
blocks.push({ kind: "code", code: buf.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hm = RE_HEADING.exec(line);
|
||||
if (hm) {
|
||||
const level = hm[1].length as HeadingLevelNum;
|
||||
const text = hm[2].trim();
|
||||
headings.push({ level, text });
|
||||
blocks.push({ kind: "heading", level, text });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_QUOTE.test(line)) {
|
||||
const buf: string[] = [];
|
||||
while (i < lines.length && RE_QUOTE.test(lines[i])) {
|
||||
buf.push(lines[i].replace(RE_QUOTE, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "blockquote", text: buf.join("\n") });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_UL.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && RE_UL.test(lines[i])) {
|
||||
items.push(lines[i].replace(RE_UL, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "list", ordered: false, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RE_OL.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && RE_OL.test(lines[i])) {
|
||||
items.push(lines[i].replace(RE_OL, ""));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "list", ordered: true, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落:合并相邻非空、非块起始行
|
||||
const buf: string[] = [line];
|
||||
i++;
|
||||
while (i < lines.length && lines[i].trim() && !RE_BLOCK_START.test(lines[i])) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: "paragraph", text: buf.join("\n") });
|
||||
}
|
||||
|
||||
return { blocks, headings };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 行内格式 → TextRun / Hyperlink
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 行内 token 匹配顺序很重要:**bold** 必须先于 *italic*;`code` 与 [link]() 独立。 */
|
||||
const RE_INLINE = /(\*\*[^*\n]+?\*\*)|(\*[^*\n]+?\*)|(_[^_\n]+?_)|(`[^`\n]+?`)|(\[[^\]\n]+\]\([^)\n]+\))/g;
|
||||
|
||||
type InlineChild = TextRun | ExternalHyperlink;
|
||||
|
||||
function parseInline(text: string): InlineChild[] {
|
||||
if (!text) return [new TextRun({ text: "" })];
|
||||
|
||||
const out: InlineChild[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
RE_INLINE.lastIndex = 0;
|
||||
|
||||
while ((m = RE_INLINE.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
out.push(new TextRun({ text: text.slice(last, m.index) }));
|
||||
}
|
||||
const tok = m[0];
|
||||
if (tok.startsWith("**")) {
|
||||
out.push(new TextRun({ text: tok.slice(2, -2), bold: true }));
|
||||
} else if (tok.startsWith("`")) {
|
||||
out.push(new TextRun({ text: tok.slice(1, -1), font: "Consolas" }));
|
||||
} else if (tok.startsWith("[")) {
|
||||
const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok);
|
||||
if (linkMatch) {
|
||||
out.push(
|
||||
new ExternalHyperlink({
|
||||
link: linkMatch[2],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: linkMatch[1],
|
||||
color: "0563C1",
|
||||
underline: {},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
out.push(new TextRun({ text: tok }));
|
||||
}
|
||||
} else if (tok.startsWith("*") || tok.startsWith("_")) {
|
||||
out.push(new TextRun({ text: tok.slice(1, -1), italics: true }));
|
||||
}
|
||||
last = RE_INLINE.lastIndex;
|
||||
}
|
||||
if (last < text.length) {
|
||||
out.push(new TextRun({ text: text.slice(last) }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Block → Paragraph[]
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DOCX_HEADING: Record<HeadingLevelNum, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||
1: HeadingLevel.HEADING_1,
|
||||
2: HeadingLevel.HEADING_2,
|
||||
3: HeadingLevel.HEADING_3,
|
||||
};
|
||||
|
||||
function blockToParagraphs(b: Block): Paragraph[] {
|
||||
switch (b.kind) {
|
||||
case "heading": {
|
||||
return [
|
||||
new Paragraph({
|
||||
heading: DOCX_HEADING[b.level],
|
||||
children: parseInline(b.text),
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "paragraph": {
|
||||
return [
|
||||
new Paragraph({
|
||||
children: parseInline(b.text),
|
||||
spacing: { line: 360, after: 120 },
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "blockquote": {
|
||||
return [
|
||||
new Paragraph({
|
||||
children: parseInline(b.text),
|
||||
indent: { left: 480 },
|
||||
spacing: { line: 360, after: 120 },
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "code": {
|
||||
return b.code.split("\n").map(
|
||||
(line) =>
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: line || " ", font: "Consolas", size: 20 })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
return b.items.map(
|
||||
(item) =>
|
||||
new Paragraph({
|
||||
numbering: {
|
||||
reference: b.ordered ? "ol" : "ul",
|
||||
level: 0,
|
||||
},
|
||||
children: parseInline(item),
|
||||
spacing: { line: 320 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 文件名工具
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Windows 非法字符 + 控制字符 → 下划线
|
||||
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").trim();
|
||||
}
|
||||
|
||||
function fallbackFilename(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `AI写作-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function resolveFilename(title: string | undefined): string {
|
||||
const base = title ? sanitizeFilename(title) : "";
|
||||
return `${base || fallbackFilename()}.docx`;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 主入口
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 把 markdown 文档导出为 .docx 并触发浏览器下载。
|
||||
*
|
||||
* @param markdown 文档源(来自 AI 写作 artifact 的 fullMarkdown)
|
||||
* @param title 文档标题,用作文件名与封面(空则用时间戳兜底)
|
||||
*/
|
||||
export async function exportMarkdownToDocx(
|
||||
markdown: string,
|
||||
title?: string,
|
||||
): Promise<void> {
|
||||
const { blocks, headings } = parseMarkdown(markdown ?? "");
|
||||
|
||||
// 封面标题:优先用入参 title;否则用 markdown 里的第一个 H1
|
||||
const coverText =
|
||||
(title && title.trim()) ||
|
||||
headings.find((h) => h.level === 1)?.text ||
|
||||
"未命名文档";
|
||||
|
||||
const children: Paragraph[] = [];
|
||||
|
||||
// 封面标题(仅当 markdown 本身没有 H1 时补一个,避免与正文里的 H1 重复)
|
||||
const hasFirstH1 = headings[0]?.level === 1;
|
||||
if (!hasFirstH1) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 240, after: 240 },
|
||||
children: [new TextRun({ text: coverText, bold: true, size: 36 })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 正文
|
||||
for (const b of blocks) {
|
||||
children.push(...blockToParagraphs(b));
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
creator: "DeerFlow AI 写作",
|
||||
title: coverText,
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: { font: "微软雅黑", size: 22 }, // 默认正文 11pt 微软雅黑
|
||||
},
|
||||
},
|
||||
},
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "ul",
|
||||
levels: [
|
||||
{
|
||||
level: 0,
|
||||
format: LevelFormat.BULLET,
|
||||
text: "•",
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: "ol",
|
||||
levels: [
|
||||
{
|
||||
level: 0,
|
||||
format: LevelFormat.DECIMAL,
|
||||
text: "%1.",
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent: { left: 360, hanging: 240 } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
saveAs(blob, resolveFilename(title));
|
||||
}
|
||||
Reference in new issue
Block a user