"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<typeof snapMoveRect>["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 (
      <svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
        <ellipse cx="50" cy="50" rx="46" ry="40" fill={fill} stroke={stroke} strokeWidth={strokeWidth} />
      </svg>
    )
  }
  if (shapeType === "line") {
    return (
      <svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
        <line x1="8" y1="50" x2="92" y2="50" stroke={stroke} strokeWidth={strokeWidth + 1} />
      </svg>
    )
  }
  if (shapeType === "arrow") {
    return (
      <svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
        <line x1="10" y1="50" x2="78" y2="50" stroke={stroke} strokeWidth={strokeWidth + 1} />
        <polygon points="78,38 92,50 78,62" fill={stroke} />
      </svg>
    )
  }
  return (
    <svg width="100%" height="100%" viewBox="0 0 100 100" aria-hidden>
      <rect x="6" y="10" width="88" height="80" rx="4" fill={fill} stroke={stroke} strokeWidth={strokeWidth} />
    </svg>
  )
}

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<typeof computeImageFitStyle>
  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 (
    <div
      className="docs-graphic-crop-image-layer absolute z-10 touch-none"
      style={{
        left: imageRect.left - panX * windowRect.width,
        top: imageRect.top - panY * windowRect.height,
        width: imageRect.width,
        height: imageRect.height,
        cursor: "grab",
      }}
      onPointerDown={(event) => {
        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,
        }
      }}
    >
      <img
        src={attrs.src!}
        alt={attrs.alt || ""}
        draggable={false}
        className="docs-graphic__image pointer-events-none block h-full w-full select-none"
        style={{
          objectFit: bakedImage ? undefined : fitStyle.objectFit,
          objectPosition: bakedImage ? undefined : fitStyle.objectPosition,
          filter: filter || undefined,
        }}
        onLoad={(event) => {
          onImageNaturalSize?.(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight)
        }}
      />
    </div>
  )
}

function GraphicContent({
  attrs,
  cropMode,
  cropEditBase,
  cropPanX,
  cropPanY,
  imageNaturalWidth,
  imageNaturalHeight,
  onImageNaturalSize,
  onCropImagePan,
}: {
  attrs: DocsGraphicAttrs
  cropMode?: boolean
  cropEditBase?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | 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 (
        <div className="flex h-full w-full items-center justify-center bg-[#f1f3f4] text-xs text-[#5f6368]">
          Image
        </div>
      )
    }
    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 (
        <CropModeImageLayer
          attrs={attrs}
          imageRect={imageRect}
          windowRect={windowRect}
          bakedImage={Boolean(cropEditBase && hasActiveCrop(cropEditBase))}
          panX={cropPanX}
          panY={cropPanY}
          fitStyle={fitStyle}
          filter={filter}
          onImageNaturalSize={onImageNaturalSize}
          onCropImagePan={onCropImagePan}
        />
      )
    }

    const cropStyle = usesCropImageFit(attrs.imageFit)
      ? computeCropImageStyle(
          attrs,
          attrs.width,
          attrs.height,
          imageNaturalWidth,
          imageNaturalHeight
        )
      : { img: {} }
    const usesCropTransform = Object.keys(cropStyle.img).length > 0
    return (
      <img
        src={attrs.src}
        alt={attrs.alt || ""}
        draggable={false}
        className="docs-graphic__image block h-full w-full"
        style={{
          objectFit: usesCropTransform ? undefined : fitStyle.objectFit,
          objectPosition: usesCropTransform ? undefined : fitStyle.objectPosition,
          ...cropStyle.img,
          filter: filter || undefined,
        }}
        onLoad={(event) => {
          onImageNaturalSize?.(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight)
        }}
      />
    )
  }

  if (attrs.graphicType === "gradient") {
    return (
      <div
        className="h-full w-full"
        style={{ background: attrs.gradientCss }}
        aria-hidden
      />
    )
  }

  if (attrs.graphicType === "draw") {
    if (!attrs.src) {
      return (
        <div className="flex h-full w-full items-center justify-center bg-[#f8f9fa] text-xs text-[#5f6368]">
          Dessin
        </div>
      )
    }
    return (
      <img
        src={attrs.src}
        alt={attrs.alt || "Dessin"}
        draggable={false}
        className="docs-graphic__image block h-full w-full object-contain"
      />
    )
  }

  return (
    <ShapePreview
      shapeType={attrs.shapeType}
      fill={attrs.fill}
      stroke={attrs.stroke}
      strokeWidth={attrs.strokeWidth}
    />
  )
}

function ResizeHandleBtn({
  handle,
  onPointerDown,
}: {
  handle: ResizeHandle
  onPointerDown: (handle: ResizeHandle, event: React.PointerEvent) => void
}) {
  const posClass: Record<ResizeHandle, string> = {
    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 (
    <span
      role="presentation"
      className={cn("docs-graphic-handle absolute z-20", posClass[handle])}
      onPointerDown={(event) => {
        event.preventDefault()
        event.stopPropagation()
        onPointerDown(handle, event)
      }}
    />
  )
}

function RotationHandle({
  onPointerDown,
}: {
  onPointerDown: (event: React.PointerEvent) => void
}) {
  return (
    <span
      role="presentation"
      className="docs-graphic-rotate-handle absolute left-1/2 top-0 z-20 flex -translate-x-1/2 -translate-y-[calc(100%+8px)] cursor-grab items-center justify-center"
      onPointerDown={(event) => {
        event.preventDefault()
        event.stopPropagation()
        onPointerDown(event)
      }}
    >
      <span className="size-2 rounded-none bg-[#1a73e8]" />
      <span className="absolute top-full h-2 w-px bg-[#1a73e8]" aria-hidden />
    </span>
  )
}

function DocsGraphicNodeViewInner({
  node,
  updateAttributes,
  selected,
  editor,
  getPos,
  extension,
}: NodeViewProps) {
  const attrs = parseGraphicAttrs(node.attrs as Record<string, unknown>)
  const attrsRef = useRef(attrs)
  attrsRef.current = attrs
  const nodePos = typeof getPos() === "number" ? getPos() : null
  const hostRef = useRef<HTMLDivElement>(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<HTMLInputElement>(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<Pick<
    DocsGraphicAttrs,
    "cropX" | "cropY" | "cropWidth" | "cropHeight"
  > | 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<DocsGraphicAttrs> = {
          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 ? (
    <>
      <span className="docs-graphic-outline" aria-hidden />
      <RotationHandle onPointerDown={onRotateStart} />
      {RESIZE_HANDLES.map((handle) => (
        <ResizeHandleBtn key={handle} handle={handle} onPointerDown={onResizeStart} />
      ))}
    </>
  ) : null

  const graphicBody = (
    <div
      ref={hostRef}
      className={cn(
        "docs-graphic",
        selected && "docs-graphic--selected",
        interacting && "docs-graphic--interacting",
        cropMode && "docs-graphic--cropping",
        layout.behindText && "docs-graphic--behind",
        layout.inFrontText && "docs-graphic--front",
        onPageLayer && "docs-graphic--page-layer"
      )}
      style={layerInnerStyle}
      data-graphic-pos={nodePos ?? undefined}
      {...dragHandleProps}
      contentEditable={false}
      onPointerDown={onPageLayer ? onGraphicPointerDown : undefined}
      onMouseDown={!onPageLayer && editable && !selected ? onInFlowSelect : undefined}
      onDoubleClick={(event) => {
        if (!editable || attrs.graphicType !== "image") return
        event.preventDefault()
        event.stopPropagation()
        startCrop()
      }}
    >
      <div
        className={cn("docs-graphic__content", cropMode && "docs-graphic__content--cropping")}
        style={{
          ...layout.content,
          overflow: cropMode ? "visible" : layout.content.overflow,
          clipPath: cropMode ? undefined : cropClip,
        }}
      >
        <GraphicContent
          attrs={attrs}
          cropMode={cropMode}
          cropEditBase={cropEditBase}
          cropPanX={cropPan.x}
          cropPanY={cropPan.y}
          imageNaturalWidth={imageNaturalSize.width}
          imageNaturalHeight={imageNaturalSize.height}
          onImageNaturalSize={(width, height) => 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),
            })
          }}
        />
      </div>
      {selected && editable && cropMode && attrs.graphicType === "image" ? (
        <DocsGraphicCropOverlay
          attrs={attrs}
          cropEditBase={cropEditBase}
          frameWidth={attrs.width}
          frameHeight={attrs.height}
          imageNaturalWidth={imageNaturalSize.width}
          imageNaturalHeight={imageNaturalSize.height}
          onChange={updateAttributes}
          onDone={finishCrop}
        />
      ) : null}
      {!chromePortaledToFront ? selectionChrome : null}
      <input
        ref={replaceInputRef}
        type="file"
        accept="image/*"
        className="hidden"
        onChange={(event) => {
          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 = ""
        }}
      />
    </div>
  )

  const selectionChromePortal =
    chromePortaledToFront && frontLayerEl && selectionChrome
      ? createPortal(
          <div
            className={cn(
              "docs-graphic docs-graphic--selected docs-graphic--selection-chrome docs-graphic--page-layer pointer-events-none"
            )}
            style={layerInnerStyle}
          >
            <div
              className="docs-graphic-drag-surface pointer-events-auto relative size-full touch-none cursor-move"
              onPointerDown={onGraphicPointerDown}
            >
              {selectionChrome}
            </div>
          </div>,
          frontLayerEl
        )
      : null

  const wrappedBody =
    selected && editable ? (
      <DocsGraphicContextMenu
        editor={editor}
        onCrop={startCrop}
        onOpenOptions={() =>
          openDocsGraphicOptionsSidebar(
            attrs.graphicType === "gradient" ? "gradient" : "size"
          )
        }
        onReplaceImage={replaceImage}
      >
        {graphicBody}
      </DocsGraphicContextMenu>
    ) : (
      graphicBody
    )

  const portaledBody =
    onPageLayer && pageLayerEl ? createPortal(wrappedBody, pageLayerEl) : wrappedBody

  return (
    <NodeViewWrapper
      as={inline ? "span" : "div"}
      className={cn(
        "docs-graphic-host",
        inline && "docs-graphic-host--inline",
        onPageLayer && "docs-graphic-host--page-layer"
      )}
      style={
        onPageLayer
          ? { width: 0, height: 0, overflow: "visible", position: "relative" }
          : layout.wrapper
      }
      data-graphic-type={attrs.graphicType}
      data-wrap={attrs.wrap}
      data-placement={attrs.placement}
      data-position-mode={attrs.positionMode}
      data-graphic-pos={nodePos ?? undefined}
    >
      {portaledBody}
      {selectionChromePortal}
    </NodeViewWrapper>
  )
}

export const DocsGraphicNodeView = memo(DocsGraphicNodeViewInner)
