fix: prevent editor bubble menu update loop

This commit is contained in:
tianma-if committed 2026-08-13 01:18:02 +08:00
1 parent e6f68378d0
commit d0c124993c
4 files changed
+73 -6

No files matched your search

+4 -4
View File
@@ -76,6 +76,7 @@ import {
} from "@/components/ui/dialog";
import { EditorToolbar } from "./EditorToolbar";
import { EditorOutline } from "./EditorOutline";
import { useAiBubbleMenu } from "./editor/useAiBubbleMenu";
import { WeChatIcon } from "./WeChatIcon";
import { ThemeToggle } from "./ThemeToggle";
import { useTheme } from "./ThemeProvider";
@@ -703,6 +704,7 @@ const RichEditorPane = ({
const [historyOpen, setHistoryOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
const aiBubbleMenu = useAiBubbleMenu(aiAssistantOpen);
const [aiSelection, setAiSelection] = useState<AiSelectionContext | null>(null);
const [systemInfoOpen, setSystemInfoOpen] = useState(false);
const [updateAvailable, setUpdateAvailable] = useState(false);
@@ -4133,10 +4135,8 @@ const RichEditorPane = ({
>
<BubbleMenu
editor={editor}
shouldShow={({ editor: activeEditor }) =>
activeEditor.isEditable && !activeEditor.state.selection.empty && !aiAssistantOpen
}
options={{ placement: "top" }}
shouldShow={aiBubbleMenu.shouldShow}
options={aiBubbleMenu.options}
>
<Button
type="button"
@@ -0,0 +1,22 @@
import { useCallback } from "react";
import type { BubbleMenuProps } from "@tiptap/react/menus";
type BubbleMenuShouldShow = NonNullable<BubbleMenuProps["shouldShow"]>;
type BubbleMenuOptions = NonNullable<BubbleMenuProps["options"]>;
const AI_BUBBLE_MENU_OPTIONS: BubbleMenuOptions = { placement: "top" };
export const useAiBubbleMenu = (assistantOpen: boolean) => {
// BubbleMenu dispatches an options-update transaction whenever these prop
// references change. Keep them stable so the editor's transaction-driven
// toolbar refresh cannot feed back into another BubbleMenu update.
const shouldShow = useCallback<BubbleMenuShouldShow>(
({ editor }) => editor.isEditable && !editor.state.selection.empty && !assistantOpen,
[assistantOpen],
);
return {
options: AI_BUBBLE_MENU_OPTIONS,
shouldShow,
};
};
+46 -1
View File
@@ -1,6 +1,10 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { EditorContent, useEditor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import StarterKit from "@tiptap/starter-kit";
import { Button } from "@/components/ui/button";
import { useAiBubbleMenu } from "@/components/editor/useAiBubbleMenu";
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,8 +19,49 @@ import {
SelectValue,
} from "@/components/ui/select";
const BubbleMenuTransactionRegression = () => {
const [transactionCount, setTransactionCount] = useState(0);
const aiBubbleMenu = useAiBubbleMenu(false);
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Bubble menu regression</p>",
}, []);
useEffect(() => {
if (!editor) return;
const handleTransaction = () => setTransactionCount((count) => count + 1);
editor.on("transaction", handleTransaction);
const frame = window.requestAnimationFrame(() => {
editor.commands.setTextSelection({ from: 1, to: 7 });
});
return () => {
window.cancelAnimationFrame(frame);
editor.off("transaction", handleTransaction);
};
}, [editor]);
return (
<div
data-bubble-menu-regression-ready={transactionCount > 0 ? "true" : "false"}
data-bubble-menu-transaction-count={transactionCount}
>
<BubbleMenu
editor={editor}
shouldShow={aiBubbleMenu.shouldShow}
options={aiBubbleMenu.options}
>
<span>Selection action</span>
</BubbleMenu>
<EditorContent editor={editor} />
</div>
);
};
const DesktopRendererTest = () => (
<main data-desktop-renderer-test-ready>
<BubbleMenuTransactionRegression />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label="More actions" title="More actions">
+1 -1
View File
@@ -55,7 +55,7 @@ try {
});
await verifyEntry({
filePath: componentTestPath,
readySelector: "[data-desktop-renderer-test-ready]",
readySelector: '[data-desktop-renderer-test-ready] [data-bubble-menu-regression-ready="true"]',
label: "Desktop renderer component test",
});