feat(editor): autofocus Home on initial desktop load (#6275)

This commit is contained in:
Johnny 2026-09-05 07:43:20 +08:00 committed by GitHub
parent f8b55ad16e
commit 60b89736a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 127 additions and 6 deletions

View file

@ -9,7 +9,7 @@ interface UseMemoInitOptions {
memo?: Memo;
cacheKey?: string;
username: string;
autoFocus?: boolean;
autoFocus?: boolean | (() => boolean);
defaultVisibility?: Visibility;
defaultCreateTime?: Date;
}
@ -59,7 +59,7 @@ export const useMemoInit = ({
if (cachedCursor !== undefined) {
editorRef.current?.setCursor(cachedCursor);
}
if (autoFocus) {
if (typeof autoFocus === "function" ? autoFocus() : autoFocus) {
editorRef.current?.focus();
}
}, 100);

View file

@ -12,7 +12,8 @@ export interface MemoEditorProps {
parentMemoName?: string;
/** Assigns a newly created top-level memo to this Space. Ignored for edits and comments. */
defaultSpace?: string;
autoFocus?: boolean;
/** A callback can decide whether focus is still appropriate after draft restoration. */
autoFocus?: boolean | (() => boolean);
/**
* Marks the instance as *hosted*: a host (the global composer dialog) presents
* the editor in the focus-mode layout and owns that frame. The editor mounts

View file

@ -9,13 +9,17 @@ import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import { useSpaceContext } from "@/contexts/SpaceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { spaceScopedCacheKey } from "@/lib/resource-names";
import { ROUTES } from "@/router/routes";
import { useTranslate } from "@/utils/i18n";
interface GlobalMemoEditorContextValue {
/** Whether the signed-in user is ready to compose; gates every visible entry point. */
canOpen: boolean;
openEditor: () => void;
/** Claims the initial desktop Home focus once, after the editor restores its draft. */
claimHomeAutoFocus: () => boolean;
}
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue | null>(null);
@ -42,6 +46,9 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
const currentUserName = useCurrentUser()?.name;
const { selectedSpaceName } = useSpaceContext();
const { isUserSettingsInitialized } = useAuth();
const desktop = useMediaQuery("md");
const [initialHome] = useState(() => ({ location, user: currentUserName, space: selectedSpaceName }));
const homeAutoFocusPending = useRef(desktop && location.pathname === ROUTES.HOME && Boolean(currentUserName));
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
const routePolicy = getRouteActionPolicy(location.pathname);
const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined;
@ -56,6 +63,26 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
const returnFocusRef = useRef<HTMLElement | null>(null);
const openRequestVersionRef = useRef(0);
useEffect(() => {
if (
location.key !== initialHome.location.key ||
location.pathname !== initialHome.location.pathname ||
location.search !== initialHome.location.search ||
currentUserName !== initialHome.user ||
selectedSpaceName !== initialHome.space ||
!desktop
) {
homeAutoFocusPending.current = false;
}
}, [location, currentUserName, selectedSpaceName, desktop, initialHome]);
const claimHomeAutoFocus = useCallback(() => {
const pending = homeAutoFocusPending.current;
homeAutoFocusPending.current = false;
// A user who already focused another control takes precedence over startup focus.
return pending && (document.activeElement === document.body || document.activeElement === null);
}, []);
const closeEditor = useCallback(() => {
openRequestVersionRef.current += 1;
isSavingRef.current = false;
@ -74,6 +101,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
const openEditor = useCallback(() => {
if (!canOpen || !currentUserName) return;
homeAutoFocusPending.current = false;
// Owned here so no caller can leave a sidebar surface open underneath.
setMobileOpen(false);
@ -112,7 +140,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
}, []);
const editorIsOpen = opened !== undefined && opened.user === currentUserName;
const value = useMemo(() => ({ canOpen, openEditor }), [canOpen, openEditor]);
const value = useMemo(() => ({ canOpen, openEditor, claimHomeAutoFocus }), [canOpen, openEditor, claimHomeAutoFocus]);
return (
<GlobalMemoEditorContext.Provider value={value}>

View file

@ -4,6 +4,7 @@ import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/util
import MemoView from "@/components/MemoView";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useAuth } from "@/contexts/AuthContext";
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { NewMemoProvider } from "@/contexts/NewMemoContext";
import { useSpaceContext } from "@/contexts/SpaceContext";
@ -18,6 +19,7 @@ const Home = () => {
const user = useCurrentUser();
const t = useTranslate();
const { isUserSettingsInitialized } = useAuth();
const { claimHomeAutoFocus } = useGlobalMemoEditor();
const { filters } = useMemoFilterContext();
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
@ -53,6 +55,7 @@ const Home = () => {
return (
<MemoEditor
key={editorCacheKey}
autoFocus={claimHomeAutoFocus}
className={useGrid ? undefined : "mb-2"}
cacheKey={editorCacheKey}
placeholder={t("editor.any-thoughts")}

View file

@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
setQuickFindOpen: vi.fn(),
selectedSpaceName: undefined as string | undefined,
pathname: "/",
desktop: true,
}));
vi.mock("react-router-dom", async (importOriginal) => ({
@ -42,6 +43,10 @@ vi.mock("@/hooks/useCurrentUser", () => ({
default: () => mocks.currentUser,
}));
vi.mock("@/hooks/useMediaQuery", () => ({
default: () => mocks.desktop,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
@ -124,6 +129,57 @@ describe("GlobalMemoEditorProvider", () => {
mocks.setQuickFindOpen.mockClear();
mocks.selectedSpaceName = undefined;
mocks.pathname = "/";
mocks.desktop = true;
});
it("allows Home autofocus once after settings finish loading", () => {
let claimFocus!: () => boolean;
const Probe = () => {
claimFocus = useGlobalMemoEditor().claimHomeAutoFocus;
return null;
};
mocks.isUserSettingsInitialized = false;
const { rerender } = renderProvider(<Probe />);
mocks.isUserSettingsInitialized = true;
rerender(
<GlobalMemoEditorProvider>
<Probe />
</GlobalMemoEditorProvider>,
);
expect(claimFocus()).toBe(true);
expect(claimFocus()).toBe(false);
});
it.each(["mobile", "other route", "navigation", "Space change", "existing focus"])("does not autofocus Home after %s", (scenario) => {
let claimFocus!: () => boolean;
const Probe = () => {
claimFocus = useGlobalMemoEditor().claimHomeAutoFocus;
return <button type="button">Another control</button>;
};
if (scenario === "mobile") mocks.desktop = false;
if (scenario === "other route") mocks.pathname = "/explore";
const { rerender } = renderProvider(<Probe />);
if (scenario === "navigation") mocks.pathname = "/inbox";
if (scenario === "Space change") mocks.selectedSpaceName = "spaces/product";
if (scenario === "existing focus") screen.getByRole("button", { name: "Another control" }).focus();
rerender(
<GlobalMemoEditorProvider>
<Probe />
</GlobalMemoEditorProvider>,
);
// Returning to the original route, Space, or viewport must not rearm focus.
mocks.pathname = "/";
mocks.selectedSpaceName = undefined;
mocks.desktop = true;
rerender(
<GlobalMemoEditorProvider>
<Probe />
</GlobalMemoEditorProvider>,
);
expect(claimFocus()).toBe(false);
});
it("opens a modal focus-mode editor, closes the sidebar surfaces, and restores focus after Escape", async () => {

View file

@ -43,6 +43,10 @@ vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isUserSettingsInitialized: true }),
}));
vi.mock("@/contexts/GlobalMemoEditorContext", () => ({
useGlobalMemoEditor: () => ({ claimHomeAutoFocus: () => true }),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
}));
@ -80,6 +84,7 @@ describe("<Home>", () => {
expect(screen.getByTestId("memo-view")).toBeInTheDocument();
expect(state.listProps).toMatchObject({ contextFilter: undefined });
expect(state.editorProps).toMatchObject({ cacheKey: "home-memo-editor", defaultSpace: undefined });
expect(state.editorProps?.autoFocus).toEqual(expect.any(Function));
});
it("filters the feed and sets new memo placement to the selected Space", () => {

View file

@ -1,5 +1,5 @@
import { create } from "@bufbuild/protobuf";
import { render, waitFor } from "@testing-library/react";
import { act, render, waitFor } from "@testing-library/react";
import type { RefObject } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMemoInit } from "@/components/MemoEditor/hooks/useMemoInit";
@ -11,12 +11,13 @@ import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
const editorRef = { current: null } as RefObject<EditorController | null>;
let getEditorState: ReturnType<typeof useEditorContext>["getState"];
function Probe() {
function Probe({ autoFocus }: { autoFocus?: boolean | (() => boolean) }) {
getEditorState = useEditorContext().getState;
useMemoInit({
editorRef,
username: "users/steven",
cacheKey: "restored-draft",
autoFocus,
});
return null;
}
@ -33,9 +34,36 @@ describe("useMemoInit", () => {
});
afterEach(() => {
editorRef.current = null;
vi.useRealTimers();
vi.unstubAllGlobals();
});
it.each([true, false])("restores the draft and cursor before evaluating autofocus (%s)", (allowed) => {
vi.useFakeTimers();
const key = cacheService.key("users/steven", "restored-draft");
cacheService.saveNow(key, "An unfinished memo");
cacheService.saveCursor(key, 5);
const focus = vi.fn();
const setCursor = vi.fn();
editorRef.current = { focus, setCursor } as unknown as EditorController;
const autoFocus = vi.fn(() => {
expect(getEditorState().content).toBe("An unfinished memo");
expect(setCursor).toHaveBeenCalledWith(5);
return allowed;
});
render(
<EditorProvider>
<Probe autoFocus={autoFocus} />
</EditorProvider>,
);
expect(autoFocus).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(100));
expect(autoFocus).toHaveBeenCalledOnce();
expect(focus).toHaveBeenCalledTimes(allowed ? 1 : 0);
});
it("restores uploaded attachment bindings with a new memo draft", async () => {
const attachment = create(AttachmentSchema, {
name: "attachments/image-one",