Download app.js from ghuser1/notes: direct link, hf CLI and curl.
- Browser
- Download file 52.2 kB
-
https://huggingface.co/spaces/ghuser1/notes/resolve/main/app.js
- Command line
-
hf download hf://spaces/ghuser1/notes/app.js
-
curl -L -o app.js https://huggingface.co/spaces/ghuser1/notes/resolve/main/app.js
52.2 kB
| 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" }); | |
| } | |