(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:
Dmitry S 2020-10-09 17:39:13 -04:00
parent d2ad5edc46
commit 99ab09651e
16 changed files with 367 additions and 127 deletions

View File

@ -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.

View File

@ -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) {

View File

@ -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 ***/

View File

@ -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(

View File

@ -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 ========== */

View File

@ -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 */

View File

@ -690,4 +690,9 @@ const cssViewContentPane = styled('div', `
position: relative;
min-width: 240px;
margin: 12px;
@media print {
& {
margin: 0px;
}
}
`);

View 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;
}
}

View 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;
}

View File

@ -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));
}

View File

@ -92,6 +92,11 @@ exports.groups = [{
name: 'dataSelectionTabOpen',
keys: [],
desc: 'Shortcut to data selection tab'
},
{
name: 'printSection',
keys: [],
desc: 'Print currently selected page widget',
}
]
}, {

View File

@ -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;
}

View File

@ -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;

View File

@ -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')
];
}

View File

@ -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 = `

View File

@ -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.