| 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 |