返回 presentation-ai
LayoutFloatingToolbar.tsx
根目录 / src / components / presentation / floating-toolbar / LayoutFloatingToolbar.tsx
1 "use client";
2
3 import { flip, offset, type FloatingToolbarState } from "@platejs/floating";
4 import { BlockSelectionPlugin } from "@platejs/selection/react";
5 import { TablePlugin } from "@platejs/table/react";
6 import { ElementApi, KEYS, type NodeEntry, type TElement } from "platejs";
7 import {
8 useComposedRef,
9 useEditorId,
10 useEditorRef,
11 useEditorSelector,
12 useEventEditorValue,
13 usePluginOption,
14 } from "platejs/react";
15 import * as React from "react";
16
17 import { getDirectLayoutToolbarTargetEntry } from "@/components/notebook/presentation/editor/lib";
18 import { type MyEditor } from "@/components/plate/editor-kit";
19 import {
20 useFloatingToolbar,
21 useFloatingToolbarState,
22 } from "@/components/plate/hooks/use-floating-toolbar";
23 import { Toolbar } from "@/components/plate/ui/toolbar";
24 import { cn } from "@/lib/utils";
25 import { FLOATING_TOOLBAR_IGNORE_CLASS } from "./toolbar-interaction";
26
27 export function LayoutFloatingToolbar({
28 children,
29 className,
30 state,
31 ...props
32 }: React.ComponentProps<typeof Toolbar> & {
33 state?: FloatingToolbarState;
34 }) {
35 const editorId = useEditorId();
36 const editor = useEditorRef<MyEditor>();
37 const focusedEditorId = useEventEditorValue("focus");
38
39 // Check if floating UI elements are open
40 const isFloatingLinkOpen = !!usePluginOption({ key: KEYS.link }, "mode");
41 const isAIChatOpen = usePluginOption({ key: KEYS.aiChat }, "open");
42
43 // Get current selection
44 const selectedIds = usePluginOption(BlockSelectionPlugin, "selectedIds");
45 const hasBlockSelection = selectedIds && selectedIds.size > 0;
46 const selectedCells = usePluginOption(TablePlugin, "selectedCells");
47 const hasSelectedTableCells = (selectedCells?.length ?? 0) > 0;
48 const isSelectionInTable = useEditorSelector(
49 (currentEditor) => currentEditor.api.some({ match: { type: KEYS.table } }),
50 [],
51 );
52 const selectedTableOrRowEntry = React.useMemo(() => {
53 if (!selectedIds) return undefined;
54
55 for (const blockId of selectedIds) {
56 const entry = editor.api.node({
57 at: [],
58 id: String(blockId),
59 }) as NodeEntry<TElement> | undefined;
60 const [element] = entry ?? [];
61
62 if (element?.type === KEYS.table || element?.type === KEYS.tr) {
63 return entry;
64 }
65 }
66
67 return undefined;
68 }, [editor, selectedIds]);
69
70 // Check if selected blocks are presentation blocks with dedicated controls.
71 const isLayoutBlockSelected = React.useMemo(() => {
72 if (!hasBlockSelection || !selectedIds) return false;
73
74 for (const blockId of selectedIds) {
75 if (getDirectLayoutToolbarTargetEntry(editor, String(blockId))) {
76 return true;
77 }
78 }
79
80 return false;
81 }, [hasBlockSelection, selectedIds, editor]);
82 const isTableSelectionActive =
83 isSelectionInTable ||
84 hasSelectedTableCells ||
85 Boolean(selectedTableOrRowEntry);
86
87 const getTableSelectionRect = React.useCallback(() => {
88 const [selectedElement, selectedPath] = selectedTableOrRowEntry ?? [];
89
90 if (selectedElement?.type === KEYS.table) {
91 const domElement = editor.api.toDOMNode(selectedElement);
92 return domElement?.getBoundingClientRect() ?? null;
93 }
94
95 if (selectedElement?.type === KEYS.tr && selectedPath) {
96 const tablePath = selectedPath.slice(0, -1);
97 const tableElement = editor.api.node({ at: tablePath })?.[0];
98
99 if (tableElement && ElementApi.isElement(tableElement)) {
100 const domElement = editor.api.toDOMNode(tableElement);
101 return domElement?.getBoundingClientRect() ?? null;
102 }
103 }
104
105 const tableEntry = editor.api.above({
106 match: { type: KEYS.table },
107 }) as NodeEntry<TElement> | undefined;
108 const [tableElement] = tableEntry ?? [];
109
110 if (!tableElement) return null;
111
112 const domElement = editor.api.toDOMNode(tableElement);
113 return domElement?.getBoundingClientRect() ?? null;
114 }, [editor, selectedTableOrRowEntry]);
115
116 // Configure floating toolbar state
117 const floatingToolbarState = useFloatingToolbarState({
118 customSelection: {
119 active: isTableSelectionActive,
120 getBoundingClientRect: getTableSelectionRect,
121 updateKey: selectedCells ?? selectedTableOrRowEntry?.[1],
122 },
123 editorId,
124 focusedEditorId,
125 hideToolbar:
126 (!isLayoutBlockSelected && !isTableSelectionActive) ||
127 isFloatingLinkOpen ||
128 isAIChatOpen,
129 enableBlockSelection: true,
130 ...state,
131 floatingOptions: {
132 middleware: [
133 offset(12),
134 flip({
135 fallbackPlacements: [
136 "top-start",
137 "top-end",
138 "bottom-start",
139 "bottom-end",
140 ],
141 padding: 12,
142 }),
143 ],
144 placement: "top",
145 strategy: "fixed",
146 ...state?.floatingOptions,
147 },
148 });
149
150 // Get floating toolbar props
151 const {
152 clickOutsideRef,
153 hidden,
154 props: rootProps,
155 ref: floatingRef,
156 } = useFloatingToolbar(floatingToolbarState);
157
158 const ref = useComposedRef<HTMLDivElement>(props.ref, floatingRef);
159
160 // Keep the wrapper mounted only while this toolbar owns the active selection.
161 if (hidden && !isLayoutBlockSelected && !isTableSelectionActive) return null;
162
163 const toolbar = (
164 <div
165 ref={clickOutsideRef}
166 className={FLOATING_TOOLBAR_IGNORE_CLASS}
167 onMouseDown={(e) => {
168 // Prevent the browser from moving focus away from the editor when
169 // clicking any button inside the toolbar. Without this, the editor
170 // detects a focus loss, deselects the block selection, and the
171 // toolbar closes unexpectedly.
172 e.preventDefault();
173 }}
174 >
175 <Toolbar
176 {...props}
177 {...rootProps}
178 ref={ref}
179 className={cn(
180 FLOATING_TOOLBAR_IGNORE_CLASS,
181 "z-999999 scrollbar-hide overflow-x-auto rounded-md border bg-popover p-1 whitespace-nowrap opacity-100 shadow-md print:hidden",
182 "max-w-[80vw]",
183 className,
184 )}
185 >
186 {children}
187 </Toolbar>
188 </div>
189 );
190
191 return toolbar;
192 }
193
193 lines Plain Text