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