1055 lines
32 KiB
TypeScript
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)
|