ultisuite-client/components/drive/richtext/docs-graphic-node-view.tsx
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

1055 lines
32 KiB
TypeScript

"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)