返回 presentation-ai
link-toolbar.tsx
根目录 / src / components / plate / ui / link-toolbar.tsx
1 "use client";
2
3 import {
4 flip,
5 offset,
6 type UseVirtualFloatingOptions,
7 } from "@platejs/floating";
8 import { getLinkAttributes } from "@platejs/link";
9 import {
10 FloatingLinkUrlInput,
11 useFloatingLinkEdit,
12 useFloatingLinkEditState,
13 useFloatingLinkInsert,
14 useFloatingLinkInsertState,
15 type LinkFloatingToolbarState,
16 } from "@platejs/link/react";
17 import { cva } from "class-variance-authority";
18 import { ExternalLink, Link, Text, Unlink } from "lucide-react";
19 import { KEYS, type NodeEntry, type TLinkElement } from "platejs";
20 import {
21 useEditorRef,
22 useEditorSelection,
23 useFormInputProps,
24 usePluginOption,
25 } from "platejs/react";
26 import * as React from "react";
27
28 import { buttonVariants } from "@/components/plate/ui/button";
29 import { Separator } from "@/components/plate/ui/separator";
30
31 const popoverVariants = cva(
32 "z-50 w-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden",
33 );
34
35 const inputVariants = cva(
36 "flex h-7 w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:ring-transparent focus-visible:outline-hidden md:text-sm",
37 );
38
39 type FloatingStyle = React.CSSProperties & {
40 WebkitUserSelect?: string;
41 };
42
43 const sanitizeFloatingStyle = (style?: FloatingStyle) => {
44 if (!style) return undefined;
45
46 const sanitizedStyle = { ...style };
47 const userSelect = sanitizedStyle.WebkitUserSelect as unknown;
48 if (typeof userSelect === "string" && userSelect === "-moz-none") {
49 delete sanitizedStyle.WebkitUserSelect;
50 }
51
52 return sanitizedStyle;
53 };
54
55 export function LinkFloatingToolbar({
56 state,
57 }: {
58 state?: LinkFloatingToolbarState;
59 }) {
60 const activeCommentId = usePluginOption({ key: KEYS.comment }, "activeId");
61 const activeSuggestionId = usePluginOption(
62 { key: KEYS.suggestion },
63 "activeId",
64 );
65
66 const floatingOptions: UseVirtualFloatingOptions = React.useMemo(
67 () => ({
68 middleware: [
69 offset(8),
70 flip({
71 fallbackPlacements: ["bottom-end", "top-start", "top-end"],
72 padding: 12,
73 }),
74 ],
75 placement:
76 activeSuggestionId || activeCommentId ? "top-start" : "bottom-start",
77 }),
78 [activeCommentId, activeSuggestionId],
79 );
80
81 const insertState = useFloatingLinkInsertState({
82 ...state,
83 floatingOptions: {
84 ...floatingOptions,
85 ...state?.floatingOptions,
86 },
87 });
88 const {
89 hidden,
90 props: insertProps,
91 ref: insertRef,
92 textInputProps,
93 } = useFloatingLinkInsert(insertState);
94
95 const editState = useFloatingLinkEditState({
96 ...state,
97 floatingOptions: {
98 ...floatingOptions,
99 ...state?.floatingOptions,
100 },
101 });
102 const {
103 editButtonProps,
104 props: editProps,
105 ref: editRef,
106 unlinkButtonProps,
107 } = useFloatingLinkEdit(editState);
108 const inputProps = useFormInputProps({
109 preventDefaultOnEnterKeydown: true,
110 });
111
112 const { style: insertStyle, ...insertRestProps } = insertProps;
113 const { style: editStyle, ...editRestProps } = editProps;
114
115 const sanitizedInsertStyle = sanitizeFloatingStyle(
116 insertStyle as FloatingStyle | undefined,
117 );
118 const sanitizedEditStyle = sanitizeFloatingStyle(
119 editStyle as FloatingStyle | undefined,
120 );
121
122 if (hidden) return null;
123
124 const input = (
125 <div className="flex w-82.5 flex-col" {...inputProps}>
126 <div className="flex items-center">
127 <div className="flex items-center pr-1 pl-2 text-muted-foreground">
128 <Link className="size-4" />
129 </div>
130
131 <FloatingLinkUrlInput
132 className={inputVariants()}
133 placeholder="Paste link"
134 data-plate-focus
135 />
136 </div>
137 <Separator className="my-1" />
138 <div className="flex items-center">
139 <div className="flex items-center pr-1 pl-2 text-muted-foreground">
140 <Text className="size-4" />
141 </div>
142 <input
143 className={inputVariants()}
144 placeholder="Text to display"
145 data-plate-focus
146 {...textInputProps}
147 />
148 </div>
149 </div>
150 );
151
152 const editContent = editState.isEditing ? (
153 input
154 ) : (
155 <div className="box-content flex items-center">
156 <button
157 className={buttonVariants({ size: "sm", variant: "ghost" })}
158 type="button"
159 {...editButtonProps}
160 >
161 Edit link
162 </button>
163
164 <Separator orientation="vertical" />
165
166 <LinkOpenButton />
167
168 <Separator orientation="vertical" />
169
170 <button
171 className={buttonVariants({
172 size: "sm",
173 variant: "ghost",
174 })}
175 type="button"
176 {...unlinkButtonProps}
177 >
178 <Unlink width={18} />
179 </button>
180 </div>
181 );
182
183 return (
184 <>
185 <div
186 ref={insertRef as unknown as React.RefObject<HTMLDivElement | null>}
187 className={popoverVariants()}
188 {...insertRestProps}
189 style={sanitizedInsertStyle}
190 >
191 {input}
192 </div>
193
194 <div
195 ref={editRef as unknown as React.RefObject<HTMLDivElement | null>}
196 className={popoverVariants()}
197 {...editRestProps}
198 style={sanitizedEditStyle}
199 >
200 {editContent}
201 </div>
202 </>
203 );
204 }
205
206 function LinkOpenButton() {
207 const editor = useEditorRef();
208 const selection = useEditorSelection();
209
210 const attributes = React.useMemo(() => {
211 const entry = editor.api.node({
212 match: { type: editor.getType(KEYS.link) },
213 }) as NodeEntry<TLinkElement> | undefined;
214 if (!entry) {
215 return {};
216 }
217 const [element] = entry;
218 return getLinkAttributes(editor, element);
219 }, [editor, selection]);
220
221 return (
222 <a
223 {...attributes}
224 className={buttonVariants({
225 size: "sm",
226 variant: "ghost",
227 })}
228 onMouseOver={(e) => {
229 e.stopPropagation();
230 }}
231 aria-label="Open link in a new tab"
232 target="_blank"
233 >
234 <ExternalLink width={18} />
235 </a>
236 );
237 }
238
238 lines Plain Text