@browser.style/print-preview
Version:
Print preview component with page layout controls and templates support
433 lines (378 loc) • 16 kB
JavaScript
export default class PrintPreview extends HTMLElement {
static #pageStyleId;
static #id;
static #instance = null;
static #printStyleId;
static {
const id = [...crypto.getRandomValues(new Uint8Array(4))]
.map(n => n.toString(16).padStart(2, '0')).join('');
this.#id = `dp${id}`;
this.#pageStyleId = `style-${id}`;
this.#printStyleId = `print-${this.#id}`;
}
get basePath() {
return new URL('.', import.meta.url).href;
}
static get id() { return this.#id; }
static observedAttributes = [
'font-family',
'font-size',
'lang',
'margin-bottom',
'margin-left',
'margin-right',
'margin-top',
'orientation',
'paper-size',
'template'
];
static #icons = {
close: 'M18 6l-12 12, M6 6l12 12',
fontfamily: 'M4 20l3 0, M14 20l7 0, M6.9 15l6.9 0, M10.2 6.3l5.8 13.7, M5 20l6 -16l2 0l7 16',
fontsize: 'M3 7v-2h13v2, M10 5v14, M12 19h-4, M15 13v-1h6v1, M18 12v7, M17 19h2',
image: 'M15 8h.01, M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z, M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5, M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3',
margin: 'M8 8h8v8h-8z, M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z',
paper: 'M14 3v4a1 1 0 0 0 1 1h4, M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z',
printer: 'M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2, M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4, M7 13m0 2a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2z'
};
static #paperSizes = {
A5: { width: '148mm', height: '210mm', ratio: 210/148 },
A4: { width: '210mm', height: '297mm', ratio: 297/210 },
A3: { width: '297mm', height: '420mm', ratio: 420/297 },
B5: { width: '176mm', height: '250mm', ratio: 250/176 },
B4: { width: '250mm', height: '353mm', ratio: 353/250 },
'JIS-B5': { width: '182mm', height: '257mm', ratio: 257/182 },
'JIS-B4': { width: '257mm', height: '364mm', ratio: 364/257 },
letter: { width: '8.5in', height: '11in', ratio: 11/8.5 },
legal: { width: '8.5in', height: '14in', ratio: 14/8.5 },
ledger: { width: '11in', height: '17in', ratio: 17/11 }
};
static #i18n = {
en: {
bottom: 'Bottom',
close: 'Close',
errors: {
single_instance: 'Only one <print-preview> element is allowed',
body_child: '<print-preview> must be a direct child of <body>'
},
font_family: 'Font Family',
font_size: 'Font Size',
hide: 'Hide',
images: 'Images',
left: 'Left',
orientation: 'Orientation',
orientation_landscape: 'Landscape',
orientation_portrait: 'Portrait',
outline: 'Outline',
paper_size: 'Paper Size',
print: 'Print',
right: 'Right',
show: 'Show',
top: 'Top'
}
};
static get i18n() { return this.#i18n; }
static set i18n(value) { this.#i18n = { ...this.#i18n, ...value }; }
#lang = 'en';
#t = () => PrintPreview.#i18n[this.#lang];
get fontSize() { return this.getAttribute('font-size') ?? 'medium'; }
get fontFamily() { return this.getAttribute('font-family') ?? 'ff-inherit'; }
get marginBottom() { return this.getAttribute('margin-bottom') ?? '10mm'; }
get marginLeft() { return this.getAttribute('margin-left') ?? '10mm'; }
get marginRight() { return this.getAttribute('margin-right') ?? '10mm'; }
get marginTop() { return this.getAttribute('margin-top') ?? '10mm'; }
get orientation() { return this.getAttribute('orientation') ?? 'portrait'; }
get paperSize() { return this.getAttribute('paper-size') ?? 'A4'; }
get template() { return this.getAttribute('template') ?? 'default'; }
get useTemplate() { return this.getAttribute('template') !== null; }
set data(value) {
this._data = value;
this.#renderContent();
}
#handleKeyPress = e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
this.print();
}
};
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._templates = new Map();
this.#lang = this.getAttribute('lang') ?? 'en';
this.shadowRoot.innerHTML = '<form></form><paper-tray></paper-tray>';
this.content = this.shadowRoot.querySelector('paper-tray');
this.#loadStyles();
this.#setupEventListeners();
// Listen for popover state changes
this.addEventListener('beforetoggle', e => {
if (e.newState === 'open') {
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', this.#handleKeyPress);
} else {
document.body.style.overflow = '';
if (!document.body.style.length) {
document.body.removeAttribute('style');
}
document.removeEventListener('keydown', this.#handleKeyPress);
}
});
// Listen for system print
window.addEventListener('beforeprint', () => {
if (!this.hasAttribute('mode')) {
this.setAttribute('mode', 'hidden');
}
});
window.addEventListener('afterprint', () => {
this.removeAttribute('mode');
});
}
attributeChangedCallback(name) {
if (name === 'lang') {
this.#lang = this.getAttribute('lang') ?? 'en';
this.#renderForm();
} else if (['paper-size', 'orientation', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'font-size', 'font-family'].includes(name)) {
this.#updatePageStyles();
}
}
connectedCallback() {
if (PrintPreview.#instance || this.parentElement !== document.body) {
console.error(this.#t()[PrintPreview.#instance ? 'errors.single_instance' : 'errors.body_child']);
this.#cleanup();
return;
}
this.id = PrintPreview.id;
PrintPreview.#instance = this;
this.setAttribute('popover', '');
this.#addPageStyles();
this.#renderForm();
this.#renderContent();
}
disconnectedCallback() {
this.#cleanup();
}
/* === private methods === */
#addPageStyles() {
if (!document.getElementById(PrintPreview.#pageStyleId)) {
const style = document.createElement('style');
style.id = PrintPreview.#pageStyleId;
document.head.appendChild(style);
this.#updatePageStyles();
}
}
#addPrintStyle() {
if (!document.getElementById(PrintPreview.#printStyleId)) {
const style = document.createElement('style');
style.id = PrintPreview.#printStyleId;
style.textContent = `
print {
body:has(print-preview) > *:not(print-preview) { display: none !important; }
}
{
margin: ${this.marginTop} ${this.marginRight} ${this.marginBottom} ${this.marginLeft};
size: ${this.paperSize} ${this.orientation};
}
`;
document.head.appendChild(style);
}
}
#cleanup = () => {
document.getElementById(PrintPreview.#pageStyleId)?.remove();
if (PrintPreview.#instance === this) PrintPreview.#instance = null;
this.remove();
};
#handleFormChange = {
images: value => this.setAttribute('images', value),
orientation: value => this.setAttribute('orientation', value),
'font-size': value => this.setAttribute('font-size', value),
'font-family': value => this.setAttribute('font-family', value),
'margin-bottom': value => this.setAttribute('margin-bottom', `${value}mm`),
'margin-left': value => this.setAttribute('margin-left', `${value}mm`),
'margin-right': value => this.setAttribute('margin-right', `${value}mm`),
'margin-top': value => this.setAttribute('margin-top', `${value}mm`),
'paper-size': value => this.setAttribute('paper-size', value)
};
#icon(paths, part) {
return `<svg viewBox="0 0 24 24"${part ? ` part="${part}"` : ''}>${
paths.split(',').map(path => `<path d="${path}"></path>`).join('')
}</svg>`;
}
async #loadStyles() {
try {
const cssPath = this.getAttribute('styles') ||
(this.basePath ? `${this.basePath}index.css` : 'index.css');
const response = await fetch(cssPath);
if (response.ok) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(await response.text());
this.shadowRoot.adoptedStyleSheets = [sheet];
}
} catch (_) {}
}
#removePrintStyle() {
document.getElementById(PrintPreview.#printStyleId)?.remove();
}
#renderContent() {
if (this.useTemplate && !this._data) return;
const templateFn = this._templates.get(this.template);
const settings = this._templates.get(`${this.template}-settings`);
if (settings) {
Object.entries(settings).forEach(([key, value]) => {
this.setAttribute(key, value);
});
this.#renderForm();
}
this.content.innerHTML = this.useTemplate && this._data ?
templateFn(this._data) : '<slot></slot>';
}
#renderForm() {
const t = this.#t();
this.shadowRoot.querySelector('form').innerHTML = `
<label aria-label="${t.paper_size}">
${this.#icon(PrintPreview.#icons.paper, 'paper')}
<select name="paper-size">
${Object.keys(PrintPreview.#paperSizes)
.map(size => `<option value="${size}"${size === this.paperSize ? ' selected' : ''}>${size}</option>`)
.join('')}
</select>
</label>
<label aria-label="${t.orientation}">
${this.#icon(PrintPreview.#icons.paper, 'paper')}
<select name="orientation">
<option value="portrait"${this.orientation === 'portrait' ? ' selected' : ''}>${t.orientation_portrait}</option>
<option value="landscape"${this.orientation === 'landscape' ? ' selected' : ''}>${t.orientation_landscape}</option>
</select>
</label>
<label aria-label="${t.font_family}">
${this.#icon(PrintPreview.#icons.fontfamily, 'fontsize')}
<select name="font-family">
${['Antique', 'Classical', 'Code', 'Didone', 'Geometric', 'Grotesque', 'Handwritten', 'Humanist', 'Industrial', 'Inherit', 'Monospace', 'Old Style', 'Rounded', 'Slab', 'System', 'Transitional']
.map(family => {
const value = `ff-${family.toLowerCase().replace(/\s+/g, '-')}`;
return `<option value="${value}"${value === this.fontFamily ? ' selected' : ''}>${family}</option>`;
}).join('')}
</select>
</label>
<label aria-label="${t.font_size}">
${this.#icon(PrintPreview.#icons.fontsize, 'fontsize')}
<select name="font-size">
${['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large']
.map(size => `<option value="${size}"${size === this.fontSize ? ' selected' : ''}>${size}</option>`)
.join('')}
</select>
</label>
<fieldset>
${this.#icon(PrintPreview.#icons.margin, 'margin')}
${['top', 'right', 'bottom', 'left'].map(pos => `
<label aria-label="${t[pos]}">
${t[pos][0]}
<input type="number" value="${parseInt(this[`margin${pos.charAt(0).toUpperCase() + pos.slice(1)}`])}"
min="0" max="100" name="margin-${pos}">
</label>
`).join('')}
</fieldset>
<label aria-label="${t.images}">
${this.#icon(PrintPreview.#icons.image, 'image')}
<select name="images">
<option value="show">${t.show}</option>
<option value="hide">${t.hide}</option>
<option value="outline">${t.outline}</option>
</select>
</label>
<button type="button" data-action="print" aria-label="${t.print}">
${this.#icon(PrintPreview.#icons.printer, 'printer')}
</button>
<button type="button" data-action="close" aria-label="${t.close}">
${this.#icon(PrintPreview.#icons.close, 'close')}
</button>
`;
}
#setupEventListeners() {
this.shadowRoot.addEventListener('submit', e => e.preventDefault());
this.shadowRoot.addEventListener('change', e => {
const handler = this.#handleFormChange[e.target.name];
if (handler) {
handler(e.target.value, e.target);
this.#updatePageStyles();
}
});
this.shadowRoot.addEventListener('click', e => {
const action = e.target.closest('button')?.dataset.action;
if (action === 'print') this.print();
if (action === 'close') this.hidePopover();
});
}
#updatePageStyles() {
const style = document.getElementById(PrintPreview.#pageStyleId);
const { width, height, ratio } = PrintPreview.#paperSizes[this.paperSize] ?? {};
if (!style || !width) return;
const isLandscape = this.orientation === 'landscape';
const cssVars = {
'--page-width': isLandscape ? height : width,
'--page-height': isLandscape ? width : height,
'--page-ratio': isLandscape ? 1/ratio : ratio,
'--page-margin-top': this.marginTop,
'--page-margin-right': this.marginRight,
'--page-margin-bottom': this.marginBottom,
'--page-margin-left': this.marginLeft,
'--page-root-size': this.fontSize,
'--page-font': `var(--${this.fontFamily})`,
'--ff-system-ui': 'system-ui, sans-serif',
'--ff-transitional': 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif',
'--ff-old-style': '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif',
'--ff-humanist': 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif',
'--ff-geometric': 'Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif',
'--ff-classical': 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif',
'--ff-grotesque': 'Inter, Roboto, "Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif',
'--ff-monospace': '"Nimbus Mono PS", "Courier New", monospace',
'--ff-code': 'ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace',
'--ff-industrial': 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif',
'--ff-rounded': 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT", "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif',
'--ff-slab': 'Rockwell, "Rockwell Nova", "Roboto Slab", "DejaVu Serif", "Sitka Small", serif',
'--ff-antique': 'Superclarendon, "Bookman Old Style", "URW Bookman", "URW Bookman L", "Georgia Pro", Georgia, serif',
'--ff-didone': 'Didot, "Bodoni MT", "Noto Serif Display", "URW Palladio L", P052, Sylfaen, serif',
'--ff-handwritten': '"Segoe Print", "Bradley Hand", Chilanka, TSCu_Comic, casual, cursive',
'--ff-inherit': 'inherit'
};
style.textContent = `
print-preview[images="hide"] img { display: none; }
print-preview[images="outline"] img {
content-visibility: hidden;
border: 1px dotted GrayText;
}
:root {
${Object.entries(cssVars).map(([key, value]) => `${key}: ${value};`).join('\n')}
}
`;
}
/* === Public API === */
addTemplate(name, template, settings = {}) {
this._templates.set(name, template);
if (settings) {
this._templates.set(`${name}-settings`, settings);
}
}
defaultTemplate(data) {
return `<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
preview() {
this.showPopover();
}
print() {
this.#addPrintStyle();
this.setAttribute('mode', 'printing');
const cleanup = () => {
this.#removePrintStyle();
this.removeAttribute('mode');
window.removeEventListener('afterprint', cleanup);
window.removeEventListener('focus', cleanup);
};
window.addEventListener('afterprint', cleanup, { once: true });
window.addEventListener('focus', cleanup, { once: true }); // Catches print dialog cancellation
window.print();
}
setContent(html) {
this.content.innerHTML = html;
}
}
customElements.define('print-preview', PrintPreview);