"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 ( {attrs.alt ) } 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)