import { Editor } from "@tiptap/core"; import TiptapImage from "@tiptap/extension-image"; import TextAlign from "@tiptap/extension-text-align"; import { Markdown } from "@tiptap/markdown"; import StarterKit from "@tiptap/starter-kit"; import { stringify as stringifyUuid } from "uuid"; const uiStorageKey = "memo-ui-state-v1"; if (new URLSearchParams(window.location.search).get("device") === "mobile") { document.documentElement.classList.add("force-mobile"); } const starterNotes = [ { id: createId(), folder: "notes", body: "欢迎使用备忘录\n\n左侧选择文件夹,中间选择笔记,右侧直接编辑。第一行会自动作为标题。", createdAt: Date.now() - 1000 * 60 * 60 * 24, updatedAt: Date.now() - 1000 * 60 * 12, version: 1 }, { id: createId(), folder: "notes", body: "待办清单\n\n- 记录想法\n- 整理项目\n- 做一个真正好用的 Web 版笔记", createdAt: Date.now() - 1000 * 60 * 60 * 3, updatedAt: Date.now() - 1000 * 60 * 35, version: 1 } ]; const authStatus = await getAuthStatus(); const state = authStatus.authenticated ? await loadState() : hydrateState({ notes: [], folders: defaultFolders() }); let saveTimer = null; let saveTimerNoteId = null; let saveInFlightNoteId = null; let queuedSaveNoteId = null; let saveStatusTimer = null; let tiptapEditor = null; let syncingEditor = false; const saveDelay = 700; const maxNoteBodyLength = 10 * 1024 * 1024; const maxPastedImageDimension = 1600; const pastedImageQuality = 0.82; const AlignedImage = TiptapImage.extend({ addAttributes() { return { ...this.parent?.(), align: { default: "center", parseHTML: (element) => element.getAttribute("data-align") || "center", renderHTML: (attributes) => ({ "data-align": attributes.align || "center" }) } }; } }); const els = { authGate: document.querySelector("#authGate"), authForm: document.querySelector("#authForm"), authTitle: document.querySelector("#authTitle"), authPassword: document.querySelector("#authPassword"), authSubmit: document.querySelector("#authSubmit"), authMessage: document.querySelector("#authMessage"), passwordGate: document.querySelector("#passwordGate"), passwordForm: document.querySelector("#passwordForm"), currentPassword: document.querySelector("#currentPassword"), nextPassword: document.querySelector("#nextPassword"), confirmPassword: document.querySelector("#confirmPassword"), cancelPassword: document.querySelector("#cancelPassword"), submitPassword: document.querySelector("#submitPassword"), passwordMessage: document.querySelector("#passwordMessage"), folderGate: document.querySelector("#folderGate"), folderForm: document.querySelector("#folderForm"), folderTitle: document.querySelector("#folderTitle"), folderName: document.querySelector("#folderName"), cancelFolder: document.querySelector("#cancelFolder"), submitFolder: document.querySelector("#submitFolder"), folderMessage: document.querySelector("#folderMessage"), folderList: document.querySelector("#folderList"), newFolder: document.querySelector("#newFolder"), listTitle: document.querySelector("#listTitle"), listCount: document.querySelector("#listCount"), noteList: document.querySelector("#noteList"), newNote: document.querySelector("#newNote"), deleteNote: document.querySelector("#deleteNote"), previewToggle: document.querySelector("#previewToggle"), changePassword: document.querySelector("#changePassword"), logout: document.querySelector("#logout"), searchInput: document.querySelector("#searchInput"), mobileBack: document.querySelector("#mobileBack"), mobileBackLabel: document.querySelector("#mobileBackLabel"), mobileTitle: document.querySelector("#mobileTitle"), mobileSearchInput: document.querySelector("#mobileSearchInput"), mobileSearchWrap: document.querySelector("#mobileSearchWrap"), mobileEdit: document.querySelector("#mobileEdit"), mobileEditorMenuToggle: document.querySelector("#mobileEditorMenuToggle"), mobileEditorFormatMenu: document.querySelector("#mobileEditorFormatMenu"), mobilePreviewToggle: document.querySelector("#mobilePreviewToggle"), mobileSave: document.querySelector("#mobileSave"), mobileMore: document.querySelector("#mobileMore"), mobileActionMenu: document.querySelector("#mobileActionMenu"), mobileMenuPreview: document.querySelector("#mobileMenuPreview"), mobileMenuSave: document.querySelector("#mobileMenuSave"), mobileMenuDelete: document.querySelector("#mobileMenuDelete"), mobileNoteFolderSelect: document.querySelector("#mobileNoteFolderSelect"), appShell: document.querySelector(".app-shell"), editor: document.querySelector("#editor"), noteFolderSelect: document.querySelector("#noteFolderSelect"), editorMenuToggle: document.querySelector("#editorMenuToggle"), editorFormatMenu: document.querySelector("#editorFormatMenu"), editorPreviewToggle: document.querySelector("#editorPreviewToggle"), saveNote: document.querySelector("#saveNote"), saveStatus: document.querySelector("#saveStatus"), editedAt: document.querySelector("#editedAt"), markdownPreview: document.querySelector("#markdownPreview"), editorPane: document.querySelector(".editor-pane") }; initTiptapEditor(); render(); if (authStatus.authenticated) { hideAuthGate(); } else { showAuthGate(authStatus.configured); } window.addEventListener("beforeunload", flushPendingSave); els.authForm.addEventListener("submit", async (event) => { event.preventDefault(); const password = els.authPassword.value; els.authSubmit.disabled = true; els.authMessage.textContent = ""; try { const path = authStatus.configured ? "/api/auth/login" : "/api/auth/setup"; const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password }) }); if (!response.ok) { const message = response.status === 401 ? "密码不正确" : "密码至少需要 8 位"; els.authMessage.textContent = message; return; } location.reload(); } finally { els.authSubmit.disabled = false; } }); els.newNote.addEventListener("click", async () => { const now = Date.now(); const note = { id: createId(), folder: getWriteFolderId(), body: "", createdAt: now, updatedAt: now, version: 1 }; state.notes.unshift(note); if (state.activeFolder === "recent") state.activeFolder = note.folder; state.selectedId = note.id; state.mobileView = "editor"; render(); els.editor.focus(); try { setSaveStatus("保存中"); const saved = await createNote(note); Object.assign(note, saved); setSaveStatus("已保存", "ok"); renderListOnly(); renderEditor(); } catch (error) { setSaveStatus("保存失败", "error"); console.error("Failed to create note.", error); } }); els.deleteNote.addEventListener("click", async () => { const current = getSelectedNote(); if (!current) return; const visible = getVisibleNotes(); const index = visible.findIndex((note) => note.id === current.id); state.notes = state.notes.filter((note) => note.id !== current.id); const next = visible[index + 1] || visible[index - 1]; state.selectedId = next && state.notes.some((note) => note.id === next.id) ? next.id : null; render(); try { await deleteNote(current.id); setSaveStatus("已删除", "ok"); } catch (error) { state.notes.unshift(current); state.selectedId = current.id; setSaveStatus("删除失败", "error"); render(); console.error("Failed to delete note.", error); } }); els.searchInput.addEventListener("input", (event) => { state.query = event.target.value; const visible = getVisibleNotes(); if (!visible.some((note) => note.id === state.selectedId)) { state.selectedId = visible[0]?.id || null; } saveUiState(); render(); }); els.mobileSearchInput.addEventListener("input", (event) => { if (state.mobileView === "folders") { state.folderQuery = event.target.value; } else { state.query = event.target.value; const visible = getVisibleNotes(); if (!visible.some((note) => note.id === state.selectedId)) { state.selectedId = visible[0]?.id || null; } } saveUiState(); render(); }); els.mobileBack.addEventListener("click", () => { if (state.mobileView === "editor") { state.mobileView = "list"; } else if (state.mobileView === "list") { state.mobileView = "folders"; } saveUiState(); render(); }); els.mobileEdit.addEventListener("click", () => { els.newNote.click(); }); els.mobileSave.addEventListener("click", () => { if (!getSelectedNote()) return; saveSelectedNote({ immediate: true }); }); els.mobilePreviewToggle.addEventListener("click", togglePreviewMode); els.editorMenuToggle.addEventListener("click", (event) => { event.stopPropagation(); toggleEditorMenu("desktop"); }); els.mobileEditorMenuToggle.addEventListener("click", (event) => { event.stopPropagation(); toggleEditorMenu("mobile"); }); [...document.querySelectorAll("[data-editor-command]")].forEach((button) => { button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); runEditorCommand(button.dataset.editorCommand); }); }); els.mobileMore.addEventListener("click", (event) => { event.stopPropagation(); els.mobileActionMenu.hidden = !els.mobileActionMenu.hidden; }); els.mobileMenuPreview.addEventListener("click", () => { els.mobileActionMenu.hidden = true; togglePreviewMode(); }); els.mobileMenuSave.addEventListener("click", () => { els.mobileActionMenu.hidden = true; if (getSelectedNote()) saveSelectedNote({ immediate: true }); }); els.mobileMenuDelete.addEventListener("click", () => { els.mobileActionMenu.hidden = true; els.deleteNote.click(); }); document.addEventListener("click", (event) => { if (!els.mobileActionMenu.hidden && !event.target.closest("#mobileActions")) { els.mobileActionMenu.hidden = true; } if (!event.target.closest(".editor-menu-wrap")) { els.editorFormatMenu.hidden = true; } if (!event.target.closest(".mobile-editor-menu-wrap")) { els.mobileEditorFormatMenu.hidden = true; } }); els.newFolder.addEventListener("click", () => { openFolderGate({ mode: "create", name: getUniqueFolderName("新建文件夹") }); }); els.noteFolderSelect.addEventListener("change", (event) => { moveSelectedNoteToFolder(event.target.value); }); els.saveNote.addEventListener("click", () => { if (!getSelectedNote()) return; saveSelectedNote({ immediate: true }); }); els.mobileNoteFolderSelect.addEventListener("change", (event) => { els.mobileActionMenu.hidden = true; moveSelectedNoteToFolder(event.target.value); }); els.previewToggle.addEventListener("click", togglePreviewMode); els.editorPreviewToggle.addEventListener("click", togglePreviewMode); els.logout.addEventListener("click", async () => { await fetch("/api/auth/logout", { method: "POST" }); location.reload(); }); els.changePassword.addEventListener("click", async () => { showPasswordGate(); }); els.cancelPassword.addEventListener("click", hidePasswordGate); els.cancelFolder.addEventListener("click", hideFolderGate); els.folderForm.addEventListener("submit", async (event) => { event.preventDefault(); const mode = els.folderForm.dataset.mode; const folderId = els.folderForm.dataset.folderId; const name = els.folderName.value.trim(); if (!name) { els.folderMessage.textContent = "请输入文件夹名称"; return; } els.submitFolder.disabled = true; els.folderMessage.textContent = ""; try { if (mode === "rename" && folderId) { await submitFolderRename(folderId, name); } else { await submitFolderCreate(name); } } finally { els.submitFolder.disabled = false; } }); els.passwordForm.addEventListener("submit", async (event) => { event.preventDefault(); const currentPassword = els.currentPassword.value; const nextPassword = els.nextPassword.value; const confirmPassword = els.confirmPassword.value; els.passwordMessage.textContent = ""; if (nextPassword !== confirmPassword) { els.passwordMessage.textContent = "两次输入的新密码不一致"; return; } els.submitPassword.disabled = true; try { const response = await fetch("/api/auth/password", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ currentPassword, nextPassword }) }); if (!response.ok) { const message = response.status === 401 ? "当前密码不正确" : "新密码至少需要 8 位"; els.passwordMessage.textContent = message; return; } hidePasswordGate(); window.alert("密码已修改"); } catch (error) { els.passwordMessage.textContent = "修改失败,请稍后再试"; console.error("Failed to change password.", error); } finally { els.submitPassword.disabled = false; } }); function moveSelectedNoteToFolder(nextFolder) { const note = getSelectedNote(); if (!note) return; if (!state.folders.some((folder) => folder.id === nextFolder)) return; note.folder = nextFolder; note.updatedAt = Date.now(); state.activeFolder = nextFolder; saveSelectedNote({ immediate: true }); render(); } function togglePreviewMode() { state.previewMode = !state.previewMode; saveUiState(); hideEditorMenus(); renderEditor(); renderMobileChrome(); } function toggleEditorMenu(target) { const note = getSelectedNote(); if (!note || state.previewMode) return; const menu = target === "mobile" ? els.mobileEditorFormatMenu : els.editorFormatMenu; const otherMenu = target === "mobile" ? els.editorFormatMenu : els.mobileEditorFormatMenu; otherMenu.hidden = true; menu.hidden = !menu.hidden; els.editorMenuToggle.classList.toggle("active", !els.editorFormatMenu.hidden); els.mobileEditorMenuToggle.classList.toggle("active", !els.mobileEditorFormatMenu.hidden); renderEditorMenuState(); } function hideEditorMenus() { els.editorFormatMenu.hidden = true; els.mobileEditorFormatMenu.hidden = true; els.editorMenuToggle.classList.remove("active"); els.mobileEditorMenuToggle.classList.remove("active"); } function runEditorCommand(command) { if (!tiptapEditor || state.previewMode || !getSelectedNote()) return; const align = { alignLeft: "left", alignCenter: "center", alignRight: "right" }[command]; if (align) { setCurrentAlignment(align); renderEditorMenuState(); return; } const chain = tiptapEditor.chain().focus(); const commands = { paragraph: () => chain.setParagraph().run(), heading1: () => chain.toggleHeading({ level: 1 }).run(), heading2: () => chain.toggleHeading({ level: 2 }).run(), bold: () => chain.toggleBold().run(), italic: () => chain.toggleItalic().run(), bulletList: () => chain.toggleBulletList().run(), orderedList: () => chain.toggleOrderedList().run() }; commands[command]?.(); renderEditorMenuState(); } function setCurrentAlignment(align) { if (isImageSelected()) { setImageAlignment(align); return; } tiptapEditor.chain().focus().setTextAlign(align).run(); } function setImageAlignment(align) { if (!isImageSelected()) return; tiptapEditor.chain().focus().updateAttributes("image", { align }).run(); } function renderEditorMenuState(editor = tiptapEditor) { if (!editor) return; const disabled = !getSelectedNote() || state.previewMode; els.editorMenuToggle.disabled = disabled; els.mobileEditorMenuToggle.disabled = disabled; if (disabled) hideEditorMenus(); const activeCommands = new Set(); if (editor.isActive("paragraph")) activeCommands.add("paragraph"); if (editor.isActive("heading", { level: 1 })) activeCommands.add("heading1"); if (editor.isActive("heading", { level: 2 })) activeCommands.add("heading2"); if (editor.isActive("bold")) activeCommands.add("bold"); if (editor.isActive("italic")) activeCommands.add("italic"); if (editor.isActive("bulletList")) activeCommands.add("bulletList"); if (editor.isActive("orderedList")) activeCommands.add("orderedList"); const imageAlign = getSelectedImageAlign(); const textAlign = editor.getAttributes("paragraph").textAlign || editor.getAttributes("heading").textAlign; const align = imageAlign || textAlign || "left"; activeCommands.add(`align${align[0].toUpperCase()}${align.slice(1)}`); document.querySelectorAll("[data-editor-command]").forEach((button) => { const isActive = activeCommands.has(button.dataset.editorCommand); button.classList.toggle("active", isActive); button.disabled = disabled; }); } function isImageSelected() { return tiptapEditor?.state.selection.node?.type.name === "image"; } function getSelectedImageAlign() { if (!isImageSelected()) return ""; return tiptapEditor.state.selection.node.attrs.align || "center"; } function initTiptapEditor() { tiptapEditor = new Editor({ element: els.editor, extensions: [ StarterKit, AlignedImage.configure({ allowBase64: true, HTMLAttributes: { class: "memo-image" } }), TextAlign.configure({ types: ["heading", "paragraph"] }), Markdown ], content: "", editorProps: { handlePaste(_view, event) { const files = [...(event.clipboardData?.files || [])].filter((file) => file.type.startsWith("image/")); if (!files.length) return false; event.preventDefault(); insertPastedImages(files); return true; } }, onUpdate({ editor }) { if (syncingEditor) return; const note = getSelectedNote(); if (!note) return; const nextBody = normalizeEditorHtml(editor.getHTML()); if (nextBody.length > maxNoteBodyLength) { setSaveStatus("内容已达上限", "error"); return; } note.body = nextBody; note.updatedAt = Date.now(); saveSelectedNote(); renderListOnly(); updateEditorMeta(note); renderMarkdownPreview(note); renderEditorMenuState(editor); }, onSelectionUpdate({ editor }) { renderEditorMenuState(editor); } }); } async function insertPastedImages(files) { if (!tiptapEditor || !getSelectedNote()) return; setSaveStatus("处理图片"); for (const file of files) { try { const dataUrl = await imageFileToDataUrl(file); const currentLength = normalizeEditorHtml(tiptapEditor.getHTML()).length; if (currentLength + dataUrl.length > maxNoteBodyLength) { setSaveStatus("图片太大", "error"); continue; } tiptapEditor .chain() .focus() .insertContent([ { type: "image", attrs: { src: dataUrl, alt: file.name || "粘贴的图片", title: file.name || null, align: "center" } }, { type: "paragraph" } ]) .run(); } catch (error) { setSaveStatus("图片粘贴失败", "error"); console.error("Failed to paste image.", error); } } } async function imageFileToDataUrl(file) { if (file.type === "image/gif") { return await readFileAsDataUrl(file); } const image = await loadImage(file); const scale = Math.min( 1, maxPastedImageDimension / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height) ); const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale)); const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas is unavailable."); context.drawImage(image, 0, 0, width, height); URL.revokeObjectURL(image.src); const webp = canvas.toDataURL("image/webp", pastedImageQuality); if (webp.startsWith("data:image/webp")) return webp; return canvas.toDataURL("image/jpeg", pastedImageQuality); } function loadImage(file) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = reject; image.src = URL.createObjectURL(file); }); } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = reject; reader.readAsDataURL(file); }); } document.addEventListener("keydown", (event) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "n") { event.preventDefault(); els.newNote.click(); } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { event.preventDefault(); els.searchInput.focus(); els.searchInput.select(); } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { event.preventDefault(); if (getSelectedNote()) saveSelectedNote({ immediate: true }); } if (event.key === "Delete" && !els.editor.contains(document.activeElement)) { els.deleteNote.click(); } if (event.key === "Escape") { hideEditorMenus(); } }); async function loadState() { try { const response = await fetch("/api/state"); if (!response.ok) throw new Error(`State API returned ${response.status}`); return hydrateState(await response.json()); } catch (error) { console.warn("Falling back to starter notes because the API is unavailable.", error); return hydrateState({ notes: starterNotes, folders: defaultFolders() }); } } async function getAuthStatus() { try { const response = await fetch("/api/auth/status"); if (!response.ok) throw new Error(`Auth API returned ${response.status}`); return await response.json(); } catch (error) { console.warn("Auth API unavailable.", error); return { configured: false, authenticated: false }; } } function showAuthGate(configured) { els.authGate.setAttribute("aria-busy", "false"); els.authTitle.textContent = configured ? "登录" : "设置密码"; els.authPassword.autocomplete = configured ? "current-password" : "new-password"; els.authPassword.placeholder = configured ? "密码" : "设置至少 8 位密码"; els.authSubmit.textContent = configured ? "登录" : "创建"; els.authGate.hidden = false; els.authPassword.focus(); } function hideAuthGate() { els.authGate.hidden = true; els.authGate.setAttribute("aria-busy", "false"); } function showPasswordGate() { els.passwordForm.reset(); els.passwordMessage.textContent = ""; els.passwordGate.hidden = false; els.currentPassword.focus(); } function hidePasswordGate() { els.passwordGate.hidden = true; els.passwordForm.reset(); els.passwordMessage.textContent = ""; } function openFolderGate({ mode, name, folderId = "" }) { els.folderForm.dataset.mode = mode; els.folderForm.dataset.folderId = folderId; els.folderTitle.textContent = mode === "rename" ? "重命名文件夹" : "新建文件夹"; els.submitFolder.textContent = mode === "rename" ? "保存" : "创建"; els.folderName.value = name || ""; els.folderMessage.textContent = ""; els.folderGate.hidden = false; requestAnimationFrame(() => { els.folderName.focus(); els.folderName.select(); }); } function hideFolderGate() { els.folderGate.hidden = true; els.folderForm.reset(); els.folderForm.dataset.mode = ""; els.folderForm.dataset.folderId = ""; els.folderMessage.textContent = ""; } function saveSelectedNote({ immediate = false } = {}) { const note = getSelectedNote(); if (!note) return; queueNoteSave(note.id, { immediate }); } function flushPendingSave() { saveUiState(); if (!saveTimer) return; const noteId = saveTimerNoteId; clearTimeout(saveTimer); saveTimer = null; saveTimerNoteId = null; if (noteId) sendNote(noteId, { keepalive: true }); } function queueNoteSave(noteId, { immediate = false } = {}) { saveUiState(); if (saveTimer) clearTimeout(saveTimer); saveTimerNoteId = noteId; if (immediate) { saveTimer = null; saveTimerNoteId = null; sendNote(noteId); return; } saveTimer = setTimeout(() => { const queuedNoteId = saveTimerNoteId; saveTimer = null; saveTimerNoteId = null; if (queuedNoteId) sendNote(queuedNoteId); }, saveDelay); setSaveStatus("未保存"); } async function sendNote(noteId, { keepalive = false } = {}) { const note = state.notes.find((entry) => entry.id === noteId); if (!note) return; if (saveInFlightNoteId) { queuedSaveNoteId = noteId; return; } setSaveStatus("保存中"); const snapshot = { body: note.body, folder: note.folder, updatedAt: note.updatedAt }; saveInFlightNoteId = noteId; try { const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}`, { method: "PATCH", keepalive, headers: { "content-type": "application/json" }, body: JSON.stringify({ folder: note.folder, body: note.body, updatedAt: note.updatedAt, version: note.version }) }); const payload = await response.json().catch(() => ({})); if (response.status === 409 && payload.note) { handleNoteConflict(note, payload.note); return; } if (!response.ok) { throw new Error(payload.error || `Save API returned ${response.status}`); } const noteChangedSinceSend = note.body !== snapshot.body || note.folder !== snapshot.folder || note.updatedAt !== snapshot.updatedAt; if (noteChangedSinceSend) { note.version = payload.version; } else { Object.assign(note, payload); } setSaveStatus(noteChangedSinceSend ? "继续保存中" : "已保存", noteChangedSinceSend ? "" : "ok"); renderListOnly(); renderEditor(); } catch (error) { setSaveStatus("保存失败", "error"); console.error("Failed to save note.", error); } finally { saveInFlightNoteId = null; if (queuedSaveNoteId) { const nextNoteId = queuedSaveNoteId; queuedSaveNoteId = null; sendNote(nextNoteId); } } } async function createNote(note) { const response = await fetch("/api/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(note) }); if (!response.ok) throw new Error(`Create note API returned ${response.status}`); return await response.json(); } async function deleteNote(noteId) { const response = await fetch(`/api/notes/${encodeURIComponent(noteId)}`, { method: "DELETE" }); if (!response.ok) throw new Error(`Delete note API returned ${response.status}`); } async function createFolder(folder) { const response = await fetch("/api/folders", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(folder) }); if (!response.ok) throw new Error(`Create folder API returned ${response.status}`); return await response.json(); } async function updateFolder(folderId, patch) { const response = await fetch(`/api/folders/${encodeURIComponent(folderId)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) }); const payload = await response.json().catch(() => ({})); if (response.status === 409 && payload.folder) { return { status: "conflict", folder: payload.folder }; } if (!response.ok) throw new Error(`Update folder API returned ${response.status}`); return payload; } async function removeFolder(folderId) { const response = await fetch(`/api/folders/${encodeURIComponent(folderId)}`, { method: "DELETE" }); if (!response.ok) throw new Error(`Delete folder API returned ${response.status}`); return await response.json(); } function handleNoteConflict(localNote, serverNote) { setSaveStatus("有冲突", "conflict"); const useServer = window.confirm("这条笔记已在其他设备修改。确定载入服务器版本吗?取消则保留本机内容,稍后可再次保存。"); if (useServer) { Object.assign(localNote, serverNote); setSaveStatus("已载入服务器版本", "ok"); render(); return; } localNote.version = serverNote.version; setSaveStatus("保留本机内容", "conflict"); } function setSaveStatus(text, type = "") { els.saveStatus.textContent = text; els.saveStatus.className = `save-status${type ? ` is-${type}` : ""}`; if (saveStatusTimer) clearTimeout(saveStatusTimer); if (type === "ok") { saveStatusTimer = setTimeout(() => { els.saveStatus.textContent = ""; els.saveStatus.className = "save-status"; }, 1800); } } function hydrateState(data) { const folders = Array.isArray(data.folders) && data.folders.length ? data.folders : defaultFolders(); const notes = Array.isArray(data.notes) ? data.notes.map((note) => ({ ...note, version: Number(note.version || 1) })) : []; const ui = loadUiState(); const selectedId = notes.some((note) => note.id === ui.selectedId) ? ui.selectedId : notes[0]?.id || null; const selectedNote = notes.find((note) => note.id === selectedId); const validFolders = new Set(["all", "recent", ...folders.map((folder) => folder.id)]); let activeFolder = validFolders.has(ui.activeFolder) ? ui.activeFolder : selectedNote?.folder || "all"; if (selectedNote && activeFolder !== "all" && activeFolder !== "recent" && activeFolder !== selectedNote.folder) { activeFolder = selectedNote.folder; } if (selectedNote && activeFolder === "recent" && !isRecentNote(selectedNote)) { activeFolder = selectedNote.folder; } const mobileView = ["folders", "list", "editor"].includes(ui.mobileView) ? ui.mobileView : selectedId ? "editor" : "folders"; return { notes, folders, activeFolder, selectedId, query: "", folderQuery: "", mobileView, previewMode: Boolean(ui.previewMode) }; } function loadUiState() { try { return JSON.parse(localStorage.getItem(uiStorageKey)) || {}; } catch { return {}; } } function saveUiState() { localStorage.setItem(uiStorageKey, JSON.stringify({ activeFolder: state.activeFolder, selectedId: state.selectedId, mobileView: state.mobileView, previewMode: state.previewMode })); } function render() { els.searchInput.value = state.query; renderFolders(); renderListOnly(); renderEditor(); renderMobileChrome(); } function renderFolders() { const now = Date.now(); const recentCutoff = now - 1000 * 60 * 60 * 24 * 7; const allFolder = { id: "all", name: "所有笔记", icon: "▣", count: state.notes.length }; const recentFolder = { id: "recent", name: "最近编辑", icon: "clock-3", count: state.notes.filter((note) => note.updatedAt >= recentCutoff).length }; allFolder.icon = "file-text"; const folderQuery = state.mobileView === "folders" ? state.folderQuery.trim().toLowerCase() : ""; const userFolders = state.folders .filter((folder) => !folderQuery || folder.name.toLowerCase().includes(folderQuery)) .map((folder) => ({ id: folder.id, name: folder.name, icon: "folder", count: state.notes.filter((note) => note.folder === folder.id).length, manageable: folder.id !== "notes" })); const smartFolders = [allFolder, recentFolder].filter((folder) => ( !folderQuery || folder.name.toLowerCase().includes(folderQuery) )); els.folderList.replaceChildren(...[...smartFolders, ...userFolders].map(renderFolderItem)); } function renderListOnly() { const visible = getVisibleNotes(); if (!visible.some((note) => note.id === state.selectedId)) { state.selectedId = visible[0]?.id || null; } const labels = { all: "所有笔记", recent: "最近编辑" }; const activeFolder = state.folders.find((folder) => folder.id === state.activeFolder); els.listTitle.textContent = labels[state.activeFolder] || activeFolder?.name || "所有笔记"; els.listCount.textContent = `${visible.length} 个笔记`; els.deleteNote.disabled = !state.selectedId; els.noteList.replaceChildren(...renderGroupedNotes(visible)); renderFolders(); } function renderGroupedNotes(notes) { const nodes = []; let currentGroup = ""; notes.forEach((note) => { const group = formatSectionDate(note.updatedAt); if (group !== currentGroup) { currentGroup = group; const heading = document.createElement("div"); heading.className = "note-section-title"; heading.textContent = group; nodes.push(heading); } nodes.push(renderNoteButton(note)); }); return nodes; } function renderFolderItem(folder) { const item = document.createElement("div"); item.className = `folder-item${folder.manageable ? " can-manage" : ""}`; const button = document.createElement("button"); button.className = `folder-row${folder.id === state.activeFolder ? " active" : ""}`; button.type = "button"; button.dataset.folder = folder.id; const icon = document.createElement("i"); icon.className = "folder-icon"; icon.dataset.lucide = folder.icon; icon.setAttribute("aria-hidden", "true"); const name = document.createElement("span"); name.className = "folder-name"; name.textContent = folder.name; const count = document.createElement("span"); count.className = "folder-count"; count.textContent = folder.count; button.append(icon, name, count); button.addEventListener("click", () => { state.activeFolder = folder.id; const visible = getVisibleNotes(); state.selectedId = visible[0]?.id || null; state.mobileView = "list"; saveUiState(); render(); }); item.append(button); if (folder.manageable) { const editButton = document.createElement("button"); editButton.className = "folder-edit"; editButton.type = "button"; editButton.setAttribute("aria-label", `重命名${folder.name}`); editButton.title = "重命名文件夹"; editButton.innerHTML = ''; editButton.addEventListener("click", (event) => { event.stopPropagation(); openFolderGate({ mode: "rename", folderId: folder.id, name: folder.name }); }); item.append(editButton); const deleteButton = document.createElement("button"); deleteButton.className = "folder-delete"; deleteButton.type = "button"; deleteButton.setAttribute("aria-label", `删除${folder.name}`); deleteButton.title = "删除文件夹"; deleteButton.innerHTML = ''; deleteButton.addEventListener("click", (event) => { event.stopPropagation(); deleteFolder(folder.id); }); item.append(deleteButton); } return item; } function renderNoteButton(note) { const button = document.createElement("button"); button.type = "button"; button.className = `note-card${note.id === state.selectedId ? " selected" : ""}`; button.dataset.noteId = note.id; button.setAttribute("aria-label", getTitle(note)); const title = document.createElement("span"); title.className = "note-title"; title.textContent = getTitle(note); const preview = document.createElement("span"); preview.className = "note-preview"; preview.innerHTML = `${formatListDate(note.updatedAt)} ${getPreview(note)}`; button.append(title, preview); button.addEventListener("click", () => { state.selectedId = note.id; state.mobileView = "editor"; saveUiState(); render(); els.editor.focus(); }); return button; } function renderEditor() { const note = getSelectedNote(); els.editorPane.classList.toggle("empty", !note); els.saveNote.disabled = !note; els.mobileSave.disabled = !note; els.editorMenuToggle.disabled = !note || state.previewMode; els.mobileEditorMenuToggle.disabled = !note || state.previewMode; els.editorPreviewToggle.disabled = !note; els.mobilePreviewToggle.disabled = !note; els.mobileMore.disabled = !note; els.mobileMenuSave.disabled = !note; els.mobileMenuPreview.disabled = !note; els.mobileMenuDelete.disabled = !note; els.previewToggle.classList.toggle("active", state.previewMode); els.editorPreviewToggle.classList.toggle("active", state.previewMode); els.mobilePreviewToggle.classList.toggle("active", state.previewMode); els.mobileMenuPreview.classList.toggle("active", state.previewMode); if (!note) { els.mobileActionMenu.hidden = true; hideEditorMenus(); setEditorContent(""); tiptapEditor?.setEditable(false, false); els.editor.hidden = false; els.editedAt.textContent = ""; els.markdownPreview.hidden = true; els.noteFolderSelect.replaceChildren(); els.mobileNoteFolderSelect.replaceChildren(); els.noteFolderSelect.disabled = true; els.mobileNoteFolderSelect.disabled = true; return; } setEditorContent(note.body); tiptapEditor?.setEditable(!state.previewMode, false); els.editor.hidden = state.previewMode; els.markdownPreview.hidden = !state.previewMode; renderMarkdownPreview(note); renderNoteFolderSelect(note); updateEditorMeta(note); renderEditorMenuState(); } function setEditorContent(body) { if (!tiptapEditor) return; const currentHtml = normalizeEditorHtml(tiptapEditor.getHTML()); if (currentHtml === normalizeEditorHtml(body) || (!isHtmlNoteBody(body) && currentHtml === normalizeEditorHtml(markdownToHtml(body)))) { return; } syncingEditor = true; tiptapEditor.commands.setContent(body || "", { contentType: isHtmlNoteBody(body) ? "html" : "markdown", emitUpdate: false }); syncingEditor = false; } function renderNoteFolderSelect(note) { const options = state.folders.map((folder) => { const option = document.createElement("option"); option.value = folder.id; option.textContent = folder.name; option.selected = folder.id === note.folder; return option; }); const mobileOptions = options.map((option) => option.cloneNode(true)); els.noteFolderSelect.disabled = false; els.mobileNoteFolderSelect.disabled = false; els.noteFolderSelect.replaceChildren(...options); els.mobileNoteFolderSelect.replaceChildren(...mobileOptions); } function renderMobileChrome() { if (!["folders", "list", "editor"].includes(state.mobileView)) { state.mobileView = "folders"; } els.appShell.dataset.mobileView = state.mobileView; const activeLabel = getActiveFolderLabel(); const selected = getSelectedNote(); if (state.mobileView === "folders") { els.mobileTitle.textContent = "文件夹"; els.mobileBackLabel.textContent = "返回"; els.mobileBack.classList.add("is-hidden"); els.mobileSearchInput.value = state.folderQuery; els.mobileSearchInput.placeholder = "搜索文件夹"; } else if (state.mobileView === "list") { els.mobileTitle.textContent = activeLabel; els.mobileBackLabel.textContent = "文件夹"; els.mobileBack.classList.remove("is-hidden"); els.mobileSearchInput.value = state.query; els.mobileSearchInput.placeholder = "搜索"; } else { els.mobileTitle.textContent = selected ? getTitle(selected) : "笔记"; els.mobileBackLabel.textContent = activeLabel; els.mobileBack.classList.remove("is-hidden"); els.mobileSearchInput.value = state.query; els.mobileSearchInput.placeholder = "搜索"; } if (state.mobileView !== "editor") { els.mobileActionMenu.hidden = true; } renderIcons(); } function updateEditorMeta(note) { els.editedAt.dateTime = new Date(note.updatedAt).toISOString(); els.editedAt.textContent = `编辑于 ${formatFullDate(note.updatedAt)}`; } function getVisibleNotes() { const query = state.query.trim().toLowerCase(); return state.notes .filter((note) => { if (state.activeFolder === "recent") return isRecentNote(note); if (state.activeFolder !== "all") return note.folder === state.activeFolder; return true; }) .filter((note) => { if (!query) return true; return `${getTitle(note)} ${note.body}`.toLowerCase().includes(query); }) .sort((a, b) => b.updatedAt - a.updatedAt); } function isRecentNote(note) { return note.updatedAt >= Date.now() - 1000 * 60 * 60 * 24 * 7; } function getSelectedNote() { return state.notes.find((note) => note.id === state.selectedId) || null; } async function deleteFolder(folderId) { const folder = state.folders.find((entry) => entry.id === folderId); if (!folder || folder.id === "notes") return; const shouldDelete = window.confirm(`删除“${folder.name}”?其中的笔记会移到“备忘录”。`); if (!shouldDelete) return; const fallbackFolder = state.folders.find((entry) => entry.id === "notes") || state.folders[0]; state.notes.forEach((note) => { if (note.folder === folder.id) { note.folder = fallbackFolder.id; note.updatedAt = Date.now(); } }); state.folders = state.folders.filter((entry) => entry.id !== folder.id); if (state.activeFolder === folder.id) { state.activeFolder = fallbackFolder.id; const visible = getVisibleNotes(); state.selectedId = visible[0]?.id || null; state.mobileView = "list"; } render(); try { await removeFolder(folder.id); setSaveStatus("文件夹已删除", "ok"); const fresh = await loadState(); Object.assign(state, fresh); render(); } catch (error) { setSaveStatus("删除失败", "error"); console.error("Failed to delete folder.", error); const fresh = await loadState(); Object.assign(state, fresh); render(); } } async function submitFolderCreate(name) { const now = Date.now(); const previousActiveFolder = state.activeFolder; const previousSelectedId = state.selectedId; const folder = { id: createId(), name, createdAt: now, updatedAt: now, version: 1 }; state.folders.push(folder); state.activeFolder = folder.id; state.selectedId = null; state.mobileView = "list"; render(); hideFolderGate(); try { const saved = await createFolder(folder); Object.assign(folder, saved); setSaveStatus("文件夹已创建", "ok"); render(); } catch (error) { state.folders = state.folders.filter((entry) => entry.id !== folder.id); state.activeFolder = previousActiveFolder; state.selectedId = previousSelectedId; setSaveStatus("创建失败", "error"); render(); console.error("Failed to create folder.", error); } } async function submitFolderRename(folderId, name) { const folder = state.folders.find((entry) => entry.id === folderId); if (!folder || folder.id === "notes") return; const previousName = folder.name; const previousUpdatedAt = folder.updatedAt; const previousVersion = folder.version || 1; folder.name = name; folder.updatedAt = Date.now(); folder.version = previousVersion + 1; render(); hideFolderGate(); try { const result = await updateFolder(folder.id, { name: folder.name, updatedAt: folder.updatedAt, version: previousVersion }); if (result.status === "conflict" && result.folder) { Object.assign(folder, result.folder); setSaveStatus("文件夹已被其他设备修改", "conflict"); } else { Object.assign(folder, result); setSaveStatus("名称已更新", "ok"); } render(); } catch (error) { folder.name = previousName; folder.updatedAt = previousUpdatedAt; folder.version = previousVersion; setSaveStatus("重命名失败", "error"); render(); console.error("Failed to rename folder.", error); } } function getActiveFolderLabel() { if (state.activeFolder === "all") return "所有笔记"; if (state.activeFolder === "recent") return "最近编辑"; return state.folders.find((folder) => folder.id === state.activeFolder)?.name || "笔记"; } function getWriteFolderId() { if (state.folders.some((folder) => folder.id === state.activeFolder)) { return state.activeFolder; } return state.folders[0]?.id || "notes"; } function getUniqueFolderName(baseName) { const existing = new Set(state.folders.map((folder) => folder.name)); if (!existing.has(baseName)) return baseName; let index = 2; while (existing.has(`${baseName} ${index}`)) { index += 1; } return `${baseName} ${index}`; } function defaultFolders() { return [{ id: "notes", name: "备忘录", version: 1 }]; } function createId() { const bytes = new Uint8Array(16); if (globalThis.crypto?.getRandomValues) { globalThis.crypto.getRandomValues(bytes); } else { for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Math.floor(Math.random() * 256); } } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; return stringifyUuid(bytes); } function renderIcons() { window.lucide?.createIcons({ attrs: { "stroke-width": 2 } }); } function renderMarkdownPreview(note) { if (!state.previewMode) return; els.markdownPreview.innerHTML = isHtmlNoteBody(note.body) ? sanitizeRichHtml(note.body) : markdownToHtml(note.body); } function normalizeEditorHtml(value) { const html = String(value || "").trim(); return html === "
" ? "" : html; } function isHtmlNoteBody(value) { return /<\/?(p|h[1-6]|ul|ol|li|blockquote|pre|code|strong|em|s|u|a|img|br|hr)\b/i.test(String(value || "")); } function sanitizeRichHtml(html) { const template = document.createElement("template"); template.innerHTML = String(html || ""); const allowedTags = new Set([ "A", "BLOCKQUOTE", "BR", "CODE", "DIV", "EM", "H1", "H2", "H3", "H4", "H5", "H6", "HR", "IMG", "LI", "OL", "P", "PRE", "S", "STRONG", "U", "UL" ]); [...template.content.querySelectorAll("*")].forEach((node) => { if (!allowedTags.has(node.tagName)) { node.replaceWith(...node.childNodes); return; } const href = node.getAttribute("href") || ""; const src = node.getAttribute("src") || ""; const alt = node.getAttribute("alt") || ""; const style = node.getAttribute("style") || ""; const textAlign = style.match(/text-align:\s*(left|center|right)/i)?.[1] || ""; const imageAlign = node.getAttribute("data-align") || node.getAttribute("align") || ""; [...node.attributes].forEach((attribute) => node.removeAttribute(attribute.name)); if (textAlign && ["P", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName)) { node.setAttribute("style", `text-align: ${textAlign}`); } if (node.tagName === "A") { if (/^(https?:|mailto:)/i.test(href)) { node.setAttribute("href", href); node.setAttribute("target", "_blank"); node.setAttribute("rel", "noreferrer"); } } if (node.tagName === "IMG") { if (/^(data:image\/|https?:\/\/)/i.test(src)) { node.setAttribute("src", src); node.setAttribute("alt", alt); if (["left", "center", "right"].includes(imageAlign)) { node.setAttribute("data-align", imageAlign); } } else { node.remove(); } } }); return template.innerHTML; } function markdownToHtml(markdown) { const lines = String(markdown || "").split("\n"); const html = []; let listType = ""; let inCode = false; let codeLines = []; const closeList = () => { if (!listType) return; html.push(`${listType}>`); listType = ""; }; lines.forEach((line) => { if (line.trim().startsWith("```")) { if (inCode) { html.push(`${escapeHtml(codeLines.join("\n"))}`);
codeLines = [];
inCode = false;
} else {
closeList();
inCode = true;
}
return;
}
if (inCode) {
codeLines.push(line);
return;
}
const trimmed = line.trim();
if (!trimmed) {
closeList();
return;
}
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
closeList();
html.push(`${renderInlineMarkdown(trimmed.slice(1).trim())}`); return; } closeList(); html.push(`
${renderInlineMarkdown(trimmed)}
`); }); if (inCode) { html.push(`${escapeHtml(codeLines.join("\n"))}`);
}
closeList();
return html.join("");
}
function renderInlineMarkdown(value) {
return escapeHtml(value)
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/!\[([^\]]*)\]\((data:image\/[^)\s]+|https?:\/\/[^)\s]+)\)/g, '