UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

328 lines (290 loc) 11 kB
/** * Deals with printing * * @module print */ import $ from 'jquery'; import { MutationsTracker } from './dom-utils'; let dpi, printStyleSheet; let printStyleSheetLink; import dialog from 'enketo/dialog'; /** * @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().top; 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). return 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 };