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:
parent
9328413797
commit
6f1ff135fb
17 changed files with 255 additions and 15 deletions
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
50
web/src/contexts/GlobalMemoEditorContext.tsx
Normal file
50
web/src/contexts/GlobalMemoEditorContext.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ function makeController(opts: { active?: Partial<ActiveFormatState>; getSelected
|
|||
getMarkdown: () => "",
|
||||
setMarkdown: () => {},
|
||||
insertMarkdown: vi.fn(),
|
||||
getCursor: () => 0,
|
||||
setCursor: vi.fn(),
|
||||
scrollToCursor: () => {},
|
||||
selectAll: () => {},
|
||||
formatting: {
|
||||
|
|
|
|||
44
web/tests/global-memo-editor.test.tsx
Normal file
44
web/tests/global-memo-editor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
46
web/tests/navigation-logo-create.test.tsx
Normal file
46
web/tests/navigation-logo-create.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue