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 (
|
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,
|
||||||
|
|||||||
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,
|
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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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
2
next-env.d.ts
vendored
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user