enketo-core
Version:
Extensible Enketo form engine
369 lines (333 loc) • 11.1 kB
JavaScript
/**
* Deals with printing
*
* @module print
*/
import $ from 'jquery';
import dialog from 'enketo/dialog';
import { MutationsTracker } from './dom-utils';
let dpi;
let printStyleSheet;
let printStyleSheetLink;
/**
* @typedef PaperObj
* @property {object} [external] - Array of external data objects, required for each external data instance in the XForm
* @property {string} [format] - Paper format name, defaults as "A4". Other valid values are " Letter", "Legal",
"Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A5", and "A6"
* @property {string} [landscape] - whether the paper is in landscape orientation, defaults to true
* @property {number} [margin] - paper margin in any valid CSS value
*/
// make sure setDpi is not called until DOM is ready
document.addEventListener('DOMContentLoaded', () => setDpi());
/**
* Calculates the dots per inch and sets the dpi property
*/
function setDpi() {
const dpiO = {};
const e = document.body.appendChild(document.createElement('DIV'));
e.style.width = '1in';
e.style.padding = '0';
dpiO.v = e.offsetWidth;
e.parentNode.removeChild(e);
dpi = dpiO.v;
}
/**
* Gets a single print stylesheet
*
* @return {object|null} stylesheet
*/
function getPrintStyleSheet() {
// document.styleSheets is an Object not an Array!
for (const i in document.styleSheets) {
if (Object.prototype.hasOwnProperty.call(document.styleSheets, i)) {
const sheet = document.styleSheets[i];
if (sheet.media.mediaText === 'print') {
return sheet;
}
}
}
return null;
}
/**
* Obtains a link element with a reference to the print stylesheet.
*
* @return {Element} stylesheet link HTML element
*/
function getPrintStyleSheetLink() {
return document.querySelector('link[media="print"]');
}
/**
* Applies the print stylesheet to the current view by changing stylesheets media property to 'all'
*
* @static
* @return {boolean} whether there was a print stylesheet to change
*/
function styleToAll() {
// sometimes, setStylesheet fails upon loading
printStyleSheet = printStyleSheet || getPrintStyleSheet();
printStyleSheetLink = printStyleSheetLink || getPrintStyleSheetLink();
// Chrome:
printStyleSheet.media.mediaText = 'all';
// Firefox:
printStyleSheetLink.setAttribute('media', 'all');
return !!printStyleSheet;
}
/**
* Resets the print stylesheet to only apply to media 'print'
*
* @static
*/
function styleReset() {
printStyleSheet.media.mediaText = 'print';
printStyleSheetLink.setAttribute('media', 'print');
document
.querySelectorAll(
'.print-height-adjusted, .print-width-adjusted, .main'
)
.forEach((el) => {
el.removeAttribute('style');
el.classList.remove(
'print-height-adjusted',
'print-width-adjusted'
);
});
$('.back-to-screen-view').off('click').remove();
}
/**
* Tests if the form element is set to use the Grid Theme.
*
* @static
* @return {boolean} whether the form definition was defined to use the Grid theme
*/
function isGrid() {
return /theme-.*grid.*/.test(
document.querySelector('form.or').getAttribute('class')
);
}
/**
* Fixes a Grid Theme layout programmatically by imitating CSS multi-line flexbox in JavaScript.
*
* @static
* @param {PaperObj} paper - paper format
* @param {number} [delay] - delay in milliseconds, to wait for re-painting to finish.
* @return {Promise} Promise that resolves with undefined
*/
function fixGrid(paper, delay = 500) {
const mutationsTracker = new MutationsTracker();
// to ensure cells grow correctly with text-wrapping before fixing heights and widths.
const main = document.querySelector('.main');
const cls = 'print-width-adjusted';
const classChange = mutationsTracker.waitForClassChange(main, cls);
main.style.width = getPaperPixelWidth(paper);
main.classList.add(cls);
// wait for browser repainting after width change
// TODO: may not work, may need to add delay
return classChange.then(() => {
let row = [];
let rowTop;
const title = document.querySelector('#form-title');
// the -1px adjustment is necessary because the h3 element width is calc(100% + 1px)
const maxWidth = title ? title.offsetWidth - 1 : null;
const els = document.querySelectorAll(
'.question:not(.draft), .trigger:not(.draft)'
);
els.forEach((el, index) => {
const lastElement = index === els.length - 1;
const { top } = $(el).offset();
rowTop = rowTop || rowTop === 0 ? rowTop : top;
if (top === rowTop) {
row = row.concat(el);
}
// If an element is hidden, top = 0. We still need to trigger a resize on the very last row
// if the last element is hidden, so this is placed outside of the previous if statement
if (lastElement) {
_resizeRowElements(row, maxWidth);
}
// process row, and start a new row
if (top > rowTop) {
_resizeRowElements(row, maxWidth);
if (lastElement && !row.includes(el)) {
_resizeRowElements([el], maxWidth);
} else {
// start a new row
row = [el];
rowTop = $(el).offset().top;
}
} else if (rowTop < top) {
console.error(
'unexpected question top position: ',
top,
'for element:',
el,
'expected >=',
rowTop
);
}
});
return mutationsTracker.waitForQuietness().then(
() =>
// The need for this 'dumb' delay is unfortunate, but at least the mutationTracker will smartly increase
// the waiting time for larger forms (more mutations).
new Promise((resolve) => {
setTimeout(resolve, delay);
})
);
});
}
/**
*
* @param {Element} row - row elements
* @param {number} maxWidth - maximum width of row
*/
function _resizeRowElements(row, maxWidth) {
const widths = [];
let cumulativeWidth = 0;
let maxHeight = 0;
row.forEach((el) => {
const width = Number($(el).css('width').replace('px', ''));
widths.push(width);
cumulativeWidth += width;
});
// adjusts widths if w-values don't add up to 100%
if (cumulativeWidth < maxWidth) {
const diff = maxWidth - cumulativeWidth;
row.forEach((el, index) => {
const width =
widths[index] + (widths[index] / cumulativeWidth) * diff;
// round down to 2 decimals to avoid 100.001% totals
el.style.width = `${
Math.floor(((width * 100) / maxWidth) * 100) / 100
}%`;
el.classList.add('print-width-adjusted');
});
}
row.forEach((el) => {
const height = el.offsetHeight;
maxHeight = height > maxHeight ? height : maxHeight;
});
row.forEach((el) => {
// unset max height for image-map widget
// (https://github.com/OpenClinica/enketo-express-oc/issues/363)
if (!el.classList.contains('or-appearance-image-map')) {
el.classList.add('print-height-adjusted');
el.style.height = `${maxHeight}px`;
}
});
}
/**
* Returns a CSS width value in px (e.g. `"100px"`) for a provided paper format, orientation (`"portrait"` or `"landscape"`) and margin (as any valid CSS value).
*
* @param {PaperObj} paper - paper format
* @return {string} pixel width string
*/
function getPaperPixelWidth(paper) {
let printWidth;
const FORMATS = {
Letter: [8.5, 11],
Legal: [8.5, 14],
Tabloid: [11, 17],
Ledger: [17, 11],
A0: [33.1, 46.8],
A1: [23.4, 33.1],
A2: [16.5, 23.4],
A3: [11.7, 16.5],
A4: [8.27, 11.7],
A5: [5.83, 8.27],
A6: [4.13, 5.83],
};
paper.landscape =
typeof paper.landscape === 'boolean'
? paper.landscape
: paper.orientation === 'landscape';
delete paper.orientation;
if (typeof paper.margin === 'undefined') {
paper.margin = 0.4;
} else if (/^[\d.]+in$/.test(paper.margin.trim())) {
paper.margin = parseFloat(paper.margin, 10);
} else if (/^[\d.]+cm$/.test(paper.margin.trim())) {
paper.margin = parseFloat(paper.margin, 10) / 2.54;
} else if (/^[\d.]+mm$/.test(paper.margin.trim())) {
paper.margin = parseFloat(paper.margin, 10) / 25.4;
}
paper.format =
typeof paper.format === 'string' &&
typeof FORMATS[paper.format] !== 'undefined'
? paper.format
: 'A4';
printWidth =
paper.landscape === true
? FORMATS[paper.format][1]
: FORMATS[paper.format][0];
return `${(printWidth - 2 * paper.margin) * dpi}px`;
}
/**
* @static
*/
function openAllDetails() {
document
.querySelectorAll('details.or-form-guidance.active')
.forEach((details) => {
if (details.open) {
details.dataset.previousOpen = true;
} else {
details.open = true;
}
});
}
/**
* @static
*/
function closeAllDetails() {
document
.querySelectorAll('details.or-form-guidance.active')
.forEach((details) => {
if (details.dataset.previousOpen) {
delete details.dataset.previousOpen;
} else {
details.open = false;
}
});
}
/**
* Prints the form after first preparing the Grid (every time it is called).
*
* It's just a demo function that only collects paper format and should be replaced
* in your app with a dialog that collects a complete paper format (size, margin, orientation);
*
* @static
* @param {string} theme - theme name
*/
function print(theme) {
if (theme === 'grid' || (!theme && isGrid())) {
let swapped = false;
dialog
.prompt('Enter valid paper format', 'A4')
.then((format) => {
if (!format) {
throw new Error('Print cancelled by user.');
}
swapped = styleToAll();
return fixGrid({
format,
});
})
.then(window.print)
.catch(console.error)
.then(() => {
if (swapped) {
setTimeout(styleReset, 500);
}
});
} else {
window.print();
}
}
export {
print,
fixGrid,
styleToAll,
styleReset,
isGrid,
openAllDetails,
closeAllDetails,
};