File
Blob: src/client/components/editor/controllers/image/insert-popover.tsx
| 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import { FloatingPortal } from "@floating-ui/react"; |
| 3 | import type { Editor } from "@tiptap/core"; |
| 4 | import { Upload } from "lucide-react"; |
| 5 | import { toast } from "@/client/components/toast-store"; |
| 6 | import { preserveEditorSelectionOnMouseDown, useEditorRectPopover } from "../menu/popover"; |
| 7 | import { |
| 8 | IMAGE_TARGET_MISSING_MESSAGE, |
| 9 | getImageTargetDom, |
| 10 | replaceImageSourceAtTarget, |
| 11 | resolveImageTargetPos, |
| 12 | triggerFileUploadAtTarget, |
| 13 | type ImageNodeTarget, |
| 14 | type UploadContext, |
| 15 | } from "../../lib/media-actions"; |
| 16 | import { validateImageUrl } from "./validate-image-url"; |
| 17 | import "../../styles/floating-controls.css"; |
| 18 | |
| 19 | export interface ImageInsertPopoverProps { |
| 20 | editor: Editor; |
| 21 | uploadContext: UploadContext; |
| 22 | target: ImageNodeTarget; |
| 23 | onClose: () => void; |
| 24 | } |
| 25 | |
| 26 | export function ImageInsertPopover({ editor, uploadContext, target, onClose }: ImageInsertPopoverProps) { |
| 27 | const [embedUrl, setEmbedUrl] = useState(""); |
| 28 | const [validatingEmbed, setValidatingEmbed] = useState(false); |
| 29 | const mountedRef = useRef(true); |
| 30 | const { floatingStyles, setFloating } = useEditorRectPopover({ |
| 31 | open: true, |
| 32 | onClose, |
| 33 | getAnchorRect: () => { |
| 34 | const targetDom = getImageTargetDom(editor, target); |
| 35 | |
| 36 | if (targetDom) { |
| 37 | const anchor = targetDom.querySelector<HTMLElement>(".tiptap-image-container, .tiptap-image-placeholder"); |
| 38 | return (anchor ?? targetDom).getBoundingClientRect(); |
| 39 | } |
| 40 | |
| 41 | try { |
| 42 | const coords = editor.view.coordsAtPos(resolveImageTargetPos(editor, target) ?? target.pos); |
| 43 | return new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top); |
| 44 | } catch { |
| 45 | return null; |
| 46 | } |
| 47 | }, |
| 48 | contextElement: editor.view.dom, |
| 49 | deferOutsidePress: true, |
| 50 | }); |
| 51 | |
| 52 | useEffect(() => { |
| 53 | return () => { |
| 54 | mountedRef.current = false; |
| 55 | }; |
| 56 | }, []); |
| 57 | |
| 58 | const handleUpload = () => { |
| 59 | triggerFileUploadAtTarget(editor, uploadContext, target, onClose); |
| 60 | }; |
| 61 | |
| 62 | const handleEmbed = async () => { |
| 63 | const url = embedUrl.trim(); |
| 64 | if (!url) { |
| 65 | onClose(); |
| 66 | return; |
| 67 | } |
| 68 | if (/^data:/i.test(url)) { |
| 69 | toast.error("Data URIs are not supported — upload the image instead."); |
| 70 | return; |
| 71 | } |
| 72 | |
| 73 | try { |
| 74 | setValidatingEmbed(true); |
| 75 | const validUrl = await validateImageUrl(url); |
| 76 | if (!replaceImageSourceAtTarget(editor, target, validUrl)) { |
| 77 | toast.error(IMAGE_TARGET_MISSING_MESSAGE); |
| 78 | } |
| 79 | onClose(); |
| 80 | } catch (e) { |
| 81 | toast.error(e instanceof Error ? e.message : "Invalid image URL"); |
| 82 | } finally { |
| 83 | if (mountedRef.current) { |
| 84 | setValidatingEmbed(false); |
| 85 | } |
| 86 | } |
| 87 | }; |
| 88 | |
| 89 | return ( |
| 90 | <FloatingPortal> |
| 91 | <div |
| 92 | ref={setFloating} |
| 93 | className="tiptap-slash-menu" |
| 94 | style={{ ...floatingStyles, zIndex: 80, minWidth: 240 }} |
| 95 | onMouseDownCapture={(e) => preserveEditorSelectionOnMouseDown(e)} |
| 96 | > |
| 97 | <div className="flex flex-col gap-2 p-2"> |
| 98 | <div className="tiptap-slash-menu-label px-2 py-1">Insert image</div> |
| 99 | <button |
| 100 | type="button" |
| 101 | disabled={validatingEmbed || !uploadContext.workspaceId} |
| 102 | className="tiptap-slash-menu-item disabled:opacity-50" |
| 103 | onMouseDown={(e) => { |
| 104 | e.preventDefault(); |
| 105 | handleUpload(); |
| 106 | }} |
| 107 | > |
| 108 | <Upload size={18} className="shrink-0 text-zinc-400" /> |
| 109 | <span>Upload file</span> |
| 110 | </button> |
| 111 | <div className="flex items-stretch gap-2"> |
| 112 | <input |
| 113 | type="text" |
| 114 | value={embedUrl} |
| 115 | disabled={validatingEmbed} |
| 116 | onChange={(e) => setEmbedUrl(e.target.value)} |
| 117 | onKeyDown={(e) => { |
| 118 | if (e.key === "Enter") void handleEmbed(); |
| 119 | if (e.key === "Escape") onClose(); |
| 120 | }} |
| 121 | placeholder="or, Paste image URL..." |
| 122 | className="tiptap-link-input min-w-0 flex-1" |
| 123 | autoFocus |
| 124 | /> |
| 125 | <button |
| 126 | type="button" |
| 127 | disabled={!embedUrl.trim() || validatingEmbed} |
| 128 | onMouseDown={(e) => { |
| 129 | e.preventDefault(); |
| 130 | void handleEmbed(); |
| 131 | }} |
| 132 | className="tiptap-inline-action shrink-0 disabled:opacity-50" |
| 133 | > |
| 134 | <span>{validatingEmbed ? "Checking..." : "Embed"}</span> |
| 135 | </button> |
| 136 | </div> |
| 137 | </div> |
| 138 | </div> |
| 139 | </FloatingPortal> |
| 140 | ); |
| 141 | } |