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);
+ });
+});