feat(drive): enhance document layout with new page separators and margin masks
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
82ca9a27db
commit
76eff3c351
@ -38,7 +38,7 @@ export function DocsBodyMarginMasks({
|
||||
return (
|
||||
<div key={`body-mask-${index}`} aria-hidden>
|
||||
<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={{
|
||||
top: pageTop,
|
||||
left: 0,
|
||||
@ -48,7 +48,7 @@ export function DocsBodyMarginMasks({
|
||||
}}
|
||||
/>
|
||||
<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={{
|
||||
top: footerTop,
|
||||
left: 0,
|
||||
|
||||
49
components/drive/richtext/docs-page-rims.tsx
Normal file
49
components/drive/richtext/docs-page-rims.tsx
Normal 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
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
66
components/drive/richtext/docs-page-separators.tsx
Normal file
66
components/drive/richtext/docs-page-separators.tsx
Normal 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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,8 @@ import {
|
||||
type DocsHeaderFooterRegion,
|
||||
} from "@/components/drive/richtext/docs-header-footer-region"
|
||||
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 {
|
||||
DOCS_REGION_EDIT_EVENT,
|
||||
type DocsRegionEditDetail,
|
||||
@ -31,40 +33,12 @@ import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DocsGraphicSnapGuides } from "@/components/drive/richtext/docs-graphic-snap-guides"
|
||||
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"
|
||||
|
||||
/** 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({
|
||||
editor,
|
||||
pageLayout,
|
||||
@ -227,25 +201,30 @@ function DocsPageViewInner({
|
||||
let cancelled = false
|
||||
|
||||
const measurePageCount = () => {
|
||||
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
|
||||
if (!prose) return
|
||||
const contentHeight = measureProseContentHeight(prose)
|
||||
if (editor.isDestroyed) return
|
||||
const contentHeight = measureFlowContentHeight(editor.view)
|
||||
const count = computePageCount(contentHeight, metrics)
|
||||
setPageCount((prev) => (prev === count ? prev : count))
|
||||
}
|
||||
|
||||
const runLayoutPasses = (passesLeft: number) => {
|
||||
const runLayoutPasses = () => {
|
||||
if (cancelled || editor.isDestroyed) return
|
||||
let passesLeft = 12
|
||||
const step = () => {
|
||||
if (cancelled || editor.isDestroyed) return
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled || editor.isDestroyed) return
|
||||
const changed = applyPageFlowLayout(editor)
|
||||
if (changed && passesLeft > 1) {
|
||||
runLayoutPasses(passesLeft - 1)
|
||||
passesLeft -= 1
|
||||
if (changed && passesLeft > 0) {
|
||||
step()
|
||||
return
|
||||
}
|
||||
measurePageCount()
|
||||
})
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
let flushPending = false
|
||||
const scheduleLayout = () => {
|
||||
@ -253,13 +232,13 @@ function DocsPageViewInner({
|
||||
flushPending = true
|
||||
requestAnimationFrame(() => {
|
||||
flushPending = false
|
||||
runLayoutPasses(2)
|
||||
runLayoutPasses()
|
||||
})
|
||||
}
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
debounceId = setTimeout(() => {
|
||||
debounceId = null
|
||||
runLayoutPasses(2)
|
||||
runLayoutPasses()
|
||||
}, 32)
|
||||
}
|
||||
|
||||
@ -282,6 +261,15 @@ function DocsPageViewInner({
|
||||
onPageCountChangeRef.current?.(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 proseMinHeight = computeProseMinHeight(pageCount, metrics)
|
||||
|
||||
@ -394,7 +382,7 @@ function DocsPageViewInner({
|
||||
<div
|
||||
key={index}
|
||||
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"
|
||||
)}
|
||||
style={{
|
||||
@ -402,16 +390,6 @@ function DocsPageViewInner({
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
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
|
||||
>
|
||||
@ -452,6 +430,26 @@ function DocsPageViewInner({
|
||||
/>
|
||||
) : 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
|
||||
? Array.from({ length: pageCount }, (_, index) => {
|
||||
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import assert from "node:assert/strict"
|
||||
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", () => {
|
||||
const bodyAreaH = 900
|
||||
@ -30,4 +35,69 @@ describe("docs-page-flow spacers", () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/** 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_TOP_NARROW_PX = 0
|
||||
|
||||
@ -26,7 +26,7 @@ describe("docs-page-metrics", () => {
|
||||
})
|
||||
|
||||
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", () => {
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { Extension } from "@tiptap/core"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import type { Node as PMNode } from "@tiptap/pm/model"
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
||||
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"
|
||||
|
||||
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(
|
||||
current: DecorationSet | undefined,
|
||||
next: DecorationSet
|
||||
@ -49,6 +54,13 @@ function blockDomAtOffset(view: EditorView, offset: number): HTMLElement | 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). */
|
||||
function measureBlockFlowHeight(dom: HTMLElement): number {
|
||||
const style = getComputedStyle(dom)
|
||||
@ -56,6 +68,191 @@ function measureBlockFlowHeight(dom: HTMLElement): number {
|
||||
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 {
|
||||
const el = document.createElement("div")
|
||||
el.className = "docs-page-flow-spacer"
|
||||
@ -80,13 +277,24 @@ export function computePageFlowPushes(
|
||||
if (blockH <= 0) return
|
||||
|
||||
while (simulatedY + blockH > nextBreakY) {
|
||||
const nextBodyStart = nextBreakY + interPageSpacer
|
||||
|
||||
if (simulatedY < nextBreakY) {
|
||||
const pushPx = nextBreakY - simulatedY + interPageSpacer
|
||||
pushes.push({ blockIndex, pushPx, breakY: nextBreakY })
|
||||
simulatedY = nextBreakY + interPageSpacer
|
||||
simulatedY = nextBodyStart
|
||||
nextBreakY += pageStep
|
||||
break
|
||||
}
|
||||
|
||||
if (simulatedY < nextBodyStart) {
|
||||
const pushPx = nextBodyStart - simulatedY
|
||||
pushes.push({ blockIndex, pushPx, breakY: nextBreakY })
|
||||
simulatedY = nextBodyStart
|
||||
nextBreakY += pageStep
|
||||
break
|
||||
}
|
||||
|
||||
nextBreakY += pageStep
|
||||
}
|
||||
|
||||
@ -108,11 +316,20 @@ export function computeSimulatedLayoutHeight(
|
||||
for (const { height: blockH } of blocks) {
|
||||
if (blockH <= 0) continue
|
||||
while (simulatedY + blockH > nextBreakY) {
|
||||
const nextBodyStart = nextBreakY + interPageSpacer
|
||||
|
||||
if (simulatedY < nextBreakY) {
|
||||
simulatedY = nextBreakY + interPageSpacer
|
||||
simulatedY = nextBodyStart
|
||||
nextBreakY += pageStep
|
||||
break
|
||||
}
|
||||
|
||||
if (simulatedY < nextBodyStart) {
|
||||
simulatedY = nextBodyStart
|
||||
nextBreakY += pageStep
|
||||
break
|
||||
}
|
||||
|
||||
nextBreakY += pageStep
|
||||
}
|
||||
simulatedY += blockH
|
||||
@ -121,45 +338,55 @@ export function computeSimulatedLayoutHeight(
|
||||
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. */
|
||||
export function buildPageFlowDecorations(view: EditorView): DecorationSet {
|
||||
const metrics = readPageFlowMetrics(view.dom)
|
||||
if (!metrics) return DecorationSet.empty
|
||||
|
||||
const { bodyAreaH, interPageSpacer } = metrics
|
||||
const blocks = collectFlowBlocks(view)
|
||||
const units = collectFlowUnits(view, bodyAreaH)
|
||||
|
||||
const pushes = computePageFlowPushes(
|
||||
blocks.map((b) => ({ height: b.height })),
|
||||
units.map((u) => ({ height: u.height })),
|
||||
bodyAreaH,
|
||||
interPageSpacer
|
||||
)
|
||||
|
||||
const decorations = pushes.map(({ blockIndex, pushPx, breakY }) => {
|
||||
const block = blocks[blockIndex]
|
||||
const unit = units[blockIndex]
|
||||
return Decoration.widget(
|
||||
block.offset,
|
||||
unit.offset,
|
||||
() => 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)
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function applyPageFlowLayout(editor: Editor): boolean {
|
||||
if (editor.isDestroyed || !editor.isInitialized) return false
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -58,6 +58,12 @@
|
||||
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 {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@ -597,12 +597,36 @@ html.dark .ultidrive-richtext-region-editor table th {
|
||||
|
||||
.ultidrive-docs-editor-surface--paginated {
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ultidrive-docs-editor-surface--paginated .ProseMirror {
|
||||
min-height: var(--docs-prose-min-height);
|
||||
max-height: var(--docs-prose-min-height);
|
||||
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 {
|
||||
@ -666,6 +690,7 @@ html.dark .docs-hf-chrome__options:hover {
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ultidrive-docs-editor-surface .ProseMirror .docs-page-flow-spacer {
|
||||
@ -1262,6 +1287,11 @@ html.dark .docs-menu-badge {
|
||||
border: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
.ultidrive-docs-page--sheet {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ultidrive-docs-page--imported-border {
|
||||
border: none;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user