import { lazy, Suspense, useState, useCallback, useEffect, useMemo, useRef, type CSSProperties, type ReactNode, type MouseEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { Home, Search, UserRound, Plus, ChevronDown, ChevronRight, RefreshCw, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { MemoListPane, MemoSelectionActionBar } from "./MemoListPane"; import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs"; import { api } from "@/lib/api"; import { MOBILE_EDITOR_RETURN_PARAM, clearMobileEditorReturnPreview, consumeStandaloneMobileEditorReturn, getStandaloneMobileEditorReturningMemoId, openStandaloneMobileEditor, readMobileEditorReturnPreview, type MobileEditorReturnPreview, } from "@/lib/mobile-editor"; import { cn } from "@/lib/utils"; import { createExcerpt, docToText, getNotebookDescendantIds, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type Resource, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import type { Pane, MemoView, MemoDeleteConfirmation, NotebookNameDialogState, AppNoticeDialogState, MobileBottomNavItem, NotebookNode, NotebookDropPosition, NotebookMoveOption, MemoTemplate, ShortcutSettings, MemoFilterMode, MemoSortMode, } from "@/lib/app-helpers"; import { MIN_MEMO_LIST_WIDTH_PX, MAX_MEMO_LIST_WIDTH_PX, DEFAULT_MEMO_LIST_WIDTH_PX, isTextEntryTarget, readAutoSaveIntervalPreference, readImageCompressionPreference, writeAutoSaveIntervalPreference, writeImageCompressionPreference, readDesktopFocusModePreference, writeDesktopFocusModePreference, readShortcutSettingsPreference, writeShortcutSettingsPreference, getShortcutActionForEvent, readMemoListWidthPreference, writeMemoListWidthPreference, clampMemoListWidth, getNotebookDropSortOrder, buildNotebookTree, notebookTreeContainsId, getNotebookAncestorIds, getExpandableNotebookIds, filterNotebookTree, getNotebookMoveOptions, } from "@/lib/app-helpers"; import { useBrowserBackLayer } from "@/lib/app-hooks"; import { updateMemoSummaryInLists, type MemoListQueryData } from "@/lib/memo-list-cache"; import type { SyncQueueSummary } from "@/lib/sync-queue"; import { createLocalDataScope, putLocalMemo, putLocalNotebook, replaceLocalMemoId, } from "@/lib/local-mirror"; import { createRepository } from "@/lib/repository"; const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; const PULL_TO_REFRESH_TRIGGER_PX = 72; const PULL_TO_REFRESH_MAX_PX = 96; const isStandaloneApp = () => window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches || Boolean((navigator as Navigator & { standalone?: boolean }).standalone); const getVerticalScrollContainer = (target: EventTarget | null) => { let element = target instanceof HTMLElement ? target : null; while (element && element !== document.body) { const style = window.getComputedStyle(element); const canScroll = /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight; if (canScroll) { return element; } element = element.parentElement; } return null; }; const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane }))); const AssetsPane = lazy(() => import("./AssetsPane").then((module) => ({ default: module.AssetsPane }))); const SettingsPane = lazy(() => import("./SettingsPane").then((module) => ({ default: module.SettingsPane }))); const NotebookPane = lazy(() => import("./NotebookPane").then((module) => ({ default: module.NotebookPane }))); const EvernoteImportGuidePane = lazy(() => import("./EvernoteImportGuidePane").then((module) => ({ default: module.EvernoteImportGuidePane })) ); const TagsPane = lazy(() => import("./TagsPane").then((module) => ({ default: module.TagsPane }))); const TemplatesPane = lazy(() => import("./TemplatesPane").then((module) => ({ default: module.TemplatesPane }))); const SETTINGS_PATH = "/settings"; const TEMPLATES_PATH = "/templates"; const TRASH_VIEW_SEARCH = "?view=trash"; const getMobileEditorReturnMemoId = (search: string) => new URLSearchParams(search).get(MOBILE_EDITOR_RETURN_PARAM); const emptySyncQueueSummary = (): SyncQueueSummary => ({ total: 0, pending: 0, syncing: 0, conflict: 0, error: 0, }); const PaneLoadingFallback = ({ label = "Loading" }: { label?: string }) => (
{label}
); const memoDetailQueryKey = (memoId: string, view: MemoView) => ["memo", memoId, view] as const; type ListNotebooksQueryData = { notebooks: Notebook[]; }; type MemoDeleteOptimisticContext = { previousMemoLists: Array<[readonly unknown[], MemoListQueryData | undefined]>; previousMemoDetails: Array<[readonly unknown[], { memo: MemoDetail } | undefined]>; previousNotebooks: ListNotebooksQueryData | undefined; previousActivePane: Pane; previousSelectedMemoId: string | null; }; type EmptyTrashOptimisticContext = { previousMemoLists: Array<[readonly unknown[], MemoListQueryData | undefined]>; previousMemoDetails: Array<[readonly unknown[], { memo: MemoDetail } | undefined]>; previousActivePane: Pane; previousSelectedMemoId: string | null; }; const memoToSummary = (memo: MemoDetail): MemoSummary => ({ id: memo.id, notebookId: memo.notebookId, title: memo.title, excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(memo.contentJson) || memo.contentMarkdown), tags: memo.tags, isPinned: memo.isPinned, isArchived: memo.isArchived, isDeleted: memo.isDeleted, revision: memo.revision, createdAt: memo.createdAt, updatedAt: memo.updatedAt, deletedAt: memo.deletedAt, }); const cacheMemoDetail = (queryClient: QueryClient, memo: MemoDetail, view: MemoView = memo.isDeleted ? "trash" : "notebook") => { queryClient.setQueryData(memoDetailQueryKey(memo.id, view), { memo }); }; const collectMemoSummariesFromCache = (queryClient: QueryClient, memoIds: Set) => { const summaries = new Map(); for (const [, current] of queryClient.getQueriesData({ queryKey: ["memos"] })) { for (const page of current?.pages ?? []) { for (const memo of page.memos) { if (memoIds.has(memo.id) && !summaries.has(memo.id)) { summaries.set(memo.id, memo); } } } } for (const [, current] of queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] })) { if (current?.memo && memoIds.has(current.memo.id) && !summaries.has(current.memo.id)) { summaries.set(current.memo.id, memoToSummary(current.memo)); } } return Array.from(summaries.values()); }; const removeMemoSummariesFromLists = (queryClient: QueryClient, memoIds: Set) => { queryClient.setQueriesData({ queryKey: ["memos"] }, (current) => { if (!current) { return current; } let changed = false; const pages = current.pages.map((page) => { const memos = page.memos.filter((memo) => !memoIds.has(memo.id)); if (memos.length === page.memos.length) { return page; } changed = true; return { ...page, memos, totalCount: Math.max(0, page.totalCount - (page.memos.length - memos.length)), }; }); return changed ? { ...current, pages } : current; }); }; const clearTrashMemoLists = (queryClient: QueryClient) => { for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ["memos", "trash"] })) { if (!current) { continue; } queryClient.setQueryData(queryKey, { ...current, pages: current.pages.map((page) => ({ ...page, memos: [], totalCount: 0, nextCursor: null })), }); } }; const decrementNotebookMemoCounts = (queryClient: QueryClient, removedMemos: MemoSummary[]) => { if (removedMemos.length === 0) { return; } const countsByNotebook = new Map(); for (const memo of removedMemos) { if (memo.isDeleted) { continue; } countsByNotebook.set(memo.notebookId, (countsByNotebook.get(memo.notebookId) ?? 0) + 1); } if (countsByNotebook.size === 0) { return; } queryClient.setQueryData(["notebooks"], (current) => current ? { notebooks: current.notebooks.map((notebook) => { const removedCount = countsByNotebook.get(notebook.id) ?? 0; return removedCount > 0 ? { ...notebook, memoCount: Math.max(0, notebook.memoCount - removedCount) } : notebook; }), } : current ); }; const getAdjacentMemoIdAfterRemoval = (memos: MemoSummary[], removedMemoIds: Set, anchorMemoId: string) => { const anchorIndex = memos.findIndex((memo) => memo.id === anchorMemoId); if (anchorIndex < 0) { return null; } for (let index = anchorIndex + 1; index < memos.length; index++) { const memoId = memos[index]?.id; if (memoId && !removedMemoIds.has(memoId)) { return memoId; } } for (let index = anchorIndex - 1; index >= 0; index--) { const memoId = memos[index]?.id; if (memoId && !removedMemoIds.has(memoId)) { return memoId; } } return null; }; const MobileBottomNavButton = ({ active = false, icon, label, onClick, }: { active?: boolean; icon: ReactNode; label: string; onClick: () => void; }) => ( ); const MobileBottomNav = ({ activeItem, canCreateMemo, isCreating, onCreateMemo, onHome, onOpenSettings, }: { activeItem: MobileBottomNavItem; canCreateMemo: boolean; isCreating: boolean; onCreateMemo: () => void; onHome: () => void; onOpenSettings: () => void; }) => { const { t } = useTranslation(); const createMemoLabel = !canCreateMemo ? t("nav.createDisabled") : isCreating ? t("nav.creating") : t("nav.createMemo"); return ( ); }; const MobileNotebookPicker = ({ currentLabel, notebooks, selectedNotebookId, onClose, onSelectAll, onSelect, }: { currentLabel?: string; notebooks: Notebook[]; selectedNotebookId: string | null; onClose: () => void; onSelectAll: () => void; onSelect: (notebookId: string) => void; }) => { const { t, i18n } = useTranslation(); const listRef = useRef(null); 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 ? t("mobileNotebookPicker.allMemos") : notebooks.find((item) => item.id === selectedNotebookId)?.name ?? t("mobileNotebookPicker.notebookFallback")); const searchQuery = notebookSearch.trim(); const searchActive = Boolean(searchQuery); const allNotebookBranchesExpanded = expandableNotebookIds.length > 0 && expandableNotebookIds.every((notebookId) => expandedNotebookIds.has(notebookId)); 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 ( { if (!open) onClose(); }}>
{t("mobileNotebookPicker.title")} {t("mobileNotebookPicker.current", { name: selectedNotebookName })}
setNotebookSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape" && notebookSearch) { event.preventDefault(); setNotebookSearch(""); } }} /> {notebookSearch && ( )}
{filteredTree.length > 0 ? ( <>
{searchActive ? t("mobileNotebookPicker.matchedNotebooks") : t("mobileNotebookPicker.notebooks")} {!searchActive && expandableNotebookIds.length > 0 && ( )}
{filteredTree.map((node) => ( ))} ) : (
{searchQuery ? t("mobileNotebookPicker.noSearchResult", { query: searchQuery }) : t("mobileNotebookPicker.noNotebook")}
{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 { t } = useTranslation(); 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, demoMode, user, isLoggingOut, onLogout, }: { authRequired: boolean; demoMode: boolean; user: AuthUser | null; isLoggingOut: boolean; onLogout: () => void; }) => { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const location = useLocation(); const navigate = useNavigate(); const localDataScope = useMemo( () => createLocalDataScope(window.location.origin, user?.id), [user?.id] ); const repository = useMemo(() => createRepository(localDataScope), [localDataScope]); const isInitialSettingsRoute = location.pathname === SETTINGS_PATH; const isInitialTemplatesRoute = location.pathname === TEMPLATES_PATH; const isInitialMobileEditorReturn = Boolean(getMobileEditorReturnMemoId(location.search)); const isTrashRoute = location.pathname === "/" && location.search === TRASH_VIEW_SEARCH; const [activePane, setActivePane] = useState(() => ((isInitialSettingsRoute || isInitialTemplatesRoute) && !isInitialMobileEditorReturn ? "editor" : "memos")); const [memoView, setMemoView] = useState(() => (isTrashRoute ? "trash" : "notebook")); const [selectedNotebookId, setSelectedNotebookId] = useState(null); const autoSelectedDemoNotebookRef = useRef(false); const [selectedMemoId, setSelectedMemoId] = useState(null); const [createdMemoEditId, setCreatedMemoEditId] = useState(null); const [selectedMemoIds, setSelectedMemoIds] = useState>(new Set()); const [memoSelectionMode, setMemoSelectionMode] = useState(false); const [selectionMoveTargetNotebookId, setSelectionMoveTargetNotebookId] = useState(""); const [memoDeleteConfirmation, setMemoDeleteConfirmation] = useState(null); const [emptyTrashConfirmationOpen, setEmptyTrashConfirmationOpen] = useState(false); const [notebookNameDialog, setNotebookNameDialog] = useState(null); const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState(null); const [appNoticeDialog, setAppNoticeDialog] = useState(null); const [demoResetConfirmationOpen, setDemoResetConfirmationOpen] = useState(false); const resetDemoMutation = useMutation({ mutationFn: () => api.resetDemo(), onSuccess: () => { setDemoResetConfirmationOpen(false); void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["resources"] }), queryClient.invalidateQueries({ queryKey: ["tags"] }), ]); setAppNoticeDialog({ title: t("demo.resetSuccess"), description: t("demo.resetSuccess"), }); }, onError: () => { setDemoResetConfirmationOpen(false); setAppNoticeDialog({ title: t("demo.resetFailed"), description: t("demo.resetFailed"), }); }, }); const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); const [autoSaveIntervalMs, setAutoSaveIntervalMs] = useState(readAutoSaveIntervalPreference); const [desktopFocusMode, setDesktopFocusMode] = useState(readDesktopFocusModePreference); const [shortcutSettings, setShortcutSettings] = useState(readShortcutSettingsPreference); const [rightView, setRightView] = useState<"editor" | "settings" | "assets" | "tags" | "templates" | "evernote-migration">(() => isInitialSettingsRoute ? "settings" : isInitialTemplatesRoute ? "templates" : "editor" ); const [templatesOpen, setTemplatesOpen] = useState(false); const [mobileNotebookPickerOpen, setMobileNotebookPickerOpen] = useState(false); const [mobileBottomNavActive, setMobileBottomNavActive] = useState(() => isInitialSettingsRoute && !isInitialMobileEditorReturn ? "settings" : isInitialTemplatesRoute ? "templates" : "home" ); const [mobileSearchFocusToken, setMobileSearchFocusToken] = useState(0); const [noteSearchFocusToken, setNoteSearchFocusToken] = useState(0); const [noteReplaceFocusToken, setNoteReplaceFocusToken] = useState(0); const [memoListWidth, setMemoListWidth] = useState(readMemoListWidthPreference); const [search, setSearch] = useState(""); const [memoFilterMode, setMemoFilterMode] = useState("all"); const [memoSortMode, setMemoSortMode] = useState("updated-desc"); const [syncSummary, setSyncSummary] = useState(emptySyncQueueSummary); const [mobileEditorReturnPreview, setMobileEditorReturnPreview] = useState(() => readMobileEditorReturnPreview(getMobileEditorReturnMemoId(location.search)) ); const [isOnline, setIsOnline] = useState(() => (typeof navigator === "undefined" ? true : navigator.onLine)); const [isDesktop, setIsDesktop] = useState(isDesktopViewport); const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false); const [isManualMemoSyncing, setIsManualMemoSyncing] = useState(false); const [isStandaloneRuntime] = useState(isStandaloneApp); const [pullToRefreshDistance, setPullToRefreshDistance] = useState(0); const [isPullRefreshing, setIsPullRefreshing] = useState(false); const isPullRefreshingRef = useRef(false); const skipNextHomeRouteSyncRef = useRef(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 navigateWorkspaceHome = () => { if (location.pathname !== "/" || location.search) { navigate("/"); } }; const navigateWorkspaceTrash = () => { if (location.pathname !== "/" || location.search !== TRASH_VIEW_SEARCH) { navigate(`/${TRASH_VIEW_SEARCH}`); } }; const navigateWorkspaceSettings = () => { if (location.pathname !== SETTINGS_PATH) { navigate(SETTINGS_PATH); } }; const navigateWorkspaceTemplates = () => { if (location.pathname !== TEMPLATES_PATH) { navigate(TEMPLATES_PATH); } }; const runQueuedSync = useCallback(async () => { if (typeof navigator !== "undefined" && !navigator.onLine) { setIsOnline(false); return; } setIsSyncingQueuedChanges(true); try { if (window.edgeeverDesktop?.isAvailable) { const { getDesktopSyncSummary, syncDesktopData } = await import("@/lib/desktop-sync"); await syncDesktopData(); setSyncSummary(await getDesktopSyncSummary()); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), ]); return; } const { syncQueuedChanges } = await import("@/lib/sync-queue"); await syncQueuedChanges({ scope: localDataScope, onSynced: async (memo, item) => { if (item.kind === "memo.create") { await replaceLocalMemoId(localDataScope, item.memoId, memo); if (selectedMemoId === item.memoId) { setSelectedMemoId(memo.id); setCreatedMemoEditId(null); } } else { await putLocalMemo(localDataScope, memo); } cacheMemoDetail(queryClient, memo); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo", memo.id] }), ]); }, onActionSynced: async (result, item) => { if (!result || (item.kind !== "memo.merge" && item.kind !== "notebook.create" && item.kind !== "notebook.update" && item.kind !== "template.create" && item.kind !== "template.update" && item.kind !== "resource.create")) return; const { deleteLocalMemo, deleteLocalNotebook, deleteLocalTemplate, getLocalMemo, putLocalMemo, putLocalNotebook, putLocalTemplate, putLocalResource } = await import("@/lib/local-mirror"); const actionPayload = item.payload as Record; const temporaryId = typeof actionPayload.temporaryId === "string" ? actionPayload.temporaryId : null; if (item.kind === "resource.create" && "url" in result) { await putLocalResource(localDataScope, { ...(result as Resource), memoTitle: null, memoExcerpt: null, memoDeleted: false, }); } else if (item.kind === "memo.merge" && "contentJson" in result && temporaryId) { await deleteLocalMemo(localDataScope, temporaryId, true); await putLocalMemo(localDataScope, result as MemoDetail); const sourceIds = Array.isArray(actionPayload.memoIds) ? actionPayload.memoIds.filter((value): value is string => typeof value === "string") : []; await Promise.all(sourceIds.map(async (sourceId) => { const source = await getLocalMemo(localDataScope, sourceId); if (source) await putLocalMemo(localDataScope, { ...source, mergedIntoMemoId: (result as MemoDetail).id }); })); } else if (item.kind === "notebook.create" && "name" in result && temporaryId) { await deleteLocalNotebook(localDataScope, temporaryId); await putLocalNotebook(localDataScope, result as Notebook); } else if (item.kind === "notebook.update" && "name" in result) { await putLocalNotebook(localDataScope, result as Notebook); } else if (item.kind === "template.create" && "contentMarkdown" in result && temporaryId) { await deleteLocalTemplate(localDataScope, temporaryId); await putLocalTemplate(localDataScope, result as SavedMemoTemplate); } else if (item.kind === "template.update" && "contentMarkdown" in result) { await putLocalTemplate(localDataScope, result as SavedMemoTemplate); } await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["templates"] }), queryClient.invalidateQueries({ queryKey: ["resources"] }), ]); }, }); } finally { setIsSyncingQueuedChanges(false); } }, [localDataScope, queryClient, selectedMemoId]); const discardConflictsNow = useCallback(async () => { if (!isOnline) return; setIsSyncingQueuedChanges(true); try { if (window.edgeeverDesktop?.isAvailable) { const { discardDesktopConflicts, getDesktopSyncSummary } = await import("@/lib/desktop-sync"); await discardDesktopConflicts(); setSyncSummary(await getDesktopSyncSummary()); } else { const { discardWebConflicts } = await import("@/lib/sync-queue"); await discardWebConflicts(localDataScope); } await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), ]); } finally { setIsSyncingQueuedChanges(false); } }, [isOnline, queryClient]); const refreshLatestMemos = useCallback(async () => { if (typeof navigator !== "undefined" && !navigator.onLine) { setIsOnline(false); setPullToRefreshDistance(0); return; } setIsPullRefreshing(true); try { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), ]); } finally { setIsPullRefreshing(false); setPullToRefreshDistance(0); } }, [isOnline, localDataScope, queryClient]); const syncMemosManually = useCallback(async () => { if (typeof navigator !== "undefined" && !navigator.onLine) { setIsOnline(false); return; } setIsManualMemoSyncing(true); try { await runQueuedSync(); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: "all" }), ]); } finally { setIsManualMemoSyncing(false); } }, [queryClient, runQueuedSync]); const notebooksQuery = useQuery({ queryKey: ["notebooks"], queryFn: () => repository.listNotebooks(), }); const templatesQuery = useQuery({ queryKey: ["templates"], queryFn: () => repository.listTemplates(), enabled: rightView === "templates", }); useEffect(() => { let cancelled = false; let timeoutId: number | null = null; let idleId: number | null = null; const refreshLocalMirror = async () => { try { const result = await repository.sync(); if (!cancelled && result.changed > 0) { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), ]); } } catch { // The existing remote queries remain the safe fallback when local // mirror sync is unavailable (for example before login or offline). } }; const scheduleRefresh = (delay: number) => { if (timeoutId !== null) { window.clearTimeout(timeoutId); } timeoutId = window.setTimeout(() => { timeoutId = null; const idleWindow = window as Window & { requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; cancelIdleCallback?: (id: number) => void; }; if (idleWindow.requestIdleCallback) { idleId = idleWindow.requestIdleCallback(() => void refreshLocalMirror(), { timeout: 2500 }); } else { void refreshLocalMirror(); } }, delay); }; // Do not compete with the first remote queries for bandwidth. The initial // screen can render from the remote fallback; the full local snapshot is // hydrated once the browser is idle. scheduleRefresh(1200); const handleOnline = () => scheduleRefresh(300); window.addEventListener("online", handleOnline); return () => { cancelled = true; if (timeoutId !== null) { window.clearTimeout(timeoutId); } const idleWindow = window as Window & { cancelIdleCallback?: (id: number) => void }; if (idleId !== null) { idleWindow.cancelIdleCallback?.(idleId); } window.removeEventListener("online", handleOnline); }; }, [queryClient, repository]); const savedTemplates = templatesQuery.data?.templates ?? []; const notebooks = notebooksQuery.data?.notebooks ?? []; useEffect(() => { const english = i18n.resolvedLanguage === "en-US"; const preferredNotebookId = english ? "nb_demo_features_en" : "nb_demo_features"; if (!notebooks.some((notebook) => notebook.id === preferredNotebookId)) { return; } if (!autoSelectedDemoNotebookRef.current && selectedNotebookId === null) { autoSelectedDemoNotebookRef.current = true; setSelectedNotebookId(preferredNotebookId); } }, [i18n.resolvedLanguage, notebooks, selectedNotebookId]); const mobileEditorReturnMemoId = getMobileEditorReturnMemoId(location.search); const visibleActivePane: Pane = mobileEditorReturnMemoId ? "memos" : activePane; const defaultMemoNotebookId = notebooks.find( (notebook) => notebook.id === "nb_inbox" || notebook.slug === "inbox" || notebook.name === "等待分类" )?.id ?? null; const createMemoNotebookId = (selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) ? selectedNotebookId : null) ?? defaultMemoNotebookId; const canCreateMemo = Boolean(createMemoNotebookId && memoView !== "trash"); const memoSelectionModeActive = memoSelectionMode || selectedMemoIds.size > 0; const mobileSearchActive = mobileBottomNavActive === "search"; const workspaceBackTargetActive = Boolean( appNoticeDialog || notebookDeleteConfirmation || notebookNameDialog || memoDeleteConfirmation || emptyTrashConfirmationOpen || mobileNotebookPickerOpen || mobileListActionsOpen || mobileMoveOpen || mobileMoreOpen || mobileSearchActive || templatesOpen || rightView !== "editor" || memoSelectionModeActive || visibleActivePane === "editor" || visibleActivePane === "notebooks" ); const mobilePullToRefreshActive = Boolean( !isDesktop && visibleActivePane === "memos" && !appNoticeDialog && !notebookDeleteConfirmation && !notebookNameDialog && !memoDeleteConfirmation && !emptyTrashConfirmationOpen && !mobileNotebookPickerOpen && !mobileListActionsOpen && !mobileMoveOpen && !mobileMoreOpen && !templatesOpen ); const clearMemoSelection = useCallback(() => { setSelectedMemoIds(new Set()); setMemoSelectionMode(false); }, []); const clearPendingCreatedMemo = useCallback(() => {}, []); const applyMobileEditorReturnPreview = useCallback((memoId: string | null) => { const returnPreview = readMobileEditorReturnPreview(memoId); if (!returnPreview) { return; } setMobileEditorReturnPreview(returnPreview); clearMobileEditorReturnPreview(memoId); }, []); useEffect(() => { const handleStandaloneMobileEditorReturn = () => { if (document.visibilityState === "hidden") { return; } const returnedMemoId = getStandaloneMobileEditorReturningMemoId(); if (!returnedMemoId) { return; } applyMobileEditorReturnPreview(returnedMemoId); consumeStandaloneMobileEditorReturn(returnedMemoId); setRightView("editor"); setMobileBottomNavActive("home"); setActivePane("memos"); setSelectedMemoId(null); setCreatedMemoEditId(null); clearMemoSelection(); }; handleStandaloneMobileEditorReturn(); window.addEventListener("pageshow", handleStandaloneMobileEditorReturn); document.addEventListener("visibilitychange", handleStandaloneMobileEditorReturn); return () => { window.removeEventListener("pageshow", handleStandaloneMobileEditorReturn); document.removeEventListener("visibilitychange", handleStandaloneMobileEditorReturn); }; }, [applyMobileEditorReturnPreview, clearMemoSelection]); useEffect(() => { const returnedMemoId = getMobileEditorReturnMemoId(location.search); if (!returnedMemoId) { return; } applyMobileEditorReturnPreview(returnedMemoId); skipNextHomeRouteSyncRef.current = false; setRightView("editor"); setMobileBottomNavActive("home"); setActivePane("memos"); setSelectedMemoId(null); setCreatedMemoEditId(null); clearMemoSelection(); if (location.pathname !== "/" || location.search) { navigate("/", { replace: true }); } }, [applyMobileEditorReturnPreview, clearMemoSelection, location.pathname, location.search, navigate]); 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(() => { writeAutoSaveIntervalPreference( autoSaveIntervalMs === null ? "1m" : autoSaveIntervalMs === 300_000 ? "5m" : autoSaveIntervalMs === 900_000 ? "15m" : autoSaveIntervalMs === 1_800_000 ? "30m" : autoSaveIntervalMs === 3_600_000 ? "1h" : autoSaveIntervalMs === 7_200_000 ? "2h" : "1m" ); }, [autoSaveIntervalMs]); useEffect(() => { writeShortcutSettingsPreference(shortcutSettings); }, [shortcutSettings]); useEffect(() => { let timeoutId: number | null = null; let idleId: number | null = null; const prefetchEditor = () => { void import("./EditorPane"); }; const idleWindow = window as Window & { requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; cancelIdleCallback?: (id: number) => void; }; if (idleWindow.requestIdleCallback) { idleId = idleWindow.requestIdleCallback(prefetchEditor, { timeout: 2500 }); } else { timeoutId = window.setTimeout(prefetchEditor, 1200); } return () => { if (idleId !== null) idleWindow.cancelIdleCallback?.(idleId); if (timeoutId !== null) window.clearTimeout(timeoutId); }; }, []); useEffect(() => { if (location.pathname === SETTINGS_PATH) { skipNextHomeRouteSyncRef.current = false; setRightView("settings"); setMobileBottomNavActive("settings"); setActivePane("editor"); return; } if (location.pathname === TEMPLATES_PATH) { skipNextHomeRouteSyncRef.current = false; setRightView("templates"); setMobileBottomNavActive("templates"); setActivePane("editor"); return; } if (skipNextHomeRouteSyncRef.current) { skipNextHomeRouteSyncRef.current = false; return; } setMemoView(isTrashRoute ? "trash" : "notebook"); setRightView("editor"); setMobileBottomNavActive("home"); }, [isTrashRoute, location.pathname]); useEffect(() => { if (window.edgeeverDesktop?.isAvailable) { let active = true; const update = async () => { const { getDesktopSyncSummary } = await import("@/lib/desktop-sync"); if (active) setSyncSummary(await getDesktopSyncSummary()); }; void update(); return () => { active = false; }; } let unsubscribe: (() => void) | undefined; let active = true; void import("@/lib/sync-queue").then(({ observeSyncQueue }) => { if (!active) { return; } unsubscribe = observeSyncQueue(setSyncSummary); }); return () => { active = false; unsubscribe?.(); }; }, []); useEffect(() => { const mediaQuery = window.matchMedia("(min-width: 1024px)"); const updateDesktopState = () => setIsDesktop(mediaQuery.matches); updateDesktopState(); mediaQuery.addEventListener("change", updateDesktopState); return () => mediaQuery.removeEventListener("change", updateDesktopState); }, []); useEffect(() => { isPullRefreshingRef.current = isPullRefreshing; }, [isPullRefreshing]); useEffect(() => { if (!mobilePullToRefreshActive) { setPullToRefreshDistance(0); return; } let tracking = false; let startX = 0; let startY = 0; let currentDistance = 0; let scrollContainer: HTMLElement | null = null; const reset = () => { tracking = false; startX = 0; startY = 0; currentDistance = 0; scrollContainer = null; setPullToRefreshDistance(0); }; const handleTouchStart = (event: TouchEvent) => { if (event.touches.length !== 1 || isPullRefreshingRef.current || isTextEntryTarget(event.target)) { return; } scrollContainer = getVerticalScrollContainer(event.target); if ((scrollContainer && scrollContainer.scrollTop > 0) || (!scrollContainer && window.scrollY > 0)) { return; } const touch = event.touches[0]; tracking = true; startX = touch.clientX; startY = touch.clientY; }; const handleTouchMove = (event: TouchEvent) => { if (!tracking || event.touches.length !== 1) { return; } if ((scrollContainer && scrollContainer.scrollTop > 0) || (!scrollContainer && window.scrollY > 0)) { reset(); return; } const touch = event.touches[0]; const deltaY = touch.clientY - startY; const deltaX = Math.abs(touch.clientX - startX); if (deltaY <= 0 || deltaX > deltaY) { reset(); return; } currentDistance = Math.min(PULL_TO_REFRESH_MAX_PX, deltaY * 0.55); if (currentDistance > 8) { event.preventDefault(); setPullToRefreshDistance(currentDistance); } }; const handleTouchEnd = () => { if (!tracking) { return; } const shouldRefresh = currentDistance >= PULL_TO_REFRESH_TRIGGER_PX; reset(); if (shouldRefresh) { setPullToRefreshDistance(PULL_TO_REFRESH_TRIGGER_PX); if (isStandaloneRuntime) { void refreshLatestMemos(); return; } setIsPullRefreshing(true); window.location.reload(); } }; document.addEventListener("touchstart", handleTouchStart, { passive: true }); document.addEventListener("touchmove", handleTouchMove, { passive: false }); document.addEventListener("touchend", handleTouchEnd, { passive: true }); document.addEventListener("touchcancel", reset, { passive: true }); return () => { document.removeEventListener("touchstart", handleTouchStart); document.removeEventListener("touchmove", handleTouchMove); document.removeEventListener("touchend", handleTouchEnd); document.removeEventListener("touchcancel", reset); }; }, [isStandaloneRuntime, mobilePullToRefreshActive, refreshLatestMemos]); 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(() => { const handleQueueChanged = () => { void runQueuedSync(); }; window.addEventListener("edgeever:sync-queue-changed", handleQueueChanged); return () => window.removeEventListener("edgeever:sync-queue-changed", handleQueueChanged); }, [runQueuedSync]); useEffect(() => { if (!isStandaloneRuntime) { return; } const refreshWorkspaceQueries = () => { if (document.visibilityState === "hidden" || (typeof navigator !== "undefined" && !navigator.onLine)) { return; } void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), ]); }; window.addEventListener("pageshow", refreshWorkspaceQueries); document.addEventListener("visibilitychange", refreshWorkspaceQueries); return () => { window.removeEventListener("pageshow", refreshWorkspaceQueries); document.removeEventListener("visibilitychange", refreshWorkspaceQueries); }; }, [isStandaloneRuntime, queryClient]); useEffect(() => { if (syncSummary.total === 0) { return; } const timer = window.setInterval(() => { void runQueuedSync(); }, 15_000); return () => window.clearInterval(timer); }, [runQueuedSync, syncSummary.total]); const selectedNotebookDescendantIds = useMemo( () => (selectedNotebookId ? getNotebookDescendantIds(notebooks, selectedNotebookId) : []), [notebooks, selectedNotebookId] ); const memosQuery = useInfiniteQuery({ queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds], queryFn: ({ pageParam }) => repository.listMemos({ notebookId: memoView === "notebook" ? selectedNotebookId : null, notebookIds: memoView === "notebook" ? selectedNotebookDescendantIds : undefined, q: search, trash: memoView === "trash", filter: memoFilterMode, sort: memoSortMode, offset: pageParam ? Number(pageParam) : 0, }), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }); const memos = useMemo(() => { const memoMap = new Map(); for (const page of memosQuery.data?.pages ?? []) { for (const memo of page.memos) { const shouldUseReturnPreview = mobileEditorReturnPreview?.memoId === memo.id && memo.revision <= mobileEditorReturnPreview.baseRevision; memoMap.set( memo.id, shouldUseReturnPreview ? { ...memo, title: mobileEditorReturnPreview.title, excerpt: mobileEditorReturnPreview.excerpt, tags: mobileEditorReturnPreview.tags, updatedAt: mobileEditorReturnPreview.updatedAt, } : memo ); } } return Array.from(memoMap.values()); }, [memosQuery.data?.pages, mobileEditorReturnPreview]); const totalMemoCount = memosQuery.data?.pages[0]?.totalCount ?? memos.length; const handleLoadMoreMemos = useCallback(() => { if (!memosQuery.hasNextPage || memosQuery.isFetchingNextPage) { return; } void memosQuery.fetchNextPage(); }, [memosQuery]); 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(() => { const selectedMemoInList = selectedMemoId ? memos.some((memo) => memo.id === selectedMemoId) : false; if (createdMemoEditId && selectedMemoId === createdMemoEditId) { // Keep the create request alive until the editor consumes it. The new // memo can appear in the list before its detail query has mounted the // editor, and clearing it here would lose the autofocus request. return; } if (memos.length === 0) { setSelectedMemoId(null); return; } if (!selectedMemoId || !selectedMemoInList) { setSelectedMemoId(memos[0].id); } }, [createdMemoEditId, memos, selectedMemoId]); const memoQuery = useQuery({ queryKey: selectedMemoId ? memoDetailQueryKey(selectedMemoId, memoView) : ["memo", selectedMemoId, memoView], queryFn: () => repository.getMemo(selectedMemoId as string, memoView === "trash"), enabled: Boolean(selectedMemoId), }); const createNotebookMutation = useMutation({ mutationFn: repository.createNotebook, onSuccess: async (data) => { await putLocalNotebook(localDataScope, data.notebook); 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 }; }) => repository.updateNotebook(notebookId, payload), onSuccess: async (data) => { if (data?.notebook) { await putLocalNotebook(localDataScope, data.notebook); } await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); }, }); const deleteNotebookMutation = useMutation({ mutationFn: repository.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: repository.createMemo, onSuccess: (data) => { void putLocalMemo(localDataScope, data.memo); const targetNotebookId = data.memo.notebookId; setMemoView("notebook"); setSearch(""); // A newly created memo is not pinned or otherwise guaranteed to match // the active list filter. Leave filtered views so the selected memo // remains visible instead of the list effect falling back to its first // item (for example, the currently pinned memo). setMemoFilterMode("all"); if (targetNotebookId !== selectedNotebookId) { setSelectedNotebookId(targetNotebookId); } cacheMemoDetail(queryClient, data.memo, "notebook"); updateMemoSummaryInLists(queryClient, memoToSummary(data.memo)); void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), ]); navigateWorkspaceHome(); setRightView("editor"); setCreatedMemoEditId(data.memo.id); setSelectedMemoId(data.memo.id); setActivePane("editor"); if (!isDesktopViewport()) { openStandaloneMobileEditor(data.memo.id); } }, }); const saveTemplateMutation = useMutation({ mutationFn: (input: { name: string; memoId: string }) => repository.createTemplate(input), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["templates"] }); setAppNoticeDialog({ title: t("templates.templateSaved"), description: t("templates.templateSaved") }); }, }); const createTemplateMutation = useMutation({ mutationFn: (input: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }) => repository.createTemplate(input), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["templates"] }); setAppNoticeDialog({ title: t("templates.templateCreated"), description: t("templates.templateCreated") }); }, }); const useTemplateMutation = useMutation({ mutationFn: (input: { templateId: string; notebookId: string }) => repository.useTemplate(input.templateId, input.notebookId), onSuccess: (data) => { const targetNotebookId = data.memo.notebookId; setTemplatesOpen(false); setMemoView("notebook"); setSearch(""); setSelectedNotebookId(targetNotebookId); cacheMemoDetail(queryClient, data.memo, "notebook"); updateMemoSummaryInLists(queryClient, memoToSummary(data.memo)); void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), ]); navigateWorkspaceHome(); setRightView("editor"); setCreatedMemoEditId(data.memo.id); setSelectedMemoId(data.memo.id); setActivePane("editor"); if (!isDesktopViewport()) openStandaloneMobileEditor(data.memo.id); }, }); const deleteTemplateMutation = useMutation({ mutationFn: (templateId: string) => repository.deleteTemplate(templateId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["templates"] }); }, }); const updateTemplateMutation = useMutation({ mutationFn: (input: { templateId: string; payload: { name: string; description: string | null; title: string | null; contentMarkdown: string; tags: string[] }; }) => repository.updateTemplate(input.templateId, input.payload), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["templates"] }); }, }); const mergeMutation = useMutation({ mutationFn: repository.mergeMemos, onSuccess: async (data) => { clearMemoSelection(); cacheMemoDetail(queryClient, data.memo, "notebook"); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), ]); navigateWorkspaceHome(); setRightView("editor"); setSelectedMemoId(data.memo.id); setActivePane("editor"); }, }); const moveMemosMutation = useMutation({ mutationFn: repository.moveMemos, onSuccess: async () => { clearMemoSelection(); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), ]); }, }); const pinMemosMutation = useMutation({ mutationFn: repository.pinMemos, onMutate: async ({ memoIds, isPinned }) => { await Promise.all([ queryClient.cancelQueries({ queryKey: ["memos"] }), queryClient.cancelQueries({ queryKey: ["memo"] }), ]); const memoIdSet = new Set(memoIds); const previousMemoDetailQueries = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }); queryClient.setQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }, (current) => current && memoIdSet.has(current.memo.id) ? { memo: { ...current.memo, isPinned }, } : current ); return { previousMemoDetailQueries }; }, onError: (_error, _variables, context) => { context?.previousMemoDetailQueries.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), ]); }, }); const deleteMemosMutation = useMutation({ mutationFn: repository.deleteMemos, onMutate: async (variables): Promise => { const deletedMemoIds = new Set(variables.memoIds); await Promise.all([ queryClient.cancelQueries({ queryKey: ["memos"] }), queryClient.cancelQueries({ queryKey: ["memo"] }), queryClient.cancelQueries({ queryKey: ["notebooks"] }), ]); const previousMemoLists = queryClient.getQueriesData({ queryKey: ["memos"] }); const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }); const previousNotebooks = queryClient.getQueryData(["notebooks"]); const removedMemos = collectMemoSummariesFromCache(queryClient, deletedMemoIds); clearMemoSelection(); if (selectedMemoId && deletedMemoIds.has(selectedMemoId)) { setSelectedMemoId(getAdjacentMemoIdAfterRemoval(memos, deletedMemoIds, selectedMemoId)); setActivePane("memos"); } removeMemoSummariesFromLists(queryClient, deletedMemoIds); decrementNotebookMemoCounts(queryClient, removedMemos); return { previousMemoLists, previousMemoDetails, previousNotebooks, previousActivePane: activePane, previousSelectedMemoId: selectedMemoId }; }, onError: (_error, _variables, context) => { context?.previousMemoLists.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); context?.previousMemoDetails.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); queryClient.setQueryData(["notebooks"], context?.previousNotebooks); setSelectedMemoId(context?.previousSelectedMemoId ?? null); setActivePane(context?.previousActivePane ?? "memos"); }, onSettled: (_data, _error, variables) => { const refetchType = _error ? "active" : "inactive"; const deletedMemoIds = new Set(variables?.memoIds ?? []); if (!_error) { for (const memoId of deletedMemoIds) { queryClient.removeQueries({ queryKey: ["memo", memoId] }); } } void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType }), queryClient.invalidateQueries({ queryKey: ["memo"], refetchType }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType }), queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: _error ? "active" : "all" }), ]); }, }); const deleteMemoMutation = useMutation({ mutationFn: async ({ memoId, permanent }: { memoId: string; permanent?: boolean }) => { return repository.deleteMemo(memoId, Boolean(permanent)); }, onMutate: async (variables): Promise => { const deletedMemoIds = new Set([variables.memoId]); await Promise.all([ queryClient.cancelQueries({ queryKey: ["memos"] }), queryClient.cancelQueries({ queryKey: ["memo", variables.memoId] }), queryClient.cancelQueries({ queryKey: ["notebooks"] }), ]); const previousMemoLists = queryClient.getQueriesData({ queryKey: ["memos"] }); const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo", variables.memoId] }); const previousNotebooks = queryClient.getQueryData(["notebooks"]); const removedMemos = collectMemoSummariesFromCache(queryClient, deletedMemoIds); if (selectedMemoId === variables.memoId) { setSelectedMemoId(getAdjacentMemoIdAfterRemoval(memos, deletedMemoIds, variables.memoId)); setActivePane("memos"); } removeMemoSummariesFromLists(queryClient, deletedMemoIds); decrementNotebookMemoCounts(queryClient, removedMemos); return { previousMemoLists, previousMemoDetails, previousNotebooks, previousActivePane: activePane, previousSelectedMemoId: selectedMemoId }; }, onError: (_error, _variables, context) => { context?.previousMemoLists.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); context?.previousMemoDetails.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); queryClient.setQueryData(["notebooks"], context?.previousNotebooks); setSelectedMemoId(context?.previousSelectedMemoId ?? null); setActivePane(context?.previousActivePane ?? "memos"); }, onSettled: (_data, _error, variables) => { const refetchType = _error ? "active" : "inactive"; if (!_error) { queryClient.removeQueries({ queryKey: ["memo", variables?.memoId] }); } void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType }), queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: _error ? "active" : "all" }), ]); }, }); const emptyTrashMutation = useMutation({ mutationFn: repository.emptyTrash, onMutate: async (): Promise => { const previousActivePane = activePane; const previousSelectedMemoId = selectedMemoId; // Leave the editor before cancelling/refetching its detail query. On a // desktop layout the editor remains mounted even when the memo pane is // visible, so keeping a trashed memo selected while its query is being // invalidated can render a deleted detail and repeatedly re-select it. setEmptyTrashConfirmationOpen(false); clearMemoSelection(); setSelectedMemoId(null); setActivePane("memos"); await Promise.all([ queryClient.cancelQueries({ queryKey: ["memos"] }), queryClient.cancelQueries({ queryKey: ["memo"] }), queryClient.cancelQueries({ queryKey: ["resources"] }), ]); const previousMemoLists = queryClient.getQueriesData({ queryKey: ["memos"] }); const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] }); // Keep the optimistic update limited to list data. Removing the active // memo detail query here can make the editor render with a missing memo // during the same React update and blank the whole workspace. clearTrashMemoLists(queryClient); return { previousMemoLists, previousMemoDetails, previousActivePane, previousSelectedMemoId }; }, onError: (_error, _variables, context) => { context?.previousMemoLists.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); context?.previousMemoDetails.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); setSelectedMemoId(context?.previousSelectedMemoId ?? null); setActivePane(context?.previousActivePane ?? "memos"); setAppNoticeDialog({ title: t("workspaceDialogs.emptyTrashFailedTitle"), description: t("workspaceDialogs.emptyTrashFailedDescription"), }); }, onSuccess: () => { // The trash detail queries are no longer valid after a successful // permanent delete. Remove them before invalidating the remaining // active queries so an editor cannot briefly observe a 404 detail. queryClient.removeQueries({ queryKey: ["memo"], predicate: (query) => { const data = query.state.data as { memo?: { isDeleted?: boolean } } | undefined; return data?.memo?.isDeleted === true; }, }); setSelectedMemoId(null); setActivePane("memos"); }, onSettled: () => { void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "active" }), queryClient.invalidateQueries({ queryKey: ["memo"], refetchType: "active" }), queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: "active" }), ]); }, }); const restoreMemoMutation = useMutation({ mutationFn: repository.restoreMemo, onSuccess: (data) => { setMemoView("notebook"); cacheMemoDetail(queryClient, data.memo, "notebook"); void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: "all" }), ]); setSelectedNotebookId(data.memo.notebookId); navigateWorkspaceHome(); setRightView("editor"); setSelectedMemoId(data.memo.id); setActivePane("editor"); }, }); const selectedNotebook = notebooks.find((notebook) => notebook.id === selectedNotebookId) ?? null; const cachedSelectedMemo = selectedMemoId ? queryClient.getQueryData<{ memo: MemoDetail }>(memoDetailQueryKey(selectedMemoId, memoView))?.memo ?? null : null; const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo; const desktopFocusModeActive = Boolean( isDesktop && desktopFocusMode && rightView === "editor" && selectedMemo && !memoSelectionModeActive ); const selectionMoveNotebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]); const selectedMemosInCurrentList = useMemo( () => memos.filter((memo) => selectedMemoIds.has(memo.id)), [memos, selectedMemoIds] ); useEffect(() => { if (selectedNotebook?.id) { setSelectionMoveTargetNotebookId(selectedNotebook.id); return; } if (!selectionMoveTargetNotebookId && selectionMoveNotebookOptions[0]?.id) { setSelectionMoveTargetNotebookId(selectionMoveNotebookOptions[0].id); } }, [selectedNotebook?.id, selectionMoveNotebookOptions, selectionMoveTargetNotebookId]); 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.id === "nb_inbox" || notebook.slug === "inbox" || notebook.name === "等待分类") { setAppNoticeDialog({ title: t("workspace.inboxDeleteTitle"), description: t("workspace.inboxDeleteDescription"), }); return; } setNotebookDeleteConfirmation(notebook); }; const handleCreateMemo = (template?: MemoTemplate) => { const targetNotebookId = createMemoNotebookId; if (!targetNotebookId || memoView === "trash") { return; } setTemplatesOpen(false); setMobileBottomNavActive("home"); createMemoMutation.mutate({ notebookId: targetNotebookId, title: template?.title ?? "", contentMarkdown: template?.contentMarkdown ?? "", tags: template?.tags ?? [], }); }; const handleSaveAsTemplate = async (memo: MemoDetail, name: string) => { await saveTemplateMutation.mutateAsync({ name, memoId: memo.id }); }; const handleUseSavedTemplate = (template: SavedMemoTemplate) => { if (!createMemoNotebookId || memoView === "trash") return; useTemplateMutation.mutate({ templateId: template.id, notebookId: createMemoNotebookId }); }; const handleMobileDefaultEditConsumed = useCallback(() => { setCreatedMemoEditId(null); }, []); 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 allSelectedMemosPinned = selectedMemosInCurrentList.length > 0 && selectedMemosInCurrentList.every((memo) => memo.isPinned); const selectedPinTarget = !allSelectedMemosPinned; const selectionPinLabel = allSelectedMemosPinned ? t("workspace.selection.unpin") : t("workspace.selection.pin"); const selectionPinTitle = selectedMemoIds.size === 0 ? t("workspace.selection.chooseMemo") : memoView === "trash" ? t("workspace.selection.trashCannotPin") : pinMemosMutation.isPending ? t("workspace.selection.updatingPin") : selectionPinLabel; const selectionMoveTitle = selectedMemoIds.size === 0 ? t("workspace.selection.chooseMemo") : memoView === "trash" ? t("workspace.selection.trashCannotMove") : notebooks.length === 0 ? t("workspace.selection.noMovableNotebook") : moveMemosMutation.isPending ? t("workspace.selection.moving") : t("workspace.selection.move"); const selectionMergeTitle = selectedMemoIds.size < 2 ? t("workspace.selection.needTwoMemos") : memoView === "trash" ? t("workspace.selection.trashCannotMerge") : mergeMutation.isPending ? t("workspace.selection.merging") : t("workspace.selection.merge"); const selectionDeleteTitle = selectedMemoIds.size === 0 ? t("workspace.selection.chooseMemo") : deleteMemosMutation.isPending || deleteMemoMutation.isPending ? t("workspace.selection.deleting") : memoView === "trash" ? t("workspace.selection.permanentDelete") : t("workspace.selection.delete"); const memoSelectionActionBar = memoSelectionModeActive ? ( handleMoveSelectedMemos(selectionMoveTargetNotebookId)} onMoveTargetChange={setSelectionMoveTargetNotebookId} onPin={() => handlePinSelectedMemos(selectedPinTarget)} pinLabel={selectionPinLabel} pinTarget={selectedPinTarget} pinTitle={selectionPinTitle} selectedCount={selectedMemoIds.size} /> ) : null; 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 handleEmptyTrash = () => { setEmptyTrashConfirmationOpen(true); }; const handleConfirmEmptyTrash = () => { emptyTrashMutation.mutate(); }; const handleSelectNotebook = (notebookId: string) => { navigateWorkspaceHome(); setMemoView("notebook"); setSelectedNotebookId(notebookId); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setMobileNotebookPickerOpen(false); setActivePane("memos"); }; const handleSelectAllMemos = () => { navigateWorkspaceHome(); setMemoView("notebook"); setSelectedNotebookId(null); setRightView("editor"); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setMobileNotebookPickerOpen(false); setActivePane("memos"); }; const handleMobileHome = () => { navigateWorkspaceHome(); if (memoView === "trash") { setMemoView("notebook"); } setMobileBottomNavActive("home"); setSelectedNotebookId(null); setSearch(""); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); 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(); skipNextHomeRouteSyncRef.current = location.pathname !== "/"; navigateWorkspaceHome(); setRightView("assets"); setActivePane("editor"); }; const handleOpenTags = () => { clearHiddenMobileSearch(); skipNextHomeRouteSyncRef.current = location.pathname !== "/"; navigateWorkspaceHome(); setRightView("tags"); setActivePane("editor"); }; const handleOpenTemplates = () => { clearHiddenMobileSearch(); navigateWorkspaceTemplates(); setRightView("templates"); setMobileBottomNavActive("templates"); setActivePane("editor"); }; const handleOpenSettings = () => { clearHiddenMobileSearch(); navigateWorkspaceSettings(); setRightView("settings"); setMobileBottomNavActive("settings"); setActivePane("editor"); }; const handleCloseAssets = () => { navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); }; const handleCloseTemplates = () => { setTemplatesOpen(false); navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); }; const handleCloseSettings = () => { navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); if (!isDesktopViewport()) { setActivePane("memos"); } }; const updateDesktopFocusMode = useCallback((enabled: boolean) => { setDesktopFocusMode(enabled); writeDesktopFocusModePreference(enabled); }, []); const toggleDesktopFocusMode = useCallback(() => { updateDesktopFocusMode(!desktopFocusModeActive); }, [desktopFocusModeActive, updateDesktopFocusMode]); useEffect(() => { const bridge = window.edgeeverDesktop; if (!bridge?.isAvailable) return; const removeCommandListener = bridge.onCommand((command) => { if (command === "new-memo") handleCreateMemo(); if (command === "new-notebook") handleCreateNotebook(); if (command === "focus-search") handleMobileSearch(); if (command === "toggle-focus-mode") toggleDesktopFocusMode(); if (command === "sync-now") { void runQueuedSync(); } if (command === "backup-now") { void bridge.sidecarRequest("storage.backup", {}); } if (command.startsWith("open-memo:")) { const memoId = command.slice("open-memo:".length); if (memoId) { navigateWorkspaceHome(); setMemoView("notebook"); setSelectedMemoId(memoId); setActivePane("editor"); } } }); const removeMarkdownListener = bridge.onImportMarkdown((payload) => { const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) ? selectedNotebookId : defaultMemoNotebookId; if (!notebookId) return; const title = payload.name.replace(/\.(?:md|markdown)$/i, "").trim(); createMemoMutation.mutate({ notebookId, title, contentMarkdown: payload.content, tags: [] }); }); return () => { removeCommandListener(); removeMarkdownListener(); }; }, [createMemoMutation, defaultMemoNotebookId, handleCreateMemo, handleCreateNotebook, handleMobileSearch, notebooks, selectedNotebookId, toggleDesktopFocusMode]); 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 (emptyTrashConfirmationOpen) { if (!emptyTrashMutation.isPending) { setEmptyTrashConfirmationOpen(false); } return true; } if (mobileNotebookPickerOpen) { setMobileNotebookPickerOpen(false); return true; } if (mobileListActionsOpen) { setMobileListActionsOpen(false); return true; } if (mobileMoveOpen) { setMobileMoveOpen(false); return true; } if (mobileMoreOpen) { setMobileMoreOpen(false); return true; } if (mobileSearchActive) { handleCancelMobileSearch(); return true; } if (templatesOpen) { handleCloseTemplates(); return true; } if (desktopFocusModeActive) { updateDesktopFocusMode(false); return true; } if (rightView === "settings") { handleCloseSettings(); return true; } if (rightView === "tags") { handleCloseAssets(); return true; } if (rightView === "assets") { handleCloseAssets(); return true; } if (memoSelectionModeActive) { clearMemoSelection(); return true; } if (visibleActivePane === "editor" || visibleActivePane === "notebooks") { clearPendingCreatedMemo(); setActivePane("memos"); return true; } return false; }, [ visibleActivePane, desktopFocusModeActive, appNoticeDialog, rightView, clearMemoSelection, createNotebookMutation.isPending, deleteMemoMutation.isPending, deleteMemosMutation.isPending, deleteNotebookMutation.isPending, emptyTrashConfirmationOpen, emptyTrashMutation.isPending, handleCloseAssets, handleCloseSettings, handleCloseTemplates, handleCancelMobileSearch, memoDeleteConfirmation, memoSelectionModeActive, mobileListActionsOpen, mobileNotebookPickerOpen, mobileMoveOpen, mobileMoreOpen, mobileSearchActive, notebookDeleteConfirmation, notebookNameDialog, templatesOpen, updateNotebookMutation.isPending, updateDesktopFocusMode, ]); 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) { return; } const action = getShortcutActionForEvent(event, shortcutSettings); if (!action) { return; } const targetElement = event.target instanceof Element ? event.target : null; const isEditorTextTarget = Boolean(targetElement?.closest(".ProseMirror")); if ((action === "focusSearch" || action === "focusReplace") && isTextEntryTarget(event.target) && !isEditorTextTarget) { return; } const transientLayerOpen = Boolean( appNoticeDialog || rightView !== "editor" || memoDeleteConfirmation || emptyTrashConfirmationOpen || mobileNotebookPickerOpen || notebookDeleteConfirmation || notebookNameDialog || templatesOpen ); if (transientLayerOpen) { return; } if (action === "focusSearch") { event.preventDefault(); if (event.shiftKey || !selectedMemoId || !isDesktopViewport()) { if (event.shiftKey) { setSearch(""); } clearMemoSelection(); handleMobileSearch(); return; } setNoteSearchFocusToken((value) => value + 1); return; } if (action === "focusReplace") { if (!selectedMemoId || memoView === "trash" || !isDesktopViewport()) { return; } event.preventDefault(); setNoteReplaceFocusToken((value) => value + 1); return; } event.preventDefault(); if (action === "createNotebook") { if (!createNotebookMutation.isPending) { handleCreateNotebook(null); } return; } if (action === "createMemo" && canCreateMemo && !createMemoMutation.isPending) { handleCreateMemo(); } }; window.addEventListener("keydown", handleWorkspaceShortcut); return () => window.removeEventListener("keydown", handleWorkspaceShortcut); }, [ rightView, appNoticeDialog, canCreateMemo, clearMemoSelection, createNotebookMutation.isPending, createMemoMutation.isPending, handleCreateNotebook, handleCreateMemo, handleMobileSearch, shortcutSettings, emptyTrashConfirmationOpen, memoDeleteConfirmation, memoView, mobileNotebookPickerOpen, notebookDeleteConfirmation, notebookNameDialog, selectedMemoId, 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); }; const shouldRenderRightPane = isDesktop || visibleActivePane === "editor"; const rightPaneLoadingLabel = rightView === "settings" ? t("workspace.loading.settings") : rightView === "assets" ? t("workspace.loading.assets") : rightView === "tags" ? t("workspace.loading.tags") : rightView === "templates" ? t("templates.title") : rightView === "evernote-migration" ? t("workspace.loading.migration") : t("workspace.loading.editor"); const pullToRefreshVisible = pullToRefreshDistance > 0 || isPullRefreshing; const pullToRefreshReady = pullToRefreshDistance >= PULL_TO_REFRESH_TRIGGER_PX; const pullToRefreshLabel = isPullRefreshing ? isStandaloneRuntime ? t("workspace.pullToRefresh.refreshingNotes") : t("workspace.pullToRefresh.refreshingPage") : pullToRefreshReady ? isStandaloneRuntime ? t("workspace.pullToRefresh.releaseNotes") : t("workspace.pullToRefresh.releasePage") : isStandaloneRuntime ? t("workspace.pullToRefresh.pullNotes") : t("workspace.pullToRefresh.pullPage"); return (
{pullToRefreshVisible && ( )}
void memosQuery.refetch()} onOpenNotebookPicker={() => setMobileNotebookPickerOpen(true)} onSearch={setSearch} onCancelMobileSearch={handleCancelMobileSearch} onCreateMemo={handleCreateMemo} onClearSelection={clearMemoSelection} onEnterSelectionMode={enterMemoSelectionMode} onReplaceSelection={replaceMemoSelection} onOpenAssets={handleOpenAssets} onOpenTags={handleOpenTags} onOpenSettings={handleOpenSettings} onSyncMemos={() => void syncMemosManually()} isSyncingMemos={isManualMemoSyncing || isSyncingQueuedChanges || isPullRefreshing || memosQuery.isRefetching} canSyncMemos={isOnline} onOpenTrash={() => { navigateWorkspaceTrash(); setMemoView("trash"); setSelectedNotebookId(null); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(null); setActivePane("memos"); }} onBackFromTrash={handleSelectAllMemos} onOpenMemo={(memoId) => { navigateWorkspaceHome(); setRightView("editor"); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(memoId); setActivePane("editor"); }} onToggleMemo={(memoId, rangeMemoIds) => { setMemoSelectionMode(true); setSelectedMemoIds((current) => { if (!rangeMemoIds?.length) { return toggleMobileMemoSelection(current, memoId); } const next = new Set(current); for (const rangeMemoId of rangeMemoIds) { next.add(rangeMemoId); } return next; }); }} onMerge={handleMerge} onDeleteMemo={handleDeleteMemoFromList} onEmptyTrash={handleEmptyTrash} onRestoreMemo={handleRestoreMemoFromList} onMoveMemo={handleMoveMemoFromList} onTogglePinMemo={handleToggleMemoPinned} onPinSelectedMemos={handlePinSelectedMemos} onDeleteSelectedMemos={handleDeleteSelectedMemos} onMoveSelectedMemos={handleMoveSelectedMemos} mobileListActionsOpen={mobileListActionsOpen} setMobileListActionsOpen={setMobileListActionsOpen} mobileMoveOpen={mobileMoveOpen} setMobileMoveOpen={setMobileMoveOpen} mobileMoreOpen={mobileMoreOpen} setMobileMoreOpen={setMobileMoreOpen} desktopFilterOpen={desktopFilterOpen} setDesktopFilterOpen={setDesktopFilterOpen} desktopSortOpen={desktopSortOpen} setDesktopSortOpen={setDesktopSortOpen} desktopActionsOpen={desktopActionsOpen} setDesktopActionsOpen={setDesktopActionsOpen} />
{shouldRenderRightPane && ( }> {rightView === "settings" ? ( ) : rightView === "assets" ? ( ) : rightView === "tags" ? ( ) : rightView === "templates" ? ( { await createTemplateMutation.mutateAsync(payload); }} savedTemplates={savedTemplates} onUseSavedTemplate={handleUseSavedTemplate} onDeleteSavedTemplate={(template) => deleteTemplateMutation.mutate(template.id)} onUpdateSavedTemplate={async (templateId, payload) => { await updateTemplateMutation.mutateAsync({ templateId, payload }); }} /> ) : rightView === "evernote-migration" ? ( setRightView("settings")} /> ) : ( { clearPendingCreatedMemo(); setMemoView("notebook"); setSelectedMemoId(memoId); setActivePane("editor"); }} imageCompressionEnabled={imageCompressionEnabled} autoSaveIntervalMs={autoSaveIntervalMs} selectionActionBar={memoSelectionActionBar} hasNextMemo={Boolean(nextMemoId)} hasPreviousMemo={Boolean(previousMemoId)} onBackToList={() => { applyMobileEditorReturnPreview(selectedMemo?.id ?? selectedMemoId); clearPendingCreatedMemo(); setActivePane("memos"); }} onOpenNextMemo={() => { if (nextMemoId) { clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(nextMemoId); } }} onOpenPreviousMemo={() => { if (previousMemoId) { clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(previousMemoId); } }} onSaved={async (memo) => { await putLocalMemo(localDataScope, memo); cacheMemoDetail(queryClient, memo, memoView); updateMemoSummaryInLists(queryClient, memoToSummary(memo)); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), ...(search.trim() ? [ queryClient.invalidateQueries({ queryKey: [ "memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds, ], exact: true, refetchType: "active", }), ] : []), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), ]); }} 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); }} onMobileDefaultEditConsumed={handleMobileDefaultEditConsumed} onSaveAsTemplate={handleSaveAsTemplate} /> )} )}
{memoDeleteConfirmation && ( setMemoDeleteConfirmation(null)} onConfirm={handleConfirmMemoDeletion} /> )} {emptyTrashConfirmationOpen && ( setEmptyTrashConfirmationOpen(false)} onConfirm={handleConfirmEmptyTrash} /> )} {notebookNameDialog && ( setNotebookNameDialog(null)} onSubmit={handleSubmitNotebookName} /> )} {notebookDeleteConfirmation && ( setNotebookDeleteConfirmation(null)} onConfirm={() => { deleteNotebookMutation.mutate(notebookDeleteConfirmation.id, { onSuccess: () => setNotebookDeleteConfirmation(null), }); }} /> )} {appNoticeDialog && ( setAppNoticeDialog(null)} onConfirm={() => setAppNoticeDialog(null)} /> )} {demoResetConfirmationOpen && ( setDemoResetConfirmationOpen(false)} onConfirm={() => resetDemoMutation.mutate()} /> )} {visibleActivePane !== "editor" && !memoSelectionModeActive && ( )} {mobileNotebookPickerOpen && ( setMobileNotebookPickerOpen(false)} onSelectAll={handleSelectAllMemos} onSelect={handleSelectNotebook} /> )}
); }; export default WorkspaceApp;