471 lines
14 KiB
TypeScript
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)
|