File
Blob: src/client/components/editor/controllers/image/toolbar.tsx
| 1 | import { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect, useId, type CSSProperties } from "react"; |
| 2 | import { useTiptap, useTiptapState, type Editor } from "@tiptap/react"; |
| 3 | import { NodeSelection } from "@tiptap/pm/state"; |
| 4 | import { autoUpdate as autoUpdateDom, computePosition, offset, shift } from "@floating-ui/dom"; |
| 5 | import { FloatingPortal } from "@floating-ui/react"; |
| 6 | import { Replace, Trash2, TextCursorInput, AlignLeft, AlignCenter, AlignRight, RotateCcw } from "lucide-react"; |
| 7 | import { |
| 8 | MAX_IMAGE_BRIGHTNESS, |
| 9 | MIN_IMAGE_BRIGHTNESS, |
| 10 | normalizeImageBrightness, |
| 11 | } from "@/shared/editor/schema/image-model"; |
| 12 | import { ToolbarButton } from "../toolbar-button"; |
| 13 | import { useEditorAffordance } from "../../editor-affordance-context"; |
| 14 | import { useEditorRuntime } from "../../editor-runtime-context"; |
| 15 | import { |
| 16 | createImageNodeTarget, |
| 17 | deleteImageAtTarget, |
| 18 | triggerFileUploadAtTarget, |
| 19 | updateImageAttributesAtTarget, |
| 20 | } from "../../lib/media-actions"; |
| 21 | import "../../styles/floating-controls.css"; |
| 22 | |
| 23 | interface ImageState { |
| 24 | pos: number; |
| 25 | src: string; |
| 26 | alt: string; |
| 27 | align: string; |
| 28 | brightness: number; |
| 29 | } |
| 30 | |
| 31 | interface ImageInsetState { |
| 32 | key: string | null; |
| 33 | value: boolean; |
| 34 | } |
| 35 | |
| 36 | interface ImageFloatingState { |
| 37 | key: string | null; |
| 38 | styles: CSSProperties; |
| 39 | } |
| 40 | |
| 41 | interface ImageAltEditState { |
| 42 | key: string | null; |
| 43 | editing: boolean; |
| 44 | text: string; |
| 45 | } |
| 46 | |
| 47 | const IMAGE_TOOLBAR_MIN_INSET_WIDTH = 236; |
| 48 | const IMAGE_TOOLBAR_MIN_INSET_HEIGHT = 88; |
| 49 | |
| 50 | function 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 | |
| 62 | function hiddenToolbarStyles(): CSSProperties { |
| 63 | return { |
| 64 | position: "fixed", |
| 65 | left: 0, |
| 66 | top: 0, |
| 67 | visibility: "hidden", |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | function 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 | |
| 75 | function sameFloatingState(a: ImageFloatingState, b: ImageFloatingState) { |
| 76 | return a.key === b.key && sameToolbarStyles(a.styles, b.styles); |
| 77 | } |
| 78 | |
| 79 | function 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 | |
| 86 | export 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 | } |