From 6f1ff135fba4f112ee6900b90116c55416eca185 Mon Sep 17 00:00:00 2001 From: boojack Date: Wed, 29 Jul 2026 08:57:04 +0800 Subject: [PATCH] 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. --- web/src/App.tsx | 5 +- .../MemoEditor/Editor/controller.ts | 8 +++ .../MemoEditor/hooks/useMemoInit.ts | 18 ++++++- web/src/components/MemoEditor/index.tsx | 27 ++++++++-- .../MemoEditor/services/cacheService.ts | 11 ++++ .../components/MemoEditor/state/context.tsx | 5 +- web/src/components/MemoEditor/state/types.ts | 4 +- .../components/MemoEditor/types/components.ts | 4 ++ .../MemoEditor/types/editorController.ts | 2 + web/src/components/Navigation.tsx | 19 +++++-- web/src/components/NavigationDrawer.tsx | 4 +- web/src/contexts/GlobalMemoEditorContext.tsx | 50 +++++++++++++++++++ web/tests/editor-controller.test.ts | 11 ++++ web/tests/formatting-toolbar.test.tsx | 2 + web/tests/global-memo-editor.test.tsx | 44 ++++++++++++++++ web/tests/memo-editor-cache.test.ts | 10 ++++ web/tests/navigation-logo-create.test.tsx | 46 +++++++++++++++++ 17 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 web/src/contexts/GlobalMemoEditorContext.tsx create mode 100644 web/tests/global-memo-editor.test.tsx create mode 100644 web/tests/navigation-logo-create.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 1f701479..dce63182 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( <> - + + + diff --git a/web/src/components/MemoEditor/Editor/controller.ts b/web/src/components/MemoEditor/Editor/controller.ts index 759477ce..ee7f2384 100644 --- a/web/src/components/MemoEditor/Editor/controller.ts +++ b/web/src/components/MemoEditor/Editor/controller.ts @@ -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, diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index 2aa59a29..c1b6361d 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -49,11 +49,25 @@ export const useMemoInit = ({ } } - if (autoFocus) { - setTimeout(() => editorRef.current?.focus(), 100); + const cachedCursor = cacheService.loadCursor(key); + let restoreCursorTimer: ReturnType | 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 }; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index ce45721b..69a460e9 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -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) => ( - + ); @@ -30,6 +30,7 @@ const MemoEditorImpl: React.FC = ({ memo, parentMemoName, autoFocus, + onFocusModeExit, placeholder, defaultCreateTime, onConfirm, @@ -62,6 +63,7 @@ const MemoEditorImpl: React.FC = ({ // 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 = ({ } }, [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 = ({ defaultCreateTime, discardDraft, onConfirm, - onCancel, + onCancel: onCancel ? handleCancel : undefined, }); return ( @@ -285,7 +304,7 @@ const MemoEditorImpl: React.FC = ({ >(); +const cursors = new Map(); 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(); }, }; diff --git a/web/src/components/MemoEditor/state/context.tsx b/web/src/components/MemoEditor/state/context.tsx index 994ff262..65c8393b 100644 --- a/web/src/components/MemoEditor/state/context.tsx +++ b/web/src/components/MemoEditor/state/context.tsx @@ -76,13 +76,14 @@ export function useEditorSelector(selector: (state: EditorState) => T): T { interface EditorProviderProps extends PropsWithChildren { initialEditorState?: EditorState; + initialFocusMode?: boolean; } -export const EditorProvider: FC = ({ children, initialEditorState }) => { +export const EditorProvider: FC = ({ children, initialEditorState, initialFocusMode }) => { // Created once; the store instance is stable across renders. const storeRef = useRef(null); if (!storeRef.current) { - storeRef.current = createEditorStore(initialEditorState ?? createInitialState()); + storeRef.current = createEditorStore(initialEditorState ?? createInitialState(initialFocusMode)); } return {children}; }; diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts index 9bd9735a..861e2539 100644 --- a/web/src/components/MemoEditor/state/types.ts +++ b/web/src/components/MemoEditor/state/types.ts @@ -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 }, }; } diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 7c9e0224..37af5a86 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -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 diff --git a/web/src/components/MemoEditor/types/editorController.ts b/web/src/components/MemoEditor/types/editorController.ts index 7b2d8566..3829027f 100644 --- a/web/src/components/MemoEditor/types/editorController.ts +++ b/web/src/components/MemoEditor/types/editorController.ts @@ -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; diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index a029ecc6..09d0ec32 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -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 (
- - - + {currentUser ? ( + + ) : ( + + + + )} {primaryNavLinks.map((navLink) => ( { 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 ( diff --git a/web/src/contexts/GlobalMemoEditorContext.tsx b/web/src/contexts/GlobalMemoEditorContext.tsx new file mode 100644 index 00000000..aac75df0 --- /dev/null +++ b/web/src/contexts/GlobalMemoEditorContext.tsx @@ -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({ + isOpen: false, + openEditor: () => {}, +}); + +export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) { + const t = useTranslate(); + const [isOpen, setIsOpen] = useState(false); + const [EditorComponent, setEditorComponent] = useState>(); + + 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 ( + + {children} + {isOpen && EditorComponent && ( + + )} + + ); +} + +export function useGlobalMemoEditor() { + return useContext(GlobalMemoEditorContext); +} diff --git a/web/tests/editor-controller.test.ts b/web/tests/editor-controller.test.ts index e159137f..da2db570 100644 --- a/web/tests/editor-controller.test.ts +++ b/web/tests/editor-controller.test.ts @@ -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); + }); }); diff --git a/web/tests/formatting-toolbar.test.tsx b/web/tests/formatting-toolbar.test.tsx index efed845a..729c1182 100644 --- a/web/tests/formatting-toolbar.test.tsx +++ b/web/tests/formatting-toolbar.test.tsx @@ -27,6 +27,8 @@ function makeController(opts: { active?: Partial; getSelected getMarkdown: () => "", setMarkdown: () => {}, insertMarkdown: vi.fn(), + getCursor: () => 0, + setCursor: vi.fn(), scrollToCursor: () => {}, selectAll: () => {}, formatting: { diff --git a/web/tests/global-memo-editor.test.tsx b/web/tests/global-memo-editor.test.tsx new file mode 100644 index 00000000..51f5bfd7 --- /dev/null +++ b/web/tests/global-memo-editor.test.tsx @@ -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 }) => ( +
+ +
+ ), + })), +})); + +vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key })); + +function Trigger() { + const { openEditor } = useGlobalMemoEditor(); + return ( + + ); +} + +describe("GlobalMemoEditorProvider", () => { + it("opens the shared editor in focus mode and closes when focus mode exits", async () => { + render( + + + , + ); + + 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(); + }); +}); diff --git a/web/tests/memo-editor-cache.test.ts b/web/tests/memo-editor-cache.test.ts index 83b8d7d7..d9a94af6 100644 --- a/web/tests/memo-editor-cache.test.ts +++ b/web/tests/memo-editor-cache.test.ts @@ -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(); + }); }); diff --git a/web/tests/navigation-logo-create.test.tsx b/web/tests/navigation-logo-create.test.tsx new file mode 100644 index 00000000..2ffa4fac --- /dev/null +++ b/web/tests/navigation-logo-create.test.tsx @@ -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: () => Memos logo, +})); + +vi.mock("@/components/UserMenu", () => ({ + default: () =>
User menu
, +})); + +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( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "common.create common.memos" })); + + expect(mocks.openEditor).toHaveBeenCalledTimes(1); + }); +});