← Files get-fableARCHIVED FILE
assets/starter-components/doc-page.js
20.5 KB · Oct 2, 2026 · 00:30 UTC
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
/**
* <doc-page> — paged-document shell for printable HTML.
*
* On screen the document renders as a single continuous sheet on a desk
* background (Google Docs' pageless view): you scroll one tall page card.
* There is no manual page-splitting — write the whole document as normal
* flow inside <doc-page> and the browser's print engine paginates it at
* export.
*
* At print the component injects `@page { size: …; margin: 0 }` (which
* leaves Chrome no margin box to draw its date/URL/page-count header in)
* and moves the visual margin onto the sheet's own padding, so the printed
* page has the same inset you see on screen. Standard break-hygiene rules
* (`break-inside: avoid` on figures, code blocks, images and table rows;
* `orphans/widows: 3`) are applied so paragraphs and groups split cleanly.
* On screen and at print, headings default to `text-wrap: balance` and
* body text (p, li, blockquote, figcaption) to `text-wrap: pretty`, so
* the document avoids widowed/orphaned words; the defaults have zero
* specificity, so any text-wrap you declare on those elements wins.
* The component also marks the document as owning its print CSS (a
* `meta[name="omelette-owns-print"]` it injects at runtime), so the
* PDF export never injects page-geometry CSS of its own on top.
*
* Usage:
* <style>doc-page:not(:defined){visibility:hidden}</style>
* <doc-page size="letter" margin="0.75in">
* <h1>Title</h1>
* <p>…body…</p>
* </doc-page>
* <script src="doc-page.js"></script>
*
* Attributes:
* size — letter | a4 | legal (default letter)
* orientation — portrait (default) | landscape. For documents built to
* export, always set it explicitly. landscape swaps the named
* size's dimensions (letter landscape prints 11in × 8.5in).
* width / height — explicit CSS lengths, override `size` and
* `orientation`: the page IS the design's size (a poster
* printed at its true dimensions). With both set, the component
* also declares the page box as the preview size (a
* `meta[name="omelette-fixed-size"]` it injects at runtime,
* never overriding one you author), so the in-app preview
* scales the whole sheet into view.
* content-width / content-height — the design's own fixed dimensions
* (CSS lengths), for scaling a fixed-size design ONTO the named
* paper: content lays out at exactly this size, and the
* component scales it to fit the printable area (centered
* horizontally, top-aligned), so e.g. a 960px-wide poster lands
* on one Letter page. Both must be set; they do not change the
* page box — `size`/`orientation` (or `width`/`height`)
* still name the paper. For pages WITHOUT running
* header/footer slots — the fit box fills the printable area
* and does not subtract slot heights.
* margin — printable inset on every page (default 0.75in); margin="0"
* makes pages full-bleed (content then owns its own insets)
*
* Running header/footer (optional): give an element `slot="header"` or
* `slot="footer"` and it repeats on every printed page via
* `position: fixed`. To keep body text from sliding under it, the
* component prints inside a single-cell table whose <thead>/<tfoot> are
* spacers sized to the header/footer height — browsers repeat thead/tfoot
* on every page, so each sheet's content starts below the header and ends
* above the footer. On screen the header/footer render once at the
* top/bottom of the sheet.
*
* Print best practices for the content you author:
* - Multi-column text: use CSS columns (`column-count` +
* `column-gap`), never side-by-side flex/grid columns — only real
* CSS columns flow and break across pages. `column-span: all` lets
* a heading span the columns; `hyphens: auto` (needs `lang` on
* the html element) keeps narrow columns readable.
* - Page breaks: `break-before: page` on an element that must start
* a new page (a chapter, an appendix). Add your own kept-together
* blocks (callouts, stat tiles, cards) to a `break-inside: avoid`
* rule, and keep each one shorter than a page.
* - Extend `orphans: 3; widows: 3` to any custom text blocks you add
* (p and li are covered by default).
* - Give long tables a <thead> — browsers repeat it on every printed
* page.
* - No `position: fixed`/`sticky` and no viewport units in content:
* fixed elements stamp every printed page (running headers/footers go
* in the component's slots) and `100vh` mis-sizes at print.
*
* Author content as static HTML so the user can click-to-edit any text
* directly. Do not set width/padding/background on the document body —
* the component owns the sheet box.
*/
/* END USAGE */
(() => {
const PAPER = {
letter: ['8.5in', '11in'],
a4: ['210mm', '297mm'],
legal: ['8.5in', '14in'],
};
const CSS_LENGTH = /^\d+(\.\d+)?(px|in|mm|cm|pt|pc)$/;
// Unitless "0" is a valid CSS length and the natural way to write
// margin="0"; normalise it to 0px so max()/calc() (which reject a bare
// number) keep working.
const safeLen = (v, fb) => {
v = (v || '').trim();
return v === '0' ? '0px' : CSS_LENGTH.test(v) ? v : fb;
};
// CSS length → px number (CSS absolute units are exact: 1in = 96px).
// Returns NaN for anything safeLen would reject — callers gate on it.
const PX_PER = { px: 1, in: 96, mm: 96 / 25.4, cm: 96 / 2.54, pt: 96 / 72, pc: 16 };
const toPx = (v) => {
const m = /^(\d+(?:\.\d+)?)(px|in|mm|cm|pt|pc)$/.exec((v || '').trim());
return m ? parseFloat(m[1]) * PX_PER[m[2]] : NaN;
};
const stylesheet = `
:host {
position: relative;
display: block;
/* When the viewport is narrower than the page, grow to wrap the
* sheet (plus this padding) instead of staying viewport-width, so
* the desk background and right margin reach the sheet's far edge
* in the horizontal scroll. */
min-width: max-content;
min-height: 100vh;
background: #ece8dd;
padding: 48px 24px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
--doc-page-w: 8.5in;
--doc-page-h: 11in;
--doc-page-margin: 0.75in;
--doc-hdr-h: 0px;
--doc-ftr-h: 0px;
--doc-hdr-pad: 0px;
--doc-ftr-pad: 0px;
}
.sheet {
width: var(--doc-page-w);
margin: 0 auto;
background: #fff;
box-shadow: 0 2px 14px rgba(20, 20, 19, 0.12);
border-radius: 2px;
box-sizing: border-box;
padding: var(--doc-page-margin);
}
.frame { width: 100%; border-collapse: collapse; }
/* Scaled-fit mode (content-width/content-height): the inner .fit box
* lays the content out at its authored fixed size and scales it onto
* the printable area; .fit-box reserves the scaled footprint in flow
* (transforms don't affect layout) and centers it. Without the mode,
* both divs are unstyled block pass-throughs. */
.fit-mode .fit-box {
width: calc(var(--doc-fit-w) * var(--doc-fit-scale));
height: calc(var(--doc-fit-h) * var(--doc-fit-scale));
margin: 0 auto;
break-inside: avoid;
}
.fit-mode .fit {
width: var(--doc-fit-w);
height: var(--doc-fit-h);
transform: scale(var(--doc-fit-scale));
transform-origin: top left;
}
.frame td, .frame th { padding: 0; text-align: left; font-weight: inherit; }
.hdr-space { height: var(--doc-hdr-h); }
.ftr-space { height: var(--doc-ftr-h); }
::slotted([slot="header"]),
::slotted([slot="footer"]) { display: block; box-sizing: border-box; }
@media print {
:host { background: none; padding: 0; min-width: 0; min-height: 0; }
.sheet {
width: auto; margin: 0; box-shadow: none; border-radius: 0;
padding: 0 var(--doc-page-margin);
}
/* The thead/tfoot spacers repeat on every page, so they carry the
* vertical page margin (which the sheet's own padding cannot, since
* that padding is consumed once on the first/last page). The running
* header/footer are fixed inside that band. */
/* The 0.35in is breathing room between a running header/footer and
* the body; without one the spacer is exactly the page margin, so a
* margin="0" full-bleed document gets truly full-bleed pages. */
.hdr-space { height: max(var(--doc-page-margin), calc(var(--doc-hdr-h) + var(--doc-hdr-pad))); }
.ftr-space { height: max(var(--doc-page-margin), calc(var(--doc-ftr-h) + var(--doc-ftr-pad))); }
::slotted([slot="header"]) {
position: fixed; top: 0; left: 0; right: 0; margin: 0;
padding: calc(var(--doc-page-margin) * 0.45) var(--doc-page-margin) 0;
}
::slotted([slot="footer"]) {
position: fixed; bottom: 0; left: 0; right: 0; margin: 0;
padding: 0 var(--doc-page-margin) calc(var(--doc-page-margin) * 0.45);
}
}
`;
class DocPage extends HTMLElement {
static get observedAttributes() {
return ['size', 'width', 'height', 'margin', 'orientation', 'content-width', 'content-height'];
}
constructor() {
super();
this._root = this.attachShadow({ mode: 'open' });
this._mo = typeof MutationObserver === 'function'
? new MutationObserver(() => this._scheduleMeasure())
: null;
}
/** The named paper's [w, h], swapped when orientation="landscape".
* Only the named size swaps — explicit width/height are exact values
* the author already oriented. */
_paperSize() {
const named = PAPER[(this.getAttribute('size') || '').toLowerCase()] || PAPER.letter;
const landscape =
(this.getAttribute('orientation') || '').trim().toLowerCase() === 'landscape';
return landscape ? [named[1], named[0]] : named;
}
get pageWidth() {
return safeLen(this.getAttribute('width'), this._paperSize()[0]);
}
get pageHeight() {
return safeLen(this.getAttribute('height'), this._paperSize()[1]);
}
get pageMargin() { return safeLen(this.getAttribute('margin'), '0.75in'); }
/** Scaled-fit mode's content box [w, h] as CSS lengths, or null when
* the mode is off (either attribute missing/invalid/zero — a partial
* declaration falls back to normal flow rather than guessing). */
_contentFit() {
const w = safeLen(this.getAttribute('content-width'), null);
const h = safeLen(this.getAttribute('content-height'), null);
if (!w || !h) return null;
const wPx = toPx(w), hPx = toPx(h);
return wPx > 0 && hPx > 0 ? [w, h, wPx, hPx] : null;
}
connectedCallback() {
if (!this._sheet) this._render();
this._syncSize();
this._syncPrintPageRule();
this._ensureTextWrapDefaults();
this._ensureOwnsPrintMeta();
this._syncFixedSizeMeta();
if (this._mo) this._mo.observe(this, {
subtree: true, childList: true, characterData: true, attributes: true,
});
this._onResize = () => this._scheduleMeasure();
window.addEventListener('resize', this._onResize);
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => this._scheduleMeasure());
}
this._scheduleMeasure();
}
disconnectedCallback() {
window.removeEventListener('resize', this._onResize);
if (this._mo) this._mo.disconnect();
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
// Drop the head rules when the last doc-page leaves, so a deleted
// document's @page geometry and text-wrap defaults can't apply to
// whatever replaces it.
const survivor = document.querySelector('doc-page');
if (!survivor) {
['doc-page-print', 'doc-page-text-wrap', 'doc-page-owns-print', 'doc-page-fixed-size'].forEach((id) => {
const tag = document.getElementById(id);
if (tag) tag.remove();
});
} else if (typeof survivor._syncFixedSizeMeta === 'function') {
// A departed true-size owner hands the page-global preview meta
// to whatever true-size page remains (or it's removed).
survivor._syncFixedSizeMeta();
}
}
attributeChangedCallback() {
if (!this._sheet) return;
this._syncSize();
this._syncPrintPageRule();
this._syncFixedSizeMeta();
this._scheduleMeasure();
}
_render() {
this._root.innerHTML = `
<style>${stylesheet}</style>
<style id="vars"></style>
<div class="sheet" data-screen-label="Document">
<table class="frame" role="presentation">
<thead><tr><th><div class="hdr-space"><slot name="header"></slot></div></th></tr></thead>
<tbody><tr><td class="body"><div class="fit-box"><div class="fit"><slot></slot></div></div></td></tr></tbody>
<tfoot><tr><td><div class="ftr-space"><slot name="footer"></slot></div></td></tr></tfoot>
</table>
</div>`;
this._sheet = this._root.querySelector('.sheet');
this._vars = this._root.getElementById('vars');
}
/** Runtime sizing lives in a shadow <style> :host rule, never on the
* light-DOM host element, so serialize-persist can't write it back. */
_syncSize(hdrH, ftrH) {
// Scaled-fit mode: content at its authored size, scaled onto the
// printable area (page minus margins on both axes). The factor is a
// plain number var so calc(length * number) stays valid; 4 decimals
// keeps the shadow style stable across re-measures. Upscaling is
// allowed — print transforms are vector, so text and CSS stay crisp
// (raster images soften, which the catalog bullet warns about).
const fit = this._contentFit();
let fitVars = '';
if (fit) {
const marginPx = toPx(this.pageMargin) || 0;
const availW = toPx(this.pageWidth) - 2 * marginPx;
const availH = toPx(this.pageHeight) - 2 * marginPx;
const scale = Math.min(availW / fit[2], availH / fit[3]);
if (scale > 0 && Number.isFinite(scale)) {
fitVars =
'--doc-fit-w:' + fit[0] + ';' +
'--doc-fit-h:' + fit[1] + ';' +
'--doc-fit-scale:' + scale.toFixed(4) + ';';
}
}
this._sheet.classList.toggle('fit-mode', !!fitVars);
this._vars.textContent = ':host{' +
fitVars +
'--doc-page-w:' + this.pageWidth + ';' +
'--doc-page-h:' + this.pageHeight + ';' +
'--doc-page-margin:' + this.pageMargin + ';' +
'--doc-hdr-h:' + (hdrH || 0) + 'px;' +
'--doc-ftr-h:' + (ftrH || 0) + 'px;' +
'--doc-hdr-pad:' + (hdrH ? '0.35in' : '0px') + ';' +
'--doc-ftr-pad:' + (ftrH ? '0.35in' : '0px') + '}';
}
/** @page is a no-op inside shadow DOM, so the rule lives in <head>.
* Re-appended on every sync so it stays last in source order — the
* @page cascade is source-order per descriptor, so this rule wins
* over any other @page rule in the document. */
_syncPrintPageRule() {
const id = 'doc-page-print';
let tag = document.getElementById(id);
if (!tag) {
tag = document.createElement('style');
tag.id = id;
}
document.head.appendChild(tag);
tag.textContent =
'@page { size: ' + this.pageWidth + ' ' + this.pageHeight + '; margin: 0; } ' +
'@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; height: auto !important; overflow: visible !important; } ' +
'h1,h2,h3,h4,h5,h6 { break-after: avoid; } ' +
'figure,pre,blockquote,img,svg,tr { break-inside: avoid; } ' +
'p,li { orphans: 3; widows: 3; } ' +
'* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } ' +
'*, *::before, *::after { animation-delay: -99s !important; animation-duration: .001s !important; ' +
'animation-iteration-count: 1 !important; animation-fill-mode: both !important; ' +
'animation-play-state: running !important; transition-duration: 0s !important; } }';
}
/** Typographic defaults for document text: balance headings, avoid
* widowed/orphaned words in body copy (browsers without text-wrap
* support drop the declarations). Zero-specificity via :where() so
* any text-wrap authored on those elements wins; document-level so the
* rules reach the slotted (light DOM) content — shadow styles can't.
* data-omelette-injected marks the tag for the host editor to strip
* at serialize, so it is never written back as authored source. */
_ensureTextWrapDefaults() {
if (document.getElementById('doc-page-text-wrap')) return;
const tag = document.createElement('style');
tag.id = 'doc-page-text-wrap';
tag.setAttribute('data-omelette-injected', '');
tag.textContent =
':where(h1,h2,h3,h4,h5,h6){text-wrap:balance}' +
':where(p,li,blockquote,figcaption){text-wrap:pretty}';
document.head.appendChild(tag);
}
/** Declares that this document owns its print CSS. The instant-PDF
* export checks for the meta by NAME PRESENCE alone (content is
* ignored) and skips its automatic print-CSS injections, so the
* component's @page geometry is never overridden by a heuristic.
* data-omelette-injected keeps it out of serialized source. */
_ensureOwnsPrintMeta() {
if (document.getElementById('doc-page-owns-print')) return;
const tag = document.createElement('meta');
tag.id = 'doc-page-owns-print';
tag.name = 'omelette-owns-print';
tag.content = 'true';
tag.setAttribute('data-omelette-injected', '');
document.head.appendChild(tag);
}
/** This page's valid true-size page box (explicit width AND height)
* as [w, h] px ints, or null when the mode is off. */
_trueSizePx() {
if (
!safeLen(this.getAttribute('width'), null) ||
!safeLen(this.getAttribute('height'), null)
) return null;
const w = Math.round(toPx(this.pageWidth));
const h = Math.round(toPx(this.pageHeight));
return w > 0 && h > 0 ? [w, h] : null;
}
/** True-size pages (explicit width AND height) also declare the page
* box as the preview size: the in-app preview reads
* meta[name="omelette-fixed-size"] (content "W,H" in px ints) and
* scales the sheet into view — without it an 18in poster previews at
* true size with scrollbars. Never overrides an author-set meta
* (only the component's own id is managed). The meta is page-global
* while doc-page instances are not, so every sync recomputes the
* page-wide owner — the first connected true-size doc-page — and a
* non-true-size sibling's sync can never delete the owner's meta.
* Removed when no true-size page remains (the owner's disconnect
* re-syncs via any survivor) or when an author-set meta exists. */
_syncFixedSizeMeta() {
const id = 'doc-page-fixed-size';
const own = document.getElementById(id);
const authored = document.querySelector(
'meta[name="omelette-fixed-size"]:not([data-omelette-injected])'
);
// The page-wide owner, not this instance: an upgraded true-size page
// anywhere in the document keeps the meta alive and sized.
let box = null;
for (const el of document.querySelectorAll('doc-page')) {
box = typeof el._trueSizePx === 'function' ? el._trueSizePx() : null;
if (box) break;
}
if (!box || authored) {
if (own) own.remove();
return;
}
const tag = own || document.createElement('meta');
tag.id = id;
tag.name = 'omelette-fixed-size';
tag.content = box[0] + ',' + box[1];
tag.setAttribute('data-omelette-injected', '');
if (!own) document.head.appendChild(tag);
}
_scheduleMeasure() {
if (this._raf) return;
this._raf = requestAnimationFrame(() => { this._raf = null; this._measure(); });
}
/** Slot heights feed the print spacers (--doc-hdr-h / --doc-ftr-h), so
* they re-measure on content mutation, resize, and font load. */
_measure() {
const hdr = this.querySelector(':scope > [slot="header"]');
const ftr = this.querySelector(':scope > [slot="footer"]');
this._syncSize(hdr ? hdr.offsetHeight : 0, ftr ? ftr.offsetHeight : 0);
}
}
if (!customElements.get('doc-page')) {
customElements.define('doc-page', DocPage);
}
})();
SHA-256: f106e1b77ea0c99ae26489ed9b394ce7f0e6bac3560d536027a5200f6490f88a