feat(drive): enhance document layout with new page separators and margin masks
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Introduced `DocsPageSeparators` component to manage visual gaps between pages in the document editor.
- Updated `DocsBodyMarginMasks` to include dark mode support for improved styling.
- Refactored `DocsPageViewInner` to integrate new separators and margin masks, enhancing layout consistency.
- Adjusted layout constants to increase the gap between stacked pages for better visual separation.
- Improved test coverage for page flow calculations and layout metrics.
This commit is contained in:
R3D347HR4Y 2026-06-15 17:28:02 +02:00
parent 82ca9a27db
commit 76eff3c351
11 changed files with 530 additions and 84 deletions

View File

@ -38,7 +38,7 @@ export function DocsBodyMarginMasks({
return ( return (
<div key={`body-mask-${index}`} aria-hidden> <div key={`body-mask-${index}`} aria-hidden>
<div <div
className="docs-body-margin-mask pointer-events-none absolute z-[15] box-border border-l border-r border-[#dadce0]" className="docs-body-margin-mask pointer-events-none absolute z-[15] box-border border-l border-r border-[#dadce0] dark:border-[#5f6368]"
style={{ style={{
top: pageTop, top: pageTop,
left: 0, left: 0,
@ -48,7 +48,7 @@ export function DocsBodyMarginMasks({
}} }}
/> />
<div <div
className="docs-body-margin-mask pointer-events-none absolute z-[15] box-border border-l border-r border-[#dadce0]" className="docs-body-margin-mask pointer-events-none absolute z-[15] box-border border-l border-r border-[#dadce0] dark:border-[#5f6368]"
style={{ style={{
top: footerTop, top: footerTop,
left: 0, left: 0,

View File

@ -0,0 +1,49 @@
"use client"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
import type { DocPageBorderCss } from "@/lib/drive/doc-page-setup"
import { cn } from "@/lib/utils"
export function DocsPageRims({
pageCount,
pageHeight,
pageWidth,
sheetBorderCss,
}: {
pageCount: number
pageHeight: number
pageWidth: number
sheetBorderCss?: DocPageBorderCss
}) {
return (
<>
{Array.from({ length: pageCount }, (_, index) => {
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
return (
<div
key={`page-rim-${index}`}
className={cn(
"docs-page-rim pointer-events-none absolute z-[22] box-border",
sheetBorderCss && "docs-page-rim--imported"
)}
style={{
top: pageTop,
left: 0,
width: pageWidth,
height: pageHeight,
...(sheetBorderCss
? {
borderTop: sheetBorderCss.top ?? "none",
borderRight: sheetBorderCss.right ?? "none",
borderBottom: sheetBorderCss.bottom ?? "none",
borderLeft: sheetBorderCss.left ?? "none",
boxShadow: "none",
}
: {}),
}}
aria-hidden
/>
)
})}
</>
)
}

View File

@ -0,0 +1,66 @@
"use client"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
/** Canvas-colored gap + side margin gutters between stacked pages. */
export function DocsPageSeparators({
pageCount,
pageHeight,
pageWidth,
margins,
pageColor,
interPageSpacer,
}: {
pageCount: number
pageHeight: number
pageWidth: number
margins: { top: number; right: number; bottom: number; left: number }
pageColor: string
interPageSpacer: number
}) {
if (pageCount <= 1) return null
return (
<>
{Array.from({ length: pageCount - 1 }, (_, index) => {
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
const gapTop = pageTop + pageHeight
const zoneTop = gapTop - margins.bottom
return (
<div key={`page-sep-${index}`} aria-hidden>
<div
className="docs-page-inter-margin-gutter pointer-events-none absolute z-[19] border-l border-[#dadce0] dark:border-[#5f6368]"
style={{
top: zoneTop,
left: 0,
width: margins.left,
height: interPageSpacer,
backgroundColor: pageColor,
}}
/>
<div
className="docs-page-inter-margin-gutter pointer-events-none absolute z-[19] border-r border-[#dadce0] dark:border-[#5f6368]"
style={{
top: zoneTop,
left: pageWidth - margins.right,
width: margins.right,
height: interPageSpacer,
backgroundColor: pageColor,
}}
/>
<div
className="docs-page-gap-band pointer-events-none absolute z-[20]"
style={{
top: gapTop,
left: 0,
width: pageWidth,
height: DOCS_PAGE_GAP_PX,
}}
/>
</div>
)
})}
</>
)
}

View File

@ -9,6 +9,8 @@ import {
type DocsHeaderFooterRegion, type DocsHeaderFooterRegion,
} from "@/components/drive/richtext/docs-header-footer-region" } from "@/components/drive/richtext/docs-header-footer-region"
import { DocsBodyMarginMasks } from "@/components/drive/richtext/docs-body-margin-masks" import { DocsBodyMarginMasks } from "@/components/drive/richtext/docs-body-margin-masks"
import { DocsPageSeparators } from "@/components/drive/richtext/docs-page-separators"
import { DocsPageRims } from "@/components/drive/richtext/docs-page-rims"
import { import {
DOCS_REGION_EDIT_EVENT, DOCS_REGION_EDIT_EVENT,
type DocsRegionEditDetail, type DocsRegionEditDetail,
@ -31,40 +33,12 @@ import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { DocsGraphicSnapGuides } from "@/components/drive/richtext/docs-graphic-snap-guides" import { DocsGraphicSnapGuides } from "@/components/drive/richtext/docs-graphic-snap-guides"
import { DocsTableContextMenu } from "@/components/drive/richtext/docs-table-context-menu" import { DocsTableContextMenu } from "@/components/drive/richtext/docs-table-context-menu"
import { applyPageFlowLayout, computeSimulatedLayoutHeight, readPageFlowMetrics } from "@/lib/drive/extensions/docs-page-flow-decoration" import {
applyPageFlowLayout,
measureFlowContentHeight,
} from "@/lib/drive/extensions/docs-page-flow-decoration"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer" import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
/** Total layout height inside ProseMirror (blocks + flow spacers). */
function measureProseContentHeight(prose: HTMLElement): number {
const metrics = readPageFlowMetrics(prose)
if (!metrics) return 0
const blocks: Array<{ height: number }> = []
for (const child of prose.children) {
const el = child as HTMLElement
if (el.classList.contains("docs-page-flow-spacer")) continue
const style = getComputedStyle(el)
const marginBottom = parseFloat(style.marginBottom) || 0
blocks.push({ height: el.offsetHeight + marginBottom })
}
if (blocks.length === 0) return 0
const simulated = computeSimulatedLayoutHeight(
blocks,
metrics.bodyAreaH,
metrics.interPageSpacer
)
let maxBottom = 0
for (const child of prose.children) {
const el = child as HTMLElement
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight)
}
return Math.max(simulated, maxBottom)
}
function DocsPageViewInner({ function DocsPageViewInner({
editor, editor,
pageLayout, pageLayout,
@ -227,24 +201,29 @@ function DocsPageViewInner({
let cancelled = false let cancelled = false
const measurePageCount = () => { const measurePageCount = () => {
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null if (editor.isDestroyed) return
if (!prose) return const contentHeight = measureFlowContentHeight(editor.view)
const contentHeight = measureProseContentHeight(prose)
const count = computePageCount(contentHeight, metrics) const count = computePageCount(contentHeight, metrics)
setPageCount((prev) => (prev === count ? prev : count)) setPageCount((prev) => (prev === count ? prev : count))
} }
const runLayoutPasses = (passesLeft: number) => { const runLayoutPasses = () => {
if (cancelled || editor.isDestroyed) return if (cancelled || editor.isDestroyed) return
requestAnimationFrame(() => { let passesLeft = 12
const step = () => {
if (cancelled || editor.isDestroyed) return if (cancelled || editor.isDestroyed) return
const changed = applyPageFlowLayout(editor) requestAnimationFrame(() => {
if (changed && passesLeft > 1) { if (cancelled || editor.isDestroyed) return
runLayoutPasses(passesLeft - 1) const changed = applyPageFlowLayout(editor)
return passesLeft -= 1
} if (changed && passesLeft > 0) {
measurePageCount() step()
}) return
}
measurePageCount()
})
}
step()
} }
let flushPending = false let flushPending = false
@ -253,13 +232,13 @@ function DocsPageViewInner({
flushPending = true flushPending = true
requestAnimationFrame(() => { requestAnimationFrame(() => {
flushPending = false flushPending = false
runLayoutPasses(2) runLayoutPasses()
}) })
} }
if (debounceId) clearTimeout(debounceId) if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(() => { debounceId = setTimeout(() => {
debounceId = null debounceId = null
runLayoutPasses(2) runLayoutPasses()
}, 32) }, 32)
} }
@ -282,6 +261,15 @@ function DocsPageViewInner({
onPageCountChangeRef.current?.(pageCount) onPageCountChangeRef.current?.(pageCount)
}, [pageCount]) }, [pageCount])
useEffect(() => {
if (!showLayout || pageCount <= 1) return
const id = requestAnimationFrame(() => {
if (editor.isDestroyed) return
applyPageFlowLayout(editor)
})
return () => cancelAnimationFrame(id)
}, [editor, pageCount, showLayout])
const stackHeight = computeStackHeight(pageCount, pageHeight) const stackHeight = computeStackHeight(pageCount, pageHeight)
const proseMinHeight = computeProseMinHeight(pageCount, metrics) const proseMinHeight = computeProseMinHeight(pageCount, metrics)
@ -394,7 +382,7 @@ function DocsPageViewInner({
<div <div
key={index} key={index}
className={cn( className={cn(
"ultidrive-docs-page absolute left-0 overflow-hidden dark:bg-white", "ultidrive-docs-page ultidrive-docs-page--sheet absolute left-0 overflow-hidden dark:bg-white",
sheetBorderCss && "ultidrive-docs-page--imported-border" sheetBorderCss && "ultidrive-docs-page--imported-border"
)} )}
style={{ style={{
@ -402,16 +390,6 @@ function DocsPageViewInner({
width: pageWidth, width: pageWidth,
height: pageHeight, height: pageHeight,
backgroundColor: pageBackground, backgroundColor: pageBackground,
boxShadow:
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
...(sheetBorderCss
? {
borderTop: sheetBorderCss.top ?? "none",
borderRight: sheetBorderCss.right ?? "none",
borderBottom: sheetBorderCss.bottom ?? "none",
borderLeft: sheetBorderCss.left ?? "none",
}
: {}),
}} }}
aria-hidden aria-hidden
> >
@ -452,6 +430,26 @@ function DocsPageViewInner({
/> />
) : null} ) : null}
{showLayout ? (
<DocsPageSeparators
pageCount={pageCount}
pageHeight={pageHeight}
pageWidth={pageWidth}
margins={effectiveMargins}
pageColor={pageBackground}
interPageSpacer={metrics.interPageSpacer}
/>
) : null}
{showLayout ? (
<DocsPageRims
pageCount={pageCount}
pageHeight={pageHeight}
pageWidth={pageWidth}
sheetBorderCss={sheetBorderCss}
/>
) : null}
{showLayout {showLayout
? Array.from({ length: pageCount }, (_, index) => { ? Array.from({ length: pageCount }, (_, index) => {
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX) const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)

View File

@ -1,6 +1,11 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { describe, it } from "node:test" import { describe, it } from "node:test"
import { countPageFlowSpacers } from "./extensions/docs-page-flow-decoration.ts" import {
computePageFlowPushes,
computeSimulatedLayoutHeight,
countPageFlowSpacers,
} from "./extensions/docs-page-flow-decoration.ts"
import { computePageCount } from "./docs-page-metrics.ts"
describe("docs-page-flow spacers", () => { describe("docs-page-flow spacers", () => {
const bodyAreaH = 900 const bodyAreaH = 900
@ -30,4 +35,69 @@ describe("docs-page-flow spacers", () => {
1 1
) )
}) })
it("inserts spacers at each page boundary for many blocks", () => {
const blocks = Array.from({ length: 10 }, () => ({ height: 400 }))
const pushes = computePageFlowPushes(blocks, bodyAreaH, interPageSpacer)
assert.equal(pushes.length, 4)
assert.deepEqual(
pushes.map((p) => p.breakY),
[900, 2000, 3100, 4200]
)
})
it("pushes content that starts in the inter-page gap", () => {
const pushes = computePageFlowPushes(
[{ height: 910 }, { height: 100 }],
bodyAreaH,
interPageSpacer
)
assert.equal(pushes.length, 2)
assert.equal(pushes[0].blockIndex, 0)
assert.equal(pushes[1].blockIndex, 1)
assert.equal(pushes[1].pushPx, 190)
})
it("does not double-count nested blocks (list model)", () => {
const listHeight = 300
const topLevelOnly = [{ height: 850 }, { height: listHeight }, { height: 100 }]
const nestedOvercount = [
{ height: 850 },
{ height: listHeight },
...Array.from({ length: 10 }, () => ({ height: listHeight })),
{ height: 100 },
]
const topLevelPushes = countPageFlowSpacers(topLevelOnly, bodyAreaH, interPageSpacer)
const nestedPushes = countPageFlowSpacers(nestedOvercount, bodyAreaH, interPageSpacer)
assert.equal(topLevelPushes, 1)
assert.ok(nestedPushes > topLevelPushes)
})
it("simulated height matches page count across five pages", () => {
const blocks = Array.from({ length: 10 }, () => ({ height: 400 }))
const simH = computeSimulatedLayoutHeight(blocks, bodyAreaH, interPageSpacer)
const pages = computePageCount(simH, {
bodyAreaHeight: bodyAreaH,
interPageSpacer,
pageWidth: 0,
pageHeight: 0,
margins: { top: 0, right: 0, bottom: 0, left: 0 },
headerMarginPx: 0,
footerMarginPx: 0,
})
assert.equal(simH, 5200)
assert.equal(pages, 5)
})
it("inserts multiple spacers for tall content split into lines", () => {
const lineHeight = 24
const totalH = 2500
const lines = Math.ceil(totalH / lineHeight)
const units = Array.from({ length: lines }, (_, i) => ({
height: i === lines - 1 ? totalH - lineHeight * (lines - 1) : lineHeight,
}))
const pushes = computePageFlowPushes(units, bodyAreaH, interPageSpacer)
assert.ok(pushes.length >= 2)
})
}) })

View File

@ -1,5 +1,5 @@
/** Gap between stacked pages in print layout (px, unscaled). */ /** Gap between stacked pages in print layout (px, unscaled). */
export const DOCS_PAGE_GAP_PX = 12 export const DOCS_PAGE_GAP_PX = 24
export const DOCS_CANVAS_PADDING_Y_PX = 32 export const DOCS_CANVAS_PADDING_Y_PX = 32
export const DOCS_CANVAS_PADDING_TOP_NARROW_PX = 0 export const DOCS_CANVAS_PADDING_TOP_NARROW_PX = 0

View File

@ -26,7 +26,7 @@ describe("docs-page-metrics", () => {
}) })
it("computes stack height with page gaps", () => { it("computes stack height with page gaps", () => {
assert.equal(computeStackHeight(2, 1122), 1122 * 2 + 12) assert.equal(computeStackHeight(2, 1122), 1122 * 2 + 24)
}) })
it("computes prose min height with inter-page spacers", () => { it("computes prose min height with inter-page spacers", () => {

View File

@ -1,10 +1,15 @@
import { Extension } from "@tiptap/core" import { Extension } from "@tiptap/core"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import type { Node as PMNode } from "@tiptap/pm/model"
import { Plugin, PluginKey } from "@tiptap/pm/state" import { Plugin, PluginKey } from "@tiptap/pm/state"
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view" import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"
export const PAGE_FLOW_PLUGIN_KEY = new PluginKey<DecorationSet>("docsPageFlowDecoration") export const PAGE_FLOW_PLUGIN_KEY = new PluginKey<DecorationSet>("docsPageFlowDecoration")
type FlowUnit = { height: number; offset: number }
const LIST_TYPES = new Set(["bulletList", "orderedList", "taskList"])
function decorationSetsEqual( function decorationSetsEqual(
current: DecorationSet | undefined, current: DecorationSet | undefined,
next: DecorationSet next: DecorationSet
@ -49,6 +54,13 @@ function blockDomAtOffset(view: EditorView, offset: number): HTMLElement | null
return null return null
} }
function nestedDomAt(view: EditorView, offset: number, selector: string): HTMLElement | null {
const dom = view.nodeDOM(offset)
if (!(dom instanceof HTMLElement)) return null
if (dom.matches(selector)) return dom
return dom.closest(selector)
}
/** Block height in prose px (offsetHeight + bottom margin; immune to canvas scale). */ /** Block height in prose px (offsetHeight + bottom margin; immune to canvas scale). */
function measureBlockFlowHeight(dom: HTMLElement): number { function measureBlockFlowHeight(dom: HTMLElement): number {
const style = getComputedStyle(dom) const style = getComputedStyle(dom)
@ -56,6 +68,191 @@ function measureBlockFlowHeight(dom: HTMLElement): number {
return dom.offsetHeight + marginBottom return dom.offsetHeight + marginBottom
} }
function measureVisualLineHeights(dom: HTMLElement): Array<{ top: number; height: number }> {
const blockTop = dom.getBoundingClientRect().top
const range = document.createRange()
const lines: Array<{ top: number; height: number }> = []
const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT)
while (walker.nextNode()) {
const text = walker.currentNode as Text
if (!text.nodeValue?.length) continue
range.selectNodeContents(text)
for (const rect of range.getClientRects()) {
if (rect.width <= 0 || rect.height <= 0) continue
const relTop = rect.top - blockTop
const last = lines[lines.length - 1]
if (!last || Math.abs(last.top - relTop) > 2) {
lines.push({ top: relTop, height: rect.height })
} else {
last.height = Math.max(last.height, rect.height)
}
}
}
if (lines.length === 0) {
const h = dom.offsetHeight
if (h > 0) lines.push({ top: 0, height: h })
}
return lines
}
function findPosAtVisualLine(
view: EditorView,
dom: HTMLElement,
blockRect: DOMRect,
relTop: number
): number | null {
const coords = view.posAtCoords({
left: blockRect.left + Math.min(Math.max(1, blockRect.width / 2), blockRect.width - 1),
top: blockRect.top + relTop + 1,
})
return coords?.pos ?? null
}
function estimatedLineUnits(
view: EditorView,
node: PMNode,
offset: number,
dom: HTMLElement,
totalH: number
): FlowUnit[] {
const style = getComputedStyle(dom)
let lineHeight = parseFloat(style.lineHeight)
if (!Number.isFinite(lineHeight) || style.lineHeight === "normal") {
lineHeight = (parseFloat(style.fontSize) || 16) * 1.25
}
const lineCount = Math.max(1, Math.ceil(totalH / lineHeight))
const textLen = Math.max(1, node.content.size)
const units: FlowUnit[] = []
for (let i = 0; i < lineCount; i++) {
const h =
i === lineCount - 1
? Math.max(1, totalH - lineHeight * (lineCount - 1))
: lineHeight
const pos =
i === 0
? offset
: offset + Math.min(textLen - 1, Math.floor((textLen * i) / lineCount))
units.push({ height: h, offset: pos })
}
return units
}
function expandTallLeafUnits(
view: EditorView,
node: PMNode,
offset: number,
dom: HTMLElement,
totalH: number,
bodyAreaH: number
): FlowUnit[] {
if (totalH <= bodyAreaH) {
return [{ height: totalH, offset }]
}
const lineSegments = measureVisualLineHeights(dom)
if (lineSegments.length <= 1) {
return estimatedLineUnits(view, node, offset, dom, totalH)
}
const blockRect = dom.getBoundingClientRect()
return lineSegments.map((segment, lineIndex) => ({
height: segment.height,
offset:
lineIndex === 0
? offset
: (findPosAtVisualLine(view, dom, blockRect, segment.top) ?? offset),
}))
}
function expandLeafBlockUnits(
view: EditorView,
node: PMNode,
offset: number,
units: FlowUnit[],
bodyAreaH: number
): void {
const dom = blockDomAtOffset(view, offset)
if (!dom) return
const totalH = measureBlockFlowHeight(dom)
if (totalH <= 0) return
units.push(...expandTallLeafUnits(view, node, offset, dom, totalH, bodyAreaH))
}
function expandNodeUnits(
view: EditorView,
node: PMNode,
offset: number,
units: FlowUnit[],
bodyAreaH: number
): void {
if (!node.isBlock) return
if (LIST_TYPES.has(node.type.name)) {
let itemOffset = offset + 1
for (let j = 0; j < node.childCount; j++) {
const item = node.child(j)
const liDom = nestedDomAt(view, itemOffset, "li")
const h = liDom ? measureBlockFlowHeight(liDom) : 0
if (h > 0) {
units.push(...expandTallLeafUnits(view, item, itemOffset, liDom, h, bodyAreaH))
}
itemOffset += item.nodeSize
}
return
}
if (node.type.name === "table") {
let rowOffset = offset + 1
for (let j = 0; j < node.childCount; j++) {
const row = node.child(j)
if (row.type.name !== "tableRow") {
rowOffset += row.nodeSize
continue
}
const rowDom = nestedDomAt(view, rowOffset, "tr")
const h = rowDom ? measureBlockFlowHeight(rowDom) : 0
if (h > 0) {
units.push(...expandTallLeafUnits(view, row, rowOffset, rowDom, h, bodyAreaH))
}
rowOffset += row.nodeSize
}
return
}
if (node.type.name === "blockquote") {
let innerOffset = offset + 1
for (let j = 0; j < node.childCount; j++) {
const child = node.child(j)
if (child.isBlock) {
expandLeafBlockUnits(view, child, innerOffset, units, bodyAreaH)
}
innerOffset += child.nodeSize
}
return
}
expandLeafBlockUnits(view, node, offset, units, bodyAreaH)
}
function collectFlowUnits(view: EditorView, bodyAreaH: number): FlowUnit[] {
const units: FlowUnit[] = []
const doc = view.state.doc
let offset = 0
for (let i = 0; i < doc.childCount; i++) {
const node = doc.child(i)
expandNodeUnits(view, node, offset, units, bodyAreaH)
offset += node.nodeSize
}
return units
}
function createSpacerElement(height: number): HTMLElement { function createSpacerElement(height: number): HTMLElement {
const el = document.createElement("div") const el = document.createElement("div")
el.className = "docs-page-flow-spacer" el.className = "docs-page-flow-spacer"
@ -80,13 +277,24 @@ export function computePageFlowPushes(
if (blockH <= 0) return if (blockH <= 0) return
while (simulatedY + blockH > nextBreakY) { while (simulatedY + blockH > nextBreakY) {
const nextBodyStart = nextBreakY + interPageSpacer
if (simulatedY < nextBreakY) { if (simulatedY < nextBreakY) {
const pushPx = nextBreakY - simulatedY + interPageSpacer const pushPx = nextBreakY - simulatedY + interPageSpacer
pushes.push({ blockIndex, pushPx, breakY: nextBreakY }) pushes.push({ blockIndex, pushPx, breakY: nextBreakY })
simulatedY = nextBreakY + interPageSpacer simulatedY = nextBodyStart
nextBreakY += pageStep nextBreakY += pageStep
break break
} }
if (simulatedY < nextBodyStart) {
const pushPx = nextBodyStart - simulatedY
pushes.push({ blockIndex, pushPx, breakY: nextBreakY })
simulatedY = nextBodyStart
nextBreakY += pageStep
break
}
nextBreakY += pageStep nextBreakY += pageStep
} }
@ -108,11 +316,20 @@ export function computeSimulatedLayoutHeight(
for (const { height: blockH } of blocks) { for (const { height: blockH } of blocks) {
if (blockH <= 0) continue if (blockH <= 0) continue
while (simulatedY + blockH > nextBreakY) { while (simulatedY + blockH > nextBreakY) {
const nextBodyStart = nextBreakY + interPageSpacer
if (simulatedY < nextBreakY) { if (simulatedY < nextBreakY) {
simulatedY = nextBreakY + interPageSpacer simulatedY = nextBodyStart
nextBreakY += pageStep nextBreakY += pageStep
break break
} }
if (simulatedY < nextBodyStart) {
simulatedY = nextBodyStart
nextBreakY += pageStep
break
}
nextBreakY += pageStep nextBreakY += pageStep
} }
simulatedY += blockH simulatedY += blockH
@ -121,45 +338,55 @@ export function computeSimulatedLayoutHeight(
return simulatedY return simulatedY
} }
function collectFlowBlocks(view: EditorView): Array<{ height: number; offset: number }> {
const blocks: Array<{ height: number; offset: number }> = []
view.state.doc.forEach((node, offset) => {
if (!node.isBlock || node.type.name === "doc") return
const dom = blockDomAtOffset(view, offset)
if (!dom) return
const blockH = measureBlockFlowHeight(dom)
if (blockH <= 0) return
blocks.push({ height: blockH, offset })
})
return blocks
}
/** Build page-flow spacer widgets for blocks that cross a page body boundary. */ /** Build page-flow spacer widgets for blocks that cross a page body boundary. */
export function buildPageFlowDecorations(view: EditorView): DecorationSet { export function buildPageFlowDecorations(view: EditorView): DecorationSet {
const metrics = readPageFlowMetrics(view.dom) const metrics = readPageFlowMetrics(view.dom)
if (!metrics) return DecorationSet.empty if (!metrics) return DecorationSet.empty
const { bodyAreaH, interPageSpacer } = metrics const { bodyAreaH, interPageSpacer } = metrics
const blocks = collectFlowBlocks(view) const units = collectFlowUnits(view, bodyAreaH)
const pushes = computePageFlowPushes( const pushes = computePageFlowPushes(
blocks.map((b) => ({ height: b.height })), units.map((u) => ({ height: u.height })),
bodyAreaH, bodyAreaH,
interPageSpacer interPageSpacer
) )
const decorations = pushes.map(({ blockIndex, pushPx, breakY }) => { const decorations = pushes.map(({ blockIndex, pushPx, breakY }) => {
const block = blocks[blockIndex] const unit = units[blockIndex]
return Decoration.widget( return Decoration.widget(
block.offset, unit.offset,
() => createSpacerElement(pushPx), () => createSpacerElement(pushPx),
{ side: -1, key: `page-flow-spacer-${block.offset}-${breakY}`, pushPx } { side: -1, key: `page-flow-spacer-${unit.offset}-${breakY}`, pushPx }
) )
}) })
return DecorationSet.create(view.state.doc, decorations) return DecorationSet.create(view.state.doc, decorations)
} }
/** Measure paginated prose height using the same flow units as layout. */
export function measureFlowContentHeight(view: EditorView): number {
const metrics = readPageFlowMetrics(view.dom)
if (!metrics) return 0
const units = collectFlowUnits(view, metrics.bodyAreaH)
if (units.length === 0) return 0
const simulated = computeSimulatedLayoutHeight(
units.map((u) => ({ height: u.height })),
metrics.bodyAreaH,
metrics.interPageSpacer
)
let maxBottom = 0
for (const child of view.dom.children) {
const el = child as HTMLElement
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight)
}
return Math.max(simulated, maxBottom)
}
/** Apply page-flow layout once. Returns true if decorations changed. */ /** Apply page-flow layout once. Returns true if decorations changed. */
export function applyPageFlowLayout(editor: Editor): boolean { export function applyPageFlowLayout(editor: Editor): boolean {
if (editor.isDestroyed || !editor.isInitialized) return false if (editor.isDestroyed || !editor.isInitialized) return false

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -58,6 +58,12 @@
border-color: transparent !important; border-color: transparent !important;
} }
.docs-printing.docs-pdf-capture .docs-page-gap-band,
.docs-printing.docs-pdf-capture .docs-page-inter-margin-gutter,
.docs-printing.docs-pdf-capture .docs-page-rim {
display: none !important;
}
.docs-printing.docs-pdf-capture .ultidrive-docs-editor-surface--dimmed .ProseMirror { .docs-printing.docs-pdf-capture .ultidrive-docs-editor-surface--dimmed .ProseMirror {
opacity: 1 !important; opacity: 1 !important;
} }

View File

@ -597,12 +597,36 @@ html.dark .ultidrive-richtext-region-editor table th {
.ultidrive-docs-editor-surface--paginated { .ultidrive-docs-editor-surface--paginated {
overflow: visible; overflow: visible;
background: transparent;
} }
.ultidrive-docs-editor-surface--paginated .ProseMirror { .ultidrive-docs-editor-surface--paginated .ProseMirror {
min-height: var(--docs-prose-min-height); min-height: var(--docs-prose-min-height);
max-height: var(--docs-prose-min-height);
overflow: visible; overflow: visible;
background: transparent;
}
.docs-page-gap-band {
background: #f9fbfd;
}
html.dark .docs-page-gap-band {
background: #202124;
}
.docs-page-rim {
border: 1px solid #dadce0;
box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15), 0 1px 2px 0 rgba(60, 64, 67, 0.3);
background: transparent;
}
html.dark .docs-page-rim:not(.docs-page-rim--imported) {
border-color: #5f6368;
box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.35), 0 1px 2px 0 rgba(0, 0, 0, 0.45);
}
.docs-page-rim--imported {
box-shadow: none;
} }
.docs-hf-chrome__checkbox { .docs-hf-chrome__checkbox {
@ -666,6 +690,7 @@ html.dark .docs-hf-chrome__options:hover {
flex-shrink: 0; flex-shrink: 0;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
background: transparent;
} }
.ultidrive-docs-editor-surface .ProseMirror .docs-page-flow-spacer { .ultidrive-docs-editor-surface .ProseMirror .docs-page-flow-spacer {
@ -1262,6 +1287,11 @@ html.dark .docs-menu-badge {
border: 1px solid #dadce0; border: 1px solid #dadce0;
} }
.ultidrive-docs-page--sheet {
border: none;
box-shadow: none;
}
.ultidrive-docs-page--imported-border { .ultidrive-docs-page--imported-border {
border: none; border: none;
} }