返回 presentation-ai
use-floating-toolbar.tsx
根目录 / src / components / plate / hooks / use-floating-toolbar.tsx
1 import { DndPlugin } from "@platejs/dnd";
2 import {
3 getSelectionBoundingClientRect,
4 useVirtualFloating,
5 type UseVirtualFloatingOptions,
6 } from "@platejs/floating";
7 import { BlockSelectionPlugin } from "@platejs/selection/react";
8 import { mergeProps, type TElement } from "platejs";
9 import {
10 useEditorReadOnly,
11 useEditorRef,
12 useEditorSelector,
13 useFocused,
14 useOnClickOutside,
15 usePluginOption,
16 } from "platejs/react";
17 import React from "react";
18
19 import { type MyEditor } from "../editor-kit";
20
21 export type FloatingToolbarState = {
22 customSelection?: {
23 active: boolean;
24 getBoundingClientRect: () => DOMRect | null;
25 updateKey?: unknown;
26 };
27 floatingOptions?: UseVirtualFloatingOptions;
28 hideToolbar?: boolean;
29 showWhenReadOnly?: boolean;
30 enableBlockSelection?: boolean;
31 };
32
33 export const useFloatingToolbarState = ({
34 customSelection,
35 editorId,
36 floatingOptions,
37 focusedEditorId,
38 hideToolbar,
39 showWhenReadOnly,
40 enableBlockSelection = true, // Changed default to true
41 }: {
42 editorId: string;
43 focusedEditorId: string | null;
44 } & FloatingToolbarState) => {
45 const editor = useEditorRef<MyEditor>();
46
47 // Existing text selection state
48 const selectionExpanded = useEditorSelector(
49 () => editor.api.isExpanded(),
50 [],
51 );
52 const selectionText = useEditorSelector(() => editor.api.string(), []);
53
54 // Block selection state
55 const selectedIds = usePluginOption(BlockSelectionPlugin, "selectedIds");
56 const hasBlockSelection =
57 enableBlockSelection && selectedIds && selectedIds.size > 0;
58 const hasCustomSelection = customSelection?.active ?? false;
59
60 // Check if dragging is active
61 const isDragging = usePluginOption(DndPlugin, "isDragging");
62
63 const readOnly = useEditorReadOnly();
64 const focused = useFocused();
65 const [open, setOpen] = React.useState(false);
66 const [mousedown, setMousedown] = React.useState(false);
67 const [waitForCollapsedSelection, setWaitForCollapsedSelection] =
68 React.useState(false);
69
70 const getBoundingClientRect = React.useCallback(() => {
71 if (hasCustomSelection) {
72 const rect = customSelection?.getBoundingClientRect();
73
74 if (rect) return rect;
75 }
76
77 if (hasBlockSelection && enableBlockSelection) {
78 // Get all selected block IDs and find their DOM elements
79 const selectedIdArray = Array.from(selectedIds || []);
80
81 if (selectedIdArray.length > 0) {
82 const elements: HTMLElement[] = [];
83
84 for (const id of selectedIdArray) {
85 const element = editor.api.node({ id, at: [] })?.[0] as TElement;
86 const domElement = editor.api.toDOMNode(element);
87 if (domElement) {
88 elements.push(domElement);
89 }
90 }
91
92 if (elements.length > 0) {
93 // Get bounding rects for all elements
94 const rects = elements.map((el) => el.getBoundingClientRect());
95
96 // Calculate combined bounding rect
97 const top = Math.min(...rects.map((r) => r.top));
98 const left = Math.min(...rects.map((r) => r.left));
99 const right = Math.max(...rects.map((r) => r.right));
100 const bottom = Math.max(...rects.map((r) => r.bottom));
101
102 const combinedRect = {
103 top,
104 left,
105 right,
106 bottom,
107 width: right - left,
108 height: bottom - top,
109 x: left,
110 y: top,
111 } as DOMRect;
112
113 return combinedRect;
114 }
115 }
116 }
117
118 // Fallback to text selection
119 return getSelectionBoundingClientRect(editor);
120 }, [
121 customSelection,
122 editor,
123 hasBlockSelection,
124 hasCustomSelection,
125 enableBlockSelection,
126 selectedIds,
127 ]);
128
129 const floating = useVirtualFloating(
130 mergeProps(
131 {
132 open,
133 getBoundingClientRect,
134 onOpenChange: setOpen,
135 },
136 floatingOptions,
137 ),
138 );
139
140 return {
141 editorId,
142 floating,
143 focused,
144 focusedEditorId,
145 hideToolbar,
146 mousedown,
147 open,
148 readOnly,
149 selectionExpanded,
150 selectionText,
151 hasBlockSelection,
152 hasCustomSelection,
153 enableBlockSelection,
154 isDragging,
155
156 setMousedown,
157 setOpen,
158 setWaitForCollapsedSelection,
159 showWhenReadOnly,
160 waitForCollapsedSelection,
161 selectedIds,
162 customSelectionUpdateKey: customSelection?.updateKey,
163 };
164 };
165
166 export const useFloatingToolbar = ({
167 editorId,
168 floating,
169 focusedEditorId,
170 hideToolbar,
171 mousedown,
172 open,
173 readOnly,
174 selectionExpanded,
175 selectionText,
176 hasBlockSelection,
177 hasCustomSelection,
178 isDragging,
179
180 setMousedown,
181 setOpen,
182 setWaitForCollapsedSelection,
183 showWhenReadOnly,
184 waitForCollapsedSelection,
185 selectedIds,
186 customSelectionUpdateKey,
187 }: ReturnType<typeof useFloatingToolbarState>) => {
188 const editor = useEditorRef<MyEditor>();
189 // On refocus, the editor keeps the previous selection,
190 const shouldWaitForCollapsedSelection =
191 editorId !== focusedEditorId &&
192 selectionExpanded &&
193 !hasBlockSelection &&
194 !hasCustomSelection;
195
196 // so we need to wait it's collapsed at the new position before displaying the floating toolbar.
197 React.useEffect(() => {
198 setWaitForCollapsedSelection((prev) => {
199 const next = Boolean(shouldWaitForCollapsedSelection);
200 return prev === next ? prev : next;
201 });
202 }, [setWaitForCollapsedSelection, shouldWaitForCollapsedSelection]);
203
204 React.useEffect(() => {
205 const mouseup = () => setMousedown((prev) => (prev ? false : prev));
206 const mousedown = () => setMousedown((prev) => (prev ? prev : true));
207 document.addEventListener("mouseup", mouseup);
208 document.addEventListener("mousedown", mousedown);
209 return () => {
210 document.removeEventListener("mouseup", mouseup);
211 document.removeEventListener("mousedown", mousedown);
212 };
213 }, []);
214
215 // MODIFIED: Updated visibility logic to include block selections and hide during dragging/mouse down
216 React.useEffect(() => {
217 const hasTextSelection = selectionExpanded && selectionText;
218 const hasAnySelection =
219 hasTextSelection || hasBlockSelection || hasCustomSelection;
220
221 // Hide conditions
222 if (
223 !hasAnySelection ||
224 (mousedown && !open) ||
225 hideToolbar ||
226 (readOnly && !showWhenReadOnly) ||
227 isDragging // Hide toolbar when dragging is active
228 ) {
229 setOpen((prev) => (prev ? false : prev));
230 }
231 // Show conditions - MODIFIED: Don't wait for collapsed selection if we have block selection
232 else if (
233 hasAnySelection &&
234 (!waitForCollapsedSelection ||
235 readOnly ||
236 hasBlockSelection ||
237 hasCustomSelection) &&
238 !isDragging // Don't show if dragging is active
239 ) {
240 setOpen((prev) => (prev ? prev : true));
241 }
242 }, [
243 setOpen,
244 editorId,
245 focusedEditorId,
246 hideToolbar,
247 showWhenReadOnly,
248 selectionExpanded,
249 selectionText,
250 hasBlockSelection,
251 hasCustomSelection,
252 mousedown,
253 waitForCollapsedSelection,
254 open,
255 readOnly,
256 isDragging, // Add isDragging to dependencies
257 ]);
258
259 const { update } = floating;
260
261 useEditorSelector(() => {
262 update?.();
263 }, [update, selectedIds, customSelectionUpdateKey]);
264
265 const clickOutsideRef = useOnClickOutside(
266 () => {
267 editor.api.blockSelection.deselect();
268 setOpen((prev) => (prev ? false : prev));
269 },
270 {
271 ignoreClass: "ignore-click-outside/toolbar",
272 },
273 );
274
275 return {
276 clickOutsideRef,
277 hidden: !open,
278 props: {
279 style: floating.style,
280 },
281 ref: floating.refs.setFloating,
282 };
283 };
284
284 lines Plain Text