| 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 = '<i data-lucide="pencil" aria-hidden="true"></i>'; |
| 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 = '<i data-lucide="trash-2" aria-hidden="true"></i>'; |
| 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 = `<span class="note-date">${formatListDate(note.updatedAt)}</span> ${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 === "<p></p>" ? "" : 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(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); |
| 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(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`); |
| return; |
| } |
|
|
| const unordered = trimmed.match(/^[-*]\s+(.+)$/); |
| if (unordered) { |
| if (listType !== "ul") { |
| closeList(); |
| listType = "ul"; |
| html.push("<ul>"); |
| } |
| html.push(`<li>${renderInlineMarkdown(unordered[1])}</li>`); |
| return; |
| } |
|
|
| const ordered = trimmed.match(/^\d+\.\s+(.+)$/); |
| if (ordered) { |
| if (listType !== "ol") { |
| closeList(); |
| listType = "ol"; |
| html.push("<ol>"); |
| } |
| html.push(`<li>${renderInlineMarkdown(ordered[1])}</li>`); |
| return; |
| } |
|
|
| if (trimmed.startsWith(">")) { |
| closeList(); |
| html.push(`<blockquote>${renderInlineMarkdown(trimmed.slice(1).trim())}</blockquote>`); |
| return; |
| } |
|
|
| closeList(); |
| html.push(`<p>${renderInlineMarkdown(trimmed)}</p>`); |
| }); |
|
|
| if (inCode) { |
| html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); |
| } |
| closeList(); |
| return html.join(""); |
| } |
|
|
| function renderInlineMarkdown(value) { |
| return escapeHtml(value) |
| .replace(/`([^`]+)`/g, "<code>$1</code>") |
| .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") |
| .replace(/\*([^*]+)\*/g, "<em>$1</em>") |
| .replace(/!\[([^\]]*)\]\((data:image\/[^)\s]+|https?:\/\/[^)\s]+)\)/g, '<img src="$2" alt="$1">') |
| .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>'); |
| } |
|
|
| function escapeHtml(value) { |
| return String(value) |
| .replaceAll("&", "&") |
| .replaceAll("<", "<") |
| .replaceAll(">", ">") |
| .replaceAll('"', """) |
| .replaceAll("'", "'"); |
| } |
|
|
| function getTitle(note) { |
| const firstLine = getNotePlainText(note).split(/\n/).find((line) => line.trim()); |
| return firstLine ? firstLine.trim().slice(0, 80) : "新建笔记"; |
| } |
|
|
| function getPreview(note) { |
| const lines = getNotePlainText(note).split(/\n/).map((line) => line.trim()).filter(Boolean); |
| const text = lines.slice(1).join(" ") || "无附加文本"; |
| return text.slice(0, 140); |
| } |
|
|
| function getNotePlainText(note) { |
| if (!isHtmlNoteBody(note.body)) return note.body; |
| const template = document.createElement("template"); |
| template.innerHTML = sanitizeRichHtml(note.body); |
| const imageCount = template.content.querySelectorAll("img").length; |
| const blockSelector = "p,h1,h2,h3,h4,h5,h6,li,blockquote,pre"; |
| const blocks = [...template.content.querySelectorAll(blockSelector)] |
| .map((node) => node.textContent.trim()) |
| .filter(Boolean); |
| const text = blocks.length ? blocks.join("\n") : template.content.textContent || ""; |
| return `${text}${imageCount ? `\n${imageCount} 张图片` : ""}`; |
| } |
|
|
| function formatListDate(value) { |
| const date = new Date(value); |
| const today = new Date(); |
| const sameDay = date.toDateString() === today.toDateString(); |
| if (sameDay) { |
| return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); |
| } |
| return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); |
| } |
|
|
| function formatFullDate(value) { |
| return new Date(value).toLocaleString("zh-CN", { |
| year: "numeric", |
| month: "long", |
| day: "numeric", |
| hour: "2-digit", |
| minute: "2-digit" |
| }); |
| } |
|
|
| function formatSectionDate(value) { |
| const date = new Date(value); |
| const today = new Date(); |
| const yesterday = new Date(); |
| yesterday.setDate(today.getDate() - 1); |
|
|
| if (date.toDateString() === today.toDateString()) return "今天"; |
| if (date.toDateString() === yesterday.toDateString()) return "昨天"; |
|
|
| const sameYear = date.getFullYear() === today.getFullYear(); |
| return date.toLocaleDateString("zh-CN", sameYear |
| ? { month: "long", day: "numeric" } |
| : { year: "numeric", month: "long", day: "numeric" }); |
| } |
|
|