This commit is contained in:
2026-02-26 15:14:13 +08:00
parent eb68553baf
commit 911c669d81
7 changed files with 278 additions and 22 deletions
BIN
View File
Binary file not shown.
+18 -3
View File
@@ -2,6 +2,7 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { requireApiAuth } from '@/lib/api-auth'; import { requireApiAuth } from '@/lib/api-auth';
import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import';
type PageRef = { id: string; parentId: string | null }; type PageRef = { id: string; parentId: string | null };
@@ -119,11 +120,23 @@ export async function PUT(
updateData.title = trimmed || '无标题'; updateData.title = trimmed || '无标题';
} }
if (body.content !== undefined) { if (body.content !== undefined || body.importMarkdown !== undefined) {
if (typeof body.content !== 'string') { if (body.content !== undefined && typeof body.content !== 'string') {
return NextResponse.json({ error: 'Invalid content' }, { status: 400 }); return NextResponse.json({ error: 'Invalid content' }, { status: 400 });
} }
updateData.content = body.content; if (body.importMarkdown !== undefined && typeof body.importMarkdown !== 'string') {
return NextResponse.json({ error: 'Invalid importMarkdown' }, { status: 400 });
}
const source = typeof body.importMarkdown === 'string' ? markdownToImportHtml(body.importMarkdown) : (body.content as string);
updateData.content = cleanupAccidentalStandaloneInlineCode(source);
if (typeof body.importMarkdown === 'string' && process.env.NODE_ENV !== 'production') {
const lines = body.importMarkdown.replace(/\r\n?/g, '\n').split('\n');
const fenceLines = lines
.map((line: string, idx: number) => (line.trimStart().startsWith('```') || line.trimStart().startsWith('~~~') ? idx + 1 : -1))
.filter((n: number) => n > 0);
const preEnd = String(updateData.content || '').indexOf('</code></pre>');
console.info(`[import-md][PUT] id=${id} fences=${fenceLines.join(',') || 'none'} preEnd=${preEnd} len=${String(updateData.content || '').length}`);
}
} }
if (body.icon !== undefined) { if (body.icon !== undefined) {
@@ -213,3 +226,5 @@ export async function DELETE(
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 }); return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
} }
} }
+17 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { requireApiAuth } from '@/lib/api-auth'; import { requireApiAuth } from '@/lib/api-auth';
import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import';
const MAX_TITLE_LENGTH = 200; const MAX_TITLE_LENGTH = 200;
const MAX_TAGS = 20; const MAX_TAGS = 20;
@@ -64,7 +65,19 @@ export async function POST(request: Request) {
} }
const type = body.type === 'folder' ? 'folder' : 'file'; const type = body.type === 'folder' ? 'folder' : 'file';
const content = typeof body.content === 'string' ? body.content : ''; const importMarkdown = typeof body.importMarkdown === 'string' ? body.importMarkdown : null;
const rawContent = typeof body.content === 'string' ? body.content : '';
const content = cleanupAccidentalStandaloneInlineCode(
importMarkdown !== null ? markdownToImportHtml(importMarkdown) : rawContent
);
if (importMarkdown !== null && process.env.NODE_ENV !== 'production') {
const lines = importMarkdown.replace(/\r\n?/g, '\n').split('\n');
const fenceLines = lines
.map((line: string, idx: number) => (line.trimStart().startsWith('```') || line.trimStart().startsWith('~~~') ? idx + 1 : -1))
.filter((n: number) => n > 0);
const preEnd = content.indexOf('</code></pre>');
console.info(`[import-md][POST] title="${title}" fences=${fenceLines.join(',') || 'none'} preEnd=${preEnd} len=${content.length}`);
}
const parentId = typeof body.parentId === 'string' ? body.parentId : null; const parentId = typeof body.parentId === 'string' ? body.parentId : null;
const tags = normalizeTags(body.tags); const tags = normalizeTags(body.tags);
const icon = typeof body.icon === 'string' ? body.icon : null; const icon = typeof body.icon === 'string' ? body.icon : null;
@@ -105,3 +118,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Error creating page' }, { status: 500 }); return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import JSZip from "jszip"; import JSZip from "jszip";
import { marked } from "marked";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireApiAuth } from "@/lib/api-auth"; import { requireApiAuth } from "@/lib/api-auth";
import { markdownToImportHtml } from "@/lib/markdown-import";
type ZipEntry = { type ZipEntry = {
path: string; path: string;
@@ -68,7 +68,7 @@ async function parseMarkdownWithFrontmatter(filePath: string, content: string):
} }
} }
const htmlContent = await marked(markdownBody, { gfm: true, breaks: true }); const htmlContent = markdownToImportHtml(markdownBody);
return { title, order, tags, htmlContent }; return { title, order, tags, htmlContent };
} }
+31 -1
View File
@@ -47,6 +47,7 @@ import { Markdown } from "tiptap-markdown";
import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model"; import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state"; import { TextSelection } from "@tiptap/pm/state";
import { buildDropIndicatorText, mergeBlockTexts, moveContiguousSpan } from "@/lib/editor-block-ops"; import { buildDropIndicatorText, mergeBlockTexts, moveContiguousSpan } from "@/lib/editor-block-ops";
import { cleanupAccidentalStandaloneInlineCode } from "@/lib/markdown-import";
interface EditorProps { interface EditorProps {
content: string; content: string;
@@ -617,6 +618,8 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
targetInsertionIndex: number; targetInsertionIndex: number;
}>({ sourceStartIndex: -1, sourceEndIndex: -1, targetInsertionIndex: -1 }); }>({ sourceStartIndex: -1, sourceEndIndex: -1, targetInsertionIndex: -1 });
const activeBlockIndexRef = useRef<number | null>(null); const activeBlockIndexRef = useRef<number | null>(null);
const suppressNextUpdateRef = useRef(false);
const allowOnUpdateRef = useRef(true);
const { fontFamily, fontSize, lineHeight, tableLineHeight } = useSettingsStore(); const { fontFamily, fontSize, lineHeight, tableLineHeight } = useSettingsStore();
const editor = useEditor({ const editor = useEditor({
@@ -691,6 +694,11 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
], ],
content, content,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
if (suppressNextUpdateRef.current) {
suppressNextUpdateRef.current = false;
return;
}
if (!allowOnUpdateRef.current) return;
if (editor.getHTML() !== content) { if (editor.getHTML() !== content) {
onChange(editor.getHTML()); onChange(editor.getHTML());
} }
@@ -702,6 +710,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
spellcheck: "false", spellcheck: "false",
}, },
handleKeyDown: (_view, event) => { handleKeyDown: (_view, event) => {
allowOnUpdateRef.current = true;
if (!editable) return false; if (!editable) return false;
const editorInstance = editor; const editorInstance = editor;
@@ -727,6 +736,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
return false; return false;
}, },
handleClick: (view, pos, event) => { handleClick: (view, pos, event) => {
allowOnUpdateRef.current = true;
if (!onOpenWikiLink) return false; if (!onOpenWikiLink) return false;
const pointerEvent = event as MouseEvent; const pointerEvent = event as MouseEvent;
if (!pointerEvent.metaKey && !pointerEvent.ctrlKey) return false; if (!pointerEvent.metaKey && !pointerEvent.ctrlKey) return false;
@@ -738,6 +748,20 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
onOpenWikiLink(title); onOpenWikiLink(title);
return true; return true;
}, },
handleDOMEvents: {
mousedown: () => {
allowOnUpdateRef.current = true;
return false;
},
paste: () => {
allowOnUpdateRef.current = true;
return false;
},
beforeinput: () => {
allowOnUpdateRef.current = true;
return false;
},
},
}, },
editable, editable,
immediatelyRender: false, immediatelyRender: false,
@@ -882,9 +906,15 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
useEffect(() => { useEffect(() => {
if (editor && content !== editor.getHTML()) { if (editor && content !== editor.getHTML()) {
const normalizedContent = cleanupAccidentalStandaloneInlineCode(content);
suppressNextUpdateRef.current = true;
allowOnUpdateRef.current = false;
queueMicrotask(() => { queueMicrotask(() => {
editor.commands.setContent(content); editor.commands.setContent(normalizedContent);
}); });
if (normalizedContent !== content) {
onChange(normalizedContent);
}
} }
if (editor && onEditorReady) { if (editor && onEditorReady) {
onEditorReady(editor); onEditorReady(editor);
+74 -6
View File
@@ -2,8 +2,8 @@
import React, { createContext, useContext, useRef, useState, useCallback } from "react"; import React, { createContext, useContext, useRef, useState, useCallback } from "react";
import { useEditorStore } from "@/lib/store"; import { useEditorStore } from "@/lib/store";
import { marked } from "marked";
import JSZip from "jszip"; import JSZip from "jszip";
import { markdownToImportHtml } from "@/lib/markdown-import";
interface ImportContextType { interface ImportContextType {
triggerImport: (parentId?: string | null) => void; triggerImport: (parentId?: string | null) => void;
@@ -27,13 +27,14 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const targetParentIdRef = useRef<string | null>(null); const targetParentIdRef = useRef<string | null>(null);
const { fetchPages } = useEditorStore(); const { pages, fetchPages } = useEditorStore();
const postPage = useCallback( const postPage = useCallback(
async ( async (
payload: { payload: {
title: string; title: string;
content?: string; content?: string;
importMarkdown?: string;
parentId?: string | null; parentId?: string | null;
type: "file" | "folder"; type: "file" | "folder";
}, },
@@ -62,6 +63,41 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
[] []
); );
const putPage = useCallback(
async (
id: string,
payload: {
title?: string;
content?: string;
importMarkdown?: string;
parentId?: string | null;
type?: "file" | "folder";
},
contextLabel: string
) => {
const res = await fetch(`/api/pages/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "same-origin",
});
if (!res.ok) {
let detail = "";
try {
const data = await res.json();
detail = typeof data?.error === "string" ? data.error : JSON.stringify(data);
} catch {
detail = await res.text();
}
throw new ImportApiError(`${contextLabel}失败(${res.status}${detail ? `: ${detail}` : ""}`, res.status, detail);
}
return res.json();
},
[]
);
const triggerImport = useCallback((parentId: string | null = null) => { const triggerImport = useCallback((parentId: string | null = null) => {
targetParentIdRef.current = parentId; targetParentIdRef.current = parentId;
if (fileInputRef.current) { if (fileInputRef.current) {
@@ -94,13 +130,30 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
const handleSingleFileImport = async (file: File) => { const handleSingleFileImport = async (file: File) => {
const text = await file.text(); const text = await file.text();
const title = file.name.replace(/\.md$/i, ""); const title = file.name.replace(/\.md$/i, "");
const html = await marked.parse(text); const html = markdownToImportHtml(text);
const parentId = targetParentIdRef.current;
const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim());
if (existing) {
await putPage(
existing.id,
{
title,
content: html,
importMarkdown: text,
parentId,
type: "file",
},
"更新导入文件"
);
return;
}
await postPage( await postPage(
{ {
title, title,
content: html, content: html,
parentId: targetParentIdRef.current, importMarkdown: text,
parentId,
type: "file", type: "file",
}, },
"创建导入文件" "创建导入文件"
@@ -199,18 +252,33 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
const markdown = await zipFile.async("string"); const markdown = await zipFile.async("string");
const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, ""); const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, "");
const html = await marked.parse(markdown); const html = markdownToImportHtml(markdown);
const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim());
if (existing) {
await putPage(
existing.id,
{
title,
content: html,
importMarkdown: markdown,
parentId,
type: "file",
},
`更新文件 ${path}`
);
} else {
await postPage( await postPage(
{ {
title, title,
content: html, content: html,
importMarkdown: markdown,
parentId, parentId,
type: "file", type: "file",
}, },
`创建文件 ${path}` `创建文件 ${path}`
); );
} }
}
}; };
return ( return (
+127
View File
@@ -0,0 +1,127 @@
import { marked } from "marked";
type MarkdownToHtmlOptions = {
breaks?: boolean;
};
type CodeFenceBlock = {
placeholder: string;
html: string;
};
function escapeHtml(raw: string): string {
return raw
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeRegExp(raw: string): string {
return raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildCodeBlockHtml(langRaw: string, codeRaw: string): string {
const language = (langRaw || "").trim().split(/\s+/)[0];
const classAttr = language ? ` class="language-${escapeHtml(language)}"` : "";
return `<pre><code${classAttr}>${escapeHtml(codeRaw)}</code></pre>`;
}
function parseWithMarked(markdown: string, breaks: boolean): string {
return marked.parse(markdown, { gfm: true, breaks }) as string;
}
function scanAndMaskFencedCodeBlocks(markdown: string): { masked: string; blocks: CodeFenceBlock[] } {
const normalized = markdown.replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
const output: string[] = [];
const blocks: CodeFenceBlock[] = [];
let blockIndex = 0;
let inFence = false;
let fenceChar = "";
let fenceLength = 0;
let fenceInfo = "";
let placeholder = "";
let contentLines: string[] = [];
for (const line of lines) {
if (!inFence) {
const open = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)\s*$/);
if (!open) {
output.push(line);
continue;
}
inFence = true;
fenceChar = open[1][0];
fenceLength = open[1].length;
fenceInfo = open[2] || "";
contentLines = [];
placeholder = `@@CODE_BLOCK_${blockIndex++}@@`;
output.push(placeholder);
continue;
}
const closeRe = new RegExp(`^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}\\s*$`);
if (closeRe.test(line)) {
blocks.push({
placeholder,
html: buildCodeBlockHtml(fenceInfo, contentLines.join("\n")),
});
inFence = false;
fenceChar = "";
fenceLength = 0;
fenceInfo = "";
placeholder = "";
contentLines = [];
continue;
}
contentLines.push(line);
}
if (inFence && placeholder) {
blocks.push({
placeholder,
html: buildCodeBlockHtml(fenceInfo, contentLines.join("\n")),
});
}
return { masked: output.join("\n"), blocks };
}
function parseWithExplicitFences(markdown: string, breaks: boolean): string {
const { masked, blocks } = scanAndMaskFencedCodeBlocks(markdown);
let html = parseWithMarked(masked, breaks);
for (const block of blocks) {
html = html.replace(`<p>${block.placeholder}</p>`, block.html);
html = html.split(block.placeholder).join(block.html);
}
return html;
}
export function cleanupAccidentalStandaloneInlineCode(html: string): string {
const standaloneMatches = html.match(/<(p|h[1-6]|li)><code>[\s\S]*?<\/code><\/\1>/g) || [];
if (standaloneMatches.length < 3) return html;
const headingCodeMatches = html.match(/<h[1-6]><code>[\s\S]*?<\/code><\/h[1-6]>/g) || [];
const suspiciousCount = standaloneMatches.filter((block) => /https?:\/\/|@\w|\/\s*\d{3,}|[一-龥]/.test(block)).length;
const hasPreBlock = /<pre>\s*<code(?:\s|>)/.test(html);
const looksMalformed =
headingCodeMatches.length > 0 ||
(hasPreBlock && standaloneMatches.length >= 5 && suspiciousCount >= 3);
if (!looksMalformed) return html;
return html.replace(/<(p|h[1-6]|li)><code>([\s\S]*?)<\/code><\/\1>/g, "<$1>$2</$1>");
}
export function markdownToImportHtml(markdown: string, options: MarkdownToHtmlOptions = {}): string {
const breaks = options.breaks ?? true;
const html = parseWithExplicitFences(markdown, breaks);
return cleanupAccidentalStandaloneInlineCode(html);
}