修复了导入bug
This commit is contained in:
Generated
-47
@@ -56,7 +56,6 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.9.0",
|
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.2",
|
||||||
"turndown-plugin-gfm": "^1.0.2",
|
"turndown-plugin-gfm": "^1.0.2",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -6509,12 +6508,6 @@
|
|||||||
"markdown-it": "bin/markdown-it.mjs"
|
"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": {
|
"node_modules/marked": {
|
||||||
"version": "17.0.1",
|
"version": "17.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
||||||
@@ -8480,46 +8473,6 @@
|
|||||||
"@popperjs/core": "^2.9.0"
|
"@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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "note-ai",
|
"name": "note-ai",
|
||||||
"version": "1.0.0105",
|
"version": "1.0.0304",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3001",
|
"dev": "next dev -p 3001",
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.9.0",
|
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.2",
|
||||||
"turndown-plugin-gfm": "^1.0.2",
|
"turndown-plugin-gfm": "^1.0.2",
|
||||||
"zustand": "^5.0.9"
|
"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.
+26
-3
@@ -181,8 +181,13 @@
|
|||||||
|
|
||||||
/* 移动端侧边栏文档树:增大字号和触摸目标 */
|
/* 移动端侧边栏文档树:增大字号和触摸目标 */
|
||||||
.sidebar-tree-item {
|
.sidebar-tree-item {
|
||||||
font-size: 0.875rem;
|
font-size: 0.9375rem !important;
|
||||||
min-height: 2.75rem;
|
min-height: 2.5rem !important;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-item .sidebar-title {
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,4 +384,22 @@ ul[data-type="taskList"],
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: linear-gradient(180deg, hsl(var(--accent) / 0.42), hsl(var(--accent) / 0.25));
|
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);
|
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 { useSettingsStore } from "@/lib/settings-store";
|
||||||
import { exportPageAsMarkdown } from "@/lib/export";
|
import { exportPageAsMarkdown } from "@/lib/export";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { cn, getTagColor } from "@/lib/utils";
|
import { cn, getTagColor } from "@/lib/utils";
|
||||||
import { useSearchStore } from "@/lib/search-store";
|
import { useSearchStore } from "@/lib/search-store";
|
||||||
import { type Editor as TiptapEditor } from "@tiptap/react";
|
import { type Editor as TiptapEditor } from "@tiptap/react";
|
||||||
@@ -25,11 +26,12 @@ const ICONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function Home() {
|
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 { openSearchWithTag } = useSearchStore();
|
||||||
const { timezone } = useSettingsStore();
|
const { timezone } = useSettingsStore();
|
||||||
|
|
||||||
const activePage = pages.find((p) => p.id === activePageId);
|
const activePage = pages.find((p) => p.id === activePageId);
|
||||||
|
const activePageSync = activePageId ? pageSync[activePageId] : undefined;
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||||
@@ -94,6 +96,33 @@ export default function Home() {
|
|||||||
await addPage(null, "file", { title, content: "" });
|
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 (
|
return (
|
||||||
<ImportProvider>
|
<ImportProvider>
|
||||||
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
|
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
|
||||||
@@ -263,6 +292,32 @@ export default function Home() {
|
|||||||
{activePage.isLocked ? "已锁定" : "锁定"}
|
{activePage.isLocked ? "已锁定" : "锁定"}
|
||||||
</button>
|
</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 && (
|
{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">
|
<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", {
|
{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 Image from "@tiptap/extension-image";
|
||||||
import Youtube from "@tiptap/extension-youtube";
|
import Youtube from "@tiptap/extension-youtube";
|
||||||
import TextAlign from "@tiptap/extension-text-align";
|
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 { 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;
|
||||||
@@ -686,11 +684,6 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
|
|||||||
TextAlign.configure({
|
TextAlign.configure({
|
||||||
types: ["heading", "paragraph"],
|
types: ["heading", "paragraph"],
|
||||||
}),
|
}),
|
||||||
Markdown.configure({
|
|
||||||
html: true,
|
|
||||||
transformPastedText: true,
|
|
||||||
transformCopiedText: true,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ export function CodeBlockComponent({
|
|||||||
</div>
|
</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 }}>
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const SlashCommand = Extension.create({
|
|||||||
return {
|
return {
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: "/",
|
char: "/",
|
||||||
|
startOfLine: false,
|
||||||
|
allowedPrefixes: [" ", "\t", "\n"],
|
||||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: CommandItem }) => {
|
command: ({ editor, range, props }: { editor: Editor; range: Range; props: CommandItem }) => {
|
||||||
props.command({ editor, range });
|
props.command({ editor, range });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
|||||||
let importedPageId: string | null = null;
|
let importedPageId: string | null = null;
|
||||||
try {
|
try {
|
||||||
if (file.name.toLowerCase().endsWith(".zip")) {
|
if (file.name.toLowerCase().endsWith(".zip")) {
|
||||||
await handleZipImport(file);
|
importedPageId = await handleZipImport(file);
|
||||||
} else {
|
} else {
|
||||||
importedPageId = await handleSingleFileImport(file);
|
importedPageId = await handleSingleFileImport(file);
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return result.id;
|
return result.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZipImport = async (file: File) => {
|
const handleZipImport = async (file: File): Promise<string | null> => {
|
||||||
const zip = await JSZip.loadAsync(file);
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
|
||||||
const rootName = file.name.replace(/(\.md)?\.zip$/i, "");
|
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>();
|
const pathMap = new Map<string, string>();
|
||||||
pathMap.set("", rootId);
|
pathMap.set("", rootId);
|
||||||
|
let firstImportedPageId: string | null = null;
|
||||||
|
|
||||||
const sortedFolders = Array.from(folderPaths).sort((a, b) => a.split("/").length - b.split("/").length);
|
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}`
|
`更新文件 ${path}`
|
||||||
);
|
);
|
||||||
|
if (!firstImportedPageId) firstImportedPageId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
await postPage(
|
const created = await postPage(
|
||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
importMarkdown: markdown,
|
importMarkdown: markdown,
|
||||||
@@ -278,8 +280,11 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
|
|||||||
},
|
},
|
||||||
`创建文件 ${path}`
|
`创建文件 ${path}`
|
||||||
);
|
);
|
||||||
|
if (!firstImportedPageId && created?.id) firstImportedPageId = created.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return firstImportedPageId;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function ResizableSidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className={cn(
|
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"
|
isCollapsed && "-right-8 opacity-100"
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||||
@@ -285,24 +285,35 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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>
|
<span className="sr-only">新建文件</span>
|
||||||
<FilePlus size={15} />
|
<FilePlus size={15} />
|
||||||
|
<span className="md:hidden">文件</span>
|
||||||
</button>
|
</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>
|
<span className="sr-only">新建文件夹</span>
|
||||||
<FolderPlus size={15} />
|
<FolderPlus size={15} />
|
||||||
|
<span className="md:hidden">文件夹</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
handleItemClick();
|
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)"
|
title="搜索文档 (Ctrl+K)"
|
||||||
aria-label="搜索文档"
|
aria-label="搜索文档"
|
||||||
>
|
>
|
||||||
<Search size={15} />
|
<Search size={15} />
|
||||||
|
<span className="md:hidden">搜索</span>
|
||||||
</button>
|
</button>
|
||||||
{onCloseMobile && (
|
{onCloseMobile && (
|
||||||
<button
|
<button
|
||||||
@@ -319,7 +330,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsFilterOpen((v) => !v)}
|
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">
|
<span className="flex items-center gap-1.5">
|
||||||
<SlidersHorizontal size={13} />
|
<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">
|
<div className="flex min-h-0 flex-1 flex-col p-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
|
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">
|
<span className="flex items-center gap-1.5">
|
||||||
<Layers size={13} className="opacity-80" />
|
<Layers size={13} className="opacity-80" />
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ function TreeNode({
|
|||||||
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
|
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "border-border/60 bg-accent/75 text-accent-foreground"
|
? "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={() => {
|
onClick={() => {
|
||||||
setActivePageId(node.id);
|
setActivePageId(node.id);
|
||||||
@@ -127,7 +127,7 @@ function TreeNode({
|
|||||||
toggleExpand(node.id);
|
toggleExpand(node.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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"
|
!hasChildren && !isFolder && "invisible"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -147,7 +147,14 @@ function TreeNode({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<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 && (
|
{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">
|
<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}
|
{folderFileCount[node.id] ?? 0}
|
||||||
@@ -160,7 +167,7 @@ function TreeNode({
|
|||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
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} />
|
<MoreHorizontal size={13} />
|
||||||
</button>
|
</button>
|
||||||
@@ -268,7 +275,7 @@ export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, f
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableContext items={nodes.map((n) => n.id)} strategy={verticalListSortingStrategy}>
|
<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) => (
|
{nodes.map((node) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
key={node.id}
|
key={node.id}
|
||||||
|
|||||||
+1
-41
@@ -1,48 +1,9 @@
|
|||||||
import TurndownService from "turndown";
|
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import { Page } from "./store";
|
import { Page } from "./store";
|
||||||
import { useSettingsStore } from "./settings-store";
|
import { useSettingsStore } from "./settings-store";
|
||||||
import { sanitizeFilename } from "./page-utils";
|
import { sanitizeFilename } from "./page-utils";
|
||||||
|
import { htmlToMarkdown } from "./markdown-codec";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to escape YAML strings
|
// Helper to escape YAML strings
|
||||||
function escapeYamlString(str: string): string {
|
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 { persist } from "zustand/middleware";
|
||||||
import { capturePageSnapshot } from "@/lib/page-history";
|
import { capturePageSnapshot } from "@/lib/page-history";
|
||||||
|
|
||||||
@@ -17,10 +17,19 @@ export interface Page {
|
|||||||
isLocked?: boolean;
|
isLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PageSyncStatus = "saved" | "saving" | "pending" | "error";
|
||||||
|
|
||||||
|
export interface PageSyncMeta {
|
||||||
|
status: PageSyncStatus;
|
||||||
|
message?: string;
|
||||||
|
lastSyncedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
pages: Page[];
|
pages: Page[];
|
||||||
activePageId: string | null;
|
activePageId: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
pageSync: Record<string, PageSyncMeta>;
|
||||||
|
|
||||||
fetchPages: () => Promise<void>;
|
fetchPages: () => Promise<void>;
|
||||||
setActivePageId: (id: string | null) => void;
|
setActivePageId: (id: string | null) => void;
|
||||||
@@ -31,17 +40,118 @@ interface EditorState {
|
|||||||
order?: number
|
order?: number
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
updatePage: (id: string, data: Partial<Page>) => 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>;
|
deletePage: (id: string) => Promise<void>;
|
||||||
movePage: (id: string, parentId: string | null) => Promise<void>;
|
movePage: (id: string, parentId: string | null) => Promise<void>;
|
||||||
reorderPages: (data: { id: string; order: number }[]) => 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>()(
|
export const useEditorStore = create<EditorState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
pages: [],
|
pages: [],
|
||||||
activePageId: null,
|
activePageId: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
pageSync: {},
|
||||||
|
|
||||||
fetchPages: async () => {
|
fetchPages: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
@@ -79,6 +189,14 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
pages: [newPage, ...state.pages],
|
pages: [newPage, ...state.pages],
|
||||||
activePageId: newPage.id,
|
activePageId: newPage.id,
|
||||||
|
pageSync: {
|
||||||
|
...state.pageSync,
|
||||||
|
[newPage.id]: {
|
||||||
|
status: "saved",
|
||||||
|
message: "已保存",
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -89,6 +207,7 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
updatePage: async (id, data) => {
|
updatePage: async (id, data) => {
|
||||||
const prevPages = get().pages;
|
const prevPages = get().pages;
|
||||||
const current = prevPages.find((p) => p.id === id);
|
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)) {
|
if (current && (data.content !== undefined || data.title !== undefined)) {
|
||||||
capturePageSnapshot({
|
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)),
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/pages/${id}`, {
|
const res = await fetch(`/api/pages/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(payloadToSend),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text().catch(() => "");
|
const errorText = await res.text().catch(() => "");
|
||||||
throw new Error(`Update failed: ${res.status}${errorText ? ` - ${errorText}` : ""}`);
|
throw new Error(`Update failed: ${res.status}${errorText ? ` - ${errorText}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPageSyncMeta(set, id, {
|
||||||
|
status: "saved",
|
||||||
|
message: "已保存",
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to update page", e);
|
console.error("Failed to update page", e);
|
||||||
set({ pages: prevPages });
|
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 prevPages = get().pages;
|
||||||
const prevActive = get().activePageId;
|
const prevActive = get().activePageId;
|
||||||
|
|
||||||
|
clearPendingContentSync(id);
|
||||||
|
removePageSyncMeta(set, id);
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
pages: state.pages.filter((p) => p.id !== id),
|
pages: state.pages.filter((p) => p.id !== id),
|
||||||
activePageId: prevActive === id ? null : prevActive,
|
activePageId: prevActive === id ? null : prevActive,
|
||||||
|
|||||||
Reference in New Issue
Block a user