Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
36fa59f9a6 fix(editor): close suggestion popups on outside focus
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 09:49:21 +08:00
4 changed files with 394 additions and 145 deletions

View File

@@ -9,8 +9,6 @@ import {
useRef,
useState,
} from "react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import type { QueryClient } from "@tanstack/react-query";
import { getCurrentWsId } from "@multica/core/platform";
import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries";
@@ -31,13 +29,15 @@ import { StatusIcon } from "../../issues/components/status-icon";
import { useT } from "../../i18n";
import { Badge } from "@multica/ui/components/ui/badge";
import type { IssueStatus } from "@multica/core/types";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import type { SuggestionOptions } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import {
getRecencyMap,
recordMentionUsage,
sortUserItemsByRecency,
} from "./mention-recency";
import { matchesPinyin } from "./pinyin-match";
import { createSuggestionPopupRender } from "./suggestion-popup";
// ---------------------------------------------------------------------------
// Types
@@ -375,10 +375,9 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
// Renderer/popup instances live in this closure so each ContentEditor owns
// its own TipTap suggestion popup lifecycle.
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
// The explicit key is passed into Tiptap Suggestion and reused by the
// shared popup controller when it dispatches exitSuggestion(view, pluginKey).
const pluginKey = new PluginKey("mentionSuggestion");
function buildSyncItems(query: string): MentionItem[] {
// Read workspace id imperatively because this runs in TipTap factory scope
@@ -454,78 +453,21 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
}
return {
pluginKey,
items: ({ query }) => {
const syncItems = buildSyncItems(query);
return syncItems;
},
render: () => {
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: {
items: props.items,
query: props.query,
command: props.command,
},
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<MentionItem>) => {
renderer?.updateProps({
items: props.items,
query: props.query,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
},
render: createSuggestionPopupRender<MentionItem, MentionItem, MentionListRef, MentionListProps>({
pluginKey,
component: MentionList,
getProps: (props) => ({
items: props.items,
query: props.query,
command: props.command,
}),
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
}),
};
}

View File

@@ -8,10 +8,9 @@ import {
useRef,
useState,
} from "react";
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
import { ReactRenderer } from "@tiptap/react";
import type { QueryClient } from "@tanstack/react-query";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import type { SuggestionOptions } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import { useAuthStore } from "@multica/core/auth";
import { useChatStore } from "@multica/core/chat";
import { getCurrentWsId } from "@multica/core/platform";
@@ -20,6 +19,7 @@ import { isImeComposing } from "@multica/core/utils";
import { workspaceKeys } from "@multica/core/workspace/queries";
import type { Agent, MemberWithUser } from "@multica/core/types";
import { useT } from "../../i18n";
import { createSuggestionPopupRender } from "./suggestion-popup";
const MAX_ITEMS = 20;
@@ -162,11 +162,11 @@ export function createSlashCommandSuggestion(qc: QueryClient): Omit<
SuggestionOptions<SlashCommandItem>,
"editor"
> {
let renderer: ReactRenderer<SlashCommandListRef> | null = null;
let popup: HTMLDivElement | null = null;
const pluginKey = new PluginKey("slashCommandSuggestion");
return {
char: "/",
pluginKey,
items: ({ query }) => buildItems(qc, query),
command: ({ editor, range, props }) => {
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
@@ -193,70 +193,15 @@ export function createSlashCommandSuggestion(qc: QueryClient): Omit<
window.getSelection()?.collapseToEnd();
},
render: () => {
return {
onStart: (props: SuggestionProps<SlashCommandItem>) => {
renderer = new ReactRenderer(SlashCommandList, {
props: {
items: props.items,
query: props.query,
command: props.command,
},
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<SlashCommandItem>) => {
renderer?.updateProps({
items: props.items,
query: props.query,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
},
render: createSuggestionPopupRender<SlashCommandItem, SlashCommandItem, SlashCommandListRef, SlashCommandListProps>({
pluginKey,
component: SlashCommandList,
getProps: (props) => ({
items: props.items,
query: props.query,
command: props.command,
}),
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
}),
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
}

View File

@@ -0,0 +1,216 @@
import { Extension, Editor } from "@tiptap/core";
import { EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Suggestion, type SuggestionProps } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import { forwardRef, useImperativeHandle } from "react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createSuggestionPopupRender } from "./suggestion-popup";
interface TestItem {
id: string;
label: string;
}
interface TestListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
interface TestListProps {
items: TestItem[];
command: (item: TestItem) => void;
}
const TestSuggestionList = forwardRef<TestListRef, TestListProps>(
function TestSuggestionList({ items, command }, ref) {
useImperativeHandle(ref, () => ({
onKeyDown: () => false,
}));
return (
<div data-testid="suggestion-popup">
{items.map((item) => (
<button key={item.id} type="button" onClick={() => command(item)}>
{item.label}
</button>
))}
</div>
);
},
);
let editor: Editor | null = null;
beforeAll(() => {
const rect = () => new DOMRect(0, 0, 0, 0);
const rectList = () => ({ length: 0, item: () => null, [Symbol.iterator]: function* () {} }) as DOMRectList;
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
configurable: true,
value: rect,
});
Object.defineProperty(Range.prototype, "getClientRects", {
configurable: true,
value: rectList,
});
Object.defineProperty(HTMLElement.prototype, "getClientRects", {
configurable: true,
value: rectList,
});
Object.defineProperty(Text.prototype, "getClientRects", {
configurable: true,
value: rectList,
});
});
afterEach(() => {
act(() => {
editor?.destroy();
});
editor = null;
document.body.innerHTML = "";
});
function makeEditor(char: "@" | "/") {
const pluginKey = new PluginKey(`test-${char}-suggestion`);
const item = char === "@"
? { id: "u1", label: "Alice" }
: { id: "s1", label: "ship" };
const TestSuggestionExtension = Extension.create({
name: `testSuggestion${char}`,
addProseMirrorPlugins() {
return [
Suggestion<TestItem, TestItem>({
editor: this.editor,
char,
pluginKey,
items: () => [item],
command: ({ editor: ed, range, props }) => {
ed.commands.insertContentAt(range, `${char}${props.label}`);
},
render: createSuggestionPopupRender<TestItem, TestItem, TestListRef, TestListProps>({
pluginKey,
component: TestSuggestionList,
getProps: (props: SuggestionProps<TestItem, TestItem>) => ({
items: props.items,
command: props.command,
}),
onKeyDown: (ref, props) => ref?.onKeyDown(props) ?? false,
}),
}),
];
},
});
editor = new Editor({
extensions: [StarterKit, TestSuggestionExtension],
content: "",
});
render(<EditorContent editor={editor} />);
return editor;
}
async function triggerSuggestion(ed: Editor, text: string) {
await act(async () => {
ed.commands.focus("end");
ed.commands.insertContent(text);
});
await waitFor(() => {
expect(screen.getByTestId("suggestion-popup")).toBeInTheDocument();
});
}
async function expectPopupClosed() {
await waitFor(() => {
expect(screen.queryByTestId("suggestion-popup")).not.toBeInTheDocument();
});
}
describe("createSuggestionPopupRender", () => {
it.each(["@", "/"] as const)(
"closes the %s popup through a real pluginKey on outside pointerdown",
async (char) => {
const ed = makeEditor(char);
await triggerSuggestion(ed, `${char}a`);
const outside = document.createElement("button");
document.body.appendChild(outside);
act(() => {
fireEvent.pointerDown(outside);
});
await expectPopupClosed();
},
);
it.each(["@", "/"] as const)(
"closes the %s popup through a real pluginKey on outside focusin",
async (char) => {
const ed = makeEditor(char);
await triggerSuggestion(ed, `${char}a`);
const outside = document.createElement("input");
document.body.appendChild(outside);
act(() => {
fireEvent.focusIn(outside);
});
await expectPopupClosed();
},
);
it.each(["@", "/"] as const)(
"closes the %s popup through a real pluginKey on window blur",
async (char) => {
const ed = makeEditor(char);
await triggerSuggestion(ed, `${char}a`);
act(() => {
fireEvent.blur(window);
});
await expectPopupClosed();
},
);
it.each(["@", "/"] as const)(
"can reopen the %s popup after an explicit exit",
async (char) => {
const ed = makeEditor(char);
await triggerSuggestion(ed, `${char}a`);
const outside = document.createElement("button");
document.body.appendChild(outside);
fireEvent.pointerDown(outside);
await expectPopupClosed();
await act(async () => {
ed.commands.insertContent(` ${char}b`);
});
await waitFor(() => {
expect(screen.getByTestId("suggestion-popup")).toBeInTheDocument();
});
},
);
it.each(["@", "/"] as const)(
"keeps the %s popup open long enough for candidate row clicks to insert",
async (char) => {
const ed = makeEditor(char);
await triggerSuggestion(ed, `${char}a`);
const label = char === "@" ? "Alice" : "ship";
const row = screen.getByRole("button", { name: label });
act(() => {
fireEvent.pointerDown(row);
fireEvent.click(row);
});
await waitFor(() => {
expect(ed.getText()).toContain(`${char}${label}`);
});
},
);
});

View File

@@ -0,0 +1,146 @@
"use client";
import type { ComponentType } from "react";
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
import { ReactRenderer } from "@tiptap/react";
import { exitSuggestion, type SuggestionKeyDownProps, type SuggestionProps } from "@tiptap/suggestion";
import type { PluginKey } from "@tiptap/pm/state";
interface SuggestionPopupRenderOptions<
TItem,
TSelected = TItem,
TRef = unknown,
TComponentProps extends object = object,
> {
pluginKey: PluginKey;
component: ComponentType<TComponentProps>;
getProps: (props: SuggestionProps<TItem, TSelected>) => TComponentProps;
onKeyDown?: (
ref: TRef | null | undefined,
props: SuggestionKeyDownProps,
) => boolean;
}
export function createSuggestionPopupRender<
TItem,
TSelected = TItem,
TRef = unknown,
TComponentProps extends object = object,
>({
pluginKey,
component,
getProps,
onKeyDown,
}: SuggestionPopupRenderOptions<TItem, TSelected, TRef, TComponentProps>) {
return () => {
let renderer: ReactRenderer<TRef> | null = null;
let popup: HTMLDivElement | null = null;
let removeOutsideListeners: (() => void) | null = null;
const cleanup = () => {
removeOutsideListeners?.();
removeOutsideListeners = null;
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
};
const requestExit = (props: SuggestionProps<TItem, TSelected>) => {
exitSuggestion(props.editor.view, pluginKey);
};
const isInsideSuggestionSurface = (
target: EventTarget | null,
props: SuggestionProps<TItem, TSelected>,
) => {
if (!(target instanceof Node)) return false;
return props.editor.view.dom.contains(target) || !!popup?.contains(target);
};
const installOutsideListeners = (props: SuggestionProps<TItem, TSelected>) => {
removeOutsideListeners?.();
const doc = props.editor.view.dom.ownerDocument;
const win = doc.defaultView ?? window;
const onPointerDown = (event: PointerEvent) => {
if (isInsideSuggestionSurface(event.target, props)) return;
requestExit(props);
};
const onFocusIn = (event: FocusEvent) => {
if (isInsideSuggestionSurface(event.target, props)) return;
requestExit(props);
};
const onWindowBlur = () => {
requestExit(props);
};
doc.addEventListener("pointerdown", onPointerDown, true);
doc.addEventListener("focusin", onFocusIn, true);
win.addEventListener("blur", onWindowBlur);
removeOutsideListeners = () => {
doc.removeEventListener("pointerdown", onPointerDown, true);
doc.removeEventListener("focusin", onFocusIn, true);
win.removeEventListener("blur", onWindowBlur);
};
};
const updatePosition = (
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) => {
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
if (popup !== el) return;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
};
return {
onStart: (props: SuggestionProps<TItem, TSelected>) => {
renderer = new ReactRenderer(component, {
props: getProps(props),
editor: props.editor,
});
const doc = props.editor.view.dom.ownerDocument;
popup = doc.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
doc.body.appendChild(popup);
installOutsideListeners(props);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<TItem, TSelected>) => {
renderer?.updateProps(getProps(props));
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: SuggestionKeyDownProps) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return onKeyDown?.(renderer?.ref, props) ?? false;
},
onExit: () => {
cleanup();
},
};
};
}