ultisuite-client/components/drive/richtext/docs-graphic-node-view.tsx
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

471 lines
14 KiB
TypeScript

"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 (
<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 GraphicContent({ attrs }: { attrs: DocsGraphicAttrs }) {
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 cropStyle = computeCropImageStyle(attrs)
return (
<img
src={attrs.src}
alt={attrs.alt || ""}
draggable={false}
className="block h-full w-full"
style={{
objectFit: Object.keys(cropStyle.img).length ? undefined : "contain",
...cropStyle.img,
clipPath: cropStyle.clipPath,
}}
/>
)
}
if (attrs.graphicType === "gradient") {
return (
<div
className="h-full w-full"
style={{ background: attrs.gradientCss }}
aria-hidden
/>
)
}
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 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
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.5 rounded-full border border-white bg-[#1a73e8] shadow" />
<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 layout = computeGraphicLayoutStyle(attrs)
const editable = editor.isEditable
const inline = extension.name === "docsInlineGraphic"
const replaceInputRef = useRef<HTMLInputElement>(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<DocsGraphicAttrs> = {
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 = (
<div
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"
)}
style={layout.inner}
contentEditable={false}
onPointerDown={(event) => {
selectNode()
onDragPointerDown(event)
}}
onDoubleClick={(event) => {
if (!editable || attrs.graphicType !== "image") return
event.preventDefault()
event.stopPropagation()
setCropMode(true)
}}
>
<div className="docs-graphic__content" style={layout.content}>
<GraphicContent attrs={attrs} />
</div>
{selected && editable && cropMode && attrs.graphicType === "image" ? (
<DocsGraphicCropOverlay
attrs={attrs}
frameWidth={attrs.width}
frameHeight={attrs.height}
onChange={updateAttributes}
onDone={() => setCropMode(false)}
/>
) : null}
{selected && editable && !cropMode ? (
<>
<span className="docs-graphic-outline" aria-hidden />
<RotationHandle onPointerDown={onRotateStart} />
{RESIZE_HANDLES.map((handle) => (
<ResizeHandleBtn key={handle} handle={handle} onPointerDown={onResizeStart} />
))}
</>
) : 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>
)
return (
<NodeViewWrapper
as={inline ? "span" : "div"}
className={cn("docs-graphic-host", inline && "docs-graphic-host--inline")}
style={layout.wrapper}
data-graphic-type={attrs.graphicType}
data-wrap={attrs.wrap}
data-placement={attrs.placement}
>
{selected && editable ? (
<DocsGraphicContextMenu
editor={editor}
onCrop={() => setCropMode(true)}
onReplaceImage={replaceImage}
>
{graphicBody}
</DocsGraphicContextMenu>
) : (
graphicBody
)}
</NodeViewWrapper>
)
}
export const DocsGraphicNodeView = memo(DocsGraphicNodeViewInner)