217 lines
5.7 KiB
TypeScript
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 }
|
|
}
|