From b201d7dfb9cbf9af270992bebb94f38ec19bbd66 Mon Sep 17 00:00:00 2001 From: tianma Date: Sun, 28 Jun 2026 00:21:02 +0800 Subject: [PATCH] refactor(web): rewrite monolithic App.tsx to modular components styled with Shadcn UI --- apps/web/src/app/App.tsx | 8520 +---------------- apps/web/src/components/EditorPane.tsx | 1030 ++ apps/web/src/components/EditorToolbar.tsx | 215 + apps/web/src/components/LoginScreen.tsx | 72 + apps/web/src/components/MemoCard.tsx | 431 + apps/web/src/components/MemoListPane.tsx | 2028 ++++ apps/web/src/components/NotebookPane.tsx | 396 + apps/web/src/components/NotebookTreeItem.tsx | 338 + apps/web/src/components/WorkspaceApp.tsx | 1663 ++++ .../src/components/dialogs/AssetsDialog.tsx | 96 + .../src/components/dialogs/ConfirmDialogs.tsx | 186 + .../dialogs/RevisionHistoryDialog.tsx | 182 + .../src/components/dialogs/SettingsDialog.tsx | 207 + .../web/src/components/dialogs/TagsDialog.tsx | 194 + .../components/dialogs/TemplatesDialog.tsx | 104 + apps/web/src/components/ui/button.tsx | 80 +- apps/web/src/components/ui/context-menu.tsx | 189 + apps/web/src/components/ui/dialog.tsx | 116 + apps/web/src/components/ui/dropdown-menu.tsx | 192 + apps/web/src/components/ui/select.tsx | 154 + apps/web/src/components/ui/switch.tsx | 26 + apps/web/src/lib/app-helpers.ts | 537 ++ apps/web/src/lib/app-hooks.ts | 507 + apps/web/src/styles/globals.css | 34 + apps/web/tailwind.config.ts | 56 +- bun.lock | 96 + package.json | 12 +- 27 files changed, 9119 insertions(+), 8542 deletions(-) create mode 100644 apps/web/src/components/EditorPane.tsx create mode 100644 apps/web/src/components/EditorToolbar.tsx create mode 100644 apps/web/src/components/LoginScreen.tsx create mode 100644 apps/web/src/components/MemoCard.tsx create mode 100644 apps/web/src/components/MemoListPane.tsx create mode 100644 apps/web/src/components/NotebookPane.tsx create mode 100644 apps/web/src/components/NotebookTreeItem.tsx create mode 100644 apps/web/src/components/WorkspaceApp.tsx create mode 100644 apps/web/src/components/dialogs/AssetsDialog.tsx create mode 100644 apps/web/src/components/dialogs/ConfirmDialogs.tsx create mode 100644 apps/web/src/components/dialogs/RevisionHistoryDialog.tsx create mode 100644 apps/web/src/components/dialogs/SettingsDialog.tsx create mode 100644 apps/web/src/components/dialogs/TagsDialog.tsx create mode 100644 apps/web/src/components/dialogs/TemplatesDialog.tsx create mode 100644 apps/web/src/components/ui/context-menu.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/dropdown-menu.tsx create mode 100644 apps/web/src/components/ui/select.tsx create mode 100644 apps/web/src/components/ui/switch.tsx create mode 100644 apps/web/src/lib/app-helpers.ts create mode 100644 apps/web/src/lib/app-hooks.ts diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 301cbb34..81651e9c 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,705 +1,25 @@ -import { - DEFAULT_MEMO_TITLE, - docToMarkdown, - type ApiToken, - type AuthSession, - type AuthUser, - type MemoDetail, - type MemoSummary, - type Notebook, - type TagSummary, - type TiptapDoc, -} from "@edgeever/shared"; -import Image from "@tiptap/extension-image"; -import Placeholder from "@tiptap/extension-placeholder"; -import { EditorContent, useEditor, type Editor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Archive, - ArrowDownWideNarrow, - Bold, - Check, - ChevronLeft, - CheckSquare, - ChevronDown, - ChevronRight, - CheckCircle2, - Code2, - AlertTriangle, - CloudOff, - ExternalLink, - File as FileIcon, - FilePlus2, - Folder, - HardDrive, - History, - Home, - ImageIcon, - Italic, - KeyRound, - LayoutList, - List, - ListOrdered, - LockKeyhole, - LogOut, - Merge, - Minus, - MoreHorizontal, - MoreVertical, - Notebook as NotebookIcon, - Pencil, - Plus, - Quote, - Redo2, - RefreshCw, - RotateCcw, - Save, - Search, - ShieldCheck, - Sparkles, - SquareCode, - Star, - Strikethrough, - TagX, - Tags, - Trash2, - Undo2, - X, -} from "lucide-react"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type CSSProperties, - type DragEvent, - type FormEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent, - type PointerEvent as ReactPointerEvent, - type ReactNode, - type RefObject, -} from "react"; +import { useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; -import { compressImageForUpload } from "@/lib/image-compression"; -import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; -import { - emptySyncQueueSummary, - getMemoUpdateQueueId, - observeSyncQueue, - queueMemoUpdate, - shouldQueueMemoSaveError, - syncQueuedChanges, - type SyncQueueSummary, -} from "@/lib/sync-queue"; -import { buildNotebookTree, cn, formatDateTime, parseTagsText, type NotebookNode } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; - -type Pane = "notebooks" | "memos" | "editor"; -type MemoView = "notebook" | "trash"; -type MemoFilterMode = "all" | "tagged" | "untagged" | "pinned"; -type MemoSortMode = "updated-desc" | "created-desc" | "title-asc"; -type MemoListDensity = "preview" | "compact"; -type MobileBottomNavItem = "home" | "search" | "templates" | "settings"; -type MemoContextMenuState = { memo: MemoSummary; x: number; y: number }; -type MemoSelectionContextMenuState = { x: number; y: number }; -type NotebookContextMenuState = { notebook: NotebookNode; x: number; y: number }; -type MemoDeleteConfirmation = { kind: "single" | "bulk"; memoIds: string[]; permanent: boolean }; -type NotebookNameDialogState = - | { mode: "create"; parentId: string | null } - | { mode: "rename"; notebook: Notebook }; -type AppNoticeDialogState = { title: string; description: string }; -type NotebookDropPosition = "before" | "inside" | "after"; -type NotebookMoveOption = { id: string; name: string; selectLabel: string; slug: string | null; depth: number }; -type ExpandNotebookSiblingsRequest = { parentId: string | null; token: number }; -type MemoTemplate = { - id: string; - title: string; - description: string; - contentMarkdown: string; - tags: string[]; -}; - -const IMAGE_COMPRESSION_STORAGE_KEY = "edgeever.imageCompressionEnabled"; -const MEMO_LIST_DENSITY_STORAGE_KEY = "edgeever.memoListDensity"; -const MEMO_LIST_WIDTH_STORAGE_KEY = "edgeever.memoListWidth"; -const DEFAULT_MEMO_LIST_WIDTH_PX = 360; -const MIN_MEMO_LIST_WIDTH_PX = 300; -const MAX_MEMO_LIST_WIDTH_PX = 540; -const MEMO_SORT_OPTIONS: Array<{ value: MemoSortMode; label: string }> = [ - { value: "updated-desc", label: "最近更新" }, - { value: "created-desc", label: "创建时间" }, - { value: "title-asc", label: "标题 A-Z" }, -]; -const MEMO_FILTER_OPTIONS: Array<{ value: MemoFilterMode; label: string }> = [ - { value: "all", label: "全部" }, - { value: "pinned", label: "置顶" }, - { value: "tagged", label: "有标签" }, - { value: "untagged", label: "无标签" }, -]; -const MEMO_TEMPLATES: MemoTemplate[] = [ - { - id: "quick-note", - title: "速记", - description: "适合临时记录想法、链接和灵感。", - contentMarkdown: "## 速记\n\n- \n\n## 后续动作\n\n- [ ] ", - tags: ["template", "quick-note"], - }, - { - id: "meeting", - title: "会议记录", - description: "议题、结论和待办放在同一页。", - contentMarkdown: "## 会议记录\n\n时间:\n参与人:\n\n## 议题\n\n- \n\n## 结论\n\n- \n\n## 待办\n\n- [ ] ", - tags: ["template", "meeting"], - }, - { - id: "checklist", - title: "清单", - description: "快速列出待办、采购、项目检查项。", - contentMarkdown: "## 清单\n\n- [ ] \n- [ ] \n- [ ] ", - tags: ["template", "checklist"], - }, - { - id: "reading", - title: "读书笔记", - description: "摘录、观点和下一步阅读整理。", - contentMarkdown: "## 读书笔记\n\n书名:\n作者:\n\n## 摘录\n\n> \n\n## 我的观点\n\n\n## 延伸问题\n\n- ", - tags: ["template", "reading"], - }, - { - id: "daily", - title: "每日复盘", - description: "记录今天完成了什么、卡在哪里。", - contentMarkdown: "## 每日复盘\n\n## 今天完成\n\n- \n\n## 遇到的问题\n\n- \n\n## 明天优先级\n\n- [ ] ", - tags: ["template", "daily"], - }, -]; -const MEMO_LONG_PRESS_DELAY_MS = 520; -const MEMO_LONG_PRESS_MOVE_TOLERANCE_PX = 14; -const BOTTOM_SHEET_DISMISS_DISTANCE_PX = 72; -const BOTTOM_SHEET_DISMISS_VELOCITY_PX_PER_MS = 0.55; -const NOTEBOOK_DRAG_EXPAND_DELAY_MS = 520; -const NOTEBOOK_DRAG_SCROLL_EDGE_PX = 56; -const NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX = 18; -const APP_BACK_HISTORY_MARKER = "__edgeever_app_back__"; -const NOTEBOOK_DRAG_MIME = "application/x-edgeever-notebook"; -const MEMO_DRAG_MIME = "application/x-edgeever-memos"; - -const getSelectionCountLabel = (count: number) => (count > 0 ? `已选择 ${count} 条` : "选择笔记"); - -const isTextEntryTarget = (target: EventTarget | null) => - target instanceof HTMLElement && - Boolean(target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .ProseMirror")); - -const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; - -const DIALOG_FOCUSABLE_SELECTOR = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - "[tabindex]:not([tabindex='-1'])", -].join(","); - -const getFocusableElements = (node: HTMLElement | null) => { - if (!node) { - return []; - } - - return Array.from(node.querySelectorAll(DIALOG_FOCUSABLE_SELECTOR)).filter((element) => { - const isVisible = element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0; - - return isVisible && !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true"; - }); -}; - -const useDismissableLayer = (open: boolean, onClose: () => void) => { - const ref = useRef(null); - - useEffect(() => { - if (!open) { - return; - } - - const handlePointerDown = (event: PointerEvent) => { - const node = ref.current; - - if (!node || node.contains(event.target as Node)) { - return; - } - - onClose(); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - - window.addEventListener("pointerdown", handlePointerDown); - window.addEventListener("keydown", handleKeyDown); - - return () => { - window.removeEventListener("pointerdown", handlePointerDown); - window.removeEventListener("keydown", handleKeyDown); - }; - }, [onClose, open]); - - return ref; -}; - -type BrowserBackLayer = { - id: string; - onBack: () => void; -}; - -let browserBackLayerCounter = 0; -let browserBackLayerPopStateAttached = false; -let browserBackLayerSuppressNextPopState = false; -const browserBackLayerStack: BrowserBackLayer[] = []; - -const getBrowserBackMarker = () => - window.history.state && typeof window.history.state === "object" - ? (window.history.state as Record)[APP_BACK_HISTORY_MARKER] - : null; - -const removeBrowserBackLayer = (id: string) => { - for (let index = browserBackLayerStack.length - 1; index >= 0; index -= 1) { - if (browserBackLayerStack[index]?.id === id) { - browserBackLayerStack.splice(index, 1); - } - } -}; - -const hasBrowserBackLayer = (id: string) => browserBackLayerStack.some((layer) => layer.id === id); - -const ensureBrowserBackLayerListener = () => { - if (browserBackLayerPopStateAttached) { - return; - } - - window.addEventListener("popstate", () => { - if (browserBackLayerSuppressNextPopState) { - browserBackLayerSuppressNextPopState = false; - return; - } - - const layer = browserBackLayerStack.at(-1); - - if (!layer) { - return; - } - - removeBrowserBackLayer(layer.id); - layer.onBack(); - }); - browserBackLayerPopStateAttached = true; -}; - -const useBrowserBackLayer = (active: boolean, onBack: () => void) => { - const idRef = useRef(""); - const onBackRef = useRef(onBack); - - if (!idRef.current) { - browserBackLayerCounter += 1; - idRef.current = `edgeever-back-layer-${browserBackLayerCounter}`; - } - - useEffect(() => { - onBackRef.current = onBack; - }, [onBack]); - - useEffect(() => { - if (!active) { - return; - } - - ensureBrowserBackLayerListener(); - - const id = idRef.current; - const currentState = - window.history.state && typeof window.history.state === "object" ? window.history.state : {}; - const layer: BrowserBackLayer = { - id, - onBack: () => onBackRef.current(), - }; - - browserBackLayerStack.push(layer); - window.history.pushState( - { - ...currentState, - [APP_BACK_HISTORY_MARKER]: id, - }, - "", - window.location.href - ); - - return () => { - removeBrowserBackLayer(id); - - window.setTimeout(() => { - if (hasBrowserBackLayer(id) || getBrowserBackMarker() !== id) { - return; - } - - browserBackLayerSuppressNextPopState = true; - window.history.back(); - }, 0); - }; - }, [active]); -}; - -const useFloatingMenuControls = (ref: RefObject, open: boolean, onClose: () => void) => { - useEffect(() => { - if (!open) { - return; - } - - const node = ref.current; - - if (!node) { - return; - } - - const previouslyFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; - const getMenuItems = () => { - const menuNode = node.querySelector("[role='menu']") ?? node; - - return getFocusableElements(menuNode).filter((element) => element.getAttribute("role") === "menuitem" || element.tagName === "BUTTON"); - }; - const restoreFocusToTrigger = () => { - if (!previouslyFocusedElement?.isConnected) { - return; - } - - window.setTimeout(() => { - const activeElement = document.activeElement; - const hasExternalFocus = - activeElement instanceof HTMLElement && activeElement !== document.body && !node.contains(activeElement); - - if (hasExternalFocus) { - return; - } - - previouslyFocusedElement.focus(); - }, 0); - }; - const focusTimer = window.setTimeout(() => getMenuItems()[0]?.focus(), 0); - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - onClose(); - restoreFocusToTrigger(); - return; - } - - if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") { - return; - } - - const menuItems = getMenuItems(); - - if (menuItems.length === 0) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const currentIndex = menuItems.findIndex((element) => element === document.activeElement); - const nextIndex = - event.key === "Home" - ? 0 - : event.key === "End" - ? menuItems.length - 1 - : event.key === "ArrowDown" - ? (Math.max(currentIndex, 0) + 1) % menuItems.length - : (currentIndex <= 0 ? menuItems.length : currentIndex) - 1; - - menuItems[nextIndex]?.focus(); - }; - - node.addEventListener("keydown", handleKeyDown); - - return () => { - window.clearTimeout(focusTimer); - node.removeEventListener("keydown", handleKeyDown); - }; - }, [onClose, open, ref]); -}; - -const useFocusTrap = (ref: RefObject, active = true) => { - useEffect(() => { - if (!active) { - return; - } - - const node = ref.current; - - if (!node) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Tab") { - return; - } - - const focusableElements = getFocusableElements(node); - - if (focusableElements.length === 0) { - event.preventDefault(); - return; - } - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - if (event.shiftKey) { - if (document.activeElement === firstElement || !node.contains(document.activeElement)) { - event.preventDefault(); - lastElement.focus(); - } - - return; - } - - if (document.activeElement === lastElement) { - event.preventDefault(); - firstElement.focus(); - } - }; - - node.addEventListener("keydown", handleKeyDown); - - return () => node.removeEventListener("keydown", handleKeyDown); - }, [active, ref]); -}; - -const useModalLayerControls = ( - ref: RefObject, - onClose: () => void, - { - active = true, - closeOnEscape = true, - closeOnBrowserBack = false, - initialFocus = true, - restoreFocus = true, - }: { - active?: boolean; - closeOnEscape?: boolean; - closeOnBrowserBack?: boolean; - initialFocus?: boolean; - restoreFocus?: boolean; - } = {} -) => { - useFocusTrap(ref, active); - useBrowserBackLayer(active && closeOnBrowserBack, onClose); - - useEffect(() => { - if (!active) { - return; - } - - const previouslyFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; - const focusTimer = initialFocus - ? window.setTimeout(() => { - const node = ref.current; - - if (!node || node.contains(document.activeElement)) { - return; - } - - getFocusableElements(node)[0]?.focus(); - }, 0) - : null; - - const node = ref.current; - - if (!node) { - return () => { - if (focusTimer !== null) { - window.clearTimeout(focusTimer); - } - }; - } - - const handleKeyDown = (event: KeyboardEvent) => { - event.stopPropagation(); - - if (!closeOnEscape || event.key !== "Escape" || event.defaultPrevented) { - return; - } - - if (isTextEntryTarget(event.target)) { - const target = event.target; - - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { - if (target.value) { - return; - } - } - } - - event.preventDefault(); - onClose(); - }; - - node.addEventListener("keydown", handleKeyDown); - - return () => { - if (focusTimer !== null) { - window.clearTimeout(focusTimer); - } - - node.removeEventListener("keydown", handleKeyDown); - - if (restoreFocus && previouslyFocusedElement?.isConnected) { - previouslyFocusedElement.focus({ preventScroll: true }); - } - }; - }, [active, closeOnEscape, initialFocus, onClose, ref, restoreFocus]); -}; - -const useBottomSheetSwipeToClose = (sheetRef: RefObject, onClose: () => void) => { - const cleanupDragRef = useRef<(() => void) | null>(null); - const onCloseRef = useRef(onClose); - - useEffect(() => { - onCloseRef.current = onClose; - }, [onClose]); - - const cleanupDrag = useCallback(() => { - cleanupDragRef.current?.(); - cleanupDragRef.current = null; - }, []); - - const resetSheetPosition = useCallback(() => { - const sheet = sheetRef.current; - - if (!sheet) { - return; - } - - sheet.style.transition = "transform 180ms cubic-bezier(0.2, 0, 0, 1)"; - sheet.style.transform = ""; - sheet.style.willChange = ""; - - window.setTimeout(() => { - if (sheetRef.current === sheet) { - sheet.style.transition = ""; - } - }, 190); - }, [sheetRef]); - - const handlePointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.pointerType === "mouse" || !event.isPrimary) { - return; - } - - const sheet = sheetRef.current; - - if (!sheet) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - cleanupDrag(); - - const pointerId = event.pointerId; - const startX = event.clientX; - const startY = event.clientY; - const startTime = performance.now(); - let latestY = startY; - let dragging = false; - - const handlePointerMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) { - return; - } - - const deltaY = Math.max(0, moveEvent.clientY - startY); - const deltaX = Math.abs(moveEvent.clientX - startX); - - if (!dragging && deltaX > deltaY * 1.4 && deltaX > 18) { - return; - } - - if (deltaY <= 0) { - return; - } - - latestY = moveEvent.clientY; - dragging = true; - moveEvent.preventDefault(); - sheet.style.transition = "none"; - sheet.style.transform = `translate3d(0, ${deltaY}px, 0)`; - sheet.style.willChange = "transform"; - }; - - const handlePointerEnd = (endEvent: PointerEvent) => { - if (endEvent.pointerId !== pointerId) { - return; - } - - const deltaY = Math.max(0, latestY - startY); - const elapsedMs = Math.max(performance.now() - startTime, 1); - const velocity = deltaY / elapsedMs; - - cleanupDrag(); - - if (deltaY >= BOTTOM_SHEET_DISMISS_DISTANCE_PX || velocity >= BOTTOM_SHEET_DISMISS_VELOCITY_PX_PER_MS) { - sheet.style.transition = "transform 160ms cubic-bezier(0.32, 0.72, 0, 1)"; - sheet.style.transform = "translate3d(0, 110%, 0)"; - sheet.style.willChange = "transform"; - window.setTimeout(() => onCloseRef.current(), 120); - return; - } - - resetSheetPosition(); - }; - - const handlePointerCancel = (cancelEvent: PointerEvent) => { - if (cancelEvent.pointerId !== pointerId) { - return; - } - - cleanupDrag(); - resetSheetPosition(); - }; - - window.addEventListener("pointermove", handlePointerMove, { passive: false }); - window.addEventListener("pointerup", handlePointerEnd); - window.addEventListener("pointercancel", handlePointerCancel); - - cleanupDragRef.current = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerEnd); - window.removeEventListener("pointercancel", handlePointerCancel); - }; - }, - [cleanupDrag, resetSheetPosition, sheetRef] - ); - - useEffect(() => cleanupDrag, [cleanupDrag]); - - return handlePointerDown; -}; +import { LoginScreen } from "@/components/LoginScreen"; +import { WorkspaceApp } from "@/components/WorkspaceApp"; +import type { AuthSession } from "@edgeever/shared"; + +const AuthLoadingScreen = () => ( +
+ EdgeEver +
+); export const App = () => { const queryClient = useQueryClient(); + const sessionQuery = useQuery({ queryKey: ["auth", "session"], queryFn: () => api.getSession(), retry: false, }); + const loginMutation = useMutation({ mutationFn: api.login, onSuccess: (session) => { @@ -707,6 +27,7 @@ export const App = () => { queryClient.setQueryData(["auth", "session"], session); }, }); + const logoutMutation = useMutation({ mutationFn: api.logout, onSuccess: () => { @@ -759,7816 +80,3 @@ export const App = () => { /> ); }; - -const WorkspaceApp = ({ - authRequired, - user, - isLoggingOut, - onLogout, -}: { - authRequired: boolean; - user: AuthUser | null; - isLoggingOut: boolean; - onLogout: () => void; -}) => { - const queryClient = useQueryClient(); - const [activePane, setActivePane] = useState("memos"); - const [memoView, setMemoView] = useState("notebook"); - const [selectedNotebookId, setSelectedNotebookId] = useState(null); - const [selectedMemoId, setSelectedMemoId] = useState(null); - const [selectedMemoIds, setSelectedMemoIds] = useState>(new Set()); - const [memoSelectionMode, setMemoSelectionMode] = useState(false); - const [memoDeleteConfirmation, setMemoDeleteConfirmation] = useState(null); - const [notebookNameDialog, setNotebookNameDialog] = useState(null); - const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState(null); - const [appNoticeDialog, setAppNoticeDialog] = useState(null); - const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); - const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); - const [assetsOpen, setAssetsOpen] = useState(false); - const [tagsOpen, setTagsOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - const [templatesOpen, setTemplatesOpen] = useState(false); - const [mobileNotebookPickerOpen, setMobileNotebookPickerOpen] = useState(false); - const [mobileBottomNavActive, setMobileBottomNavActive] = useState("home"); - const [mobileSearchFocusToken, setMobileSearchFocusToken] = useState(0); - const [memoListWidth, setMemoListWidth] = useState(readMemoListWidthPreference); - const [search, setSearch] = useState(""); - const [syncSummary, setSyncSummary] = useState(emptySyncQueueSummary); - const [isOnline, setIsOnline] = useState(() => (typeof navigator === "undefined" ? true : navigator.onLine)); - const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false); - - const runQueuedSync = useCallback(async () => { - if (typeof navigator !== "undefined" && !navigator.onLine) { - setIsOnline(false); - return; - } - - setIsSyncingQueuedChanges(true); - - try { - await syncQueuedChanges({ - onSynced: async (memo) => { - queryClient.setQueryData(["memo", memo.id], { memo }); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["memos"] }), - queryClient.invalidateQueries({ queryKey: ["memo", memo.id] }), - ]); - }, - }); - } finally { - setIsSyncingQueuedChanges(false); - } - }, [queryClient]); - - const notebooksQuery = useQuery({ - queryKey: ["notebooks"], - queryFn: () => api.listNotebooks(), - }); - - const notebooks = notebooksQuery.data?.notebooks ?? []; - const defaultMemoNotebookId = - notebooks.find((notebook) => notebook.slug === "inbox")?.id ?? selectedNotebookId ?? notebooks[0]?.id ?? null; - const canCreateMemo = Boolean(defaultMemoNotebookId && memoView !== "trash"); - const memoSelectionModeActive = memoSelectionMode || selectedMemoIds.size > 0; - const mobileSearchActive = mobileBottomNavActive === "search"; - const workspaceBackTargetActive = Boolean( - appNoticeDialog || - notebookDeleteConfirmation || - notebookNameDialog || - memoDeleteConfirmation || - mobileNotebookPickerOpen || - mobileSearchActive || - templatesOpen || - settingsOpen || - tagsOpen || - assetsOpen || - memoSelectionModeActive || - activePane === "editor" || - activePane === "notebooks" - ); - - const clearMemoSelection = useCallback(() => { - setSelectedMemoIds(new Set()); - setMemoSelectionMode(false); - }, []); - - const replaceMemoSelection = useCallback((memoIds: string[]) => { - setSelectedMemoIds(new Set(memoIds)); - setMemoSelectionMode(true); - }, []); - - const enterMemoSelectionMode = useCallback(() => { - setMemoSelectionMode(true); - setActivePane("memos"); - }, []); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setMultiSelectKeyDown(false); - return; - } - - if (isTextEntryTarget(event.target)) { - setMultiSelectKeyDown(false); - return; - } - - if (event.ctrlKey || event.metaKey || event.key === "Control" || event.key === "Meta") { - setMultiSelectKeyDown(true); - } - }; - - const handleKeyUp = (event: KeyboardEvent) => { - if (isTextEntryTarget(event.target)) { - setMultiSelectKeyDown(false); - return; - } - - setMultiSelectKeyDown(event.ctrlKey || event.metaKey); - }; - - const handleBlur = () => setMultiSelectKeyDown(false); - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - window.addEventListener("blur", handleBlur); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - window.removeEventListener("blur", handleBlur); - }; - }, []); - - useEffect(() => { - writeImageCompressionPreference(imageCompressionEnabled); - }, [imageCompressionEnabled]); - - useEffect(() => observeSyncQueue(setSyncSummary), []); - - useEffect(() => { - const updateOnlineState = () => { - const online = navigator.onLine; - setIsOnline(online); - - if (online) { - void runQueuedSync(); - } - }; - - window.addEventListener("online", updateOnlineState); - window.addEventListener("offline", updateOnlineState); - updateOnlineState(); - - return () => { - window.removeEventListener("online", updateOnlineState); - window.removeEventListener("offline", updateOnlineState); - }; - }, [runQueuedSync]); - - useEffect(() => { - if (syncSummary.total === 0) { - return; - } - - const timer = window.setInterval(() => { - void runQueuedSync(); - }, 15_000); - - return () => window.clearInterval(timer); - }, [runQueuedSync, syncSummary.total]); - - const memosQuery = useQuery({ - queryKey: ["memos", memoView, selectedNotebookId, search], - queryFn: () => - api.listMemos({ - notebookId: memoView === "notebook" ? selectedNotebookId : null, - q: search, - trash: memoView === "trash", - }), - }); - - const memos = memosQuery.data?.memos ?? []; - const selectedMemoIndex = selectedMemoId ? memos.findIndex((memo) => memo.id === selectedMemoId) : -1; - const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null; - const nextMemoId = - selectedMemoIndex >= 0 && selectedMemoIndex < memos.length - 1 ? memos[selectedMemoIndex + 1]?.id : null; - - useEffect(() => { - if (memos.length === 0) { - setSelectedMemoId(null); - return; - } - - if (!selectedMemoId || !memos.some((memo) => memo.id === selectedMemoId)) { - setSelectedMemoId(memos[0].id); - } - }, [memos, selectedMemoId]); - - const memoQuery = useQuery({ - queryKey: ["memo", selectedMemoId, memoView], - queryFn: () => api.getMemo(selectedMemoId as string, { includeDeleted: memoView === "trash" }), - enabled: Boolean(selectedMemoId), - }); - - const createNotebookMutation = useMutation({ - mutationFn: api.createNotebook, - onSuccess: async (data) => { - await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); - setSelectedNotebookId(data.notebook.id); - setActivePane("memos"); - }, - }); - - const updateNotebookMutation = useMutation({ - mutationFn: ({ - notebookId, - payload, - }: { - notebookId: string; - payload: { name?: string; parentId?: string | null; sortOrder?: number }; - }) => api.updateNotebook(notebookId, payload), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); - }, - }); - - const deleteNotebookMutation = useMutation({ - mutationFn: api.deleteNotebook, - onSuccess: async (_data, notebookId) => { - if (selectedNotebookId === notebookId) { - setSelectedNotebookId(null); - setSelectedMemoId(null); - } - - await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - }, - }); - - const createMemoMutation = useMutation({ - mutationFn: api.createMemo, - onSuccess: async (data) => { - setMemoView("notebook"); - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); - setSelectedMemoId(data.memo.id); - setActivePane("editor"); - }, - }); - - const mergeMutation = useMutation({ - mutationFn: api.mergeMemos, - onSuccess: async (data) => { - clearMemoSelection(); - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); - setSelectedMemoId(data.memo.id); - setActivePane("editor"); - }, - }); - - const moveMemosMutation = useMutation({ - mutationFn: api.moveMemos, - onSuccess: async () => { - clearMemoSelection(); - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - await queryClient.invalidateQueries({ queryKey: ["memo"] }); - }, - }); - - const pinMemosMutation = useMutation({ - mutationFn: async ({ memoIds, isPinned }: { memoIds: string[]; isPinned: boolean }) => - Promise.all(memoIds.map((memoId) => api.updateMemo(memoId, { isPinned }))), - onMutate: async ({ memoIds, isPinned }) => { - await Promise.all([ - queryClient.cancelQueries({ queryKey: ["memos"] }), - queryClient.cancelQueries({ queryKey: ["memo"] }), - ]); - - const memoIdSet = new Set(memoIds); - const previousMemoQueries = queryClient.getQueriesData<{ memos: MemoSummary[] }>({ queryKey: ["memos"] }); - const previousMemoDetailQueries = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }); - - queryClient.setQueriesData<{ memos: MemoSummary[] }>({ queryKey: ["memos"] }, (current) => - current - ? { - memos: current.memos.map((memo) => (memoIdSet.has(memo.id) ? { ...memo, isPinned } : memo)), - } - : current - ); - queryClient.setQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }, (current) => - current && memoIdSet.has(current.memo.id) - ? { - memo: { ...current.memo, isPinned }, - } - : current - ); - - return { previousMemoQueries, previousMemoDetailQueries }; - }, - onError: (_error, _variables, context) => { - context?.previousMemoQueries.forEach(([queryKey, data]) => { - queryClient.setQueryData(queryKey, data); - }); - context?.previousMemoDetailQueries.forEach(([queryKey, data]) => { - queryClient.setQueryData(queryKey, data); - }); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - await queryClient.invalidateQueries({ queryKey: ["memo"] }); - }, - }); - - const deleteMemosMutation = useMutation({ - mutationFn: api.deleteMemos, - onSuccess: async (_, variables) => { - const deletedMemoIds = new Set(variables.memoIds); - - clearMemoSelection(); - - if (selectedMemoId && deletedMemoIds.has(selectedMemoId)) { - setSelectedMemoId(null); - setActivePane("memos"); - } - - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["memos"] }), - queryClient.invalidateQueries({ queryKey: ["memo"] }), - queryClient.invalidateQueries({ queryKey: ["resources"] }), - ]); - }, - }); - - const deleteMemoMutation = useMutation({ - mutationFn: ({ memoId, permanent }: { memoId: string; permanent?: boolean }) => - api.deleteMemo(memoId, { permanent }), - onSuccess: async (_data, variables) => { - if (selectedMemoId === variables.memoId) { - setSelectedMemoId(null); - setActivePane("memos"); - } - - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - }, - }); - - const restoreMemoMutation = useMutation({ - mutationFn: api.restoreMemo, - onSuccess: async (data) => { - setMemoView("notebook"); - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); - setSelectedNotebookId(data.memo.notebookId); - setSelectedMemoId(data.memo.id); - setActivePane("editor"); - }, - }); - - const selectedNotebook = notebooks.find((notebook) => notebook.id === selectedNotebookId) ?? null; - const selectedMemo = memoQuery.data?.memo ?? null; - - const handleCreateNotebook = (parentId?: string | null) => { - setNotebookNameDialog({ mode: "create", parentId: parentId ?? null }); - }; - - const handleRenameNotebook = (notebook: Notebook) => { - setNotebookNameDialog({ mode: "rename", notebook }); - }; - - const handleSubmitNotebookName = (name: string) => { - if (!notebookNameDialog) { - return; - } - - const trimmedName = name.trim(); - - if (!trimmedName) { - return; - } - - if (notebookNameDialog.mode === "create") { - createNotebookMutation.mutate( - { name: trimmedName, parentId: notebookNameDialog.parentId }, - { onSuccess: () => setNotebookNameDialog(null) } - ); - return; - } - - if (trimmedName === notebookNameDialog.notebook.name) { - setNotebookNameDialog(null); - return; - } - - updateNotebookMutation.mutate( - { notebookId: notebookNameDialog.notebook.id, payload: { name: trimmedName } }, - { onSuccess: () => setNotebookNameDialog(null) } - ); - }; - - const handleDeleteNotebook = (notebook: Notebook) => { - if (notebook.slug === "inbox") { - setAppNoticeDialog({ - title: "等待分类不能删除", - description: "等待分类是默认笔记本,用来保证新笔记始终有归属。", - }); - return; - } - - setNotebookDeleteConfirmation(notebook); - }; - - const handleCreateMemo = (template?: MemoTemplate) => { - if (!defaultMemoNotebookId || memoView === "trash") { - return; - } - - setTemplatesOpen(false); - setMobileBottomNavActive("home"); - createMemoMutation.mutate({ - notebookId: defaultMemoNotebookId, - title: template?.title ?? DEFAULT_MEMO_TITLE, - contentMarkdown: template?.contentMarkdown ?? "", - tags: template?.tags ?? [], - }); - }; - - const handleCreateChecklistMemo = () => { - const checklistTemplate = MEMO_TEMPLATES.find((template) => template.id === "checklist"); - - handleCreateMemo(checklistTemplate); - }; - - const handleMoveNotebook = ( - notebookId: string, - targetNotebookId: string, - position: NotebookDropPosition - ) => { - if (notebookId === targetNotebookId) { - return; - } - - const target = notebooks.find((notebook) => notebook.id === targetNotebookId); - - if (!target) { - return; - } - - updateNotebookMutation.mutate({ - notebookId, - payload: { - parentId: position === "inside" ? target.id : target.parentId, - sortOrder: position === "inside" ? Date.now() : getNotebookDropSortOrder(notebooks, target, position), - }, - }); - }; - - const getMemoIdsNeedingMove = (memoIds: string[], targetNotebookId: string) => { - const memoNotebookMap = new Map(memos.map((memo) => [memo.id, memo.notebookId])); - - return Array.from(new Set(memoIds.filter(Boolean))).filter((memoId) => memoNotebookMap.get(memoId) !== targetNotebookId); - }; - - const handleMoveSelectedMemos = (targetNotebookId: string) => { - if (selectedMemoIds.size === 0 || memoView === "trash") { - return; - } - - const memoIds = getMemoIdsNeedingMove(Array.from(selectedMemoIds), targetNotebookId); - - if (memoIds.length === 0) { - return; - } - - moveMemosMutation.mutate({ - memoIds, - notebookId: targetNotebookId, - }); - }; - - const handleMoveDraggedMemos = (memoIds: string[], targetNotebookId: string) => { - if (memoView === "trash" || moveMemosMutation.isPending) { - return; - } - - const movableMemoIds = getMemoIdsNeedingMove(memoIds, targetNotebookId); - - if (movableMemoIds.length === 0) { - return; - } - - moveMemosMutation.mutate({ - memoIds: movableMemoIds, - notebookId: targetNotebookId, - }); - }; - - const handleMoveMemoFromList = (memoId: string, targetNotebookId: string) => { - if (memoView === "trash") { - return; - } - - const memoIds = getMemoIdsNeedingMove([memoId], targetNotebookId); - - if (memoIds.length === 0) { - return; - } - - moveMemosMutation.mutate({ - memoIds, - notebookId: targetNotebookId, - }); - }; - - const handleToggleMemoPinned = (memo: MemoSummary) => { - if (memoView === "trash") { - return; - } - - pinMemosMutation.mutate({ - memoIds: [memo.id], - isPinned: !memo.isPinned, - }); - }; - - const handlePinSelectedMemos = (isPinned: boolean) => { - if (selectedMemoIds.size === 0 || memoView === "trash") { - return; - } - - pinMemosMutation.mutate( - { - memoIds: Array.from(selectedMemoIds), - isPinned, - }, - { - onSuccess: clearMemoSelection, - } - ); - }; - - const handleMerge = () => { - if (selectedMemoIds.size < 2 || memoView === "trash") { - return; - } - - mergeMutation.mutate({ - memoIds: Array.from(selectedMemoIds), - notebookId: selectedNotebookId ?? undefined, - }); - }; - - const handleDeleteSelectedMemos = () => { - if (selectedMemoIds.size === 0) { - return; - } - - if (memoView !== "trash") { - deleteMemosMutation.mutate({ - memoIds: Array.from(selectedMemoIds), - permanent: false, - }); - return; - } - - setMemoDeleteConfirmation({ - kind: "bulk", - memoIds: Array.from(selectedMemoIds), - permanent: true, - }); - }; - - const handleDeleteMemoFromList = (memoId: string) => { - if (memoView !== "trash") { - deleteMemoMutation.mutate({ memoId, permanent: false }); - return; - } - - setMemoDeleteConfirmation({ kind: "single", memoIds: [memoId], permanent: true }); - }; - - const handleConfirmMemoDeletion = () => { - if (!memoDeleteConfirmation) { - return; - } - - const { kind, memoIds, permanent } = memoDeleteConfirmation; - - setMemoDeleteConfirmation(null); - - if (kind === "bulk") { - deleteMemosMutation.mutate({ memoIds, permanent }); - return; - } - - const [memoId] = memoIds; - - if (memoId) { - deleteMemoMutation.mutate({ memoId, permanent }); - } - }; - - const handleRestoreMemoFromList = (memoId: string) => { - restoreMemoMutation.mutate(memoId); - }; - - const handleSelectNotebook = (notebookId: string) => { - setMemoView("notebook"); - setSelectedNotebookId(notebookId); - setMobileBottomNavActive("home"); - clearMemoSelection(); - setMobileNotebookPickerOpen(false); - setActivePane("memos"); - }; - - const handleSelectAllMemos = () => { - setMemoView("notebook"); - setSelectedNotebookId(null); - setMobileBottomNavActive("home"); - clearMemoSelection(); - setMobileNotebookPickerOpen(false); - setActivePane("memos"); - }; - - const handleMobileHome = () => { - if (memoView === "trash") { - setMemoView("notebook"); - } - - setMobileBottomNavActive("home"); - setSelectedNotebookId(null); - setSearch(""); - clearMemoSelection(); - setActivePane("memos"); - }; - - const handleMobileSearch = () => { - setMobileBottomNavActive("search"); - setActivePane("memos"); - setMobileSearchFocusToken((value) => value + 1); - }; - - const handleCancelMobileSearch = () => { - setSearch(""); - setMobileBottomNavActive("home"); - clearMemoSelection(); - setActivePane("memos"); - }; - - const clearHiddenMobileSearch = () => { - if (!isDesktopViewport()) { - setSearch(""); - } - }; - - const handleOpenAssets = () => { - clearHiddenMobileSearch(); - setAssetsOpen(true); - }; - - const handleOpenTags = () => { - clearHiddenMobileSearch(); - setTagsOpen(true); - }; - - const handleOpenTemplates = () => { - clearHiddenMobileSearch(); - setMobileBottomNavActive("templates"); - setTemplatesOpen(true); - }; - - const handleOpenSettings = () => { - clearHiddenMobileSearch(); - setMobileBottomNavActive("settings"); - setSettingsOpen(true); - }; - - const handleCloseAssets = () => { - setAssetsOpen(false); - setMobileBottomNavActive("home"); - }; - - const handleCloseTemplates = () => { - setTemplatesOpen(false); - setMobileBottomNavActive("home"); - }; - - const handleCloseSettings = () => { - setSettingsOpen(false); - setMobileBottomNavActive("home"); - }; - - const handleWorkspaceBackRequest = useCallback(() => { - if (appNoticeDialog) { - setAppNoticeDialog(null); - return true; - } - - if (notebookDeleteConfirmation) { - if (!deleteNotebookMutation.isPending) { - setNotebookDeleteConfirmation(null); - } - return true; - } - - if (notebookNameDialog) { - if (!createNotebookMutation.isPending && !updateNotebookMutation.isPending) { - setNotebookNameDialog(null); - } - return true; - } - - if (memoDeleteConfirmation) { - if (!deleteMemosMutation.isPending && !deleteMemoMutation.isPending) { - setMemoDeleteConfirmation(null); - } - return true; - } - - if (mobileNotebookPickerOpen) { - setMobileNotebookPickerOpen(false); - return true; - } - - if (mobileSearchActive) { - handleCancelMobileSearch(); - return true; - } - - if (templatesOpen) { - handleCloseTemplates(); - return true; - } - - if (settingsOpen) { - handleCloseSettings(); - return true; - } - - if (tagsOpen) { - setTagsOpen(false); - return true; - } - - if (assetsOpen) { - handleCloseAssets(); - return true; - } - - if (memoSelectionModeActive) { - clearMemoSelection(); - return true; - } - - if (activePane === "editor" || activePane === "notebooks") { - setActivePane("memos"); - return true; - } - - return false; - }, [ - activePane, - appNoticeDialog, - assetsOpen, - clearMemoSelection, - createNotebookMutation.isPending, - deleteMemoMutation.isPending, - deleteMemosMutation.isPending, - deleteNotebookMutation.isPending, - handleCloseAssets, - handleCloseSettings, - handleCloseTemplates, - handleCancelMobileSearch, - memoDeleteConfirmation, - memoSelectionModeActive, - mobileNotebookPickerOpen, - mobileSearchActive, - notebookDeleteConfirmation, - notebookNameDialog, - settingsOpen, - tagsOpen, - templatesOpen, - updateNotebookMutation.isPending, - ]); - - useBrowserBackLayer(workspaceBackTargetActive, handleWorkspaceBackRequest); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isBackShortcut = - event.key === "Escape" || - event.key === "BrowserBack" || - (!event.ctrlKey && !event.metaKey && event.altKey && event.key === "ArrowLeft"); - - if (!isBackShortcut || event.defaultPrevented || isTextEntryTarget(event.target)) { - return; - } - - if (!handleWorkspaceBackRequest()) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - }; - - window.addEventListener("keydown", handleKeyDown); - - return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleWorkspaceBackRequest]); - - useEffect(() => { - const handleWorkspaceShortcut = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.altKey || (!event.ctrlKey && !event.metaKey)) { - return; - } - - const key = event.key.toLowerCase(); - - if (key !== "f" && key !== "n") { - return; - } - - const transientLayerOpen = Boolean( - appNoticeDialog || - assetsOpen || - memoDeleteConfirmation || - mobileNotebookPickerOpen || - notebookDeleteConfirmation || - notebookNameDialog || - settingsOpen || - tagsOpen || - templatesOpen - ); - - if (transientLayerOpen) { - return; - } - - if (key === "f") { - event.preventDefault(); - if (event.shiftKey) { - setSearch(""); - } - clearMemoSelection(); - handleMobileSearch(); - return; - } - - event.preventDefault(); - - if (event.shiftKey) { - if (!createNotebookMutation.isPending) { - handleCreateNotebook(null); - } - - return; - } - - if (canCreateMemo && !createMemoMutation.isPending) { - handleCreateMemo(); - } - }; - - window.addEventListener("keydown", handleWorkspaceShortcut); - - return () => window.removeEventListener("keydown", handleWorkspaceShortcut); - }, [ - assetsOpen, - appNoticeDialog, - canCreateMemo, - clearMemoSelection, - createNotebookMutation.isPending, - createMemoMutation.isPending, - handleCreateNotebook, - handleCreateMemo, - handleMobileSearch, - memoDeleteConfirmation, - mobileNotebookPickerOpen, - notebookDeleteConfirmation, - notebookNameDialog, - settingsOpen, - tagsOpen, - templatesOpen, - ]); - - const handleMemoListResizePointerDown = (event: ReactPointerEvent) => { - if (!window.matchMedia("(min-width: 1024px)").matches) { - return; - } - - event.preventDefault(); - event.currentTarget.focus({ preventScroll: true }); - const startX = event.clientX; - const startWidth = memoListWidth; - - const handlePointerMove = (moveEvent: PointerEvent) => { - const nextWidth = clampMemoListWidth(startWidth + moveEvent.clientX - startX); - setMemoListWidth(nextWidth); - writeMemoListWidthPreference(nextWidth); - }; - - const handlePointerUp = () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp); - }; - - const handleResetMemoListWidth = () => { - setMemoListWidth(DEFAULT_MEMO_LIST_WIDTH_PX); - writeMemoListWidthPreference(DEFAULT_MEMO_LIST_WIDTH_PX); - }; - - const updateMemoListWidth = (width: number) => { - const nextWidth = clampMemoListWidth(width); - - setMemoListWidth(nextWidth); - writeMemoListWidthPreference(nextWidth); - }; - - const handleMemoListResizeKeyDown = (event: ReactKeyboardEvent) => { - if (!window.matchMedia("(min-width: 1024px)").matches) { - return; - } - - const step = event.shiftKey ? 48 : 16; - let nextWidth: number | null = null; - - if (event.key === "ArrowLeft") { - nextWidth = memoListWidth - step; - } else if (event.key === "ArrowRight") { - nextWidth = memoListWidth + step; - } else if (event.key === "Home") { - nextWidth = MIN_MEMO_LIST_WIDTH_PX; - } else if (event.key === "End") { - nextWidth = MAX_MEMO_LIST_WIDTH_PX; - } else if (event.key === "Enter" || event.key === " ") { - nextWidth = DEFAULT_MEMO_LIST_WIDTH_PX; - } - - if (nextWidth === null) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - updateMemoListWidth(nextWidth); - }; - - return ( -
-
-
- - -
- setMobileNotebookPickerOpen(true)} - onSearch={setSearch} - onCancelMobileSearch={handleCancelMobileSearch} - onCreateChecklist={handleCreateChecklistMemo} - onCreateMemo={handleCreateMemo} - onClearSelection={clearMemoSelection} - onEnterSelectionMode={enterMemoSelectionMode} - onReplaceSelection={replaceMemoSelection} - onOpenAssets={handleOpenAssets} - onOpenTags={handleOpenTags} - onOpenTemplates={handleOpenTemplates} - onOpenSettings={handleOpenSettings} - onOpenTrash={() => { - setMemoView("trash"); - setSelectedNotebookId(null); - setMobileBottomNavActive("home"); - clearMemoSelection(); - setSelectedMemoId(null); - setActivePane("memos"); - }} - onOpenMemo={(memoId) => { - setSelectedMemoId(memoId); - setActivePane("editor"); - }} - onToggleMemo={(memoId, rangeMemoIds) => { - setMemoSelectionMode(true); - setSelectedMemoIds((current) => { - if (!rangeMemoIds?.length) { - return toggleMemoSelection(current, memoId); - } - - const next = new Set(current); - - for (const rangeMemoId of rangeMemoIds) { - next.add(rangeMemoId); - } - - return next; - }); - }} - onMerge={handleMerge} - onDeleteMemo={handleDeleteMemoFromList} - onRestoreMemo={handleRestoreMemoFromList} - onMoveMemo={handleMoveMemoFromList} - onTogglePinMemo={handleToggleMemoPinned} - onPinSelectedMemos={handlePinSelectedMemos} - onDeleteSelectedMemos={handleDeleteSelectedMemos} - onMoveSelectedMemos={handleMoveSelectedMemos} - onSyncQueuedChanges={() => void runQueuedSync()} - /> -
-
- -
- setActivePane("memos")} - onOpenNextMemo={() => { - if (nextMemoId) { - setSelectedMemoId(nextMemoId); - } - }} - onOpenPreviousMemo={() => { - if (previousMemoId) { - setSelectedMemoId(previousMemoId); - } - }} - onSaved={async (memo) => { - queryClient.setQueryData(["memo", memo.id], { memo }); - await queryClient.invalidateQueries({ queryKey: ["memos"] }); - }} - onDeleted={async (memoId) => { - deleteMemoMutation.mutate({ memoId, permanent: false }); - }} - onPermanentDeleted={async (memoId) => { - setMemoDeleteConfirmation({ kind: "single", memoIds: [memoId], permanent: true }); - }} - onRestored={async (memoId) => { - await restoreMemoMutation.mutateAsync(memoId); - }} - /> -
-
-
- {assetsOpen ? : null} - {tagsOpen ? setTagsOpen(false)} /> : null} - {settingsOpen ? : null} - {templatesOpen ? ( - - ) : null} - {memoDeleteConfirmation ? ( - setMemoDeleteConfirmation(null)} - onConfirm={handleConfirmMemoDeletion} - /> - ) : null} - {notebookNameDialog ? ( - setNotebookNameDialog(null)} - onSubmit={handleSubmitNotebookName} - /> - ) : null} - {notebookDeleteConfirmation ? ( - setNotebookDeleteConfirmation(null)} - onConfirm={() => { - deleteNotebookMutation.mutate(notebookDeleteConfirmation.id, { - onSuccess: () => setNotebookDeleteConfirmation(null), - }); - }} - /> - ) : null} - {appNoticeDialog ? ( - setAppNoticeDialog(null)} - onConfirm={() => setAppNoticeDialog(null)} - /> - ) : null} - {activePane !== "editor" && !memoSelectionModeActive ? ( - - ) : null} - {mobileNotebookPickerOpen ? ( - setMobileNotebookPickerOpen(false)} - onSelectAll={handleSelectAllMemos} - onSelect={handleSelectNotebook} - /> - ) : null} -
- ); -}; - -const AuthLoadingScreen = () => ( -
- EdgeEver -
-); - -const readImageCompressionPreference = () => { - try { - return window.localStorage.getItem(IMAGE_COMPRESSION_STORAGE_KEY) !== "false"; - } catch { - return true; - } -}; - -const writeImageCompressionPreference = (enabled: boolean) => { - try { - window.localStorage.setItem(IMAGE_COMPRESSION_STORAGE_KEY, enabled ? "true" : "false"); - } catch { - // Local storage can be unavailable in private or restricted browser contexts. - } -}; - -const readMemoListDensityPreference = (): MemoListDensity => { - try { - const density = window.localStorage.getItem(MEMO_LIST_DENSITY_STORAGE_KEY); - return density === "compact" ? "compact" : "preview"; - } catch { - return "preview"; - } -}; - -const writeMemoListDensityPreference = (density: MemoListDensity) => { - try { - window.localStorage.setItem(MEMO_LIST_DENSITY_STORAGE_KEY, density); - } catch { - // Local storage can be unavailable in private or restricted browser contexts. - } -}; - -const clampMemoListWidth = (width: number) => - Math.min(MAX_MEMO_LIST_WIDTH_PX, Math.max(MIN_MEMO_LIST_WIDTH_PX, Math.round(width))); - -const readMemoListWidthPreference = () => { - try { - const width = Number(window.localStorage.getItem(MEMO_LIST_WIDTH_STORAGE_KEY)); - return Number.isFinite(width) ? clampMemoListWidth(width) : DEFAULT_MEMO_LIST_WIDTH_PX; - } catch { - return DEFAULT_MEMO_LIST_WIDTH_PX; - } -}; - -const writeMemoListWidthPreference = (width: number) => { - try { - window.localStorage.setItem(MEMO_LIST_WIDTH_STORAGE_KEY, String(clampMemoListWidth(width))); - } catch { - // Local storage can be unavailable in private or restricted browser contexts. - } -}; - -const LoginScreen = ({ - error, - isSubmitting, - onSubmit, -}: { - error: string | null; - isSubmitting: boolean; - onSubmit: (payload: { username: string; password: string }) => void; -}) => { - const [username, setUsername] = useState("admin"); - const [password, setPassword] = useState(""); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - - if (!username.trim() || !password) { - return; - } - - onSubmit({ username: username.trim(), password }); - }; - - return ( -
-
-
-
- -
-
-

登录 EdgeEver

-

自托管笔记工作区

-
-
- -
- - - - - {error ? ( -
{error}
- ) : null} - - -
-
-
- ); -}; - -const toggleMemoSelection = (current: Set, memoId: string) => { - const next = new Set(current); - - if (next.has(memoId)) { - next.delete(memoId); - } else { - next.add(memoId); - } - - return next; -}; - -const hasMemoDragData = (dataTransfer: DataTransfer) => Array.from(dataTransfer.types).includes(MEMO_DRAG_MIME); - -const hasNotebookDragData = (dataTransfer: DataTransfer) => Array.from(dataTransfer.types).includes(NOTEBOOK_DRAG_MIME); - -const hasEdgeEverDragData = (dataTransfer: DataTransfer) => hasMemoDragData(dataTransfer) || hasNotebookDragData(dataTransfer); - -const getMemoDragIds = (dataTransfer: DataTransfer) => { - if (!hasMemoDragData(dataTransfer)) { - return []; - } - - try { - const parsed = JSON.parse(dataTransfer.getData(MEMO_DRAG_MIME)) as unknown; - - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.filter((value): value is string => typeof value === "string" && Boolean(value.trim())); - } catch { - return []; - } -}; - -const setMemoDragPreview = (dataTransfer: DataTransfer, label: string) => { - const dragImage = document.createElement("div"); - - dragImage.textContent = label; - Object.assign(dragImage.style, { - position: "fixed", - top: "-9999px", - left: "-9999px", - maxWidth: "220px", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - border: "1px solid #cbd9c4", - borderRadius: "6px", - background: "#f3f7f1", - boxShadow: "0 14px 30px rgba(15, 23, 42, 0.16)", - color: "#1f2a1f", - font: "600 13px system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", - padding: "8px 10px", - pointerEvents: "none", - zIndex: "9999", - }); - - document.body.appendChild(dragImage); - dataTransfer.setDragImage(dragImage, 16, 18); - window.setTimeout(() => dragImage.remove(), 0); -}; - -const getNotebookDropPosition = (event: DragEvent): NotebookDropPosition => { - const rect = event.currentTarget.getBoundingClientRect(); - const offset = event.clientY - rect.top; - - if (offset < rect.height * 0.28) { - return "before"; - } - - if (offset > rect.height * 0.72) { - return "after"; - } - - return "inside"; -}; - -const getNotebookDropSortOrder = ( - notebooks: Notebook[], - target: Notebook, - position: Exclude -) => { - const siblings = notebooks - .filter((notebook) => notebook.parentId === target.parentId) - .sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name)); - const targetIndex = siblings.findIndex((notebook) => notebook.id === target.id); - const insertionIndex = targetIndex < 0 ? siblings.length : position === "before" ? targetIndex : targetIndex + 1; - const previous = siblings[insertionIndex - 1]; - const next = siblings[insertionIndex]; - - if (!previous && !next) { - return target.sortOrder + (position === "before" ? -1000 : 1000); - } - - if (!previous) { - return next.sortOrder - 1000; - } - - if (!next) { - return previous.sortOrder + 1000; - } - - return Math.floor((previous.sortOrder + next.sortOrder) / 2); -}; - -const focusNotebookTreeButton = ( - currentButton: HTMLButtonElement, - direction: "next" | "previous" | "first" | "last" -) => { - const tree = currentButton.closest("[data-notebook-tree]"); - - if (!tree) { - return; - } - - const buttons = Array.from(tree.querySelectorAll("[data-notebook-tree-button]")).filter( - (button) => !button.disabled - ); - const currentIndex = buttons.indexOf(currentButton); - - if (currentIndex < 0 || buttons.length === 0) { - return; - } - - const nextIndex = - direction === "first" - ? 0 - : direction === "last" - ? buttons.length - 1 - : direction === "next" - ? Math.min(currentIndex + 1, buttons.length - 1) - : Math.max(currentIndex - 1, 0); - const nextButton = buttons[nextIndex]; - - if (!nextButton || nextButton === currentButton) { - return; - } - - nextButton.focus({ preventScroll: true }); - nextButton.scrollIntoView({ block: "nearest" }); -}; - -const NotebookPane = ({ - authRequired, - user, - notebooks, - selectedNotebookId, - view, - isLoading, - canCreateMemo, - isCreatingMemo, - onSelect, - onCreateMemo, - onCreateNotebook, - onRenameNotebook, - onDeleteNotebook, - onMoveNotebook, - onMoveMemos, - onBackToList, - onLogout, - isLoggingOut, - imageCompressionEnabled, - onImageCompressionChange, - syncSummary, - isOnline, - isSyncingQueuedChanges, - onSyncQueuedChanges, - onOpenAssets, - onOpenTags, - onOpenSettings, - onOpenTrash, -}: { - authRequired: boolean; - user: AuthUser | null; - notebooks: Notebook[]; - selectedNotebookId: string | null; - view: MemoView; - isLoading: boolean; - canCreateMemo: boolean; - isCreatingMemo: boolean; - onSelect: (notebookId: string) => void; - onCreateMemo: () => void; - onCreateNotebook: (parentId?: string | null) => void; - onRenameNotebook: (notebook: Notebook) => void; - onDeleteNotebook: (notebook: Notebook) => void; - onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void; - onMoveMemos: (memoIds: string[], targetNotebookId: string) => void; - onBackToList: () => void; - onLogout: () => void; - isLoggingOut: boolean; - imageCompressionEnabled: boolean; - onImageCompressionChange: (enabled: boolean) => void; - syncSummary: SyncQueueSummary; - isOnline: boolean; - isSyncingQueuedChanges: boolean; - onSyncQueuedChanges: () => void; - onOpenAssets: () => void; - onOpenTags: () => void; - onOpenSettings: () => void; - onOpenTrash: () => void; -}) => { - const tree = useMemo(() => buildNotebookTree(notebooks), [notebooks]); - const notebookScrollRef = useRef(null); - const notebookDragScrollFrameRef = useRef(null); - const [notebookContextMenu, setNotebookContextMenu] = useState(null); - const [expandSiblingsRequest, setExpandSiblingsRequest] = useState(null); - const notebookContextMenuRef = useDismissableLayer(Boolean(notebookContextMenu), () => setNotebookContextMenu(null)); - useFloatingMenuControls(notebookContextMenuRef, Boolean(notebookContextMenu), () => setNotebookContextMenu(null)); - - const stopNotebookDragAutoScroll = useCallback(() => { - if (notebookDragScrollFrameRef.current === null) { - return; - } - - window.cancelAnimationFrame(notebookDragScrollFrameRef.current); - notebookDragScrollFrameRef.current = null; - }, []); - - useEffect(() => () => stopNotebookDragAutoScroll(), [stopNotebookDragAutoScroll]); - - useEffect(() => { - if (!selectedNotebookId) { - return; - } - - window.setTimeout(() => { - const selectedNode = notebookScrollRef.current?.querySelector( - `[data-notebook-id="${CSS.escape(selectedNotebookId)}"]` - ); - - selectedNode?.scrollIntoView({ block: "nearest" }); - }, 0); - }, [selectedNotebookId, tree]); - - const openNotebookContextMenuAt = (notebook: NotebookNode, clientX: number, clientY: number) => { - const menuWidth = 220; - const menuHeight = notebook.slug === "inbox" ? 96 : 142; - const x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12)); - const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); - - setNotebookContextMenu({ notebook, x, y }); - }; - - const handleOpenNotebookContextMenu = (notebook: NotebookNode, event: MouseEvent) => { - if (!isDesktopViewport()) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - openNotebookContextMenuAt(notebook, event.clientX, event.clientY); - }; - - const handleOpenNotebookKeyboardContextMenu = (notebook: NotebookNode, target: HTMLElement) => { - if (!isDesktopViewport()) { - return; - } - - const rect = target.getBoundingClientRect(); - openNotebookContextMenuAt(notebook, rect.left + Math.min(rect.width, 220), rect.top + rect.height); - }; - - const handleExpandNotebookSiblings = useCallback((parentId: string | null) => { - setExpandSiblingsRequest((current) => ({ parentId, token: (current?.token ?? 0) + 1 })); - }, []); - - const handleNotebookScrollDragOver = (event: DragEvent) => { - if (!hasEdgeEverDragData(event.dataTransfer)) { - stopNotebookDragAutoScroll(); - return; - } - - const scrollContainer = notebookScrollRef.current; - - if (!scrollContainer) { - return; - } - - const rect = scrollContainer.getBoundingClientRect(); - const distanceToTop = event.clientY - rect.top; - const distanceToBottom = rect.bottom - event.clientY; - const topPressure = Math.max(0, NOTEBOOK_DRAG_SCROLL_EDGE_PX - distanceToTop); - const bottomPressure = Math.max(0, NOTEBOOK_DRAG_SCROLL_EDGE_PX - distanceToBottom); - const direction = bottomPressure > 0 ? 1 : topPressure > 0 ? -1 : 0; - - if (direction === 0) { - stopNotebookDragAutoScroll(); - return; - } - - event.preventDefault(); - - const pressure = Math.max(topPressure, bottomPressure) / NOTEBOOK_DRAG_SCROLL_EDGE_PX; - const scrollStep = Math.max(4, Math.ceil(pressure * NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX)) * direction; - const tick = () => { - scrollContainer.scrollTop += scrollStep; - notebookDragScrollFrameRef.current = window.requestAnimationFrame(tick); - }; - - if (notebookDragScrollFrameRef.current !== null) { - return; - } - - notebookDragScrollFrameRef.current = window.requestAnimationFrame(tick); - }; - - return ( -
-
-
-
笔记本
-
EdgeEver
-
{user?.username ?? "边缘笔记工作区"}
-
-
- - -
-
- -
{ - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - stopNotebookDragAutoScroll(); - } - }} - onDragOver={handleNotebookScrollDragOver} - onDrop={stopNotebookDragAutoScroll} - > -
- -
- - - -
- - - 笔记本 - - -
- - {isLoading ? ( -
加载中
- ) : ( -
- {tree.map((node) => ( - - ))} -
- )} - - -
- -
- - - {authRequired ? ( - - ) : null} -
- {notebookContextMenu ? ( -
event.preventDefault()} - > - - - {notebookContextMenu.notebook.slug !== "inbox" ? ( - <> -
- - - ) : null} -
- ) : null} -
- ); -}; - -const SyncStatusBar = ({ - summary, - isOnline, - isSyncing, - onSyncNow, -}: { - summary: SyncQueueSummary; - isOnline: boolean; - isSyncing: boolean; - onSyncNow: () => void; -}) => { - const hasQueuedWork = summary.total > 0; - const label = getSyncStatusLabel(summary, isOnline, isSyncing); - const statusClassName = !isOnline - ? "border-slate-200 bg-slate-50 text-slate-600" - : summary.conflict > 0 - ? "border-amber-200 bg-amber-50 text-amber-800" - : hasQueuedWork - ? "border-emerald-200 bg-emerald-50 text-emerald-800" - : "border-slate-200 bg-white text-slate-500"; - - return ( -
- {!isOnline ? ( - - ) : summary.conflict > 0 ? ( - - ) : hasQueuedWork || isSyncing ? ( - - ) : ( - - )} - {label} - {hasQueuedWork ? ( - - ) : null} -
- ); -}; - -const SidebarNavButton = ({ - active = false, - icon, - label, - onClick, -}: { - active?: boolean; - icon: ReactNode; - label: string; - onClick: () => void; -}) => ( - -); - -const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSyncing: boolean) => { - if (!isOnline) { - return summary.total > 0 ? `离线,${summary.total} 项待同步` : "离线"; - } - - if (isSyncing || summary.syncing > 0) { - return "同步中"; - } - - if (summary.conflict > 0) { - return `${summary.conflict} 项同步冲突`; - } - - if (summary.error > 0) { - return `${summary.error} 项等待重试`; - } - - if (summary.pending > 0) { - return `${summary.pending} 项待同步`; - } - - return "已同步"; -}; - -const MobileBottomNav = ({ - activeItem, - canCreateMemo, - isCreating, - onCreateMemo, - onHome, - onOpenSettings, - onOpenTemplates, - onSearch, -}: { - activeItem: MobileBottomNavItem; - canCreateMemo: boolean; - isCreating: boolean; - onCreateMemo: () => void; - onHome: () => void; - onOpenSettings: () => void; - onOpenTemplates: () => void; - onSearch: () => void; -}) => ( - -); - -const MobileBottomNavButton = ({ - active = false, - icon, - label, - onClick, -}: { - active?: boolean; - icon: ReactNode; - label: string; - onClick: () => void; -}) => ( - -); - -const MobileSheetGrabber = ({ onPointerDown }: { onPointerDown?: (event: ReactPointerEvent) => void }) => ( -
-
-
-); - -const MemoDeleteConfirmDialog = ({ - confirmation, - isDeleting, - onCancel, - onConfirm, -}: { - confirmation: MemoDeleteConfirmation; - isDeleting: boolean; - onCancel: () => void; - onConfirm: () => void; -}) => { - const count = confirmation.memoIds.length; - const isBulk = confirmation.kind === "bulk" || count > 1; - const title = confirmation.permanent ? (isBulk ? `永久删除 ${count} 条笔记` : "永久删除笔记") : isBulk ? `删除 ${count} 条笔记` : "删除笔记"; - const description = confirmation.permanent ? "这个操作不可恢复。" : "删除后可以在回收站恢复。"; - const confirmLabel = confirmation.permanent ? "永久删除" : "删除"; - const dialogRef = useRef(null); - const cancelButtonRef = useRef(null); - - useFocusTrap(dialogRef); - - useEffect(() => { - cancelButtonRef.current?.focus(); - }, []); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && !isDeleting) { - onCancel(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isDeleting, onCancel]); - - return ( -
{ - event.stopPropagation(); - - if (!isDeleting) { - onCancel(); - } - }} - > -
event.stopPropagation()} - > - -
- - - -
-

- {title} -

-

{description}

-
- -
-
- - -
-
-
- ); -}; - -const AppConfirmDialog = ({ - cancelLabel = "取消", - confirmLabel, - description, - hideCancel = false, - isWorking = false, - closeOnBrowserBack = true, - title, - tone = "danger", - onCancel, - onConfirm, -}: { - cancelLabel?: string; - confirmLabel: string; - description: string; - hideCancel?: boolean; - isWorking?: boolean; - closeOnBrowserBack?: boolean; - title: string; - tone?: "danger" | "neutral" | "primary"; - onCancel: () => void; - onConfirm: () => void; -}) => { - const toneClassName = - tone === "danger" - ? "bg-rose-50 text-rose-700" - : tone === "primary" - ? "bg-emerald-50 text-emerald-700" - : "bg-slate-100 text-slate-600"; - const confirmVariant = tone === "danger" ? "danger" : "solid"; - const Icon = tone === "danger" ? AlertTriangle : ShieldCheck; - const dialogRef = useRef(null); - const cancelButtonRef = useRef(null); - const confirmButtonRef = useRef(null); - - useFocusTrap(dialogRef); - useBrowserBackLayer(closeOnBrowserBack && !isWorking, onCancel); - - useEffect(() => { - if (hideCancel) { - confirmButtonRef.current?.focus(); - return; - } - - cancelButtonRef.current?.focus(); - }, [hideCancel]); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && !isWorking) { - onCancel(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isWorking, onCancel]); - - return ( -
{ - event.stopPropagation(); - - if (!isWorking) { - onCancel(); - } - }} - > -
event.stopPropagation()} - > - -
- - - -
-

- {title} -

-

{description}

-
- {!hideCancel ? ( - - ) : null} -
-
- {!hideCancel ? ( - - ) : null} - -
-
-
- ); -}; - -const NotebookNameDialog = ({ - dialog, - isSaving, - onCancel, - onSubmit, -}: { - dialog: NotebookNameDialogState; - isSaving: boolean; - onCancel: () => void; - onSubmit: (name: string) => void; -}) => { - const initialName = dialog.mode === "rename" ? dialog.notebook.name : ""; - const [name, setName] = useState(initialName); - const inputRef = useRef(null); - const trimmedName = name.trim(); - const unchanged = dialog.mode === "rename" && trimmedName === dialog.notebook.name; - const title = dialog.mode === "create" ? "新建笔记本" : "重命名笔记本"; - const submitLabel = dialog.mode === "create" ? "创建" : "保存"; - const dialogRef = useRef(null); - - useFocusTrap(dialogRef); - - useEffect(() => { - setName(initialName); - }, [initialName]); - - useEffect(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, []); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && !isSaving) { - onCancel(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isSaving, onCancel]); - - return ( -
{ - event.stopPropagation(); - - if (!isSaving) { - onCancel(); - } - }} - > -
event.stopPropagation()} - > - -
{ - event.preventDefault(); - - if (!trimmedName || unchanged || isSaving) { - return; - } - - onSubmit(trimmedName); - }} - > -
-
-

- {title} -

-

- {dialog.mode === "create" ? "创建一个新的笔记本,用来整理相关笔记。" : "更新后会立即同步到笔记本树。"} -

-
- -
-
- - setName(event.target.value)} - /> -
-
- - -
-
-
-
- ); -}; - -const MobileHomeHeader = ({ - isOnline, - isSyncingQueuedChanges, - user, - onSyncQueuedChanges, -}: { - isOnline: boolean; - isSyncingQueuedChanges: boolean; - user: AuthUser | null; - onSyncQueuedChanges: () => void; -}) => { - const displayName = user?.displayName?.trim() || user?.username?.trim() || "EdgeEver"; - const initial = displayName.charAt(0).toLocaleUpperCase("zh-CN") || "E"; - - return ( -
-
-
- {initial} -
-
-
Hi {displayName}
-
{isOnline ? "已连接 EdgeEver" : "离线模式"}
-
-
-
- -
-
- ); -}; - -const MobileQuickActions = ({ - canCreateMemo, - isCreating, - locked = false, - onCreateChecklist, - onCreateMemo, - onOpenAssets, - onOpenTags, - onOpenTemplates, -}: { - canCreateMemo: boolean; - isCreating: boolean; - locked?: boolean; - onCreateChecklist: () => void; - onCreateMemo: () => void; - onOpenAssets: () => void; - onOpenTags: () => void; - onOpenTemplates: () => void; -}) => { - const scrollRef = useRef(null); - const [activePage, setActivePage] = useState(0); - const [pageCount, setPageCount] = useState(1); - const actions = [ - { - disabled: !canCreateMemo || isCreating, - icon: , - label: "笔记", - onClick: onCreateMemo, - }, - { - disabled: !canCreateMemo || isCreating, - icon: , - label: "清单", - onClick: onCreateChecklist, - }, - { - disabled: !canCreateMemo || isCreating, - icon: , - label: "模板", - onClick: onOpenTemplates, - }, - { icon: , label: "标签", onClick: onOpenTags }, - { icon: , label: "附件", onClick: onOpenAssets }, - ]; - const updatePagination = () => { - const node = scrollRef.current; - - if (!node) { - return; - } - - const pageWidth = Math.max(node.clientWidth, 1); - const scrollableWidth = node.scrollWidth - node.clientWidth; - const nextPageCount = Math.max(1, Math.ceil(Math.max(scrollableWidth, 0) / pageWidth) + 1); - const nextActivePage = Math.min(nextPageCount - 1, Math.round(node.scrollLeft / pageWidth)); - - setPageCount(nextPageCount); - setActivePage(nextActivePage); - }; - - useEffect(() => { - updatePagination(); - - const node = scrollRef.current; - - if (!node) { - return undefined; - } - - const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updatePagination); - - resizeObserver?.observe(node); - window.addEventListener("resize", updatePagination); - - return () => { - resizeObserver?.disconnect(); - window.removeEventListener("resize", updatePagination); - }; - }, []); - - const handleScroll = () => { - updatePagination(); - }; - - return ( -
-
- {actions.map((action) => ( - - ))} -
- {pageCount > 1 ? ( - - ) : null} -
- ); -}; - -const MobileQuickActionButton = ({ - disabled = false, - icon, - label, - locked = false, - onClick, -}: { - disabled?: boolean; - icon: ReactNode; - label: string; - locked?: boolean; - onClick: () => void; -}) => ( - -); - -const MobileNotebookPicker = ({ - currentLabel, - notebooks, - selectedNotebookId, - onClose, - onSelectAll, - onSelect, -}: { - currentLabel?: string; - notebooks: Notebook[]; - selectedNotebookId: string | null; - onClose: () => void; - onSelectAll: () => void; - onSelect: (notebookId: string) => void; -}) => { - const dialogRef = useRef(null); - const listRef = useRef(null); - const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); - const [notebookSearch, setNotebookSearch] = useState(""); - const tree = useMemo(() => buildNotebookTree(notebooks), [notebooks]); - const filteredTree = useMemo(() => filterNotebookTree(tree, notebookSearch), [notebookSearch, tree]); - const selectedAncestorIds = useMemo( - () => (selectedNotebookId ? getNotebookAncestorIds(tree, selectedNotebookId) : []), - [selectedNotebookId, tree] - ); - const expandableNotebookIds = useMemo(() => getExpandableNotebookIds(tree), [tree]); - const [expandedNotebookIds, setExpandedNotebookIds] = useState>(() => new Set(selectedAncestorIds)); - const allSelected = !currentLabel && selectedNotebookId === null; - const selectedNotebookName = - currentLabel ?? (allSelected ? "全部笔记" : notebooks.find((item) => item.id === selectedNotebookId)?.name ?? "笔记本"); - const searchQuery = notebookSearch.trim(); - const searchActive = Boolean(searchQuery); - const allNotebookBranchesExpanded = - expandableNotebookIds.length > 0 && expandableNotebookIds.every((notebookId) => expandedNotebookIds.has(notebookId)); - - useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); - - useEffect(() => { - if (selectedAncestorIds.length === 0) { - return; - } - - setExpandedNotebookIds((current) => { - const next = new Set(current); - - for (const notebookId of selectedAncestorIds) { - next.add(notebookId); - } - - return next; - }); - }, [selectedAncestorIds]); - - useEffect(() => { - if (searchActive) { - return; - } - - window.setTimeout(() => { - const listNode = listRef.current; - const targetNotebookId = selectedNotebookId ?? "__all__"; - const selectedNode = listNode?.querySelector(`[data-mobile-notebook-id="${CSS.escape(targetNotebookId)}"]`); - - selectedNode?.scrollIntoView({ block: "center" }); - }, 0); - }, [searchActive, selectedNotebookId]); - - const handleToggleNotebookExpanded = (notebookId: string) => { - setExpandedNotebookIds((current) => { - const next = new Set(current); - - if (next.has(notebookId)) { - next.delete(notebookId); - } else { - next.add(notebookId); - } - - return next; - }); - }; - - const handleToggleAllNotebookBranches = () => { - setExpandedNotebookIds(allNotebookBranchesExpanded ? new Set() : new Set(expandableNotebookIds)); - }; - - return ( -
-
event.stopPropagation()} - > - -
-
-
- 切换笔记本 -
-
当前:{selectedNotebookName}
-
- -
-
-
- - setNotebookSearch(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && notebookSearch) { - event.preventDefault(); - setNotebookSearch(""); - } - }} - /> - {notebookSearch ? ( - - ) : null} -
-
-
- - {filteredTree.length > 0 ? ( - <> -
- {searchActive ? "匹配的笔记本" : "笔记本"} - {!searchActive && expandableNotebookIds.length > 0 ? ( - - ) : null} -
- {filteredTree.map((node) => ( - - ))} - - ) : ( -
-
- {searchQuery ? `没有找到「${searchQuery}」` : "没有找到笔记本"} -
- {searchQuery ? ( - - ) : null} -
- )} -
-
-
- ); -}; - -const MobileNotebookPickerItem = ({ - node, - depth, - expandedNotebookIds, - searchActive, - selectedNotebookId, - onSelect, - onToggleExpanded, -}: { - node: NotebookNode; - depth: number; - expandedNotebookIds: Set; - searchActive: boolean; - selectedNotebookId: string | null; - onSelect: (notebookId: string) => void; - onToggleExpanded: (notebookId: string) => void; -}) => { - const selected = node.id === selectedNotebookId; - const hasChildren = node.children.length > 0; - const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false; - const expanded = searchActive || expandedNotebookIds.has(node.id); - - return ( -
-
- {hasChildren ? ( - - ) : ( -
- {hasChildren && expanded ? ( -
- {node.children.map((child) => ( - - ))} -
- ) : null} -
- ); -}; - -const filterNotebookTree = (nodes: NotebookNode[], search: string): NotebookNode[] => { - const query = search.trim().toLocaleLowerCase("zh-CN"); - - if (!query) { - return nodes; - } - - const walk = (items: NotebookNode[]): NotebookNode[] => { - const filteredNodes: NotebookNode[] = []; - - for (const node of items) { - const children = walk(node.children); - const matched = node.name.toLocaleLowerCase("zh-CN").includes(query); - - if (matched) { - filteredNodes.push({ ...node, children: node.children }); - continue; - } - - if (children.length > 0) { - filteredNodes.push({ ...node, children }); - } - } - - return filteredNodes; - }; - - return walk(nodes); -}; - -const getExpandableNotebookIds = (nodes: NotebookNode[]) => { - const ids: string[] = []; - const walk = (items: NotebookNode[]) => { - for (const node of items) { - if (node.children.length > 0) { - ids.push(node.id); - walk(node.children); - } - } - }; - - walk(nodes); - return ids; -}; - -const notebookTreeContainsId = (nodes: NotebookNode[], targetNotebookId: string): boolean => { - for (const node of nodes) { - if (node.id === targetNotebookId || notebookTreeContainsId(node.children, targetNotebookId)) { - return true; - } - } - - return false; -}; - -const getNotebookAncestorIds = (nodes: NotebookNode[], targetNotebookId: string) => { - const walk = (items: NotebookNode[], ancestors: string[]): string[] | null => { - for (const node of items) { - if (node.id === targetNotebookId) { - return node.children.length > 0 ? [...ancestors, node.id] : ancestors; - } - - const result = walk(node.children, [...ancestors, node.id]); - - if (result) { - return result; - } - } - - return null; - }; - - return walk(nodes, []) ?? []; -}; - -const mobileSortOptions: Array<{ value: MemoSortMode; label: string }> = [ - { value: "updated-desc", label: "最近更新" }, - { value: "created-desc", label: "创建时间" }, - { value: "title-asc", label: "标题 A-Z" }, -]; -const memoListDensityOptions: Array<{ value: MemoListDensity; label: string; icon: ReactNode }> = [ - { value: "preview", label: "卡片预览", icon: }, - { value: "compact", label: "紧凑列表", icon: }, -]; - -const MobileListActionsSheet = ({ - canSelectMemos, - isSelectionMode, - listDescription, - listDensity, - listTitle, - sortMode, - onClose, - onEnterSelectionMode, - onOpenAssets, - onOpenSettings, - onOpenTags, - onOpenTrash, - onListDensityChange, - onSortModeChange, -}: { - canSelectMemos: boolean; - isSelectionMode: boolean; - listDescription: string; - listDensity: MemoListDensity; - listTitle: string; - sortMode: MemoSortMode; - onClose: () => void; - onEnterSelectionMode: () => void; - onOpenAssets: () => void; - onOpenSettings: () => void; - onOpenTags: () => void; - onOpenTrash: () => void; - onListDensityChange: (value: MemoListDensity) => void; - onSortModeChange: (value: MemoSortMode) => void; -}) => { - const dialogRef = useRef(null); - const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); - - useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- 列表选项 -
-
- {listTitle} · {listDescription} -
-
- -
-
- {!isSelectionMode ? ( - <> - } - label="选择笔记" - onClick={onEnterSelectionMode} - /> - -
- - ) : null} - -
显示方式
- {memoListDensityOptions.map((option) => ( - - ))} - -
- -
排序方式
- {mobileSortOptions.map((option) => ( - - ))} - -
- - } label="标签" onClick={onOpenTags} /> - } label="附件" onClick={onOpenAssets} /> - } label="回收站" onClick={onOpenTrash} /> - } label="MCP Token" onClick={onOpenSettings} /> -
-
-
- ); -}; - -const MobileListActionButton = ({ - disabled = false, - icon, - label, - onClick, -}: { - disabled?: boolean; - icon: ReactNode; - label: string; - onClick: () => void; -}) => ( - -); - -const MobileSelectionActionBar = ({ - canDelete, - canMove, - deleteTitle, - isTrashView, - moveTitle, - onDelete, - onOpenMore, - onOpenMove, -}: { - canDelete: boolean; - canMove: boolean; - deleteTitle: string; - isTrashView: boolean; - moveTitle: string; - onDelete: () => void; - onOpenMore: () => void; - onOpenMove: () => void; -}) => ( - -); - -const MobileSelectionActionButton = ({ - disabled = false, - icon, - label, - title = label, - onClick, -}: { - disabled?: boolean; - icon: ReactNode; - label: string; - title?: string; - onClick: () => void; -}) => ( - -); - -const MobileMoveSheet = ({ - isMoving, - notebooks, - selectedCount, - selectedNotebookId, - onClose, - onMove, -}: { - isMoving: boolean; - notebooks: Notebook[]; - selectedCount: number; - selectedNotebookId: string; - onClose: () => void; - onMove: (notebookId: string) => void; -}) => { - const dialogRef = useRef(null); - const listRef = useRef(null); - const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); - const [notebookSearch, setNotebookSearch] = useState(""); - const options = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); - const selectedCountLabel = getSelectionCountLabel(selectedCount); - const moveSearchQuery = notebookSearch.trim().toLocaleLowerCase("zh-CN"); - const filteredOptions = useMemo(() => { - if (!moveSearchQuery) { - return options; - } - - return options.filter((option) => - [option.name, option.selectLabel, option.slug ?? ""].some((value) => value.toLocaleLowerCase("zh-CN").includes(moveSearchQuery)) - ); - }, [moveSearchQuery, options]); - - useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); - - useEffect(() => { - if (moveSearchQuery) { - return; - } - - window.setTimeout(() => { - const selectedNode = listRef.current?.querySelector( - `[data-mobile-move-notebook-id="${CSS.escape(selectedNotebookId)}"]` - ); - - selectedNode?.scrollIntoView({ block: "center" }); - }, 0); - }, [moveSearchQuery, selectedNotebookId]); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- 移动到笔记本 -
-
{selectedCountLabel}
-
- -
-
-
- - setNotebookSearch(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && notebookSearch) { - event.preventDefault(); - setNotebookSearch(""); - } - }} - /> - {notebookSearch ? ( - - ) : null} -
-
-
- {filteredOptions.length > 0 ? filteredOptions.map((option) => { - const selected = option.id === selectedNotebookId; - - return ( - - ); - }) : ( -
没有找到笔记本
- )} -
-
-
- ); -}; - -const MobileNotebookSelectSheet = ({ - isUpdating, - options, - selectedNotebookId, - onClose, - onSelect, -}: { - isUpdating: boolean; - options: NotebookMoveOption[]; - selectedNotebookId: string; - onClose: () => void; - onSelect: (notebookId: string) => void; -}) => { - const dialogRef = useRef(null); - const listRef = useRef(null); - const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); - - useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); - - useEffect(() => { - window.setTimeout(() => { - const selectedNode = listRef.current?.querySelector( - `[data-mobile-notebook-select-id="${CSS.escape(selectedNotebookId)}"]` - ); - - selectedNode?.scrollIntoView({ block: "center" }); - }, 0); - }, [selectedNotebookId]); - - return ( -
-
event.stopPropagation()} - > - -
-
- 所在笔记本 -
- -
-
- {options.map((option) => { - const selected = option.id === selectedNotebookId; - - return ( - - ); - })} -
-
-
- ); -}; - -const MobileSelectionMoreSheet = ({ - canMerge, - canPin, - canToggleVisibleSelection, - mergeTitle, - pinLabel, - pinTitle, - selectedCount, - selectionToggleLabel, - selectionToggleTitle, - onClearSelection, - onClose, - onMerge, - onPin, - onToggleVisibleSelection, -}: { - canMerge: boolean; - canPin: boolean; - canToggleVisibleSelection: boolean; - mergeTitle: string; - pinLabel: string; - pinTitle: string; - selectedCount: number; - selectionToggleLabel: string; - selectionToggleTitle: string; - onClearSelection: () => void; - onClose: () => void; - onMerge: () => void; - onPin: () => void; - onToggleVisibleSelection: () => void; -}) => { - const dialogRef = useRef(null); - const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); - - useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- 批量操作 -
-
{getSelectionCountLabel(selectedCount)}
-
- -
- - - - -
-
- ); -}; - -const NotebookTreeItem = ({ - node, - depth, - selectedNotebookId, - onSelect, - onCreateNotebook, - onRenameNotebook, - onDeleteNotebook, - onMoveNotebook, - onMoveMemos, - onDragScroll, - expandSiblingsRequest, - onExpandSiblings, - onOpenContextMenu, - onOpenKeyboardContextMenu, -}: { - node: NotebookNode; - depth: number; - selectedNotebookId: string | null; - onSelect: (notebookId: string) => void; - onCreateNotebook: (parentId?: string | null) => void; - onRenameNotebook: (notebook: Notebook) => void; - onDeleteNotebook: (notebook: Notebook) => void; - onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void; - onMoveMemos: (memoIds: string[], targetNotebookId: string) => void; - onDragScroll: (event: DragEvent) => void; - expandSiblingsRequest: ExpandNotebookSiblingsRequest | null; - onExpandSiblings: (parentId: string | null) => void; - onOpenContextMenu: (notebook: NotebookNode, event: MouseEvent) => void; - onOpenKeyboardContextMenu: (notebook: NotebookNode, target: HTMLElement) => void; -}) => { - const [open, setOpen] = useState(true); - const hasChildren = node.children.length > 0; - const selected = node.id === selectedNotebookId; - const isInbox = node.slug === "inbox"; - const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false; - const [dropPosition, setDropPosition] = useState(null); - const expandTimerRef = useRef(null); - - const clearExpandTimer = () => { - if (expandTimerRef.current === null) { - return; - } - - window.clearTimeout(expandTimerRef.current); - expandTimerRef.current = null; - }; - - useEffect(() => () => clearExpandTimer(), []); - - useEffect(() => { - if (hasSelectedDescendant) { - setOpen(true); - } - }, [hasSelectedDescendant]); - - useEffect(() => { - if (!expandSiblingsRequest || expandSiblingsRequest.parentId !== node.parentId || !hasChildren) { - return; - } - - setOpen(true); - }, [expandSiblingsRequest, hasChildren, node.parentId]); - - const scheduleDragExpand = (position: NotebookDropPosition) => { - if (!hasChildren || open || position !== "inside") { - clearExpandTimer(); - return; - } - - if (expandTimerRef.current !== null) { - return; - } - - expandTimerRef.current = window.setTimeout(() => { - expandTimerRef.current = null; - setOpen(true); - }, NOTEBOOK_DRAG_EXPAND_DELAY_MS); - }; - - const handleDragOver = (event: DragEvent) => { - onDragScroll(event); - - if (hasMemoDragData(event.dataTransfer)) { - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = "move"; - setDropPosition("inside"); - scheduleDragExpand("inside"); - return; - } - - if (!hasNotebookDragData(event.dataTransfer)) { - return; - } - - const position = getNotebookDropPosition(event); - - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - setDropPosition(position); - scheduleDragExpand(position); - }; - - const handleDrop = (event: DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - const memoIds = getMemoDragIds(event.dataTransfer); - const notebookId = event.dataTransfer.getData(NOTEBOOK_DRAG_MIME); - const position = getNotebookDropPosition(event); - setDropPosition(null); - clearExpandTimer(); - - if (memoIds.length > 0) { - onMoveMemos(memoIds, node.id); - setOpen(true); - return; - } - - if (!notebookId || notebookId === node.id) { - return; - } - - onMoveNotebook(notebookId, node.id, position); - setOpen(true); - }; - - return ( -
-
{ - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData(NOTEBOOK_DRAG_MIME, node.id); - event.dataTransfer.setData("text/plain", node.id); - }} - onDragOver={handleDragOver} - onDragLeave={() => { - setDropPosition(null); - clearExpandTimer(); - }} - onDragEnd={clearExpandTimer} - onDrop={handleDrop} - onContextMenu={(event) => onOpenContextMenu(node, event)} - style={{ paddingLeft: `${8 + depth * 14}px` }} - > - {hasChildren ? ( - - ) : ( -
- - {hasChildren && open ? ( -
- {node.children.map((child) => ( - - ))} -
- ) : null} -
- ); -}; - -const MemoListPane = ({ - notebook, - notebooks, - user, - view, - memos, - selectedMemoId, - selectedMemoIds, - selectionMode, - search, - mobileSearchActive, - searchFocusToken, - canCreateMemo, - isLoading, - isCreating, - isMerging, - isMoving, - isPinning, - isDeleting, - isOnline, - isSyncingQueuedChanges, - multiSelectKeyDown, - onOpenNotebookPicker, - onSearch, - onCancelMobileSearch, - onCreateChecklist, - onCreateMemo, - onClearSelection, - onEnterSelectionMode, - onReplaceSelection, - onOpenAssets, - onOpenMemo, - onToggleMemo, - onOpenSettings, - onOpenTags, - onOpenTemplates, - onOpenTrash, - onMerge, - onDeleteMemo, - onRestoreMemo, - onMoveMemo, - onTogglePinMemo, - onPinSelectedMemos, - onDeleteSelectedMemos, - onMoveSelectedMemos, - onSyncQueuedChanges, -}: { - notebook: Notebook | null; - notebooks: Notebook[]; - user: AuthUser | null; - view: MemoView; - memos: MemoSummary[]; - selectedMemoId: string | null; - selectedMemoIds: Set; - selectionMode: boolean; - search: string; - mobileSearchActive: boolean; - searchFocusToken: number; - canCreateMemo: boolean; - isLoading: boolean; - isCreating: boolean; - isMerging: boolean; - isMoving: boolean; - isPinning: boolean; - isDeleting: boolean; - isOnline: boolean; - isSyncingQueuedChanges: boolean; - multiSelectKeyDown: boolean; - onOpenNotebookPicker: () => void; - onSearch: (value: string) => void; - onCancelMobileSearch: () => void; - onCreateChecklist: () => void; - onCreateMemo: () => void; - onClearSelection: () => void; - onEnterSelectionMode: () => void; - onReplaceSelection: (memoIds: string[]) => void; - onOpenAssets: () => void; - onOpenMemo: (memoId: string) => void; - onToggleMemo: (memoId: string, rangeMemoIds?: string[]) => void; - onOpenSettings: () => void; - onOpenTags: () => void; - onOpenTemplates: () => void; - onOpenTrash: () => void; - onMerge: () => void; - onDeleteMemo: (memoId: string) => void; - onRestoreMemo: (memoId: string) => void; - onMoveMemo: (memoId: string, targetNotebookId: string) => void; - onTogglePinMemo: (memo: MemoSummary) => void; - onPinSelectedMemos: (isPinned: boolean) => void; - onDeleteSelectedMemos: () => void; - onMoveSelectedMemos: (notebookId: string) => void; - onSyncQueuedChanges: () => void; -}) => { - const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(notebook?.id ?? notebooks[0]?.id ?? ""); - const [desktopFilterOpen, setDesktopFilterOpen] = useState(false); - const [desktopSortOpen, setDesktopSortOpen] = useState(false); - const [desktopActionsOpen, setDesktopActionsOpen] = useState(false); - const [mobileListActionsOpen, setMobileListActionsOpen] = useState(false); - const [mobileMoveOpen, setMobileMoveOpen] = useState(false); - const [mobileMoreOpen, setMobileMoreOpen] = useState(false); - const [memoContextMenu, setMemoContextMenu] = useState(null); - const [selectionContextMenu, setSelectionContextMenu] = useState(null); - const [contextMoveOpen, setContextMoveOpen] = useState(false); - const [filterMode, setFilterMode] = useState("all"); - const [sortMode, setSortMode] = useState("updated-desc"); - const [listDensity, setListDensity] = useState(() => readMemoListDensityPreference()); - const [lastSelectedMemoId, setLastSelectedMemoId] = useState(null); - const filterOptions = MEMO_FILTER_OPTIONS; - const mobileFilterOptions = useMemo(() => filterOptions.filter((option) => option.value !== "all"), [filterOptions]); - const filteredMemos = useMemo(() => filterMemos(memos, filterMode), [filterMode, memos]); - const sortedMemos = useMemo(() => sortMemos(filteredMemos, sortMode, view !== "trash"), [filteredMemos, sortMode, view]); - const visibleMemoIds = useMemo(() => sortedMemos.map((memo) => memo.id), [sortedMemos]); - const memoGroups = useMemo(() => groupMemos(sortedMemos, sortMode), [sortedMemos, sortMode]); - const moveNotebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); - const selectedMemosInList = useMemo(() => memos.filter((memo) => selectedMemoIds.has(memo.id)), [memos, selectedMemoIds]); - const searchInputRef = useRef(null); - const mobileSearchInputRef = useRef(null); - const listRootRef = useRef(null); - const listScrollRef = useRef(null); - const moveTargetSelectRef = useRef(null); - const previousSelectionModeRef = useRef(selectionMode); - const skipSelectedMemoAutoScrollRef = useRef(false); - const canEnterSelectionMode = visibleMemoIds.length > 0; - const selectedVisibleMemoCount = visibleMemoIds.filter((memoId) => selectedMemoIds.has(memoId)).length; - const allVisibleMemosSelected = visibleMemoIds.length > 0 && selectedVisibleMemoCount === visibleMemoIds.length; - const canToggleVisibleMemoSelection = visibleMemoIds.length > 0; - const visibleSelectionToggleLabel = allVisibleMemosSelected ? "全不选当前列表" : "全选当前列表"; - const desktopFilterRef = useDismissableLayer(desktopFilterOpen, () => setDesktopFilterOpen(false)); - const desktopSortRef = useDismissableLayer(desktopSortOpen, () => setDesktopSortOpen(false)); - const desktopActionsRef = useDismissableLayer(desktopActionsOpen, () => setDesktopActionsOpen(false)); - const memoContextMenuRef = useDismissableLayer(Boolean(memoContextMenu), () => setMemoContextMenu(null)); - const selectionContextMenuRef = useDismissableLayer(Boolean(selectionContextMenu), () => setSelectionContextMenu(null)); - useFloatingMenuControls(desktopFilterRef, desktopFilterOpen, () => setDesktopFilterOpen(false)); - useFloatingMenuControls(desktopSortRef, desktopSortOpen, () => setDesktopSortOpen(false)); - useFloatingMenuControls(desktopActionsRef, desktopActionsOpen, () => setDesktopActionsOpen(false)); - useFloatingMenuControls(memoContextMenuRef, Boolean(memoContextMenu), () => setMemoContextMenu(null)); - useFloatingMenuControls(selectionContextMenuRef, Boolean(selectionContextMenu), () => setSelectionContextMenu(null)); - const listTitle = view === "trash" ? "回收站" : notebook?.name ?? "全部笔记"; - const listContextLabel = view === "trash" ? "已删除笔记" : notebook ? "当前笔记本" : "所有笔记本"; - const listCountLabel = `${filteredMemos.length}${filterMode !== "all" ? ` / ${memos.length}` : ""} ${ - view === "trash" ? "条已删除" : "条笔记" - }`; - const selectionCountLabel = getSelectionCountLabel(selectedMemoIds.size); - const selectionMoveTitle = - selectedMemoIds.size === 0 - ? "请选择笔记" - : view === "trash" - ? "回收站内不可移动" - : notebooks.length === 0 - ? "没有可移动的笔记本" - : isMoving - ? "正在移动" - : "移动"; - const selectionDeleteTitle = - selectedMemoIds.size === 0 ? "请选择笔记" : isDeleting ? "正在删除" : view === "trash" ? "永久删除" : "删除"; - const selectionMergeTitle = - selectedMemoIds.size < 2 ? "至少选择 2 条笔记" : view === "trash" ? "回收站内不可合并" : isMerging ? "正在合并" : "合并笔记"; - const allSelectedMemosPinned = selectedMemosInList.length > 0 && selectedMemosInList.every((memo) => memo.isPinned); - const selectedPinTarget = !allSelectedMemosPinned; - const selectionPinLabel = allSelectedMemosPinned ? "取消置顶" : "置顶"; - const selectionPinTitle = - selectedMemoIds.size === 0 - ? "请选择笔记" - : view === "trash" - ? "回收站内不可置顶" - : isPinning - ? "正在更新置顶" - : selectionPinLabel; - const selectionToggleTitle = canToggleVisibleMemoSelection ? visibleSelectionToggleLabel : "当前列表没有可选择的笔记"; - const moveTargetTitle = - view === "trash" ? "回收站内不可移动" : notebooks.length === 0 ? "没有可移动的笔记本" : isMoving ? "正在移动" : "移动到笔记本"; - const hasListConstraint = Boolean(search.trim()) || filterMode !== "all"; - const activeFilterLabel = filterOptions.find((option) => option.value === filterMode)?.label ?? "全部"; - const activeSortLabel = MEMO_SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? "最近更新"; - - useEffect(() => { - if (notebook?.id) { - setMoveTargetNotebookId(notebook.id); - return; - } - - if (!moveTargetNotebookId && moveNotebookOptions[0]?.id) { - setMoveTargetNotebookId(moveNotebookOptions[0].id); - } - }, [moveNotebookOptions, moveTargetNotebookId, notebook?.id]); - - useEffect(() => { - if (searchFocusToken === 0) { - return; - } - - if (mobileSearchActive && !isDesktopViewport()) { - mobileSearchInputRef.current?.focus(); - return; - } - - searchInputRef.current?.focus(); - }, [mobileSearchActive, searchFocusToken]); - - useEffect(() => { - if (!filterOptions.some((option) => option.value === filterMode)) { - setFilterMode("all"); - } - }, [filterMode, filterOptions]); - - useEffect(() => { - const scrollContainer = listScrollRef.current; - - if (!scrollContainer) { - return; - } - - skipSelectedMemoAutoScrollRef.current = true; - scrollContainer.scrollTo({ top: 0, behavior: "auto" }); - - const resetSkipTimer = window.setTimeout(() => { - skipSelectedMemoAutoScrollRef.current = false; - }, 0); - - return () => window.clearTimeout(resetSkipTimer); - }, [filterMode, notebook?.id, search, sortMode, view]); - - useEffect(() => { - if (!selectionMode || selectedMemoIds.size === 0) { - return; - } - - const visibleMemoIdSet = new Set(visibleMemoIds); - const nextSelectedMemoIds = Array.from(selectedMemoIds).filter((memoId) => visibleMemoIdSet.has(memoId)); - - if (nextSelectedMemoIds.length === selectedMemoIds.size) { - return; - } - - if (nextSelectedMemoIds.length === 0) { - onClearSelection(); - return; - } - - onReplaceSelection(nextSelectedMemoIds); - }, [onClearSelection, onReplaceSelection, selectedMemoIds, selectionMode, visibleMemoIds]); - - useEffect(() => { - if (!selectedMemoId || !visibleMemoIds.includes(selectedMemoId) || !window.matchMedia("(min-width: 1024px)").matches) { - return; - } - - if (skipSelectedMemoAutoScrollRef.current) { - skipSelectedMemoAutoScrollRef.current = false; - return; - } - - const scrollContainer = listScrollRef.current; - - if (!scrollContainer) { - return; - } - - const escapedMemoId = CSS.escape(selectedMemoId); - const selectedNode = scrollContainer.querySelector(`[data-memo-id="${escapedMemoId}"]`); - - if (!selectedNode) { - return; - } - - const containerRect = scrollContainer.getBoundingClientRect(); - const selectedRect = selectedNode.getBoundingClientRect(); - const stickyHeaderOffset = 40; - - if (selectedRect.top < containerRect.top + stickyHeaderOffset) { - scrollContainer.scrollTop -= containerRect.top + stickyHeaderOffset - selectedRect.top; - return; - } - - if (selectedRect.bottom > containerRect.bottom) { - scrollContainer.scrollTop += selectedRect.bottom - containerRect.bottom; - } - }, [selectedMemoId, visibleMemoIds]); - - useEffect(() => { - const wasSelectionMode = previousSelectionModeRef.current; - previousSelectionModeRef.current = selectionMode; - - if (!wasSelectionMode || selectionMode || !isDesktopViewport()) { - return; - } - - const activeElement = document.activeElement; - const listRoot = listRootRef.current; - const shouldRestoreFocus = - !activeElement || - activeElement === document.body || - (activeElement instanceof HTMLElement && Boolean(listRoot?.contains(activeElement))); - - if (!shouldRestoreFocus) { - return; - } - - const memoIdToFocus = lastSelectedMemoId ?? selectedMemoId ?? visibleMemoIds[0]; - - if (!memoIdToFocus) { - return; - } - - window.setTimeout(() => { - const scrollContainer = listScrollRef.current; - - if (!scrollContainer) { - return; - } - - const escapedMemoId = CSS.escape(memoIdToFocus); - const memoButton = scrollContainer.querySelector( - `[data-memo-id="${escapedMemoId}"] button[title^="Ctrl/Cmd"]` - ); - - memoButton?.focus({ preventScroll: true }); - }, 0); - }, [lastSelectedMemoId, selectedMemoId, selectionMode, visibleMemoIds]); - - useEffect(() => { - if (!selectionMode) { - setMobileMoveOpen(false); - setMobileMoreOpen(false); - return; - } - - setDesktopActionsOpen(false); - setDesktopFilterOpen(false); - setDesktopSortOpen(false); - setContextMoveOpen(false); - setMemoContextMenu(null); - setSelectionContextMenu(null); - setMobileListActionsOpen(false); - setMobileMoveOpen(false); - setMobileMoreOpen(false); - }, [selectionMode]); - - const handleToggleMemo = (memoId: string, event?: MouseEvent) => { - const currentIndex = visibleMemoIds.indexOf(memoId); - const anchorIndex = lastSelectedMemoId ? visibleMemoIds.indexOf(lastSelectedMemoId) : -1; - - if (event?.shiftKey && currentIndex >= 0 && anchorIndex >= 0) { - const start = Math.min(currentIndex, anchorIndex); - const end = Math.max(currentIndex, anchorIndex); - onToggleMemo(memoId, visibleMemoIds.slice(start, end + 1)); - } else { - onToggleMemo(memoId); - } - - setLastSelectedMemoId(memoId); - }; - - const handleSelectAllVisibleMemos = () => { - if (visibleMemoIds.length === 0) { - return; - } - - onReplaceSelection(visibleMemoIds); - setLastSelectedMemoId(visibleMemoIds.at(-1) ?? visibleMemoIds[0]); - }; - - const handleClearVisibleMemos = () => { - const nextSelectedMemoIds = Array.from(selectedMemoIds).filter((memoId) => !visibleMemoIds.includes(memoId)); - - onReplaceSelection(nextSelectedMemoIds); - setLastSelectedMemoId(null); - }; - - const openMemoContextMenuAt = (memo: MemoSummary, clientX: number, clientY: number) => { - const menuWidth = 224; - const menuHeight = view === "trash" ? 160 : 260; - const x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12)); - const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); - - setContextMoveOpen(false); - setSelectionContextMenu(null); - setMemoContextMenu({ memo, x, y }); - }; - - const openSelectionContextMenuAt = (clientX: number, clientY: number) => { - const menuWidth = 224; - const menuHeight = view === "trash" ? 128 : 272; - const x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12)); - const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); - - setContextMoveOpen(false); - setMemoContextMenu(null); - setSelectionContextMenu({ x, y }); - }; - - const handleOpenMemoContextMenu = (memo: MemoSummary, event: MouseEvent) => { - openMemoContextMenuAt(memo, event.clientX, event.clientY); - }; - - const handleOpenSelectionContextMenu = (memo: MemoSummary, event: MouseEvent) => { - if (!isDesktopViewport()) { - return; - } - - if (!selectedMemoIds.has(memo.id)) { - handleToggleMemo(memo.id, event); - } - - openSelectionContextMenuAt(event.clientX, event.clientY); - }; - - const handleOpenSelectionKeyboardContextMenu = (memo: MemoSummary, target: HTMLElement) => { - if (!isDesktopViewport()) { - return; - } - - if (!selectedMemoIds.has(memo.id)) { - handleToggleMemo(memo.id); - } - - const rect = target.getBoundingClientRect(); - openSelectionContextMenuAt(rect.left + Math.min(rect.width, 224), rect.top + Math.min(rect.height, 96)); - }; - - const handleOpenMemoKeyboardContextMenu = (memo: MemoSummary, target: HTMLElement) => { - if (!isDesktopViewport()) { - return; - } - - const rect = target.getBoundingClientRect(); - openMemoContextMenuAt(memo, rect.left + Math.min(rect.width, 224), rect.top + Math.min(rect.height, 96)); - }; - - const focusSearchInput = () => { - if (mobileSearchActive && !isDesktopViewport()) { - mobileSearchInputRef.current?.focus(); - return; - } - - searchInputRef.current?.focus(); - }; - - const handleClearSearch = () => { - onClearSelection(); - onSearch(""); - focusSearchInput(); - }; - - const handleSearchChange = (value: string) => { - if (value !== search) { - onClearSelection(); - } - - onSearch(value); - }; - - const handleResetListConstraints = () => { - onClearSelection(); - setFilterMode("all"); - onSearch(""); - focusSearchInput(); - }; - - const handleFilterModeChange = (value: MemoFilterMode) => { - onClearSelection(); - setFilterMode(value); - }; - - const handleListDensityChange = (value: MemoListDensity) => { - setListDensity(value); - writeMemoListDensityPreference(value); - }; - - const handleMoveTargetSelectKeyDown = (event: ReactKeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - event.currentTarget.blur(); - listRootRef.current?.focus(); - return; - } - - if (event.key !== "Enter") { - return; - } - - if (selectedMemoIds.size === 0 || !moveTargetNotebookId || isMoving || view === "trash") { - return; - } - - event.preventDefault(); - onMoveSelectedMemos(moveTargetNotebookId); - }; - - const replaceRangeSelection = (anchorMemoId: string, targetMemoId: string) => { - const anchorIndex = visibleMemoIds.indexOf(anchorMemoId); - const targetIndex = visibleMemoIds.indexOf(targetMemoId); - - if (anchorIndex < 0 || targetIndex < 0) { - return; - } - - const start = Math.min(anchorIndex, targetIndex); - const end = Math.max(anchorIndex, targetIndex); - - onReplaceSelection(visibleMemoIds.slice(start, end + 1)); - }; - - const handleListKeyDown = (event: ReactKeyboardEvent) => { - if (!window.matchMedia("(min-width: 1024px)").matches) { - return; - } - - const target = event.target instanceof HTMLElement ? event.target : null; - - if (target?.closest("input, textarea, select, [contenteditable='true']")) { - return; - } - - if (event.key === "Escape" && selectionMode) { - event.preventDefault(); - event.stopPropagation(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - onClearSelection(); - setLastSelectedMemoId(null); - return; - } - - if (!event.altKey && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") { - if (visibleMemoIds.length === 0) { - return; - } - - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - handleSelectAllVisibleMemos(); - return; - } - - if (!event.altKey && (event.ctrlKey || event.metaKey) && (event.key === "Delete" || event.key === "Backspace")) { - if (selectionMode && selectedMemoIds.size > 0) { - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - onDeleteSelectedMemos(); - return; - } - - if (!selectedMemoId || !visibleMemoIds.includes(selectedMemoId)) { - return; - } - - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - onDeleteMemo(selectedMemoId); - return; - } - - if (event.altKey || event.ctrlKey || event.metaKey) { - return; - } - - const currentIndex = selectedMemoId ? visibleMemoIds.indexOf(selectedMemoId) : -1; - - if (selectionMode && selectedMemoIds.size > 0 && event.key.toLowerCase() === "m") { - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - - const moveTargetSelect = moveTargetSelectRef.current; - - moveTargetSelect?.focus(); - - try { - (moveTargetSelect as (HTMLSelectElement & { showPicker?: () => void }) | null)?.showPicker?.(); - } catch { - // Some browsers require a trusted pointer gesture for showPicker; focus still gives keyboard access. - } - - return; - } - - if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") { - if (visibleMemoIds.length === 0) { - return; - } - - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - - const nextIndex = - event.key === "Home" - ? 0 - : event.key === "End" - ? visibleMemoIds.length - 1 - : event.key === "ArrowDown" - ? Math.min(currentIndex < 0 ? 0 : currentIndex + 1, visibleMemoIds.length - 1) - : Math.max(currentIndex < 0 ? 0 : currentIndex - 1, 0); - const nextMemoId = visibleMemoIds[nextIndex]; - const shouldExtendSelection = event.shiftKey; - const rangeAnchorMemoId = - lastSelectedMemoId && visibleMemoIds.includes(lastSelectedMemoId) - ? lastSelectedMemoId - : selectedMemoId && visibleMemoIds.includes(selectedMemoId) - ? selectedMemoId - : nextMemoId; - - onOpenMemo(nextMemoId); - - if (shouldExtendSelection) { - replaceRangeSelection(rangeAnchorMemoId, nextMemoId); - setLastSelectedMemoId(rangeAnchorMemoId); - } else { - setLastSelectedMemoId(nextMemoId); - } - - return; - } - - if (event.key === " " || event.key === "Spacebar") { - const targetMemoId = selectedMemoId && visibleMemoIds.includes(selectedMemoId) ? selectedMemoId : visibleMemoIds[0]; - - if (!targetMemoId) { - return; - } - - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - onToggleMemo(targetMemoId); - setLastSelectedMemoId(targetMemoId); - return; - } - - if (event.key === "Enter") { - if (!selectedMemoId || !visibleMemoIds.includes(selectedMemoId)) { - return; - } - - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - onOpenMemo(selectedMemoId); - return; - } - - if (event.key === "Delete" || event.key === "Backspace") { - if (selectionMode && selectedMemoIds.size > 0) { - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - onDeleteSelectedMemos(); - return; - } - - if (!selectedMemoId || !visibleMemoIds.includes(selectedMemoId)) { - return; - } - - event.preventDefault(); - setMemoContextMenu(null); - setSelectionContextMenu(null); - onDeleteMemo(selectedMemoId); - } - }; - - return ( -
-
- {selectionMode ? ( -
- -
{selectionCountLabel}
-
- ) : mobileSearchActive ? ( -
- -
- - handleSearchChange(event.target.value)} - onKeyDown={(event) => { - if (event.key !== "Escape") { - return; - } - - event.preventDefault(); - - if (search) { - handleClearSearch(); - return; - } - - onCancelMobileSearch(); - }} - className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400" - placeholder="搜索笔记" - /> - {search ? ( - - ) : null} -
- -
- ) : ( - - )} - {!mobileSearchActive ? ( - - ) : null} - {!mobileSearchActive ? ( -
-
- -
- -
- ) : null} -
-
{listTitle}
-
- {listContextLabel} · {listCountLabel} -
-
-
-
- -
- - {desktopFilterOpen ? ( -
- {filterOptions.map((option) => ( - - ))} -
- ) : null} -
-
- - {desktopSortOpen ? ( -
- {MEMO_SORT_OPTIONS.map((option) => ( - - ))} -
- ) : null} -
-
- - -
-
-
- -
- - {desktopActionsOpen ? ( -
- - - - -
- ) : null} -
-
-
-
-
- - handleSearchChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && search) { - event.preventDefault(); - handleClearSearch(); - } - }} - className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400" - placeholder="搜索笔记" - /> - {search ? ( - - ) : null} -
-
- {mobileFilterOptions.map((option) => ( - - ))} -
-
- {hasListConstraint ? ( -
- - {search.trim() ? `搜索「${search.trim()}」` : `筛选:${activeFilterLabel}`} · {filteredMemos.length} 条 - - -
- ) : null} -
- -
- {selectionMode ? ( -
-
- - {selectionCountLabel} -
-
- - - - - - - -
-
- ) : null} - - {isLoading ? ( -
加载中
- ) : filteredMemos.length === 0 ? ( -
-
- {memos.length === 0 ? (view === "trash" ? "回收站为空" : "暂无笔记") : "没有符合筛选的笔记"} -
-
- {memos.length === 0 - ? view === "trash" - ? "删除的笔记会显示在这里。" - : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。" - : "试试切换筛选条件,或调整搜索关键词。"} -
- {memos.length === 0 && canCreateMemo && view !== "trash" ? ( - - ) : null} -
- ) : ( -
- {memoGroups.map((group) => ( -
-
- {group.label} - {group.items.length} -
-
- {group.items.map((memo) => ( - onOpenMemo(memo.id)} - onDelete={() => onDeleteMemo(memo.id)} - onRestore={() => onRestoreMemo(memo.id)} - onOpenContextMenu={(event) => handleOpenMemoContextMenu(memo, event)} - onOpenSelectionContextMenu={(event) => handleOpenSelectionContextMenu(memo, event)} - onOpenSelectionKeyboardContextMenu={(target) => handleOpenSelectionKeyboardContextMenu(memo, target)} - onOpenKeyboardContextMenu={(target) => handleOpenMemoKeyboardContextMenu(memo, target)} - onToggle={(event) => handleToggleMemo(memo.id, event)} - /> - ))} -
-
- ))} -
- )} -
- {memoContextMenu ? ( -
event.preventDefault()} - > - - - {view !== "trash" ? ( - - ) : null} -
- {view === "trash" ? ( - <> - - - - ) : ( - <> - - {contextMoveOpen ? ( -
- {moveNotebookOptions.map((option) => ( - - ))} -
- ) : null} - - - )} -
- ) : null} - {selectionContextMenu && selectedMemoIds.size > 0 ? ( -
event.preventDefault()} - > -
{selectionCountLabel}
- {view !== "trash" ? ( - <> - - {contextMoveOpen ? ( -
- {moveNotebookOptions.map((option) => ( - - ))} -
- ) : null} - - -
- - ) : null} - -
- -
- ) : null} - {selectionMode ? ( - 0 && !isDeleting} - canMove={selectedMemoIds.size > 0 && view !== "trash" && notebooks.length > 0 && !isMoving} - deleteTitle={selectionDeleteTitle} - isTrashView={view === "trash"} - moveTitle={selectionMoveTitle} - onDelete={onDeleteSelectedMemos} - onOpenMore={() => setMobileMoreOpen(true)} - onOpenMove={() => setMobileMoveOpen(true)} - /> - ) : null} - {mobileListActionsOpen ? ( - setMobileListActionsOpen(false)} - onEnterSelectionMode={() => { - setMobileListActionsOpen(false); - onEnterSelectionMode(); - }} - onOpenAssets={() => { - setMobileListActionsOpen(false); - onOpenAssets(); - }} - onOpenSettings={() => { - setMobileListActionsOpen(false); - onOpenSettings(); - }} - onOpenTags={() => { - setMobileListActionsOpen(false); - onOpenTags(); - }} - onOpenTrash={() => { - setMobileListActionsOpen(false); - onOpenTrash(); - }} - onListDensityChange={(value) => { - handleListDensityChange(value); - }} - onSortModeChange={(value) => { - setSortMode(value); - }} - /> - ) : null} - {mobileMoveOpen ? ( - setMobileMoveOpen(false)} - onMove={(notebookId) => { - setMoveTargetNotebookId(notebookId); - onMoveSelectedMemos(notebookId); - setMobileMoveOpen(false); - }} - /> - ) : null} - {mobileMoreOpen ? ( - = 2 && view !== "trash" && !isMerging} - canPin={selectedMemoIds.size > 0 && view !== "trash" && !isPinning} - canToggleVisibleSelection={canToggleVisibleMemoSelection} - mergeTitle={selectionMergeTitle} - pinLabel={selectionPinLabel} - pinTitle={selectionPinTitle} - selectedCount={selectedMemoIds.size} - selectionToggleLabel={visibleSelectionToggleLabel} - selectionToggleTitle={selectionToggleTitle} - onToggleVisibleSelection={() => { - setMobileMoreOpen(false); - if (allVisibleMemosSelected) { - handleClearVisibleMemos(); - return; - } - - handleSelectAllVisibleMemos(); - }} - onClearSelection={() => { - setMobileMoreOpen(false); - onClearSelection(); - }} - onClose={() => setMobileMoreOpen(false)} - onMerge={() => { - setMobileMoreOpen(false); - onMerge(); - }} - onPin={() => { - setMobileMoreOpen(false); - onPinSelectedMemos(selectedPinTarget); - }} - /> - ) : null} -
- ); -}; - -const sortMemos = (memos: MemoSummary[], sortMode: MemoSortMode, prioritizePinned = true) => - [...memos].sort((first, second) => { - if (prioritizePinned && first.isPinned !== second.isPinned) { - return first.isPinned ? -1 : 1; - } - - if (sortMode === "title-asc") { - const titleCompare = getMemoTitle(first.title).localeCompare(getMemoTitle(second.title), "zh-CN", { - numeric: true, - sensitivity: "base", - }); - - if (titleCompare !== 0) { - return titleCompare; - } - - return compareDateDesc(first.updatedAt, second.updatedAt); - } - - if (sortMode === "created-desc") { - return compareDateDesc(first.createdAt, second.createdAt); - } - - return compareDateDesc(first.updatedAt, second.updatedAt); - }); - -const filterMemos = (memos: MemoSummary[], filterMode: MemoFilterMode) => { - if (filterMode === "tagged") { - return memos.filter((memo) => memo.tags.length > 0); - } - - if (filterMode === "untagged") { - return memos.filter((memo) => memo.tags.length === 0); - } - - if (filterMode === "pinned") { - return memos.filter((memo) => memo.isPinned); - } - - return memos; -}; - -const getMobileFilterIcon = (filterMode: MemoFilterMode) => { - if (filterMode === "tagged") { - return ; - } - - if (filterMode === "untagged") { - return ; - } - - if (filterMode === "pinned") { - return ; - } - - return ; -}; - -const groupMemos = (memos: MemoSummary[], sortMode: MemoSortMode) => - sortMode === "title-asc" ? groupMemosByTitle(memos) : groupMemosByDate(memos, sortMode); - -const groupMemosByDate = (memos: MemoSummary[], sortMode: MemoSortMode) => { - const groups: Array<{ key: string; label: string; items: MemoSummary[] }> = []; - - for (const memo of memos) { - const date = new Date(sortMode === "created-desc" ? memo.createdAt : memo.updatedAt); - const { key, label } = getMemoMonthGroup(date); - const current = groups[groups.length - 1]; - - if (current?.key === key) { - current.items.push(memo); - continue; - } - - groups.push({ key, label, items: [memo] }); - } - - return groups; -}; - -const getMemoMonthGroup = (date: Date) => { - if (Number.isNaN(date.getTime())) { - return { key: "unknown", label: "未知时间" }; - } - - return { - key: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, - label: new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long" }).format(date), - }; -}; - -const groupMemosByTitle = (memos: MemoSummary[]) => { - const groups: Array<{ key: string; label: string; items: MemoSummary[] }> = []; - - for (const memo of memos) { - const title = getMemoTitle(memo.title); - const firstChar = title.trim().charAt(0).toLocaleUpperCase("zh-CN"); - const label = firstChar || "#"; - const current = groups[groups.length - 1]; - - if (current?.key === label) { - current.items.push(memo); - continue; - } - - groups.push({ key: label, label, items: [memo] }); - } - - return groups; -}; - -const compareDateDesc = (first: string, second: string) => { - const firstTime = Date.parse(first); - const secondTime = Date.parse(second); - - if (Number.isNaN(firstTime) && Number.isNaN(secondTime)) { - return 0; - } - - if (Number.isNaN(firstTime)) { - return 1; - } - - if (Number.isNaN(secondTime)) { - return -1; - } - - return secondTime - firstTime; -}; - -const getNotebookMoveOptions = (notebooks: Notebook[]) => { - const options: NotebookMoveOption[] = []; - const walk = (nodes: NotebookNode[], depth: number) => { - for (const node of nodes) { - options.push({ - id: node.id, - name: node.name, - selectLabel: `${"\u00A0\u00A0".repeat(depth)}${depth > 0 ? "└ " : ""}${node.name}`, - slug: node.slug, - depth, - }); - walk(node.children, depth + 1); - } - }; - - walk(buildNotebookTree(notebooks), 0); - return options; -}; - -const MemoCard = ({ - memo, - selected, - checked, - dragMemoIds, - isTrashView, - selectionMode, - listDensity, - multiSelectKeyDown, - onOpen, - onDelete, - onRestore, - onOpenContextMenu, - onOpenSelectionContextMenu, - onOpenSelectionKeyboardContextMenu, - onOpenKeyboardContextMenu, - onToggle, -}: { - memo: MemoSummary; - selected: boolean; - checked: boolean; - dragMemoIds: string[]; - isTrashView: boolean; - selectionMode: boolean; - listDensity: MemoListDensity; - multiSelectKeyDown: boolean; - onOpen: () => void; - onDelete: () => void; - onRestore: () => void; - onOpenContextMenu: (event: MouseEvent) => void; - onOpenSelectionContextMenu: (event: MouseEvent) => void; - onOpenSelectionKeyboardContextMenu: (target: HTMLElement) => void; - onOpenKeyboardContextMenu: (target: HTMLElement) => void; - onToggle: (event?: MouseEvent) => void; -}) => { - const handledModifierPointerRef = useRef(false); - const longPressTimerRef = useRef(null); - const longPressPointRef = useRef<{ x: number; y: number } | null>(null); - const [modifierHoverActive, setModifierHoverActive] = useState(false); - const memoTitle = getMemoTitle(memo.title); - const memoExcerpt = memo.excerpt.trim() || "空笔记"; - const showSelectionControl = selectionMode || checked || multiSelectKeyDown || modifierHoverActive; - const selectionControlLabel = checked ? `取消选择 ${memoTitle}` : `选择 ${memoTitle}`; - - const shouldToggleSelection = (event: MouseEvent) => - event.ctrlKey || event.metaKey || event.shiftKey || multiSelectKeyDown; - - const clearLongPressTimer = () => { - if (longPressTimerRef.current === null) { - return; - } - - window.clearTimeout(longPressTimerRef.current); - longPressTimerRef.current = null; - }; - - const resetLongPress = () => { - clearLongPressTimer(); - longPressPointRef.current = null; - }; - - useEffect(() => () => resetLongPress(), []); - - useEffect(() => { - if (!modifierHoverActive) { - return; - } - - const handleModifierKeyUp = (event: KeyboardEvent) => { - if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { - setModifierHoverActive(false); - } - }; - const handleWindowBlur = () => setModifierHoverActive(false); - - window.addEventListener("keyup", handleModifierKeyUp); - window.addEventListener("blur", handleWindowBlur); - - return () => { - window.removeEventListener("keyup", handleModifierKeyUp); - window.removeEventListener("blur", handleWindowBlur); - }; - }, [modifierHoverActive]); - - const markModifierPointerHandled = () => { - handledModifierPointerRef.current = true; - window.setTimeout(() => { - handledModifierPointerRef.current = false; - }, 450); - }; - - const handleModifierToggle = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - markModifierPointerHandled(); - onToggle(event); - }; - - const handleLongPressSelection = () => { - markModifierPointerHandled(); - - if (typeof navigator !== "undefined" && "vibrate" in navigator) { - navigator.vibrate(8); - } - - onToggle(); - }; - - const handleMouseDown = (event: MouseEvent) => { - if (shouldToggleSelection(event)) { - handleModifierToggle(event); - } - }; - - const handleMouseMove = (event: MouseEvent) => { - const modifierActive = event.ctrlKey || event.metaKey || event.shiftKey; - - setModifierHoverActive((current) => (current === modifierActive ? current : modifierActive)); - }; - - const handleMouseLeave = () => { - setModifierHoverActive(false); - }; - - const handlePointerDown = (event: ReactPointerEvent) => { - if (event.pointerType !== "touch" || selectionMode) { - return; - } - - try { - event.currentTarget.setPointerCapture(event.pointerId); - } catch { - // Some browsers may already release capture during gesture cancellation. - } - - clearLongPressTimer(); - longPressPointRef.current = { x: event.clientX, y: event.clientY }; - longPressTimerRef.current = window.setTimeout(() => { - longPressTimerRef.current = null; - longPressPointRef.current = null; - handleLongPressSelection(); - }, MEMO_LONG_PRESS_DELAY_MS); - }; - - const handlePointerMove = (event: ReactPointerEvent) => { - if (event.pointerType !== "touch" || !longPressPointRef.current) { - return; - } - - const xDistance = Math.abs(event.clientX - longPressPointRef.current.x); - const yDistance = Math.abs(event.clientY - longPressPointRef.current.y); - - if (xDistance > MEMO_LONG_PRESS_MOVE_TOLERANCE_PX || yDistance > MEMO_LONG_PRESS_MOVE_TOLERANCE_PX) { - resetLongPress(); - } - }; - - const handlePointerEnd = (event: ReactPointerEvent) => { - if (event.pointerType === "touch") { - try { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - } catch { - // Gesture cancellation can race with pointer capture release. - } - - resetLongPress(); - } - }; - - const handleDragStart = (event: DragEvent) => { - if (isTrashView || !isDesktopViewport() || dragMemoIds.length === 0) { - event.preventDefault(); - return; - } - - const dragLabel = dragMemoIds.length > 1 ? `移动 ${dragMemoIds.length} 条笔记` : `移动「${memoTitle}」`; - - resetLongPress(); - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData(MEMO_DRAG_MIME, JSON.stringify(dragMemoIds)); - event.dataTransfer.setData("text/plain", dragLabel); - setMemoDragPreview(event.dataTransfer, dragLabel); - }; - - const handleClick = (event: MouseEvent) => { - if (handledModifierPointerRef.current) { - event.preventDefault(); - event.stopPropagation(); - handledModifierPointerRef.current = false; - return; - } - - if (selectionMode) { - event.preventDefault(); - event.stopPropagation(); - onToggle(event); - return; - } - - if (shouldToggleSelection(event)) { - handleModifierToggle(event); - return; - } - - onOpen(); - }; - - const handleContextMenu = (event: MouseEvent) => { - if (longPressPointRef.current) { - event.preventDefault(); - event.stopPropagation(); - resetLongPress(); - handleLongPressSelection(); - return; - } - - if (selectionMode) { - event.preventDefault(); - event.stopPropagation(); - onOpenSelectionContextMenu(event); - return; - } - - event.preventDefault(); - event.stopPropagation(); - - if (handledModifierPointerRef.current) { - return; - } - - if (shouldToggleSelection(event)) { - markModifierPointerHandled(); - onToggle(event); - return; - } - - if (window.matchMedia("(min-width: 1024px)").matches) { - onOpenContextMenu(event); - } - }; - - const handleKeyDown = (event: ReactKeyboardEvent) => { - const opensContextMenu = event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"); - - if (!opensContextMenu) { - return; - } - - if (selectionMode) { - event.preventDefault(); - event.stopPropagation(); - onOpenSelectionKeyboardContextMenu(event.currentTarget); - return; - } - - event.preventDefault(); - event.stopPropagation(); - onOpenKeyboardContextMenu(event.currentTarget); - }; - - return ( -
-
- {showSelectionControl ? ( - - ) : null} - - {!selectionMode ? ( - - ) : null} -
-
- ); -}; - -const getMemoTitle = (title: string | null | undefined) => title?.trim() || DEFAULT_MEMO_TITLE; - -const formatMemoPreviewDate = (value: string) => { - const date = new Date(value); - - if (Number.isNaN(date.getTime())) { - return ""; - } - - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const memoDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); - - if (memoDay === today) { - return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(date); - } - - if (memoDay === today - 24 * 60 * 60 * 1000) { - return "昨天"; - } - - return new Intl.DateTimeFormat("zh-CN", { - year: "numeric", - month: "numeric", - day: "numeric", - }).format(date); -}; - -const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]); - -const getImageFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => { - if (!dataTransfer) { - return []; - } - - const fileItems = Array.from(dataTransfer.items ?? []) - .filter((item) => item.kind === "file") - .map((item) => item.getAsFile()) - .filter((file): file is File => Boolean(file)); - const files = fileItems.length > 0 ? fileItems : Array.from(dataTransfer.files ?? []); - - return files.filter((file) => SUPPORTED_PASTE_IMAGE_TYPES.has(file.type)); -}; - -const TemplatesDialog = ({ - canCreateMemo, - isCreating, - onClose, - onCreateMemo, -}: { - canCreateMemo: boolean; - isCreating: boolean; - onClose: () => void; - onCreateMemo: (template: MemoTemplate) => void; -}) => { - const dialogRef = useRef(null); - - useModalLayerControls(dialogRef, onClose); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- - 模板 -
-
选择一个模板,直接创建新笔记。
-
- -
- -
-
- {MEMO_TEMPLATES.map((template) => ( - - ))} -
- {!canCreateMemo ? ( -
- 当前无法创建笔记,请先选择可用笔记本。 -
- ) : null} -
-
-
- ); -}; - -const AssetsDialog = ({ onClose }: { onClose: () => void }) => { - const dialogRef = useRef(null); - const resourcesQuery = useQuery({ - queryKey: ["resources"], - queryFn: () => api.listResources(), - }); - const resources = resourcesQuery.data?.resources ?? []; - const summary = resourcesQuery.data?.summary ?? { - totalCount: 0, - totalBytes: 0, - imageCount: 0, - attachmentCount: 0, - }; - - useModalLayerControls(dialogRef, onClose); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- - 附件 -
-
- - - {formatBytes(summary.totalBytes)} - - {summary.totalCount} files - {summary.imageCount} images -
-
- -
- - -
-
- ); -}; - -const TagsDialog = ({ onClose }: { onClose: () => void }) => { - const queryClient = useQueryClient(); - const dialogRef = useRef(null); - const [editingTagName, setEditingTagName] = useState(null); - const [editingTagValue, setEditingTagValue] = useState(""); - const [tagDeleteConfirmation, setTagDeleteConfirmation] = useState(null); - const tagsQuery = useQuery({ - queryKey: ["tags"], - queryFn: () => api.listTags(), - }); - const renameMutation = useMutation({ - mutationFn: ({ tag, name }: { tag: string; name: string }) => api.renameTag(tag, name), - onSuccess: async () => { - setEditingTagName(null); - setEditingTagValue(""); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["tags"] }), - queryClient.invalidateQueries({ queryKey: ["memos"] }), - queryClient.invalidateQueries({ queryKey: ["memo"] }), - ]); - }, - }); - const deleteMutation = useMutation({ - mutationFn: api.deleteTag, - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["tags"] }), - queryClient.invalidateQueries({ queryKey: ["memos"] }), - queryClient.invalidateQueries({ queryKey: ["memo"] }), - ]); - }, - }); - const tags = tagsQuery.data?.tags ?? []; - - const handleRename = (tag: TagSummary) => { - setEditingTagName(tag.name); - setEditingTagValue(tag.name); - }; - - const handleCancelRename = () => { - setEditingTagName(null); - setEditingTagValue(""); - }; - - const handleSubmitRename = (tag: TagSummary) => { - const nextName = editingTagValue.trim(); - - if (!nextName || nextName === tag.name || renameMutation.isPending) { - return; - } - - renameMutation.mutate({ tag: tag.name, name: nextName }); - }; - - const handleDelete = (tag: TagSummary) => { - setTagDeleteConfirmation(tag); - }; - - useModalLayerControls(dialogRef, onClose, { active: !tagDeleteConfirmation }); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- - 标签 -
-
{tags.length} tags
-
- -
- -
- {tagsQuery.isLoading ? ( -
加载中
- ) : tags.length === 0 ? ( -
- 暂无标签 -
- ) : ( -
- {tags.map((tag) => { - const isEditing = editingTagName === tag.name; - const nextName = editingTagValue.trim(); - - return ( -
- - {isEditing ? ( -
{ - event.preventDefault(); - handleSubmitRename(tag); - }} - > - - setEditingTagValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - handleCancelRename(); - } - }} - /> -
- - -
-
- ) : ( - <> - #{tag.name} - - {tag.memoCount} 条笔记{tag.updatedAt ? ` · ${formatDateTime(tag.updatedAt)}` : ""} - - - )} -
- {!isEditing ? ( - <> - - - - ) : null} -
- ); - })} -
- )} -
-
- {tagDeleteConfirmation ? ( - setTagDeleteConfirmation(null)} - onConfirm={() => { - deleteMutation.mutate(tagDeleteConfirmation.name, { - onSuccess: () => setTagDeleteConfirmation(null), - }); - }} - /> - ) : null} -
- ); -}; - -const DEFAULT_TOKEN_SCOPES = ["read:notebooks", "read:memos", "read:tags"]; - -const SettingsDialog = ({ onClose }: { onClose: () => void }) => { - const queryClient = useQueryClient(); - const dialogRef = useRef(null); - const [name, setName] = useState("MCP Agent"); - const [selectedScopes, setSelectedScopes] = useState>(() => new Set(DEFAULT_TOKEN_SCOPES)); - const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null); - const [tokenRevokeConfirmation, setTokenRevokeConfirmation] = useState(null); - const tokensQuery = useQuery({ - queryKey: ["api-tokens"], - queryFn: () => api.listApiTokens(), - }); - const availableScopes = tokensQuery.data?.availableScopes ?? [ - "read:notebooks", - "write:notebooks", - "read:memos", - "write:memos", - "read:resources", - "write:resources", - "read:tags", - "write:tags", - ]; - const createMutation = useMutation({ - mutationFn: api.createApiToken, - onSuccess: async (data) => { - setCreatedToken(data); - setName(""); - setSelectedScopes(new Set(DEFAULT_TOKEN_SCOPES)); - await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); - }, - }); - const revokeMutation = useMutation({ - mutationFn: api.revokeApiToken, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); - }, - }); - const tokens = tokensQuery.data?.apiTokens ?? []; - - const toggleScope = (scope: string) => { - setSelectedScopes((current) => { - const next = new Set(current); - - if (next.has(scope)) { - next.delete(scope); - } else { - next.add(scope); - } - - return next; - }); - }; - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - const scopes = Array.from(selectedScopes); - - if (!name.trim() || scopes.length === 0) { - return; - } - - createMutation.mutate({ name: name.trim(), scopes }); - }; - - useModalLayerControls(dialogRef, onClose, { - active: !tokenRevokeConfirmation, - closeOnEscape: !createdToken, - }); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- - 设置 -
-
API Token / MCP
-
- -
- -
- {createdToken ? ( -
-
- - Token 已生成 -
-
- - -
-
明文 Token 只显示这一次。
-
- ) : null} - -
-
- setName(event.target.value)} - placeholder="Token 名称" - /> - -
-
- {availableScopes.map((scope) => ( - - ))} -
-
- -
- {tokensQuery.isLoading ? ( -
加载中
- ) : tokens.length === 0 ? ( -
- 暂无 API Token -
- ) : ( - tokens.map((token) => ( -
- - {token.name} - - {token.scopes.join(", ") || "no scopes"} - - - {token.lastUsedAt ? `Last used ${formatDateTime(token.lastUsedAt)}` : "Never used"} - - - -
- )) - )} -
-
-
- {tokenRevokeConfirmation ? ( - setTokenRevokeConfirmation(null)} - onConfirm={() => { - revokeMutation.mutate(tokenRevokeConfirmation.id, { - onSuccess: () => setTokenRevokeConfirmation(null), - }); - }} - /> - ) : null} -
- ); -}; - -const formatBytes = (bytes: number) => { - if (!Number.isFinite(bytes) || bytes <= 0) { - return "0 B"; - } - - const units = ["B", "KB", "MB", "GB", "TB"]; - const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - const value = bytes / 1024 ** exponent; - - return `${exponent === 0 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[exponent]}`; -}; - -const RevisionHistoryDialog = ({ - memo, - currentMarkdown, - onClose, - onRestored, -}: { - memo: MemoDetail; - currentMarkdown: string; - onClose: () => void; - onRestored: (memo: MemoDetail) => Promise; -}) => { - const dialogRef = useRef(null); - const [selectedRevisionId, setSelectedRevisionId] = useState(null); - const [restoreRevisionConfirmationId, setRestoreRevisionConfirmationId] = useState(null); - const revisionsQuery = useQuery({ - queryKey: ["memo-revisions", memo.id], - queryFn: () => api.listMemoRevisions(memo.id), - }); - const revisions = revisionsQuery.data?.revisions ?? []; - const selectedRevision = - revisions.find((revision) => revision.id === selectedRevisionId) ?? revisions[0] ?? null; - const diffSummary = useMemo( - () => summarizeMarkdownDiff(selectedRevision?.contentMarkdown ?? "", currentMarkdown), - [currentMarkdown, selectedRevision?.contentMarkdown] - ); - const restoreMutation = useMutation({ - mutationFn: (revisionId: string) => api.restoreMemoRevision(memo.id, revisionId), - onSuccess: async (data) => { - setRestoreRevisionConfirmationId(null); - await onRestored(data.memo); - }, - }); - - useEffect(() => { - if (!selectedRevisionId && revisions.length > 0) { - setSelectedRevisionId(revisions[0].id); - } - }, [revisions, selectedRevisionId]); - - useModalLayerControls(dialogRef, onClose, { active: !restoreRevisionConfirmationId, closeOnBrowserBack: true }); - - return ( -
-
event.stopPropagation()} - > - -
-
-
- - 版本历史 -
-
{getMemoTitle(memo.title)}
-
- -
- -
- - -
-
-
- {selectedRevision ? `${diffSummary.changed} changed lines` : "No revision selected"} -
- -
- -
- - -
-
-
-
- {restoreRevisionConfirmationId ? ( - setRestoreRevisionConfirmationId(null)} - onConfirm={() => restoreMutation.mutate(restoreRevisionConfirmationId)} - /> - ) : null} -
- ); -}; - -const RevisionPreview = ({ title, markdown }: { title: string; markdown: string }) => ( -
-
{title}
-
-      {markdown || "空笔记"}
-    
-
-); - -const summarizeMarkdownDiff = (left: string, right: string) => { - const leftLines = left.split("\n"); - const rightLines = right.split("\n"); - const maxLines = Math.max(leftLines.length, rightLines.length); - let changed = 0; - - for (let index = 0; index < maxLines; index += 1) { - if ((leftLines[index] ?? "") !== (rightLines[index] ?? "")) { - changed += 1; - } - } - - return { changed }; -}; - -const formatRevisionActor = (actor: string) => { - if (actor.startsWith("user:")) { - return "user"; - } - - if (actor.startsWith("agent:")) { - return "agent"; - } - - return actor || "system"; -}; - -const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => { - if (status === "conflict") { - return "conflict"; - } - - if (status === "syncing") { - return "saving"; - } - - return "queued"; -}; - -class MemoSaveRequestError extends Error { - originalError: unknown; - payload: MemoUpdateSyncPayload; - tagsText: string; - - constructor(originalError: unknown, payload: MemoUpdateSyncPayload, tagsText: string) { - super(originalError instanceof Error ? originalError.message : "Memo save failed"); - this.name = "MemoSaveRequestError"; - this.originalError = originalError; - this.payload = payload; - this.tagsText = tagsText; - } -} - -const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; readOnly: boolean }) => { - const disabled = readOnly || !editor; - const blockValue = getActiveBlockValue(editor); - const canRun = (command: (editor: Editor) => boolean) => { - if (!editor || readOnly) { - return false; - } - - return command(editor); - }; - const run = (command: (editor: Editor) => void) => { - if (!editor || readOnly) { - return; - } - - command(editor); - }; - - const setBlock = (value: string) => { - run((current) => { - const chain = current.chain().focus(); - - if (value === "paragraph") { - chain.setParagraph().run(); - return; - } - - if (value === "heading-1") { - chain.setHeading({ level: 1 }).run(); - return; - } - - if (value === "heading-2") { - chain.setHeading({ level: 2 }).run(); - return; - } - - if (value === "heading-3") { - chain.setHeading({ level: 3 }).run(); - } - }); - }; - - return ( -
-
-
-
- - - - current.can().chain().focus().undo().run())} - onClick={() => run((current) => current.chain().focus().undo().run())} - > - - - current.can().chain().focus().redo().run())} - onClick={() => run((current) => current.chain().focus().redo().run())} - > - - - - - current.can().chain().focus().toggleBold().run())} - onClick={() => run((current) => current.chain().focus().toggleBold().run())} - > - - - current.can().chain().focus().toggleItalic().run())} - onClick={() => run((current) => current.chain().focus().toggleItalic().run())} - > - - - current.can().chain().focus().toggleStrike().run())} - onClick={() => run((current) => current.chain().focus().toggleStrike().run())} - > - - - current.can().chain().focus().toggleCode().run())} - onClick={() => run((current) => current.chain().focus().toggleCode().run())} - > - - - - - run((current) => current.chain().focus().toggleBulletList().run())} - > - - - run((current) => current.chain().focus().toggleOrderedList().run())} - > - - - run((current) => current.chain().focus().toggleBlockquote().run())} - > - - - run((current) => current.chain().focus().toggleCodeBlock().run())} - > - - - run((current) => current.chain().focus().setHorizontalRule().run())} - > - - -
-
- ); -}; - -const EditorToolbarButton = ({ - active = false, - children, - disabled = false, - onClick, - title, -}: { - active?: boolean; - children: ReactNode; - disabled?: boolean; - onClick: () => void; - title: string; -}) => ( - -); - -const ToolbarDivider = () =>
; - -const MobileEditorActionsSheet = ({ - readOnly, - onClose, - onDelete, - onOpenHistory, - onPermanentDelete, - onRestore, -}: { - readOnly: boolean; - onClose: () => void; - onDelete: () => void; - onOpenHistory: () => void; - onPermanentDelete: () => void; - onRestore: () => void; -}) => { - const dialogRef = useRef(null); - const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); - - useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); - - return ( -
-
event.stopPropagation()} - > - -
-
- 笔记操作 -
- -
-
- - {readOnly ? ( - <> - - - - ) : ( - - )} -
-
-
- ); -}; - -const getActiveBlockValue = (editor: Editor | null) => { - if (!editor) { - return "paragraph"; - } - - if (editor.isActive("heading", { level: 1 })) { - return "heading-1"; - } - - if (editor.isActive("heading", { level: 2 })) { - return "heading-2"; - } - - if (editor.isActive("heading", { level: 3 })) { - return "heading-3"; - } - - return "paragraph"; -}; - -const EditorPane = ({ - memo, - isTrashView, - notebooks, - isLoading, - imageCompressionEnabled, - hasNextMemo, - hasPreviousMemo, - onBackToList, - onOpenNextMemo, - onOpenPreviousMemo, - onSaved, - onDeleted, - onPermanentDeleted, - onRestored, -}: { - memo: MemoDetail | null; - isTrashView: boolean; - notebooks: Notebook[]; - isLoading: boolean; - imageCompressionEnabled: boolean; - hasNextMemo: boolean; - hasPreviousMemo: boolean; - onBackToList: () => void; - onOpenNextMemo: () => void; - onOpenPreviousMemo: () => void; - onSaved: (memo: MemoDetail) => Promise; - onDeleted: (memoId: string) => Promise; - onPermanentDeleted: (memoId: string) => Promise; - onRestored: (memoId: string) => Promise; -}) => { - const queryClient = useQueryClient(); - const [title, setTitle] = useState(""); - const [tagsText, setTagsText] = useState(""); - const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "queued" | "error" | "conflict">("idle"); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [dirtyVersion, setDirtyVersion] = useState(0); - const [, setEditorStateVersion] = useState(0); - const [imageUploadState, setImageUploadState] = useState<"idle" | "compressing" | "uploading" | "error">("idle"); - const [historyOpen, setHistoryOpen] = useState(false); - const [editorActionsOpen, setEditorActionsOpen] = useState(false); - const [mobileNotebookSheetOpen, setMobileNotebookSheetOpen] = useState(false); - const [notebookUpdatePending, setNotebookUpdatePending] = useState(false); - const notebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); - const memoRef = useRef(memo); - const editorRef = useRef(null); - const editorActionsRef = useDismissableLayer(editorActionsOpen, () => setEditorActionsOpen(false)); - useFloatingMenuControls(editorActionsRef, editorActionsOpen, () => setEditorActionsOpen(false)); - const hydratingRef = useRef(false); - const hasUnsavedChangesRef = useRef(false); - const editingMemoIdRef = useRef(memo?.id ?? null); - const imageCompressionEnabledRef = useRef(imageCompressionEnabled); - const insertImageFiles = useCallback((files: File[]) => { - const currentMemo = memoRef.current; - const currentEditor = editorRef.current; - - if (!currentMemo || currentMemo.isDeleted || !currentEditor || files.length === 0) { - return; - } - - const targetMemoId = currentMemo.id; - - void (async () => { - setImageUploadState("uploading"); - - try { - for (const file of files) { - const shouldCompress = imageCompressionEnabledRef.current; - setImageUploadState(shouldCompress ? "compressing" : "uploading"); - const uploadFile = shouldCompress ? (await compressImageForUpload(file)).file : file; - - setImageUploadState("uploading"); - const { resource } = await api.uploadMemoResource(targetMemoId, uploadFile); - void queryClient.invalidateQueries({ queryKey: ["resources"] }); - - if (memoRef.current?.id !== targetMemoId || !editorRef.current) { - setImageUploadState("idle"); - return; - } - - editorRef.current - .chain() - .focus() - .setImage({ - src: resource.url, - alt: file.name, - title: file.name, - }) - .run(); - } - - setImageUploadState("idle"); - } catch { - setImageUploadState("error"); - window.setTimeout(() => setImageUploadState("idle"), 2200); - } - })(); - }, [queryClient]); - const editor = useEditor({ - extensions: [ - StarterKit, - Image.configure({ - allowBase64: false, - inline: false, - }), - Placeholder.configure({ - placeholder: "开始记录...", - }), - ], - content: memo?.contentJson ?? { type: "doc", content: [{ type: "paragraph" }] }, - editable: Boolean(memo && !memo.isDeleted && !isTrashView), - editorProps: { - attributes: { - class: "prose prose-slate max-w-none", - }, - handlePaste: (_view, event) => { - const files = getImageFilesFromDataTransfer(event.clipboardData); - - if (files.length === 0) { - return false; - } - - event.preventDefault(); - insertImageFiles(files); - return true; - }, - handleDrop: (_view, event) => { - const files = getImageFilesFromDataTransfer(event.dataTransfer); - - if (files.length === 0) { - return false; - } - - event.preventDefault(); - insertImageFiles(files); - return true; - }, - }, - }); - - useEffect(() => { - imageCompressionEnabledRef.current = imageCompressionEnabled; - }, [imageCompressionEnabled]); - - useEffect(() => { - editorRef.current = editor; - - return () => { - if (editorRef.current === editor) { - editorRef.current = null; - } - }; - }, [editor]); - - useEffect(() => { - if (!editor) { - return; - } - - const refreshToolbar = () => setEditorStateVersion((version) => version + 1); - - editor.on("selectionUpdate", refreshToolbar); - editor.on("transaction", refreshToolbar); - - return () => { - editor.off("selectionUpdate", refreshToolbar); - editor.off("transaction", refreshToolbar); - }; - }, [editor]); - - const persistCurrentDraft = useCallback( - (nextTitle = title, nextTagsText = tagsText) => { - const currentMemo = memoRef.current; - const currentEditor = editorRef.current; - - if (!currentMemo || currentMemo.isDeleted || !currentEditor) { - return; - } - - void localDb.drafts.put({ - memoId: currentMemo.id, - title: nextTitle, - tagsText: nextTagsText, - contentJson: currentEditor.getJSON() as TiptapDoc, - updatedAt: new Date().toISOString(), - }); - }, - [tagsText, title] - ); - - const markDirty = useCallback(() => { - const currentMemo = memoRef.current; - - if (hydratingRef.current || currentMemo?.isDeleted) { - return; - } - - hasUnsavedChangesRef.current = true; - setHasUnsavedChanges(true); - setDirtyVersion((version) => version + 1); - setSaveState((current) => (current === "conflict" ? current : "idle")); - }, []); - - const currentSnapshot = useCallback(() => { - const currentEditor = editorRef.current; - - if (!currentEditor) { - return null; - } - - return JSON.stringify({ - title, - tagsText, - contentJson: currentEditor.getJSON(), - }); - }, [tagsText, title]); - - useEffect(() => { - const currentEditor = editorRef.current; - let cancelled = false; - - if (!memo) { - memoRef.current = null; - editingMemoIdRef.current = null; - hasUnsavedChangesRef.current = false; - setHasUnsavedChanges(false); - setTitle(""); - setTagsText(""); - setSaveState("idle"); - currentEditor?.commands.clearContent(); - return; - } - - const sameMemo = editingMemoIdRef.current === memo.id; - memoRef.current = memo; - currentEditor?.setEditable(!memo.isDeleted && !isTrashView); - - if (sameMemo && hasUnsavedChangesRef.current && !memo.isDeleted) { - return; - } - - void (async () => { - const [draft, queuedUpdate] = memo.isDeleted - ? [null, null] - : await Promise.all([ - localDb.drafts.get(memo.id), - localDb.syncQueue.get(getMemoUpdateQueueId(memo.id)), - ]); - - if (cancelled) { - return; - } - - const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0; - const remoteUpdatedAt = Date.parse(memo.updatedAt); - const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt)); - const nextTitle = useDraft && draft ? draft.title : memo.title ?? ""; - const nextTagsText = useDraft && draft ? draft.tagsText : memo.tags.join(", "); - const nextContent = useDraft && draft ? draft.contentJson : memo.contentJson; - const nextHasUnsavedChanges = Boolean(useDraft && !queuedUpdate); - - hydratingRef.current = true; - editingMemoIdRef.current = memo.id; - hasUnsavedChangesRef.current = nextHasUnsavedChanges; - setHasUnsavedChanges(nextHasUnsavedChanges); - setSaveState(queuedUpdate ? syncStatusToSaveState(queuedUpdate.status) : "idle"); - setTitle(nextTitle); - setTagsText(nextTagsText); - - if (currentEditor) { - currentEditor.commands.setContent(nextContent); - } - - window.setTimeout(() => { - hydratingRef.current = false; - }, 0); - })(); - - return () => { - cancelled = true; - }; - }, [isTrashView, memo, editor]); - - useEffect(() => { - if (!editor || !memo) { - return; - } - - const persistDraft = () => { - if (hydratingRef.current || memoRef.current?.isDeleted) { - return; - } - - persistCurrentDraft(); - markDirty(); - }; - - editor.on("update", persistDraft); - return () => { - editor.off("update", persistDraft); - }; - }, [editor, markDirty, memo, persistCurrentDraft]); - - const saveMutation = useMutation({ - mutationFn: async () => { - const currentMemo = memoRef.current; - const currentEditor = editorRef.current; - - if (!currentMemo || !currentEditor) { - throw new Error("No memo selected"); - } - - if (currentMemo.isDeleted) { - throw new Error("Deleted memos are read-only"); - } - - const snapshot = currentSnapshot(); - - if (!snapshot) { - throw new Error("Editor is not ready"); - } - - const contentJson = currentEditor.getJSON() as TiptapDoc; - const payload: MemoUpdateSyncPayload = { - memoId: currentMemo.id, - expectedRevision: currentMemo.revision, - title, - contentJson, - tags: parseTagsText(tagsText), - }; - let data; - - try { - data = await api.updateMemo(currentMemo.id, { - expectedRevision: payload.expectedRevision, - title: payload.title, - contentJson: payload.contentJson, - tags: payload.tags, - }); - } catch (error) { - throw new MemoSaveRequestError(error, payload, tagsText); - } - - return { memo: data.memo, snapshot }; - }, - onMutate: () => setSaveState("saving"), - onSuccess: async ({ memo: savedMemo, snapshot }) => { - memoRef.current = savedMemo; - await onSaved(savedMemo); - - if (currentSnapshot() === snapshot) { - hasUnsavedChangesRef.current = false; - setHasUnsavedChanges(false); - await localDb.drafts.delete(savedMemo.id); - setSaveState("saved"); - window.setTimeout(() => setSaveState("idle"), 1400); - return; - } - - persistCurrentDraft(); - hasUnsavedChangesRef.current = true; - setHasUnsavedChanges(true); - setSaveState("idle"); - }, - onError: async (error) => { - const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error; - const code = - sourceError && typeof sourceError === "object" && "code" in sourceError - ? String(sourceError.code) - : null; - - if (code === "revision_conflict") { - setSaveState("conflict"); - return; - } - - if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) { - await queueMemoUpdate(error.payload); - await localDb.drafts.put({ - memoId: error.payload.memoId, - title: error.payload.title, - tagsText: error.tagsText, - contentJson: error.payload.contentJson, - updatedAt: new Date().toISOString(), - }); - - hasUnsavedChangesRef.current = false; - setHasUnsavedChanges(false); - setSaveState("queued"); - return; - } - - setSaveState("error"); - }, - }); - - useEffect(() => { - if ( - !memo || - memo.isDeleted || - !editor || - !hasUnsavedChanges || - saveMutation.isPending || - saveState === "conflict" - ) { - return; - } - - const timer = window.setTimeout(() => { - saveMutation.mutate(); - }, 1200); - - return () => window.clearTimeout(timer); - }, [dirtyVersion, editor, hasUnsavedChanges, memo, saveMutation, saveState]); - - if (isLoading) { - return
加载中
; - } - - if (!memo) { - return ( -
-
- -
选择或新建一条笔记
-
-
- ); - } - - const readOnly = isTrashView || memo.isDeleted; - const saveLabel = - saveState === "saving" - ? "保存中" - : saveState === "saved" - ? "已保存" - : saveState === "queued" - ? "待同步" - : saveState === "conflict" - ? "有冲突" - : saveState === "error" - ? "保存失败" - : hasUnsavedChanges - ? "未保存" - : "已保存"; - const saveStateClassName = - saveState === "error" || saveState === "conflict" - ? "bg-rose-50 text-rose-700" - : saveState === "queued" - ? "bg-amber-50 text-amber-700" - : saveState === "saving" || hasUnsavedChanges - ? "bg-emerald-50 text-emerald-700" - : "bg-slate-100 text-slate-500"; - const imageUploadLabel = - imageUploadState === "error" - ? "图片失败" - : imageUploadState === "compressing" - ? "压缩中" - : imageUploadState === "uploading" - ? "上传中" - : null; - const mobileStatusLabel = imageUploadLabel ?? saveLabel; - const mobileStatusClassName = - imageUploadState === "error" - ? "bg-rose-50 text-rose-700" - : imageUploadState !== "idle" - ? "bg-emerald-50 text-emerald-700" - : saveStateClassName; - const updatedLabel = formatDateTime(memo.updatedAt); - const currentNotebookLabel = notebookOptions.find((notebook) => notebook.id === memo.notebookId)?.name ?? "笔记本"; - const mobileDoneDisabled = - saveMutation.isPending || - notebookUpdatePending || - imageUploadState === "compressing" || - imageUploadState === "uploading"; - const updateMemoNotebook = (notebookId: string, sourceMemo: MemoDetail = memoRef.current ?? memo) => { - if (readOnly || notebookId === sourceMemo.notebookId || notebookUpdatePending) { - setMobileNotebookSheetOpen(false); - return; - } - - setNotebookUpdatePending(true); - setSaveState("saving"); - - void api - .updateMemo(sourceMemo.id, { - expectedRevision: sourceMemo.revision, - notebookId, - }) - .then(async (data) => { - memoRef.current = data.memo; - await onSaved(data.memo); - setSaveState("saved"); - window.setTimeout(() => setSaveState("idle"), 1200); - }) - .catch(() => setSaveState("error")) - .finally(() => { - setNotebookUpdatePending(false); - setMobileNotebookSheetOpen(false); - }); - }; - const handleNotebookChange = (notebookId: string) => { - if (!hasUnsavedChanges || saveMutation.isPending) { - updateMemoNotebook(notebookId); - return; - } - - saveMutation.mutate(undefined, { - onSuccess: ({ memo: savedMemo }) => updateMemoNotebook(notebookId, savedMemo), - }); - }; - const handleMobileDone = () => { - if (readOnly || !editor || !hasUnsavedChanges) { - onBackToList(); - return; - } - - saveMutation.mutate(undefined, { - onSuccess: () => onBackToList(), - onError: (error) => { - const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error; - - if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) { - onBackToList(); - } - }, - }); - }; - - return ( -
-
-
-
- -
- - -
-
- - -
- - - - 更新于 {updatedLabel} - -
- -
- {imageUploadState !== "idle" ? ( - - {imageUploadState === "error" - ? "图片上传失败" - : imageUploadState === "compressing" - ? "图片压缩中" - : "图片上传中"} - - ) : null} - - {saveLabel} - - - {mobileStatusLabel} - - - - {!readOnly ? ( - - ) : null} -
- - {editorActionsOpen ? ( -
- - {readOnly ? ( - <> - - - - ) : ( - - )} -
- ) : null} - {editorActionsOpen ? ( - setEditorActionsOpen(false)} - onOpenHistory={() => { - setEditorActionsOpen(false); - setHistoryOpen(true); - }} - onRestore={() => { - setEditorActionsOpen(false); - void onRestored(memo.id); - }} - onPermanentDelete={() => { - setEditorActionsOpen(false); - void onPermanentDeleted(memo.id); - }} - onDelete={() => { - setEditorActionsOpen(false); - void onDeleted(memo.id); - }} - /> - ) : null} -
-
-
- -
- { - setTitle(event.target.value); - persistCurrentDraft(event.target.value, tagsText); - markDirty(); - }} - className="block w-full rounded-md border-0 bg-transparent text-2xl font-semibold leading-tight text-slate-950 outline-none transition placeholder:text-slate-300 focus-visible:bg-slate-50 focus-visible:shadow-[inset_3px_0_0_#627f58] sm:text-3xl" - placeholder={DEFAULT_MEMO_TITLE} - /> - -
- -
- -
- -
- {historyOpen ? ( - setHistoryOpen(false)} - onRestored={async (restoredMemo) => { - await localDb.drafts.delete(restoredMemo.id); - hasUnsavedChangesRef.current = false; - setHasUnsavedChanges(false); - await onSaved(restoredMemo); - setHistoryOpen(false); - }} - /> - ) : null} - {mobileNotebookSheetOpen ? ( - setMobileNotebookSheetOpen(false)} - onSelect={handleNotebookChange} - /> - ) : null} -
- ); -}; diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx new file mode 100644 index 00000000..1fc76b59 --- /dev/null +++ b/apps/web/src/components/EditorPane.tsx @@ -0,0 +1,1030 @@ +import { useRef, useState, useEffect, useCallback, useMemo } from "react"; +import { useQueryClient, useMutation } from "@tanstack/react-query"; +import { useEditor, EditorContent, type Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Image from "@tiptap/extension-image"; +import Placeholder from "@tiptap/extension-placeholder"; +import { + ChevronLeft, + ChevronRight, + ChevronDown, + History, + RotateCcw, + Trash2, + Tags, + Save, + MoreHorizontal, + Sparkles, + X, + Plus, + CheckSquare, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; +import { EditorToolbar } from "./EditorToolbar"; +import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog"; +import { api } from "@/lib/api"; +import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; +import type { Notebook, MemoDetail, TiptapDoc } from "@edgeever/shared"; +import type { MemoUpdateSyncPayload } from "@/lib/app-helpers"; +import { + getNotebookMoveOptions, + DEFAULT_MEMO_TITLE, + localDb, + getMemoUpdateQueueId, + compressImageForUpload, + docToMarkdown, + shouldQueueMemoSaveError, + queueMemoUpdate, +} from "@/lib/app-helpers"; +import { useDismissableLayer, useFloatingMenuControls, useBottomSheetSwipeToClose, useModalLayerControls } from "@/lib/app-hooks"; + +const MobileSheetGrabber = ({ onPointerDown }: { onPointerDown?: (event: React.PointerEvent) => void }) => ( +
+
+
+); + +const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]); + +const getImageFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => { + if (!dataTransfer) { + return []; + } + + const fileItems = Array.from(dataTransfer.items ?? []) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + const files = fileItems.length > 0 ? fileItems : Array.from(dataTransfer.files ?? []); + + return files.filter((file) => SUPPORTED_PASTE_IMAGE_TYPES.has(file.type)); +}; + +const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => { + if (status === "conflict") { + return "conflict"; + } + if (status === "syncing") { + return "saving"; + } + return "queued"; +}; + +class MemoSaveRequestError extends Error { + originalError: unknown; + payload: MemoUpdateSyncPayload; + tagsText: string; + + constructor(originalError: unknown, payload: MemoUpdateSyncPayload, tagsText: string) { + super(originalError instanceof Error ? originalError.message : "Memo save failed"); + this.name = "MemoSaveRequestError"; + this.originalError = originalError; + this.payload = payload; + this.tagsText = tagsText; + } +} + +const MobileEditorActionsSheet = ({ + readOnly, + onClose, + onDelete, + onOpenHistory, + onPermanentDelete, + onRestore, +}: { + readOnly: boolean; + onClose: () => void; + onDelete: () => void; + onOpenHistory: () => void; + onPermanentDelete: () => void; + onRestore: () => void; +}) => { + const dialogRef = useRef(null); + const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); + useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); + + return ( +
+
event.stopPropagation()} + > + +
+
+ 笔记操作 +
+ +
+
+ + {readOnly ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; + +const MobileNotebookSelectSheet = ({ + isUpdating, + options, + selectedNotebookId, + onClose, + onSelect, +}: { + isUpdating: boolean; + options: any[]; + selectedNotebookId: string; + onClose: () => void; + onSelect: (notebookId: string) => void; +}) => { + const dialogRef = useRef(null); + const listRef = useRef(null); + const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); + useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); + + useEffect(() => { + window.setTimeout(() => { + const selectedNode = listRef.current?.querySelector( + `[data-mobile-notebook-select-id="${CSS.escape(selectedNotebookId)}"]` + ); + selectedNode?.scrollIntoView({ block: "center" }); + }, 0); + }, [selectedNotebookId]); + + return ( +
+
event.stopPropagation()} + > + +
+
+ 所在笔记本 +
+ +
+
+ {options.map((option) => { + const selected = option.id === selectedNotebookId; + return ( + + ); + })} +
+
+
+ ); +}; + +export const EditorPane = ({ + memo, + isTrashView, + notebooks, + isLoading, + imageCompressionEnabled, + hasNextMemo, + hasPreviousMemo, + onBackToList, + onOpenNextMemo, + onOpenPreviousMemo, + onSaved, + onDeleted, + onPermanentDeleted, + onRestored, +}: { + memo: MemoDetail | null; + isTrashView: boolean; + notebooks: Notebook[]; + isLoading: boolean; + imageCompressionEnabled: boolean; + hasNextMemo: boolean; + hasPreviousMemo: boolean; + onBackToList: () => void; + onOpenNextMemo: () => void; + onOpenPreviousMemo: () => void; + onSaved: (memo: MemoDetail) => Promise; + onDeleted: (memoId: string) => Promise; + onPermanentDeleted: (memoId: string) => Promise; + onRestored: (memoId: string) => Promise; +}) => { + const queryClient = useQueryClient(); + const [title, setTitle] = useState(""); + const [tagsText, setTagsText] = useState(""); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "queued" | "error" | "conflict">("idle"); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [dirtyVersion, setDirtyVersion] = useState(0); + const [, setEditorStateVersion] = useState(0); + const [imageUploadState, setImageUploadState] = useState<"idle" | "compressing" | "uploading" | "error">("idle"); + const [historyOpen, setHistoryOpen] = useState(false); + const [editorActionsOpen, setEditorActionsOpen] = useState(false); + const [mobileNotebookSheetOpen, setMobileNotebookSheetOpen] = useState(false); + const [notebookUpdatePending, setNotebookUpdatePending] = useState(false); + const notebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); + + const memoRef = useRef(memo); + const editorRef = useRef(null); + const hydratingRef = useRef(false); + const hasUnsavedChangesRef = useRef(false); + const editingMemoIdRef = useRef(memo?.id ?? null); + const imageCompressionEnabledRef = useRef(imageCompressionEnabled); + + const insertImageFiles = useCallback((files: File[]) => { + const currentMemo = memoRef.current; + const currentEditor = editorRef.current; + + if (!currentMemo || currentMemo.isDeleted || !currentEditor || files.length === 0) { + return; + } + + const targetMemoId = currentMemo.id; + + void (async () => { + setImageUploadState("uploading"); + + try { + for (const file of files) { + const shouldCompress = imageCompressionEnabledRef.current; + setImageUploadState(shouldCompress ? "compressing" : "uploading"); + const uploadFile = shouldCompress ? (await compressImageForUpload(file)).file : file; + + setImageUploadState("uploading"); + const { resource } = await api.uploadMemoResource(targetMemoId, uploadFile); + void queryClient.invalidateQueries({ queryKey: ["resources"] }); + + if (memoRef.current?.id !== targetMemoId || !editorRef.current) { + setImageUploadState("idle"); + return; + } + + editorRef.current + .chain() + .focus() + .setImage({ + src: resource.url, + alt: file.name, + title: file.name, + }) + .run(); + } + + setImageUploadState("idle"); + } catch { + setImageUploadState("error"); + window.setTimeout(() => setImageUploadState("idle"), 2200); + } + })(); + }, [queryClient]); + + const editor = useEditor({ + extensions: [ + StarterKit, + Image.configure({ + allowBase64: false, + inline: false, + }), + Placeholder.configure({ + placeholder: "开始记录...", + }), + ], + content: memo?.contentJson ?? { type: "doc", content: [{ type: "paragraph" }] }, + editable: Boolean(memo && !memo.isDeleted && !isTrashView), + editorProps: { + attributes: { + class: "prose prose-slate max-w-none focus:outline-none min-h-[300px] px-4 py-3 sm:px-7", + }, + handlePaste: (_view, event) => { + const files = getImageFilesFromDataTransfer(event.clipboardData); + + if (files.length === 0) { + return false; + } + + event.preventDefault(); + insertImageFiles(files); + return true; + }, + handleDrop: (_view, event) => { + const files = getImageFilesFromDataTransfer(event.dataTransfer); + + if (files.length === 0) { + return false; + } + + event.preventDefault(); + insertImageFiles(files); + return true; + }, + }, + }); + + useEffect(() => { + imageCompressionEnabledRef.current = imageCompressionEnabled; + }, [imageCompressionEnabled]); + + useEffect(() => { + editorRef.current = editor; + return () => { + if (editorRef.current === editor) { + editorRef.current = null; + } + }; + }, [editor]); + + useEffect(() => { + if (!editor) { + return; + } + + const refreshToolbar = () => setEditorStateVersion((version) => version + 1); + editor.on("selectionUpdate", refreshToolbar); + editor.on("transaction", refreshToolbar); + + return () => { + editor.off("selectionUpdate", refreshToolbar); + editor.off("transaction", refreshToolbar); + }; + }, [editor]); + + const persistCurrentDraft = useCallback( + (nextTitle = title, nextTagsText = tagsText) => { + const currentMemo = memoRef.current; + const currentEditor = editorRef.current; + + if (!currentMemo || currentMemo.isDeleted || !currentEditor) { + return; + } + + void localDb.drafts.put({ + memoId: currentMemo.id, + title: nextTitle, + tagsText: nextTagsText, + contentJson: currentEditor.getJSON() as TiptapDoc, + updatedAt: new Date().toISOString(), + }); + }, + [tagsText, title] + ); + + const markDirty = useCallback(() => { + const currentMemo = memoRef.current; + if (hydratingRef.current || currentMemo?.isDeleted) { + return; + } + + hasUnsavedChangesRef.current = true; + setHasUnsavedChanges(true); + setDirtyVersion((version) => version + 1); + setSaveState((current) => (current === "conflict" ? current : "idle")); + }, []); + + const currentSnapshot = useCallback(() => { + const currentEditor = editorRef.current; + if (!currentEditor) { + return null; + } + + return JSON.stringify({ + title, + tagsText, + contentJson: currentEditor.getJSON(), + }); + }, [tagsText, title]); + + useEffect(() => { + const currentEditor = editorRef.current; + let cancelled = false; + + if (!memo) { + memoRef.current = null; + editingMemoIdRef.current = null; + hasUnsavedChangesRef.current = false; + setHasUnsavedChanges(false); + setTitle(""); + setTagsText(""); + setSaveState("idle"); + currentEditor?.commands.clearContent(); + return; + } + + const sameMemo = editingMemoIdRef.current === memo.id; + memoRef.current = memo; + currentEditor?.setEditable(!memo.isDeleted && !isTrashView); + + if (sameMemo && hasUnsavedChangesRef.current && !memo.isDeleted) { + return; + } + + void (async () => { + const [draft, queuedUpdate] = memo.isDeleted + ? [null, null] + : await Promise.all([ + localDb.drafts.get(memo.id), + localDb.syncQueue.get(getMemoUpdateQueueId(memo.id)), + ]); + + if (cancelled) { + return; + } + + const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0; + const remoteUpdatedAt = Date.parse(memo.updatedAt); + const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt)); + const nextTitle = useDraft && draft ? draft.title : memo.title ?? ""; + const nextTagsText = useDraft && draft ? draft.tagsText : memo.tags.join(", "); + const nextContent = useDraft && draft ? draft.contentJson : memo.contentJson; + const nextHasUnsavedChanges = Boolean(useDraft && !queuedUpdate); + + hydratingRef.current = true; + editingMemoIdRef.current = memo.id; + hasUnsavedChangesRef.current = nextHasUnsavedChanges; + setHasUnsavedChanges(nextHasUnsavedChanges); + setSaveState(queuedUpdate ? syncStatusToSaveState(queuedUpdate.status) : "idle"); + setTitle(nextTitle); + setTagsText(nextTagsText); + + if (currentEditor) { + currentEditor.commands.setContent(nextContent); + } + + window.setTimeout(() => { + hydratingRef.current = false; + }, 0); + })(); + + return () => { + cancelled = true; + }; + }, [isTrashView, memo, editor]); + + useEffect(() => { + if (!editor || !memo) { + return; + } + + const persistDraft = () => { + if (hydratingRef.current || memoRef.current?.isDeleted) { + return; + } + persistCurrentDraft(); + markDirty(); + }; + + editor.on("update", persistDraft); + return () => { + editor.off("update", persistDraft); + }; + }, [editor, markDirty, memo, persistCurrentDraft]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const currentMemo = memoRef.current; + const currentEditor = editorRef.current; + + if (!currentMemo || !currentEditor) { + throw new Error("No memo selected"); + } + + if (currentMemo.isDeleted) { + throw new Error("Deleted memos are read-only"); + } + + const snapshot = currentSnapshot(); + if (!snapshot) { + throw new Error("Editor is not ready"); + } + + const contentJson = currentEditor.getJSON() as TiptapDoc; + const payload: MemoUpdateSyncPayload = { + memoId: currentMemo.id, + expectedRevision: currentMemo.revision, + title, + contentJson, + tags: parseTagsText(tagsText), + }; + let data; + + try { + data = await api.updateMemo(currentMemo.id, { + expectedRevision: payload.expectedRevision, + title: payload.title, + contentJson: payload.contentJson, + tags: payload.tags, + }); + } catch (error) { + throw new MemoSaveRequestError(error, payload, tagsText); + } + + return { memo: data.memo, snapshot }; + }, + onMutate: () => setSaveState("saving"), + onSuccess: async ({ memo: savedMemo, snapshot }) => { + memoRef.current = savedMemo; + await onSaved(savedMemo); + + if (currentSnapshot() === snapshot) { + hasUnsavedChangesRef.current = false; + setHasUnsavedChanges(false); + await localDb.drafts.delete(savedMemo.id); + setSaveState("saved"); + window.setTimeout(() => setSaveState("idle"), 1400); + return; + } + + persistCurrentDraft(); + hasUnsavedChangesRef.current = true; + setHasUnsavedChanges(true); + setSaveState("idle"); + }, + onError: async (error) => { + const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error; + const code = + sourceError && typeof sourceError === "object" && "code" in sourceError + ? String(sourceError.code) + : null; + + if (code === "revision_conflict") { + setSaveState("conflict"); + return; + } + + if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) { + await queueMemoUpdate(error.payload); + await localDb.drafts.put({ + memoId: error.payload.memoId, + title: error.payload.title, + tagsText: error.tagsText, + contentJson: error.payload.contentJson, + updatedAt: new Date().toISOString(), + }); + + hasUnsavedChangesRef.current = false; + setHasUnsavedChanges(false); + setSaveState("queued"); + return; + } + + setSaveState("error"); + }, + }); + + useEffect(() => { + if ( + !memo || + memo.isDeleted || + !editor || + !hasUnsavedChanges || + saveMutation.isPending || + saveState === "conflict" + ) { + return; + } + + const timer = window.setTimeout(() => { + saveMutation.mutate(); + }, 1200); + + return () => window.clearTimeout(timer); + }, [dirtyVersion, editor, hasUnsavedChanges, memo, saveMutation, saveState]); + + if (isLoading) { + return
加载中
; + } + + if (!memo) { + return ( +
+
+ +
选择或新建一条笔记
+
+
+ ); + } + + const readOnly = isTrashView || memo.isDeleted; + const saveLabel = + saveState === "saving" + ? "保存中" + : saveState === "saved" + ? "已保存" + : saveState === "queued" + ? "待同步" + : saveState === "conflict" + ? "有冲突" + : saveState === "error" + ? "保存失败" + : hasUnsavedChanges + ? "未保存" + : "已保存"; + + const saveStateClassName = + saveState === "error" || saveState === "conflict" + ? "bg-rose-50 text-rose-700" + : saveState === "queued" + ? "bg-amber-50 text-amber-700" + : saveState === "saving" || hasUnsavedChanges + ? "bg-emerald-50 text-emerald-700" + : "bg-slate-100 text-slate-500"; + + const imageUploadLabel = + imageUploadState === "error" + ? "图片失败" + : imageUploadState === "compressing" + ? "压缩中" + : imageUploadState === "uploading" + ? "上传中" + : null; + + const mobileStatusLabel = imageUploadLabel ?? saveLabel; + const mobileStatusClassName = + imageUploadState === "error" + ? "bg-rose-50 text-rose-700" + : imageUploadState !== "idle" + ? "bg-emerald-50 text-emerald-700" + : saveStateClassName; + + const updatedLabel = formatDateTime(memo.updatedAt); + const currentNotebookLabel = notebookOptions.find((notebook) => notebook.id === memo.notebookId)?.name ?? "笔记本"; + + const mobileDoneDisabled = + saveMutation.isPending || + notebookUpdatePending || + imageUploadState === "compressing" || + imageUploadState === "uploading"; + + const updateMemoNotebook = (notebookId: string, sourceMemo: MemoDetail = memoRef.current ?? memo) => { + if (readOnly || notebookId === sourceMemo.notebookId || notebookUpdatePending) { + setMobileNotebookSheetOpen(false); + return; + } + + setNotebookUpdatePending(true); + setSaveState("saving"); + + void api + .updateMemo(sourceMemo.id, { + expectedRevision: sourceMemo.revision, + notebookId, + }) + .then(async (data) => { + memoRef.current = data.memo; + await onSaved(data.memo); + setSaveState("saved"); + window.setTimeout(() => setSaveState("idle"), 1200); + }) + .catch(() => setSaveState("error")) + .finally(() => { + setNotebookUpdatePending(false); + setMobileNotebookSheetOpen(false); + }); + }; + + const handleNotebookChange = (notebookId: string) => { + if (!hasUnsavedChanges || saveMutation.isPending) { + updateMemoNotebook(notebookId); + return; + } + + saveMutation.mutate(undefined, { + onSuccess: ({ memo: savedMemo }) => updateMemoNotebook(notebookId, savedMemo), + }); + }; + + const handleMobileDone = () => { + if (readOnly || !editor || !hasUnsavedChanges) { + onBackToList(); + return; + } + + saveMutation.mutate(undefined, { + onSuccess: () => onBackToList(), + onError: (error) => { + const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error; + if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) { + onBackToList(); + } + }, + }); + }; + + return ( +
+
+
+
+ +
+ + +
+
+ + +
+ + +
+ +
+ + + 更新于 {updatedLabel} + +
+ +
+ {imageUploadState !== "idle" && ( + + {imageUploadState === "error" + ? "图片上传失败" + : imageUploadState === "compressing" + ? "图片压缩中" + : "图片上传中"} + + )} + + {saveLabel} + + + {mobileStatusLabel} + + + + {!readOnly && ( + + )} + + + + + + { + setHistoryOpen(true); + }} + > + + 版本历史 + + {readOnly ? ( + <> + void onRestored(memo.id)} + > + + 恢复笔记 + + + void onPermanentDeleted(memo.id)} + > + + 彻底删除 + + + ) : ( + <> + + void onDeleted(memo.id)} + > + + 删除笔记 + + + )} + + +
+
+ +
+ { + setTitle(event.target.value); + persistCurrentDraft(event.target.value, tagsText); + markDirty(); + }} + className="block w-full rounded-md border-0 bg-transparent text-2xl font-bold leading-tight text-slate-950 outline-none transition placeholder:text-slate-300 focus-visible:bg-slate-50 focus-visible:shadow-[inset_3px_0_0_#627f58] sm:text-3xl" + placeholder={DEFAULT_MEMO_TITLE} + /> + +
+ +
+ +
+ +
+ + {historyOpen && ( + setHistoryOpen(false)} + onRestored={async (restoredMemo) => { + await localDb.drafts.delete(restoredMemo.id); + hasUnsavedChangesRef.current = false; + setHasUnsavedChanges(false); + await onSaved(restoredMemo); + setHistoryOpen(false); + }} + /> + )} + + {mobileNotebookSheetOpen && ( + setMobileNotebookSheetOpen(false)} + onSelect={handleNotebookChange} + /> + )} +
+ ); +}; diff --git a/apps/web/src/components/EditorToolbar.tsx b/apps/web/src/components/EditorToolbar.tsx new file mode 100644 index 00000000..448e2d41 --- /dev/null +++ b/apps/web/src/components/EditorToolbar.tsx @@ -0,0 +1,215 @@ +import type { ReactNode } from "react"; +import type { Editor } from "@tiptap/react"; +import { + Undo2, + Redo2, + Bold, + Italic, + Strikethrough, + Code2, + List, + ListOrdered, + Quote, + SquareCode, + Minus, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; +import { getActiveBlockValue } from "@/lib/app-helpers"; + +const EditorToolbarButton = ({ + active = false, + children, + disabled = false, + onClick, + title, +}: { + active?: boolean; + children: ReactNode; + disabled?: boolean; + onClick: () => void; + title: string; +}) => ( + +); + +const ToolbarDivider = () =>
; + +export const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; readOnly: boolean }) => { + const disabled = readOnly || !editor; + const blockValue = getActiveBlockValue(editor); + + const canRun = (command: (editor: Editor) => boolean) => { + if (!editor || readOnly) { + return false; + } + return command(editor); + }; + + const run = (command: (editor: Editor) => void) => { + if (!editor || readOnly) { + return; + } + command(editor); + }; + + const setBlock = (value: string) => { + run((current) => { + const chain = current.chain().focus(); + + if (value === "paragraph") { + chain.setParagraph().run(); + return; + } + + if (value === "heading-1") { + chain.setHeading({ level: 1 }).run(); + return; + } + + if (value === "heading-2") { + chain.setHeading({ level: 2 }).run(); + return; + } + + if (value === "heading-3") { + chain.setHeading({ level: 3 }).run(); + } + }); + }; + + return ( +
+
+
+
+ + + + current.can().chain().focus().undo().run())} + onClick={() => run((current) => current.chain().focus().undo().run())} + > + + + current.can().chain().focus().redo().run())} + onClick={() => run((current) => current.chain().focus().redo().run())} + > + + + + + current.can().chain().focus().toggleBold().run())} + onClick={() => run((current) => current.chain().focus().toggleBold().run())} + > + + + current.can().chain().focus().toggleItalic().run())} + onClick={() => run((current) => current.chain().focus().toggleItalic().run())} + > + + + current.can().chain().focus().toggleStrike().run())} + onClick={() => run((current) => current.chain().focus().toggleStrike().run())} + > + + + current.can().chain().focus().toggleCode().run())} + onClick={() => run((current) => current.chain().focus().toggleCode().run())} + > + + + + + run((current) => current.chain().focus().toggleBulletList().run())} + > + + + run((current) => current.chain().focus().toggleOrderedList().run())} + > + + + run((current) => current.chain().focus().toggleBlockquote().run())} + > + + + run((current) => current.chain().focus().toggleCodeBlock().run())} + > + + + run((current) => current.chain().focus().setHorizontalRule().run())} + > + + +
+
+ ); +}; diff --git a/apps/web/src/components/LoginScreen.tsx b/apps/web/src/components/LoginScreen.tsx new file mode 100644 index 00000000..12496d26 --- /dev/null +++ b/apps/web/src/components/LoginScreen.tsx @@ -0,0 +1,72 @@ +import { useState, type FormEvent } from "react"; +import { LockKeyhole } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface LoginScreenProps { + error: string | null; + isSubmitting: boolean; + onSubmit: (payload: { username: string; password: string }) => void; +} + +export const LoginScreen = ({ error, isSubmitting, onSubmit }: LoginScreenProps) => { + const [username, setUsername] = useState("admin"); + const [password, setPassword] = useState(""); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!username.trim() || !password) { + return; + } + + onSubmit({ username: username.trim(), password }); + }; + + return ( +
+
+
+
+ +
+
+

登录 EdgeEver

+

自托管笔记工作区

+
+
+ +
+ + + + + {error ? ( +
{error}
+ ) : null} + + +
+
+
+ ); +}; diff --git a/apps/web/src/components/MemoCard.tsx b/apps/web/src/components/MemoCard.tsx new file mode 100644 index 00000000..9c23bbea --- /dev/null +++ b/apps/web/src/components/MemoCard.tsx @@ -0,0 +1,431 @@ +import { useRef, useState, useEffect, type DragEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react"; +import type { MemoSummary } from "@edgeever/shared"; +import { cn } from "@/lib/utils"; +import type { MemoListDensity } from "@/lib/app-helpers"; +import { getMemoTitle, MEMO_DRAG_MIME, setMemoDragPreview } from "@/lib/app-helpers"; + +const MEMO_LONG_PRESS_DELAY_MS = 520; +const MEMO_LONG_PRESS_MOVE_TOLERANCE_PX = 14; + +const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; + +const formatMemoPreviewDate = (value: string) => { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return ""; + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const memoDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + + if (memoDay === today) { + return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(date); + } + + if (memoDay === today - 24 * 60 * 60 * 1000) { + return "昨天"; + } + + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "numeric", + day: "numeric", + }).format(date); +}; + +export const MemoCard = ({ + memo, + selected, + checked, + dragMemoIds, + isTrashView, + selectionMode, + listDensity, + multiSelectKeyDown, + onOpen, + onDelete, + onRestore, + onOpenContextMenu, + onOpenSelectionContextMenu, + onOpenSelectionKeyboardContextMenu, + onOpenKeyboardContextMenu, + onToggle, +}: { + memo: MemoSummary; + selected: boolean; + checked: boolean; + dragMemoIds: string[]; + isTrashView: boolean; + selectionMode: boolean; + listDensity: MemoListDensity; + multiSelectKeyDown: boolean; + onOpen: () => void; + onDelete: () => void; + onRestore: () => void; + onOpenContextMenu: (event: MouseEvent) => void; + onOpenSelectionContextMenu: (event: MouseEvent) => void; + onOpenSelectionKeyboardContextMenu: (target: HTMLElement) => void; + onOpenKeyboardContextMenu: (target: HTMLElement) => void; + onToggle: (event?: MouseEvent) => void; +}) => { + const handledModifierPointerRef = useRef(false); + const longPressTimerRef = useRef(null); + const longPressPointRef = useRef<{ x: number; y: number } | null>(null); + const [modifierHoverActive, setModifierHoverActive] = useState(false); + const memoTitle = getMemoTitle(memo.title); + const memoExcerpt = memo.excerpt.trim() || "空笔记"; + const showSelectionControl = selectionMode || checked || multiSelectKeyDown || modifierHoverActive; + const selectionControlLabel = checked ? `取消选择 ${memoTitle}` : `选择 ${memoTitle}`; + + const shouldToggleSelection = (event: MouseEvent) => + event.ctrlKey || event.metaKey || event.shiftKey || multiSelectKeyDown; + + const clearLongPressTimer = () => { + if (longPressTimerRef.current === null) { + return; + } + + window.clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + }; + + const resetLongPress = () => { + clearLongPressTimer(); + longPressPointRef.current = null; + }; + + useEffect(() => () => resetLongPress(), []); + + useEffect(() => { + if (!modifierHoverActive) { + return; + } + + const handleModifierKeyUp = (event: KeyboardEvent) => { + if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { + setModifierHoverActive(false); + } + }; + const handleWindowBlur = () => setModifierHoverActive(false); + + window.addEventListener("keyup", handleModifierKeyUp); + window.addEventListener("blur", handleWindowBlur); + + return () => { + window.removeEventListener("keyup", handleModifierKeyUp); + window.removeEventListener("blur", handleWindowBlur); + }; + }, [modifierHoverActive]); + + const markModifierPointerHandled = () => { + handledModifierPointerRef.current = true; + window.setTimeout(() => { + handledModifierPointerRef.current = false; + }, 450); + }; + + const handleModifierToggle = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + markModifierPointerHandled(); + onToggle(event); + }; + + const handleLongPressSelection = () => { + markModifierPointerHandled(); + + if (typeof navigator !== "undefined" && "vibrate" in navigator) { + navigator.vibrate(8); + } + + onToggle(); + }; + + const handleMouseDown = (event: MouseEvent) => { + if (shouldToggleSelection(event)) { + handleModifierToggle(event); + } + }; + + const handleMouseMove = (event: MouseEvent) => { + const modifierActive = event.ctrlKey || event.metaKey || event.shiftKey; + + setModifierHoverActive((current) => (current === modifierActive ? current : modifierActive)); + }; + + const handleMouseLeave = () => { + setModifierHoverActive(false); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.pointerType !== "touch" || selectionMode) { + return; + } + + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Some browsers may already release capture during gesture cancellation. + } + + clearLongPressTimer(); + longPressPointRef.current = { x: event.clientX, y: event.clientY }; + longPressTimerRef.current = window.setTimeout(() => { + longPressTimerRef.current = null; + longPressPointRef.current = null; + handleLongPressSelection(); + }, MEMO_LONG_PRESS_DELAY_MS); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + if (event.pointerType !== "touch" || !longPressPointRef.current) { + return; + } + + const xDistance = Math.abs(event.clientX - longPressPointRef.current.x); + const yDistance = Math.abs(event.clientY - longPressPointRef.current.y); + + if (xDistance > MEMO_LONG_PRESS_MOVE_TOLERANCE_PX || yDistance > MEMO_LONG_PRESS_MOVE_TOLERANCE_PX) { + resetLongPress(); + } + }; + + const handlePointerEnd = (event: ReactPointerEvent) => { + if (event.pointerType === "touch") { + try { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + } catch { + // Gesture cancellation can race with pointer capture release. + } + + resetLongPress(); + } + }; + + const handleDragStart = (event: DragEvent) => { + if (isTrashView || !isDesktopViewport() || dragMemoIds.length === 0) { + event.preventDefault(); + return; + } + + const dragLabel = dragMemoIds.length > 1 ? `移动 ${dragMemoIds.length} 条笔记` : `移动「${memoTitle}」`; + + resetLongPress(); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(MEMO_DRAG_MIME, JSON.stringify(dragMemoIds)); + event.dataTransfer.setData("text/plain", dragLabel); + setMemoDragPreview(event.dataTransfer, dragLabel); + }; + + const handleClick = (event: MouseEvent) => { + if (handledModifierPointerRef.current) { + event.preventDefault(); + event.stopPropagation(); + handledModifierPointerRef.current = false; + return; + } + + if (selectionMode) { + event.preventDefault(); + event.stopPropagation(); + onToggle(event); + return; + } + + if (shouldToggleSelection(event)) { + handleModifierToggle(event); + return; + } + + onOpen(); + }; + + const handleContextMenu = (event: MouseEvent) => { + if (longPressPointRef.current) { + event.preventDefault(); + event.stopPropagation(); + resetLongPress(); + handleLongPressSelection(); + return; + } + + if (selectionMode) { + event.preventDefault(); + event.stopPropagation(); + onOpenSelectionContextMenu(event); + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (handledModifierPointerRef.current) { + return; + } + + if (shouldToggleSelection(event)) { + markModifierPointerHandled(); + onToggle(event); + return; + } + + if (isDesktopViewport()) { + onOpenContextMenu(event); + } + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + const opensContextMenu = event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"); + + if (!opensContextMenu) { + return; + } + + if (selectionMode) { + event.preventDefault(); + event.stopPropagation(); + onOpenSelectionKeyboardContextMenu(event.currentTarget); + return; + } + + event.preventDefault(); + event.stopPropagation(); + onOpenKeyboardContextMenu(event.currentTarget); + }; + + return ( +
+
+ {showSelectionControl && ( + + )} + + {!selectionMode && ( + + )} +
+
+ ); +}; diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx new file mode 100644 index 00000000..82ba89f5 --- /dev/null +++ b/apps/web/src/components/MemoListPane.tsx @@ -0,0 +1,2028 @@ +import { + useState, + useMemo, + useRef, + useEffect, + type MouseEvent, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; +import { + X, + ChevronLeft, + ChevronRight, + ChevronDown, + Search, + MoreHorizontal, + Tags, + Archive, + Trash2, + KeyRound, + CheckSquare, + ArrowDownWideNarrow, + LayoutList, + List, + FileText as FileIcon, + Star, + RotateCcw, + Folder, + Merge, + FilePlus2, + Plus, + Compass, + Sparkles, + Layers, + Settings, + MoreVertical, + RefreshCw, + CheckCircle2, + TagX, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; +import { MemoCard } from "./MemoCard"; +import { cn } from "@/lib/utils"; +import type { Notebook, AuthUser, MemoSummary } from "@edgeever/shared"; +import type { + MemoFilterMode, + MemoSortMode, + MemoListDensity, + MemoContextMenuState, + MemoSelectionContextMenuState, + SyncQueueSummary, +} from "@/lib/app-helpers"; +import { + MEMO_FILTER_OPTIONS, + MEMO_SORT_OPTIONS, + filterMemos, + sortMemos, + groupMemos, + getNotebookMoveOptions, + readMemoListDensityPreference, + writeMemoListDensityPreference, +} from "@/lib/app-helpers"; +import { useDismissableLayer, useFloatingMenuControls, useBottomSheetSwipeToClose, useModalLayerControls } from "@/lib/app-hooks"; + +const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; + +const MobileSheetGrabber = ({ onPointerDown }: { onPointerDown?: (event: React.PointerEvent) => void }) => ( +
+
+
+); + +const getSelectionCountLabel = (count: number) => (count > 0 ? `已选择 ${count} 条` : "选择笔记"); + +const getMobileFilterIcon = (filterMode: MemoFilterMode) => { + if (filterMode === "tagged") { + return ; + } + if (filterMode === "untagged") { + return ; + } + if (filterMode === "pinned") { + return ; + } + return ; +}; + +const memoListDensityOptions = [ + { value: "preview" as const, label: "预览列表", icon: }, + { value: "compact" as const, label: "紧凑列表", icon: }, +]; + +const mobileSortOptions = MEMO_SORT_OPTIONS; + +const MobileListActionButton = ({ + disabled = false, + icon, + label, + onClick, +}: { + disabled?: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +}) => ( + +); + +const MobileListActionsSheet = ({ + canSelectMemos, + isSelectionMode, + listDescription, + listDensity, + listTitle, + sortMode, + onClose, + onEnterSelectionMode, + onOpenAssets, + onOpenSettings, + onOpenTags, + onOpenTrash, + onListDensityChange, + onSortModeChange, +}: { + canSelectMemos: boolean; + isSelectionMode: boolean; + listDescription: string; + listDensity: MemoListDensity; + listTitle: string; + sortMode: MemoSortMode; + onClose: () => void; + onEnterSelectionMode: () => void; + onOpenAssets: () => void; + onOpenSettings: () => void; + onOpenTags: () => void; + onOpenTrash: () => void; + onListDensityChange: (value: MemoListDensity) => void; + onSortModeChange: (value: MemoSortMode) => void; +}) => { + const dialogRef = useRef(null); + const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); + useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); + + return ( +
+
event.stopPropagation()} + > + +
+
+
+ 列表选项 +
+
+ {listTitle} · {listDescription} +
+
+ +
+
+ {!isSelectionMode && ( + <> + } + label="选择笔记" + onClick={onEnterSelectionMode} + /> +
+ + )} + +
显示方式
+ {memoListDensityOptions.map((option) => ( + + ))} + +
+ +
排序方式
+ {mobileSortOptions.map((option: any) => ( + + ))} + +
+ + } label="标签" onClick={onOpenTags} /> + } label="附件" onClick={onOpenAssets} /> + } label="回收站" onClick={onOpenTrash} /> + } label="MCP Token" onClick={onOpenSettings} /> +
+
+
+ ); +}; + +const MobileSelectionActionButton = ({ + disabled = false, + icon, + label, + title = label, + onClick, +}: { + disabled?: boolean; + icon: ReactNode; + label: string; + title?: string; + onClick: () => void; +}) => ( + +); + +const MobileSelectionActionBar = ({ + canDelete, + canMove, + deleteTitle, + isTrashView, + moveTitle, + onDelete, + onOpenMore, + onOpenMove, +}: { + canDelete: boolean; + canMove: boolean; + deleteTitle: string; + isTrashView: boolean; + moveTitle: string; + onDelete: () => void; + onOpenMore: () => void; + onOpenMove: () => void; +}) => ( + +); + +const MobileMoveSheet = ({ + isMoving, + notebooks, + selectedCount, + selectedNotebookId, + onClose, + onMove, +}: { + isMoving: boolean; + notebooks: Notebook[]; + selectedCount: number; + selectedNotebookId: string; + onClose: () => void; + onMove: (notebookId: string) => void; +}) => { + const dialogRef = useRef(null); + const listRef = useRef(null); + const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); + const [notebookSearch, setNotebookSearch] = useState(""); + const options = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); + const selectedCountLabel = getSelectionCountLabel(selectedCount); + const moveSearchQuery = notebookSearch.trim().toLocaleLowerCase("zh-CN"); + + const filteredOptions = useMemo(() => { + if (!moveSearchQuery) { + return options; + } + return options.filter((option) => + [option.name, option.selectLabel, option.slug ?? ""].some((value) => + value.toLocaleLowerCase("zh-CN").includes(moveSearchQuery) + ) + ); + }, [moveSearchQuery, options]); + + useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); + + useEffect(() => { + if (moveSearchQuery) { + return; + } + window.setTimeout(() => { + const selectedNode = listRef.current?.querySelector( + `[data-mobile-move-notebook-id="${CSS.escape(selectedNotebookId)}"]` + ); + selectedNode?.scrollIntoView({ block: "center" }); + }, 0); + }, [moveSearchQuery, selectedNotebookId]); + + return ( +
+
event.stopPropagation()} + > + +
+
+
+ 移动到笔记本 +
+
{selectedCountLabel}
+
+ +
+
+
+ + setNotebookSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && notebookSearch) { + event.preventDefault(); + setNotebookSearch(""); + } + }} + /> + {notebookSearch && ( + + )} +
+
+
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option: any) => { + const selected = option.id === selectedNotebookId; + return ( + + ); + }) + ) : ( +
没有找到笔记本
+ )} +
+
+
+ ); +}; + +const MobileSelectionMoreSheet = ({ + canMerge, + canPin, + canToggleVisibleSelection, + mergeTitle, + pinLabel, + pinTitle, + selectedCount, + selectionToggleLabel, + selectionToggleTitle, + onClearSelection, + onClose, + onMerge, + onPin, + onToggleVisibleSelection, +}: { + canMerge: boolean; + canPin: boolean; + canToggleVisibleSelection: boolean; + mergeTitle: string; + pinLabel: string; + pinTitle: string; + selectedCount: number; + selectionToggleLabel: string; + selectionToggleTitle: string; + onClearSelection: () => void; + onClose: () => void; + onMerge: () => void; + onPin: () => void; + onToggleVisibleSelection: () => void; +}) => { + const dialogRef = useRef(null); + const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); + useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); + + return ( +
+
event.stopPropagation()} + > + +
+
+
+ 批量操作 +
+
{getSelectionCountLabel(selectedCount)}
+
+ +
+ + + + +
+
+ ); +}; + +const CheckCircleCheck = ({ className }: { className?: string }) => ( + + + +); + +const MobileHomeHeader = ({ + user, + isOnline, + isSyncingQueuedChanges, + onSyncQueuedChanges, +}: { + user: AuthUser | null; + isOnline: boolean; + isSyncingQueuedChanges: boolean; + onSyncQueuedChanges: () => void; +}) => ( +
+
+ + {user?.username?.[0]?.toUpperCase() ?? "E"} + + EdgeEver +
+
+ +
+
+); + +const MobileQuickActions = ({ + canCreateMemo, + isCreating, + locked, + onCreateChecklist, + onCreateMemo, + onOpenAssets, + onOpenTags, + onOpenTemplates, +}: { + canCreateMemo: boolean; + isCreating: boolean; + locked: boolean; + onCreateChecklist: () => void; + onCreateMemo: () => void; + onOpenAssets: () => void; + onOpenTags: () => void; + onOpenTemplates: () => void; +}) => ( +
+ + + +
+); + +export const MemoListPane = ({ + user, + notebooks, + notebook, + memos, + selectedMemoId, + selectedMemoIds, + selectionMode, + isCreating, + canCreateMemo, + isPinning, + isMoving, + isMerging, + isDeleting, + view, + search, + searchFocusToken, + mobileSearchActive, + isOnline, + isSyncingQueuedChanges, + onOpenMemo, + onDeleteMemo, + onRestoreMemo, + onTogglePinMemo, + onMoveMemo, + onMoveSelectedMemos, + onPinSelectedMemos, + onDeleteSelectedMemos, + onMerge, + onEnterSelectionMode, + onClearSelection, + onToggleMemo, + onReplaceSelection, + onSearch, + onCancelMobileSearch, + onOpenNotebookPicker, + onOpenTags, + onOpenAssets, + onOpenTrash, + onOpenSettings, + onCreateMemo, + onCreateChecklist, + onOpenTemplates, + onSyncQueuedChanges, + mobileListActionsOpen, + setMobileListActionsOpen, + mobileMoveOpen, + setMobileMoveOpen, + mobileMoreOpen, + setMobileMoreOpen, + desktopFilterOpen, + setDesktopFilterOpen, + desktopSortOpen, + setDesktopSortOpen, + desktopActionsOpen, + setDesktopActionsOpen, + isLoading, + multiSelectKeyDown, +}: { + isLoading: boolean; + multiSelectKeyDown: boolean; + user: AuthUser | null; + notebooks: Notebook[]; + notebook: Notebook | null; + memos: MemoSummary[]; + selectedMemoId: string | null; + selectedMemoIds: Set; + selectionMode: boolean; + isCreating: boolean; + canCreateMemo: boolean; + isPinning: boolean; + isMoving: boolean; + isMerging: boolean; + isDeleting: boolean; + view: string; + search: string; + searchFocusToken: number; + mobileSearchActive: boolean; + isOnline: boolean; + isSyncingQueuedChanges: boolean; + onOpenMemo: (memoId: string) => void; + onDeleteMemo: (memoId: string) => void; + onRestoreMemo: (memoId: string) => void; + onTogglePinMemo: (memo: MemoSummary) => void; + onMoveMemo: (memoId: string, notebookId: string) => void; + onMoveSelectedMemos: (notebookId: string) => void; + onPinSelectedMemos: (pinned: boolean) => void; + onDeleteSelectedMemos: () => void; + onMerge: () => void; + onEnterSelectionMode: () => void; + onClearSelection: () => void; + onToggleMemo: (memoId: string, rangeIds?: string[]) => void; + onReplaceSelection: (memoIds: string[]) => void; + onSearch: (query: string) => void; + onCancelMobileSearch: () => void; + onOpenNotebookPicker: () => void; + onOpenTags: () => void; + onOpenAssets: () => void; + onOpenTrash: () => void; + onOpenSettings: () => void; + onCreateMemo: () => void; + onCreateChecklist: () => void; + onOpenTemplates: () => void; + onSyncQueuedChanges: () => void; + mobileListActionsOpen: boolean; + setMobileListActionsOpen: (open: boolean) => void; + mobileMoveOpen: boolean; + setMobileMoveOpen: (open: boolean) => void; + mobileMoreOpen: boolean; + setMobileMoreOpen: (open: boolean) => void; + desktopFilterOpen: boolean; + setDesktopFilterOpen: (open: boolean) => void; + desktopSortOpen: boolean; + setDesktopSortOpen: (open: boolean) => void; + desktopActionsOpen: boolean; + setDesktopActionsOpen: (open: boolean) => void; +}) => { + const [memoContextMenu, setMemoContextMenu] = useState(null); + const [selectionContextMenu, setSelectionContextMenu] = useState(null); + const [contextMoveOpen, setContextMoveOpen] = useState(false); + const [filterMode, setFilterMode] = useState("all"); + const [sortMode, setSortMode] = useState("updated-desc"); + const [listDensity, setListDensity] = useState(() => readMemoListDensityPreference()); + const [lastSelectedMemoId, setLastSelectedMemoId] = useState(null); + const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(""); + + const filterOptions = MEMO_FILTER_OPTIONS; + const mobileFilterOptions = useMemo(() => filterOptions.filter((option: any) => option.value !== "all"), [filterOptions]); + const filteredMemos = useMemo(() => filterMemos(memos, filterMode), [filterMode, memos]); + const sortedMemos = useMemo(() => sortMemos(filteredMemos, sortMode, view !== "trash"), [filteredMemos, sortMode, view]); + const visibleMemoIds = useMemo(() => sortedMemos.map((memo) => memo.id), [sortedMemos]); + const memoGroups = useMemo(() => groupMemos(sortedMemos, sortMode), [sortedMemos, sortMode]); + const moveNotebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); + const selectedMemosInList = useMemo(() => memos.filter((memo) => selectedMemoIds.has(memo.id)), [memos, selectedMemoIds]); + + const searchInputRef = useRef(null); + const mobileSearchInputRef = useRef(null); + const listRootRef = useRef(null); + const listScrollRef = useRef(null); + const moveTargetSelectRef = useRef(null); + const previousSelectionModeRef = useRef(selectionMode); + const skipSelectedMemoAutoScrollRef = useRef(false); + + const canEnterSelectionMode = visibleMemoIds.length > 0; + const selectedVisibleMemoCount = visibleMemoIds.filter((memoId) => selectedMemoIds.has(memoId)).length; + const allVisibleMemosSelected = visibleMemoIds.length > 0 && selectedVisibleMemoCount === visibleMemoIds.length; + const canToggleVisibleMemoSelection = visibleMemoIds.length > 0; + const visibleSelectionToggleLabel = allVisibleMemosSelected ? "全不选当前列表" : "全选当前列表"; + + const listTitle = view === "trash" ? "回收站" : notebook?.name ?? "全部笔记"; + const listContextLabel = view === "trash" ? "已删除笔记" : notebook ? "当前笔记本" : "所有笔记本"; + const listCountLabel = `${filteredMemos.length}${filterMode !== "all" ? ` / ${memos.length}` : ""} ${ + view === "trash" ? "条已删除" : "条笔记" + }`; + const selectionCountLabel = getSelectionCountLabel(selectedMemoIds.size); + + const selectionMoveTitle = + selectedMemoIds.size === 0 + ? "请选择笔记" + : view === "trash" + ? "回收站内不可移动" + : notebooks.length === 0 + ? "没有可移动的笔记本" + : isMoving + ? "正在移动" + : "移动"; + const selectionDeleteTitle = + selectedMemoIds.size === 0 ? "请选择笔记" : isDeleting ? "正在删除" : view === "trash" ? "永久删除" : "删除"; + const selectionMergeTitle = + selectedMemoIds.size < 2 ? "至少选择 2 条笔记" : view === "trash" ? "回收站内不可合并" : isMerging ? "正在合并" : "合并笔记"; + const allSelectedMemosPinned = selectedMemosInList.length > 0 && selectedMemosInList.every((memo) => memo.isPinned); + const selectedPinTarget = !allSelectedMemosPinned; + const selectionPinLabel = allSelectedMemosPinned ? "取消置顶" : "置顶"; + const selectionPinTitle = + selectedMemoIds.size === 0 + ? "请选择笔记" + : view === "trash" + ? "回收站内不可置顶" + : isPinning + ? "正在更新置顶" + : selectionPinLabel; + const selectionToggleTitle = canToggleVisibleMemoSelection ? visibleSelectionToggleLabel : "当前列表没有可选择的笔记"; + const moveTargetTitle = + view === "trash" ? "回收站内不可移动" : notebooks.length === 0 ? "没有可移动的笔记本" : isMoving ? "正在移动" : "移动到笔记本"; + const hasListConstraint = Boolean(search.trim()) || filterMode !== "all"; + const activeFilterLabel = filterOptions.find((option) => option.value === filterMode)?.label ?? "全部"; + const activeSortLabel = MEMO_SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? "最近更新"; + + useEffect(() => { + if (notebook?.id) { + setMoveTargetNotebookId(notebook.id); + return; + } + + if (!moveTargetNotebookId && moveNotebookOptions[0]?.id) { + setMoveTargetNotebookId(moveNotebookOptions[0].id); + } + }, [moveNotebookOptions, moveTargetNotebookId, notebook?.id]); + + useEffect(() => { + if (searchFocusToken === 0) { + return; + } + + if (mobileSearchActive && !isDesktopViewport()) { + mobileSearchInputRef.current?.focus(); + return; + } + + searchInputRef.current?.focus(); + }, [mobileSearchActive, searchFocusToken]); + + useEffect(() => { + if (!filterOptions.some((option) => option.value === filterMode)) { + setFilterMode("all"); + } + }, [filterMode, filterOptions]); + + useEffect(() => { + const scrollContainer = listScrollRef.current; + if (!scrollContainer) { + return; + } + + skipSelectedMemoAutoScrollRef.current = true; + scrollContainer.scrollTo({ top: 0, behavior: "auto" }); + + const resetSkipTimer = window.setTimeout(() => { + skipSelectedMemoAutoScrollRef.current = false; + }, 0); + + return () => window.clearTimeout(resetSkipTimer); + }, [filterMode, notebook?.id, search, sortMode, view]); + + useEffect(() => { + if (!selectionMode || selectedMemoIds.size === 0) { + return; + } + + const visibleMemoIdSet = new Set(visibleMemoIds); + const nextSelectedMemoIds = Array.from(selectedMemoIds).filter((memoId) => visibleMemoIdSet.has(memoId)); + + if (nextSelectedMemoIds.length === selectedMemoIds.size) { + return; + } + + if (nextSelectedMemoIds.length === 0) { + onClearSelection(); + return; + } + + onReplaceSelection(nextSelectedMemoIds); + }, [onClearSelection, onReplaceSelection, selectedMemoIds, selectionMode, visibleMemoIds]); + + useEffect(() => { + if (!selectedMemoId || !visibleMemoIds.includes(selectedMemoId) || !isDesktopViewport()) { + return; + } + + if (skipSelectedMemoAutoScrollRef.current) { + skipSelectedMemoAutoScrollRef.current = false; + return; + } + + const scrollContainer = listScrollRef.current; + if (!scrollContainer) { + return; + } + + const escapedMemoId = CSS.escape(selectedMemoId); + const selectedNode = scrollContainer.querySelector(`[data-memo-id="${escapedMemoId}"]`); + + if (!selectedNode) { + return; + } + + const containerRect = scrollContainer.getBoundingClientRect(); + const selectedRect = selectedNode.getBoundingClientRect(); + const stickyHeaderOffset = 40; + + if (selectedRect.top < containerRect.top + stickyHeaderOffset) { + scrollContainer.scrollTop -= containerRect.top + stickyHeaderOffset - selectedRect.top; + return; + } + + if (selectedRect.bottom > containerRect.bottom) { + scrollContainer.scrollTop += selectedRect.bottom - containerRect.bottom; + } + }, [selectedMemoId, visibleMemoIds]); + + useEffect(() => { + const wasSelectionMode = previousSelectionModeRef.current; + previousSelectionModeRef.current = selectionMode; + + if (!wasSelectionMode || selectionMode || !isDesktopViewport()) { + return; + } + + const activeElement = document.activeElement; + const listRoot = listRootRef.current; + const shouldRestoreFocus = + !activeElement || + activeElement === document.body || + (activeElement instanceof HTMLElement && Boolean(listRoot?.contains(activeElement))); + + if (!shouldRestoreFocus) { + return; + } + + const memoIdToFocus = lastSelectedMemoId ?? selectedMemoId ?? visibleMemoIds[0]; + + if (!memoIdToFocus) { + return; + } + + window.setTimeout(() => { + const scrollContainer = listScrollRef.current; + if (!scrollContainer) { + return; + } + + const escapedMemoId = CSS.escape(memoIdToFocus); + const memoButton = scrollContainer.querySelector( + `[data-memo-id="${escapedMemoId}"] button[title^="Ctrl/Cmd"]` + ); + memoButton?.focus({ preventScroll: true }); + }, 0); + }, [lastSelectedMemoId, selectedMemoId, selectionMode, visibleMemoIds]); + + useEffect(() => { + if (!selectionMode) { + setMobileMoveOpen(false); + setMobileMoreOpen(false); + return; + } + + setDesktopActionsOpen(false); + setDesktopFilterOpen(false); + setDesktopSortOpen(false); + setContextMoveOpen(false); + setMemoContextMenu(null); + setSelectionContextMenu(null); + setMobileListActionsOpen(false); + setMobileMoveOpen(false); + setMobileMoreOpen(false); + }, [selectionMode, setDesktopActionsOpen, setDesktopFilterOpen, setDesktopSortOpen, setMobileListActionsOpen, setMobileMoreOpen, setMobileMoveOpen]); + + const handleToggleMemo = (memoId: string, event?: MouseEvent) => { + const currentIndex = visibleMemoIds.indexOf(memoId); + const anchorIndex = lastSelectedMemoId ? visibleMemoIds.indexOf(lastSelectedMemoId) : -1; + + if (event?.shiftKey && currentIndex >= 0 && anchorIndex >= 0) { + const start = Math.min(currentIndex, anchorIndex); + const end = Math.max(currentIndex, anchorIndex); + onToggleMemo(memoId, visibleMemoIds.slice(start, end + 1)); + } else { + onToggleMemo(memoId); + } + + setLastSelectedMemoId(memoId); + }; + + const handleSelectAllVisibleMemos = () => { + if (visibleMemoIds.length === 0) { + return; + } + + onReplaceSelection(visibleMemoIds); + setLastSelectedMemoId(visibleMemoIds.at(-1) ?? visibleMemoIds[0]); + }; + + const handleClearVisibleMemos = () => { + const nextSelectedMemoIds = Array.from(selectedMemoIds).filter((memoId) => !visibleMemoIds.includes(memoId)); + onReplaceSelection(nextSelectedMemoIds); + setLastSelectedMemoId(null); + }; + + const openMemoContextMenuAt = (memo: MemoSummary, clientX: number, clientY: number) => { + const menuWidth = 224; + const menuHeight = view === "trash" ? 160 : 260; + const x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12)); + const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); + + setContextMoveOpen(false); + setSelectionContextMenu(null); + setMemoContextMenu({ memo, x, y }); + }; + + const openSelectionContextMenuAt = (clientX: number, clientY: number) => { + const menuWidth = 224; + const menuHeight = view === "trash" ? 128 : 272; + const x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12)); + const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); + + setContextMoveOpen(false); + setMemoContextMenu(null); + setSelectionContextMenu({ x, y }); + }; + + const handleOpenMemoContextMenu = (memo: MemoSummary, event: MouseEvent) => { + openMemoContextMenuAt(memo, event.clientX, event.clientY); + }; + + const handleOpenSelectionContextMenu = (memo: MemoSummary, event: MouseEvent) => { + if (!isDesktopViewport()) { + return; + } + + if (!selectedMemoIds.has(memo.id)) { + handleToggleMemo(memo.id, event); + } + + openSelectionContextMenuAt(event.clientX, event.clientY); + }; + + const handleOpenSelectionKeyboardContextMenu = (memo: MemoSummary, target: HTMLElement) => { + if (!isDesktopViewport()) { + return; + } + + if (!selectedMemoIds.has(memo.id)) { + handleToggleMemo(memo.id); + } + + const rect = target.getBoundingClientRect(); + openSelectionContextMenuAt(rect.left + Math.min(rect.width, 224), rect.top + Math.min(rect.height, 96)); + }; + + const handleOpenMemoKeyboardContextMenu = (memo: MemoSummary, target: HTMLElement) => { + if (!isDesktopViewport()) { + return; + } + + const rect = target.getBoundingClientRect(); + openMemoContextMenuAt(memo, rect.left + Math.min(rect.width, 224), rect.top + Math.min(rect.height, 96)); + }; + + const focusSearchInput = () => { + if (mobileSearchActive && !isDesktopViewport()) { + mobileSearchInputRef.current?.focus(); + return; + } + + searchInputRef.current?.focus(); + }; + + const handleClearSearch = () => { + onClearSelection(); + onSearch(""); + focusSearchInput(); + }; + + const handleSearchChange = (value: string) => { + if (value !== search) { + onClearSelection(); + } + onSearch(value); + }; + + const handleResetListConstraints = () => { + onClearSelection(); + setFilterMode("all"); + onSearch(""); + focusSearchInput(); + }; + + const handleFilterModeChange = (value: MemoFilterMode) => { + onClearSelection(); + setFilterMode(value); + }; + + const handleListDensityChange = (value: MemoListDensity) => { + setListDensity(value); + writeMemoListDensityPreference(value); + }; + + const handleMoveTargetSelectKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.currentTarget.blur(); + listRootRef.current?.focus(); + return; + } + + if (event.key !== "Enter") { + return; + } + + if (selectedMemoIds.size === 0 || !moveTargetNotebookId || isMoving || view === "trash") { + return; + } + + event.preventDefault(); + onMoveSelectedMemos(moveTargetNotebookId); + }; + + const handleListKeyDown = (event: ReactKeyboardEvent) => { + if (!isDesktopViewport()) { + return; + } + + const target = event.target instanceof HTMLElement ? event.target : null; + + if (target?.closest("input, textarea, select, [contenteditable='true']")) { + return; + } + + if (event.key === "Escape" && selectionMode) { + event.preventDefault(); + event.stopPropagation(); + setMemoContextMenu(null); + setSelectionContextMenu(null); + onClearSelection(); + setLastSelectedMemoId(null); + return; + } + + if (!event.altKey && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") { + if (visibleMemoIds.length === 0) { + return; + } + event.preventDefault(); + setMemoContextMenu(null); + setSelectionContextMenu(null); + handleSelectAllVisibleMemos(); + return; + } + + if (!event.altKey && (event.ctrlKey || event.metaKey) && (event.key === "Delete" || event.key === "Backspace")) { + if (selectionMode && selectedMemoIds.size > 0) { + event.preventDefault(); + setMemoContextMenu(null); + setSelectionContextMenu(null); + onDeleteSelectedMemos(); + return; + } + + if (!selectedMemoId || !visibleMemoIds.includes(selectedMemoId)) { + return; + } + + event.preventDefault(); + setMemoContextMenu(null); + setSelectionContextMenu(null); + onDeleteMemo(selectedMemoId); + return; + } + }; + + return ( +
+
+ {selectionMode ? ( +
+ +
{selectionCountLabel}
+
+ ) : mobileSearchActive ? ( +
+ +
+ + handleSearchChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") { + return; + } + + event.preventDefault(); + if (search) { + handleClearSearch(); + return; + } + onCancelMobileSearch(); + }} + className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400" + placeholder="搜索笔记" + /> + {search && ( + + )} +
+ +
+ ) : ( + + )} + + {!mobileSearchActive && ( + + )} + + {!mobileSearchActive && ( +
+
+ +
+ +
+ )} + +
+
{listTitle}
+
+ {listContextLabel} · {listCountLabel} +
+
+ +
+
+ + + + + + + {filterOptions.map((option: any) => ( + handleFilterModeChange(option.value)} + > + {getMobileFilterIcon(option.value)} + {option.label} + + ))} + + + + + + + + + {MEMO_SORT_OPTIONS.map((option: any) => ( + setSortMode(option.value)} + > + {option.label} + + ))} + + + +
+ + +
+
+
+ + + + + + + + 标签 + + + + 附件 + + + + 回收站 + + + + MCP Token + + + +
+
+ +
+
+ + handleSearchChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && search) { + event.preventDefault(); + handleClearSearch(); + } + }} + className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400" + placeholder="搜索笔记" + /> + {search && ( + + )} +
+
+ {mobileFilterOptions.map((option: any) => ( + + ))} +
+
+ + {hasListConstraint && ( +
+ + {search.trim() ? `搜索「${search.trim()}」` : `筛选:${activeFilterLabel}`} · {filteredMemos.length} 条 + + +
+ )} +
+ +
+ {selectionMode && ( +
+
+ + {selectionCountLabel} +
+
+ + + + {view !== "trash" && notebooks.length > 0 && ( + + )} + + + +
+
+ )} + + {isLoading ? ( +
加载中
+ ) : filteredMemos.length === 0 ? ( +
+
+ {memos.length === 0 ? (view === "trash" ? "回收站为空" : "暂无笔记") : "没有符合筛选的笔记"} +
+
+ {memos.length === 0 + ? view === "trash" + ? "删除的笔记会显示在这里。" + : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。" + : "试试切换筛选条件,或调整搜索关键词。"} +
+ {memos.length === 0 && canCreateMemo && view !== "trash" && ( + + )} +
+ ) : ( +
+ {memoGroups.map((group) => ( +
+
+ {group.label} + {group.items.length} +
+
+ {group.items.map((memo) => ( + onOpenMemo(memo.id)} + onDelete={() => onDeleteMemo(memo.id)} + onRestore={() => onRestoreMemo(memo.id)} + onOpenContextMenu={(event) => handleOpenMemoContextMenu(memo, event)} + onOpenSelectionContextMenu={(event) => handleOpenSelectionContextMenu(memo, event)} + onOpenSelectionKeyboardContextMenu={(target) => handleOpenSelectionKeyboardContextMenu(memo, target)} + onOpenKeyboardContextMenu={(target) => handleOpenMemoKeyboardContextMenu(memo, target)} + onToggle={(event) => handleToggleMemo(memo.id, event)} + /> + ))} +
+
+ ))} +
+ )} +
+ + {/* Controlled Right Click context menu for single note on Desktop using absolute placement */} + {memoContextMenu && ( +
+ { if (!open) setMemoContextMenu(null); }}> + + + + + { + const { memo } = memoContextMenu; + setMemoContextMenu(null); + onOpenMemo(memo.id); + }} + > + + 打开笔记 + + { + const { memo } = memoContextMenu; + setMemoContextMenu(null); + handleToggleMemo(memo.id); + }} + > + + 选择笔记 + + {view !== "trash" && ( + { + const { memo } = memoContextMenu; + setMemoContextMenu(null); + onTogglePinMemo(memo); + }} + > + + {memoContextMenu.memo.isPinned ? "取消置顶" : "置顶笔记"} + + )} + + {view === "trash" ? ( + <> + { + const { memo } = memoContextMenu; + setMemoContextMenu(null); + onRestoreMemo(memo.id); + }} + > + + 恢复笔记 + + { + const { memo } = memoContextMenu; + setMemoContextMenu(null); + onDeleteMemo(memo.id); + }} + > + + 永久删除 + + + ) : ( + <> + setContextMoveOpen((value) => !value)} + > + + 移动到笔记本 + + + {contextMoveOpen && ( +
+ {moveNotebookOptions.map((option: any) => ( + + ))} +
+ )} + { + const { memo } = memoContextMenu; + setMemoContextMenu(null); + onDeleteMemo(memo.id); + }} + > + + 删除笔记 + + + )} +
+
+
+ )} + + {/* Controlled Right Click context menu for bulk selection on Desktop using absolute placement */} + {selectionContextMenu && selectedMemoIds.size > 0 && ( +
+ { if (!open) setSelectionContextMenu(null); }}> + + + + +
{selectionCountLabel}
+ {view !== "trash" ? ( + <> + setContextMoveOpen((value) => !value)} + > + + 移动到笔记本 + + + {contextMoveOpen && ( +
+ {moveNotebookOptions.map((option: any) => ( + + ))} +
+ )} + { + setSelectionContextMenu(null); + onPinSelectedMemos(selectedPinTarget); + }} + > + + {selectionPinLabel} + + { + setSelectionContextMenu(null); + onMerge(); + }} + > + + 合并笔记 + + + + ) : null} + { + setSelectionContextMenu(null); + onDeleteSelectedMemos(); + }} + > + + {view === "trash" ? "永久删除" : "删除"} + + + { + setSelectionContextMenu(null); + onClearSelection(); + }} + > + + 取消选择 + +
+
+
+ )} + + {selectionMode && ( + 0 && !isDeleting} + canMove={selectedMemoIds.size > 0 && view !== "trash" && notebooks.length > 0 && !isMoving} + deleteTitle={selectionDeleteTitle} + isTrashView={view === "trash"} + moveTitle={selectionMoveTitle} + onDelete={onDeleteSelectedMemos} + onOpenMore={() => setMobileMoreOpen(true)} + onOpenMove={() => setMobileMoveOpen(true)} + /> + )} + + {mobileListActionsOpen && ( + setMobileListActionsOpen(false)} + onEnterSelectionMode={() => { + setMobileListActionsOpen(false); + onEnterSelectionMode(); + }} + onOpenAssets={() => { + setMobileListActionsOpen(false); + onOpenAssets(); + }} + onOpenSettings={() => { + setMobileListActionsOpen(false); + onOpenSettings(); + }} + onOpenTags={() => { + setMobileListActionsOpen(false); + onOpenTags(); + }} + onOpenTrash={() => { + setMobileListActionsOpen(false); + onOpenTrash(); + }} + onListDensityChange={(value) => { + handleListDensityChange(value); + }} + onSortModeChange={(value) => { + setSortMode(value); + }} + /> + )} + + {mobileMoveOpen && ( + setMobileMoveOpen(false)} + onMove={(notebookId) => { + setMoveTargetNotebookId(notebookId); + onMoveSelectedMemos(notebookId); + setMobileMoveOpen(false); + }} + /> + )} + + {mobileMoreOpen && ( + = 2 && view !== "trash" && !isMerging} + canPin={selectedMemoIds.size > 0 && view !== "trash" && !isPinning} + canToggleVisibleSelection={canToggleVisibleMemoSelection} + mergeTitle={selectionMergeTitle} + pinLabel={selectionPinLabel} + pinTitle={selectionPinTitle} + selectedCount={selectedMemoIds.size} + selectionToggleLabel={visibleSelectionToggleLabel} + selectionToggleTitle={selectionToggleTitle} + onToggleVisibleSelection={() => { + setMobileMoreOpen(false); + if (allVisibleMemosSelected) { + handleClearVisibleMemos(); + return; + } + handleSelectAllVisibleMemos(); + }} + onClearSelection={() => { + setMobileMoreOpen(false); + onClearSelection(); + }} + onClose={() => setMobileMoreOpen(false)} + onMerge={() => { + setMobileMoreOpen(false); + onMerge(); + }} + onPin={() => { + setMobileMoreOpen(false); + onPinSelectedMemos(selectedPinTarget); + }} + /> + )} +
+ ); +}; + +const NotebookIcon = ({ className }: { className?: string }) => ( + + + +); diff --git a/apps/web/src/components/NotebookPane.tsx b/apps/web/src/components/NotebookPane.tsx new file mode 100644 index 00000000..54a009f2 --- /dev/null +++ b/apps/web/src/components/NotebookPane.tsx @@ -0,0 +1,396 @@ +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + ChevronLeft, + Plus, + LayoutList, + Notebook as NotebookIcon, + Tags, + Archive, + Trash2, + KeyRound, + LogOut, + CloudOff, + AlertTriangle, + RefreshCw, + CheckCircle2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { NotebookTreeItem } from "./NotebookTreeItem"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import type { Notebook, AuthUser } from "@edgeever/shared"; +import type { NotebookNode, NotebookDropPosition, SyncQueueSummary } from "@/lib/app-helpers"; +import { buildNotebookTree, hasEdgeEverDragData } from "@/lib/app-helpers"; + +const NOTEBOOK_DRAG_SCROLL_EDGE_PX = 56; +const NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX = 18; + +const SidebarNavButton = ({ + active = false, + icon, + label, + onClick, +}: { + active?: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +}) => ( + +); + +const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSyncing: boolean) => { + if (!isOnline) { + return summary.total > 0 ? `离线,${summary.total} 项待同步` : "离线"; + } + + if (isSyncing || summary.syncing > 0) { + return "同步中"; + } + + if (summary.conflict > 0) { + return `${summary.conflict} 项同步冲突`; + } + + if (summary.error > 0) { + return `${summary.error} 项等待重试`; + } + + if (summary.pending > 0) { + return `${summary.pending} 项待同步`; + } + + return "已同步"; +}; + +const SyncStatusBar = ({ + summary, + isOnline, + isSyncing, + onSyncNow, +}: { + summary: SyncQueueSummary; + isOnline: boolean; + isSyncing: boolean; + onSyncNow: () => void; +}) => { + const hasQueuedWork = summary.total > 0; + const label = getSyncStatusLabel(summary, isOnline, isSyncing); + const statusClassName = !isOnline + ? "border-slate-200 bg-slate-50 text-slate-600" + : summary.conflict > 0 + ? "border-amber-200 bg-amber-50 text-amber-800" + : hasQueuedWork + ? "border-emerald-200 bg-emerald-50 text-emerald-800" + : "border-slate-200 bg-white text-slate-500"; + + return ( +
+ {!isOnline ? ( + + ) : summary.conflict > 0 ? ( + + ) : hasQueuedWork || isSyncing ? ( + + ) : ( + + )} + {label} + {hasQueuedWork && ( + + )} +
+ ); +}; + +export const NotebookPane = ({ + user, + view, + selectedNotebookId, + onSelect, + onCreateNotebook, + onRenameNotebook, + onDeleteNotebook, + onMoveNotebook, + onMoveMemos, + onBackToList, + onOpenTags, + onOpenAssets, + onOpenTrash, + onOpenSettings, + onCreateMemo, + canCreateMemo, + isCreatingMemo, + syncSummary, + isOnline, + isSyncingQueuedChanges, + onSyncQueuedChanges, + imageCompressionEnabled, + onImageCompressionChange, + authRequired, + onLogout, + isLoggingOut, +}: { + user: AuthUser | null; + view: string; + selectedNotebookId: string | null; + onSelect: (notebookId: string) => void; + onCreateNotebook: (parentId?: string | null) => void; + onRenameNotebook: (notebook: Notebook) => void; + onDeleteNotebook: (notebook: Notebook) => void; + onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void; + onMoveMemos: (memoIds: string[], targetNotebookId: string) => void; + onBackToList: () => void; + onOpenTags: () => void; + onOpenAssets: () => void; + onOpenTrash: () => void; + onOpenSettings: () => void; + onCreateMemo: () => void; + canCreateMemo: boolean; + isCreatingMemo: boolean; + syncSummary: SyncQueueSummary; + isOnline: boolean; + isSyncingQueuedChanges: boolean; + onSyncQueuedChanges: () => void; + imageCompressionEnabled: boolean; + onImageCompressionChange: (enabled: boolean) => void; + authRequired: boolean; + onLogout: () => void; + isLoggingOut: boolean; +}) => { + const notebookScrollRef = useRef(null); + const notebookDragScrollFrameRef = useRef(null); + const [expandSiblingsRequest, setExpandSiblingsRequest] = useState<{ parentId: string | null; token: number } | null>(null); + + const stopNotebookDragAutoScroll = useCallback(() => { + if (notebookDragScrollFrameRef.current === null) { + return; + } + + window.cancelAnimationFrame(notebookDragScrollFrameRef.current); + notebookDragScrollFrameRef.current = null; + }, []); + + useEffect(() => () => stopNotebookDragAutoScroll(), [stopNotebookDragAutoScroll]); + + const handleExpandNotebookSiblings = useCallback((parentId: string | null) => { + setExpandSiblingsRequest((current: { parentId: string | null; token: number } | null) => ({ parentId, token: (current?.token ?? 0) + 1 })); + }, []); + + const handleNotebookScrollDragOver = (event: React.DragEvent) => { + if (!hasEdgeEverDragData(event.dataTransfer)) { + stopNotebookDragAutoScroll(); + return; + } + + const scrollContainer = notebookScrollRef.current; + + if (!scrollContainer) { + return; + } + + const rect = scrollContainer.getBoundingClientRect(); + const distanceToTop = event.clientY - rect.top; + const distanceToBottom = rect.bottom - event.clientY; + const topPressure = Math.max(0, NOTEBOOK_DRAG_SCROLL_EDGE_PX - distanceToTop); + const bottomPressure = Math.max(0, NOTEBOOK_DRAG_SCROLL_EDGE_PX - distanceToBottom); + const direction = bottomPressure > 0 ? 1 : topPressure > 0 ? -1 : 0; + + if (direction === 0) { + stopNotebookDragAutoScroll(); + return; + } + + event.preventDefault(); + + const pressure = Math.max(topPressure, bottomPressure) / NOTEBOOK_DRAG_SCROLL_EDGE_PX; + const scrollStep = Math.max(4, Math.ceil(pressure * NOTEBOOK_DRAG_SCROLL_MAX_STEP_PX)) * direction; + const tick = () => { + scrollContainer.scrollTop += scrollStep; + notebookDragScrollFrameRef.current = window.requestAnimationFrame(tick); + }; + + if (notebookDragScrollFrameRef.current !== null) { + return; + } + + notebookDragScrollFrameRef.current = window.requestAnimationFrame(tick); + }; + + const notebooksQuery = useQuery({ + queryKey: ["notebooks"], + queryFn: () => api.listNotebooks(), + }); + + const notebooks = notebooksQuery.data?.notebooks ?? []; + const tree = buildNotebookTree(notebooks); + const isLoading = notebooksQuery.isLoading; + + useEffect(() => { + if (!selectedNotebookId) { + return; + } + + window.setTimeout(() => { + const selectedNode = notebookScrollRef.current?.querySelector( + `[data-notebook-id="${CSS.escape(selectedNotebookId)}"]` + ); + + selectedNode?.scrollIntoView({ block: "nearest" }); + }, 0); + }, [selectedNotebookId, tree]); + + return ( +
+
+
+
笔记本
+
EdgeEver
+
{user?.username ?? "边缘笔记工作区"}
+
+
+ + +
+
+ +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + stopNotebookDragAutoScroll(); + } + }} + onDragOver={handleNotebookScrollDragOver} + onDrop={stopNotebookDragAutoScroll} + > +
+ +
+ + + +
+ + + 笔记本 + + +
+ + {isLoading ? ( +
加载中
+ ) : ( +
+ {tree.map((node) => ( + + ))} +
+ )} + + +
+ +
+ + + {authRequired && ( + + )} +
+
+ ); +}; diff --git a/apps/web/src/components/NotebookTreeItem.tsx b/apps/web/src/components/NotebookTreeItem.tsx new file mode 100644 index 00000000..cef0fc01 --- /dev/null +++ b/apps/web/src/components/NotebookTreeItem.tsx @@ -0,0 +1,338 @@ +import { useState, useEffect, useRef, type DragEvent } from "react"; +import { ChevronDown, ChevronRight, Notebook as NotebookIcon, Plus, Pencil, Trash2 } from "lucide-react"; +import type { NotebookNode, NotebookDropPosition } from "@/lib/app-helpers"; +import { + hasMemoDragData, + hasNotebookDragData, + getMemoDragIds, + getNotebookDropPosition, + notebookTreeContainsId, + focusNotebookTreeButton, + NOTEBOOK_DRAG_MIME, +} from "@/lib/app-helpers"; +import { cn } from "@/lib/utils"; +import type { Notebook } from "@edgeever/shared"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; + +const NOTEBOOK_DRAG_EXPAND_DELAY_MS = 520; + +export const NotebookTreeItem = ({ + node, + depth, + selectedNotebookId, + onSelect, + onCreateNotebook, + onRenameNotebook, + onDeleteNotebook, + onMoveNotebook, + onMoveMemos, + onDragScroll, + expandSiblingsRequest, + onExpandSiblings, +}: { + node: NotebookNode; + depth: number; + selectedNotebookId: string | null; + onSelect: (notebookId: string) => void; + onCreateNotebook: (parentId?: string | null) => void; + onRenameNotebook: (notebook: Notebook) => void; + onDeleteNotebook: (notebook: Notebook) => void; + onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void; + onMoveMemos: (memoIds: string[], targetNotebookId: string) => void; + onDragScroll: (event: DragEvent) => void; + expandSiblingsRequest: { parentId: string | null; token: number } | null; + onExpandSiblings: (parentId: string | null) => void; +}) => { + const [open, setOpen] = useState(true); + const hasChildren = node.children.length > 0; + const selected = node.id === selectedNotebookId; + const isInbox = node.slug === "inbox"; + const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false; + const [dropPosition, setDropPosition] = useState(null); + const expandTimerRef = useRef(null); + + const clearExpandTimer = () => { + if (expandTimerRef.current === null) { + return; + } + + window.clearTimeout(expandTimerRef.current); + expandTimerRef.current = null; + }; + + useEffect(() => () => clearExpandTimer(), []); + + useEffect(() => { + if (hasSelectedDescendant) { + setOpen(true); + } + }, [hasSelectedDescendant]); + + useEffect(() => { + if (!expandSiblingsRequest || expandSiblingsRequest.parentId !== node.parentId || !hasChildren) { + return; + } + + setOpen(true); + }, [expandSiblingsRequest, hasChildren, node.parentId]); + + const scheduleDragExpand = (position: NotebookDropPosition) => { + if (!hasChildren || open || position !== "inside") { + clearExpandTimer(); + return; + } + + if (expandTimerRef.current !== null) { + return; + } + + expandTimerRef.current = window.setTimeout(() => { + expandTimerRef.current = null; + setOpen(true); + }, NOTEBOOK_DRAG_EXPAND_DELAY_MS); + }; + + const handleDragOver = (event: DragEvent) => { + onDragScroll(event); + + if (hasMemoDragData(event.dataTransfer)) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + setDropPosition("inside"); + scheduleDragExpand("inside"); + return; + } + + if (!hasNotebookDragData(event.dataTransfer)) { + return; + } + + const position = getNotebookDropPosition(event); + + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setDropPosition(position); + scheduleDragExpand(position); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + const memoIds = getMemoDragIds(event.dataTransfer); + const notebookId = event.dataTransfer.getData(NOTEBOOK_DRAG_MIME); + const position = getNotebookDropPosition(event); + setDropPosition(null); + clearExpandTimer(); + + if (memoIds.length > 0) { + onMoveMemos(memoIds, node.id); + setOpen(true); + return; + } + + if (!notebookId || notebookId === node.id) { + return; + } + + onMoveNotebook(notebookId, node.id, position); + setOpen(true); + }; + + return ( +
+ + +
{ + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(NOTEBOOK_DRAG_MIME, node.id); + event.dataTransfer.setData("text/plain", node.id); + }} + onDragOver={handleDragOver} + onDragLeave={() => { + setDropPosition(null); + clearExpandTimer(); + }} + onDragEnd={clearExpandTimer} + onDrop={handleDrop} + style={{ paddingLeft: `${8 + depth * 14}px` }} + > + {hasChildren ? ( + + ) : ( +
+
+ + onCreateNotebook(node.id)} + > + + 新建子笔记本 + + onRenameNotebook(node)} + > + + 重命名 + + {!isInbox && ( + <> + + onDeleteNotebook(node)} + > + + 删除笔记本 + + + )} + +
+ + {hasChildren && open ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); +}; diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx new file mode 100644 index 00000000..2012ae47 --- /dev/null +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -0,0 +1,1663 @@ +import { + useState, + useCallback, + useEffect, + useMemo, + useRef, + type CSSProperties, + type ReactNode, + type MouseEvent, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Home, Search, LayoutList, KeyRound, Plus, ChevronDown, ChevronRight, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { NotebookPane } from "./NotebookPane"; +import { MemoListPane } from "./MemoListPane"; +import { EditorPane } from "./EditorPane"; +import { AssetsDialog } from "./dialogs/AssetsDialog"; +import { TagsDialog } from "./dialogs/TagsDialog"; +import { SettingsDialog } from "./dialogs/SettingsDialog"; +import { TemplatesDialog } from "./dialogs/TemplatesDialog"; +import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import type { Notebook, AuthUser, MemoSummary, MemoDetail } from "@edgeever/shared"; +import type { + Pane, + MemoView, + MemoDeleteConfirmation, + NotebookNameDialogState, + AppNoticeDialogState, + MobileBottomNavItem, + SyncQueueSummary, + NotebookNode, + NotebookDropPosition, + NotebookMoveOption, + MemoTemplate, +} from "@/lib/app-helpers"; +import { + emptySyncQueueSummary, + DEFAULT_MEMO_TITLE, + MIN_MEMO_LIST_WIDTH_PX, + MAX_MEMO_LIST_WIDTH_PX, + DEFAULT_MEMO_LIST_WIDTH_PX, + MEMO_TEMPLATES, + isTextEntryTarget, + readImageCompressionPreference, + writeImageCompressionPreference, + readMemoListWidthPreference, + writeMemoListWidthPreference, + clampMemoListWidth, + toggleMemoSelection, + getNotebookDropSortOrder, + buildNotebookTree, + notebookTreeContainsId, + getNotebookAncestorIds, + getExpandableNotebookIds, + filterNotebookTree, + getNotebookMoveOptions, + syncQueuedChanges, + observeSyncQueue, +} from "@/lib/app-helpers"; +import { useBrowserBackLayer, useBottomSheetSwipeToClose, useModalLayerControls } from "@/lib/app-hooks"; + +const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; + +const MobileSheetGrabber = ({ onPointerDown }: { onPointerDown?: (event: React.PointerEvent) => void }) => ( +
+
+
+); + +const MobileBottomNavButton = ({ + active = false, + icon, + label, + onClick, +}: { + active?: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +}) => ( + +); + +const MobileBottomNav = ({ + activeItem, + canCreateMemo, + isCreating, + onCreateMemo, + onHome, + onOpenSettings, + onOpenTemplates, + onSearch, +}: { + activeItem: MobileBottomNavItem; + canCreateMemo: boolean; + isCreating: boolean; + onCreateMemo: () => void; + onHome: () => void; + onOpenSettings: () => void; + onOpenTemplates: () => void; + onSearch: () => void; +}) => ( + +); + +const MobileNotebookPicker = ({ + currentLabel, + notebooks, + selectedNotebookId, + onClose, + onSelectAll, + onSelect, +}: { + currentLabel?: string; + notebooks: Notebook[]; + selectedNotebookId: string | null; + onClose: () => void; + onSelectAll: () => void; + onSelect: (notebookId: string) => void; +}) => { + const dialogRef = useRef(null); + const listRef = useRef(null); + const handleSheetSwipePointerDown = useBottomSheetSwipeToClose(dialogRef, onClose); + const [notebookSearch, setNotebookSearch] = useState(""); + const tree = useMemo(() => buildNotebookTree(notebooks), [notebooks]); + const filteredTree = useMemo(() => filterNotebookTree(tree, notebookSearch), [notebookSearch, tree]); + const selectedAncestorIds = useMemo( + () => (selectedNotebookId ? getNotebookAncestorIds(tree, selectedNotebookId) : []), + [selectedNotebookId, tree] + ); + const expandableNotebookIds = useMemo(() => getExpandableNotebookIds(tree), [tree]); + const [expandedNotebookIds, setExpandedNotebookIds] = useState>(() => new Set(selectedAncestorIds)); + const allSelected = !currentLabel && selectedNotebookId === null; + const selectedNotebookName = + currentLabel ?? (allSelected ? "全部笔记" : notebooks.find((item) => item.id === selectedNotebookId)?.name ?? "笔记本"); + const searchQuery = notebookSearch.trim(); + const searchActive = Boolean(searchQuery); + const allNotebookBranchesExpanded = + expandableNotebookIds.length > 0 && expandableNotebookIds.every((notebookId) => expandedNotebookIds.has(notebookId)); + + useModalLayerControls(dialogRef, onClose, { closeOnBrowserBack: true }); + + useEffect(() => { + if (selectedAncestorIds.length === 0) { + return; + } + setExpandedNotebookIds((current) => { + const next = new Set(current); + for (const notebookId of selectedAncestorIds) { + next.add(notebookId); + } + return next; + }); + }, [selectedAncestorIds]); + + useEffect(() => { + if (searchActive) { + return; + } + window.setTimeout(() => { + const listNode = listRef.current; + const targetNotebookId = selectedNotebookId ?? "__all__"; + const selectedNode = listNode?.querySelector(`[data-mobile-notebook-id="${CSS.escape(targetNotebookId)}"]`); + selectedNode?.scrollIntoView({ block: "center" }); + }, 0); + }, [searchActive, selectedNotebookId]); + + const handleToggleNotebookExpanded = (notebookId: string) => { + setExpandedNotebookIds((current) => { + const next = new Set(current); + if (next.has(notebookId)) { + next.delete(notebookId); + } else { + next.add(notebookId); + } + return next; + }); + }; + + const handleToggleAllNotebookBranches = () => { + setExpandedNotebookIds(allNotebookBranchesExpanded ? new Set() : new Set(expandableNotebookIds)); + }; + + return ( +
+
event.stopPropagation()} + > + +
+
+
+ 切换笔记本 +
+
当前:{selectedNotebookName}
+
+ +
+
+
+ + setNotebookSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && notebookSearch) { + event.preventDefault(); + setNotebookSearch(""); + } + }} + /> + {notebookSearch && ( + + )} +
+
+
+ + {filteredTree.length > 0 ? ( + <> +
+ {searchActive ? "匹配的笔记本" : "笔记本"} + {!searchActive && expandableNotebookIds.length > 0 && ( + + )} +
+ {filteredTree.map((node) => ( + + ))} + + ) : ( +
+
+ {searchQuery ? `没有找到「${searchQuery}」` : "没有找到笔记本"} +
+ {searchQuery && ( + + )} +
+ )} +
+
+
+ ); +}; + +const MobileNotebookPickerItem = ({ + node, + depth, + expandedNotebookIds, + searchActive, + selectedNotebookId, + onSelect, + onToggleExpanded, +}: { + node: NotebookNode; + depth: number; + expandedNotebookIds: Set; + searchActive: boolean; + selectedNotebookId: string | null; + onSelect: (notebookId: string) => void; + onToggleExpanded: (notebookId: string) => void; +}) => { + const selected = node.id === selectedNotebookId; + const hasChildren = node.children.length > 0; + const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false; + const expanded = searchActive || expandedNotebookIds.has(node.id); + + return ( +
+
+ {hasChildren ? ( + + ) : ( +
+ {hasChildren && expanded ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); +}; + +export const WorkspaceApp = ({ + authRequired, + user, + isLoggingOut, + onLogout, +}: { + authRequired: boolean; + user: AuthUser | null; + isLoggingOut: boolean; + onLogout: () => void; +}) => { + const queryClient = useQueryClient(); + const [activePane, setActivePane] = useState("memos"); + const [memoView, setMemoView] = useState("notebook"); + const [selectedNotebookId, setSelectedNotebookId] = useState(null); + const [selectedMemoId, setSelectedMemoId] = useState(null); + const [selectedMemoIds, setSelectedMemoIds] = useState>(new Set()); + const [memoSelectionMode, setMemoSelectionMode] = useState(false); + const [memoDeleteConfirmation, setMemoDeleteConfirmation] = useState(null); + const [notebookNameDialog, setNotebookNameDialog] = useState(null); + const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState(null); + const [appNoticeDialog, setAppNoticeDialog] = useState(null); + const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); + const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); + const [assetsOpen, setAssetsOpen] = useState(false); + const [tagsOpen, setTagsOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [templatesOpen, setTemplatesOpen] = useState(false); + const [mobileNotebookPickerOpen, setMobileNotebookPickerOpen] = useState(false); + const [mobileBottomNavActive, setMobileBottomNavActive] = useState("home"); + const [mobileSearchFocusToken, setMobileSearchFocusToken] = useState(0); + const [memoListWidth, setMemoListWidth] = useState(readMemoListWidthPreference); + const [search, setSearch] = useState(""); + const [syncSummary, setSyncSummary] = useState(emptySyncQueueSummary); + const [isOnline, setIsOnline] = useState(() => (typeof navigator === "undefined" ? true : navigator.onLine)); + const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false); + + const [mobileListActionsOpen, setMobileListActionsOpen] = useState(false); + const [mobileMoveOpen, setMobileMoveOpen] = useState(false); + const [mobileMoreOpen, setMobileMoreOpen] = useState(false); + const [desktopFilterOpen, setDesktopFilterOpen] = useState(false); + const [desktopSortOpen, setDesktopSortOpen] = useState(false); + const [desktopActionsOpen, setDesktopActionsOpen] = useState(false); + + const runQueuedSync = useCallback(async () => { + if (typeof navigator !== "undefined" && !navigator.onLine) { + setIsOnline(false); + return; + } + + setIsSyncingQueuedChanges(true); + + try { + await syncQueuedChanges({ + onSynced: async (memo) => { + queryClient.setQueryData(["memo", memo.id], { memo }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo", memo.id] }), + ]); + }, + }); + } finally { + setIsSyncingQueuedChanges(false); + } + }, [queryClient]); + + const notebooksQuery = useQuery({ + queryKey: ["notebooks"], + queryFn: () => api.listNotebooks(), + }); + + const notebooks = notebooksQuery.data?.notebooks ?? []; + const defaultMemoNotebookId = + notebooks.find((notebook) => notebook.slug === "inbox")?.id ?? selectedNotebookId ?? notebooks[0]?.id ?? null; + const canCreateMemo = Boolean(defaultMemoNotebookId && memoView !== "trash"); + const memoSelectionModeActive = memoSelectionMode || selectedMemoIds.size > 0; + const mobileSearchActive = mobileBottomNavActive === "search"; + const workspaceBackTargetActive = Boolean( + appNoticeDialog || + notebookDeleteConfirmation || + notebookNameDialog || + memoDeleteConfirmation || + mobileNotebookPickerOpen || + mobileSearchActive || + templatesOpen || + settingsOpen || + tagsOpen || + assetsOpen || + memoSelectionModeActive || + activePane === "editor" || + activePane === "notebooks" + ); + + const clearMemoSelection = useCallback(() => { + setSelectedMemoIds(new Set()); + setMemoSelectionMode(false); + }, []); + + const replaceMemoSelection = useCallback((memoIds: string[]) => { + setSelectedMemoIds(new Set(memoIds)); + setMemoSelectionMode(true); + }, []); + + const enterMemoSelectionMode = useCallback(() => { + setMemoSelectionMode(true); + setActivePane("memos"); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMultiSelectKeyDown(false); + return; + } + + if (isTextEntryTarget(event.target)) { + setMultiSelectKeyDown(false); + return; + } + + if (event.ctrlKey || event.metaKey || event.key === "Control" || event.key === "Meta") { + setMultiSelectKeyDown(true); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (isTextEntryTarget(event.target)) { + setMultiSelectKeyDown(false); + return; + } + setMultiSelectKeyDown(event.ctrlKey || event.metaKey); + }; + + const handleBlur = () => setMultiSelectKeyDown(false); + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); + + useEffect(() => { + writeImageCompressionPreference(imageCompressionEnabled); + }, [imageCompressionEnabled]); + + useEffect(() => observeSyncQueue(setSyncSummary), []); + + useEffect(() => { + const updateOnlineState = () => { + const online = navigator.onLine; + setIsOnline(online); + if (online) { + void runQueuedSync(); + } + }; + + window.addEventListener("online", updateOnlineState); + window.addEventListener("offline", updateOnlineState); + updateOnlineState(); + + return () => { + window.removeEventListener("online", updateOnlineState); + window.removeEventListener("offline", updateOnlineState); + }; + }, [runQueuedSync]); + + useEffect(() => { + if (syncSummary.total === 0) { + return; + } + const timer = window.setInterval(() => { + void runQueuedSync(); + }, 15_000); + + return () => window.clearInterval(timer); + }, [runQueuedSync, syncSummary.total]); + + const memosQuery = useQuery({ + queryKey: ["memos", memoView, selectedNotebookId, search], + queryFn: () => + api.listMemos({ + notebookId: memoView === "notebook" ? selectedNotebookId : null, + q: search, + trash: memoView === "trash", + }), + }); + + const memos = memosQuery.data?.memos ?? []; + const selectedMemoIndex = selectedMemoId ? memos.findIndex((memo) => memo.id === selectedMemoId) : -1; + const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null; + const nextMemoId = + selectedMemoIndex >= 0 && selectedMemoIndex < memos.length - 1 ? memos[selectedMemoIndex + 1]?.id : null; + + useEffect(() => { + if (memos.length === 0) { + setSelectedMemoId(null); + return; + } + + if (!selectedMemoId || !memos.some((memo) => memo.id === selectedMemoId)) { + setSelectedMemoId(memos[0].id); + } + }, [memos, selectedMemoId]); + + const memoQuery = useQuery({ + queryKey: ["memo", selectedMemoId, memoView], + queryFn: () => api.getMemo(selectedMemoId as string, { includeDeleted: memoView === "trash" }), + enabled: Boolean(selectedMemoId), + }); + + const createNotebookMutation = useMutation({ + mutationFn: api.createNotebook, + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); + setSelectedNotebookId(data.notebook.id); + setActivePane("memos"); + }, + }); + + const updateNotebookMutation = useMutation({ + mutationFn: ({ + notebookId, + payload, + }: { + notebookId: string; + payload: { name?: string; parentId?: string | null; sortOrder?: number }; + }) => api.updateNotebook(notebookId, payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); + }, + }); + + const deleteNotebookMutation = useMutation({ + mutationFn: api.deleteNotebook, + onSuccess: async (_data, notebookId) => { + if (selectedNotebookId === notebookId) { + setSelectedNotebookId(null); + setSelectedMemoId(null); + } + await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + }, + }); + + const createMemoMutation = useMutation({ + mutationFn: api.createMemo, + onSuccess: async (data) => { + setMemoView("notebook"); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); + setSelectedMemoId(data.memo.id); + setActivePane("editor"); + }, + }); + + const mergeMutation = useMutation({ + mutationFn: api.mergeMemos, + onSuccess: async (data) => { + clearMemoSelection(); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); + setSelectedMemoId(data.memo.id); + setActivePane("editor"); + }, + }); + + const moveMemosMutation = useMutation({ + mutationFn: api.moveMemos, + onSuccess: async () => { + clearMemoSelection(); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + await queryClient.invalidateQueries({ queryKey: ["memo"] }); + }, + }); + + const pinMemosMutation = useMutation({ + mutationFn: async ({ memoIds, isPinned }: { memoIds: string[]; isPinned: boolean }) => + Promise.all(memoIds.map((memoId) => api.updateMemo(memoId, { isPinned }))), + onMutate: async ({ memoIds, isPinned }) => { + await Promise.all([ + queryClient.cancelQueries({ queryKey: ["memos"] }), + queryClient.cancelQueries({ queryKey: ["memo"] }), + ]); + + const memoIdSet = new Set(memoIds); + const previousMemoQueries = queryClient.getQueriesData<{ memos: MemoSummary[] }>({ queryKey: ["memos"] }); + const previousMemoDetailQueries = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }); + + queryClient.setQueriesData<{ memos: MemoSummary[] }>({ queryKey: ["memos"] }, (current) => + current + ? { + memos: current.memos.map((memo) => (memoIdSet.has(memo.id) ? { ...memo, isPinned } : memo)), + } + : current + ); + queryClient.setQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }, (current) => + current && memoIdSet.has(current.memo.id) + ? { + memo: { ...current.memo, isPinned }, + } + : current + ); + + return { previousMemoQueries, previousMemoDetailQueries }; + }, + onError: (_error, _variables, context) => { + context?.previousMemoQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + context?.previousMemoDetailQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + await queryClient.invalidateQueries({ queryKey: ["memo"] }); + }, + }); + + const deleteMemosMutation = useMutation({ + mutationFn: api.deleteMemos, + onSuccess: async (_, variables) => { + const deletedMemoIds = new Set(variables.memoIds); + clearMemoSelection(); + + if (selectedMemoId && deletedMemoIds.has(selectedMemoId)) { + setSelectedMemoId(null); + setActivePane("memos"); + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo"] }), + queryClient.invalidateQueries({ queryKey: ["resources"] }), + ]); + }, + }); + + const deleteMemoMutation = useMutation({ + mutationFn: ({ memoId, permanent }: { memoId: string; permanent?: boolean }) => + api.deleteMemo(memoId, { permanent }), + onSuccess: async (_data, variables) => { + if (selectedMemoId === variables.memoId) { + setSelectedMemoId(null); + setActivePane("memos"); + } + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + }, + }); + + const restoreMemoMutation = useMutation({ + mutationFn: api.restoreMemo, + onSuccess: async (data) => { + setMemoView("notebook"); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); + setSelectedNotebookId(data.memo.notebookId); + setSelectedMemoId(data.memo.id); + setActivePane("editor"); + }, + }); + + const selectedNotebook = notebooks.find((notebook) => notebook.id === selectedNotebookId) ?? null; + const selectedMemo = memoQuery.data?.memo ?? null; + + const handleCreateNotebook = (parentId?: string | null) => { + setNotebookNameDialog({ mode: "create", parentId: parentId ?? null }); + }; + + const handleRenameNotebook = (notebook: Notebook) => { + setNotebookNameDialog({ mode: "rename", notebook }); + }; + + const handleSubmitNotebookName = (name: string) => { + if (!notebookNameDialog) { + return; + } + + const trimmedName = name.trim(); + if (!trimmedName) { + return; + } + + if (notebookNameDialog.mode === "create") { + createNotebookMutation.mutate( + { name: trimmedName, parentId: notebookNameDialog.parentId }, + { onSuccess: () => setNotebookNameDialog(null) } + ); + return; + } + + if (trimmedName === notebookNameDialog.notebook.name) { + setNotebookNameDialog(null); + return; + } + + updateNotebookMutation.mutate( + { notebookId: notebookNameDialog.notebook.id, payload: { name: trimmedName } }, + { onSuccess: () => setNotebookNameDialog(null) } + ); + }; + + const handleDeleteNotebook = (notebook: Notebook) => { + if (notebook.slug === "inbox") { + setAppNoticeDialog({ + title: "等待分类不能删除", + description: "等待分类是默认笔记本,用来保证新笔记始终有归属。", + }); + return; + } + setNotebookDeleteConfirmation(notebook); + }; + + const handleCreateMemo = (template?: MemoTemplate) => { + if (!defaultMemoNotebookId || memoView === "trash") { + return; + } + + setTemplatesOpen(false); + setMobileBottomNavActive("home"); + createMemoMutation.mutate({ + notebookId: defaultMemoNotebookId, + title: template?.title ?? DEFAULT_MEMO_TITLE, + contentMarkdown: template?.contentMarkdown ?? "", + tags: template?.tags ?? [], + }); + }; + + const handleCreateChecklistMemo = () => { + const checklistTemplate = MEMO_TEMPLATES.find((template) => template.id === "checklist"); + handleCreateMemo(checklistTemplate); + }; + + const handleMoveNotebook = ( + notebookId: string, + targetNotebookId: string, + position: NotebookDropPosition + ) => { + if (notebookId === targetNotebookId) { + return; + } + + const target = notebooks.find((notebook) => notebook.id === targetNotebookId); + if (!target) { + return; + } + + updateNotebookMutation.mutate({ + notebookId, + payload: { + parentId: position === "inside" ? target.id : target.parentId, + sortOrder: position === "inside" ? Date.now() : getNotebookDropSortOrder(notebooks, target, position), + }, + }); + }; + + const getMemoIdsNeedingMove = (memoIds: string[], targetNotebookId: string) => { + const memoNotebookMap = new Map(memos.map((memo) => [memo.id, memo.notebookId])); + return Array.from(new Set(memoIds.filter(Boolean))).filter((memoId) => memoNotebookMap.get(memoId) !== targetNotebookId); + }; + + const handleMoveSelectedMemos = (targetNotebookId: string) => { + if (selectedMemoIds.size === 0 || memoView === "trash") { + return; + } + + const memoIds = getMemoIdsNeedingMove(Array.from(selectedMemoIds), targetNotebookId); + if (memoIds.length === 0) { + return; + } + + moveMemosMutation.mutate({ + memoIds, + notebookId: targetNotebookId, + }); + }; + + const handleMoveDraggedMemos = (memoIds: string[], targetNotebookId: string) => { + if (memoView === "trash" || moveMemosMutation.isPending) { + return; + } + + const movableMemoIds = getMemoIdsNeedingMove(memoIds, targetNotebookId); + if (movableMemoIds.length === 0) { + return; + } + + moveMemosMutation.mutate({ + memoIds: movableMemoIds, + notebookId: targetNotebookId, + }); + }; + + const handleMoveMemoFromList = (memoId: string, targetNotebookId: string) => { + if (memoView === "trash") { + return; + } + + const memoIds = getMemoIdsNeedingMove([memoId], targetNotebookId); + if (memoIds.length === 0) { + return; + } + + moveMemosMutation.mutate({ + memoIds, + notebookId: targetNotebookId, + }); + }; + + const handleToggleMemoPinned = (memo: MemoSummary) => { + if (memoView === "trash") { + return; + } + + pinMemosMutation.mutate({ + memoIds: [memo.id], + isPinned: !memo.isPinned, + }); + }; + + const handlePinSelectedMemos = (isPinned: boolean) => { + if (selectedMemoIds.size === 0 || memoView === "trash") { + return; + } + + pinMemosMutation.mutate( + { + memoIds: Array.from(selectedMemoIds), + isPinned, + }, + { + onSuccess: clearMemoSelection, + } + ); + }; + + const handleMerge = () => { + if (selectedMemoIds.size < 2 || memoView === "trash") { + return; + } + + mergeMutation.mutate({ + memoIds: Array.from(selectedMemoIds), + notebookId: selectedNotebookId ?? undefined, + }); + }; + + const handleDeleteSelectedMemos = () => { + if (selectedMemoIds.size === 0) { + return; + } + + if (memoView !== "trash") { + deleteMemosMutation.mutate({ + memoIds: Array.from(selectedMemoIds), + permanent: false, + }); + return; + } + + setMemoDeleteConfirmation({ + kind: "bulk", + memoIds: Array.from(selectedMemoIds), + permanent: true, + }); + }; + + const handleDeleteMemoFromList = (memoId: string) => { + if (memoView !== "trash") { + deleteMemoMutation.mutate({ memoId, permanent: false }); + return; + } + setMemoDeleteConfirmation({ kind: "single", memoIds: [memoId], permanent: true }); + }; + + const handleConfirmMemoDeletion = () => { + if (!memoDeleteConfirmation) { + return; + } + + const { kind, memoIds, permanent } = memoDeleteConfirmation; + setMemoDeleteConfirmation(null); + + if (kind === "bulk") { + deleteMemosMutation.mutate({ memoIds, permanent }); + return; + } + + const [memoId] = memoIds; + if (memoId) { + deleteMemoMutation.mutate({ memoId, permanent }); + } + }; + + const handleRestoreMemoFromList = (memoId: string) => { + restoreMemoMutation.mutate(memoId); + }; + + const handleSelectNotebook = (notebookId: string) => { + setMemoView("notebook"); + setSelectedNotebookId(notebookId); + setMobileBottomNavActive("home"); + clearMemoSelection(); + setMobileNotebookPickerOpen(false); + setActivePane("memos"); + }; + + const handleSelectAllMemos = () => { + setMemoView("notebook"); + setSelectedNotebookId(null); + setMobileBottomNavActive("home"); + clearMemoSelection(); + setMobileNotebookPickerOpen(false); + setActivePane("memos"); + }; + + const handleMobileHome = () => { + if (memoView === "trash") { + setMemoView("notebook"); + } + setMobileBottomNavActive("home"); + setSelectedNotebookId(null); + setSearch(""); + clearMemoSelection(); + setActivePane("memos"); + }; + + const handleMobileSearch = () => { + setMobileBottomNavActive("search"); + setActivePane("memos"); + setMobileSearchFocusToken((value) => value + 1); + }; + + const handleCancelMobileSearch = () => { + setSearch(""); + setMobileBottomNavActive("home"); + clearMemoSelection(); + setActivePane("memos"); + }; + + const clearHiddenMobileSearch = () => { + if (!isDesktopViewport()) { + setSearch(""); + } + }; + + const handleOpenAssets = () => { + clearHiddenMobileSearch(); + setAssetsOpen(true); + }; + + const handleOpenTags = () => { + clearHiddenMobileSearch(); + setTagsOpen(true); + }; + + const handleOpenTemplates = () => { + clearHiddenMobileSearch(); + setMobileBottomNavActive("templates"); + setTemplatesOpen(true); + }; + + const handleOpenSettings = () => { + clearHiddenMobileSearch(); + setMobileBottomNavActive("settings"); + setSettingsOpen(true); + }; + + const handleCloseAssets = () => { + setAssetsOpen(false); + setMobileBottomNavActive("home"); + }; + + const handleCloseTemplates = () => { + setTemplatesOpen(false); + setMobileBottomNavActive("home"); + }; + + const handleCloseSettings = () => { + setSettingsOpen(false); + setMobileBottomNavActive("home"); + }; + + const handleWorkspaceBackRequest = useCallback(() => { + if (appNoticeDialog) { + setAppNoticeDialog(null); + return true; + } + + if (notebookDeleteConfirmation) { + if (!deleteNotebookMutation.isPending) { + setNotebookDeleteConfirmation(null); + } + return true; + } + + if (notebookNameDialog) { + if (!createNotebookMutation.isPending && !updateNotebookMutation.isPending) { + setNotebookNameDialog(null); + } + return true; + } + + if (memoDeleteConfirmation) { + if (!deleteMemosMutation.isPending && !deleteMemoMutation.isPending) { + setMemoDeleteConfirmation(null); + } + return true; + } + + if (mobileNotebookPickerOpen) { + setMobileNotebookPickerOpen(false); + return true; + } + + if (mobileSearchActive) { + handleCancelMobileSearch(); + return true; + } + + if (templatesOpen) { + handleCloseTemplates(); + return true; + } + + if (settingsOpen) { + handleCloseSettings(); + return true; + } + + if (tagsOpen) { + setTagsOpen(false); + return true; + } + + if (assetsOpen) { + handleCloseAssets(); + return true; + } + + if (memoSelectionModeActive) { + clearMemoSelection(); + return true; + } + + if (activePane === "editor" || activePane === "notebooks") { + setActivePane("memos"); + return true; + } + + return false; + }, [ + activePane, + appNoticeDialog, + assetsOpen, + clearMemoSelection, + createNotebookMutation.isPending, + deleteMemoMutation.isPending, + deleteMemosMutation.isPending, + deleteNotebookMutation.isPending, + handleCloseAssets, + handleCloseSettings, + handleCloseTemplates, + handleCancelMobileSearch, + memoDeleteConfirmation, + memoSelectionModeActive, + mobileNotebookPickerOpen, + mobileSearchActive, + notebookDeleteConfirmation, + notebookNameDialog, + settingsOpen, + tagsOpen, + templatesOpen, + updateNotebookMutation.isPending, + ]); + + useBrowserBackLayer(workspaceBackTargetActive, handleWorkspaceBackRequest); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const isBackShortcut = + event.key === "Escape" || + event.key === "BrowserBack" || + (!event.ctrlKey && !event.metaKey && event.altKey && event.key === "ArrowLeft"); + + if (!isBackShortcut || event.defaultPrevented || isTextEntryTarget(event.target)) { + return; + } + + if (!handleWorkspaceBackRequest()) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleWorkspaceBackRequest]); + + useEffect(() => { + const handleWorkspaceShortcut = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.altKey || (!event.ctrlKey && !event.metaKey)) { + return; + } + + const key = event.key.toLowerCase(); + if (key !== "f" && key !== "n") { + return; + } + + const transientLayerOpen = Boolean( + appNoticeDialog || + assetsOpen || + memoDeleteConfirmation || + mobileNotebookPickerOpen || + notebookDeleteConfirmation || + notebookNameDialog || + settingsOpen || + tagsOpen || + templatesOpen + ); + + if (transientLayerOpen) { + return; + } + + if (key === "f") { + event.preventDefault(); + if (event.shiftKey) { + setSearch(""); + } + clearMemoSelection(); + handleMobileSearch(); + return; + } + + event.preventDefault(); + + if (event.shiftKey) { + if (!createNotebookMutation.isPending) { + handleCreateNotebook(null); + } + return; + } + + if (canCreateMemo && !createMemoMutation.isPending) { + handleCreateMemo(); + } + }; + + window.addEventListener("keydown", handleWorkspaceShortcut); + return () => window.removeEventListener("keydown", handleWorkspaceShortcut); + }, [ + assetsOpen, + appNoticeDialog, + canCreateMemo, + clearMemoSelection, + createNotebookMutation.isPending, + createMemoMutation.isPending, + handleCreateNotebook, + handleCreateMemo, + handleMobileSearch, + memoDeleteConfirmation, + mobileNotebookPickerOpen, + notebookDeleteConfirmation, + notebookNameDialog, + settingsOpen, + tagsOpen, + templatesOpen, + ]); + + const handleMemoListResizePointerDown = (event: ReactPointerEvent) => { + if (!isDesktopViewport()) { + return; + } + + event.preventDefault(); + event.currentTarget.focus({ preventScroll: true }); + const startX = event.clientX; + const startWidth = memoListWidth; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const nextWidth = clampMemoListWidth(startWidth + moveEvent.clientX - startX); + setMemoListWidth(nextWidth); + writeMemoListWidthPreference(nextWidth); + }; + + const handlePointerUp = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + }; + + const handleResetMemoListWidth = () => { + setMemoListWidth(DEFAULT_MEMO_LIST_WIDTH_PX); + writeMemoListWidthPreference(DEFAULT_MEMO_LIST_WIDTH_PX); + }; + + const updateMemoListWidth = (width: number) => { + const nextWidth = clampMemoListWidth(width); + setMemoListWidth(nextWidth); + writeMemoListWidthPreference(nextWidth); + }; + + const handleMemoListResizeKeyDown = (event: ReactKeyboardEvent) => { + if (!isDesktopViewport()) { + return; + } + + const step = event.shiftKey ? 48 : 16; + let nextWidth: number | null = null; + + if (event.key === "ArrowLeft") { + nextWidth = memoListWidth - step; + } else if (event.key === "ArrowRight") { + nextWidth = memoListWidth + step; + } else if (event.key === "Home") { + nextWidth = MIN_MEMO_LIST_WIDTH_PX; + } else if (event.key === "End") { + nextWidth = MAX_MEMO_LIST_WIDTH_PX; + } else if (event.key === "Enter" || event.key === " ") { + nextWidth = DEFAULT_MEMO_LIST_WIDTH_PX; + } + + if (nextWidth === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + updateMemoListWidth(nextWidth); + }; + + return ( +
+
+
+ + +
+ setMobileNotebookPickerOpen(true)} + onSearch={setSearch} + onCancelMobileSearch={handleCancelMobileSearch} + onCreateChecklist={handleCreateChecklistMemo} + onCreateMemo={handleCreateMemo} + onClearSelection={clearMemoSelection} + onEnterSelectionMode={enterMemoSelectionMode} + onReplaceSelection={replaceMemoSelection} + onOpenAssets={handleOpenAssets} + onOpenTags={handleOpenTags} + onOpenTemplates={handleOpenTemplates} + onOpenSettings={handleOpenSettings} + onOpenTrash={() => { + setMemoView("trash"); + setSelectedNotebookId(null); + setMobileBottomNavActive("home"); + clearMemoSelection(); + setSelectedMemoId(null); + setActivePane("memos"); + }} + onOpenMemo={(memoId) => { + setSelectedMemoId(memoId); + setActivePane("editor"); + }} + onToggleMemo={(memoId, rangeMemoIds) => { + setMemoSelectionMode(true); + setSelectedMemoIds((current) => { + if (!rangeMemoIds?.length) { + return toggleMemoSelection(current, memoId); + } + const next = new Set(current); + for (const rangeMemoId of rangeMemoIds) { + next.add(rangeMemoId); + } + return next; + }); + }} + onMerge={handleMerge} + onDeleteMemo={handleDeleteMemoFromList} + onRestoreMemo={handleRestoreMemoFromList} + onMoveMemo={handleMoveMemoFromList} + onTogglePinMemo={handleToggleMemoPinned} + onPinSelectedMemos={handlePinSelectedMemos} + onDeleteSelectedMemos={handleDeleteSelectedMemos} + onMoveSelectedMemos={handleMoveSelectedMemos} + onSyncQueuedChanges={() => void runQueuedSync()} + mobileListActionsOpen={mobileListActionsOpen} + setMobileListActionsOpen={setMobileListActionsOpen} + mobileMoveOpen={mobileMoveOpen} + setMobileMoveOpen={setMobileMoveOpen} + mobileMoreOpen={mobileMoreOpen} + setMobileMoreOpen={setMobileMoreOpen} + desktopFilterOpen={desktopFilterOpen} + setDesktopFilterOpen={setDesktopFilterOpen} + desktopSortOpen={desktopSortOpen} + setDesktopSortOpen={setDesktopSortOpen} + desktopActionsOpen={desktopActionsOpen} + setDesktopActionsOpen={setDesktopActionsOpen} + /> +
+
+ +
+ setActivePane("memos")} + onOpenNextMemo={() => { + if (nextMemoId) { + setSelectedMemoId(nextMemoId); + } + }} + onOpenPreviousMemo={() => { + if (previousMemoId) { + setSelectedMemoId(previousMemoId); + } + }} + onSaved={async (memo) => { + queryClient.setQueryData(["memo", memo.id], { memo }); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + }} + onDeleted={async (memoId) => { + deleteMemoMutation.mutate({ memoId, permanent: false }); + }} + onPermanentDeleted={async (memoId) => { + setMemoDeleteConfirmation({ kind: "single", memoIds: [memoId], permanent: true }); + }} + onRestored={async (memoId) => { + await restoreMemoMutation.mutateAsync(memoId); + }} + /> +
+
+
+ + {assetsOpen && } + {tagsOpen && setTagsOpen(false)} />} + {settingsOpen && } + {templatesOpen && ( + + )} + {memoDeleteConfirmation && ( + setMemoDeleteConfirmation(null)} + onConfirm={handleConfirmMemoDeletion} + /> + )} + {notebookNameDialog && ( + setNotebookNameDialog(null)} + onSubmit={handleSubmitNotebookName} + /> + )} + {notebookDeleteConfirmation && ( + setNotebookDeleteConfirmation(null)} + onConfirm={() => { + deleteNotebookMutation.mutate(notebookDeleteConfirmation.id, { + onSuccess: () => setNotebookDeleteConfirmation(null), + }); + }} + /> + )} + {appNoticeDialog && ( + setAppNoticeDialog(null)} + onConfirm={() => setAppNoticeDialog(null)} + /> + )} + {activePane !== "editor" && !memoSelectionModeActive && ( + + )} + {mobileNotebookPickerOpen && ( + setMobileNotebookPickerOpen(false)} + onSelectAll={handleSelectAllMemos} + onSelect={handleSelectNotebook} + /> + )} +
+ ); +}; +export default WorkspaceApp; diff --git a/apps/web/src/components/dialogs/AssetsDialog.tsx b/apps/web/src/components/dialogs/AssetsDialog.tsx new file mode 100644 index 00000000..d499be53 --- /dev/null +++ b/apps/web/src/components/dialogs/AssetsDialog.tsx @@ -0,0 +1,96 @@ +import { useQuery } from "@tanstack/react-query"; +import { Archive, HardDrive, ImageIcon, File as FileIcon, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { api } from "@/lib/api"; +import { formatDateTime } from "@/lib/utils"; + +export const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** exponent; + + return `${exponent === 0 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[exponent]}`; +}; + +export const AssetsDialog = ({ onClose }: { onClose: () => void }) => { + const resourcesQuery = useQuery({ + queryKey: ["resources"], + queryFn: () => api.listResources(), + }); + const resources = resourcesQuery.data?.resources ?? []; + const summary = resourcesQuery.data?.summary ?? { + totalCount: 0, + totalBytes: 0, + imageCount: 0, + attachmentCount: 0, + }; + + return ( + { if (!open) onClose(); }}> + + +
+ + + 附件 + + + + + {formatBytes(summary.totalBytes)} + + {summary.totalCount} files + {summary.imageCount} images + +
+
+ + +
+
+ ); +}; diff --git a/apps/web/src/components/dialogs/ConfirmDialogs.tsx b/apps/web/src/components/dialogs/ConfirmDialogs.tsx new file mode 100644 index 00000000..ed206030 --- /dev/null +++ b/apps/web/src/components/dialogs/ConfirmDialogs.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect, useRef } from "react"; +import { AlertTriangle, ShieldCheck, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { Notebook } from "@edgeever/shared"; + +// Types derived from App.tsx +export type MemoDeleteConfirmation = { kind: "single" | "bulk"; memoIds: string[]; permanent: boolean }; +export type NotebookNameDialogState = + | { mode: "create"; parentId: string | null } + | { mode: "rename"; notebook: Notebook }; + +export const AppConfirmDialog = ({ + cancelLabel = "取消", + confirmLabel, + description, + hideCancel = false, + isWorking = false, + title, + tone = "danger", + closeOnBrowserBack, + onCancel, + onConfirm, +}: { + cancelLabel?: string; + confirmLabel: string; + description: string; + hideCancel?: boolean; + isWorking?: boolean; + title: string; + tone?: "danger" | "neutral" | "primary"; + closeOnBrowserBack?: boolean; + onCancel: () => void; + onConfirm: () => void; +}) => { + const toneClassName = + tone === "danger" + ? "bg-rose-50 text-rose-700" + : tone === "primary" + ? "bg-emerald-50 text-emerald-700" + : "bg-slate-100 text-slate-600"; + const confirmVariant = tone === "danger" ? "danger" : "solid"; + const Icon = tone === "danger" ? AlertTriangle : ShieldCheck; + + return ( + { if (!open && !isWorking) onCancel(); }}> + + + + + +
+ + {title} + + + {description} + +
+
+ + {!hideCancel && ( + + )} + + +
+
+ ); +}; + +export const MemoDeleteConfirmDialog = ({ + confirmation, + isDeleting, + onCancel, + onConfirm, +}: { + confirmation: MemoDeleteConfirmation; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +}) => { + const count = confirmation.memoIds.length; + const isBulk = confirmation.kind === "bulk" || count > 1; + const title = confirmation.permanent ? (isBulk ? `永久删除 ${count} 条笔记` : "永久删除笔记") : isBulk ? `删除 ${count} 条笔记` : "删除笔记"; + const description = confirmation.permanent ? "这个操作不可恢复。" : "删除后可以在回收站恢复。"; + const confirmLabel = confirmation.permanent ? "永久删除" : "删除"; + + return ( + + ); +}; + +export const NotebookNameDialog = ({ + dialog, + isSaving, + onCancel, + onSubmit, +}: { + dialog: NotebookNameDialogState; + isSaving: boolean; + onCancel: () => void; + onSubmit: (name: string) => void; +}) => { + const initialName = dialog.mode === "rename" ? dialog.notebook.name : ""; + const [name, setName] = useState(initialName); + const inputRef = useRef(null); + const trimmedName = name.trim(); + const unchanged = dialog.mode === "rename" && trimmedName === dialog.notebook.name; + const title = dialog.mode === "create" ? "新建笔记本" : "重命名笔记本"; + const submitLabel = dialog.mode === "create" ? "创建" : "保存"; + + useEffect(() => { + setName(initialName); + }, [initialName]); + + useEffect(() => { + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 50); + }, []); + + return ( + { if (!open && !isSaving) onCancel(); }}> + +
{ + event.preventDefault(); + if (!trimmedName || unchanged || isSaving) { + return; + } + onSubmit(trimmedName); + }} + > + +
+ + {title} + + + {dialog.mode === "create" ? "创建一个新的笔记本,用来整理相关笔记。" : "更新后会立即同步到笔记本树。"} + +
+
+
+ + setName(event.target.value)} + /> +
+ + + + +
+
+
+ ); +}; diff --git a/apps/web/src/components/dialogs/RevisionHistoryDialog.tsx b/apps/web/src/components/dialogs/RevisionHistoryDialog.tsx new file mode 100644 index 00000000..c8086bec --- /dev/null +++ b/apps/web/src/components/dialogs/RevisionHistoryDialog.tsx @@ -0,0 +1,182 @@ +import { useState, useMemo, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { History, RotateCcw, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { api } from "@/lib/api"; +import { cn, formatDateTime } from "@/lib/utils"; +import { getMemoTitle } from "@/lib/app-helpers"; +import { AppConfirmDialog } from "./ConfirmDialogs"; +import type { MemoDetail } from "@edgeever/shared"; + +const summarizeMarkdownDiff = (left: string, right: string) => { + const leftLines = left.split("\n"); + const rightLines = right.split("\n"); + const maxLines = Math.max(leftLines.length, rightLines.length); + let changed = 0; + + for (let index = 0; index < maxLines; index += 1) { + if ((leftLines[index] ?? "") !== (rightLines[index] ?? "")) { + changed += 1; + } + } + + return { changed }; +}; + +const formatRevisionActor = (actor: string) => { + if (actor.startsWith("user:")) { + return "user"; + } + + if (actor.startsWith("agent:")) { + return "agent"; + } + + return actor || "system"; +}; + +export const RevisionPreview = ({ title, markdown }: { title: string; markdown: string }) => ( +
+
{title}
+
+      {markdown || "空笔记"}
+    
+
+); + +export const RevisionHistoryDialog = ({ + memo, + currentMarkdown, + onClose, + onRestored, +}: { + memo: MemoDetail; + currentMarkdown: string; + onClose: () => void; + onRestored: (memo: MemoDetail) => Promise; +}) => { + const [selectedRevisionId, setSelectedRevisionId] = useState(null); + const [restoreRevisionConfirmationId, setRestoreRevisionConfirmationId] = useState(null); + + const revisionsQuery = useQuery({ + queryKey: ["memo-revisions", memo.id], + queryFn: () => api.listMemoRevisions(memo.id), + }); + + const revisions = revisionsQuery.data?.revisions ?? []; + const selectedRevision = + revisions.find((revision) => revision.id === selectedRevisionId) ?? revisions[0] ?? null; + + const diffSummary = useMemo( + () => summarizeMarkdownDiff(selectedRevision?.contentMarkdown ?? "", currentMarkdown), + [currentMarkdown, selectedRevision?.contentMarkdown] + ); + + const restoreMutation = useMutation({ + mutationFn: (revisionId: string) => api.restoreMemoRevision(memo.id, revisionId), + onSuccess: async (data) => { + setRestoreRevisionConfirmationId(null); + await onRestored(data.memo); + }, + }); + + useEffect(() => { + if (!selectedRevisionId && revisions.length > 0) { + setSelectedRevisionId(revisions[0].id); + } + }, [revisions, selectedRevisionId]); + + return ( + { if (!open && !restoreRevisionConfirmationId) onClose(); }}> + + +
+ + + 版本历史 + + + {getMemoTitle(memo.title)} + +
+
+ +
+ + +
+
+
+ {selectedRevision ? `${diffSummary.changed} changed lines` : "No revision selected"} +
+ +
+ +
+ + +
+
+
+
+ + {restoreRevisionConfirmationId && ( + setRestoreRevisionConfirmationId(null)} + onConfirm={() => restoreMutation.mutate(restoreRevisionConfirmationId)} + /> + )} +
+ ); +}; diff --git a/apps/web/src/components/dialogs/SettingsDialog.tsx b/apps/web/src/components/dialogs/SettingsDialog.tsx new file mode 100644 index 00000000..d0de895e --- /dev/null +++ b/apps/web/src/components/dialogs/SettingsDialog.tsx @@ -0,0 +1,207 @@ +import { useState, type FormEvent } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, ShieldCheck, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { api } from "@/lib/api"; +import { cn, formatDateTime } from "@/lib/utils"; +import { AppConfirmDialog } from "./ConfirmDialogs"; +import type { ApiToken } from "@edgeever/shared"; + +const DEFAULT_TOKEN_SCOPES = ["read:notebooks", "read:memos", "read:tags"]; + +export const SettingsDialog = ({ onClose }: { onClose: () => void }) => { + const queryClient = useQueryClient(); + const [name, setName] = useState("MCP Agent"); + const [selectedScopes, setSelectedScopes] = useState>(() => new Set(DEFAULT_TOKEN_SCOPES)); + const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null); + const [tokenRevokeConfirmation, setTokenRevokeConfirmation] = useState(null); + + const tokensQuery = useQuery({ + queryKey: ["api-tokens"], + queryFn: () => api.listApiTokens(), + }); + + const availableScopes = tokensQuery.data?.availableScopes ?? [ + "read:notebooks", + "write:notebooks", + "read:memos", + "write:memos", + "read:resources", + "write:resources", + "read:tags", + "write:tags", + ]; + + const createMutation = useMutation({ + mutationFn: api.createApiToken, + onSuccess: async (data) => { + setCreatedToken(data); + setName(""); + setSelectedScopes(new Set(DEFAULT_TOKEN_SCOPES)); + await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: api.revokeApiToken, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + }, + }); + + const tokens = tokensQuery.data?.apiTokens ?? []; + + const toggleScope = (scope: string) => { + setSelectedScopes((current) => { + const next = new Set(current); + if (next.has(scope)) { + next.delete(scope); + } else { + next.add(scope); + } + return next; + }); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const scopes = Array.from(selectedScopes); + + if (!name.trim() || scopes.length === 0) { + return; + } + + createMutation.mutate({ name: name.trim(), scopes }); + }; + + return ( + { if (!open && !tokenRevokeConfirmation) onClose(); }}> + + +
+ + + 设置 + + + API Token / MCP + +
+
+ +
+ {createdToken && ( +
+
+ + Token 已生成 +
+
+ + +
+
明文 Token 只显示这一次。
+
+ )} + +
+
+ setName(event.target.value)} + placeholder="Token 名称" + /> + +
+
+ {availableScopes.map((scope) => ( + + ))} +
+
+ +
+ {tokensQuery.isLoading ? ( +
加载中
+ ) : tokens.length === 0 ? ( +
+ 暂无 API Token +
+ ) : ( + tokens.map((token) => ( +
+ + {token.name} + + {token.scopes.join(", ") || "no scopes"} + + + {token.lastUsedAt ? `Last used ${formatDateTime(token.lastUsedAt)}` : "Never used"} + + + +
+ )) + )} +
+
+
+ + {tokenRevokeConfirmation && ( + setTokenRevokeConfirmation(null)} + onConfirm={() => { + revokeMutation.mutate(tokenRevokeConfirmation.id, { + onSuccess: () => setTokenRevokeConfirmation(null), + }); + }} + /> + )} +
+ ); +}; diff --git a/apps/web/src/components/dialogs/TagsDialog.tsx b/apps/web/src/components/dialogs/TagsDialog.tsx new file mode 100644 index 00000000..fd4946f9 --- /dev/null +++ b/apps/web/src/components/dialogs/TagsDialog.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Tags, X, Pencil, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { api } from "@/lib/api"; +import { cn, formatDateTime } from "@/lib/utils"; +import { AppConfirmDialog } from "./ConfirmDialogs"; +import type { TagSummary } from "@edgeever/shared"; + +export const TagsDialog = ({ onClose }: { onClose: () => void }) => { + const queryClient = useQueryClient(); + const [editingTagName, setEditingTagName] = useState(null); + const [editingTagValue, setEditingTagValue] = useState(""); + const [tagDeleteConfirmation, setTagDeleteConfirmation] = useState(null); + + const tagsQuery = useQuery({ + queryKey: ["tags"], + queryFn: () => api.listTags(), + }); + + const renameMutation = useMutation({ + mutationFn: ({ tag, name }: { tag: string; name: string }) => api.renameTag(tag, name), + onSuccess: async () => { + setEditingTagName(null); + setEditingTagValue(""); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["tags"] }), + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo"] }), + ]); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: api.deleteTag, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["tags"] }), + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo"] }), + ]); + }, + }); + + const tags = tagsQuery.data?.tags ?? []; + + const handleRename = (tag: TagSummary) => { + setEditingTagName(tag.name); + setEditingTagValue(tag.name); + }; + + const handleCancelRename = () => { + setEditingTagName(null); + setEditingTagValue(""); + }; + + const handleSubmitRename = (tag: TagSummary) => { + const nextName = editingTagValue.trim(); + + if (!nextName || nextName === tag.name || renameMutation.isPending) { + return; + } + + renameMutation.mutate({ tag: tag.name, name: nextName }); + }; + + const handleDelete = (tag: TagSummary) => { + setTagDeleteConfirmation(tag); + }; + + return ( + { if (!open && !tagDeleteConfirmation) onClose(); }}> + + +
+ + + 标签 + + + {tags.length} tags + +
+
+ +
+ {tagsQuery.isLoading ? ( +
加载中
+ ) : tags.length === 0 ? ( +
+ 暂无标签 +
+ ) : ( +
+ {tags.map((tag) => { + const isEditing = editingTagName === tag.name; + const nextName = editingTagValue.trim(); + + return ( +
+ + {isEditing ? ( +
{ + event.preventDefault(); + handleSubmitRename(tag); + }} + > + + setEditingTagValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + handleCancelRename(); + } + }} + /> +
+ + +
+
+ ) : ( + <> + #{tag.name} + + {tag.memoCount} 条笔记{tag.updatedAt ? ` · ${formatDateTime(tag.updatedAt)}` : ""} + + + )} +
+ {!isEditing && ( + <> + + + + )} +
+ ); + })} +
+ )} +
+
+ + {tagDeleteConfirmation && ( + setTagDeleteConfirmation(null)} + onConfirm={() => { + deleteMutation.mutate(tagDeleteConfirmation.name, { + onSuccess: () => setTagDeleteConfirmation(null), + }); + }} + /> + )} +
+ ); +}; diff --git a/apps/web/src/components/dialogs/TemplatesDialog.tsx b/apps/web/src/components/dialogs/TemplatesDialog.tsx new file mode 100644 index 00000000..64d5606f --- /dev/null +++ b/apps/web/src/components/dialogs/TemplatesDialog.tsx @@ -0,0 +1,104 @@ +import { LayoutList, File as FileIcon, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; + +export type MemoTemplate = { + id: string; + title: string; + description: string; + contentMarkdown: string; + tags: string[]; +}; + +export const MEMO_TEMPLATES: MemoTemplate[] = [ + { + id: "quick-note", + title: "速记", + description: "适合临时记录想法、链接和灵感。", + contentMarkdown: "## 速记\n\n- \n\n## 后续动作\n\n- [ ] ", + tags: ["template", "quick-note"], + }, + { + id: "meeting", + title: "会议记录", + description: "议题、结论和待办放在同一页。", + contentMarkdown: "## 会议记录\n\n时间:\n参与人:\n\n## 议题\n\n- \n\n## 结论\n\n- \n\n## 待办\n\n- [ ] ", + tags: ["template", "meeting"], + }, + { + id: "checklist", + title: "清单", + description: "快速列出待办、采购、项目检查项。", + contentMarkdown: "## 清单\n\n- [ ] \n- [ ] \n- [ ] ", + tags: ["template", "checklist"], + }, + { + id: "reading", + title: "读书笔记", + description: "摘录、观点和下一步阅读整理。", + contentMarkdown: "## 读书笔记\n\n书名:\n作者:\n\n## 摘录\n\n> \n\n## 我的观点\n\n\n## 延伸问题\n\n- ", + tags: ["template", "reading"], + }, + { + id: "daily", + title: "每日复盘", + description: "记录今天完成了什么、卡在哪里。", + contentMarkdown: "## 每日复盘\n\n## 今天完成\n\n- \n\n## 遇到的问题\n\n- \n\n## 明天优先级\n\n- [ ] ", + tags: ["template", "daily"], + }, +]; + +export const TemplatesDialog = ({ + canCreateMemo, + isCreating, + onClose, + onCreateMemo, +}: { + canCreateMemo: boolean; + isCreating: boolean; + onClose: () => void; + onCreateMemo: (template: MemoTemplate) => void; +}) => { + return ( + { if (!open && !isCreating) onClose(); }}> + + +
+ + + 模板 + + + 选择一个模板,直接创建新笔记。 + +
+
+ +
+
+ {MEMO_TEMPLATES.map((template) => ( + + ))} +
+ {!canCreateMemo && ( +
+ 当前无法创建笔记,请先选择可用笔记本。 +
+ )} +
+
+
+ ); +}; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 6e350658..e920cd3b 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,39 +1,51 @@ import { cn } from "@/lib/utils"; -import { forwardRef, type ButtonHTMLAttributes } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; -type ButtonVariant = "solid" | "soft" | "ghost" | "outline" | "danger"; -type ButtonSize = "sm" | "md" | "icon"; +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#627f58]/70 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + solid: "bg-[#627f58] text-white hover:bg-[#526d49] border-[#627f58]", + soft: "bg-slate-50 text-slate-700 hover:bg-slate-100 border-slate-200", + ghost: "bg-transparent text-slate-600 hover:bg-slate-50 hover:text-slate-900 border-transparent", + outline: "bg-white text-slate-700 hover:bg-slate-50 border border-slate-200", + danger: "bg-rose-50 text-rose-700 hover:bg-rose-100 border border-rose-100", + }, + size: { + default: "h-9 px-4 py-2 text-sm", + sm: "h-8 gap-1.5 px-2.5 text-xs", + md: "h-9 gap-2 px-3 text-sm", + icon: "h-8 w-8 p-0", + }, + }, + defaultVariants: { + variant: "soft", + size: "md", + }, + } +); -type ButtonProps = ButtonHTMLAttributes & { - variant?: ButtonVariant; - size?: ButtonSize; -}; - -const variantClassName: Record = { - solid: "bg-[#627f58] text-white hover:bg-[#526d49] border-[#627f58]", - soft: "bg-slate-50 text-slate-700 hover:bg-slate-100 border-slate-200", - ghost: "bg-transparent text-slate-600 hover:bg-slate-50 hover:text-slate-900 border-transparent", - outline: "bg-white text-slate-700 hover:bg-slate-50 border-slate-200", - danger: "bg-rose-50 text-rose-700 hover:bg-rose-100 border-rose-100", -}; - -const sizeClassName: Record = { - sm: "h-8 gap-1.5 px-2.5 text-xs", - md: "h-9 gap-2 px-3 text-sm", - icon: "h-8 w-8 justify-center p-0", -}; - -export const Button = forwardRef(({ className, variant = "soft", size = "md", ...props }, ref) => ( -