3222 lines
114 KiB
TypeScript
3222 lines
114 KiB
TypeScript
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 * as m from "motion/react-m";
|
|
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 {
|
|
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 { createExcerpt, docToText, getNotebookDescendantIds, resolveMemoContentDoc, 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,
|
|
NotebookNode,
|
|
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,
|
|
isTextEntryTarget,
|
|
getShortcutActionForEvent,
|
|
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 { shouldAcceptRemoteMemoDetail } from "@/lib/memo-detail-freshness";
|
|
import {
|
|
createLocalDataScope,
|
|
putLocalMemo,
|
|
putLocalNotebook,
|
|
} from "@/lib/local-mirror";
|
|
import { createRepository } from "@/lib/repository";
|
|
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 { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences";
|
|
import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection";
|
|
import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync";
|
|
import { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
|
|
|
|
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
|
|
const PULL_TO_REFRESH_TRIGGER_PX = 72;
|
|
const PULL_TO_REFRESH_MAX_PX = 96;
|
|
|
|
type ViewTransitionDocument = Document & {
|
|
startViewTransition?: (update: () => void) => unknown;
|
|
};
|
|
|
|
const runWorkspaceViewTransition = (update: () => void) => {
|
|
const viewTransitionDocument = document as ViewTransitionDocument;
|
|
|
|
if (
|
|
!viewTransitionDocument.startViewTransition ||
|
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
) {
|
|
update();
|
|
return;
|
|
}
|
|
|
|
viewTransitionDocument.startViewTransition(update);
|
|
};
|
|
|
|
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 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 PaneLoadingFallback = ({ label = "Loading" }: { label?: string }) => (
|
|
<div className="flex h-full min-h-0 items-center justify-center bg-white text-sm font-medium text-slate-400" role="status">
|
|
{label}
|
|
</div>
|
|
);
|
|
|
|
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(resolveMemoContentDoc(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<string>) => {
|
|
const summaries = new Map<string, MemoSummary>();
|
|
|
|
for (const [, current] of queryClient.getQueriesData<MemoListQueryData>({ 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<string>) => {
|
|
queryClient.setQueriesData<MemoListQueryData>({ 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<MemoListQueryData>({ 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<string, number>();
|
|
|
|
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<ListNotebooksQueryData>(["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<string>, 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;
|
|
}) => (
|
|
<button
|
|
className={cn(
|
|
"flex h-mobile-touch flex-col items-center justify-center gap-0.5 rounded-md text-xs font-medium transition-all duration-200",
|
|
active ? "text-slate-950" : "text-slate-500 hover:bg-slate-100 hover:text-slate-950"
|
|
)}
|
|
type="button"
|
|
aria-current={active ? "page" : undefined}
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
>
|
|
{icon}
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
|
|
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 (
|
|
<nav
|
|
className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 px-5 pb-[max(0.125rem,env(safe-area-inset-bottom))] pt-0 shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur lg:hidden"
|
|
aria-label={t("nav.mobileMain")}
|
|
>
|
|
<div className="relative grid h-mobile-bottom-nav grid-cols-3 items-center">
|
|
<MobileBottomNavButton active={activeItem === "home"} icon={<Home className="h-5 w-5" />} label={t("nav.home")} onClick={onHome} />
|
|
<div aria-hidden="true" />
|
|
<MobileBottomNavButton active={activeItem === "settings"} icon={<UserRound className="h-5 w-5" />} label={t("nav.mine")} onClick={onOpenSettings} />
|
|
<button
|
|
className="absolute left-1/2 top-[-0.8rem] flex h-mobile-fab w-mobile-fab -translate-x-1/2 items-center justify-center rounded-full border-[5px] border-white bg-emerald-500 text-white shadow-[0_12px_26px_rgb(var(--brand-green-rgb)/0.32)] transition hover:bg-emerald-600 disabled:cursor-not-allowed disabled:bg-emerald-200 disabled:opacity-70 disabled:hover:bg-emerald-200"
|
|
type="button"
|
|
title={createMemoLabel}
|
|
aria-label={createMemoLabel}
|
|
disabled={!canCreateMemo || isCreating}
|
|
onClick={onCreateMemo}
|
|
>
|
|
<Plus className="h-7 w-7" />
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
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<HTMLDivElement | null>(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<Set<string>>(() => 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<HTMLElement>(`[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 (
|
|
<Drawer open={true} onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
<DrawerContent className="inset-x-0 max-h-[82dvh] overflow-hidden border-x-0 border-b-0 pb-[env(safe-area-inset-bottom)] lg:hidden">
|
|
<header className="flex h-14 items-center justify-between border-b border-slate-200 px-4">
|
|
<DrawerHeader className="min-w-0 p-0">
|
|
<DrawerTitle className="text-base">{t("mobileNotebookPicker.title")}</DrawerTitle>
|
|
<DrawerDescription className="truncate">{t("mobileNotebookPicker.current", { name: selectedNotebookName })}</DrawerDescription>
|
|
</DrawerHeader>
|
|
<Button size="icon" variant="ghost" title={t("common.close")} aria-label={t("common.close")} onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</header>
|
|
<div className="border-b border-slate-100 px-4 py-2">
|
|
<div className="flex h-9 items-center gap-2 rounded-md bg-slate-100 px-3 text-sm text-slate-500">
|
|
<Search className="h-4 w-4" />
|
|
<input
|
|
className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400"
|
|
value={notebookSearch}
|
|
placeholder={t("mobileNotebookPicker.searchPlaceholder")}
|
|
aria-label={t("mobileNotebookPicker.searchPlaceholder")}
|
|
onChange={(event) => setNotebookSearch(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape" && notebookSearch) {
|
|
event.preventDefault();
|
|
setNotebookSearch("");
|
|
}
|
|
}}
|
|
/>
|
|
{notebookSearch && (
|
|
<button
|
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-white hover:text-slate-700"
|
|
type="button"
|
|
title={t("mobileNotebookPicker.clearSearch")}
|
|
aria-label={t("mobileNotebookPicker.clearSearch")}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => setNotebookSearch("")}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div ref={listRef} className="max-h-[calc(82dvh_-_8.25rem_-_env(safe-area-inset-bottom))] overflow-y-auto p-2">
|
|
<button
|
|
className={cn(
|
|
"mb-1 flex h-12 w-full items-center gap-3 rounded-md px-3 text-left text-sm transition",
|
|
allSelected ? "bg-slate-100 font-semibold text-slate-950" : "text-slate-800 hover:bg-slate-50"
|
|
)}
|
|
type="button"
|
|
data-mobile-notebook-id="__all__"
|
|
aria-label={allSelected ? t("mobileNotebookPicker.currentAll") : t("mobileNotebookPicker.switchAll")}
|
|
aria-current={allSelected ? "page" : undefined}
|
|
onClick={onSelectAll}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-base">{t("mobileNotebookPicker.allMemos")}</span>
|
|
</button>
|
|
{filteredTree.length > 0 ? (
|
|
<>
|
|
<div className="mb-1 flex h-8 items-center justify-between px-3 text-xs font-semibold text-slate-400">
|
|
<span>{searchActive ? t("mobileNotebookPicker.matchedNotebooks") : t("mobileNotebookPicker.notebooks")}</span>
|
|
{!searchActive && expandableNotebookIds.length > 0 && (
|
|
<button
|
|
className="rounded-md px-2 py-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
|
|
type="button"
|
|
aria-label={allNotebookBranchesExpanded ? t("mobileNotebookPicker.collapseAllAria") : t("mobileNotebookPicker.expandAllAria")}
|
|
aria-pressed={allNotebookBranchesExpanded}
|
|
onClick={handleToggleAllNotebookBranches}
|
|
>
|
|
{allNotebookBranchesExpanded ? t("mobileNotebookPicker.collapseAll") : t("mobileNotebookPicker.expandAll")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{filteredTree.map((node) => (
|
|
<MobileNotebookPickerItem
|
|
key={node.id}
|
|
node={node}
|
|
depth={0}
|
|
expandedNotebookIds={expandedNotebookIds}
|
|
searchActive={searchActive}
|
|
selectedNotebookId={selectedNotebookId}
|
|
onSelect={onSelect}
|
|
onToggleExpanded={handleToggleNotebookExpanded}
|
|
/>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="px-3 py-8 text-center">
|
|
<div className="text-sm font-medium text-slate-700">
|
|
{searchQuery ? t("mobileNotebookPicker.noSearchResult", { query: searchQuery }) : t("mobileNotebookPicker.noNotebook")}
|
|
</div>
|
|
{searchQuery && (
|
|
<button
|
|
className="mt-3 text-sm font-semibold text-slate-600"
|
|
type="button"
|
|
onClick={() => setNotebookSearch("")}
|
|
>
|
|
{t("mobileNotebookPicker.showAll")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
);
|
|
};
|
|
|
|
const MobileNotebookPickerItem = ({
|
|
node,
|
|
depth,
|
|
expandedNotebookIds,
|
|
searchActive,
|
|
selectedNotebookId,
|
|
onSelect,
|
|
onToggleExpanded,
|
|
}: {
|
|
node: NotebookNode;
|
|
depth: number;
|
|
expandedNotebookIds: Set<string>;
|
|
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 (
|
|
<div>
|
|
<div
|
|
data-mobile-notebook-id={node.id}
|
|
className={cn(
|
|
"flex h-12 w-full items-center gap-3 rounded-md px-3 text-left text-sm transition",
|
|
selected
|
|
? "bg-slate-100 font-semibold text-slate-950"
|
|
: hasSelectedDescendant
|
|
? "bg-slate-50 text-slate-900 hover:bg-slate-100"
|
|
: "text-slate-800 hover:bg-slate-50"
|
|
)}
|
|
style={{ paddingLeft: `${12 + depth * 18}px` }}
|
|
>
|
|
{hasChildren ? (
|
|
<button
|
|
className={cn(
|
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 transition",
|
|
searchActive ? "cursor-default" : "hover:bg-slate-100 hover:text-slate-700"
|
|
)}
|
|
type="button"
|
|
disabled={searchActive}
|
|
aria-label={expanded ? t("mobileNotebookPicker.collapse", { name: node.name }) : t("mobileNotebookPicker.expand", { name: node.name })}
|
|
aria-expanded={expanded}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleExpanded(node.id);
|
|
}}
|
|
>
|
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
) : (
|
|
<span className="h-8 w-8 shrink-0" aria-hidden="true" />
|
|
)}
|
|
<button
|
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
|
type="button"
|
|
aria-label={selected ? t("mobileNotebookPicker.currentNotebook", { name: node.name }) : t("mobileNotebookPicker.switchToNotebook", { name: node.name })}
|
|
aria-current={selected ? "page" : undefined}
|
|
onClick={() => onSelect(node.id)}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-base">{node.name}</span>
|
|
</button>
|
|
</div>
|
|
{hasChildren && expanded ? (
|
|
<div className="mt-1 border-l border-slate-100 pl-1">
|
|
{node.children.map((child) => (
|
|
<MobileNotebookPickerItem
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
expandedNotebookIds={expandedNotebookIds}
|
|
searchActive={searchActive}
|
|
selectedNotebookId={selectedNotebookId}
|
|
onSelect={onSelect}
|
|
onToggleExpanded={onToggleExpanded}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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,
|
|
} = useWorkspaceRoute();
|
|
const localDataScope = useMemo(
|
|
() => createLocalDataScope(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 isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId);
|
|
const isTrashRoute = route.isTrash;
|
|
const [activePane, setActivePane] = useState<Pane>(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
|
|
const [memoView, setMemoView] = useState<MemoView>(() => (isTrashRoute ? "trash" : "notebook"));
|
|
const {
|
|
beginMemoSelection,
|
|
clearMemoSelection,
|
|
memoSelectionMode,
|
|
replaceMemoSelection,
|
|
selectedMemoId,
|
|
selectedMemoIdRef,
|
|
selectedMemoIds,
|
|
selectedNotebookId,
|
|
selectionMoveTargetNotebookId,
|
|
setMemoSelectionMode,
|
|
setSelectedMemoId,
|
|
setSelectedMemoIds,
|
|
setSelectedNotebookId,
|
|
setSelectionMoveTargetNotebookId,
|
|
} = useWorkspaceSelection();
|
|
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 creatingMemoSelectionRef = 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 [demoResetConfirmationOpen, setDemoResetConfirmationOpen] = useState(false);
|
|
const pluginHost = useMemo(() => new EdgeEverPluginHost({
|
|
repository,
|
|
scope: localDataScope,
|
|
onNotice: (message) => setAppNoticeDialog({ title: t("plugins.noticeTitle"), description: message }),
|
|
onWorkspaceChanged: async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["memos"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["memo"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["notebooks"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["tags"] }),
|
|
]);
|
|
},
|
|
}), [localDataScope, queryClient, repository, t]);
|
|
|
|
useEffect(() => {
|
|
void pluginHost.activateEnabled();
|
|
return () => {
|
|
void pluginHost.dispose();
|
|
};
|
|
}, [pluginHost]);
|
|
|
|
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 {
|
|
desktopFocusMode,
|
|
editorContentAlignment,
|
|
imageCompressionEnabled,
|
|
memoListWidth,
|
|
resetMemoListWidth,
|
|
setDesktopFocusMode,
|
|
setEditorContentAlignment,
|
|
setImageCompressionEnabled,
|
|
setMemoListWidth,
|
|
setShortcutSettings,
|
|
setSyncIntervalMs,
|
|
shortcutSettings,
|
|
syncIntervalMs,
|
|
} = useWorkspacePreferences();
|
|
const [rightView, setRightView] = useState<"editor" | "settings" | "plugins" | "assets" | "tags" | "templates" | "ai-prompts" | "evernote-migration">(() =>
|
|
isInitialSettingsRoute
|
|
? "settings"
|
|
: isInitialPluginsRoute
|
|
? "plugins"
|
|
: isInitialTemplatesRoute
|
|
? "templates"
|
|
: isInitialAiPromptsRoute
|
|
? "ai-prompts"
|
|
: "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 [noteSearchFocusToken, setNoteSearchFocusToken] = useState(0);
|
|
const [noteReplaceFocusToken, setNoteReplaceFocusToken] = useState(0);
|
|
const [noteSaveAndSyncToken, setNoteSaveAndSyncToken] = useState(0);
|
|
const [noteEditorModeToggleToken, setNoteEditorModeToggleToken] = 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 ||
|
|
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 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 (skipNextHomeRouteSyncRef.current) {
|
|
skipNextHomeRouteSyncRef.current = false;
|
|
return;
|
|
}
|
|
|
|
setMemoView(isTrashRoute ? "trash" : "notebook");
|
|
setRightView("editor");
|
|
setMobileBottomNavActive("home");
|
|
}, [isTrashRoute, route.isSettings, route.isPlugins, route.isTemplates, route.isAiPrompts]);
|
|
|
|
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({
|
|
pendingSyncCount: syncSummary.total,
|
|
backgroundRefreshKey: localDataScope,
|
|
refreshWorkspace: refreshWorkspaceFromServer,
|
|
runQueuedSync,
|
|
setOnline: setIsOnline,
|
|
syncIntervalMs,
|
|
});
|
|
|
|
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 = selectedMemoId ?? memos[0]?.id ?? null;
|
|
|
|
useEffect(() => {
|
|
const selectedMemoInList = selectedMemoId ? memos.some((memo) => memo.id === selectedMemoId) : false;
|
|
|
|
if (creatingMemoSelectionRef.current || pendingCreatedMemoIdRef.current) {
|
|
return;
|
|
}
|
|
|
|
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: detailMemoId ? memoDetailQueryKey(detailMemoId, memoView) : ["memo", detailMemoId, memoView],
|
|
queryFn: () => repository.getMemo(detailMemoId as string, memoView === "trash"),
|
|
enabled: Boolean(detailMemoId),
|
|
});
|
|
|
|
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"] });
|
|
},
|
|
});
|
|
|
|
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);
|
|
await putLocalMemo(localDataScope, data.memo);
|
|
return data;
|
|
},
|
|
onSuccess: (data) => {
|
|
const targetNotebookId = data.memo.notebookId;
|
|
|
|
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, 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");
|
|
pendingCreatedMemoIdRef.current = data.memo.id;
|
|
setCreatedMemoEditId(data.memo.id);
|
|
setSelectedMemoId(data.memo.id);
|
|
setActivePane("editor");
|
|
|
|
if (!isDesktopViewport()) {
|
|
openStandaloneMobileEditor(data.memo.id);
|
|
}
|
|
},
|
|
onError: () => {
|
|
clearPendingCreatedMemo();
|
|
setCreatedMemoEditId(null);
|
|
},
|
|
});
|
|
|
|
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);
|
|
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 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 = () => {
|
|
const targetNotebookId = createMemoNotebookId;
|
|
|
|
if (!targetNotebookId || memoView === "trash") {
|
|
return;
|
|
}
|
|
|
|
setTemplatesOpen(false);
|
|
setMobileBottomNavActive("home");
|
|
creatingMemoSelectionRef.current = true;
|
|
createMemoMutation.mutate({
|
|
notebookId: targetNotebookId,
|
|
title: "",
|
|
contentMarkdown: "",
|
|
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(() => {
|
|
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 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 ? (
|
|
<MemoSelectionActionBar
|
|
deleteTitle={selectionDeleteTitle}
|
|
isDeleting={deleteMemosMutation.isPending || deleteMemoMutation.isPending}
|
|
isMerging={mergeMutation.isPending}
|
|
isMoving={moveMemosMutation.isPending}
|
|
isPinning={pinMemosMutation.isPending}
|
|
isTrashView={memoView === "trash"}
|
|
mergeTitle={selectionMergeTitle}
|
|
moveNotebookOptions={selectionMoveNotebookOptions}
|
|
moveTargetNotebookId={selectionMoveTargetNotebookId}
|
|
moveTitle={selectionMoveTitle}
|
|
onClearSelection={clearMemoSelection}
|
|
onDelete={handleDeleteSelectedMemos}
|
|
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 = () => {
|
|
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 = 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 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") 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 === "plugins") {
|
|
handleClosePluginMarketplace();
|
|
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"));
|
|
|
|
const transientLayerOpen = Boolean(
|
|
appNoticeDialog ||
|
|
rightView !== "editor" ||
|
|
memoDeleteConfirmation ||
|
|
emptyTrashConfirmationOpen ||
|
|
mobileNotebookPickerOpen ||
|
|
notebookDeleteConfirmation ||
|
|
notebookNameDialog ||
|
|
templatesOpen
|
|
);
|
|
|
|
if (action === "saveAndSync" || action === "toggleEditorMode") {
|
|
// 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 === "saveAndSync") {
|
|
setNoteSaveAndSyncToken((value) => value + 1);
|
|
} else {
|
|
setNoteEditorModeToggleToken((value) => value + 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((action === "focusSearch" || action === "focusReplace") && isTextEntryTarget(event.target) && !isEditorTextTarget) {
|
|
return;
|
|
}
|
|
|
|
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<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 === "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 (
|
|
<WorkspaceMotionProvider>
|
|
<div className="flex h-[100dvh] overflow-hidden bg-slate-50 text-slate-950">
|
|
{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-white/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"
|
|
)}
|
|
style={{ "--memo-list-width": `${memoListWidth}px` } as CSSProperties}
|
|
>
|
|
<aside
|
|
className={cn(
|
|
"min-h-0 border-r border-slate-200 bg-white/75 backdrop-blur-lg",
|
|
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}
|
|
onOpenAiPrompts={handleOpenAiPrompts}
|
|
onOpenPluginMarketplace={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}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</aside>
|
|
|
|
<section
|
|
className={cn(
|
|
"relative min-w-0 overflow-hidden border-r border-slate-200 bg-slate-50",
|
|
desktopFocusModeActive
|
|
? "hidden"
|
|
: rightView === "editor"
|
|
? (visibleActivePane === "memos" ? "block lg:block lg:bg-white/75 lg:backdrop-blur-lg" : "hidden lg:block lg:bg-white/75 lg:backdrop-blur-lg")
|
|
: (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}
|
|
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}
|
|
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");
|
|
}}
|
|
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,
|
|
});
|
|
}}
|
|
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}
|
|
/>
|
|
<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("min-h-0 min-w-0 bg-white lg:block", visibleActivePane === "editor" ? "block" : "hidden")}>
|
|
{shouldRenderRightPane && (
|
|
<Suspense fallback={<PaneLoadingFallback label={rightPaneLoadingLabel} />}>
|
|
<m.div key={rightView} className="h-full min-h-0 min-w-0" {...paneEnterMotion}>
|
|
{rightView === "settings" ? (
|
|
<SettingsPane
|
|
onClose={handleCloseSettings}
|
|
onOpenTemplates={handleOpenTemplates}
|
|
onOpenAiPrompts={handleOpenAiPrompts}
|
|
imageCompressionEnabled={imageCompressionEnabled}
|
|
onImageCompressionChange={setImageCompressionEnabled}
|
|
syncIntervalMs={syncIntervalMs}
|
|
onSyncIntervalChange={setSyncIntervalMs}
|
|
shortcutSettings={shortcutSettings}
|
|
onShortcutSettingsChange={setShortcutSettings}
|
|
editorContentAlignment={editorContentAlignment}
|
|
onEditorContentAlignmentChange={setEditorContentAlignment}
|
|
onLogout={onLogout}
|
|
isLoggingOut={isLoggingOut}
|
|
authRequired={authRequired}
|
|
demoMode={demoMode}
|
|
isOwner={authRequired && user?.role === "owner"}
|
|
user={user}
|
|
refreshWorkspaceAfterImport={async () => {
|
|
await refreshWorkspaceFromServer("manual");
|
|
}}
|
|
pluginHost={pluginHost}
|
|
onOpenPluginMarketplace={handleOpenPluginManager}
|
|
/>
|
|
) : rightView === "plugins" ? (
|
|
<PluginMarketplacePane host={pluginHost} onClose={handleClosePluginMarketplace} />
|
|
) : rightView === "assets" ? (
|
|
<AssetsPane onClose={handleCloseAssets} repository={repository} />
|
|
) : rightView === "tags" ? (
|
|
<TagsPane onClose={handleCloseAssets} onSelectTag={handleSelectTag} repository={repository} />
|
|
) : 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 });
|
|
}}
|
|
/>
|
|
) : rightView === "ai-prompts" ? (
|
|
<AiPromptsPane onClose={handleCloseAiPrompts} />
|
|
) : rightView === "evernote-migration" ? (
|
|
<EvernoteImportGuidePane onClose={() => setRightView("settings")} />
|
|
) : (
|
|
<EditorPane
|
|
memo={selectedMemo}
|
|
repository={repository}
|
|
pluginHost={pluginHost}
|
|
onOpenPluginManager={handleOpenPluginManager}
|
|
onOpenAiPrompts={handleOpenAiPrompts}
|
|
desktopFocusMode={desktopFocusModeActive}
|
|
onToggleDesktopFocusMode={toggleDesktopFocusMode}
|
|
editorContentAlignment={editorContentAlignment}
|
|
mobileDefaultEditMemoId={createdMemoEditId}
|
|
isTrashView={memoView === "trash"}
|
|
notebooks={notebooks}
|
|
isLoading={memoQuery.isLoading}
|
|
contentSearchQuery={search}
|
|
searchFocusToken={noteSearchFocusToken}
|
|
replaceFocusToken={noteReplaceFocusToken}
|
|
saveAndSyncToken={noteSaveAndSyncToken}
|
|
editorModeToggleToken={noteEditorModeToggleToken}
|
|
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}
|
|
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,
|
|
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}
|
|
/>
|
|
)}
|
|
</m.div>
|
|
</Suspense>
|
|
)}
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
{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)}
|
|
/>
|
|
)}
|
|
{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()}
|
|
/>
|
|
)}
|
|
{visibleActivePane !== "editor" && !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;
|