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

217 lines
5.7 KiB
TypeScript

"use client"
import { memo, useRef, type ReactNode } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
export type DocsRulerDragTooltipState = {
label: string
x: number
y: number
} | null
export const DocsRulerMarginDragTooltip = memo(function DocsRulerMarginDragTooltip({
tooltip,
}: {
tooltip: DocsRulerDragTooltipState
}) {
if (!tooltip || typeof document === "undefined") return null
return createPortal(
<div
className="docs-ruler-margin-tooltip pointer-events-none fixed z-[200] rounded px-2 py-1 text-xs font-medium tabular-nums text-white shadow-md"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
role="status"
aria-live="polite"
>
{tooltip.label}
</div>,
document.body
)
})
/** Blue downward triangle (margin / left indent). */
export const DocsRulerTriangleMarker = memo(function DocsRulerTriangleMarker({
left,
className,
}: {
left: number
className?: string
}) {
return (
<div className={className} style={{ left }} aria-hidden>
<svg width="8" height="6" viewBox="0 0 8 6" className="block">
<path d="M0 0 L8 0 L4 6 Z" fill="#1a73e8" />
</svg>
</div>
)
})
/** Blue rectangle on horizontal ruler (first-line indent). */
export const DocsRulerFirstLineMarker = memo(function DocsRulerFirstLineMarker({
left,
}: {
left: number
}) {
return (
<div
className="pointer-events-none absolute top-0 h-[5px] w-[6px] -translate-x-1/2 rounded-[1px] bg-[#1a73e8]"
style={{ left }}
aria-hidden
/>
)
})
/** Blue upward triangle on vertical ruler (top margin). */
export const DocsRulerUpTriangleMarker = memo(function DocsRulerUpTriangleMarker({
top,
}: {
top: number
}) {
return (
<div
className="pointer-events-none absolute left-1/2 -translate-x-1/2"
style={{ top }}
aria-hidden
>
<svg width="6" height="8" viewBox="0 0 6 8" className="block">
<path d="M0 8 L6 8 L3 0 Z" fill="#1a73e8" />
</svg>
</div>
)
})
/** Blue downward triangle on vertical ruler (bottom margin). */
export const DocsRulerDownTriangleMarker = memo(function DocsRulerDownTriangleMarker({
top,
}: {
top: number
}) {
return (
<div
className="absolute left-1/2 -translate-x-1/2 -translate-y-full"
style={{ top }}
aria-hidden
>
<svg width="6" height="8" viewBox="0 0 6 8" className="block">
<path d="M0 0 L6 0 L3 8 Z" fill="#1a73e8" />
</svg>
</div>
)
})
export const DocsRulerDraggableHandle = memo(function DocsRulerDraggableHandle({
style,
className,
disabled,
axis,
ariaLabel,
onPointerDown,
children,
}: {
style: React.CSSProperties
className?: string
disabled?: boolean
axis: "horizontal" | "vertical"
ariaLabel: string
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
children: ReactNode
}) {
return (
<div
role="slider"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
className={cn(
"docs-ruler-drag-handle absolute touch-none select-none",
axis === "horizontal"
? "bottom-0 -translate-x-1/2 cursor-ew-resize"
: "left-1/2 -translate-x-1/2 cursor-ns-resize",
disabled ? "pointer-events-none" : "pointer-events-auto",
className
)}
style={style}
onPointerDown={disabled ? undefined : onPointerDown}
>
<div
className={cn(
"flex items-center justify-center",
axis === "horizontal" ? "h-5 w-4 -mb-0.5" : "h-4 w-5"
)}
>
{children}
</div>
</div>
)
})
export function useRulerPointerDrag({
rulerRef,
axis,
disabled,
onDrag,
onDragEnd,
}: {
rulerRef: React.RefObject<HTMLElement | null>
axis: "horizontal" | "vertical"
disabled?: boolean
onDrag: (pagePx: number, clientX: number, clientY: number) => void
onDragEnd: () => void
}) {
const draggingRef = useRef(false)
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return
event.preventDefault()
event.stopPropagation()
const handle = event.currentTarget
handle.setPointerCapture(event.pointerId)
draggingRef.current = true
document.body.style.userSelect = "none"
document.body.style.cursor = axis === "horizontal" ? "ew-resize" : "ns-resize"
const readPagePx = (clientX: number, clientY: number) => {
const ruler = rulerRef.current
if (!ruler) return null
const rect = ruler.getBoundingClientRect()
const scaleAttr = ruler.dataset.docsRulerScale
const scale = scaleAttr ? Number.parseFloat(scaleAttr) : 1
if (!Number.isFinite(scale) || scale <= 0) return null
return axis === "horizontal"
? (clientX - rect.left) / scale
: (clientY - rect.top) / scale
}
const move = (clientX: number, clientY: number) => {
const pagePx = readPagePx(clientX, clientY)
if (pagePx != null) onDrag(pagePx, clientX, clientY)
}
move(event.clientX, event.clientY)
const onMove = (ev: PointerEvent) => move(ev.clientX, ev.clientY)
const onUp = (ev: PointerEvent) => {
draggingRef.current = false
document.body.style.userSelect = ""
document.body.style.cursor = ""
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId)
}
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
window.removeEventListener("pointercancel", onUp)
onDragEnd()
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
window.addEventListener("pointercancel", onUp)
}
return { onPointerDown, draggingRef }
}