Skip to content
File

Blob: src/client/components/editor/controllers/image/toolbar.tsx

typescript428 lines
1import { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect, useId, type CSSProperties } from "react";
2import { useTiptap, useTiptapState, type Editor } from "@tiptap/react";
3import { NodeSelection } from "@tiptap/pm/state";
4import { autoUpdate as autoUpdateDom, computePosition, offset, shift } from "@floating-ui/dom";
5import { FloatingPortal } from "@floating-ui/react";
6import { Replace, Trash2, TextCursorInput, AlignLeft, AlignCenter, AlignRight, RotateCcw } from "lucide-react";
7import {
8 MAX_IMAGE_BRIGHTNESS,
9 MIN_IMAGE_BRIGHTNESS,
10 normalizeImageBrightness,
11} from "@/shared/editor/schema/image-model";
12import { ToolbarButton } from "../toolbar-button";
13import { useEditorAffordance } from "../../editor-affordance-context";
14import { useEditorRuntime } from "../../editor-runtime-context";
15import {
16 createImageNodeTarget,
17 deleteImageAtTarget,
18 triggerFileUploadAtTarget,
19 updateImageAttributesAtTarget,
20} from "../../lib/media-actions";
21import "../../styles/floating-controls.css";
22 
23interface ImageState {
24 pos: number;
25 src: string;
26 alt: string;
27 align: string;
28 brightness: number;
29}
30 
31interface ImageInsetState {
32 key: string | null;
33 value: boolean;
34}
35 
36interface ImageFloatingState {
37 key: string | null;
38 styles: CSSProperties;
39}
40 
41interface ImageAltEditState {
42 key: string | null;
43 editing: boolean;
44 text: string;
45}
46 
47const IMAGE_TOOLBAR_MIN_INSET_WIDTH = 236;
48const IMAGE_TOOLBAR_MIN_INSET_HEIGHT = 88;
49 
50function canInsetImageToolbar(
51 rect: Pick<DOMRectReadOnly, "width" | "height">,
52 toolbar: Pick<DOMRectReadOnly, "width" | "height">,
53 editingAlt: boolean,
54) {
55 return (
56 !editingAlt &&
57 rect.width >= Math.max(IMAGE_TOOLBAR_MIN_INSET_WIDTH, toolbar.width + 16) &&
58 rect.height >= Math.max(IMAGE_TOOLBAR_MIN_INSET_HEIGHT, toolbar.height + 16)
59 );
60}
61 
62function hiddenToolbarStyles(): CSSProperties {
63 return {
64 position: "fixed",
65 left: 0,
66 top: 0,
67 visibility: "hidden",
68 };
69}
70 
71function sameToolbarStyles(a: CSSProperties, b: CSSProperties) {
72 return a.left === b.left && a.top === b.top && a.position === b.position && a.visibility === b.visibility;
73}
74 
75function sameFloatingState(a: ImageFloatingState, b: ImageFloatingState) {
76 return a.key === b.key && sameToolbarStyles(a.styles, b.styles);
77}
78 
79function resolveImageReference(editor: Editor, pos: number) {
80 const dom = editor.view.nodeDOM(pos);
81 if (!(dom instanceof HTMLElement)) return null;
82 const container = dom.querySelector(".tiptap-image-container") ?? dom;
83 return container instanceof HTMLElement ? container : null;
84}
85 
86export function ImageToolbar() {
87 const brightnessId = useId();
88 const { editor } = useTiptap();
89 const { workspaceId, pageId, shareToken } = useEditorRuntime();
90 const affordance = useEditorAffordance();
91 const [altEditState, setAltEditState] = useState<ImageAltEditState>({ key: null, editing: false, text: "" });
92 const [insetState, setInsetState] = useState<ImageInsetState>({ key: null, value: false });
93 const [floatingElement, setFloatingElement] = useState<HTMLDivElement | null>(null);
94 const [floatingState, setFloatingState] = useState<ImageFloatingState>(() => ({
95 key: null,
96 styles: hiddenToolbarStyles(),
97 }));
98 const forcedPosRef = useRef<number | null>(null);
99 const lastFloatingStateRef = useRef<ImageFloatingState>({ key: null, styles: hiddenToolbarStyles() });
100 
101 const imageState = useTiptapState((ctx): ImageState | null => {
102 const { selection } = ctx.editor.state;
103 
104 if (forcedPosRef.current !== null) {
105 const node = ctx.editor.state.doc.nodeAt(forcedPosRef.current);
106 if (node?.type.name === "image" && node.attrs.src) {
107 return {
108 pos: forcedPosRef.current,
109 src: node.attrs.src as string,
110 alt: (node.attrs.alt as string) ?? "",
111 align: (node.attrs.align as string) ?? "left",
112 brightness: normalizeImageBrightness(node.attrs.brightness),
113 };
114 }
115 forcedPosRef.current = null;
116 }
117 
118 if (!(selection instanceof NodeSelection)) return null;
119 const node = selection.node;
120 if (node.type.name !== "image" || !node.attrs.src) return null;
121 return {
122 pos: selection.from,
123 src: node.attrs.src as string,
124 alt: (node.attrs.alt as string) ?? "",
125 align: (node.attrs.align as string) ?? "left",
126 brightness: normalizeImageBrightness(node.attrs.brightness),
127 };
128 });
129 
130 const imageKey = imageState ? `${imageState.pos}:${imageState.src}` : null;
131 const imagePos = imageState?.pos ?? null;
132 const floatingStyles = floatingState.key === imageKey ? floatingState.styles : hiddenToolbarStyles();
133 const useInsetPosition = insetState.key === imageKey ? insetState.value : false;
134 const editingAlt = imageKey !== null && altEditState.key === imageKey && altEditState.editing;
135 const altText = altEditState.key === imageKey ? altEditState.text : "";
136 const open = imageState !== null;
137 
138 const placement = useInsetPosition ? "top-end" : "top";
139 const middleware = useMemo(
140 () => [
141 offset(({ rects }) => (useInsetPosition ? { mainAxis: -rects.floating.height - 8, crossAxis: -8 } : 10)),
142 shift({ padding: 10, crossAxis: true }),
143 ],
144 [useInsetPosition],
145 );
146 
147 const commitFloatingState = useCallback((next: ImageFloatingState) => {
148 if (sameFloatingState(lastFloatingStateRef.current, next)) return;
149 lastFloatingStateRef.current = next;
150 setFloatingState(next);
151 }, []);
152 
153 const hideToolbar = useCallback(() => {
154 commitFloatingState({ key: null, styles: hiddenToolbarStyles() });
155 }, [commitFloatingState]);
156 
157 const setFloatingNode = useCallback(
158 (node: HTMLDivElement | null) => {
159 if (!node) {
160 hideToolbar();
161 }
162 setFloatingElement(node);
163 },
164 [hideToolbar],
165 );
166 
167 useLayoutEffect(() => {
168 if (!open || imagePos === null || !imageKey || !floatingElement) {
169 hideToolbar();
170 return;
171 }
172 
173 const initialReference = resolveImageReference(editor, imagePos);
174 if (!initialReference) {
175 hideToolbar();
176 return;
177 }
178 
179 let active = true;
180 
181 // The image toolbar anchors to a selected node view owned outside this
182 // component tree. Position it from the resolved DOM node directly instead
183 // of useFloating's reference lifecycle; otherwise it can render at (0, 0)
184 // even though the image selection is correct.
185 const updatePosition = async () => {
186 const referenceEl = resolveImageReference(editor, imagePos);
187 if (!referenceEl) {
188 if (active) hideToolbar();
189 return;
190 }
191 
192 const next = await computePosition(referenceEl, floatingElement, {
193 placement,
194 strategy: "fixed",
195 middleware,
196 });
197 
198 if (!active) return;
199 
200 commitFloatingState({
201 key: imageKey,
202 styles: {
203 position: "fixed",
204 left: next.x,
205 top: next.y,
206 },
207 });
208 };
209 
210 const cleanup = autoUpdateDom(initialReference, floatingElement, updatePosition);
211 void updatePosition();
212 
213 return () => {
214 active = false;
215 cleanup();
216 };
217 }, [commitFloatingState, editor, floatingElement, hideToolbar, imageKey, imagePos, middleware, open, placement]);
218 
219 useEffect(() => {
220 if (!imageKey || imagePos === null || !floatingElement) return;
221 
222 const referenceEl = resolveImageReference(editor, imagePos);
223 if (!referenceEl) return;
224 
225 let rafId: number | null = null;
226 const updatePlacement = () => {
227 const currentReference = resolveImageReference(editor, imagePos);
228 if (!currentReference) return;
229 const nextInsetPosition = canInsetImageToolbar(
230 currentReference.getBoundingClientRect(),
231 floatingElement.getBoundingClientRect(),
232 editingAlt,
233 );
234 setInsetState((current) =>
235 current.key === imageKey && current.value === nextInsetPosition
236 ? current
237 : { key: imageKey, value: nextInsetPosition },
238 );
239 };
240 const schedule = () => {
241 if (rafId !== null) return;
242 rafId = requestAnimationFrame(() => {
243 rafId = null;
244 updatePlacement();
245 });
246 };
247 updatePlacement();
248 
249 const observer = new ResizeObserver(schedule);
250 observer.observe(referenceEl);
251 observer.observe(floatingElement);
252 return () => {
253 if (rafId !== null) cancelAnimationFrame(rafId);
254 observer.disconnect();
255 };
256 }, [editor, editingAlt, floatingElement, imageKey, imagePos]);
257 
258 const updateImageAttr = useCallback(
259 (attrs: Record<string, unknown>) => {
260 if (!imageState) return;
261 updateImageAttributesAtTarget(editor, createImageNodeTarget(editor, imageState.pos), attrs);
262 },
263 [editor, imageState],
264 );
265 
266 const deleteImage = useCallback(() => {
267 if (!imageState) return;
268 deleteImageAtTarget(editor, createImageNodeTarget(editor, imageState.pos));
269 }, [editor, imageState]);
270 
271 const handleAltEdit = () => {
272 if (!imageState || !imageKey) return;
273 forcedPosRef.current = imageState.pos;
274 setAltEditState({ key: imageKey, editing: true, text: imageState.alt });
275 };
276 
277 const handleAltSave = () => {
278 updateImageAttr({ alt: altText || null });
279 forcedPosRef.current = null;
280 setAltEditState((current) => (current.key === imageKey ? { ...current, editing: false } : current));
281 };
282 
283 const handleAltCancel = () => {
284 forcedPosRef.current = null;
285 setAltEditState((current) => (current.key === imageKey ? { ...current, editing: false } : current));
286 };
287 
288 const handleAltInput = (text: string) => {
289 if (!imageKey) return;
290 setAltEditState((current) =>
291 current.key === imageKey ? { ...current, text } : { key: imageKey, editing: true, text },
292 );
293 };
294 
295 const uploadCtx = useMemo(() => ({ workspaceId, pageId, shareToken }), [pageId, shareToken, workspaceId]);
296 
297 if (!editor || !affordance.documentEditable || !open) return null;
298 const isEditingAlt = editingAlt && imageState !== null;
299 
300 return (
301 <FloatingPortal>
302 <div
303 ref={setFloatingNode}
304 style={{ ...floatingStyles, zIndex: 50 }}
305 className="tiptap-toolbar tiptap-image-toolbar"
306 onMouseDownCapture={(e) => {
307 if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
308 }}
309 >
310 {isEditingAlt ? (
311 <div className="flex items-center gap-1">
312 <input
313 type="text"
314 value={altText}
315 onChange={(e) => handleAltInput(e.target.value)}
316 onKeyDown={(e) => {
317 if (e.key === "Enter") handleAltSave();
318 if (e.key === "Escape") handleAltCancel();
319 }}
320 placeholder="Alt text..."
321 className="tiptap-link-input"
322 autoFocus
323 />
324 <button
325 type="button"
326 title="Save"
327 aria-label="Save"
328 onMouseDown={(e) => {
329 e.preventDefault();
330 handleAltSave();
331 }}
332 >
333 <TextCursorInput size={16} />
334 </button>
335 </div>
336 ) : (
337 <>
338 <div className="tiptap-image-toolbar-actions">
339 <button
340 type="button"
341 title="Replace image"
342 aria-label="Replace image"
343 disabled={!affordance.canInsertImages}
344 onMouseDown={(e) => {
345 e.preventDefault();
346 if (!imageState) return;
347 if (!affordance.canInsertImages) return;
348 triggerFileUploadAtTarget(editor, uploadCtx, createImageNodeTarget(editor, imageState.pos));
349 }}
350 >
351 <Replace size={16} />
352 </button>
353 <button
354 type="button"
355 title="Alt text"
356 aria-label="Alt text"
357 onMouseDown={(e) => {
358 e.preventDefault();
359 handleAltEdit();
360 }}
361 >
362 <TextCursorInput size={16} />
363 </button>
364 <button
365 type="button"
366 title="Delete image"
367 aria-label="Delete image"
368 className="text-red-400"
369 onMouseDown={(e) => {
370 e.preventDefault();
371 deleteImage();
372 }}
373 >
374 <Trash2 size={16} />
375 </button>
376 <div className="tiptap-toolbar-sep" />
377 {(["left", "center", "right"] as const).map((a) => {
378 const Icon = a === "left" ? AlignLeft : a === "center" ? AlignCenter : AlignRight;
379 return (
380 <button
381 key={a}
382 type="button"
383 title={`Align ${a}`}
384 aria-label={`Align ${a}`}
385 className={imageState.align === a ? "is-active" : undefined}
386 onMouseDown={(e) => {
387 e.preventDefault();
388 updateImageAttr({ align: a });
389 }}
390 >
391 <Icon size={16} />
392 </button>
393 );
394 })}
395 </div>
396 <div className="tiptap-image-brightness">
397 <div className="tiptap-image-brightness-label">
398 <label htmlFor={brightnessId}>Brightness</label>
399 <output htmlFor={brightnessId} aria-hidden="true">
400 {imageState.brightness}%
401 </output>
402 <ToolbarButton
403 title="Reset brightness"
404 disabled={imageState.brightness === MAX_IMAGE_BRIGHTNESS}
405 onActivate={() => updateImageAttr({ brightness: MAX_IMAGE_BRIGHTNESS })}
406 >
407 <RotateCcw size={14} />
408 </ToolbarButton>
409 </div>
410 <input
411 id={brightnessId}
412 type="range"
413 min={MIN_IMAGE_BRIGHTNESS}
414 max={MAX_IMAGE_BRIGHTNESS}
415 step={1}
416 value={imageState.brightness}
417 aria-label="Image brightness"
418 aria-valuetext={`${imageState.brightness}%`}
419 onChange={(event) => updateImageAttr({ brightness: Number(event.currentTarget.value) })}
420 />
421 </div>
422 </>
423 )}
424 </div>
425 </FloatingPortal>
426 );
427}