chore: add global quick-create entry

Use the authenticated navigation logo to open the existing focus-mode editor and preserve the draft cursor across reopenings.
This commit is contained in:
boojack 2026-07-29 08:57:04 +08:00
parent 9328413797
commit 6f1ff135fb
17 changed files with 255 additions and 15 deletions

View file

@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Outlet, ScrollRestoration } from "react-router-dom";
import { GlobalMemoEditorProvider } from "./contexts/GlobalMemoEditorContext";
import { useInstance } from "./contexts/InstanceContext";
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
import useNavigateTo from "./hooks/useNavigateTo";
@ -61,7 +62,9 @@ const App = () => {
return (
<>
<MemoFilterProvider>
<Outlet />
<GlobalMemoEditorProvider>
<Outlet />
</GlobalMemoEditorProvider>
</MemoFilterProvider>
<ScrollRestoration />
</>

View file

@ -30,6 +30,14 @@ export function createController(view: EditorView, formatting: FormattingControl
view.dispatch({ changes: { from, to, insert }, selection: { anchor: caret }, scrollIntoView: true });
view.focus();
},
getCursor: () => view.state.selection.main.head,
setCursor: (position: number) => {
const cursor = Math.min(Math.max(position, 0), view.state.doc.length);
view.dispatch({
selection: EditorSelection.cursor(cursor),
scrollIntoView: true,
});
},
scrollToCursor: () => view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head) }),
selectAll: () => view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) }),
formatting,

View file

@ -49,11 +49,25 @@ export const useMemoInit = ({
}
}
if (autoFocus) {
setTimeout(() => editorRef.current?.focus(), 100);
const cachedCursor = cacheService.loadCursor(key);
let restoreCursorTimer: ReturnType<typeof setTimeout> | undefined;
if (autoFocus || cachedCursor !== undefined) {
restoreCursorTimer = setTimeout(() => {
if (cachedCursor !== undefined) {
editorRef.current?.setCursor(cachedCursor);
}
if (autoFocus) {
editorRef.current?.focus();
}
}, 100);
}
setIsInitialized(true);
return () => {
if (restoreCursorTimer) {
clearTimeout(restoreCursorTimer);
}
};
}, [memo, cacheKey, username, autoFocus, defaultVisibility, defaultCreateTime, actions, dispatch, editorRef]);
return { isInitialized };

View file

@ -11,7 +11,7 @@ import { convertVisibilityFromString } from "@/utils/memo";
import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components";
import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants";
import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit, useMemoSave } from "./hooks";
import { errorService, transcriptionService } from "./services";
import { cacheService, errorService, transcriptionService } from "./services";
import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
import { EditorToolbar, FormattingToolbar } from "./Toolbar";
import type { MemoEditorProps } from "./types";
@ -19,7 +19,7 @@ import type { LocalFile } from "./types/attachment";
import type { EditorController } from "./types/editorController";
const MemoEditor = (props: MemoEditorProps) => (
<EditorProvider>
<EditorProvider initialFocusMode={props.initialFocusMode}>
<MemoEditorImpl {...props} />
</EditorProvider>
);
@ -30,6 +30,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
memo,
parentMemoName,
autoFocus,
onFocusModeExit,
placeholder,
defaultCreateTime,
onConfirm,
@ -62,6 +63,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Get default visibility from user settings
const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined;
const editorCacheKey = cacheService.key(currentUser?.name ?? "", cacheKey);
const { isInitialized } = useMemoInit({
editorRef,
@ -180,10 +182,27 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
}
}, [isAudioRecorderOpen, audioRecorder.error, audioRecorder.status, t]);
const rememberCursor = useCallback(() => {
const cursor = editorRef.current?.getCursor();
if (cursor !== undefined) {
cacheService.saveCursor(editorCacheKey, cursor);
}
}, [editorCacheKey]);
const handleToggleFocusMode = () => {
if (isFocusMode && onFocusModeExit) {
rememberCursor();
onFocusModeExit();
return;
}
dispatch(actions.toggleFocusMode());
};
const handleCancel = useCallback(() => {
rememberCursor();
onCancel?.();
}, [onCancel, rememberCursor]);
const handleToggleFormattingToolbar = useCallback(() => {
setFormattingToolbarVisible((visible) => !visible);
}, [setFormattingToolbarVisible]);
@ -226,7 +245,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
defaultCreateTime,
discardDraft,
onConfirm,
onCancel,
onCancel: onCancel ? handleCancel : undefined,
});
return (
@ -285,7 +304,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
<EditorMetadata memoName={memoName} />
<EditorToolbar
onSave={handleSave}
onCancel={onCancel}
onCancel={onCancel ? handleCancel : undefined}
memoName={memoName}
onAudioRecorderClick={handleAudioRecorderClick}
isFormattingToolbarVisible={isFormattingToolbarVisible}

View file

@ -1,6 +1,7 @@
export const CACHE_DEBOUNCE_DELAY = 500;
const pendingSaves = new Map<string, ReturnType<typeof window.setTimeout>>();
const cursors = new Map<string, number>();
const STRUCTURED_CACHE_ENTRY_KIND = "memos.editor-cache";
const STRUCTURED_CACHE_ENTRY_VERSION = 1;
@ -64,6 +65,14 @@ export const cacheService = {
return raw ? deserializeContent(raw) : "";
},
saveCursor(key: string, cursor: number): void {
cursors.set(key, cursor);
},
loadCursor(key: string): number | undefined {
return cursors.get(key);
},
clear(key: string): void {
const pendingSave = pendingSaves.get(key);
if (pendingSave) {
@ -72,6 +81,7 @@ export const cacheService = {
}
localStorage.removeItem(key);
cursors.delete(key);
},
clearAll(): void {
@ -79,5 +89,6 @@ export const cacheService = {
window.clearTimeout(timeoutId);
}
pendingSaves.clear();
cursors.clear();
},
};

View file

@ -76,13 +76,14 @@ export function useEditorSelector<T>(selector: (state: EditorState) => T): T {
interface EditorProviderProps extends PropsWithChildren {
initialEditorState?: EditorState;
initialFocusMode?: boolean;
}
export const EditorProvider: FC<EditorProviderProps> = ({ children, initialEditorState }) => {
export const EditorProvider: FC<EditorProviderProps> = ({ children, initialEditorState, initialFocusMode }) => {
// Created once; the store instance is stable across renders.
const storeRef = useRef<EditorStore | null>(null);
if (!storeRef.current) {
storeRef.current = createEditorStore(initialEditorState ?? createInitialState());
storeRef.current = createEditorStore(initialEditorState ?? createInitialState(initialFocusMode));
}
return <EditorStoreContext.Provider value={storeRef.current}>{children}</EditorStoreContext.Provider>;
};

View file

@ -70,9 +70,9 @@ const defaultState: EditorState = {
};
/** Fresh initial state for a mounting editor. */
export function createInitialState(): EditorState {
export function createInitialState(initialFocusMode = false): EditorState {
return {
...defaultState,
ui: { ...defaultState.ui },
ui: { ...defaultState.ui, isFocusMode: initialFocusMode },
};
}

View file

@ -9,6 +9,10 @@ export interface MemoEditorProps {
memo?: Memo;
parentMemoName?: string;
autoFocus?: boolean;
/** Opens this editor instance directly in the existing focus-mode presentation. */
initialFocusMode?: boolean;
/** Closes an externally mounted editor when the user exits focus mode. */
onFocusModeExit?: () => void;
/**
* Default `createTime` for a *new* memo (create mode only). When set, the
* editor seeds both `createTime` and `updateTime` to this value and renders

View file

@ -16,6 +16,8 @@ export interface EditorController {
setMarkdown(markdown: string): void;
/** Insert markdown at the cursor as its own block. */
insertMarkdown(markdown: string): void;
getCursor(): number;
setCursor(position: number): void;
scrollToCursor(): void;
/** Select the entire document (used by tests and select-all flows). */
selectAll(): void;

View file

@ -1,6 +1,7 @@
import { BellIcon, EarthIcon, InfoIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useNotifications } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
@ -26,6 +27,7 @@ const Navigation = (props: Props) => {
const { collapsed, className } = props;
const t = useTranslate();
const currentUser = useCurrentUser();
const { openEditor } = useGlobalMemoEditor();
const { data: notifications = [] } = useNotifications();
const homeNavLink: NavLinkItem = {
@ -83,9 +85,20 @@ const Navigation = (props: Props) => {
return (
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4", className)}>
<div className="w-full px-1 py-1 flex flex-col justify-start items-start space-y-2 overflow-auto overflow-x-hidden shrink">
<NavLink className="mb-3 cursor-default" to={currentUser ? Routes.HOME : Routes.EXPLORE}>
<MemosLogo collapsed={collapsed} />
</NavLink>
{currentUser ? (
<button
type="button"
className="mb-3 cursor-default"
aria-label={`${t("common.create")} ${t("common.memos")}`}
onClick={openEditor}
>
<MemosLogo collapsed={collapsed} />
</button>
) : (
<NavLink className="mb-3 cursor-default" to={Routes.EXPLORE}>
<MemosLogo collapsed={collapsed} />
</NavLink>
)}
<TooltipProvider>
{primaryNavLinks.map((navLink) => (
<NavLink

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
import { useInstance } from "@/contexts/InstanceContext";
import Navigation from "./Navigation";
import UserAvatar from "./UserAvatar";
@ -9,13 +10,14 @@ import UserAvatar from "./UserAvatar";
const NavigationDrawer = () => {
const location = useLocation();
const [open, setOpen] = useState(false);
const { isOpen: isGlobalMemoEditorOpen } = useGlobalMemoEditor();
const { generalSetting } = useInstance();
const title = generalSetting.customProfile?.title || "Memos";
const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp";
useEffect(() => {
setOpen(false);
}, [location.key]);
}, [location.key, isGlobalMemoEditorOpen]);
return (
<Sheet open={open} onOpenChange={setOpen}>

View file

@ -0,0 +1,50 @@
import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { useTranslate } from "@/utils/i18n";
interface GlobalMemoEditorContextValue {
isOpen: boolean;
openEditor: () => void;
}
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue>({
isOpen: false,
openEditor: () => {},
});
export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) {
const t = useTranslate();
const [isOpen, setIsOpen] = useState(false);
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
const openEditor = useCallback(() => {
setIsOpen(true);
void loadMemoEditor()
.then(({ default: MemoEditor }) => setEditorComponent(() => MemoEditor))
.catch(() => setIsOpen(false));
}, []);
const closeEditor = useCallback(() => setIsOpen(false), []);
const value = useMemo(() => ({ isOpen, openEditor }), [isOpen, openEditor]);
return (
<GlobalMemoEditorContext.Provider value={value}>
{children}
{isOpen && EditorComponent && (
<EditorComponent
autoFocus
initialFocusMode
cacheKey="global-memo-editor"
placeholder={t("editor.any-thoughts")}
onConfirm={closeEditor}
onCancel={closeEditor}
onFocusModeExit={closeEditor}
/>
)}
</GlobalMemoEditorContext.Provider>
);
}
export function useGlobalMemoEditor() {
return useContext(GlobalMemoEditorContext);
}

View file

@ -29,4 +29,15 @@ describe("source editor controller", () => {
c.insertMarkdown("beta");
expect(c.getMarkdown()).toBe("alpha\n\nbeta");
});
it("captures and restores the cursor", () => {
const v = view("alpha beta");
const c = createController(v, {} as never);
v.dispatch({ selection: { anchor: 7 } });
expect(c.getCursor()).toBe(7);
c.setCursor(99);
expect(c.getCursor()).toBe(10);
});
});

View file

@ -27,6 +27,8 @@ function makeController(opts: { active?: Partial<ActiveFormatState>; getSelected
getMarkdown: () => "",
setMarkdown: () => {},
insertMarkdown: vi.fn(),
getCursor: () => 0,
setCursor: vi.fn(),
scrollToCursor: () => {},
selectAll: () => {},
formatting: {

View file

@ -0,0 +1,44 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { GlobalMemoEditorProvider, useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
vi.mock("@/components/MemoEditor/loader", () => ({
loadMemoEditor: vi.fn(async () => ({
default: ({ initialFocusMode, onFocusModeExit }: { initialFocusMode?: boolean; onFocusModeExit?: () => void }) => (
<div data-testid="global-editor" data-focus-mode={initialFocusMode}>
<button type="button" onClick={onFocusModeExit}>
Exit focus mode
</button>
</div>
),
})),
}));
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
function Trigger() {
const { openEditor } = useGlobalMemoEditor();
return (
<button type="button" onClick={openEditor}>
Open editor
</button>
);
}
describe("GlobalMemoEditorProvider", () => {
it("opens the shared editor in focus mode and closes when focus mode exits", async () => {
render(
<GlobalMemoEditorProvider>
<Trigger />
</GlobalMemoEditorProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
const editor = await screen.findByTestId("global-editor");
expect(editor).toHaveAttribute("data-focus-mode", "true");
fireEvent.click(screen.getByRole("button", { name: "Exit focus mode" }));
expect(screen.queryByTestId("global-editor")).not.toBeInTheDocument();
});
});

View file

@ -54,4 +54,14 @@ describe("memo editor cache", () => {
expect(cacheService.load(key)).toBe(jsonDraft);
});
it("keeps the cursor for the next editor mount", () => {
const key = cacheService.key("users/steven", "global-memo-editor");
cacheService.saveCursor(key, 9);
expect(cacheService.loadCursor(key)).toBe(9);
cacheService.clear(key);
expect(cacheService.loadCursor(key)).toBeUndefined();
});
});

View file

@ -0,0 +1,46 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import Navigation from "@/components/Navigation";
const mocks = vi.hoisted(() => ({
openEditor: vi.fn(),
}));
vi.mock("@/components/MemosLogo", () => ({
default: () => <span>Memos logo</span>,
}));
vi.mock("@/components/UserMenu", () => ({
default: () => <div>User menu</div>,
}));
vi.mock("@/contexts/GlobalMemoEditorContext", () => ({
useGlobalMemoEditor: () => ({ isOpen: false, openEditor: mocks.openEditor }),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/test" }),
}));
vi.mock("@/hooks/useUserQueries", () => ({
useNotifications: () => ({ data: [] }),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
describe("Navigation logo", () => {
it("opens the global memo editor for an authenticated user", () => {
render(
<MemoryRouter>
<Navigation collapsed />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "common.create common.memos" }));
expect(mocks.openEditor).toHaveBeenCalledTimes(1);
});
});