Skip to content
File

Blob: src/client/components/editor/controllers/image/insert-popover.tsx

typescript142 lines
1import { useEffect, useRef, useState } from "react";
2import { FloatingPortal } from "@floating-ui/react";
3import type { Editor } from "@tiptap/core";
4import { Upload } from "lucide-react";
5import { toast } from "@/client/components/toast-store";
6import { preserveEditorSelectionOnMouseDown, useEditorRectPopover } from "../menu/popover";
7import {
8 IMAGE_TARGET_MISSING_MESSAGE,
9 getImageTargetDom,
10 replaceImageSourceAtTarget,
11 resolveImageTargetPos,
12 triggerFileUploadAtTarget,
13 type ImageNodeTarget,
14 type UploadContext,
15} from "../../lib/media-actions";
16import { validateImageUrl } from "./validate-image-url";
17import "../../styles/floating-controls.css";
18 
19export interface ImageInsertPopoverProps {
20 editor: Editor;
21 uploadContext: UploadContext;
22 target: ImageNodeTarget;
23 onClose: () => void;
24}
25 
26export 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}