mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Implement 'Print widget' option to print individual view sections.
Summary: - Supports multi-page printing with some aggressive css overrides. - Relies on a new function implemented by grist-plugin-api to print a multi-page CustomView. - Renders all rows for printing for scrolly-based views. Test Plan: Doesn't seem possible to do a selenium test for printing. Tested manually on Chrome, Firefox, and Safari. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2635
This commit is contained in:
		
							parent
							
								
									d2ad5edc46
								
							
						
					
					
						commit
						99ab09651e
					
				@ -230,6 +230,9 @@ function BaseView(gristDoc, viewSectionModel, options) {
 | 
			
		||||
  this.autoDispose(this.viewSection.activeSortSpec.subscribe(() => this.setCursorPos({rowIndex: 0})));
 | 
			
		||||
 | 
			
		||||
  this.copySelection = ko.observable(null);
 | 
			
		||||
 | 
			
		||||
  // Whether parts needed for printing should be rendered now.
 | 
			
		||||
  this._isPrinting = ko.observable(false);
 | 
			
		||||
}
 | 
			
		||||
Base.setBaseFor(BaseView);
 | 
			
		||||
_.extend(Base.prototype, BackboneEvents);
 | 
			
		||||
@ -612,6 +615,13 @@ BaseView.prototype.onResize = function() {
 | 
			
		||||
BaseView.prototype.onRowResize = function(rowModels) {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Called before and after printing this section.
 | 
			
		||||
 */
 | 
			
		||||
BaseView.prototype.prepareToPrint = function(onOff) {
 | 
			
		||||
  this._isPrinting(onOff);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Called to obtain the rowModel for the given rowId. Returns a rowModel if it belongs to the
 | 
			
		||||
 * section and is rendered, otherwise returns null.
 | 
			
		||||
 | 
			
		||||
@ -89,6 +89,12 @@ export class CustomView extends Disposable {
 | 
			
		||||
    this.autoDispose(this.cursor.rowIndex.subscribe(this._updateCursor));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async triggerPrint() {
 | 
			
		||||
    if (!this.isDisposed() && this._rpc) {
 | 
			
		||||
      return await this._rpc.callRemoteFunc("print");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _updateView(dataChange: boolean) {
 | 
			
		||||
    if (this.isDisposed()) { return; }
 | 
			
		||||
    if (this._rpc) {
 | 
			
		||||
 | 
			
		||||
@ -79,10 +79,12 @@
 | 
			
		||||
  margin-left: -3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detailview_record_detail.active {
 | 
			
		||||
  /* highlight active record in Card List by overlaying the active-section highlight */
 | 
			
		||||
  margin-left: -3px;
 | 
			
		||||
  border-left: 3px solid var(--grist-color-light-green);
 | 
			
		||||
@media not print {
 | 
			
		||||
  .detailview_record_detail.active {
 | 
			
		||||
    /* highlight active record in Card List by overlaying the active-section highlight */
 | 
			
		||||
    margin-left: -3px;
 | 
			
		||||
    border-left: 3px solid var(--grist-color-light-green);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*** single record ***/
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ var ko            = require('knockout');
 | 
			
		||||
var dom           = require('app/client/lib/dom');
 | 
			
		||||
var kd            = require('app/client/lib/koDom');
 | 
			
		||||
var koDomScrolly  = require('app/client/lib/koDomScrolly');
 | 
			
		||||
const {renderAllRows} = require('app/client/components/Printing');
 | 
			
		||||
 | 
			
		||||
require('app/client/lib/koUtil'); // Needed for subscribeInit.
 | 
			
		||||
 | 
			
		||||
@ -282,6 +283,11 @@ DetailView.prototype.buildDom = function() {
 | 
			
		||||
          }),
 | 
			
		||||
          koDomScrolly.scrolly(this.viewData, {fitToWidth: true},
 | 
			
		||||
            row => this.makeRecord(row)),
 | 
			
		||||
 | 
			
		||||
          kd.maybe(this._isPrinting, () =>
 | 
			
		||||
            renderAllRows(this.tableModel, this.sortedRows.getKoArray().peek(), row =>
 | 
			
		||||
              this.makeRecord(row))
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        return dom(
 | 
			
		||||
 | 
			
		||||
@ -71,6 +71,7 @@
 | 
			
		||||
  left: 0px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  width: 4rem; /* Also should match width for .gridview_header_corner, and the overlay elements */
 | 
			
		||||
  flex: none;
 | 
			
		||||
 | 
			
		||||
  border-bottom: 1px solid var(--grist-color-dark-grey);
 | 
			
		||||
  background-color: var(--grist-color-light-grey);
 | 
			
		||||
@ -89,6 +90,15 @@
 | 
			
		||||
  .gridview_data_row_num {
 | 
			
		||||
    background-color: var(--grist-color-light-grey) !important;
 | 
			
		||||
  }
 | 
			
		||||
  .gridview_header_backdrop_top {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
  .column_name.mod-add-column {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
  .gridview_data_header {
 | 
			
		||||
    background-color: var(--grist-color-light-grey) !important;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* ========= Overlay styles ========== */
 | 
			
		||||
 | 
			
		||||
@ -21,6 +21,7 @@ var BaseView      = require('./BaseView');
 | 
			
		||||
var selector      = require('./Selector');
 | 
			
		||||
var CopySelection = require('./CopySelection');
 | 
			
		||||
 | 
			
		||||
const {renderAllRows} = require('app/client/components/Printing');
 | 
			
		||||
const {reportError} = require('app/client/models/AppModel');
 | 
			
		||||
 | 
			
		||||
// Grist UI Components
 | 
			
		||||
@ -831,127 +832,132 @@ GridView.prototype.buildDom = function() {
 | 
			
		||||
        ) //end hbox
 | 
			
		||||
      ), // END COL HEADER BOX
 | 
			
		||||
 | 
			
		||||
      koDomScrolly.scrolly(data, { paddingBottom: 80, paddingRight: 28 }, function(row) {
 | 
			
		||||
        // TODO. There are several ways to implement a cursor; similar concerns may arise
 | 
			
		||||
        // when implementing selection and cell editor.
 | 
			
		||||
        // (1) Class on 'div.field.field_clip'. Fewest elements, seems possibly best for
 | 
			
		||||
        //     performance. Problem is: it's impossible to get cursor exactly right with a
 | 
			
		||||
        //     one-sided border. Attaching a cursor as additional element inside the cell
 | 
			
		||||
        //     truncates the cursor to the cell's inside because of 'overflow: hidden'.
 | 
			
		||||
        // (2) 'div.field' with 'div.field_clip' inside, on which a class is toggled. This
 | 
			
		||||
        //     works well. The only concern is whether this slows down rendering. Would be
 | 
			
		||||
        //     good to measure and compare rendering speed.
 | 
			
		||||
        //     Related: perhaps the fastest rendering would be for a table.
 | 
			
		||||
        // (3) Separate element attached to the row, absolutely positioned at left
 | 
			
		||||
        //     position and width of the selected cell. This works too. Requires
 | 
			
		||||
        //     maintaining a list of leftOffsets (or measuring the cell's), and feels less
 | 
			
		||||
        //     clean and more complicated than (2).
 | 
			
		||||
 | 
			
		||||
        // IsRowActive and isCellActive are a significant optimization. IsRowActive is called
 | 
			
		||||
        // for all rows when cursor.rowIndex changes, but the value only changes for two of the
 | 
			
		||||
        // rows. IsCellActive is only subscribed to columns for the active row. This way, when
 | 
			
		||||
        // the cursor moves, there are (rows+2*columns) calls rather than rows*columns.
 | 
			
		||||
        var isRowActive = ko.computed(() => row._index() === self.cursor.rowIndex());
 | 
			
		||||
        return dom('div.gridview_row',
 | 
			
		||||
          dom.autoDispose(isRowActive),
 | 
			
		||||
 | 
			
		||||
          // rowid dom
 | 
			
		||||
          dom('div.gridview_data_row_num',
 | 
			
		||||
            dom('div.gridview_data_row_info',
 | 
			
		||||
              kd.toggleClass('linked_dst', () => {
 | 
			
		||||
                // Must ensure that linkedRowId is not null to avoid drawing on rows whose
 | 
			
		||||
                // row ids are null.
 | 
			
		||||
                return self.linkedRowId() && self.linkedRowId() === row.getRowId();
 | 
			
		||||
              })
 | 
			
		||||
            ),
 | 
			
		||||
            kd.text(function() { return row._index() + 1; }),
 | 
			
		||||
 | 
			
		||||
            kd.scope(row._validationFailures, function(failures) {
 | 
			
		||||
              if (!row._isAddRow() && failures.length > 0) {
 | 
			
		||||
                return dom('div.validation_error_number', failures.length,
 | 
			
		||||
                  kd.attr('title', function() {
 | 
			
		||||
                    return "Validation failed: " +
 | 
			
		||||
                      failures.map(function(val) { return val.name(); }).join(", ");
 | 
			
		||||
                  })
 | 
			
		||||
                );
 | 
			
		||||
              }
 | 
			
		||||
            }),
 | 
			
		||||
            kd.toggleClass('selected', () =>
 | 
			
		||||
              !row._isAddRow() && self.cellSelector.isRowSelected(row._index())),
 | 
			
		||||
            dom.on('contextmenu', ev => self.maybeSelectRow(ev.currentTarget, row.getRowId())),
 | 
			
		||||
            menu(ctl => RowContextMenu({
 | 
			
		||||
              disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()),
 | 
			
		||||
              disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()),
 | 
			
		||||
              isViewSorted: self.viewSection.activeSortSpec.peek().length > 0,
 | 
			
		||||
            }), { trigger: ['contextmenu'] }),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
          dom('div.record',
 | 
			
		||||
            kd.toggleClass('record-add', row._isAddRow),
 | 
			
		||||
            kd.style('borderLeftWidth', v.borderWidthPx),
 | 
			
		||||
            kd.style('borderBottomWidth', v.borderWidthPx),
 | 
			
		||||
            //These are grabbed from v.optionsObj at start of GridView buildDom
 | 
			
		||||
            kd.toggleClass('record-hlines', vHorizontalGridlines),
 | 
			
		||||
            kd.toggleClass('record-vlines', vVerticalGridlines),
 | 
			
		||||
            kd.toggleClass('record-zebra', vZebraStripes),
 | 
			
		||||
            // even by 1-indexed rownum, so +1 (makes more sense for user-facing display stuff)
 | 
			
		||||
            kd.toggleClass('record-even', () => (row._index()+1) % 2 === 0 ),
 | 
			
		||||
 | 
			
		||||
            self.comparison ? kd.cssClass(() => {
 | 
			
		||||
              var rowId = row.id();
 | 
			
		||||
              if (rightAddRows.has(rowId))         { return 'diff-remote'; }
 | 
			
		||||
              else if (rightRemoveRows.has(rowId)) { return 'diff-parent'; }
 | 
			
		||||
              else if (leftAddRows.has(rowId))     { return 'diff-local';  }
 | 
			
		||||
              return '';
 | 
			
		||||
            }) : null,
 | 
			
		||||
 | 
			
		||||
            kd.foreach(v.viewFields(), function(field) {
 | 
			
		||||
              // Whether the cell has a cursor (possibly in an inactive view section).
 | 
			
		||||
              var isCellSelected = ko.computed(() =>
 | 
			
		||||
                isRowActive() && field._index() === self.cursor.fieldIndex());
 | 
			
		||||
 | 
			
		||||
              // Whether the cell is active: has the cursor in the active section.
 | 
			
		||||
              var isCellActive = ko.computed(() => isCellSelected() && v.hasFocus());
 | 
			
		||||
 | 
			
		||||
              // Whether the cell is part of an active copy-paste operation.
 | 
			
		||||
              var isCopyActive = ko.computed(function() {
 | 
			
		||||
                return self.copySelection() &&
 | 
			
		||||
                  self.copySelection().isCellSelected(row.id(), field.colId());
 | 
			
		||||
              });
 | 
			
		||||
              var fieldBuilder = self.fieldBuilders.at(field._index());
 | 
			
		||||
              var isSelected = ko.computed(() => {
 | 
			
		||||
                return !row._isAddRow() &&
 | 
			
		||||
                  !self.cellSelector.isCurrentSelectType(selector.NONE) &&
 | 
			
		||||
                  ko.unwrap(self.isColSelected.at(field._index())) &&
 | 
			
		||||
                  self.cellSelector.isRowSelected(row._index());
 | 
			
		||||
              });
 | 
			
		||||
              return dom(
 | 
			
		||||
                'div.field',
 | 
			
		||||
                kd.toggleClass('scissors', isCopyActive),
 | 
			
		||||
                dom.autoDispose(isCopyActive),
 | 
			
		||||
                dom.autoDispose(isCellSelected),
 | 
			
		||||
                dom.autoDispose(isCellActive),
 | 
			
		||||
                dom.autoDispose(isSelected),
 | 
			
		||||
                kd.style('width', field.widthPx),
 | 
			
		||||
                //TODO: Ensure that fields in a row resize when
 | 
			
		||||
                //a cell in that row becomes larger
 | 
			
		||||
                kd.style('borderRightWidth', v.borderWidthPx),
 | 
			
		||||
                kd.style('color', field.textColor),
 | 
			
		||||
                // If making a comparison, use the background exclusively for
 | 
			
		||||
                // marking that up.
 | 
			
		||||
                self.comparison ? null : kd.style('background-color', () => (row._isAddRow() || isSelected()) ? '' : field.fillColor()),
 | 
			
		||||
 | 
			
		||||
                kd.toggleClass('selected', isSelected),
 | 
			
		||||
                fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected)
 | 
			
		||||
              );
 | 
			
		||||
            })
 | 
			
		||||
          )
 | 
			
		||||
        );
 | 
			
		||||
      }) //end scrolly
 | 
			
		||||
      koDomScrolly.scrolly(data, { paddingBottom: 80, paddingRight: 28 }, renderRow),
 | 
			
		||||
 | 
			
		||||
      kd.maybe(this._isPrinting, () =>
 | 
			
		||||
        renderAllRows(this.tableModel, this.sortedRows.getKoArray().peek(), renderRow)
 | 
			
		||||
      ),
 | 
			
		||||
    ) // end scrollpane
 | 
			
		||||
  );// END MAIN VIEW BOX
 | 
			
		||||
 | 
			
		||||
  function renderRow(row) {
 | 
			
		||||
    // TODO. There are several ways to implement a cursor; similar concerns may arise
 | 
			
		||||
    // when implementing selection and cell editor.
 | 
			
		||||
    // (1) Class on 'div.field.field_clip'. Fewest elements, seems possibly best for
 | 
			
		||||
    //     performance. Problem is: it's impossible to get cursor exactly right with a
 | 
			
		||||
    //     one-sided border. Attaching a cursor as additional element inside the cell
 | 
			
		||||
    //     truncates the cursor to the cell's inside because of 'overflow: hidden'.
 | 
			
		||||
    // (2) 'div.field' with 'div.field_clip' inside, on which a class is toggled. This
 | 
			
		||||
    //     works well. The only concern is whether this slows down rendering. Would be
 | 
			
		||||
    //     good to measure and compare rendering speed.
 | 
			
		||||
    //     Related: perhaps the fastest rendering would be for a table.
 | 
			
		||||
    // (3) Separate element attached to the row, absolutely positioned at left
 | 
			
		||||
    //     position and width of the selected cell. This works too. Requires
 | 
			
		||||
    //     maintaining a list of leftOffsets (or measuring the cell's), and feels less
 | 
			
		||||
    //     clean and more complicated than (2).
 | 
			
		||||
 | 
			
		||||
    // IsRowActive and isCellActive are a significant optimization. IsRowActive is called
 | 
			
		||||
    // for all rows when cursor.rowIndex changes, but the value only changes for two of the
 | 
			
		||||
    // rows. IsCellActive is only subscribed to columns for the active row. This way, when
 | 
			
		||||
    // the cursor moves, there are (rows+2*columns) calls rather than rows*columns.
 | 
			
		||||
    var isRowActive = ko.computed(() => row._index() === self.cursor.rowIndex());
 | 
			
		||||
    return dom('div.gridview_row',
 | 
			
		||||
      dom.autoDispose(isRowActive),
 | 
			
		||||
 | 
			
		||||
      // rowid dom
 | 
			
		||||
      dom('div.gridview_data_row_num',
 | 
			
		||||
        dom('div.gridview_data_row_info',
 | 
			
		||||
          kd.toggleClass('linked_dst', () => {
 | 
			
		||||
            // Must ensure that linkedRowId is not null to avoid drawing on rows whose
 | 
			
		||||
            // row ids are null.
 | 
			
		||||
            return self.linkedRowId() && self.linkedRowId() === row.getRowId();
 | 
			
		||||
          })
 | 
			
		||||
        ),
 | 
			
		||||
        kd.text(function() { return row._index() + 1; }),
 | 
			
		||||
 | 
			
		||||
        kd.scope(row._validationFailures, function(failures) {
 | 
			
		||||
          if (!row._isAddRow() && failures.length > 0) {
 | 
			
		||||
            return dom('div.validation_error_number', failures.length,
 | 
			
		||||
              kd.attr('title', function() {
 | 
			
		||||
                return "Validation failed: " +
 | 
			
		||||
                  failures.map(function(val) { return val.name(); }).join(", ");
 | 
			
		||||
              })
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
        }),
 | 
			
		||||
        kd.toggleClass('selected', () =>
 | 
			
		||||
          !row._isAddRow() && self.cellSelector.isRowSelected(row._index())),
 | 
			
		||||
        dom.on('contextmenu', ev => self.maybeSelectRow(ev.currentTarget, row.getRowId())),
 | 
			
		||||
        menu(ctl => RowContextMenu({
 | 
			
		||||
          disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()),
 | 
			
		||||
          disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()),
 | 
			
		||||
          isViewSorted: self.viewSection.activeSortSpec.peek().length > 0,
 | 
			
		||||
        }), { trigger: ['contextmenu'] }),
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      dom('div.record',
 | 
			
		||||
        kd.toggleClass('record-add', row._isAddRow),
 | 
			
		||||
        kd.style('borderLeftWidth', v.borderWidthPx),
 | 
			
		||||
        kd.style('borderBottomWidth', v.borderWidthPx),
 | 
			
		||||
        //These are grabbed from v.optionsObj at start of GridView buildDom
 | 
			
		||||
        kd.toggleClass('record-hlines', vHorizontalGridlines),
 | 
			
		||||
        kd.toggleClass('record-vlines', vVerticalGridlines),
 | 
			
		||||
        kd.toggleClass('record-zebra', vZebraStripes),
 | 
			
		||||
        // even by 1-indexed rownum, so +1 (makes more sense for user-facing display stuff)
 | 
			
		||||
        kd.toggleClass('record-even', () => (row._index()+1) % 2 === 0 ),
 | 
			
		||||
 | 
			
		||||
        self.comparison ? kd.cssClass(() => {
 | 
			
		||||
          var rowId = row.id();
 | 
			
		||||
          if (rightAddRows.has(rowId))         { return 'diff-remote'; }
 | 
			
		||||
          else if (rightRemoveRows.has(rowId)) { return 'diff-parent'; }
 | 
			
		||||
          else if (leftAddRows.has(rowId))     { return 'diff-local';  }
 | 
			
		||||
          return '';
 | 
			
		||||
        }) : null,
 | 
			
		||||
 | 
			
		||||
        kd.foreach(v.viewFields(), function(field) {
 | 
			
		||||
          // Whether the cell has a cursor (possibly in an inactive view section).
 | 
			
		||||
          var isCellSelected = ko.computed(() =>
 | 
			
		||||
            isRowActive() && field._index() === self.cursor.fieldIndex());
 | 
			
		||||
 | 
			
		||||
          // Whether the cell is active: has the cursor in the active section.
 | 
			
		||||
          var isCellActive = ko.computed(() => isCellSelected() && v.hasFocus());
 | 
			
		||||
 | 
			
		||||
          // Whether the cell is part of an active copy-paste operation.
 | 
			
		||||
          var isCopyActive = ko.computed(function() {
 | 
			
		||||
            return self.copySelection() &&
 | 
			
		||||
              self.copySelection().isCellSelected(row.id(), field.colId());
 | 
			
		||||
          });
 | 
			
		||||
          var fieldBuilder = self.fieldBuilders.at(field._index());
 | 
			
		||||
          var isSelected = ko.computed(() => {
 | 
			
		||||
            return !row._isAddRow() &&
 | 
			
		||||
              !self.cellSelector.isCurrentSelectType(selector.NONE) &&
 | 
			
		||||
              ko.unwrap(self.isColSelected.at(field._index())) &&
 | 
			
		||||
              self.cellSelector.isRowSelected(row._index());
 | 
			
		||||
          });
 | 
			
		||||
          return dom(
 | 
			
		||||
            'div.field',
 | 
			
		||||
            kd.toggleClass('scissors', isCopyActive),
 | 
			
		||||
            dom.autoDispose(isCopyActive),
 | 
			
		||||
            dom.autoDispose(isCellSelected),
 | 
			
		||||
            dom.autoDispose(isCellActive),
 | 
			
		||||
            dom.autoDispose(isSelected),
 | 
			
		||||
            kd.style('width', field.widthPx),
 | 
			
		||||
            //TODO: Ensure that fields in a row resize when
 | 
			
		||||
            //a cell in that row becomes larger
 | 
			
		||||
            kd.style('borderRightWidth', v.borderWidthPx),
 | 
			
		||||
            kd.style('color', field.textColor),
 | 
			
		||||
            // If making a comparison, use the background exclusively for
 | 
			
		||||
            // marking that up.
 | 
			
		||||
            self.comparison ? null : kd.style('background-color', () => (row._isAddRow() || isSelected()) ? '' : field.fillColor()),
 | 
			
		||||
 | 
			
		||||
            kd.toggleClass('selected', isSelected),
 | 
			
		||||
            fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected)
 | 
			
		||||
          );
 | 
			
		||||
        })
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** @inheritdoc */
 | 
			
		||||
 | 
			
		||||
@ -690,4 +690,9 @@ const cssViewContentPane = styled('div', `
 | 
			
		||||
  position: relative;
 | 
			
		||||
  min-width: 240px;
 | 
			
		||||
  margin: 12px;
 | 
			
		||||
  @media print {
 | 
			
		||||
    & {
 | 
			
		||||
      margin: 0px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										60
									
								
								app/client/components/Printing.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/client/components/Printing.css
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
@media print {
 | 
			
		||||
  /* Various style overrides needed to print a single section (page widget). */
 | 
			
		||||
 | 
			
		||||
  .print-hide {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-parent {
 | 
			
		||||
    display: block !important;
 | 
			
		||||
    position: relative !important;
 | 
			
		||||
    height: max-content !important;
 | 
			
		||||
    overflow: visible !important;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-widget {
 | 
			
		||||
    margin: 0px !important;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-widget .viewsection_title {
 | 
			
		||||
    display: none !important;
 | 
			
		||||
  }
 | 
			
		||||
  .print-widget .view_data_pane_container {
 | 
			
		||||
    border: none !important;
 | 
			
		||||
  }
 | 
			
		||||
  .print-widget .viewsection_content {
 | 
			
		||||
    margin: 0px !important;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-widget .detailview_single {
 | 
			
		||||
    overflow: visible;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-widget .gridview_data_pane {
 | 
			
		||||
    display: block !important;
 | 
			
		||||
    position: relative !important;
 | 
			
		||||
    height: max-content !important;
 | 
			
		||||
    overflow: visible !important;
 | 
			
		||||
    border-top: 1px solid var(--grist-color-dark-grey);
 | 
			
		||||
    border-left: 1px solid var(--grist-color-dark-grey);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-widget .gridview_data_scroll {
 | 
			
		||||
    position: relative !important;
 | 
			
		||||
    height: max-content !important;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-widget .scrolly_outer {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .print-widget .custom_view {
 | 
			
		||||
    height: calc(100vh - 24px);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media not print {
 | 
			
		||||
  .print-all-rows {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										108
									
								
								app/client/components/Printing.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								app/client/components/Printing.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,108 @@
 | 
			
		||||
import {CustomView} from 'app/client/components/CustomView';
 | 
			
		||||
import {DataRowModel} from 'app/client/models/DataRowModel';
 | 
			
		||||
import * as DataTableModel from 'app/client/models/DataTableModel';
 | 
			
		||||
import {ViewSectionRec} from 'app/client/models/DocModel';
 | 
			
		||||
import {dom} from 'grainjs';
 | 
			
		||||
 | 
			
		||||
type RowId = number|'new';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Print the specified viewSection (aka page widget). We use the existing view instance rather
 | 
			
		||||
 * than render a new one, since it may have state local to this instance view, such as current
 | 
			
		||||
 * filters.
 | 
			
		||||
 *
 | 
			
		||||
 * Views get a chance to render things specially for printing (which is needed when they use
 | 
			
		||||
 * scrolly for normal rendering).
 | 
			
		||||
 *
 | 
			
		||||
 * To let an existing view print across multiple pages, we can't have it nested in a flexbox or a
 | 
			
		||||
 * div with 'height: 100%'. We achieve it by forcing all parents of our view to have a simple
 | 
			
		||||
 * layout. This is potentially fragile.
 | 
			
		||||
 */
 | 
			
		||||
export async function printViewSection(layout: any, viewSection: ViewSectionRec) {
 | 
			
		||||
  const viewInstance = viewSection.viewInstance.peek();
 | 
			
		||||
  const sectionElem = viewInstance?.viewPane?.closest('.viewsection_content');
 | 
			
		||||
  if (!sectionElem) {
 | 
			
		||||
    throw new Error("No page widget to print");
 | 
			
		||||
  }
 | 
			
		||||
  if (viewInstance instanceof CustomView) {
 | 
			
		||||
    try {
 | 
			
		||||
      await viewInstance.triggerPrint();
 | 
			
		||||
      return;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      // tslint:disable-next-line:no-console
 | 
			
		||||
      console.warn(`Failed to trigger print in CustomView: ${e}`);
 | 
			
		||||
      // continue on to trying to print from outside, which should work OK for a single page.
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function prepareToPrint(onOff: boolean) {
 | 
			
		||||
    // Hide all layout boxes that do NOT contain the section to be printed.
 | 
			
		||||
    layout.forEachBox((box: any) => {
 | 
			
		||||
      if (!box.dom.contains(sectionElem)) {
 | 
			
		||||
        box.dom.classList.toggle('print-hide', onOff);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Mark the section to be printed.
 | 
			
		||||
    sectionElem.classList.toggle('print-widget', onOff);
 | 
			
		||||
 | 
			
		||||
    // Let the view instance update its rendering, e.g. to render all rows when scrolly is in use.
 | 
			
		||||
    viewInstance?.prepareToPrint(onOff);
 | 
			
		||||
 | 
			
		||||
    // If .print-all-rows element is present (created for scrolly-based views), use it as the
 | 
			
		||||
    // start element for the loop below, to ensure it's rendered flexbox-free.
 | 
			
		||||
    const keyElem = sectionElem.querySelector('.print-all-rows') || sectionElem;
 | 
			
		||||
 | 
			
		||||
    // Go through all parents of the element to be printed. For @media print, we override their
 | 
			
		||||
    // layout in a heavy-handed way, forcing them all to be non-flexbox and sized to content,
 | 
			
		||||
    // since our normal flexbox-based layout is sized to screen and would not print multiple pages.
 | 
			
		||||
    let elem = keyElem.parentElement;
 | 
			
		||||
    while (elem) {
 | 
			
		||||
      elem.classList.toggle('print-parent', onOff);
 | 
			
		||||
      elem = elem.parentElement;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const sub1 = dom.onElem(window, 'beforeprint', () => prepareToPrint(true));
 | 
			
		||||
  const sub2 = dom.onElem(window, 'afterprint', () => {
 | 
			
		||||
    sub1.dispose();
 | 
			
		||||
    sub2.dispose();
 | 
			
		||||
    // To debug printing, set window.debugPringint=1 in the console, then print a section, dismiss
 | 
			
		||||
    // the print dialog, switch to "@media print" emulation, and you can explore the styles. You'd
 | 
			
		||||
    // need to reload the page to do it again.
 | 
			
		||||
    if (!(window as any).debugPrinting) {
 | 
			
		||||
      prepareToPrint(false);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  window.print();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Produces a div with all requested rows using the same renderRow() function as used with scrolly
 | 
			
		||||
 * for dynamically rendered views. This is used for printing, so these rows do not subscribe to
 | 
			
		||||
 * data.
 | 
			
		||||
 *
 | 
			
		||||
 * To avoid creating a lot of subscriptions when rendering rows this way, we render one DOM row at
 | 
			
		||||
 * a time, copy the produced HTML, and dispose the produced DOM.
 | 
			
		||||
 */
 | 
			
		||||
export function renderAllRows(
 | 
			
		||||
  tableModel: DataTableModel, rowIds: RowId[], renderRow: (r: DataRowModel) => Element,
 | 
			
		||||
) {
 | 
			
		||||
  const rowModel = tableModel.createFloatingRowModel(null) as DataRowModel;
 | 
			
		||||
  const html: string[] = [];
 | 
			
		||||
  rowIds.forEach((rowId, index) => {
 | 
			
		||||
    if (rowId !== 'new') {
 | 
			
		||||
      rowModel._index(index);
 | 
			
		||||
      rowModel.assign(rowId);
 | 
			
		||||
      const elem = renderRow(rowModel);
 | 
			
		||||
      html.push(elem.outerHTML);
 | 
			
		||||
      dom.domDispose(elem);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  rowModel.dispose();
 | 
			
		||||
  const result = dom('div.print-all-rows');
 | 
			
		||||
  result.innerHTML = html.join("\n");
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
@ -7,6 +7,7 @@ import * as GridView from 'app/client/components/GridView';
 | 
			
		||||
import {GristDoc} from 'app/client/components/GristDoc';
 | 
			
		||||
import {Layout} from 'app/client/components/Layout';
 | 
			
		||||
import {LayoutEditor} from 'app/client/components/LayoutEditor';
 | 
			
		||||
import {printViewSection} from 'app/client/components/Printing';
 | 
			
		||||
import {Delay} from 'app/client/lib/Delay';
 | 
			
		||||
import {createObsArray} from 'app/client/lib/koArrayWrap';
 | 
			
		||||
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
 | 
			
		||||
@ -124,6 +125,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
 | 
			
		||||
      deleteSection: () => { this._removeViewSection(this.viewModel.activeSectionId()); },
 | 
			
		||||
      nextSection: () => { this._otherSection(+1); },
 | 
			
		||||
      prevSection: () => { this._otherSection(-1); },
 | 
			
		||||
      printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); },
 | 
			
		||||
    };
 | 
			
		||||
    this.autoDispose(commands.createGroup(commandGroup, this, true));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -92,6 +92,11 @@ exports.groups = [{
 | 
			
		||||
      name: 'dataSelectionTabOpen',
 | 
			
		||||
      keys: [],
 | 
			
		||||
      desc: 'Shortcut to data selection tab'
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'printSection',
 | 
			
		||||
      keys: [],
 | 
			
		||||
      desc: 'Print currently selected page widget',
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}, {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								app/client/declarations.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								app/client/declarations.d.ts
									
									
									
									
										vendored
									
									
								
							@ -68,6 +68,7 @@ declare module "app/client/components/BaseView" {
 | 
			
		||||
    public buildTitleControls(): DomArg;
 | 
			
		||||
    public getLoadingDonePromise(): Promise<void>;
 | 
			
		||||
    public onResize(): void;
 | 
			
		||||
    public prepareToPrint(onOff: boolean): void;
 | 
			
		||||
  }
 | 
			
		||||
  export = BaseView;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -199,6 +199,12 @@ const cssTopHeader = styled('div', `
 | 
			
		||||
const cssResizeFlexVHandle = styled(resizeFlexVHandle, `
 | 
			
		||||
  --resize-handle-color: ${colors.mediumGrey};
 | 
			
		||||
  --resize-handle-highlight: ${colors.lightGreen};
 | 
			
		||||
 | 
			
		||||
  @media print {
 | 
			
		||||
    & {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
const cssResizeDisabledBorder = styled('div', `
 | 
			
		||||
  flex: none;
 | 
			
		||||
 | 
			
		||||
@ -9,17 +9,19 @@ import {dom} from 'grainjs';
 | 
			
		||||
 */
 | 
			
		||||
export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionRec, isReadonly: boolean) {
 | 
			
		||||
  return [
 | 
			
		||||
    menuItemCmd(allCommands.printSection, 'Print widget', testId('print-section')),
 | 
			
		||||
    dom.maybe((use) => ['detail', 'single'].includes(use(viewSection.parentKey)), () =>
 | 
			
		||||
      menuItemCmd(allCommands.editLayout, 'Edit Card Layout',
 | 
			
		||||
        dom.cls('disabled', isReadonly))),
 | 
			
		||||
 | 
			
		||||
    menuDivider(),
 | 
			
		||||
    menuItemCmd(allCommands.viewTabOpen, 'Widget options', testId('widget-options')),
 | 
			
		||||
    menuItemCmd(allCommands.sortFilterTabOpen, 'Advanced Sort & Filter'),
 | 
			
		||||
    menuItemCmd(allCommands.dataSelectionTabOpen, 'Data selection'),
 | 
			
		||||
 | 
			
		||||
    menuDivider(),
 | 
			
		||||
    menuItemCmd(allCommands.deleteSection, 'Delete widget',
 | 
			
		||||
      dom.cls('disabled', viewModel.viewSections().peekLength <= 1 || isReadonly),
 | 
			
		||||
      testId('section-delete')),
 | 
			
		||||
    menuDivider(),
 | 
			
		||||
 | 
			
		||||
    menuItemCmd(allCommands.viewTabOpen, 'Widget options', testId('widget-options')),
 | 
			
		||||
    menuItemCmd(allCommands.sortFilterTabOpen, 'Advanced Sort & Filter'),
 | 
			
		||||
    menuItemCmd(allCommands.dataSelectionTabOpen, 'Data selection')
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -45,6 +45,12 @@ const cssMenuElem = styled('div', `
 | 
			
		||||
  z-index: 999;
 | 
			
		||||
  --weaseljs-selected-background-color: ${vars.primaryBg};
 | 
			
		||||
  --weaseljs-menu-item-padding: 8px 24px;
 | 
			
		||||
 | 
			
		||||
  @media print {
 | 
			
		||||
    & {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const menuItemStyle = `
 | 
			
		||||
 | 
			
		||||
@ -150,6 +150,11 @@ if (typeof window !== 'undefined') {
 | 
			
		||||
    rpc.setSendMessage(msg => window.parent.postMessage(msg, "*"));
 | 
			
		||||
    window.onmessage = (e: MessageEvent) => rpc.receiveMessage(e.data);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Allow outer Grist application to trigger printing. This is similar to using
 | 
			
		||||
  // iframe.contentWindow.print(), but that call does not work cross-domain.
 | 
			
		||||
  rpc.registerFunc("print", () => window.print());
 | 
			
		||||
 | 
			
		||||
} else if (typeof process === 'undefined') {
 | 
			
		||||
  // Web worker. We can't really bring in the types for WebWorker (available with --lib flag)
 | 
			
		||||
  // without conflicting with a regular window, so use just use `self as any` here.
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user