js/print.js

/**
 * 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 };