186 lines
5.1 KiB
TypeScript
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
|
|
}
|