修复了导入bug
This commit is contained in:
Generated
-47
@@ -56,7 +56,6 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"turndown": "^7.2.2",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"zustand": "^5.0.9"
|
||||
@@ -6509,12 +6508,6 @@
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it-task-lists": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
|
||||
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
||||
@@ -8480,46 +8473,6 @@
|
||||
"@popperjs/core": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiptap-markdown": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^13.0.7",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"prosemirror-markdown": "^1.11.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tiptap-markdown/node_modules/@types/linkify-it": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
|
||||
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiptap-markdown/node_modules/@types/markdown-it": {
|
||||
"version": "13.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
|
||||
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^3",
|
||||
"@types/mdurl": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/tiptap-markdown/node_modules/@types/mdurl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
|
||||
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "note-ai",
|
||||
"version": "1.0.0105",
|
||||
"version": "1.0.0304",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
@@ -61,7 +61,6 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"turndown": "^7.2.2",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"zustand": "^5.0.9"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
-2
@@ -181,8 +181,13 @@
|
||||
|
||||
/* 移动端侧边栏文档树:增大字号和触摸目标 */
|
||||
.sidebar-tree-item {
|
||||
font-size: 0.875rem;
|
||||
min-height: 2.75rem;
|
||||
font-size: 0.9375rem !important;
|
||||
min-height: 2.5rem !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sidebar-tree-item .sidebar-title {
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,3 +385,21 @@ ul[data-type="taskList"],
|
||||
background: linear-gradient(180deg, hsl(var(--accent) / 0.42), hsl(var(--accent) / 0.25));
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--ring) / 0.28);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sidebar-title {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
max-height: calc(1.25em * 2);
|
||||
}
|
||||
}
|
||||
|
||||
+56
-1
@@ -7,6 +7,7 @@ import { Hash, X, Plus, ChevronLeft, ChevronRight, ChevronDown, Sparkles, Lock,
|
||||
import { useSettingsStore } from "@/lib/settings-store";
|
||||
import { exportPageAsMarkdown } from "@/lib/export";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { cn, getTagColor } from "@/lib/utils";
|
||||
import { useSearchStore } from "@/lib/search-store";
|
||||
import { type Editor as TiptapEditor } from "@tiptap/react";
|
||||
@@ -25,11 +26,12 @@ const ICONS = [
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const { activePageId, pages, updatePage, setActivePageId, addPage } = useEditorStore();
|
||||
const { activePageId, pages, pageSync, updatePage, setActivePageId, addPage, retryPageSync, retryPendingSyncs, flushPendingSyncs } = useEditorStore();
|
||||
const { openSearchWithTag } = useSearchStore();
|
||||
const { timezone } = useSettingsStore();
|
||||
|
||||
const activePage = pages.find((p) => p.id === activePageId);
|
||||
const activePageSync = activePageId ? pageSync[activePageId] : undefined;
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||
@@ -94,6 +96,33 @@ export default function Home() {
|
||||
await addPage(null, "file", { title, content: "" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => {
|
||||
retryPendingSyncs();
|
||||
};
|
||||
window.addEventListener("online", onOnline);
|
||||
return () => window.removeEventListener("online", onOnline);
|
||||
}, [retryPendingSyncs]);
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => flushPendingSyncs();
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
flush();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", flush);
|
||||
window.addEventListener("pagehide", flush);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", flush);
|
||||
window.removeEventListener("pagehide", flush);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [flushPendingSyncs]);
|
||||
|
||||
return (
|
||||
<ImportProvider>
|
||||
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
|
||||
@@ -263,6 +292,32 @@ export default function Home() {
|
||||
{activePage.isLocked ? "已锁定" : "锁定"}
|
||||
</button>
|
||||
|
||||
{activePageSync && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border px-2 py-0.5 text-[11px] md:text-xs",
|
||||
activePageSync.status === "saving" && "border-blue-200 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300",
|
||||
activePageSync.status === "pending" && "border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300",
|
||||
activePageSync.status === "error" && "border-red-200 bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300",
|
||||
activePageSync.status === "saved" && "border-emerald-200 bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300"
|
||||
)}
|
||||
title={activePageSync.message}
|
||||
>
|
||||
{activePageSync.message || "已保存"}
|
||||
</div>
|
||||
{(activePageSync.status === "pending" || activePageSync.status === "error") && (
|
||||
<button
|
||||
onClick={() => activePage && retryPageSync(activePage.id)}
|
||||
className="ui-btn-secondary px-2 py-0.5 text-[11px] md:text-xs"
|
||||
title="立即尝试同步到本地数据库"
|
||||
>
|
||||
立即同步
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePage.updatedAt && (
|
||||
<div className="w-full pt-0.5 text-[11px] text-muted-foreground/60 md:ml-1 md:h-4 md:w-auto md:border-l md:pl-2 md:pt-0 md:text-xs md:text-muted-foreground/50">
|
||||
{new Date(activePage.updatedAt).toLocaleString("zh-CN", {
|
||||
|
||||
@@ -43,11 +43,9 @@ import TableHeader from "@tiptap/extension-table-header";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Youtube from "@tiptap/extension-youtube";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { buildDropIndicatorText, mergeBlockTexts, moveContiguousSpan } from "@/lib/editor-block-ops";
|
||||
import { cleanupAccidentalStandaloneInlineCode } from "@/lib/markdown-import";
|
||||
|
||||
interface EditorProps {
|
||||
content: string;
|
||||
@@ -686,11 +684,6 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformPastedText: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
|
||||
@@ -95,7 +95,10 @@ export function CodeBlockComponent({
|
||||
</div>
|
||||
|
||||
<pre className="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-white/10 !my-0 !border-0 !p-0 overflow-x-auto text-zinc-300" style={{ backgroundColor: codeBg }}>
|
||||
<NodeViewContent className="block min-w-full !whitespace-pre !bg-transparent !p-4 !font-mono !text-sm !leading-6 outline-none" />
|
||||
<NodeViewContent
|
||||
as={"code" as unknown as "div"}
|
||||
className="block min-w-full !whitespace-pre !bg-transparent !p-4 !font-mono !text-sm !leading-6 outline-none"
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
||||
@@ -38,6 +38,8 @@ export const SlashCommand = Extension.create({
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
startOfLine: false,
|
||||
allowedPrefixes: [" ", "\t", "\n"],
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: CommandItem }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
|
||||
@@ -113,7 +113,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
let importedPageId: string | null = null;
|
||||
try {
|
||||
if (file.name.toLowerCase().endsWith(".zip")) {
|
||||
await handleZipImport(file);
|
||||
importedPageId = await handleZipImport(file);
|
||||
} else {
|
||||
importedPageId = await handleSingleFileImport(file);
|
||||
}
|
||||
@@ -164,7 +164,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
return result.id;
|
||||
};
|
||||
|
||||
const handleZipImport = async (file: File) => {
|
||||
const handleZipImport = async (file: File): Promise<string | null> => {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
|
||||
const rootName = file.name.replace(/(\.md)?\.zip$/i, "");
|
||||
@@ -220,6 +220,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const pathMap = new Map<string, string>();
|
||||
pathMap.set("", rootId);
|
||||
let firstImportedPageId: string | null = null;
|
||||
|
||||
const sortedFolders = Array.from(folderPaths).sort((a, b) => a.split("/").length - b.split("/").length);
|
||||
|
||||
@@ -268,8 +269,9 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
`更新文件 ${path}`
|
||||
);
|
||||
if (!firstImportedPageId) firstImportedPageId = existing.id;
|
||||
} else {
|
||||
await postPage(
|
||||
const created = await postPage(
|
||||
{
|
||||
title,
|
||||
importMarkdown: markdown,
|
||||
@@ -278,8 +280,11 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
`创建文件 ${path}`
|
||||
);
|
||||
if (!firstImportedPageId && created?.id) firstImportedPageId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
return firstImportedPageId;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -113,7 +113,7 @@ export function ResizableSidebar() {
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={cn(
|
||||
"absolute -right-3 top-6 z-50 rounded-full border bg-card p-1 text-foreground shadow-sm transition-all opacity-0 group-hover/sidebar:opacity-100 hover:bg-primary hover:text-primary-foreground",
|
||||
"absolute -right-3 top-6 z-50 rounded-full border bg-card p-1 text-foreground shadow-sm transition-all opacity-100 lg:opacity-0 lg:group-hover/sidebar:opacity-100 hover:bg-primary hover:text-primary-foreground",
|
||||
isCollapsed && "-right-8 opacity-100"
|
||||
)}
|
||||
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||
@@ -285,24 +285,35 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => addPage(null, "file")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件">
|
||||
<button
|
||||
onClick={() => addPage(null, "file")}
|
||||
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
|
||||
title="新建文件"
|
||||
>
|
||||
<span className="sr-only">新建文件</span>
|
||||
<FilePlus size={15} />
|
||||
<span className="md:hidden">文件</span>
|
||||
</button>
|
||||
<button onClick={() => addPage(null, "folder")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件夹">
|
||||
<button
|
||||
onClick={() => addPage(null, "folder")}
|
||||
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
|
||||
title="新建文件夹"
|
||||
>
|
||||
<span className="sr-only">新建文件夹</span>
|
||||
<FolderPlus size={15} />
|
||||
<span className="md:hidden">文件夹</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
handleItemClick();
|
||||
}}
|
||||
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
|
||||
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
|
||||
title="搜索文档 (Ctrl+K)"
|
||||
aria-label="搜索文档"
|
||||
>
|
||||
<Search size={15} />
|
||||
<span className="md:hidden">搜索</span>
|
||||
</button>
|
||||
{onCloseMobile && (
|
||||
<button
|
||||
@@ -319,7 +330,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
||||
|
||||
<button
|
||||
onClick={() => setIsFilterOpen((v) => !v)}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground md:h-8"
|
||||
className="flex h-11 w-full items-center justify-between rounded-md px-2 text-xs font-medium text-foreground/80 transition-colors hover:bg-muted/60 hover:text-foreground md:h-9"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<SlidersHorizontal size={13} />
|
||||
@@ -370,7 +381,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
||||
<div className="flex min-h-0 flex-1 flex-col p-2">
|
||||
<button
|
||||
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
|
||||
className="mb-1 flex h-10 w-full items-center justify-between rounded-lg px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground md:h-8"
|
||||
className="mb-1 flex h-11 w-full items-center justify-between rounded-lg px-2 text-sm font-semibold uppercase tracking-wide text-foreground/85 transition-colors hover:bg-muted/60 hover:text-foreground md:h-9 md:text-xs"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Layers size={13} className="opacity-80" />
|
||||
|
||||
@@ -107,10 +107,10 @@ function TreeNode({
|
||||
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
|
||||
<div
|
||||
className={cn(
|
||||
"sidebar-tree-item group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[13px] transition-colors cursor-pointer select-none md:h-8",
|
||||
"sidebar-tree-item group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[14px] transition-colors cursor-pointer select-none md:h-9 md:text-[13px]",
|
||||
isActive
|
||||
? "border-border/60 bg-accent/75 text-accent-foreground"
|
||||
: "text-muted-foreground hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
|
||||
: "text-foreground/85 hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
setActivePageId(node.id);
|
||||
@@ -127,7 +127,7 @@ function TreeNode({
|
||||
toggleExpand(node.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted-foreground/15 hover:text-foreground md:h-6 md:w-6",
|
||||
"flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/80 transition-colors hover:bg-muted-foreground/15 hover:text-foreground md:h-8 md:w-8",
|
||||
!hasChildren && !isFolder && "invisible"
|
||||
)}
|
||||
>
|
||||
@@ -147,7 +147,14 @@ function TreeNode({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{node.title || "无标题"}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"sidebar-title",
|
||||
level === 0 ? "text-[15px] font-semibold md:text-[14px]" : "text-[14px] md:text-[13px]"
|
||||
)}
|
||||
>
|
||||
{node.title || "无标题"}
|
||||
</span>
|
||||
{isFolder && (
|
||||
<span className="inline-flex min-w-[1.2rem] shrink-0 items-center justify-center rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{folderFileCount[node.id] ?? 0}
|
||||
@@ -160,7 +167,7 @@ function TreeNode({
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/75 transition-opacity hover:bg-muted-foreground/15 hover:text-foreground opacity-100 md:h-6 md:w-6 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/85 transition-opacity hover:bg-muted-foreground/15 hover:text-foreground opacity-100 md:h-8 md:w-8 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
@@ -268,7 +275,7 @@ export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, f
|
||||
|
||||
return (
|
||||
<SortableContext items={nodes.map((n) => n.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex flex-col gap-0 md:gap-0.5">
|
||||
{nodes.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
|
||||
+1
-41
@@ -1,48 +1,9 @@
|
||||
import TurndownService from "turndown";
|
||||
import JSZip from "jszip";
|
||||
import { saveAs } from "file-saver";
|
||||
import { Page } from "./store";
|
||||
import { useSettingsStore } from "./settings-store";
|
||||
import { sanitizeFilename } from "./page-utils";
|
||||
|
||||
import { gfm } from "turndown-plugin-gfm";
|
||||
|
||||
// Initialize Turndown service
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
bulletListMarker: "-",
|
||||
});
|
||||
|
||||
// Use GFM plugin for tables
|
||||
turndownService.use(gfm);
|
||||
|
||||
// Configure Turndown to handle some common elements better if needed
|
||||
turndownService.addRule('strikethrough', {
|
||||
filter: ['del', 's'],
|
||||
replacement: function (content: string) {
|
||||
return '~~' + content + '~~';
|
||||
}
|
||||
});
|
||||
|
||||
// Task List Support (Simple)
|
||||
turndownService.addRule('taskListItems', {
|
||||
filter: function (node: HTMLElement) {
|
||||
return node.nodeName === 'LI' && node.className.includes('task-list-item');
|
||||
},
|
||||
replacement: function (content: string, node: HTMLElement) {
|
||||
const isChecked = node.getAttribute('data-checked') === 'true';
|
||||
return (isChecked ? '- [x] ' : '- [ ] ') + content.replace(/^- /, '') + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts HTML content to Markdown
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html) return "";
|
||||
return turndownService.turndown(html);
|
||||
}
|
||||
import { htmlToMarkdown } from "./markdown-codec";
|
||||
|
||||
// Helper to escape YAML strings
|
||||
function escapeYamlString(str: string): string {
|
||||
@@ -179,4 +140,3 @@ function addPagesToZipRecursive(currentFolder: JSZip, parentId: string | null, a
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
export function exportToMarkdown(title: string, htmlContent: string) {
|
||||
// Simple HTML to MD conversion (basic elements)
|
||||
const md = htmlContent
|
||||
.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n')
|
||||
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
|
||||
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
|
||||
.replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
|
||||
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
|
||||
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/<.*?>/g, ''); // Strip remaining tags
|
||||
|
||||
const blob = new Blob([`# ${title}\n\n${md}`], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'untitled'}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function importFromMarkdown(file: File): Promise<{ title: string; content: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const lines = text.split('\n');
|
||||
let title = "导入文档";
|
||||
let content = "";
|
||||
|
||||
if (lines[0]?.startsWith('# ')) {
|
||||
title = lines[0].substring(2).trim();
|
||||
content = lines.slice(1).join('\n');
|
||||
} else {
|
||||
content = text;
|
||||
}
|
||||
|
||||
// Simple MD to HTML for TipTap (basic)
|
||||
const html = content
|
||||
.split('\n\n')
|
||||
.map(p => {
|
||||
if (p.startsWith('## ')) return `<h2>${p.substring(3)}</h2>`;
|
||||
if (p.startsWith('### ')) return `<h3>${p.substring(4)}</h3>`;
|
||||
return `<p>${p}</p>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
resolve({ title, content: html });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import TurndownService from "turndown";
|
||||
import { gfm } from "turndown-plugin-gfm";
|
||||
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
bulletListMarker: "-",
|
||||
});
|
||||
|
||||
turndownService.use(gfm);
|
||||
|
||||
turndownService.addRule("strikethrough", {
|
||||
filter: ["del", "s"],
|
||||
replacement(content: string) {
|
||||
return `~~${content}~~`;
|
||||
},
|
||||
});
|
||||
|
||||
turndownService.addRule("taskListItems", {
|
||||
filter(node: HTMLElement) {
|
||||
return node.nodeName === "LI" && node.className.includes("task-list-item");
|
||||
},
|
||||
replacement(content: string, node: HTMLElement) {
|
||||
const isChecked = node.getAttribute("data-checked") === "true";
|
||||
return `${isChecked ? "- [x] " : "- [ ] "}${content.replace(/^- /, "")}\n`;
|
||||
},
|
||||
});
|
||||
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html) return "";
|
||||
return turndownService.turndown(html);
|
||||
}
|
||||
+195
-2
@@ -1,4 +1,4 @@
|
||||
import { create } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { capturePageSnapshot } from "@/lib/page-history";
|
||||
|
||||
@@ -17,10 +17,19 @@ export interface Page {
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
export type PageSyncStatus = "saved" | "saving" | "pending" | "error";
|
||||
|
||||
export interface PageSyncMeta {
|
||||
status: PageSyncStatus;
|
||||
message?: string;
|
||||
lastSyncedAt?: string;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
pages: Page[];
|
||||
activePageId: string | null;
|
||||
isLoading: boolean;
|
||||
pageSync: Record<string, PageSyncMeta>;
|
||||
|
||||
fetchPages: () => Promise<void>;
|
||||
setActivePageId: (id: string | null) => void;
|
||||
@@ -31,17 +40,118 @@ interface EditorState {
|
||||
order?: number
|
||||
) => Promise<void>;
|
||||
updatePage: (id: string, data: Partial<Page>) => Promise<void>;
|
||||
retryPageSync: (id: string) => Promise<void>;
|
||||
retryPendingSyncs: () => Promise<void>;
|
||||
flushPendingSyncs: () => void;
|
||||
deletePage: (id: string) => Promise<void>;
|
||||
movePage: (id: string, parentId: string | null) => Promise<void>;
|
||||
reorderPages: (data: { id: string; order: number }[]) => Promise<void>;
|
||||
}
|
||||
|
||||
type StoreSet = (partial: Partial<EditorState> | ((state: EditorState) => Partial<EditorState>)) => void;
|
||||
|
||||
type PendingContentPayload = {
|
||||
content: string;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
const CONTENT_SYNC_DEBOUNCE_MS = 600;
|
||||
const CONTENT_RETRY_BASE_MS = 1200;
|
||||
const CONTENT_RETRY_MAX_MS = 30000;
|
||||
|
||||
const pendingContentTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const pendingContentPayloads = new Map<string, PendingContentPayload>();
|
||||
|
||||
function setPageSyncMeta(set: StoreSet, id: string, meta: PageSyncMeta): void {
|
||||
set((state) => ({
|
||||
pageSync: {
|
||||
...state.pageSync,
|
||||
[id]: meta,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function removePageSyncMeta(set: StoreSet, id: string): void {
|
||||
set((state) => {
|
||||
if (!state.pageSync[id]) return {};
|
||||
const next = { ...state.pageSync };
|
||||
delete next[id];
|
||||
return { pageSync: next };
|
||||
});
|
||||
}
|
||||
|
||||
function clearPendingContentSync(id: string): void {
|
||||
const timer = pendingContentTimers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
pendingContentTimers.delete(id);
|
||||
}
|
||||
pendingContentPayloads.delete(id);
|
||||
}
|
||||
|
||||
function scheduleContentSync(id: string, delayMs: number, set: StoreSet): void {
|
||||
const existingTimer = pendingContentTimers.get(id);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
pendingContentTimers.delete(id);
|
||||
const payload = pendingContentPayloads.get(id);
|
||||
if (!payload) return;
|
||||
|
||||
setPageSyncMeta(set, id, { status: "saving", message: "保存中..." });
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/pages/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: payload.content }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`Update failed: ${res.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||
}
|
||||
|
||||
pendingContentPayloads.delete(id);
|
||||
setPageSyncMeta(set, id, {
|
||||
status: "saved",
|
||||
message: "已保存",
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to sync page content", e);
|
||||
const latest = pendingContentPayloads.get(id);
|
||||
if (!latest) return;
|
||||
|
||||
latest.attempts += 1;
|
||||
pendingContentPayloads.set(id, latest);
|
||||
|
||||
const isOnline = typeof navigator === "undefined" ? true : navigator.onLine;
|
||||
setPageSyncMeta(set, id, {
|
||||
status: "pending",
|
||||
message: isOnline ? "同步失败,自动重试中" : "离线,待同步",
|
||||
});
|
||||
|
||||
const retryDelay = Math.min(
|
||||
CONTENT_RETRY_MAX_MS,
|
||||
CONTENT_RETRY_BASE_MS * 2 ** Math.min(latest.attempts, 5)
|
||||
);
|
||||
scheduleContentSync(id, retryDelay, set);
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
pendingContentTimers.set(id, timer);
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
pages: [],
|
||||
activePageId: null,
|
||||
isLoading: false,
|
||||
pageSync: {},
|
||||
|
||||
fetchPages: async () => {
|
||||
set({ isLoading: true });
|
||||
@@ -79,6 +189,14 @@ export const useEditorStore = create<EditorState>()(
|
||||
set((state) => ({
|
||||
pages: [newPage, ...state.pages],
|
||||
activePageId: newPage.id,
|
||||
pageSync: {
|
||||
...state.pageSync,
|
||||
[newPage.id]: {
|
||||
status: "saved",
|
||||
message: "已保存",
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -89,6 +207,7 @@ export const useEditorStore = create<EditorState>()(
|
||||
updatePage: async (id, data) => {
|
||||
const prevPages = get().pages;
|
||||
const current = prevPages.find((p) => p.id === id);
|
||||
const isContentOnlyUpdate = data.content !== undefined && Object.keys(data).every((key) => key === "content");
|
||||
|
||||
if (current && (data.content !== undefined || data.title !== undefined)) {
|
||||
capturePageSnapshot({
|
||||
@@ -102,19 +221,90 @@ export const useEditorStore = create<EditorState>()(
|
||||
pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)),
|
||||
}));
|
||||
|
||||
if (isContentOnlyUpdate) {
|
||||
pendingContentPayloads.set(id, {
|
||||
content: data.content ?? "",
|
||||
attempts: pendingContentPayloads.get(id)?.attempts ?? 0,
|
||||
});
|
||||
setPageSyncMeta(set, id, { status: "pending", message: "本地已保存,待同步" });
|
||||
scheduleContentSync(id, CONTENT_SYNC_DEBOUNCE_MS, set);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingContent = pendingContentPayloads.get(id);
|
||||
clearPendingContentSync(id);
|
||||
|
||||
const payloadToSend: Partial<Page> =
|
||||
pendingContent && data.content === undefined
|
||||
? { ...data, content: pendingContent.content }
|
||||
: data;
|
||||
|
||||
setPageSyncMeta(set, id, { status: "saving", message: "保存中..." });
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/pages/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
body: JSON.stringify(payloadToSend),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(`Update failed: ${res.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||
}
|
||||
|
||||
setPageSyncMeta(set, id, {
|
||||
status: "saved",
|
||||
message: "已保存",
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update page", e);
|
||||
set({ pages: prevPages });
|
||||
setPageSyncMeta(set, id, {
|
||||
status: "error",
|
||||
message: "保存失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
retryPageSync: async (id) => {
|
||||
const pending = pendingContentPayloads.get(id);
|
||||
if (!pending) {
|
||||
const page = get().pages.find((p) => p.id === id);
|
||||
if (!page) return;
|
||||
pendingContentPayloads.set(id, { content: page.content || "", attempts: 0 });
|
||||
} else {
|
||||
pending.attempts = 0;
|
||||
pendingContentPayloads.set(id, pending);
|
||||
}
|
||||
|
||||
setPageSyncMeta(set, id, { status: "pending", message: "正在尝试同步..." });
|
||||
scheduleContentSync(id, 80, set);
|
||||
},
|
||||
|
||||
retryPendingSyncs: async () => {
|
||||
const ids = Array.from(pendingContentPayloads.keys());
|
||||
await Promise.all(ids.map((id) => get().retryPageSync(id)));
|
||||
},
|
||||
|
||||
flushPendingSyncs: () => {
|
||||
const entries = Array.from(pendingContentPayloads.entries());
|
||||
for (const [id, payload] of entries) {
|
||||
const timer = pendingContentTimers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
pendingContentTimers.delete(id);
|
||||
}
|
||||
|
||||
// Best-effort write on page hide/unload.
|
||||
fetch(`/api/pages/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: payload.content }),
|
||||
keepalive: true,
|
||||
}).catch((e) => {
|
||||
console.error("Failed to flush page sync", e);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,6 +316,9 @@ export const useEditorStore = create<EditorState>()(
|
||||
const prevPages = get().pages;
|
||||
const prevActive = get().activePageId;
|
||||
|
||||
clearPendingContentSync(id);
|
||||
removePageSyncMeta(set, id);
|
||||
|
||||
set((state) => ({
|
||||
pages: state.pages.filter((p) => p.id !== id),
|
||||
activePageId: prevActive === id ? null : prevActive,
|
||||
|
||||
Reference in New Issue
Block a user