docs/solutions/developer-experience/2026-05-21-slate-layout-source-entry-and-paged-editable-dx.md
The experimental Slate layout packages typechecked through monorepo path aliases and rendered an editable inside page chrome. The API looked clean at the call site, but source-first package checks and browser proof exposed two hidden DX failures.
slate-layout typecheck pulled slate-react / slate-dom source and missed
package-local ambient declarations such as dom-globals.d.ts and
@types/direction.d.ts.useStateFieldValue(pageSettings) separately
from the layout snapshot created an avoidable object-subscription loop.zIndex: 0 only in the example would have made PagedEditable easy to
use wrong in the next example.height: page.height on the same wrapper that contained the
editable hid the symptom by clipping or colliding with continuous content..example-content card made
A4 and facing-page layouts fight a 42em wrapper instead of behaving like a
document viewport.root in a layout API without binding extraction and range
projection to that root made the API look multi-root-ready while still
behaving as a default-root shortcut.Make dependency source entries carry their own ambient declarations:
/// <reference path="./dom-globals.d.ts" />
/// <reference path="./@types/direction.d.ts" />
/// <reference path="./dom-globals.d.ts" />
Make PagedEditable own the editable stacking fix by default:
const editable = (
<Editable {...editableProps} style={{ zIndex: 0, ...style }} />
)
Page geometry should be a shared view primitive, not ad hoc CSS in each example:
const geometry = getSlatePageLayoutGeometry(snapshot.pages, {
pageGap: 24,
pageLayoutMode: 'spread',
})
PagedEditable should render fixed page surfaces and a single editor overlay as
siblings. The editor overlay covers the whole page stack like Premirror, and
renderPage is page chrome only; it should not receive the editable as children:
<div data-slate-paged-editable style={{ position: 'relative' }}>
{pages.map((page) => (
<div data-slate-page-surface style={{ position: 'absolute' }}>
{renderPage({ attributes, children: null, page })}
</div>
))}
<div data-slate-paged-editable-editor-overlay style={{ position: 'absolute' }}>
<div data-slate-paged-editable-editor style={{ position: 'absolute' }}>
<Editable {...editableProps} style={{ position: 'relative', zIndex: 0 }} />
</div>
</div>
</div>
The layout engine should expose per-line ranges so examples can project text into page coordinates:
fragment.lines.map((line) => ({
range: {
anchor: { path, offset: line.start },
focus: { path, offset: line.end },
},
data: {
paginationLine: {
left: pagePlacement.left + page.content.left,
top: pagePlacement.top + line.top,
},
},
}))
The example can then render those decorated leaves as absolute line fragments:
renderLeaf={({ attributes, children, segment }) => {
const line = segment.slices.find((slice) => slice.data?.paginationLine)
?.data?.paginationLine
return line ? (
<span {...attributes} style={{ position: 'absolute', left: line.left, top: line.top }}>
{children}
</span>
) : (
<span {...attributes}>{children}</span>
)
}}
Expose block and line projection as a layout API, not example-only math:
const projection = getSlatePageLayoutProjection(snapshot, { geometry })
Use projected block boxes for real editable paragraph elements and projected
line boxes for relative leaf positioning. Empty paragraphs still need a
full-width, non-zero-height block box even when their text line has width: 0.
Bridge those boxes by Slate path, not element object identity:
const box = projectionByPath.get(attributes['data-slate-path'])
Render the block as the caret target and the leaf as the visible line:
<div
{...attributes}
style={{ position: 'absolute', left: box.left, top: box.top, height: box.height }}
>
{children}
</div>
Projected line leaves also need an invisible hit width that extends through the blank tail of the paragraph. Keep measured text width as layout data, but render the leaf box wide enough for native click resolution:
paginationLine: {
width: line.width,
hitWidth: Math.max(
line.width,
blockBox.width - inlineInset - (line.left - blockBox.left)
),
}
<span style={{ position: 'absolute', width: line.hitWidth }}>
{children}
</span>
The same rule applies vertically. Paragraph spacing must still belong to an adjacent line hit target, otherwise a click between paragraphs hits only the editable root and the browser can resolve the caret to an unrelated projected block. Extend the previous block's last line through the following small block gap while keeping the visual line-height unchanged:
paginationLine: {
height: line.height,
hitHeight: isLastLine ? line.height + nextBlockGap : line.height,
}
<span
style={{
height: line.hitHeight,
lineHeight: `${line.height}px`,
}}
>
{children}
</span>
Once the behavior is proven, move that projection math behind slate-layout so
examples show API use instead of private layout bookkeeping:
const projection = getSlatePageLayoutProjection(snapshot, {
geometry,
hitTesting: { inlineInset: 2 },
})
const decorations = getSlatePageLayoutDecorations(projection, {
rects: 'block',
data: ({ rects }) => ({ paginationLine: rects }),
})
Projected runs need both block offsets and leaf offsets. The line keeps
textRect for visual placement and hitRect for native click resolution; the
decoration helper uses leafRange so callers do not walk NodeApi.texts(...)
in React examples:
type SlatePageLayoutProjectedLine = {
hitRect: SlatePageRect
textRect: SlatePageRect
}
type SlatePageLayoutPlacedRun = {
leafRange: { start: number; end: number }
path: Path
}
Decoration rects must also be run-scoped. Iterating per placed run while passing
line-wide rects is a dirty half-abstraction: every leaf receives the same
absolute left/top and mixed inline content piles onto itself. Build each
decoration from the run's left and width, then extend only the final run's
hitRect through the line tail for native blank-tail clicks:
const textRect = {
height: line.textRect.height,
left: line.textRect.left + run.left,
top: line.textRect.top,
width: run.width,
}
const hitRight =
run.range.end >= line.end
? Math.max(line.hitRect.left + line.hitRect.width, textRect.left + textRect.width)
: textRect.left + textRect.width
Run positions must come from the active layout engine, not the estimated fallback
in slate-layout. For Pretext-backed pages, measure every line run with that
run's own font and letter spacing before pagination returns fragments:
const width = measureNaturalWidth(
prepareWithSegments(runText, run.textStyle.font, {
letterSpacing: run.textStyle.letterSpacing,
whiteSpace,
wordBreak,
})
)
For mixed Markdown proof, keep structured blocks honest: flow-render table, image, and thematic-break elements inside the projected block box, and leave line decorations to ordinary text blocks until table/cell projection is deep enough to own real grid selection.
Custom page renderers should keep page chrome fixed:
style={{
height: page.height,
overflow: 'hidden',
width: page.width,
}}
Document-style pagination examples should own an immersive shell under the examples header and scale their page stack to the viewport:
<div className={viewportCss} ref={viewportRef}>
<div style={{ height: unscaledHeight * pageScale }}>
<div style={{ transform: `scale(${pageScale})`, width: pageStackWidth }}>
<PagedEditable pageLayoutMode="spread" />
</div>
</div>
</div>
Root-bound layout stores must expose root ownership in their snapshot and reject projection for ranges that belong to a different root:
const layout = createSlatePageLayout(editor, () => ({
engine,
root: 'header',
}))
layout.getSnapshot().root // 'header'
layout.projectRange({
anchor: { root: 'header', path: [0, 0], offset: 0 },
focus: { root: 'header', path: [0, 0], offset: 6 },
})
Range projection should use the same page geometry options as the visible page layout, including facing spreads:
layout.projectRange(range, {
pageGap: 24,
pageLayoutMode: 'spread',
})
Use the layout snapshot as the page-settings read model in examples:
const layout = useSlatePageLayout(editor, {
engine,
settings: pageSettings,
typography,
})
const snapshot = useSlatePageLayoutSnapshot(layout)
const settings = snapshot.settings
Keep Pretext preparation in the engine closure, not React render:
const preparedCache = new Map<string, ReturnType<typeof prepare>>()
Slate-backed Pretext layout should default to editable whitespace semantics:
const prepared = prepareWithSegments(text, font, {
letterSpacing,
whiteSpace: 'pre-wrap',
wordBreak: 'normal',
})
Expose whiteSpace and wordBreak as engine options for static-layout callers,
but keep the default aligned with Slate's editable DOM so trailing spaces,
explicit line breaks, and projected leaf ranges stay addressable.
Source-first package checks compile dependency source through path aliases, so
ambient declarations must be reachable from the package source entry, not only
from that package's own tsconfig.include. PagedEditable wraps an Editable
inside page chrome; because raw Editable defaults to zIndex: -1, the wrapper
must establish the interactive stacking context itself.
Until Slate supports true fragmented editable DOM, page chrome cannot wrap the full editable tree. Fixed page surfaces are correct only when they are siblings of a full-stack editor overlay. Premirror's useful trick is not just the page stack; it is projecting document fragments into absolute page coordinates. Slate can demo the same architecture with range decorations, even though production cross-page editing/selection still needs deeper runtime work.
Paged examples are not ordinary rich-text examples. They need a document canvas,
not a centered docs card. Scaling the page stack keeps single and spread
layout modes usable inside the existing example route without introducing
horizontal document scroll.
Layout is derived view data, but it still participates in the root/view architecture. A root-bound layout should read blocks from that root and project only root-compatible ranges. Rootless ranges remain acceptable at the public single-root edge, but the layout normalizes them against its root before doing any projection.
Reading page settings from the layout snapshot keeps the example centered on one store: state fields own persisted settings, while the layout store owns derived page data.
Pretext's normal whitespace mode is right for CSS-normal static text because it
collapses and trims boundary whitespace. A Slate editable is not static prose:
the trailing spaces are real offsets that selection, native input repair, and
projected leaf decorations must preserve. Using pre-wrap keeps Pretext's line
ranges and Slate's DOM text positions in the same coordinate space.
Backspace across repeated leading empty paragraphs needs a command-level guard: when the caret is at the start of a block and the previous top-level block is empty, remove that previous block as a path target. Do not turn that gesture into a hanging range cleanup, because structural cleanup will erase the whole empty run in one command.
When Backspace merges a whitespace-only paragraph with the following paragraph, range cleanup must stay bounded to the active delete range. Global top-level empty-block cleanup will erase unrelated blank paragraphs created by earlier Enter presses.
Run-scoped decoration rects match the actual abstraction boundary. The layout
line owns page-relative visual and hit geometry; the placed run owns inline
advance and leaf-local offsets. Combining those once in slate-layout prevents
every React example from rebuilding offset math and keeps final-run blank-tail
selection behavior compatible with mixed inline rendering.
The Pretext engine must emit measured run positions because the fallback run builder is intentionally approximate. It is good enough for source-free lines, but it is not acceptable for a browser-visible rich-text example: Helvetica regular, Helvetica bold/italic, and monospace code all have different advances. Once decorations are run-scoped, bad run widths become visible as gaps.
Editable; visible text is not enough. Click
and type through the real [contenteditable="true"] target.renderPage, use fixed height for page chrome and keep the
editable out of that subtree.single/spread layout
and viewport scaling; do not leave that as ad hoc example CSS.data-slate-path or a real path hook when render UI depends on path.insertBreak plus Backspace when a paged
example uses custom renderElement and absolute leaves.Enter x N, Space, Enter, Backspace.
Programmatic deleteBackward() coverage alone misses the whitespace block
merge path.root, add tests proving extraction, snapshots, and range
projection all use that root. Fake root support is worse than no root support.end === text.length, and the
browser test should assert the final rendered leaf is still absolutely
projected after typing spaces at paragraph end.slate-layout and keep the example
focused on renderElement / renderLeaf.textRect and hitRect; CSS-only blank-tail
and paragraph-gap hacks are hard to test and easy to copy wrong.hitRect recreates the overlap bug.split: 'avoid' should move whole to the next
page when they fit on a fresh page. Otherwise one projected block rect can
union fragments across facing pages and paint through the gutter.