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

186 lines
5.1 KiB
TypeScript

"use client"
import { useCallback, useEffect, useState } from "react"
import type { Editor } from "@tiptap/react"
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
import {
docsPageLengthToScreen,
docsScreenLengthToPage,
docsZoomToScale,
} from "@/lib/drive/docs-ruler-scale"
export type DocsParagraphIndents = {
leftPx: number
firstLinePx: number
rightPx: number
}
export type DocsRulerSyncState = {
currentPage: number
pageTopInViewport: number
pageHeightScaled: number
/** Canvas client width — ruler track content area matches this. */
canvasWidth: number
/** Scrollbar gutter so ruler track centers like the canvas viewport. */
canvasScrollbarWidth: number
canvasScrollLeft: number
indents: DocsParagraphIndents
}
const DEFAULT_INDENTS = (layout: DocPageLayout): DocsParagraphIndents => ({
leftPx: layout.marginsPx.left,
firstLinePx: layout.marginsPx.left,
rightPx: layout.widthPx - layout.marginsPx.right,
})
function readIndents(
editor: Editor | null,
scale: number,
layout: DocPageLayout
): DocsParagraphIndents {
const fallback = DEFAULT_INDENTS(layout)
if (!editor || editor.isDestroyed) return fallback
const prose = editor.view.dom as HTMLElement | null
if (!prose) return fallback
const { from } = editor.state.selection
const domPos = editor.view.domAtPos(from)
let el = domPos.node as HTMLElement
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement ?? el
const block = el.closest?.(
"p, h1, h2, h3, h4, li, blockquote, pre, td, th"
) as HTMLElement | null
if (!block || !prose.contains(block)) return fallback
const pageStack = prose.closest(
"[data-docs-page-stack]"
) as HTMLElement | null
if (!pageStack) return fallback
const pageRect = pageStack.getBoundingClientRect()
const blockRect = block.getBoundingClientRect()
const leftPx = docsScreenLengthToPage(blockRect.left - pageRect.left, scale)
const style = getComputedStyle(block)
const textIndent = parseFloat(style.textIndent) || 0
return {
leftPx: Math.max(layout.marginsPx.left, leftPx),
firstLinePx: Math.max(layout.marginsPx.left, leftPx + textIndent),
rightPx: layout.widthPx - layout.marginsPx.right,
}
}
import {
computePageTopInViewport,
resolveCurrentPageInViewport,
} from "./docs-ruler-sync-math"
export function useDocsRulerSync({
canvasRef,
rulerTrackRef,
editor,
pageLayout,
zoom,
pageCount,
}: {
canvasRef: React.RefObject<HTMLDivElement | null>
rulerTrackRef: React.RefObject<HTMLDivElement | null>
editor: Editor | null
pageLayout: DocPageLayout
zoom: number
pageCount: number
narrowViewport: boolean
}) {
const scale = docsZoomToScale(zoom)
const pageHeight = pageLayout.heightPx
const pageHeightScaled = docsPageLengthToScreen(pageHeight, scale)
const gapScaled = docsPageLengthToScreen(DOCS_PAGE_GAP_PX, scale)
const pageStride = pageHeightScaled + gapScaled
const [state, setState] = useState<DocsRulerSyncState>(() => ({
currentPage: 0,
pageTopInViewport: 0,
pageHeightScaled,
canvasWidth: 0,
canvasScrollbarWidth: 0,
canvasScrollLeft: 0,
indents: DEFAULT_INDENTS(pageLayout),
}))
const sync = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const canvasRect = canvas.getBoundingClientRect()
const stack = canvas.querySelector("[data-docs-page-stack]") as HTMLElement | null
let pageTopInViewport = 0
let currentPage = 0
if (stack) {
const stackRect = stack.getBoundingClientRect()
const stackTopInViewport = stackRect.top - canvasRect.top
currentPage = resolveCurrentPageInViewport(
stackTopInViewport,
canvas.clientHeight,
pageStride,
pageCount
)
pageTopInViewport = computePageTopInViewport(
stackTopInViewport,
currentPage,
pageStride
)
}
setState({
currentPage,
pageTopInViewport,
pageHeightScaled,
canvasWidth: canvas.clientWidth,
canvasScrollbarWidth: Math.max(0, canvas.offsetWidth - canvas.clientWidth),
canvasScrollLeft: canvas.scrollLeft,
indents: readIndents(editor, scale, pageLayout),
})
}, [canvasRef, editor, pageCount, pageHeightScaled, pageLayout, pageStride, scale])
useEffect(() => {
sync()
const canvas = canvasRef.current
if (!canvas) return
canvas.addEventListener("scroll", sync, { passive: true })
const ro = new ResizeObserver(sync)
ro.observe(canvas)
const rulerTrack = rulerTrackRef.current
if (rulerTrack) ro.observe(rulerTrack)
return () => {
canvas.removeEventListener("scroll", sync)
ro.disconnect()
}
}, [canvasRef, rulerTrackRef, sync])
useEffect(() => {
if (!editor || editor.isDestroyed) return
const onSelection = () => sync()
editor.on("selectionUpdate", onSelection)
editor.on("transaction", onSelection)
return () => {
editor.off("selectionUpdate", onSelection)
editor.off("transaction", onSelection)
}
}, [editor, sync])
useEffect(() => {
sync()
}, [pageLayout, zoom, pageCount, sync])
return state
}