"use client"
import { memo, useCallback, useEffect, useRef, useState } from "react"
import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"
import {
computeGraphicLayoutStyle,
resizeWithHandle,
type ResizeHandle,
RESIZE_HANDLES,
} from "@/lib/drive/docs-graphic-layout"
import {
computeCropImageStyle,
parseGraphicAttrs,
type DocsGraphicAttrs,
type DocsShapeType,
} from "@/lib/drive/docs-graphic-types"
import { DocsGraphicCropOverlay } from "@/components/drive/richtext/docs-graphic-crop-overlay"
import { DocsGraphicContextMenu } from "@/components/drive/richtext/docs-graphic-context-menu"
import { cn } from "@/lib/utils"
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 GraphicContent({ attrs }: { attrs: DocsGraphicAttrs }) {
if (attrs.graphicType === "image") {
if (!attrs.src) {
return (
Image
)
}
const cropStyle = computeCropImageStyle(attrs)
return (
)
}
if (attrs.graphicType === "gradient") {
return (
)
}
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 layout = computeGraphicLayoutStyle(attrs)
const editable = editor.isEditable
const inline = extension.name === "docsInlineGraphic"
const replaceInputRef = useRef(null)
const dragRef = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>(
null
)
const pendingDragRef = useRef<{
pointerId: number
startX: number
startY: number
originX: number
originY: number
host: HTMLElement
} | null>(null)
const resizeRef = useRef<{
handle: ResizeHandle
startX: number
startY: number
width: number
height: number
originX: number
originY: number
lockAspect: boolean
} | 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)
useEffect(() => {
if (!selected) setCropMode(false)
}, [selected])
const onDragPointerDown = useCallback(
(event: React.PointerEvent) => {
if (!editable || !selected || cropMode) return
if ((event.target as HTMLElement).closest(".docs-graphic-handle, .docs-graphic-rotate-handle, .docs-graphic-crop")) {
return
}
event.stopPropagation()
const host = event.currentTarget as HTMLElement
pendingDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: attrs.x,
originY: attrs.y,
host,
}
setTrackingPointer(true)
},
[attrs.x, attrs.y, cropMode, editable, selected]
)
const onResizeStart = useCallback(
(handle: ResizeHandle, event: React.PointerEvent) => {
if (!editable || cropMode) return
event.preventDefault()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
resizeRef.current = {
handle,
startX: event.clientX,
startY: event.clientY,
width: attrs.width,
height: attrs.height,
originX: attrs.x,
originY: attrs.y,
lockAspect: event.shiftKey,
}
setInteracting(true)
},
[attrs.height, attrs.width, attrs.x, attrs.y, cropMode, editable]
)
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: attrs.rotationDeg,
}
setInteracting(true)
},
[attrs.rotationDeg, cropMode, editable]
)
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
const prose = pending.host.closest(".ProseMirror") as HTMLElement | null
let originX = pending.originX
let originY = pending.originY
const needsAbsolute =
attrs.placement !== "absolute" &&
attrs.wrap !== "behind" &&
attrs.wrap !== "in-front"
if (needsAbsolute && prose) {
const proseRect = prose.getBoundingClientRect()
const rect = pending.host.getBoundingClientRect()
originX = Math.round(rect.left - proseRect.left)
originY = Math.round(rect.top - proseRect.top)
updateAttributes({
placement: "absolute",
x: originX,
y: originY,
})
}
pending.host.setPointerCapture(event.pointerId)
dragRef.current = {
startX: pending.startX,
startY: pending.startY,
originX,
originY,
}
pendingDragRef.current = null
setInteracting(true)
}
if (dragRef.current) {
const { startX, startY, originX, originY } = dragRef.current
updateAttributes({
x: Math.round(originX + (event.clientX - startX)),
y: Math.round(originY + (event.clientY - startY)),
})
}
if (resizeRef.current) {
const { handle, startX, startY, width, height, originX, originY, lockAspect } =
resizeRef.current
const next = resizeWithHandle(
handle,
width,
height,
event.clientX - startX,
event.clientY - startY,
24,
lockAspect || event.shiftKey
)
const patch: Partial = {
width: next.width,
height: next.height,
}
if (
attrs.placement === "absolute" ||
attrs.wrap === "behind" ||
attrs.wrap === "in-front"
) {
patch.x = Math.round(originX + next.xOffset)
patch.y = Math.round(originY + 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 = () => {
dragRef.current = null
pendingDragRef.current = null
resizeRef.current = null
rotateRef.current = null
setInteracting(false)
setTrackingPointer(false)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
return () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
}, [attrs.placement, attrs.wrap, interacting, trackingPointer, updateAttributes])
const selectNode = useCallback(() => {
const pos = getPos()
if (typeof pos !== "number") return
editor.chain().focus().setNodeSelection(pos).run()
}, [editor, getPos])
const replaceImage = useCallback(() => {
replaceInputRef.current?.click()
}, [])
const graphicBody = (
{
selectNode()
onDragPointerDown(event)
}}
onDoubleClick={(event) => {
if (!editable || attrs.graphicType !== "image") return
event.preventDefault()
event.stopPropagation()
setCropMode(true)
}}
>
{selected && editable && cropMode && attrs.graphicType === "image" ? (
setCropMode(false)}
/>
) : null}
{selected && editable && !cropMode ? (
<>
{RESIZE_HANDLES.map((handle) => (
))}
>
) : 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 = ""
}}
/>
)
return (
{selected && editable ? (
setCropMode(true)}
onReplaceImage={replaceImage}
>
{graphicBody}
) : (
graphicBody
)}
)
}
export const DocsGraphicNodeView = memo(DocsGraphicNodeViewInner)