Skip to content
File

Blob: src/client/components/editor/extensions/image/node-view.tsx

typescript225 lines
1import { useRef, useCallback, useState, useEffect, useMemo } from "react";
2import { NodeViewWrapper } from "@tiptap/react";
3import type { NodeViewProps } from "@tiptap/react";
4import { ImageIcon, X } from "lucide-react";
5import { Skeleton } from "@/client/components/ui/skeleton";
6import { ImageElement, ImageErrorState, ImageLoadingSkeleton } from "@/shared/editor/presentation/image";
7import { useEditorAffordance } from "../../editor-affordance-context";
8import { useEditorRuntime } from "../../editor-runtime-context";
9import { showImageInsertPanel } from "../../controllers/image/insert-panel";
10import { prepareBlockDragPreview } from "../../lib/block-drag-preview";
11import { createImageNodeTarget, getLocalImagePreview, resolveShareUrl } from "../../lib/media-actions";
12import "@/styles/editor/image-node.css";
13 
14const FALLBACK_ASPECT_RATIO = 16 / 9;
15 
16export function ImageNodeView({ node, selected, updateAttributes, deleteNode, editor, getPos }: NodeViewProps) {
17 const { workspaceId, pageId, shareToken } = useEditorRuntime();
18 const { canInsertImages } = useEditorAffordance();
19 const {
20 src,
21 alt = "",
22 title,
23 align = "left",
24 width,
25 naturalWidth,
26 naturalHeight,
27 pendingInsertId,
28 brightness,
29 } = node.attrs;
30 const imgRef = useRef<HTMLImageElement>(null);
31 const dragRef = useRef<number | null>(null);
32 const abortRef = useRef<AbortController | null>(null);
33 const [liveWidth, setLiveWidth] = useState<number | null>(null);
34 const resolvedSrc = resolveShareUrl(typeof src === "string" ? src : "", shareToken);
35 const [loadState, setLoadState] = useState<{ src: string; status: "loading" | "loaded" | "errored" }>(() => ({
36 src: resolvedSrc,
37 status: "loading",
38 }));
39 const editable = editor.isEditable;
40 
41 const loadStatus = loadState.src === resolvedSrc ? loadState.status : "loading";
42 
43 const finishDrag = useCallback(() => {
44 abortRef.current?.abort();
45 abortRef.current = null;
46 dragRef.current = null;
47 setLiveWidth(null);
48 }, []);
49 
50 useEffect(() => () => finishDrag(), [finishDrag]);
51 useEffect(() => {
52 if (!editable) return;
53 
54 const handleDocumentDragStart = (event: DragEvent) => {
55 if (!event.dataTransfer || liveWidth !== null) return;
56 
57 const imageDom = imgRef.current;
58 const dragSource = imageDom?.closest(".react-renderer.node-image");
59 const target = event.target;
60 if (!(dragSource instanceof HTMLElement) || !(target instanceof Node) || !dragSource.contains(target)) return;
61 
62 const pos = getPos();
63 if (typeof pos !== "number") return;
64 
65 prepareBlockDragPreview(editor, pos, event.dataTransfer);
66 };
67 
68 document.addEventListener("dragstart", handleDocumentDragStart, true);
69 return () => document.removeEventListener("dragstart", handleDocumentDragStart, true);
70 }, [editable, editor, getPos, liveWidth]);
71 
72 const handleResizeStart = useCallback(
73 (side: "left" | "right", e: React.PointerEvent) => {
74 e.preventDefault();
75 e.stopPropagation();
76 const img = imgRef.current;
77 if (!img || img.offsetWidth <= 0) return;
78 
79 (e.target as HTMLElement).setPointerCapture(e.pointerId);
80 
81 finishDrag();
82 const ac = new AbortController();
83 abortRef.current = ac;
84 
85 const startX = e.clientX;
86 const startWidth = img.offsetWidth;
87 const minWidth = Math.min(40, startWidth);
88 const dir = side === "right" ? 1 : -1;
89 
90 document.addEventListener(
91 "pointermove",
92 (ev: PointerEvent) => {
93 const newWidth = Math.max(minWidth, startWidth + dir * (ev.clientX - startX));
94 dragRef.current = newWidth;
95 setLiveWidth(newWidth);
96 },
97 { signal: ac.signal },
98 );
99 
100 document.addEventListener(
101 "pointerup",
102 () => {
103 if (dragRef.current !== null) {
104 updateAttributes({ width: Math.round(dragRef.current) });
105 }
106 finishDrag();
107 },
108 { signal: ac.signal },
109 );
110 },
111 [updateAttributes, finishDrag],
112 );
113 const displayWidth = liveWidth ?? (typeof width === "number" && width > 0 ? width : undefined);
114 const alignClass =
115 align === "center"
116 ? "tiptap-image-node--align-center"
117 : align === "right"
118 ? "tiptap-image-node--align-right"
119 : "tiptap-image-node--align-left";
120 
121 const uploadCtx = useMemo(() => ({ workspaceId, pageId, shareToken }), [pageId, shareToken, workspaceId]);
122 const openImagePanel = useCallback(() => {
123 if (!editor.isEditable || !canInsertImages) return;
124 const pos = getPos();
125 if (typeof pos !== "number") return;
126 showImageInsertPanel(editor, {
127 uploadContext: uploadCtx,
128 target: createImageNodeTarget(editor, pos),
129 });
130 }, [canInsertImages, editor, getPos, uploadCtx]);
131 
132 const aspectRatio =
133 typeof naturalWidth === "number" && typeof naturalHeight === "number" && naturalWidth > 0 && naturalHeight > 0
134 ? naturalWidth / naturalHeight
135 : FALLBACK_ASPECT_RATIO;
136 const naturalDisplayWidth = typeof naturalWidth === "number" && naturalWidth > 0 ? naturalWidth : undefined;
137 const frameWidth = displayWidth ?? naturalDisplayWidth;
138 const frameStyle: React.CSSProperties = {
139 aspectRatio: String(aspectRatio),
140 width: frameWidth ? `${frameWidth}px` : "100%",
141 };
142 const pendingPreviewUrl = typeof pendingInsertId === "string" ? getLocalImagePreview(pendingInsertId) : null;
143 
144 if (!resolvedSrc) {
145 if (pendingPreviewUrl) {
146 return (
147 <NodeViewWrapper className={`tiptap-image-node ${alignClass}`}>
148 <div className="tiptap-image-uploading" style={frameStyle}>
149 <img className="tiptap-image-uploading-preview" src={pendingPreviewUrl} alt="" />
150 <Skeleton className="tiptap-image-uploading-skeleton" />
151 <span className="tiptap-image-uploading-label">Uploading…</span>
152 </div>
153 </NodeViewWrapper>
154 );
155 }
156 return (
157 <NodeViewWrapper>
158 <div
159 className="tiptap-image-placeholder"
160 onClick={editor.isEditable && canInsertImages ? openImagePanel : undefined}
161 style={editor.isEditable && canInsertImages ? undefined : { cursor: "default" }}
162 >
163 <ImageIcon size={20} />
164 <span>{editor.isEditable && canInsertImages ? "Add an image" : "Image"}</span>
165 {editor.isEditable && (
166 <button
167 type="button"
168 title="Remove"
169 className="tiptap-image-placeholder-close"
170 onClick={(e) => {
171 e.stopPropagation();
172 deleteNode();
173 }}
174 >
175 <X size={14} />
176 </button>
177 )}
178 </div>
179 </NodeViewWrapper>
180 );
181 }
182 
183 const isLoading = loadStatus === "loading";
184 const isErrored = loadStatus === "errored";
185 const containerStyle: React.CSSProperties | undefined =
186 isLoading || isErrored ? frameStyle : displayWidth ? { width: `${displayWidth}px` } : undefined;
187 
188 const containerClass = [
189 "tiptap-image-container",
190 selected ? "is-selected" : "",
191 isLoading ? "is-loading" : "",
192 isErrored ? "is-errored" : "",
193 ]
194 .filter(Boolean)
195 .join(" ");
196 
197 return (
198 <NodeViewWrapper className={`tiptap-image-node ${alignClass}`}>
199 <div className={containerClass} style={containerStyle}>
200 <ImageElement
201 ref={imgRef}
202 src={resolvedSrc}
203 brightness={brightness}
204 alt={alt ?? undefined}
205 title={title ?? undefined}
206 draggable={false}
207 data-drag-handle={editable && !isLoading && !isErrored ? "" : undefined}
208 style={displayWidth && !isLoading ? { width: "100%" } : undefined}
209 onLoad={() => setLoadState({ src: resolvedSrc, status: "loaded" })}
210 onError={() => setLoadState({ src: resolvedSrc, status: "errored" })}
211 />
212 {isLoading && <ImageLoadingSkeleton />}
213 {isErrored && <ImageErrorState />}
214 {editable && !isLoading && !isErrored && (
215 <>
216 <div className="tiptap-image-resize-handle left" onPointerDown={(e) => handleResizeStart("left", e)} />
217 <div className="tiptap-image-resize-handle right" onPointerDown={(e) => handleResizeStart("right", e)} />
218 </>
219 )}
220 {alt && !isLoading && !isErrored && <figcaption className="tiptap-image-alt">{alt}</figcaption>}
221 </div>
222 </NodeViewWrapper>
223 );
224}