import { lazy, Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore, type CSSProperties, type ReactNode, type MouseEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { PluginPanelOpenOptions } from "@edgeever/plugin-api"; import { RefreshCw, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as m from "motion/react-m"; import { Button } from "@/components/ui/button"; import { MemoListPane, MemoSelectionActionBar } from "./MemoListPane"; import { MobileBottomNav, MobileNotebookPicker } from "./WorkspaceMobileChrome"; import { QuickMemoSwitcher } from "./QuickMemoSwitcher"; import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs"; import { PluginPanelDialog } from "./plugins/PluginPanelDialog"; import { shouldDiscardPluginNoteSearchRequest } from "./editor/note-search"; import { api, getOrCreateClientDeviceId } from "@/lib/api"; import { MarkdownExportMemoryLimitError, type MarkdownExportProgress } from "@/lib/markdown-export"; import { exportSelectedMemosAsMarkdownZip } from "@/lib/selected-markdown-export"; import { createPluginScheduleAdapter } from "@/lib/plugins/plugin-schedule-adapter"; import { createPluginCatalogAdapter } from "@/lib/plugins/plugin-catalog-sync"; import { acknowledgePluginTrustWarning, hasAcknowledgedPluginTrustWarning, PLUGIN_TRUST_WARNING_COPY, } from "@/lib/plugins/plugin-trust"; import { clearMobileEditorReturnPreview, consumeStandaloneMobileEditorReturn, getStandaloneMobileEditorReturningMemoId, openStandaloneMobileEditor, readMobileEditorReturnPreview, requiresRemoteMemoForStandaloneMobileEditor, type MobileEditorReturnPreview, } from "@/lib/mobile-editor"; import { cn } from "@/lib/utils"; import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status"; import { createDefaultDiagramDocument, createDefaultInfographicDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import type { Pane, MemoView, MemoDeleteConfirmation, NotebookNameDialogState, AppNoticeDialogState, MobileBottomNavItem, NotebookDropPosition, NotebookMoveOption, MemoFilterMode, MemoSortMode, MemoDocumentAction, MemoDocumentActionRequest, } from "@/lib/app-helpers"; import { MIN_MEMO_LIST_WIDTH_PX, MAX_MEMO_LIST_WIDTH_PX, DEFAULT_MEMO_LIST_WIDTH_PX, EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY, isTextEntryTarget, getSearchShortcutScope, getShortcutActionForEvent, getNotebookDropSortOrder, getNotebookMoveOptions, getMemoIdsNeedingMove, resolveSelectionMoveTargetNotebookId, } from "@/lib/app-helpers"; import { useBrowserBackLayer } from "@/lib/app-hooks"; import { updateMemoSummaryInLists, type MemoListQueryData } from "@/lib/memo-list-cache"; import { cacheMemoDetail, evictIdleMemoDetails, clearTrashMemoLists, collectMemoSummariesFromCache, decrementNotebookMemoCounts, getAdjacentMemoIdAfterRemoval, memoDetailQueryKey, memoToSummary, removeMemoSummariesFromLists, type ListNotebooksQueryData, } from "@/lib/workspace-memo-cache"; import { getVerticalScrollContainer, isDesktopViewport, isStandaloneApp, PULL_TO_REFRESH_MAX_PX, PULL_TO_REFRESH_TRIGGER_PX, runWorkspaceViewTransition, } from "@/lib/workspace-shell"; import { shouldAcceptRemoteMemoDetail } from "@/lib/memo-detail-freshness"; import { clearLocalScope, createLocalDataScope, putLocalMemo, putLocalNotebook, } from "@/lib/local-mirror"; import { isNotebookNotEmptyError } from "@/lib/notebook-delete"; import { getPersistentDataScopeOrigin } from "@/lib/app-page-path"; import { createRepository } from "@/lib/repository"; import { notifyRepositoryMutation } from "@/lib/repository-events"; import { refreshWorkspaceData, shouldNavigateHomeWhenOpeningMemo, type WorkspaceRefreshMode, } from "@/lib/workspace-refresh"; import { useWorkspaceSyncLifecycle } from "@/hooks/useWorkspaceSyncLifecycle"; import { paneEnterMotion } from "@/lib/motion"; import { WorkspaceMotionProvider } from "./WorkspaceMotionProvider"; import { useWorkspaceRoute } from "@/hooks/useWorkspaceRoute"; import { useNoteProse } from "@/hooks/useNoteProse"; import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences"; import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection"; import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync"; import { EdgeEverPluginHost, type RegisteredPluginPanel } from "@/lib/plugins/plugin-host"; import { loadResolvedPluginMarketplace } from "@/lib/plugins/plugin-marketplace"; import { updateOfficialMarketplacePlugins } from "@/lib/plugins/plugin-updates"; import { createPublicNetworkAdapter } from "@/lib/plugins/public-network-adapter"; import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery"; import { readDesktopWorkspaceRestoreState, shouldRestoreDesktopWorkspace, writeDesktopWorkspaceRestoreState, } from "@/lib/desktop-workspace-restore"; import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary"; import { isMarkdownFile, readMarkdownFile } from "@/lib/markdown-file-import"; import { compressImageForUpload } from "@/lib/image-compression"; import { createScreenshotMemo, screenshotFileFromImportPayload, screenshotImportDedupeKey, screenshotImportGate } from "@/lib/screenshot-import"; import { createSharedFileMemo, sharedFileTitle } from "@/lib/shared-file-import"; import { createWeChatChatMemo } from "@/lib/wechat-chat-import"; import { copyMarkdownToWeChat } from "@/lib/wechat-copy"; import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { findMatchingMemoResource } from "@/lib/staged-resource-repair"; const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane }))); const DiagramEditorPane = lazy(() => import("./DiagramEditorPane")); const TableEditorPane = lazy(() => import("./TableEditorPane")); const InfographicEditorPane = lazy(() => import("./InfographicEditorPane")); const AssetsPane = lazy(() => import("./AssetsPane").then((module) => ({ default: module.AssetsPane }))); const SettingsPane = lazy(() => import("./SettingsPane").then((module) => ({ default: module.SettingsPane }))); const PluginMarketplacePane = lazy(() => import("./PluginMarketplacePane").then((module) => ({ default: module.PluginMarketplacePane }))); 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 AiPromptsPane = lazy(() => import("./AiPromptsPane").then((module) => ({ default: module.AiPromptsPane }))); const ExecutionCenterPane = lazy(() => import("./execution/ExecutionCenterPane").then((module) => ({ default: module.ExecutionCenterPane })) ); const PaneLoadingFallback = ({ label = "Loading" }: { label?: string }) => (
{label}
); 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; }; 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 { route, navigateHome: navigateWorkspaceHome, navigateTrash: navigateWorkspaceTrash, navigateSettings: navigateWorkspaceSettings, navigatePlugins: navigateWorkspacePlugins, navigateTemplates: navigateWorkspaceTemplates, navigateAiPrompts: navigateWorkspaceAiPrompts, navigateExecutionCenter: navigateWorkspaceExecutionCenter, } = useWorkspaceRoute(); const localDataScope = useMemo( () => createLocalDataScope(getPersistentDataScopeOrigin(window.location.origin), user?.id), [user?.id] ); const repository = useMemo(() => createRepository(localDataScope), [localDataScope]); const isInitialSettingsRoute = route.isSettings; const isInitialPluginsRoute = route.isPlugins; const isInitialTemplatesRoute = route.isTemplates; const isInitialAiPromptsRoute = route.isAiPrompts; const isInitialExecutionCenterRoute = route.isExecutionCenter; const isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId); const isTrashRoute = route.isTrash; const [rendererRecoveryMode, setRendererRecoveryMode] = useState(() => Boolean(window.edgeeverDesktop?.recoveredAfterAbnormalExit) || isRendererRecoveryRequired() ); const [desktopWorkspaceRestore] = useState(() => ( shouldRestoreDesktopWorkspace() && !isRendererRecoveryRequired() ? readDesktopWorkspaceRestoreState() : null )); const [activePane, setActivePane] = useState(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute || isInitialExecutionCenterRoute) && !isInitialMobileEditorReturn ? "editor" : "memos")); const [memoView, setMemoView] = useState(() => ( isTrashRoute ? "trash" : desktopWorkspaceRestore?.memoView ?? "notebook" )); const { beginMemoSelection, clearMemoSelection, memoSelectionMode, replaceMemoSelection, selectedMemoId, selectedMemoIdRef, selectedMemoIds, selectedNotebookId, selectionMoveTargetNotebookId, setMemoSelectionMode, setSelectedMemoId, setSelectedMemoIds, setSelectedNotebookId, setSelectionMoveTargetNotebookId, } = useWorkspaceSelection(desktopWorkspaceRestore); const [selectedTag, setSelectedTag] = useState(null); const autoSelectedDemoNotebookRef = useRef(false); const [createdMemoEditId, setCreatedMemoEditId] = useState(null); const pendingCreatedMemoIdRef = useRef(null); const pendingQuickSwitcherMemoIdRef = useRef(desktopWorkspaceRestore?.selectedMemoId ?? null); // also companion/plugin opens not yet in the list useEffect(() => { if (!window.edgeeverDesktop?.isAvailable) return; writeDesktopWorkspaceRestoreState({ selectedMemoId, selectedNotebookId, memoView, }); }, [memoView, selectedMemoId, selectedNotebookId]); const creatingMemoSelectionRef = useRef(false); const createMemoInFlightRef = useRef(false); const memoDocumentActionIdRef = useRef(0); const [memoDocumentActionRequest, setMemoDocumentActionRequest] = useState(null); 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 [wechatImportFailureId, setWeChatImportFailureId] = useState(null); const [wechatImportsInProgress, setWeChatImportsInProgress] = useState(0); const [sharedFileImportsInProgress, setSharedFileImportsInProgress] = useState(0); const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState([]); const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false); const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState({ completed: 0, total: 0, }); const [demoResetConfirmationOpen, setDemoResetConfirmationOpen] = useState(false); const scheduledTaskDeviceId = useMemo( () => window.edgeeverDesktop?.isAvailable ? getOrCreateClientDeviceId() : null, [], ); const pluginScheduleAdapter = useMemo(() => scheduledTaskDeviceId ? createPluginScheduleAdapter(scheduledTaskDeviceId, () => queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] })) : undefined, [queryClient, scheduledTaskDeviceId]); const pluginPublicNetworkAdapter = useMemo(() => createPublicNetworkAdapter(api.pluginNetwork, { desktop: window.edgeeverDesktop?.isAvailable ? window.edgeeverDesktop : undefined, }), []); const pluginCatalogAdapter = useMemo(() => demoMode ? undefined : createPluginCatalogAdapter(), [demoMode]); const pluginHost = useMemo(() => new EdgeEverPluginHost({ repository, scope: localDataScope, aiAdapter: api.pluginAi, publicNetworkAdapter: pluginPublicNetworkAdapter, onNotice: (message) => setAppNoticeDialog({ title: t("plugins.noticeTitle"), description: message }), scheduleAdapter: pluginScheduleAdapter, catalogAdapter: pluginCatalogAdapter, onWorkspaceChanged: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["tags"] }), queryClient.invalidateQueries({ queryKey: ["templates"] }), queryClient.invalidateQueries({ queryKey: ["resources"] }), ]); }, }), [localDataScope, pluginCatalogAdapter, pluginPublicNetworkAdapter, pluginScheduleAdapter, queryClient, repository, t]); const [pluginHostReady, setPluginHostReady] = useState(false); const pluginHostSnapshot = useSyncExternalStore(pluginHost.subscribe, pluginHost.getSnapshot, pluginHost.getSnapshot); useEffect(() => { let active = true; setPluginHostReady(false); void pluginHost.activateEnabled().then((pendingTrustPluginIds) => { if (!active) return; setPluginHostReady(true); if (pendingTrustPluginIds.length > 0) setPendingCatalogTrustPluginIds(pendingTrustPluginIds); }).catch(() => { if (active) setPluginHostReady(false); }); return () => { active = false; void pluginHost.dispose(); }; }, [pluginHost]); useEffect(() => { if (!pluginHostReady) return; let active = true; let running = false; const updateOfficialPlugins = async () => { if (!active || running) return; running = true; try { const pendingTrustPluginIds = await pluginHost.syncFromCatalog(); if (active && pendingTrustPluginIds.length > 0 && !hasAcknowledgedPluginTrustWarning()) { setPendingCatalogTrustPluginIds(pendingTrustPluginIds); } const marketplace = await loadResolvedPluginMarketplace(); await updateOfficialMarketplacePlugins(pluginHost, marketplace.entries); const firstResolutionError = Object.entries(marketplace.resolutionErrors)[0]; if (firstResolutionError) { console.error(`Official plugin ${firstResolutionError[0]} update resolution failed.`, firstResolutionError[1]); } } catch (error) { console.error("Official plugin update check failed.", error); } finally { running = false; } }; const handleFocus = () => { void updateOfficialPlugins(); }; const intervalId = window.setInterval(() => void updateOfficialPlugins(), 30 * 60_000); window.addEventListener("focus", handleFocus); void updateOfficialPlugins(); return () => { active = false; window.clearInterval(intervalId); window.removeEventListener("focus", handleFocus); }; }, [pluginHost, pluginHostReady]); const scheduledTasksQuery = useQuery({ queryKey: ["scheduled-tasks", scheduledTaskDeviceId], queryFn: () => api.listScheduledTasks(scheduledTaskDeviceId ?? undefined), enabled: Boolean(scheduledTaskDeviceId && pluginHostReady), refetchInterval: 60_000, }); const runningScheduledTaskIdsRef = useRef(new Set()); const runnableScheduledTasks = useMemo(() => { const commandKeys = new Set(pluginHostSnapshot.commands.map((command) => `${command.pluginId}\0${command.id}`)); return (scheduledTasksQuery.data?.tasks ?? []).filter((task) => commandKeys.has(`${task.taskPayload.pluginId}\0${task.taskPayload.commandId}`)); }, [pluginHostSnapshot.commands, scheduledTasksQuery.data?.tasks]); useEffect(() => { if (!scheduledTaskDeviceId || !scheduledTasksQuery.data?.tasks) return; const interrupted = scheduledTasksQuery.data.tasks.filter((task) => task.lastRun?.status === "running" && !runningScheduledTaskIdsRef.current.has(task.id)); if (interrupted.length === 0) return; void Promise.all(interrupted.map((task) => api.finishScheduledTaskRun(task.id, task.lastRun!.id, { executorDeviceId: scheduledTaskDeviceId, status: "failed", errorMessage: "The desktop app stopped before the scheduled task completed.", }).catch(() => null))).then(() => { void Promise.all([ queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }), queryClient.invalidateQueries({ queryKey: ["scheduled-task-run-history"] }), ]); }); }, [queryClient, scheduledTaskDeviceId, scheduledTasksQuery.data?.tasks]); useEffect(() => { const bridge = window.edgeeverDesktop; if (!bridge?.isAvailable || !scheduledTaskDeviceId || !pluginHostReady) return; void bridge.syncScheduledTasks(runnableScheduledTasks).catch(() => {}); }, [pluginHostReady, runnableScheduledTasks, scheduledTaskDeviceId]); useEffect(() => () => { void window.edgeeverDesktop?.syncScheduledTasks([]).catch(() => {}); }, []); useEffect(() => { const bridge = window.edgeeverDesktop; if (!bridge?.isAvailable || !scheduledTaskDeviceId || !pluginHostReady) return; return bridge.onScheduledTask(async ({ task, scheduledFor }) => { if (task.executorDeviceId !== scheduledTaskDeviceId || runningScheduledTaskIdsRef.current.has(task.id)) return; runningScheduledTaskIdsRef.current.add(task.id); let runId: string | null = null; try { const claimed = await api.claimScheduledTaskRun(task.id, { scheduledFor, executorDeviceId: scheduledTaskDeviceId, }); runId = claimed.run.id; if (task.taskType !== "plugin-command") throw new Error("Unsupported scheduled task type."); await pluginHost.runCommand(task.taskPayload.pluginId, task.taskPayload.commandId); await api.finishScheduledTaskRun(task.id, runId, { executorDeviceId: scheduledTaskDeviceId, status: "succeeded", }); } catch (error) { if (runId) { await api.finishScheduledTaskRun(task.id, runId, { executorDeviceId: scheduledTaskDeviceId, status: "failed", errorMessage: error instanceof Error ? error.message : String(error), }).catch(() => {}); } } finally { runningScheduledTaskIdsRef.current.delete(task.id); void Promise.all([ queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] }), queryClient.invalidateQueries({ queryKey: ["scheduled-task-run-history"] }), ]); } }); }, [pluginHost, pluginHostReady, queryClient, scheduledTaskDeviceId]); const [requestedPluginPanel, setRequestedPluginPanel] = useState<{ panel: RegisteredPluginPanel; options?: PluginPanelOpenOptions; } | null>(null); const pluginNavigationRequestIdRef = useRef(0); const [pluginNavigationRequest, setPluginNavigationRequest] = useState<{ id: number; noteId: string; search: string } | null>(null); useEffect(() => pluginHost.setPanelAdapter({ openPanel(pluginId, panelId, options) { const panel = pluginHost.getSnapshot().panels.find((candidate) => candidate.pluginId === pluginId && candidate.id === panelId); if (!panel) throw new Error("Plugin panel is not registered."); setRequestedPluginPanel({ panel, options }); }, }), [pluginHost]); useEffect(() => { const unsubscribe = pluginHost.subscribe(() => { setRequestedPluginPanel((current) => current && pluginHost.getSnapshot().panels.some( (panel) => panel.pluginId === current.panel.pluginId && panel.id === current.panel.id, ) ? current : null); }); return () => { unsubscribe(); }; }, [pluginHost]); const resetDemoMutation = useMutation({ mutationFn: async () => { await api.resetDemo(); await clearLocalScope(localDataScope); await repository.sync(); }, onMutate: () => { const previousSelectedMemoId = selectedMemoIdRef.current; setSelectedMemoId(null); setCreatedMemoEditId(null); pendingCreatedMemoIdRef.current = null; pendingQuickSwitcherMemoIdRef.current = null; return { previousSelectedMemoId }; }, onSuccess: async () => { try { window.localStorage.removeItem(EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY); } catch { // Local storage can be unavailable in private or restricted browser contexts. } setDemoResetConfirmationOpen(false); queryClient.removeQueries({ queryKey: ["memo"] }); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["templates"] }), queryClient.invalidateQueries({ queryKey: ["resources"] }), queryClient.invalidateQueries({ queryKey: ["tags"] }), ]); setAppNoticeDialog({ title: t("demo.resetSuccess"), description: t("demo.resetSuccess"), }); }, onError: (_error, _variables, context) => { setDemoResetConfirmationOpen(false); if (context?.previousSelectedMemoId) { setSelectedMemoId(context.previousSelectedMemoId); } setAppNoticeDialog({ title: t("demo.resetFailed"), description: t("demo.resetFailed"), }); }, }); const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); const { desktopFocusMode, editorContentWidth, imageCompressionEnabled, memoListWidth, notebookSidebarCollapsed, resetMemoListWidth, setDesktopFocusMode, setEditorContentWidth, setImageCompressionEnabled, setMemoListWidth, setNotebookSidebarCollapsed, setShortcutSettings, shortcutSettings, } = useWorkspacePreferences(); const { noteProse, updateNoteProse } = useNoteProse(); const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "execution-center" | "evernote-migration">(() => isInitialSettingsRoute ? "settings" : isInitialPluginsRoute ? "plugins" : isInitialTemplatesRoute ? "templates" : isInitialAiPromptsRoute ? "ai-prompts" : isInitialExecutionCenterRoute ? "execution-center" : "editor" ); const [templatesOpen, setTemplatesOpen] = useState(false); const [mobileNotebookPickerOpen, setMobileNotebookPickerOpen] = useState(false); const [mobileBottomNavActive, setMobileBottomNavActive] = useState(() => isInitialSettingsRoute && !isInitialMobileEditorReturn ? "settings" : isInitialTemplatesRoute || isInitialAiPromptsRoute ? "templates" : "home" ); const [mobileSearchFocusToken, setMobileSearchFocusToken] = useState(0); const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false); const [quickSwitcherQuery, setQuickSwitcherQuery] = useState(""); const [noteSearchFocusToken, setNoteSearchFocusToken] = useState(0); const [noteReplaceFocusToken, setNoteReplaceFocusToken] = useState(0); const [noteAiAssistantOpenToken, setNoteAiAssistantOpenToken] = useState(0); const [noteSaveAndSyncToken, setNoteSaveAndSyncToken] = useState(0); const [noteReadingProtectionToggleToken, setNoteReadingProtectionToggleToken] = useState(0); const [noteEditorModeToggleToken, setNoteEditorModeToggleToken] = useState(0); const [noteOutlineToggleToken, setNoteOutlineToggleToken] = useState(0); const [search, setSearch] = useState(""); const [memoFilterMode, setMemoFilterMode] = useState("all"); const [memoSortMode, setMemoSortMode] = useState("updated-desc"); const [mobileEditorReturnPreview, setMobileEditorReturnPreview] = useState(() => readMobileEditorReturnPreview(route.mobileEditorReturnMemoId) ); const [isOnline, setIsOnline] = useState(isBrowserOnline); const [isDesktop, setIsDesktop] = useState(isDesktopViewport); 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 { discardConflictsNow, isSyncingQueuedChanges, runQueuedSync, setSyncSummary, syncSummary, } = useWorkspaceQueuedSync({ isOnline, localDataScope, pendingCreatedMemoIdRef, queryClient, selectedMemoIdRef, setCreatedMemoEditId, setOnline: setIsOnline, setSelectedMemoId, }); const invalidateWorkspaceQueries = useCallback(async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"] }), queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["tags"] }), queryClient.invalidateQueries({ queryKey: ["resources"], refetchType: "all" }), ]); }, [queryClient]); const refreshWorkspaceFromServer = useCallback(async (mode: WorkspaceRefreshMode) => { if (isBrowserOffline()) { setIsOnline(false); return { changed: 0, skipped: true }; } const isDesktopRuntime = Boolean(window.edgeeverDesktop?.isAvailable); return refreshWorkspaceData({ mode, hasPendingLocalChanges: syncSummary.total > 0, // Desktop sync already pushes the outbox and pulls remote changes in // one operation. The web repository keeps those phases separate. pushLocalChanges: isDesktopRuntime ? async () => undefined : runQueuedSync, pullRemoteChanges: isDesktopRuntime ? async () => { await runQueuedSync(); return { changed: 0 }; } : repository.sync, invalidateWorkspaceQueries, }); }, [invalidateWorkspaceQueries, repository, runQueuedSync, syncSummary.total]); const refreshLatestMemos = useCallback(async () => { if (isBrowserOffline()) { setIsOnline(false); setPullToRefreshDistance(0); return; } setIsPullRefreshing(true); try { await refreshWorkspaceFromServer("manual"); } finally { setIsPullRefreshing(false); setPullToRefreshDistance(0); } }, [refreshWorkspaceFromServer]); const syncMemosManually = useCallback(async () => { if (isBrowserOffline()) { setIsOnline(false); return; } setIsManualMemoSyncing(true); try { await refreshWorkspaceFromServer("manual"); } finally { setIsManualMemoSyncing(false); } }, [refreshWorkspaceFromServer]); const notebooksQuery = useQuery({ queryKey: ["notebooks"], queryFn: () => repository.listNotebooks(), }); const templatesQuery = useQuery({ queryKey: ["templates"], queryFn: () => repository.listTemplates(), enabled: rightView === "templates", }); 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 && selectedTag === null) { autoSelectedDemoNotebookRef.current = true; setSelectedNotebookId(preferredNotebookId); } }, [i18n.resolvedLanguage, notebooks, selectedNotebookId, selectedTag]); const mobileEditorReturnMemoId = route.mobileEditorReturnMemoId; 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 || pendingCatalogTrustPluginIds.length > 0 || notebookDeleteConfirmation || notebookNameDialog || memoDeleteConfirmation || emptyTrashConfirmationOpen || mobileNotebookPickerOpen || mobileListActionsOpen || mobileMoveOpen || mobileMoreOpen || mobileSearchActive || templatesOpen || memoSelectionModeActive || // A routed workspace uses browser history, not a synthetic modal back layer. (rightView !== "editor" || visibleActivePane === "editor" || visibleActivePane === "notebooks") ); const mobilePullToRefreshActive = Boolean( !isDesktop && visibleActivePane === "memos" && !appNoticeDialog && pendingCatalogTrustPluginIds.length === 0 && !notebookDeleteConfirmation && !notebookNameDialog && !memoDeleteConfirmation && !emptyTrashConfirmationOpen && !mobileNotebookPickerOpen && !mobileListActionsOpen && !mobileMoveOpen && !mobileMoreOpen && !templatesOpen ); const clearPendingCreatedMemo = useCallback(() => { pendingCreatedMemoIdRef.current = null; creatingMemoSelectionRef.current = false; }, []); 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 = route.mobileEditorReturnMemoId; if (!returnedMemoId) { return; } applyMobileEditorReturnPreview(returnedMemoId); skipNextHomeRouteSyncRef.current = false; setRightView("editor"); setMobileBottomNavActive("home"); setActivePane("memos"); setSelectedMemoId(null); setCreatedMemoEditId(null); clearMemoSelection(); navigateWorkspaceHome({ replace: true }); }, [applyMobileEditorReturnPreview, clearMemoSelection, navigateWorkspaceHome, route.mobileEditorReturnMemoId]); const enterMemoSelectionMode = useCallback(() => { beginMemoSelection(); setActivePane("memos"); }, [beginMemoSelection]); 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(() => { 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 (route.isSettings) { skipNextHomeRouteSyncRef.current = false; setRightView("settings"); setMobileBottomNavActive("settings"); setActivePane("editor"); return; } if (route.isPlugins) { skipNextHomeRouteSyncRef.current = false; setRightView("plugins"); setMobileBottomNavActive("home"); setActivePane("editor"); return; } if (route.isTemplates) { skipNextHomeRouteSyncRef.current = false; setRightView("templates"); setMobileBottomNavActive("templates"); setActivePane("editor"); return; } if (route.isAiPrompts) { skipNextHomeRouteSyncRef.current = false; setRightView("ai-prompts"); setMobileBottomNavActive("templates"); setActivePane("editor"); return; } if (route.isExecutionCenter) { skipNextHomeRouteSyncRef.current = false; setRightView("execution-center"); setMobileBottomNavActive("home"); setActivePane("editor"); return; } if (skipNextHomeRouteSyncRef.current) { skipNextHomeRouteSyncRef.current = false; return; } setMemoView(isTrashRoute ? "trash" : "notebook"); setRightView("editor"); setMobileBottomNavActive("home"); }, [isTrashRoute, route.isSettings, route.isPlugins, route.isTemplates, route.isAiPrompts, route.isExecutionCenter]); 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); void refreshLatestMemos(); } }; 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); }; }, [mobilePullToRefreshActive, refreshLatestMemos]); useWorkspaceSyncLifecycle({ failedSyncCount: syncSummary.error, backgroundRefreshKey: localDataScope, refreshWorkspace: refreshWorkspaceFromServer, runQueuedSync, setOnline: setIsOnline, }); const selectedNotebookDescendantIds = useMemo( () => (selectedNotebookId && !selectedTag ? getNotebookDescendantIds(notebooks, selectedNotebookId) : []), [notebooks, selectedNotebookId, selectedTag] ); const memosQuery = useInfiniteQuery({ queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode, selectedNotebookDescendantIds, selectedTag], queryFn: ({ pageParam }) => repository.listMemos({ notebookId: memoView === "notebook" && !selectedTag ? selectedNotebookId : null, notebookIds: memoView === "notebook" && !selectedTag ? selectedNotebookDescendantIds : undefined, q: search, tag: memoView === "notebook" ? selectedTag ?? undefined : undefined, 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; const detailMemoId = rendererRecoveryMode ? null : selectedMemoId ?? memos[0]?.id ?? null; useEffect(() => { const selectedMemoInList = selectedMemoId ? memos.some((memo) => memo.id === selectedMemoId) : false; if (rendererRecoveryMode) { return; } if (creatingMemoSelectionRef.current || pendingCreatedMemoIdRef.current) { return; } if (pendingQuickSwitcherMemoIdRef.current) { if (!memos.some((memo) => memo.id === pendingQuickSwitcherMemoIdRef.current)) { return; } pendingQuickSwitcherMemoIdRef.current = null; } 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, rendererRecoveryMode, selectedMemoId]); useEffect(() => { if (!rendererRecoveryMode || !selectedMemoId) return; clearRendererRecoveryRequired(); setRendererRecoveryMode(false); }, [rendererRecoveryMode, selectedMemoId]); const memoQuery = useQuery({ queryKey: detailMemoId ? memoDetailQueryKey(detailMemoId, memoView) : ["memo", detailMemoId, memoView], queryFn: () => repository.getMemo(detailMemoId as string, memoView === "trash"), enabled: Boolean(detailMemoId), }); const prefetchMemoDetail = useCallback((memoId: string) => { void queryClient.prefetchQuery({ queryKey: memoDetailQueryKey(memoId, memoView), queryFn: () => repository.getMemo(memoId, memoView === "trash"), }); }, [memoView, queryClient, repository]); useEffect(() => { evictIdleMemoDetails(queryClient, detailMemoId); }, [detailMemoId, queryClient]); useEffect(() => { const handleMemoDetailRefreshed = (event: Event) => { const memo = (event as CustomEvent).detail; if (!memo?.id) return; // Repository already filters stale remotes; still refuse to regress the // in-memory query cache if a newer local snapshot is already present. let accepted = false; for (const view of ["notebook", "trash"] as const) { const key = memoDetailQueryKey(memo.id, view); const current = queryClient.getQueryData<{ memo: MemoDetail }>(key)?.memo; if (current && !shouldAcceptRemoteMemoDetail(current, memo)) { continue; } queryClient.setQueryData(key, { memo }); accepted = true; } if (accepted) { updateMemoSummaryInLists(queryClient, memoToSummary(memo)); } }; window.addEventListener("edgeever:memo-detail-refreshed", handleMemoDetailRefreshed); return () => window.removeEventListener("edgeever:memo-detail-refreshed", handleMemoDetailRefreshed); }, [queryClient]); const createNotebookMutation = useMutation({ mutationFn: repository.createNotebook, onSuccess: async (data) => { await putLocalNotebook(localDataScope, data.notebook); await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); setSelectedTag(null); 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"] }); }, onError: (error) => { setNotebookDeleteConfirmation(null); setAppNoticeDialog({ title: t("workspaceDialogs.deleteNotebookFailedTitle"), description: isNotebookNotEmptyError(error) ? t("workspaceDialogs.deleteNotebookNotEmpty") : t("workspaceDialogs.deleteNotebookFailed"), }); }, }); const revealCreatedMemo = (memo: MemoDetail) => { const targetNotebookId = memo.notebookId; const isStructuredNote = Boolean(parseDiagramDocument(memo.contentMarkdown) || parseTableDocument(memo.contentMarkdown) || parseInfographicDocument(memo.contentMarkdown)); setMemoView("notebook"); setSearch(""); setSelectedTag(null); // 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, memo, "notebook"); updateMemoSummaryInLists(queryClient, memoToSummary(memo)); void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), ]); navigateWorkspaceHome(); setRightView("editor"); pendingCreatedMemoIdRef.current = memo.id; setCreatedMemoEditId(isStructuredNote ? null : memo.id); setSelectedMemoId(memo.id); setActivePane("editor"); if (!isDesktopViewport() && !isStructuredNote) { openStandaloneMobileEditor(memo.id); } }; const createMemoMutation = useMutation({ mutationFn: async (input: Parameters[0]) => { const requiresRemoteMemo = requiresRemoteMemoForStandaloneMobileEditor({ mobileViewport: !isDesktopViewport(), desktopRuntime: Boolean(window.edgeeverDesktop?.isAvailable), }); const data = requiresRemoteMemo ? await api.createMemo(input) : await repository.createMemo(input); if (requiresRemoteMemo) notifyRepositoryMutation(localDataScope, { type: "note.created", note: data.memo }); await putLocalMemo(localDataScope, data.memo); return data; }, onSuccess: (data) => { revealCreatedMemo(data.memo); }, onError: () => { clearPendingCreatedMemo(); setCreatedMemoEditId(null); }, onSettled: () => { createMemoInFlightRef.current = false; }, }); 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: async (input: { templateId: string; notebookId: string }) => { const requiresRemoteMemo = requiresRemoteMemoForStandaloneMobileEditor({ mobileViewport: !isDesktopViewport(), desktopRuntime: Boolean(window.edgeeverDesktop?.isAvailable), }); const data = requiresRemoteMemo ? await api.useTemplate(input.templateId, input.notebookId) : await repository.useTemplate(input.templateId, input.notebookId); if (requiresRemoteMemo) notifyRepositoryMutation(localDataScope, { type: "note.created", note: data.memo }); await putLocalMemo(localDataScope, data.memo); return data; }, onSuccess: (data) => { const targetNotebookId = data.memo.notebookId; setTemplatesOpen(false); setMemoView("notebook"); setSearch(""); setSelectedTag(null); 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"); setSelectedTag(null); 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 = detailMemoId ? queryClient.getQueryData<{ memo: MemoDetail }>(memoDetailQueryKey(detailMemoId, memoView))?.memo ?? null : null; const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo; const selectedDiagram = parseDiagramDocument(selectedMemo?.contentMarkdown); const selectedTableNote = hasTableDocumentMarker(selectedMemo?.contentMarkdown); const selectedInfographicNote = Boolean(parseInfographicDocument(selectedMemo?.contentMarkdown)); const desktopNotebookSidebarCollapsed = Boolean(isDesktop && notebookSidebarCollapsed); 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(() => { const nextTargetId = resolveSelectionMoveTargetNotebookId( selectionMoveTargetNotebookId, selectionMoveNotebookOptions.map((option) => option.id), selectedNotebook?.id ); if (nextTargetId !== selectionMoveTargetNotebookId) { setSelectionMoveTargetNotebookId(nextTargetId); } }, [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 handleImportScreenshot = useCallback(async (payload: { captureId?: string; name: string; type: string; title?: string; bytes: Uint8Array }) => { const importKey = screenshotImportDedupeKey(payload); if (!screenshotImportGate.tryBegin(importKey)) return; const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) && memoView !== "trash" ? selectedNotebookId : defaultMemoNotebookId; if (!notebookId) { screenshotImportGate.fail(); return; } const file = screenshotFileFromImportPayload(payload); if (file.size === 0) { screenshotImportGate.fail(); setAppNoticeDialog({ title: t("memoList.importScreenshotFailedTitle"), description: t("memoList.importScreenshotEmpty"), }); return; } setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; let resumeDesktopSync: (() => void) | null = null; try { if (isDesktopResourceRuntime()) { resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport(); } const preparedFile = imageCompressionEnabled ? (await compressImageForUpload(file)).file : file; const memo = await createScreenshotMemo({ notebookId, title: payload.title?.trim() || "", file: preparedFile, createMemo: (input) => repository.createMemo(input), uploadResource: async (memoId, uploadFile) => { try { const { resource } = await repository.uploadMemoResource(memoId, uploadFile); return { url: toDesktopResourceUrl(resource.url), filename: resource.filename }; } catch (error) { if (!isDesktopResourceRuntime()) throw error; const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null }> })); const existing = findMatchingMemoResource( listed.resources.filter((resource) => resource.memoId === memoId), uploadFile.name, "image", ); if (existing) return { url: toDesktopResourceUrl(existing.url), filename: existing.filename || uploadFile.name }; const staged = await stageDesktopResource(memoId, uploadFile); if (!staged) throw error; return { url: `edgeever-staged://${staged.id}`, filename: uploadFile.name }; } }, updateMemo: (created, content) => repository.updateMemo(created, { expectedRevision: created.revision, expectedContentHash: created.contentHash, editSessionId: `screenshot:${created.id}`, title: created.title ?? "", contentJson: content.contentJson, contentMarkdown: content.contentMarkdown, tags: created.tags, }), deleteMemo: (memoId) => isDesktopResourceRuntime() ? import("@/lib/desktop-repository").then(({ cancelPendingDesktopImportMemo }) => cancelPendingDesktopImportMemo(memoId)) : repository.deleteMemo(memoId, true), }); await putLocalMemo(localDataScope, memo); revealCreatedMemo(memo); screenshotImportGate.finish(importKey); } catch { screenshotImportGate.fail(); creatingMemoSelectionRef.current = false; setAppNoticeDialog({ title: t("memoList.importScreenshotFailedTitle"), description: t("memoList.importScreenshotFailed"), }); } finally { resumeDesktopSync?.(); } }, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); const handleImportScreenshotRef = useRef(handleImportScreenshot); handleImportScreenshotRef.current = handleImportScreenshot; const pendingWeChatImportsRef = useRef; }>>([]); const handleImportSharedFile = useCallback(async (payload: { ok: boolean; reason?: string; importId?: string; filename?: string; mimeType?: string; }) => { const bridge = window.edgeeverDesktop; if (!payload.ok || !payload.importId || !payload.filename) { setAppNoticeDialog({ title: t("memoList.importSharedFileFailedTitle"), description: payload.reason === "too-large" ? t("memoList.importSharedFileTooLarge") : payload.reason === "unreadable" ? t("memoList.importSharedFileUnreadable") : t("memoList.importSharedFileFailed"), }); return; } const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) && memoView !== "trash" ? selectedNotebookId : defaultMemoNotebookId; if (!notebookId) { pendingWeChatImportsRef.current.push({ ...payload, kind: "file" }); return; } const importId = payload.importId; const filename = payload.filename; setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; setSharedFileImportsInProgress((count) => count + 1); let savedMemo: MemoDetail | null = null; let resumeDesktopSync: (() => void) | null = null; try { if (isDesktopResourceRuntime()) { resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport(); } const memo = await createSharedFileMemo({ notebookId, filename, file: await (async () => { const shared = await bridge?.readWeChatImportMedia?.(importId, "file"); if (!shared?.bytes?.byteLength) throw new Error("Missing shared file"); const bytes = shared.bytes; const copy = new ArrayBuffer(bytes.byteLength); new Uint8Array(copy).set(bytes); return new File([copy], shared.filename || filename, { type: shared.mimeType || payload.mimeType || "application/octet-stream" }); })(), createMemo: (input) => repository.createMemo(input), prepareFile: async (file) => (imageCompressionEnabled && file.type.startsWith("image/") ? (await compressImageForUpload(file)).file : file), uploadResource: async (memoId, uploadFile) => { try { const { resource } = await repository.uploadMemoResource(memoId, uploadFile); return { url: toDesktopResourceUrl(resource.url), filename: resource.filename }; } catch (error) { if (!isDesktopResourceRuntime()) throw error; const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null }> })); const existing = findMatchingMemoResource( listed.resources.filter((resource) => resource.memoId === memoId), uploadFile.name, uploadFile.type.startsWith("image/") ? "image" : "attachment", ); if (existing) return { url: toDesktopResourceUrl(existing.url), filename: existing.filename }; const staged = await stageDesktopResource(memoId, uploadFile); if (!staged) throw error; return { url: `edgeever-staged://${staged.id}`, filename: uploadFile.name }; } }, updateMemo: (created, content) => repository.updateMemo(created, { expectedRevision: created.revision, expectedContentHash: created.contentHash, editSessionId: `share:${created.id}`, title: created.title ?? sharedFileTitle(filename), contentJson: content.contentJson, contentMarkdown: content.contentMarkdown, tags: created.tags, }), deleteMemo: (memoId) => isDesktopResourceRuntime() ? import("@/lib/desktop-repository").then(({ cancelPendingDesktopImportMemo }) => cancelPendingDesktopImportMemo(memoId)) : repository.deleteMemo(memoId, true), }); savedMemo = memo; await bridge?.finishWeChatImport?.(importId, true).catch(() => undefined); await putLocalMemo(localDataScope, memo).catch(() => undefined); revealCreatedMemo(memo); } catch { if (!savedMemo) { await bridge?.finishWeChatImport?.(importId, false).catch(() => undefined); setAppNoticeDialog({ title: t("memoList.importSharedFileFailedTitle"), description: t("memoList.importSharedFileFailed"), }); } creatingMemoSelectionRef.current = false; } finally { resumeDesktopSync?.(); setSharedFileImportsInProgress((count) => Math.max(0, count - 1)); } }, [defaultMemoNotebookId, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); const handleImportWeChatChat = useCallback(async (payload: { ok: boolean; kind?: "file"; reason?: string; importId?: string; title?: string; filename?: string; mimeType?: string; markdown?: string; media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>; }) => { if (payload.kind === "file") { await handleImportSharedFile(payload); return; } const bridge = window.edgeeverDesktop; if (!payload.ok || !payload.importId || !payload.markdown) { setAppNoticeDialog({ title: t("memoList.importWeChatFailedTitle"), description: payload.reason === "unrecognized" ? t("memoList.importWeChatUnrecognized") : t("memoList.importWeChatFailed"), }); return; } const notebookId = selectedNotebookId && notebooks.some((notebook) => notebook.id === selectedNotebookId) && memoView !== "trash" ? selectedNotebookId : defaultMemoNotebookId; if (!notebookId) { pendingWeChatImportsRef.current.push(payload); return; } const importId = payload.importId; const markdown = payload.markdown; setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; setWeChatImportsInProgress((count) => count + 1); let savedMemo: MemoDetail | null = null; let resumeDesktopSync: (() => void) | null = null; try { if (isDesktopResourceRuntime()) { resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport(); } const memo = await createWeChatChatMemo({ notebookId, title: payload.title?.trim() || "", markdown, media: payload.media ?? [], createMemo: (input) => repository.createMemo(input), readMedia: async (item) => { const file = await bridge?.readWeChatImportMedia?.(importId, item.id); if (!file?.bytes?.byteLength) throw new Error("Missing WeChat attachment"); const bytes = file.bytes; const copy = new ArrayBuffer(bytes.byteLength); new Uint8Array(copy).set(bytes); return new File([copy], file.filename || item.filename, { type: file.mimeType || item.mimeType }); }, prepareFile: async (file) => (imageCompressionEnabled && file.type.startsWith("image/") ? (await compressImageForUpload(file)).file : file), uploadResource: async (memoId, uploadFile) => { try { const { resource } = await repository.uploadMemoResource(memoId, uploadFile); return { url: toDesktopResourceUrl(resource.url) }; } catch (error) { if (!isDesktopResourceRuntime()) throw error; const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null }> })); const existing = findMatchingMemoResource( listed.resources.filter((resource) => resource.memoId === memoId), uploadFile.name, uploadFile.type.startsWith("image/") ? "image" : "attachment", ); if (existing) return { url: toDesktopResourceUrl(existing.url) }; const staged = await stageDesktopResource(memoId, uploadFile); if (!staged) throw error; return { url: `edgeever-staged://${staged.id}` }; } }, updateMemo: (created, content) => repository.updateMemo(created, { expectedRevision: created.revision, expectedContentHash: created.contentHash, editSessionId: `wechat:${created.id}`, title: created.title ?? payload.title ?? "", contentJson: content.contentJson, contentMarkdown: content.contentMarkdown, tags: created.tags, }), deleteMemo: (memoId) => isDesktopResourceRuntime() ? import("@/lib/desktop-repository").then(({ cancelPendingDesktopImportMemo }) => cancelPendingDesktopImportMemo(memoId)) : repository.deleteMemo(memoId, true), }); savedMemo = memo; await bridge?.finishWeChatImport?.(importId, true).catch(() => undefined); await putLocalMemo(localDataScope, memo).catch(() => undefined); revealCreatedMemo(memo); } catch { if (!savedMemo) { await bridge?.finishWeChatImport?.(importId, false).catch(() => undefined); setWeChatImportFailureId(importId); } creatingMemoSelectionRef.current = false; } finally { resumeDesktopSync?.(); setWeChatImportsInProgress((count) => Math.max(0, count - 1)); } }, [defaultMemoNotebookId, handleImportSharedFile, imageCompressionEnabled, localDataScope, memoView, notebooks, repository, selectedNotebookId, t]); const handleImportWeChatChatRef = useRef(handleImportWeChatChat); handleImportWeChatChatRef.current = handleImportWeChatChat; useEffect(() => { const pending = pendingWeChatImportsRef.current.splice(0); for (const payload of pending) void handleImportWeChatChat(payload); }, [handleImportWeChatChat]); const handleCreateMemo = (kind?: NoteCreateKind) => { const targetNotebookId = createMemoNotebookId; if (!targetNotebookId || memoView === "trash") { return; } // Desktop Cmd+N is handled by both the native menu and the in-app shortcut. // A second click can also land before React Query flips `isPending`. if (createMemoInFlightRef.current || createMemoMutation.isPending) { return; } createMemoInFlightRef.current = true; setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; if (kind === "infographic") { const infographic = createDefaultInfographicDocument(); createMemoMutation.mutate({ notebookId: targetNotebookId, title: t("infographic.name"), contentJson: markdownToDoc(infographicFallbackMarkdown(infographic)), contentMarkdown: serializeInfographicDocument(infographic), tags: [], }); return; } if (kind === "table") { const table = createDefaultTableDocument({ name: t("structuredTable.defaultFields.name"), status: t("structuredTable.defaultFields.status"), date: t("structuredTable.defaultFields.date"), sample: t("structuredTable.defaultFields.sample"), notStarted: t("structuredTable.defaultFields.notStarted"), inProgress: t("structuredTable.defaultFields.inProgress"), done: t("structuredTable.defaultFields.done"), }); createMemoMutation.mutate({ notebookId: targetNotebookId, title: t("structuredTable.name"), contentJson: markdownToDoc(tableFallbackMarkdown(table)), contentMarkdown: serializeTableDocument(table), tags: [], }); return; } const diagram = kind ? createDefaultDiagramDocument(kind) : null; createMemoMutation.mutate({ notebookId: targetNotebookId, title: diagram ? (kind === "mind-map" ? t("diagram.mindMap") : kind === "architecture" ? t("diagram.architecture") : t("diagram.flowchart")) : "", contentJson: diagram ? markdownToDoc(diagramFallbackMarkdown(diagram)) : undefined, contentMarkdown: diagram ? serializeDiagramDocument(diagram) : "", tags: [], }); }; const handleImportMarkdownFiles = async (files: File[]) => { const targetNotebookId = createMemoNotebookId; if (!targetNotebookId || memoView === "trash") return; if (files.length !== 1) { setAppNoticeDialog({ title: t("memoList.importMarkdownFailedTitle"), description: t("memoList.importMarkdownSingleFile"), }); return; } const [file] = files; if (!isMarkdownFile(file)) { setAppNoticeDialog({ title: t("memoList.importMarkdownFailedTitle"), description: t("memoList.importMarkdownUnsupported"), }); return; } try { const payload = await readMarkdownFile(file); setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; await createMemoMutation.mutateAsync({ notebookId: targetNotebookId, title: payload.title, contentMarkdown: payload.contentMarkdown, tags: [], }); } catch { setAppNoticeDialog({ title: t("memoList.importMarkdownFailedTitle"), description: t("memoList.importMarkdownReadFailed"), }); } }; 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(() => { pendingCreatedMemoIdRef.current = null; 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 handleMoveSelectedMemos = (targetNotebookId: string) => { if (selectedMemoIds.size === 0 || memoView === "trash") { return; } const memoIds = getMemoIdsNeedingMove(memos, 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(memos, 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(memos, [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 handleExportSelectedMemos = () => { if (selectedMemoIds.size === 0 || memoView === "trash" || isExportingSelectedMemos) { return; } setIsExportingSelectedMemos(true); setSelectedMarkdownExportProgress({ completed: 0, total: selectedMemoIds.size }); void exportSelectedMemosAsMarkdownZip({ memoIds: Array.from(selectedMemoIds), listNotebooks: api.listNotebooks, getPage: api.getMarkdownExportPage, getResourceBlob: api.getResourceBlob, onProgress: setSelectedMarkdownExportProgress, }).then((result) => { if (result.status === "too-many") { setAppNoticeDialog({ title: t("workspace.selection.export"), description: t("workspace.selection.exportTooMany", { max: result.max }), }); return; } if (result.status === "local-only") { setAppNoticeDialog({ title: t("workspace.selection.export"), description: t("workspace.selection.exportLocalOnly"), }); return; } if (result.status === "empty") { setAppNoticeDialog({ title: t("workspace.selection.export"), description: t("workspace.selection.exportEmpty"), }); return; } if (result.skippedLocal > 0) { setAppNoticeDialog({ title: t("workspace.selection.export"), description: t("workspace.selection.exportCompleteWithSkipped", { count: result.skippedLocal }), }); } }).catch((error: unknown) => { console.error("Failed to export selected notes as Markdown ZIP", error); setAppNoticeDialog({ title: t("workspace.selection.export"), description: error instanceof MarkdownExportMemoryLimitError ? t("dataExport.largeBackupRequiresStreaming") : t("workspace.selection.exportError"), }); }).finally(() => { setIsExportingSelectedMemos(false); }); }; 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 selectedMemosNeedingMove = getMemoIdsNeedingMove( memos, Array.from(selectedMemoIds), selectionMoveTargetNotebookId ); const canMoveSelectedMemos = selectedMemosNeedingMove.length > 0; 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") : !canMoveSelectedMemos ? t("workspace.selection.alreadyInNotebook") : 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 selectionExportTitle = selectedMemoIds.size === 0 ? t("workspace.selection.chooseMemo") : memoView === "trash" ? t("workspace.selection.trashCannotExport") : isExportingSelectedMemos ? t("workspace.selection.exportProgress", { completed: selectedMarkdownExportProgress.completed, total: selectedMarkdownExportProgress.total, }) : t("workspace.selection.exportHint"); 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"); setSelectedTag(null); setSelectedNotebookId(notebookId); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setMobileNotebookPickerOpen(false); setActivePane("memos"); }; const handleSelectAllMemos = () => { navigateWorkspaceHome(); setMemoView("notebook"); setSelectedTag(null); setSelectedNotebookId(null); setRightView("editor"); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setMobileNotebookPickerOpen(false); setActivePane("memos"); }; const handleMobileHome = () => { navigateWorkspaceHome(); if (memoView === "trash") { setMemoView("notebook"); } setMobileBottomNavActive("home"); setSelectedTag(null); setSelectedNotebookId(null); setSearch(""); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setActivePane("memos"); }; const handleSelectTag = (tag: string) => { navigateWorkspaceHome(); setMemoView("notebook"); setSelectedTag(tag); setSelectedNotebookId(null); setRightView("editor"); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(null); setActivePane("memos"); }; const handleMobileSearch = useCallback(() => { setMobileBottomNavActive("search"); setActivePane("memos"); setMobileSearchFocusToken((value) => value + 1); }, []); const handleGlobalSearch = useCallback(() => { navigateWorkspaceHome(); setMemoView("notebook"); setSelectedTag(null); setSelectedNotebookId(null); setMemoFilterMode("all"); setRightView("editor"); clearMemoSelection(); handleMobileSearch(); }, [clearMemoSelection, handleMobileSearch, navigateWorkspaceHome, setSelectedNotebookId]); const handleOpenQuickSwitcherMemo = useCallback((memo: MemoSummary) => { pendingQuickSwitcherMemoIdRef.current = memo.id; setQuickSwitcherOpen(false); setQuickSwitcherQuery(""); navigateWorkspaceHome(); setMemoView("notebook"); setSelectedTag(null); setSelectedNotebookId(memo.notebookId); setSearch(""); setMemoFilterMode("all"); setRightView("editor"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(memo.id); setActivePane("editor"); }, [clearMemoSelection, clearPendingCreatedMemo, navigateWorkspaceHome, setSelectedMemoId, setSelectedNotebookId]); const handleOpenPluginNote = useCallback((memoId: string, notebookId: string, options?: { search?: string }) => { setRequestedPluginPanel(null); navigateWorkspaceHome(); setMemoView("notebook"); setSelectedTag(null); if (notebookId) setSelectedNotebookId(notebookId); setSearch(""); setMemoFilterMode("all"); setRightView("editor"); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); pendingQuickSwitcherMemoIdRef.current = memoId; setSelectedMemoId(memoId); setActivePane("editor"); if (options?.search) { pluginNavigationRequestIdRef.current += 1; setPluginNavigationRequest({ id: pluginNavigationRequestIdRef.current, noteId: memoId, search: options.search }); } else { setPluginNavigationRequest(null); } }, [clearMemoSelection, clearPendingCreatedMemo, navigateWorkspaceHome, setSelectedMemoId, setSelectedNotebookId]); useEffect(() => pluginHost.setNavigationAdapter({ openNote: handleOpenPluginNote }), [handleOpenPluginNote, pluginHost]); useEffect(() => { if (shouldDiscardPluginNoteSearchRequest(pluginNavigationRequest, selectedMemoId)) { setPluginNavigationRequest(null); } }, [pluginNavigationRequest, selectedMemoId]); const handleCancelMobileSearch = () => { setSearch(""); setMobileBottomNavActive("home"); clearMemoSelection(); setActivePane("memos"); }; const clearHiddenMobileSearch = () => { if (!isDesktopViewport()) { setSearch(""); } }; const handleOpenAssets = () => { clearHiddenMobileSearch(); skipNextHomeRouteSyncRef.current = route.pathname !== "/"; navigateWorkspaceHome(); setRightView("assets"); setActivePane("editor"); }; const handleOpenTags = () => { clearHiddenMobileSearch(); skipNextHomeRouteSyncRef.current = route.pathname !== "/"; navigateWorkspaceHome(); setRightView("tags"); setActivePane("editor"); }; const handleOpenTemplates = () => { clearHiddenMobileSearch(); navigateWorkspaceTemplates(); setRightView("templates"); setMobileBottomNavActive("templates"); setActivePane("editor"); }; const handleOpenAiPrompts = () => { clearHiddenMobileSearch(); navigateWorkspaceAiPrompts(); setRightView("ai-prompts"); setMobileBottomNavActive("templates"); setActivePane("editor"); }; const handleOpenSettings = () => { clearHiddenMobileSearch(); navigateWorkspaceSettings(); setRightView("settings"); setMobileBottomNavActive("settings"); setActivePane("editor"); }; const handleOpenPluginManager = () => { clearHiddenMobileSearch(); navigateWorkspacePlugins(); setRightView("plugins"); setMobileBottomNavActive("home"); setActivePane("editor"); }; const handleOpenExecutionCenter = () => { clearHiddenMobileSearch(); navigateWorkspaceExecutionCenter(); setRightView("execution-center"); setMobileBottomNavActive("home"); setActivePane("editor"); }; const handleCloseExecutionCenter = () => { navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); if (!isDesktopViewport()) { setActivePane("memos"); } }; const handleCloseAssets = () => { navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); }; const handleCloseTemplates = () => { setTemplatesOpen(false); navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); }; const handleCloseAiPrompts = () => { navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); }; const handleCloseSettings = () => { navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); if (!isDesktopViewport()) { setActivePane("memos"); } }; const handleClosePluginMarketplace = () => { navigateWorkspaceHome(); setRightView("editor"); setMobileBottomNavActive("home"); if (!isDesktopViewport()) { setActivePane("memos"); } }; const updateDesktopFocusMode = useCallback((enabled: boolean) => { runWorkspaceViewTransition(() => { setDesktopFocusMode(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") handleGlobalSearch(); 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"); } } }); // The Markdown listener is what marks the renderer ready, so register this first. const removeWeChatListener = bridge.onImportWeChatChat?.((payload) => { void handleImportWeChatChatRef.current(payload); }) ?? (() => {}); 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: [] }); }); const removeScreenshotListener = bridge.onImportScreenshot?.((payload) => { void handleImportScreenshotRef.current(payload); }) ?? (() => {}); return () => { removeCommandListener(); removeWeChatListener(); removeMarkdownListener(); removeScreenshotListener(); }; }, [createMemoMutation, defaultMemoNotebookId, handleCreateMemo, handleCreateNotebook, handleGlobalSearch, notebooks, selectedNotebookId, toggleDesktopFocusMode]); const handleWorkspaceBackRequest = useCallback(() => { if (appNoticeDialog) { setAppNoticeDialog(null); return true; } if (pendingCatalogTrustPluginIds.length > 0) { setPendingCatalogTrustPluginIds([]); 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 === "plugins") { handleClosePluginMarketplace(); return true; } if (rightView === "execution-center") { handleCloseExecutionCenter(); 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, pendingCatalogTrustPluginIds, rightView, clearMemoSelection, createNotebookMutation.isPending, deleteMemoMutation.isPending, deleteMemosMutation.isPending, deleteNotebookMutation.isPending, emptyTrashConfirmationOpen, emptyTrashMutation.isPending, handleCloseAssets, handleCloseSettings, handleCloseExecutionCenter, handleCloseTemplates, handleSelectAllMemos, 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; } if (action === "openQuickSwitcher") { event.preventDefault(); if (event.repeat || event.isComposing) { return; } setQuickSwitcherQuery(""); setQuickSwitcherOpen(true); return; } const targetElement = event.target instanceof Element ? event.target : null; const isEditorTextTarget = Boolean(targetElement?.closest(".ProseMirror")); const transientLayerOpen = Boolean( appNoticeDialog || pendingCatalogTrustPluginIds.length > 0 || rightView !== "editor" || memoDeleteConfirmation || emptyTrashConfirmationOpen || mobileNotebookPickerOpen || notebookDeleteConfirmation || notebookNameDialog || templatesOpen || quickSwitcherOpen ); if ( action === "openAiAssistant" || action === "saveAndSync" || action === "toggleReadingProtection" || action === "toggleEditorMode" || action === "toggleOutline" || action === "openPreviousMemo" || action === "openNextMemo" ) { // These replace browser-level commands, so consume them throughout the // workspace even when the current editor cannot perform the action. event.preventDefault(); if (event.repeat || event.isComposing || transientLayerOpen || !selectedMemoId || memoView === "trash") { return; } if (action === "openAiAssistant") { setNoteAiAssistantOpenToken((value) => value + 1); } else if (action === "saveAndSync") { setNoteSaveAndSyncToken((value) => value + 1); } else if (action === "toggleReadingProtection") { setNoteReadingProtectionToggleToken((value) => value + 1); } else if (action === "toggleEditorMode") { setNoteEditorModeToggleToken((value) => value + 1); } else if (action === "toggleOutline") { setNoteOutlineToggleToken((value) => value + 1); } else { const targetMemoId = action === "openPreviousMemo" ? previousMemoId : nextMemoId; if (targetMemoId) { clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(targetMemoId); } } return; } if ((action === "focusSearch" || action === "focusReplace") && isTextEntryTarget(event.target) && !isEditorTextTarget) { return; } if (transientLayerOpen) { return; } if (action === "focusSearch") { event.preventDefault(); if (getSearchShortcutScope(selectedMemoId) === "memo-list") { clearMemoSelection(); handleMobileSearch(); return; } setNoteSearchFocusToken((value) => value + 1); return; } if (action === "focusGlobalSearch") { event.preventDefault(); handleGlobalSearch(); 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, pendingCatalogTrustPluginIds, canCreateMemo, clearPendingCreatedMemo, clearMemoSelection, createNotebookMutation.isPending, createMemoMutation.isPending, handleCreateNotebook, handleCreateMemo, handleGlobalSearch, handleMobileSearch, shortcutSettings, emptyTrashConfirmationOpen, memoDeleteConfirmation, memoView, mobileNotebookPickerOpen, notebookDeleteConfirmation, notebookNameDialog, nextMemoId, previousMemoId, quickSwitcherOpen, selectedMemoId, setSelectedMemoId, 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) => { setMemoListWidth(startWidth + moveEvent.clientX - startX); }; 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 = () => { resetMemoListWidth(); }; const updateMemoListWidth = (width: number) => { setMemoListWidth(width); }; 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 === "plugins" ? t("plugins.marketplace.loading") : rightView === "assets" ? t("workspace.loading.assets") : rightView === "tags" ? t("workspace.loading.tags") : rightView === "templates" ? t("templates.title") : rightView === "ai-prompts" ? t("aiPrompts.title") : rightView === "execution-center" ? t("executionHistory.centerTitle") : 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"); const showMobileSettingsNav = visibleActivePane === "editor" && rightView === "settings"; return (
{(wechatImportsInProgress > 0 || sharedFileImportsInProgress > 0) && (
{wechatImportsInProgress > 0 ? t("memoList.importWeChatInProgress") : t("memoList.importSharedFileInProgress")}
)} {pullToRefreshVisible && ( )}
void memosQuery.refetch()} onOpenNotebookPicker={() => setMobileNotebookPickerOpen(true)} onSearch={setSearch} onCancelMobileSearch={handleCancelMobileSearch} onCreateMemo={handleCreateMemo} onImportMarkdownFiles={(files) => void handleImportMarkdownFiles(files)} 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"); setSelectedTag(null); setSelectedNotebookId(null); setMobileBottomNavActive("home"); clearMemoSelection(); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(null); setActivePane("memos"); }} onBackFromTrash={handleSelectAllMemos} onClearTag={handleSelectAllMemos} onOpenMemo={(memoId) => { if (shouldNavigateHomeWhenOpeningMemo(memoView)) { navigateWorkspaceHome(); } setRightView("editor"); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(memoId); setActivePane("editor"); }} onPrefetchMemo={prefetchMemoDetail} 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} onRequestDocumentAction={(memoId: string, action: MemoDocumentAction, printWindow?: Window | null) => { if (shouldNavigateHomeWhenOpeningMemo(memoView)) { navigateWorkspaceHome(); } setRightView("editor"); clearPendingCreatedMemo(); setCreatedMemoEditId(null); setSelectedMemoId(memoId); setActivePane("editor"); memoDocumentActionIdRef.current += 1; setMemoDocumentActionRequest({ id: memoDocumentActionIdRef.current, memoId, action, printWindow, }); }} onCopyMemoToWeChat={async (memoId) => { const openTextNote = selectedMemo?.id === memoId && !selectedDiagram && !selectedTableNote && !selectedInfographicNote; if (openTextNote) { memoDocumentActionIdRef.current += 1; setMemoDocumentActionRequest({ id: memoDocumentActionIdRef.current, memoId, action: "copy-wechat", }); return "editor"; } try { const { memo } = await repository.getMemo(memoId, memoView === "trash"); await copyMarkdownToWeChat(memo.contentMarkdown); return "copied"; } catch { return "error"; } }} onTogglePinMemo={handleToggleMemoPinned} onPinSelectedMemos={handlePinSelectedMemos} onExportSelectedMemos={handleExportSelectedMemos} 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" ? ( { await refreshWorkspaceFromServer("manual"); }} /> ) : rightView === "plugins" ? ( ) : 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 }); }} onOpenExecutionCenter={handleOpenExecutionCenter} /> ) : rightView === "ai-prompts" ? ( ) : rightView === "execution-center" ? ( ) : rightView === "evernote-migration" ? ( setRightView("settings")} onOpenExecutionCenter={handleOpenExecutionCenter} /> ) : rendererRecoveryMode ? ( ) : memoSelectionModeActive ? (
{memoSelectionActionBar}
) : ( { setRendererRecoveryMode(true); setSelectedMemoId(null); setActivePane("memos"); }} > {selectedMemo && selectedDiagram ? ( { clearPendingCreatedMemo(); setActivePane("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); }} onSaved={async (memo) => { await putLocalMemo(localDataScope, memo); cacheMemoDetail(queryClient, memo, memoView); updateMemoSummaryInLists(queryClient, memoToSummary(memo)); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), ]); }} onSaveAsTemplate={handleSaveAsTemplate} onToggleDesktopFocusMode={toggleDesktopFocusMode} onOpenExecutionCenter={handleOpenExecutionCenter} /> ) : selectedMemo && selectedInfographicNote ? ( { clearPendingCreatedMemo(); setActivePane("memos"); }} onOpenExecutionCenter={handleOpenExecutionCenter} onToggleDesktopFocusMode={toggleDesktopFocusMode} onSaved={async (memo) => { await putLocalMemo(localDataScope, memo); cacheMemoDetail(queryClient, memo, memoView); updateMemoSummaryInLists(queryClient, memoToSummary(memo)); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), ]); }} /> ) : selectedMemo && selectedTableNote ? ( { clearPendingCreatedMemo(); setActivePane("memos"); }} onSaved={async (memo) => { await putLocalMemo(localDataScope, memo); cacheMemoDetail(queryClient, memo, memoView); updateMemoSummaryInLists(queryClient, memoToSummary(memo)); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), ]); }} /> ) : ( { const { assertCompanionChangesSynced } = await import("@/lib/companion-actions"); await assertCompanionChangesSynced(localDataScope); }} onCompanionNotesChanged={async () => { const result = await refreshWorkspaceFromServer("manual"); if ("skipped" in result && result.skipped) throw new Error("Workspace refresh was skipped."); }} onOpenCompanionNote={handleOpenPluginNote} desktopFocusMode={desktopFocusModeActive} onToggleDesktopFocusMode={toggleDesktopFocusMode} editorContentWidth={editorContentWidth} noteProse={noteProse} mobileDefaultEditMemoId={createdMemoEditId} isTrashView={memoView === "trash"} notebooks={notebooks} isLoading={memoQuery.isLoading} contentSearchQuery={search} searchFocusToken={noteSearchFocusToken} replaceFocusToken={noteReplaceFocusToken} aiAssistantOpenToken={noteAiAssistantOpenToken} saveAndSyncToken={noteSaveAndSyncToken} readingProtectionToggleToken={noteReadingProtectionToggleToken} editorModeToggleToken={noteEditorModeToggleToken} outlineToggleToken={noteOutlineToggleToken} shortcutSettings={shortcutSettings} onSyncRequested={syncMemosManually} documentActionRequest={memoDocumentActionRequest} onDocumentActionConsumed={(requestId) => { setMemoDocumentActionRequest((current) => current?.id === requestId ? null : current); }} onOpenMemo={(memoId) => { clearPendingCreatedMemo(); setMemoView("notebook"); setSelectedMemoId(memoId); setActivePane("editor"); }} imageCompressionEnabled={imageCompressionEnabled} selectionActionBar={memoSelectionActionBar} onBackToList={() => { applyMobileEditorReturnPreview(selectedMemo?.id ?? selectedMemoId); clearPendingCreatedMemo(); setActivePane("memos"); }} 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, selectedTag, ], 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} /> )} )}
)}
{ setQuickSwitcherOpen(open); if (!open) { setQuickSwitcherQuery(""); } }} onQueryChange={setQuickSwitcherQuery} onOpenMemo={handleOpenQuickSwitcherMemo} /> {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)} /> )} {wechatImportFailureId && ( setWeChatImportFailureId(null)} onConfirm={() => { const importId = wechatImportFailureId; setWeChatImportFailureId(null); void Promise.resolve(window.edgeeverDesktop?.retryWeChatImport?.(importId) ?? false).then((retried) => { if (!retried) setAppNoticeDialog({ title: t("memoList.importWeChatFailedTitle"), description: t("memoList.importWeChatFailed"), }); }).catch(() => setAppNoticeDialog({ title: t("memoList.importWeChatFailedTitle"), description: t("memoList.importWeChatFailed"), })); }} /> )} {pendingCatalogTrustPluginIds.length > 0 ? ( setPendingCatalogTrustPluginIds([])} onConfirm={() => { const pluginIds = pendingCatalogTrustPluginIds; acknowledgePluginTrustWarning(); setPendingCatalogTrustPluginIds([]); void Promise.all(pluginIds.map((pluginId) => pluginHost.setEnabled(pluginId, true).catch(() => undefined))); }} /> ) : null} {demoResetConfirmationOpen && ( setDemoResetConfirmationOpen(false)} onConfirm={() => resetDemoMutation.mutate()} /> )} setRequestedPluginPanel(null)} /> {(visibleActivePane !== "editor" || showMobileSettingsNav) && !memoSelectionModeActive && ( )} {mobileNotebookPickerOpen && ( setMobileNotebookPickerOpen(false)} onSelectAll={handleSelectAllMemos} onSelect={handleSelectNotebook} /> )}
); }; export default WorkspaceApp;