(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,11 +79,13 @@
margin-left: -3px;
}
@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 ***/
.detailview_single {

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,7 +832,15 @@ GridView.prototype.buildDom = function() {
) //end hbox
), // END COL HEADER BOX
koDomScrolly.scrolly(data, { paddingBottom: 80, paddingRight: 28 }, function(row) {
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
@ -948,10 +957,7 @@ GridView.prototype.buildDom = function() {
})
)
);
}) //end scrolly
) // end scrollpane
);// END MAIN VIEW BOX
}
};
/** @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.