diff --git a/package-lock.json b/package-lock.json index 0d4e04b..cd43286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8816316..8df84f8 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/prisma/dev.db b/prisma/dev.db index 4d1f467..f9a4dae 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/release/noteai-fixes-2026-03-04-v2.zip b/release/noteai-fixes-2026-03-04-v2.zip new file mode 100644 index 0000000..ddd28e1 Binary files /dev/null and b/release/noteai-fixes-2026-03-04-v2.zip differ diff --git a/release/noteai-fixes-2026-03-04-v3-flat.zip b/release/noteai-fixes-2026-03-04-v3-flat.zip new file mode 100644 index 0000000..48fd162 Binary files /dev/null and b/release/noteai-fixes-2026-03-04-v3-flat.zip differ diff --git a/release/noteai-fixes-2026-03-04.zip b/release/noteai-fixes-2026-03-04.zip new file mode 100644 index 0000000..c7e6d9e Binary files /dev/null and b/release/noteai-fixes-2026-03-04.zip differ diff --git a/release/noteai-full-source-with-db-2026-03-04-flat.zip b/release/noteai-full-source-with-db-2026-03-04-flat.zip new file mode 100644 index 0000000..2b84c3d Binary files /dev/null and b/release/noteai-full-source-with-db-2026-03-04-flat.zip differ diff --git a/release/noteai-full-source-with-db-2026-03-04.zip b/release/noteai-full-source-with-db-2026-03-04.zip new file mode 100644 index 0000000..c09a4ba Binary files /dev/null and b/release/noteai-full-source-with-db-2026-03-04.zip differ diff --git a/src/app/globals.css b/src/app/globals.css index 2565b60..a88b24f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } } @@ -379,4 +384,22 @@ ul[data-type="taskList"], border-radius: 0.5rem; 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); -} \ No newline at end of file +} + +.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); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8741101..24b91ac 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(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 (
@@ -263,6 +292,32 @@ export default function Home() { {activePage.isLocked ? "已锁定" : "锁定"} + {activePageSync && ( +
+
+ {activePageSync.message || "已保存"} +
+ {(activePageSync.status === "pending" || activePageSync.status === "error") && ( + + )} +
+ )} + {activePage.updatedAt && (
{new Date(activePage.updatedAt).toLocaleString("zh-CN", { diff --git a/src/components/editor.tsx b/src/components/editor.tsx index a578189..5cec2f0 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -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 }) => { diff --git a/src/components/editor/code-block.tsx b/src/components/editor/code-block.tsx index e5b80e5..bf1840b 100644 --- a/src/components/editor/code-block.tsx +++ b/src/components/editor/code-block.tsx @@ -95,7 +95,10 @@ export function CodeBlockComponent({
-                    
+                    
                 
diff --git a/src/components/editor/slash-command.tsx b/src/components/editor/slash-command.tsx index 620e01b..16f9b2a 100644 --- a/src/components/editor/slash-command.tsx +++ b/src/components/editor/slash-command.tsx @@ -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 }); }, diff --git a/src/components/import-context.tsx b/src/components/import-context.tsx index a5978e1..181e58a 100644 --- a/src/components/import-context.tsx +++ b/src/components/import-context.tsx @@ -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 => { 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(); 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 ( diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 36b1e1d..94b0833 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -113,7 +113,7 @@ export function ResizableSidebar() { - {onCloseMobile && ( @@ -268,7 +275,7 @@ export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, f return ( n.id)} strategy={verticalListSortingStrategy}> -
+
{nodes.map((node) => ( (.*?)<\/h1>/gi, '# $1\n\n') - .replace(/

(.*?)<\/h2>/gi, '## $1\n\n') - .replace(/

(.*?)<\/h3>/gi, '### $1\n\n') - .replace(/

(.*?)<\/p>/gi, '$1\n\n') - .replace(/(.*?)<\/strong>/gi, '**$1**') - .replace(/(.*?)<\/em>/gi, '*$1*') - .replace(//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 `

${p.substring(3)}

`; - if (p.startsWith('### ')) return `

${p.substring(4)}

`; - return `

${p}

`; - }) - .join(''); - - resolve({ title, content: html }); - }; - reader.onerror = reject; - reader.readAsText(file); - }); -} diff --git a/src/lib/markdown-codec.ts b/src/lib/markdown-codec.ts new file mode 100644 index 0000000..4fae0df --- /dev/null +++ b/src/lib/markdown-codec.ts @@ -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); +} diff --git a/src/lib/store.ts b/src/lib/store.ts index b35ce33..dbaf634 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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; fetchPages: () => Promise; setActivePageId: (id: string | null) => void; @@ -31,17 +40,118 @@ interface EditorState { order?: number ) => Promise; updatePage: (id: string, data: Partial) => Promise; + retryPageSync: (id: string) => Promise; + retryPendingSyncs: () => Promise; + flushPendingSyncs: () => void; deletePage: (id: string) => Promise; movePage: (id: string, parentId: string | null) => Promise; reorderPages: (data: { id: string; order: number }[]) => Promise; } +type StoreSet = (partial: Partial | ((state: EditorState) => Partial)) => 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>(); +const pendingContentPayloads = new Map(); + +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()( persist( (set, get) => ({ pages: [], activePageId: null, isLoading: false, + pageSync: {}, fetchPages: async () => { set({ isLoading: true }); @@ -79,6 +189,14 @@ export const useEditorStore = create()( 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()( 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()( 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 = + 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()( 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,