"use client" import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react" import { NodeSelection } from "@tiptap/pm/state" import { computeGraphicLayoutStyle, resizeWithHandle, type ResizeHandle, RESIZE_HANDLES, } from "@/lib/drive/docs-graphic-layout" import { normalizePageCoords, pageLayerElementId, pageLayerSlot, usesPageLayer, } from "@/lib/drive/docs-graphic-position" import { collectOtherPageGraphicRects, readPageSnapContextFromStack, snapMoveRect, snapResizeRect, type SnapRect, } from "@/lib/drive/docs-graphic-snap" import { clearDocsGraphicSnapGuides, setDocsGraphicSnapGuides, } from "@/lib/drive/docs-graphic-snap-bridge" import { computeCropDisplayGeometry, computeCropImageStyle, computeGraphicFilterCss, computeImageFitStyle, hasActiveCrop, computeCropApplyPatch, computeCropReeditInitialRegion, usesCropImageFit, parseGraphicAttrs, type DocsGraphicAttrs, type DocsShapeType, } from "@/lib/drive/docs-graphic-types" import { DocsGraphicCropOverlay } from "@/components/drive/richtext/docs-graphic-crop-overlay" import { DOCS_GRAPHIC_CROP_APPLY_EVENT, DOCS_GRAPHIC_CROP_START_EVENT, notifyDocsGraphicCropChanged, } from "@/lib/drive/docs-graphic-crop-bridge" import { DocsGraphicContextMenu } from "@/components/drive/richtext/docs-graphic-context-menu" import { openDocsGraphicOptionsSidebar } from "@/components/drive/richtext/docs-graphic-options-sidebar" import { cn } from "@/lib/utils" function clampPan(value: number, cropSize: number): number { return Math.min(Math.max(value, 0), Math.max(0, 1 - cropSize)) } function readPageStackContext(host: HTMLElement | null) { const stack = host?.closest("[data-docs-page-stack]") as HTMLElement | null if (!stack) return null const scale = Number.parseFloat(stack.dataset.docsPageScale ?? "1") || 1 const pageHeight = Number.parseFloat(stack.dataset.docsPageHeight ?? "0") || 0 const pageWidth = Number.parseFloat(stack.dataset.docsPageWidth ?? "0") || 0 return { stack, scale, pageHeight, pageWidth } } function readGraphicSnapContext( stack: HTMLElement | null, editor: NodeViewProps["editor"], pageIndex: number, selfPos: number | null ) { const otherRects = collectOtherPageGraphicRects( editor.state.doc, pageIndex, selfPos ) return readPageSnapContextFromStack(stack, otherRects) } function publishSnapGuides( pageIndex: number, pageWidth: number, pageHeight: number, guides: ReturnType["guides"] ) { if (guides.length === 0) { clearDocsGraphicSnapGuides() return } setDocsGraphicSnapGuides({ pageIndex, pageWidth, pageHeight, guides }) } function ShapePreview({ shapeType, fill, stroke, strokeWidth, }: { shapeType: DocsShapeType fill: string stroke: string strokeWidth: number }) { if (shapeType === "ellipse") { return ( ) } if (shapeType === "line") { return ( ) } if (shapeType === "arrow") { return ( ) } return ( ) } function CropModeImageLayer({ attrs, imageRect, windowRect, bakedImage, panX, panY, fitStyle, filter, onImageNaturalSize, onCropImagePan, }: { attrs: DocsGraphicAttrs imageRect: { left: number; top: number; width: number; height: number } windowRect: { left: number; top: number; width: number; height: number } bakedImage: boolean panX: number panY: number fitStyle: ReturnType filter: string onImageNaturalSize?: (width: number, height: number) => void onCropImagePan?: ( dxNorm: number, dyNorm: number, origin: { panX: number; panY: number } ) => void }) { const panRef = useRef<{ startX: number startY: number origin: { panX: number; panY: number } scale: number windowWidth: number windowHeight: number } | null>(null) useEffect(() => { const onMove = (event: PointerEvent) => { if (!panRef.current || !onCropImagePan) return const { startX, startY, origin, scale, windowWidth, windowHeight } = panRef.current const dxNorm = (event.clientX - startX) / scale / Math.max(windowWidth, 1) const dyNorm = (event.clientY - startY) / scale / Math.max(windowHeight, 1) onCropImagePan(dxNorm, dyNorm, origin) } const onUp = () => { panRef.current = null } window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", onUp) window.addEventListener("pointercancel", onUp) return () => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) window.removeEventListener("pointercancel", onUp) } }, [onCropImagePan]) return (
{ if (!onCropImagePan) return event.preventDefault() event.stopPropagation() ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) const host = event.currentTarget.parentElement const hostRect = host?.getBoundingClientRect() const scale = hostRect && attrs.width > 0 ? hostRect.width / attrs.width : 1 panRef.current = { startX: event.clientX, startY: event.clientY, origin: { panX, panY }, scale, windowWidth: windowRect.width, windowHeight: windowRect.height, } }} > {attrs.alt { onImageNaturalSize?.(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight) }} />
) } function GraphicContent({ attrs, cropMode, cropEditBase, cropPanX, cropPanY, imageNaturalWidth, imageNaturalHeight, onImageNaturalSize, onCropImagePan, }: { attrs: DocsGraphicAttrs cropMode?: boolean cropEditBase?: Pick | null imageNaturalWidth: number imageNaturalHeight: number onImageNaturalSize?: (width: number, height: number) => void cropPanX: number cropPanY: number onCropImagePan?: (dxNorm: number, dyNorm: number, origin: { panX: number; panY: number }) => void }) { if (attrs.graphicType === "image") { if (!attrs.src) { return (
Image
) } const filter = computeGraphicFilterCss(attrs) const fitStyle = computeImageFitStyle(attrs) if (cropMode && imageNaturalWidth > 0 && imageNaturalHeight > 0) { const { imageRect, windowRect } = computeCropDisplayGeometry( attrs, attrs.width, attrs.height, imageNaturalWidth, imageNaturalHeight, cropEditBase ) return ( ) } const cropStyle = usesCropImageFit(attrs.imageFit) ? computeCropImageStyle( attrs, attrs.width, attrs.height, imageNaturalWidth, imageNaturalHeight ) : { img: {} } const usesCropTransform = Object.keys(cropStyle.img).length > 0 return ( {attrs.alt { onImageNaturalSize?.(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight) }} /> ) } if (attrs.graphicType === "gradient") { return (
) } if (attrs.graphicType === "draw") { if (!attrs.src) { return (
Dessin
) } return ( {attrs.alt ) } return ( ) } function ResizeHandleBtn({ handle, onPointerDown, }: { handle: ResizeHandle onPointerDown: (handle: ResizeHandle, event: React.PointerEvent) => void }) { const posClass: Record = { nw: "left-0 top-0 cursor-nwse-resize", n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize", ne: "right-0 top-0 cursor-nesw-resize", e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize", se: "right-0 bottom-0 cursor-nwse-resize", s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize", sw: "left-0 bottom-0 cursor-nesw-resize", w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize", } return ( { event.preventDefault() event.stopPropagation() onPointerDown(handle, event) }} /> ) } function RotationHandle({ onPointerDown, }: { onPointerDown: (event: React.PointerEvent) => void }) { return ( { event.preventDefault() event.stopPropagation() onPointerDown(event) }} > ) } function DocsGraphicNodeViewInner({ node, updateAttributes, selected, editor, getPos, extension, }: NodeViewProps) { const attrs = parseGraphicAttrs(node.attrs as Record) const attrsRef = useRef(attrs) attrsRef.current = attrs const nodePos = typeof getPos() === "number" ? getPos() : null const hostRef = useRef(null) const onPageLayer = usesPageLayer(attrs) const layerSlot = onPageLayer ? pageLayerSlot(attrs) : null const pageLayerChromeOnFront = onPageLayer // Page-stack context is only readable from the DOM, so resolve it after // mount; otherwise fixed graphics render at the wrong spot until the next // editor transaction. const [pageHeight, setPageHeight] = useState(0) const [mounted, setMounted] = useState(false) useLayoutEffect(() => { if (!mounted) setMounted(true) const ctx = readPageStackContext(hostRef.current) if (ctx && ctx.pageHeight !== pageHeight) setPageHeight(ctx.pageHeight) }) const pageLayerEl = mounted && layerSlot ? document.getElementById(pageLayerElementId(layerSlot)) : null const frontLayerEl = mounted && pageLayerChromeOnFront ? document.getElementById(pageLayerElementId("front")) : null const layout = computeGraphicLayoutStyle(attrs, { pageHeight: pageHeight || undefined }) const editable = editor.isEditable const inline = extension.name === "docsInlineGraphic" const replaceInputRef = useRef(null) const dragRef = useRef<{ startX: number startY: number pageIndex: number pageX: number pageY: number scale: number } | null>(null) const pendingDragRef = useRef<{ pointerId: number startX: number startY: number host: HTMLElement } | null>(null) const resizeRef = useRef<{ handle: ResizeHandle startX: number startY: number width: number height: number pageX: number pageY: number offsetX: number offsetY: number onPageLayer: boolean lockAspect: boolean scale: number } | null>(null) const rotateRef = useRef<{ centerX: number centerY: number startAngle: number originRotation: number } | null>(null) const [interacting, setInteracting] = useState(false) const [trackingPointer, setTrackingPointer] = useState(false) const [cropMode, setCropMode] = useState(false) const [cropPan, setCropPan] = useState({ x: 0, y: 0 }) const [imageNaturalSize, setImageNaturalSize] = useState({ width: 0, height: 0 }) const [cropEditBase, setCropEditBase] = useState | null>(null) const startCrop = useCallback(() => { setCropPan({ x: 0, y: 0 }) const current = attrsRef.current const natW = imageNaturalSize.width const natH = imageNaturalSize.height if (usesCropImageFit(current.imageFit) && hasActiveCrop(current)) { const base = { cropX: current.cropX, cropY: current.cropY, cropWidth: current.cropWidth, cropHeight: current.cropHeight, } setCropEditBase(base) if (natW > 0 && natH > 0) { updateAttributes( computeCropReeditInitialRegion(base, current.width, current.height, natW, natH) ) } else { updateAttributes({ cropX: 0, cropY: 0, cropWidth: 1, cropHeight: 1 }) } } else { setCropEditBase(null) updateAttributes({ cropX: 0, cropY: 0, cropWidth: 1, cropHeight: 1 }) } setCropMode(true) }, [imageNaturalSize.height, imageNaturalSize.width, updateAttributes]) const finishCrop = useCallback(() => { const current = attrsRef.current const natW = imageNaturalSize.width const natH = imageNaturalSize.height if (natW > 0 && natH > 0) { updateAttributes( computeCropApplyPatch( current, cropPan.x, cropPan.y, natW, natH, cropEditBase ) ) } else { updateAttributes({ imageFit: "crop" }) } setCropEditBase(null) setCropPan({ x: 0, y: 0 }) setCropMode(false) }, [cropEditBase, cropPan.x, cropPan.y, imageNaturalSize.height, imageNaturalSize.width, updateAttributes]) useEffect(() => { if (!selected && cropMode) finishCrop() }, [cropMode, finishCrop, selected]) useEffect(() => { notifyDocsGraphicCropChanged(cropMode) }, [cropMode]) useEffect(() => { const onStart = () => { if (!selected || !editable || attrs.graphicType !== "image") return startCrop() } const onApply = () => { if (!selected || !cropMode) return finishCrop() } window.addEventListener(DOCS_GRAPHIC_CROP_START_EVENT, onStart) window.addEventListener(DOCS_GRAPHIC_CROP_APPLY_EVENT, onApply) return () => { window.removeEventListener(DOCS_GRAPHIC_CROP_START_EVENT, onStart) window.removeEventListener(DOCS_GRAPHIC_CROP_APPLY_EVENT, onApply) } }, [attrs.graphicType, cropMode, editable, finishCrop, selected, startCrop]) // Custom drag only applies to page-layer graphics; in-flow graphics use // ProseMirror's native drag & drop so they stay anchored in the text. const onDragPointerDown = useCallback( (event: React.PointerEvent) => { if (!editable || cropMode) return if ((event.target as HTMLElement).closest(".docs-graphic-handle, .docs-graphic-rotate-handle, .docs-graphic-crop")) { return } event.preventDefault() event.stopPropagation() const captureTarget = hostRef.current?.isConnected ? hostRef.current : (event.currentTarget as HTMLElement) try { captureTarget.setPointerCapture(event.pointerId) } catch { // Pointer already released: window-level listeners still handle the drag. } pendingDragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, host: captureTarget, } setTrackingPointer(true) }, [cropMode, editable] ) const onResizeStart = useCallback( (handle: ResizeHandle, event: React.PointerEvent) => { if (!editable || cropMode) return event.preventDefault() ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) const a = attrsRef.current resizeRef.current = { handle, startX: event.clientX, startY: event.clientY, width: a.width, height: a.height, pageX: a.pageX, pageY: a.pageY, offsetX: a.x, offsetY: a.y, onPageLayer, lockAspect: a.graphicType === "image" ? a.lockAspectRatio !== false && handle.length === 2 : event.shiftKey || handle.length === 2, scale: readPageStackContext(hostRef.current)?.scale ?? 1, } setInteracting(true) }, [cropMode, editable, onPageLayer] ) const onRotateStart = useCallback( (event: React.PointerEvent) => { if (!editable || cropMode) return event.preventDefault() ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) const host = (event.currentTarget as HTMLElement).closest(".docs-graphic") as HTMLElement const rect = host.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2 const startAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX) rotateRef.current = { centerX, centerY, startAngle, originRotation: attrsRef.current.rotationDeg, } setInteracting(true) }, [cropMode, editable] ) useEffect(() => { const stack = hostRef.current?.closest("[data-docs-page-stack]") as HTMLElement | null if (interacting || trackingPointer) { stack?.setAttribute("data-graphic-dragging", "true") } else { stack?.removeAttribute("data-graphic-dragging") } return () => stack?.removeAttribute("data-graphic-dragging") }, [interacting, trackingPointer]) useEffect(() => { if (!interacting && !trackingPointer) return const onMove = (event: PointerEvent) => { if (pendingDragRef.current && !dragRef.current) { const pending = pendingDragRef.current if (pending.pointerId !== event.pointerId) return const dx = event.clientX - pending.startX const dy = event.clientY - pending.startY if (Math.hypot(dx, dy) < 4) return // Selecting can re-portal the graphic (behind -> front slot), so the // original pointerdown target may be disconnected; capture is a UX // nicety only (listeners are window-level), never let it throw. const captureTarget = hostRef.current?.isConnected ? hostRef.current : pending.host.isConnected ? pending.host : null try { captureTarget?.setPointerCapture(event.pointerId) } catch { // Pointer already released or invalid: ignore. } const a = attrsRef.current dragRef.current = { startX: pending.startX, startY: pending.startY, pageIndex: a.pageIndex, pageX: a.pageX, pageY: a.pageY, scale: readPageStackContext(hostRef.current)?.scale ?? 1, } pendingDragRef.current = null setInteracting(true) } if (dragRef.current) { const { startX, startY, pageIndex, pageX, pageY, scale } = dragRef.current const dx = (event.clientX - startX) / scale const dy = (event.clientY - startY) / scale const a = attrsRef.current const ctxStack = readPageStackContext(hostRef.current) const snapCtx = readGraphicSnapContext( ctxStack?.stack ?? null, editor, pageIndex, typeof getPos() === "number" ? getPos() : null ) const rawRect: SnapRect = { x: pageX + dx, y: pageY + dy, width: a.width, height: a.height, } const snapped = snapCtx ? snapMoveRect(rawRect, snapCtx) : { rect: rawRect, guides: [] } publishSnapGuides( pageIndex, snapCtx?.pageWidth ?? ctxStack?.pageWidth ?? 0, snapCtx?.pageHeight ?? ctxStack?.pageHeight ?? 0, snapped.guides ) updateAttributes({ pageIndex, pageX: snapped.rect.x, pageY: snapped.rect.y, }) } if (resizeRef.current) { const { handle, startX, startY, width, height, pageX, pageY, offsetX, offsetY, onPageLayer: layerActive, lockAspect, scale, } = resizeRef.current const next = resizeWithHandle( handle, width, height, (event.clientX - startX) / scale, (event.clientY - startY) / scale, 24, lockAspect || event.shiftKey ) const patch: Partial = { width: next.width, height: next.height, } if (layerActive) { const tentative: SnapRect = { x: pageX + next.xOffset, y: pageY + next.yOffset, width: next.width, height: next.height, } const a = attrsRef.current const ctxStack = readPageStackContext(hostRef.current) const snapCtx = readGraphicSnapContext( ctxStack?.stack ?? null, editor, a.pageIndex, typeof getPos() === "number" ? getPos() : null ) const snapped = snapCtx ? snapResizeRect(handle, tentative, snapCtx) : { rect: tentative, guides: [] } publishSnapGuides( a.pageIndex, snapCtx?.pageWidth ?? ctxStack?.pageWidth ?? 0, snapCtx?.pageHeight ?? ctxStack?.pageHeight ?? 0, snapped.guides ) patch.pageX = snapped.rect.x patch.pageY = snapped.rect.y patch.width = snapped.rect.width patch.height = snapped.rect.height } else if (next.xOffset || next.yOffset) { patch.x = Math.round(offsetX + next.xOffset) patch.y = Math.round(offsetY + next.yOffset) } updateAttributes(patch) } if (rotateRef.current) { const { centerX, centerY, startAngle, originRotation } = rotateRef.current const angle = Math.atan2(event.clientY - centerY, event.clientX - centerX) const delta = ((angle - startAngle) * 180) / Math.PI updateAttributes({ rotationDeg: Math.round(originRotation + delta) }) } } const onUp = () => { clearDocsGraphicSnapGuides() if (dragRef.current) { // Re-resolve the page the graphic visually landed on. const a = attrsRef.current const ctx = readPageStackContext(hostRef.current) if (ctx && ctx.pageHeight > 0) { const norm = normalizePageCoords(a.pageIndex, a.pageY, ctx.pageHeight) if (norm.pageIndex !== a.pageIndex || norm.pageY !== a.pageY) { updateAttributes(norm) } } } dragRef.current = null pendingDragRef.current = null resizeRef.current = null rotateRef.current = null setInteracting(false) setTrackingPointer(false) } window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", onUp) window.addEventListener("pointercancel", onUp) return () => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) window.removeEventListener("pointercancel", onUp) } }, [interacting, trackingPointer, updateAttributes, editor, getPos]) const selectNode = useCallback(() => { const pos = getPos() if (typeof pos !== "number") return const sel = editor.state.selection if (sel instanceof NodeSelection && sel.from === pos) return editor.chain().focus().setNodeSelection(pos).run() }, [editor, getPos]) const onGraphicPointerDown = useCallback( (event: React.PointerEvent) => { if (!editable || !onPageLayer) return if ( (event.target as HTMLElement).closest( ".docs-graphic-handle, .docs-graphic-rotate-handle, .docs-graphic-crop" ) ) { return } selectNode() onDragPointerDown(event) }, [editable, onDragPointerDown, onPageLayer, selectNode] ) const onInFlowSelect = useCallback( (event: React.MouseEvent) => { if (!editable || onPageLayer || selected) return event.stopPropagation() selectNode() }, [editable, onPageLayer, selected, selectNode] ) const replaceImage = useCallback(() => { replaceInputRef.current?.click() }, []) const showSelectionChrome = selected && editable && !cropMode const selectionChromeOnFront = pageLayerChromeOnFront && showSelectionChrome const chromePortaledToFront = selectionChromeOnFront && Boolean(frontLayerEl) const layerInnerStyle = onPageLayer ? { ...layout.inner, position: "absolute" as const, pointerEvents: (chromePortaledToFront && selected ? "none" : "auto") as "none" | "auto", } : layout.inner const cropClip = attrs.graphicType === "image" && attrs.cropShape === "ellipse" && !cropMode && usesCropImageFit(attrs.imageFit) && hasActiveCrop(attrs) ? "ellipse(50% 50% at 50% 50%)" : undefined // In-flow graphics rely on ProseMirror's native drag & drop to move within // the text; page-layer graphics use the custom pixel drag instead. const dragHandleProps = !onPageLayer && editable ? { "data-drag-handle": "" } : {} const selectionChrome = showSelectionChrome ? ( <> {RESIZE_HANDLES.map((handle) => ( ))} ) : null const graphicBody = (
{ if (!editable || attrs.graphicType !== "image") return event.preventDefault() event.stopPropagation() startCrop() }} >
setImageNaturalSize({ width, height })} onCropImagePan={(dxNorm, dyNorm, origin) => { const current = attrsRef.current setCropPan({ x: clampPan(origin.panX - dxNorm, current.cropWidth), y: clampPan(origin.panY - dyNorm, current.cropHeight), }) }} />
{selected && editable && cropMode && attrs.graphicType === "image" ? ( ) : null} {!chromePortaledToFront ? selectionChrome : null} { const file = event.target.files?.[0] if (!file) return const reader = new FileReader() reader.onload = () => { updateAttributes({ src: reader.result as string }) } reader.readAsDataURL(file) event.target.value = "" }} />
) const selectionChromePortal = chromePortaledToFront && frontLayerEl && selectionChrome ? createPortal(
{selectionChrome}
, frontLayerEl ) : null const wrappedBody = selected && editable ? ( openDocsGraphicOptionsSidebar( attrs.graphicType === "gradient" ? "gradient" : "size" ) } onReplaceImage={replaceImage} > {graphicBody} ) : ( graphicBody ) const portaledBody = onPageLayer && pageLayerEl ? createPortal(wrappedBody, pageLayerEl) : wrappedBody return ( {portaledBody} {selectionChromePortal} ) } export const DocsGraphicNodeView = memo(DocsGraphicNodeViewInner)