Files
edgeever/apps/web/src/components/WorkspaceApp.tsx
T
tianma-if 65ef837f45 feat(web): copy a note to WeChat from the card menu
Text-note card menus can copy the note as rich text. The open note uses the editor, including unsaved changes; other notes copy saved content without switching the editor.
2026-09-30 20:12:49 +08:00

3845 lines
147 KiB
TypeScript

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 }) => (
<div className="flex h-full min-h-0 items-center justify-center bg-card text-sm font-medium text-slate-400" role="status">
{label}
</div>
);
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<Pane>(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute || isInitialExecutionCenterRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
const [memoView, setMemoView] = useState<MemoView>(() => (
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<string | null>(null);
const autoSelectedDemoNotebookRef = useRef(false);
const [createdMemoEditId, setCreatedMemoEditId] = useState<string | null>(null);
const pendingCreatedMemoIdRef = useRef<string | null>(null);
const pendingQuickSwitcherMemoIdRef = useRef<string | null>(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<MemoDocumentActionRequest | null>(null);
const [memoDeleteConfirmation, setMemoDeleteConfirmation] = useState<MemoDeleteConfirmation | null>(null);
const [emptyTrashConfirmationOpen, setEmptyTrashConfirmationOpen] = useState(false);
const [notebookNameDialog, setNotebookNameDialog] = useState<NotebookNameDialogState | null>(null);
const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState<Notebook | null>(null);
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
const [wechatImportFailureId, setWeChatImportFailureId] = useState<string | null>(null);
const [wechatImportsInProgress, setWeChatImportsInProgress] = useState(0);
const [sharedFileImportsInProgress, setSharedFileImportsInProgress] = useState(0);
const [pendingCatalogTrustPluginIds, setPendingCatalogTrustPluginIds] = useState<string[]>([]);
const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false);
const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState<MarkdownExportProgress>({
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<string>());
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<MobileBottomNavItem>(() =>
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<MemoFilterMode>("all");
const [memoSortMode, setMemoSortMode] = useState<MemoSortMode>("updated-desc");
const [mobileEditorReturnPreview, setMobileEditorReturnPreview] = useState<MobileEditorReturnPreview | null>(() =>
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<string, MemoSummary>();
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<MemoDetail>).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<typeof repository.createMemo>[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<MemoDeleteOptimisticContext> => {
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<MemoListQueryData>({ queryKey: ["memos"] });
const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] });
const previousNotebooks = queryClient.getQueryData<ListNotebooksQueryData>(["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<MemoDeleteOptimisticContext> => {
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<MemoListQueryData>({ queryKey: ["memos"] });
const previousMemoDetails = queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo", variables.memoId] });
const previousNotebooks = queryClient.getQueryData<ListNotebooksQueryData>(["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<EmptyTrashOptimisticContext> => {
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<MemoListQueryData>({ 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<Array<{
ok: boolean;
kind?: "file";
reason?: string;
importId?: string;
title?: string;
filename?: string;
mimeType?: string;
byteSize?: number;
markdown?: string;
media?: Array<{ id: string; filename: string; mimeType: string; byteSize: number }>;
}>>([]);
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 ? (
<MemoSelectionActionBar
canMove={canMoveSelectedMemos}
deleteTitle={selectionDeleteTitle}
exportTitle={selectionExportTitle}
isDeleting={deleteMemosMutation.isPending || deleteMemoMutation.isPending}
isExporting={isExportingSelectedMemos}
isMerging={mergeMutation.isPending}
isMoving={moveMemosMutation.isPending}
isPinning={pinMemosMutation.isPending}
isTrashView={memoView === "trash"}
mergeTitle={selectionMergeTitle}
moveNotebookOptions={selectionMoveNotebookOptions}
moveTargetNotebookId={selectionMoveTargetNotebookId}
moveTitle={selectionMoveTitle}
onClearSelection={clearMemoSelection}
onDelete={handleDeleteSelectedMemos}
onExport={handleExportSelectedMemos}
onMerge={handleMerge}
onMove={() => 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<WorkspaceMotionProvider>
<div className="edgeever-workspace-shell flex h-[100dvh] overflow-hidden text-slate-950">
{(wechatImportsInProgress > 0 || sharedFileImportsInProgress > 0) && (
<div className="pointer-events-none fixed inset-x-0 top-3 z-50 flex justify-center" role="status" aria-live="polite">
<div className="rounded-full border border-slate-200 bg-card px-4 py-2 text-sm font-medium text-slate-700 shadow-lg">
{wechatImportsInProgress > 0 ? t("memoList.importWeChatInProgress") : t("memoList.importSharedFileInProgress")}
</div>
</div>
)}
{pullToRefreshVisible && (
<div
className="pointer-events-none fixed inset-x-0 top-[max(0.75rem,env(safe-area-inset-top))] z-50 flex justify-center lg:hidden"
style={{ transform: `translateY(${Math.max(0, pullToRefreshDistance - 24)}px)` }}
aria-hidden="true"
>
<div className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-card/95 px-3 text-xs font-semibold text-slate-600 shadow-[0_10px_28px_rgba(15,23,42,0.12)] backdrop-blur">
<RefreshCw className={cn("h-4 w-4 text-slate-500", (isPullRefreshing || pullToRefreshReady) && "animate-spin")} />
<span>{pullToRefreshLabel}</span>
</div>
</div>
)}
<div className="min-w-0 flex-1">
<main
className={cn(
"edgeever-workspace-grid grid h-[100dvh] min-h-0 grid-cols-[minmax(0,1fr)]",
desktopFocusModeActive
? "edgeever-workspace-grid--focus"
: rightView === "editor"
? "edgeever-workspace-grid--editor"
: "edgeever-workspace-grid--single-right",
!desktopFocusModeActive && desktopNotebookSidebarCollapsed && "edgeever-workspace-grid--sidebar-collapsed"
)}
style={{ "--memo-list-width": `${memoListWidth}px` } as CSSProperties}
>
<aside
id="edgeever-notebook-sidebar"
className={cn(
"edgeever-workspace-sidebar min-h-0 overflow-hidden border-r",
desktopFocusModeActive
? "hidden"
: visibleActivePane === "notebooks"
? "block lg:block"
: "hidden lg:block"
)}
>
{(isDesktop || visibleActivePane === "notebooks") && (
<Suspense fallback={<PaneLoadingFallback label={t("workspace.loading.notebooks")} />}>
<NotebookPane
repository={repository}
authRequired={authRequired}
user={user}
selectedNotebookId={selectedNotebookId}
view={memoView}
canCreateMemo={canCreateMemo}
isCreatingMemo={createMemoMutation.isPending}
onSelect={(notebookId) => {
navigateWorkspaceHome();
setMemoView("notebook");
setSelectedTag(null);
setSelectedNotebookId(notebookId);
clearMemoSelection();
setRightView("editor");
setActivePane("memos");
}}
onCreateMemo={handleCreateMemo}
onCreateNotebook={handleCreateNotebook}
onRenameNotebook={handleRenameNotebook}
onDeleteNotebook={handleDeleteNotebook}
onMoveNotebook={handleMoveNotebook}
onMoveMemos={handleMoveDraggedMemos}
onBackToList={handleSelectAllMemos}
onLogout={onLogout}
isLoggingOut={isLoggingOut}
imageCompressionEnabled={imageCompressionEnabled}
onImageCompressionChange={setImageCompressionEnabled}
syncSummary={syncSummary}
isOnline={isOnline}
isSyncingQueuedChanges={isSyncingQueuedChanges}
onSyncQueuedChanges={() => void runQueuedSync()}
onDiscardConflicts={() => void discardConflictsNow()}
onOpenAssets={handleOpenAssets}
onOpenTags={handleOpenTags}
onOpenTemplates={handleOpenTemplates}
pluginHost={pluginHost}
onOpenPluginManager={handleOpenPluginManager}
onOpenSettings={handleOpenSettings}
onOpenTrash={() => {
navigateWorkspaceTrash();
setMemoView("trash");
setSelectedTag(null);
setSelectedNotebookId(null);
setMobileBottomNavActive("home");
clearMemoSelection();
setSelectedMemoId(null);
setActivePane("memos");
}}
onEmptyTrash={handleEmptyTrash}
demoMode={demoMode}
onResetDemo={() => setDemoResetConfirmationOpen(true)}
isResettingDemo={resetDemoMutation.isPending}
collapsed={desktopNotebookSidebarCollapsed}
onToggleCollapsed={() => setNotebookSidebarCollapsed(!notebookSidebarCollapsed)}
/>
</Suspense>
)}
</aside>
<section
className={cn(
"edgeever-workspace-memo-list relative min-w-0 overflow-hidden border-r",
desktopFocusModeActive
? "hidden"
: rightView === "editor"
? (visibleActivePane === "memos" ? "block lg:block" : "hidden lg:block")
: (visibleActivePane === "memos" ? "block lg:hidden" : "hidden lg:hidden")
)}
>
<MemoListPane
notebook={selectedTag ? null : selectedNotebook}
selectedTag={selectedTag}
notebooks={notebooks}
view={memoView}
memos={memos}
totalMemoCount={totalMemoCount}
hasMoreMemos={Boolean(memosQuery.hasNextPage)}
isLoadingMoreMemos={memosQuery.isFetchingNextPage}
selectedMemoId={selectedMemoId}
selectedMemoIds={selectedMemoIds}
selectionMode={memoSelectionModeActive}
search={search}
filterMode={memoFilterMode}
sortMode={memoSortMode}
mobileSearchActive={mobileSearchActive}
searchFocusToken={mobileSearchFocusToken}
onFilterModeChange={setMemoFilterMode}
onSortModeChange={setMemoSortMode}
onLoadMoreMemos={handleLoadMoreMemos}
canCreateMemo={canCreateMemo}
isLoading={memosQuery.isLoading}
isRefreshing={memosQuery.isFetching}
isError={memosQuery.isError}
isCreating={createMemoMutation.isPending}
isMerging={mergeMutation.isPending}
isExporting={isExportingSelectedMemos}
isMoving={moveMemosMutation.isPending}
isPinning={pinMemosMutation.isPending}
isDeleting={deleteMemosMutation.isPending || deleteMemoMutation.isPending}
multiSelectKeyDown={multiSelectKeyDown}
onRetry={() => 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}
/>
<div
className="absolute inset-y-0 right-[-3px] z-20 hidden w-1.5 cursor-col-resize transition hover:bg-slate-300/70 focus-visible:bg-slate-400/80 focus-visible:outline-none lg:block"
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_MEMO_LIST_WIDTH_PX}
aria-valuemax={MAX_MEMO_LIST_WIDTH_PX}
aria-valuenow={memoListWidth}
aria-label={t("workspaceDialogs.resizeMemoList")}
tabIndex={0}
title={t("workspaceDialogs.resizeMemoListHint")}
onDoubleClick={handleResetMemoListWidth}
onKeyDown={handleMemoListResizeKeyDown}
onPointerDown={handleMemoListResizePointerDown}
/>
</section>
<section className={cn("edgeever-workspace-editor min-h-0 min-w-0 lg:block", visibleActivePane === "editor" ? "block" : "hidden", showMobileSettingsNav && "pb-[calc(4rem+env(safe-area-inset-bottom))] lg:pb-0")}>
{shouldRenderRightPane && (
<Suspense fallback={<PaneLoadingFallback label={rightPaneLoadingLabel} />}>
<m.div key={rightView} className="h-full min-h-0 min-w-0" {...paneEnterMotion}>
{rightView === "settings" ? (
<SettingsPane
onOpenExecutionCenter={handleOpenExecutionCenter}
onClose={handleCloseSettings}
onOpenTemplates={handleOpenTemplates}
onOpenAiPrompts={handleOpenAiPrompts}
imageCompressionEnabled={imageCompressionEnabled}
onImageCompressionChange={setImageCompressionEnabled}
shortcutSettings={shortcutSettings}
onShortcutSettingsChange={setShortcutSettings}
editorContentWidth={editorContentWidth}
onEditorContentWidthChange={setEditorContentWidth}
noteProse={noteProse}
onNoteProseChange={updateNoteProse}
onLogout={onLogout}
isLoggingOut={isLoggingOut}
authRequired={authRequired}
demoMode={demoMode}
isOwner={authRequired && user?.role === "owner"}
user={user}
refreshWorkspaceAfterImport={async () => {
await refreshWorkspaceFromServer("manual");
}}
/>
) : rightView === "plugins" ? (
<PluginMarketplacePane host={pluginHost} onClose={handleClosePluginMarketplace} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rightView === "assets" ? (
<AssetsPane onClose={handleCloseAssets} repository={repository} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rightView === "tags" ? (
<TagsPane onClose={handleCloseAssets} onSelectTag={handleSelectTag} repository={repository} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rightView === "templates" ? (
<TemplatesPane
canCreateMemo={canCreateMemo}
isCreating={createMemoMutation.isPending || createTemplateMutation.isPending}
onClose={handleCloseTemplates}
onCreateSavedTemplate={async (payload) => {
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" ? (
<AiPromptsPane key={localDataScope} onClose={handleCloseAiPrompts} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rightView === "execution-center" ? (
<ExecutionCenterPane currentDeviceId={scheduledTaskDeviceId} onClose={handleCloseExecutionCenter} />
) : rightView === "evernote-migration" ? (
<EvernoteImportGuidePane onClose={() => setRightView("settings")} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rendererRecoveryMode ? (
<EditorRecoveryPane />
) : memoSelectionModeActive ? (
<div className="flex h-full min-w-0 flex-col bg-transparent">
{memoSelectionActionBar}
</div>
) : (
<EditorPaneErrorBoundary
resetKey={selectedMemo?.id ?? selectedMemoId}
onBackToList={() => {
setRendererRecoveryMode(true);
setSelectedMemoId(null);
setActivePane("memos");
}}
>
{selectedMemo && selectedDiagram ? (
<DiagramEditorPane
memo={selectedMemo}
notebooks={notebooks}
repository={repository}
readOnly={memoView === "trash" || selectedMemo.isDeleted}
desktopFocusMode={desktopFocusModeActive}
onBackToList={() => {
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 ? (
<InfographicEditorPane
key={selectedMemo.id}
memo={selectedMemo}
notebooks={notebooks}
repository={repository}
readOnly={memoView === "trash" || selectedMemo.isDeleted}
desktopFocusMode={desktopFocusModeActive}
onBackToList={() => {
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 ? (
<TableEditorPane
key={selectedMemo.id}
memo={selectedMemo}
repository={repository}
readOnly={memoView === "trash" || selectedMemo.isDeleted}
onBackToList={() => {
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" }),
]);
}}
/>
) : (
<EditorPane
demoMode={demoMode}
onOpenExecutionCenter={handleOpenExecutionCenter}
memo={selectedMemo}
repository={repository}
pluginHost={pluginHost}
pluginNavigationRequest={pluginNavigationRequest}
onOpenAiPrompts={handleOpenAiPrompts}
companionAvailable={authRequired && Boolean(user) && !demoMode}
beforeCompanionApply={async () => {
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}
/>
)}
</EditorPaneErrorBoundary>
)}
</m.div>
</Suspense>
)}
</section>
</main>
</div>
<QuickMemoSwitcher
open={quickSwitcherOpen}
query={quickSwitcherQuery}
repository={repository}
onOpenChange={(open) => {
setQuickSwitcherOpen(open);
if (!open) {
setQuickSwitcherQuery("");
}
}}
onQueryChange={setQuickSwitcherQuery}
onOpenMemo={handleOpenQuickSwitcherMemo}
/>
{memoDeleteConfirmation && (
<MemoDeleteConfirmDialog
confirmation={memoDeleteConfirmation}
isDeleting={deleteMemosMutation.isPending || deleteMemoMutation.isPending}
onCancel={() => setMemoDeleteConfirmation(null)}
onConfirm={handleConfirmMemoDeletion}
/>
)}
{emptyTrashConfirmationOpen && (
<AppConfirmDialog
title={t("workspaceDialogs.emptyTrashTitle")}
description={t("workspaceDialogs.emptyTrashDescription")}
confirmLabel={t("workspaceDialogs.emptyTrashConfirm")}
closeOnBrowserBack={false}
isWorking={emptyTrashMutation.isPending}
tone="danger"
onCancel={() => setEmptyTrashConfirmationOpen(false)}
onConfirm={handleConfirmEmptyTrash}
/>
)}
{notebookNameDialog && (
<NotebookNameDialog
dialog={notebookNameDialog}
isSaving={createNotebookMutation.isPending || updateNotebookMutation.isPending}
onCancel={() => setNotebookNameDialog(null)}
onSubmit={handleSubmitNotebookName}
/>
)}
{notebookDeleteConfirmation && (
<AppConfirmDialog
title={t("workspaceDialogs.deleteNotebookTitle", { name: notebookDeleteConfirmation.name })}
description={t("workspaceDialogs.deleteNotebookDescription")}
confirmLabel={t("common.delete")}
closeOnBrowserBack={false}
isWorking={deleteNotebookMutation.isPending}
tone="danger"
onCancel={() => setNotebookDeleteConfirmation(null)}
onConfirm={() => {
deleteNotebookMutation.mutate(notebookDeleteConfirmation.id, {
onSuccess: () => setNotebookDeleteConfirmation(null),
});
}}
/>
)}
{appNoticeDialog && (
<AppConfirmDialog
title={appNoticeDialog.title}
description={appNoticeDialog.description}
confirmLabel={t("workspaceDialogs.ok")}
closeOnBrowserBack={false}
hideCancel
tone="neutral"
onCancel={() => setAppNoticeDialog(null)}
onConfirm={() => setAppNoticeDialog(null)}
/>
)}
{wechatImportFailureId && (
<AppConfirmDialog
title={t("memoList.importWeChatFailedTitle")}
description={t("memoList.importWeChatRetryHint")}
confirmLabel={t("memoList.importWeChatRetry")}
closeOnBrowserBack={false}
tone="neutral"
onCancel={() => 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 ? (
<AppConfirmDialog
title={t(PLUGIN_TRUST_WARNING_COPY.titleKey)}
description={t(PLUGIN_TRUST_WARNING_COPY.descriptionKey)}
confirmLabel={t(PLUGIN_TRUST_WARNING_COPY.confirmLabelKey)}
closeOnBrowserBack={false}
tone="neutral"
onCancel={() => setPendingCatalogTrustPluginIds([])}
onConfirm={() => {
const pluginIds = pendingCatalogTrustPluginIds;
acknowledgePluginTrustWarning();
setPendingCatalogTrustPluginIds([]);
void Promise.all(pluginIds.map((pluginId) => pluginHost.setEnabled(pluginId, true).catch(() => undefined)));
}}
/>
) : null}
{demoResetConfirmationOpen && (
<AppConfirmDialog
title={t("demo.resetTitle")}
description={t("demo.resetDescription")}
confirmLabel={t("demo.resetConfirm")}
cancelLabel={t("common.cancel")}
isWorking={resetDemoMutation.isPending}
tone="primary"
onCancel={() => setDemoResetConfirmationOpen(false)}
onConfirm={() => resetDemoMutation.mutate()}
/>
)}
<PluginPanelDialog
host={pluginHost}
panel={requestedPluginPanel?.panel ?? null}
options={requestedPluginPanel?.options}
onClose={() => setRequestedPluginPanel(null)}
/>
{(visibleActivePane !== "editor" || showMobileSettingsNav) && !memoSelectionModeActive && (
<MobileBottomNav
activeItem={mobileBottomNavActive}
canCreateMemo={canCreateMemo && memoView !== "trash"}
isCreating={createMemoMutation.isPending}
onCreateMemo={handleCreateMemo}
onHome={handleMobileHome}
onOpenSettings={handleOpenSettings}
/>
)}
{mobileNotebookPickerOpen && (
<MobileNotebookPicker
currentLabel={memoView === "trash" ? t("notebookPane.trash") : selectedTag ? `#${selectedTag}` : undefined}
notebooks={notebooks}
selectedNotebookId={selectedNotebookId}
onClose={() => setMobileNotebookPickerOpen(false)}
onSelectAll={handleSelectAllMemos}
onSelect={handleSelectNotebook}
/>
)}
</div>
</WorkspaceMotionProvider>
);
};
export default WorkspaceApp;