Merge pull request #1091 from TriliumNext/feature/different_printing_mechanism

Export as PDF
This commit is contained in:
Elian Doran 2025-02-01 01:35:43 +02:00 committed by GitHub
commit c09ef76f87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 311 additions and 130 deletions

View File

@ -81,7 +81,6 @@ const copy = async () => {
"node_modules/mermaid/dist/",
"node_modules/jquery/dist/",
"node_modules/jquery-hotkeys/",
"node_modules/print-this/",
"node_modules/split.js/dist/",
"node_modules/panzoom/dist/",
"node_modules/i18next/",

21
package-lock.json generated
View File

@ -78,7 +78,6 @@
"normalize-strings": "1.1.1",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"print-this": "2.0.0",
"rand-token": "1.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
@ -12681,15 +12680,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/ora": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
@ -13495,17 +13485,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/print-this": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/print-this/-/print-this-2.0.0.tgz",
"integrity": "sha512-/v1/tXs4BQGpEF7OYKe05h4xiQR09Q4HgASL28pngx6aedCQaB1OlHs8t9RDVgUayXHDWHG9V5EBjPlXb46k4w==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"jquery": ">=1.11",
"opencollective-postinstall": "^2.0.2"
}
},
"node_modules/proc-log": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",

View File

@ -123,7 +123,6 @@
"normalize-strings": "1.1.1",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"print-this": "2.0.0",
"rand-token": "1.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",

View File

@ -81,6 +81,10 @@ export type CommandMappings = {
showOptions: CommandData & {
section: string;
};
showExportDialog: CommandData & {
notePath: string;
defaultType: "single";
};
showDeleteNotesDialog: CommandData & {
branchIdsToDelete: string[];
callback: (value: ResolveOptions) => void;

View File

@ -51,10 +51,6 @@ const RELATION_MAP: Library = {
css: ["stylesheets/relation_map.css"]
};
const PRINT_THIS: Library = {
js: ["node_modules/print-this/printThis.js"]
};
const CALENDAR_WIDGET: Library = {
css: ["stylesheets/calendar.css"]
};
@ -193,7 +189,6 @@ export default {
CODE_MIRROR,
ESLINT,
RELATION_MAP,
PRINT_THIS,
CALENDAR_WIDGET,
KATEX,
WHEEL_ZOOM,

View File

@ -5,8 +5,15 @@ import dialogService from "../../services/dialog.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
import ws from "../../services/ws.js";
import appContext from "../../components/app_context.js";
import appContext, { type EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { FAttachmentRow } from "../../entities/fattachment.js";
// TODO: Deduplicate with server
interface ConvertToAttachmentResponse {
attachment: FAttachmentRow;
}
const TPL = `
<div class="dropdown note-actions">
@ -52,8 +59,12 @@ const TPL = `
</li>
<li data-trigger-command="printActiveNote" class="dropdown-item print-active-note-button">
<span class="bx bx-printer"></span> ${t("note_actions.print_note")}<kbd data-command="printActiveNote"></kbd></li>
<span class="bx bx-printer"></span> ${t("note_actions.print_note")}<kbd data-command="printActiveNote"></kbd>
</li>
<li data-trigger-command="exportAsPdf" class="dropdown-item export-as-pdf-button">
<span class="bx bxs-file-pdf"></span> ${t("note_actions.print_pdf")}<kbd data-command="exportAsPdf"></kbd>
</li>
<div class="dropdown-divider"></div>
@ -100,17 +111,37 @@ const TPL = `
</div>`;
export default class NoteActionsWidget extends NoteContextAwareWidget {
private $convertNoteIntoAttachmentButton!: JQuery<HTMLElement>;
private $findInTextButton!: JQuery<HTMLElement>;
private $printActiveNoteButton!: JQuery<HTMLElement>;
private $exportAsPdfButton!: JQuery<HTMLElement>;
private $showSourceButton!: JQuery<HTMLElement>;
private $showAttachmentsButton!: JQuery<HTMLElement>;
private $renderNoteButton!: JQuery<HTMLElement>;
private $saveRevisionButton!: JQuery<HTMLElement>;
private $exportNoteButton!: JQuery<HTMLElement>;
private $importNoteButton!: JQuery<HTMLElement>;
private $openNoteExternallyButton!: JQuery<HTMLElement>;
private $openNoteCustomButton!: JQuery<HTMLElement>;
private $deleteNoteButton!: JQuery<HTMLElement>;
isEnabled() {
return this.note?.type !== "launcher";
}
doRender() {
this.$widget = $(TPL);
this.$widget.on("show.bs.dropdown", () => this.refreshVisibility(this.note));
this.$widget.on("show.bs.dropdown", () => {
if (this.note) {
this.refreshVisibility(this.note);
}
});
this.$convertNoteIntoAttachmentButton = this.$widget.find("[data-trigger-command='convertNoteIntoAttachment']");
this.$findInTextButton = this.$widget.find(".find-in-text-button");
this.$printActiveNoteButton = this.$widget.find(".print-active-note-button");
this.$exportAsPdfButton = this.$widget.find(".export-as-pdf-button");
this.$showSourceButton = this.$widget.find(".show-source-button");
this.$showAttachmentsButton = this.$widget.find(".show-attachments-button");
this.$renderNoteButton = this.$widget.find(".render-note-button");
@ -118,7 +149,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.$exportNoteButton = this.$widget.find(".export-note-button");
this.$exportNoteButton.on("click", () => {
if (this.$exportNoteButton.hasClass("disabled")) {
if (this.$exportNoteButton.hasClass("disabled") || !this.noteContext?.notePath) {
return;
}
@ -129,7 +160,11 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
});
this.$importNoteButton = this.$widget.find(".import-files-button");
this.$importNoteButton.on("click", () => this.triggerCommand("showImportDialog", { noteId: this.noteId }));
this.$importNoteButton.on("click", () => {
if (this.noteId) {
this.triggerCommand("showImportDialog", { noteId: this.noteId });
}
});
this.$widget.on("click", ".dropdown-item", () => this.$widget.find("[data-bs-toggle='dropdown']").dropdown("toggle"));
@ -138,7 +173,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.$deleteNoteButton = this.$widget.find(".delete-note-button");
this.$deleteNoteButton.on("click", () => {
if (this.note.noteId === "root") {
if (!this.note || this.note.noteId === "root") {
return;
}
@ -146,7 +181,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
});
}
async refreshVisibility(note) {
async refreshVisibility(note: FNote) {
const isInOptions = note.noteId.startsWith("_options");
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
@ -156,7 +191,10 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "geoMap"].includes(note.type));
this.toggleDisabled(this.$printActiveNoteButton, ["text", "code"].includes(note.type));
const canPrint = ["text", "code"].includes(note.type);
this.toggleDisabled(this.$printActiveNoteButton, canPrint);
this.toggleDisabled(this.$exportAsPdfButton, canPrint);
this.$exportAsPdfButton.toggleClass("hidden-ext", !utils.isElectron());
this.$renderNoteButton.toggle(note.type === "render");
@ -177,11 +215,11 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
}
async convertNoteIntoAttachmentCommand() {
if (!(await dialogService.confirm(t("note_actions.convert_into_attachment_prompt", { title: this.note.title })))) {
if (!this.note || !(await dialogService.confirm(t("note_actions.convert_into_attachment_prompt", { title: this.note.title })))) {
return;
}
const { attachment: newAttachment } = await server.post(`notes/${this.noteId}/convert-to-attachment`);
const { attachment: newAttachment } = await server.post<ConvertToAttachmentResponse>(`notes/${this.noteId}/convert-to-attachment`);
if (!newAttachment) {
toastService.showMessage(t("note_actions.convert_into_attachment_failed", { title: this.note.title }));
@ -198,7 +236,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
});
}
toggleDisabled($el, enable) {
toggleDisabled($el: JQuery<HTMLElement>, enable: boolean) {
if (enable) {
$el.removeAttr("disabled");
} else {
@ -206,7 +244,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
}
}
entitiesReloadedEvent({ loadResults }) {
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) {
this.refresh();
}

View File

@ -32,6 +32,7 @@ import AttachmentDetailTypeWidget from "./type_widgets/attachment_detail.js";
import MindMapWidget from "./type_widgets/mind_map.js";
import { getStylesheetUrl, isSyntaxHighlightEnabled } from "../services/syntax_highlight.js";
import GeoMapTypeWidget from "./type_widgets/geo_map.js";
import utils from "../services/utils.js";
const TPL = `
<div class="note-detail">
@ -249,45 +250,18 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
return;
}
await libraryLoader.requireLibrary(libraryLoader.PRINT_THIS);
let $promotedAttributes = $("");
if (this.note.getPromotedDefinitionAttributes().length > 0) {
$promotedAttributes = (await attributeRenderer.renderNormalAttributes(this.note)).$renderedAttributes;
window.print();
}
const { assetPath } = window.glob;
const cssToLoad = [
`${assetPath}/node_modules/codemirror/lib/codemirror.css`,
`${assetPath}/libraries/ckeditor/ckeditor-content.css`,
`${assetPath}/node_modules/bootstrap/dist/css/bootstrap.min.css`,
`${assetPath}/node_modules/katex/dist/katex.min.css`,
`${assetPath}/stylesheets/print.css`,
`${assetPath}/stylesheets/relation_map.css`,
`${assetPath}/stylesheets/ckeditor-theme.css`
];
if (isSyntaxHighlightEnabled()) {
cssToLoad.push(getStylesheetUrl("default:vs"));
async exportAsPdfEvent() {
if (!this.noteContext.isActive()) {
return;
}
this.$widget.find(".note-detail-printable:visible").printThis({
header: $("<div>").append($("<h2>").text(this.note.title)).append($promotedAttributes).prop("outerHTML"),
footer: `
<script src="${assetPath}/node_modules/katex/dist/katex.min.js"></script>
<script src="${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script>
<script src="${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script>
<script>
document.body.className += ' ck-content printed-content';
renderMathInElement(document.body, {trust: true});
</script>
`,
importCSS: false,
loadCSS: cssToLoad,
debug: true
const { ipcRenderer } = utils.dynamicRequire("electron");
ipcRenderer.send("export-as-pdf", {
title: this.note.title,
landscape: this.note.hasAttribute("label", "printLandscape")
});
}

View File

@ -1,40 +1,162 @@
@media print {
html body {
:root {
--main-background-color: white;
--root-background: var(--main-background-color);
--launcher-pane-background-color: var(--main-background-color);
--main-text-color: black;
--input-text-color: var(--main-text-color);
}
.no-print,
.no-print *,
.tab-row-container,
.tab-row-widget,
#launcher-pane,
#left-pane,
#right-pane,
.title-row .note-icon-widget,
.title-row .button-widget,
.ribbon-container,
.promoted-attributes-widget,
.scroll-padding-widget,
.note-list-widget,
.spacer {
display: none !important;
}
body.mobile #mobile-sidebar-wrapper,
body.mobile .classic-toolbar-widget,
body.mobile .action-button {
display: none !important;
}
body.mobile #detail-container {
max-height: unset;
}
body.mobile .note-title-widget {
padding: 0 !important;
}
body,
#root-widget,
#rest-pane > div.component:first-child,
.note-detail-printable,
.note-detail-editable-text-editor {
height: unset !important;
overflow: auto;
}
.note-title-widget input,
.note-detail-editable-text,
.note-detail-editable-text-editor {
padding: 0 !important;
}
html,
body {
width: unset !important;
height: unset !important;
overflow: visible;
position: unset;
/* https://github.com/zadam/trilium/issues/3202 */
color: black;
}
}
.no-print,
.no-print * {
#root-widget,
#horizontal-main-container,
#rest-pane,
#vertical-main-container,
#center-pane,
.split-note-container-widget,
.note-split:not(.hidden-ext),
body.mobile #mobile-rest-container {
display: block !important;
overflow: auto;
border-radius: 0 !important;
}
#center-pane,
#rest-pane,
.note-split,
body.mobile #detail-container {
width: unset !important;
max-width: unset !important;
}
.component {
contain: none !important;
}
/* Respect page breaks */
.page-break {
page-break-after: always;
break-after: always;
}
.page-break > * {
display: none !important;
}
}
.relation-map-wrapper {
.relation-map-wrapper {
height: 100vh !important;
}
}
.table thead th,
.table td,
.table th {
.table thead th,
.table td,
.table th {
/* Fix center vertical alignment of table cells */
vertical-align: middle;
}
}
pre {
pre {
box-shadow: unset !important;
border: 0.75pt solid gray !important;
border-radius: 2pt !important;
}
}
span[style] {
th,
span[style] {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
/* Fix visibility of checkbox checkmarks
/*
* Text note specific fixes
*/
.ck-widget {
outline: none !important;
}
.ck-placeholder,
.ck-widget__type-around,
.ck-widget__selection-handle {
display: none !important;
}
.ck-widget.table td.ck-editor__nested-editable.ck-editor__nested-editable_focused,
.ck-widget.table td.ck-editor__nested-editable:focus,
.ck-widget.table th.ck-editor__nested-editable.ck-editor__nested-editable_focused,
.ck-widget.table th.ck-editor__nested-editable:focus {
background: unset !important;
outline: unset !important;
}
/* Fix visibility of checkbox checkmarks
see https://github.com/TriliumNext/Notes/issues/901 */
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input[checked]::after {
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input[checked]::after {
/* fallback to default ck-editor green */
border-color: hsl(126, 64%, 41%);
}
}
.include-note .include-note-content {
max-height: unset !important;
overflow: unset !important;
}
/*
* Code note specific fixes.
*/
.note-detail-code pre {
border: unset !important;
border-radius: unset !important;
}

View File

@ -104,11 +104,13 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
opacity: 1;
}
.ck-content p code {
@media (screen) {
.ck-content p code {
border: 1px solid var(--card-border-color);
box-shadow: var(--card-box-shadow);
border-radius: 6px;
background-color: var(--card-background-color);
}
}
.note-detail-printable:not(.word-wrap) pre code {

View File

@ -672,7 +672,8 @@
"save_revision": "Save revision",
"convert_into_attachment_failed": "Converting note '{{title}}' failed.",
"convert_into_attachment_successful": "Note '{{title}}' has been converted to attachment.",
"convert_into_attachment_prompt": "Are you sure you want to convert note '{{title}}' into an attachment of the parent note?"
"convert_into_attachment_prompt": "Are you sure you want to convert note '{{title}}' into an attachment of the parent note?",
"print_pdf": "Export as PDF..."
},
"onclick_button": {
"no_click_handler": "Button widget '{{componentId}}' has no defined click handler"

View File

@ -824,7 +824,8 @@
"search_in_note": "Caută în notiță",
"convert_into_attachment_failed": "Nu s-a putut converti notița „{{title}}”.",
"convert_into_attachment_successful": "Notița „{{title}}” a fost convertită în atașament.",
"convert_into_attachment_prompt": "Doriți convertirea notiței „{{title}}” într-un atașament al notiței părinte?"
"convert_into_attachment_prompt": "Doriți convertirea notiței „{{title}}” într-un atașament al notiței părinte?",
"print_pdf": "Exportare ca PDF..."
},
"note_erasure_timeout": {
"deleted_notes_erased": "Notițele șterse au fost eliminate permanent.",

View File

@ -66,8 +66,6 @@ async function register(app: express.Application) {
app.use(`/${assetPath}/node_modules/jquery-hotkeys/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/jquery-hotkeys/")));
app.use(`/${assetPath}/node_modules/print-this/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/print-this/")));
app.use(`/${assetPath}/node_modules/split.js/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/split.js/dist/")));
app.use(`/${assetPath}/node_modules/panzoom/dist/`, persistentCacheStatic(path.join(srcRoot, "..", "node_modules/panzoom/dist/")));

View File

@ -503,6 +503,12 @@ function getDefaultKeyboardActions() {
description: t("keyboard_actions.print-active-note"),
scope: "window"
},
{
actionName: "exportAsPdf",
defaultShortcuts: [],
description: t("keyboard_actions.export-as-pdf"),
scope: "window"
},
{
actionName: "openNoteExternally",
defaultShortcuts: [],

View File

@ -75,6 +75,7 @@ const enum KeyboardActionNamesEnum {
toggleRibbonTabSimilarNotes,
toggleRightPane,
printActiveNote,
exportAsPdf,
openNoteExternally,
renderActiveNote,
runActiveNote,

View File

@ -1,3 +1,4 @@
import fs from "fs/promises";
import path from "path";
import url from "url";
import port from "./port.js";
@ -7,12 +8,13 @@ import sqlInit from "./sql_init.js";
import cls from "./cls.js";
import keyboardActionsService from "./keyboard_actions.js";
import remoteMain from "@electron/remote/main/index.js";
import type { App, BrowserWindow, BrowserWindowConstructorOptions, WebContents } from "electron";
import { ipcMain } from "electron";
import { isDev, isMac, isWindows } from "./utils.js";
import { BrowserWindow, shell, type App, type BrowserWindowConstructorOptions, type WebContents } from "electron";
import { dialog, ipcMain } from "electron";
import { formatDownloadTitle, isDev, isMac, isWindows } from "./utils.js";
import { fileURLToPath } from "url";
import { dirname } from "path";
import { t } from "i18next";
// Prevent the window being garbage collected
let mainWindow: BrowserWindow | null;
@ -46,6 +48,50 @@ ipcMain.on("create-extra-window", (event, arg) => {
createExtraWindow(arg.extraWindowHash);
});
interface ExportAsPdfOpts {
title: string;
landscape: boolean;
}
ipcMain.on("export-as-pdf", async (e, opts: ExportAsPdfOpts) => {
const browserWindow = BrowserWindow.fromWebContents(e.sender);
if (!browserWindow) {
return;
}
const filePath = dialog.showSaveDialogSync(browserWindow, {
defaultPath: formatDownloadTitle(opts.title, "file", "application/pdf"),
filters: [
{
name: t("pdf.export_filter"),
extensions: [ "pdf" ]
}
]
});
if (!filePath) {
return;
}
let buffer: Buffer;
try {
buffer = await browserWindow.webContents.printToPDF({
landscape: opts.landscape
});
} catch (e) {
dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message"));
return;
}
try {
await fs.writeFile(filePath, buffer);
} catch (e) {
dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
return;
}
shell.openPath(filePath);
});
async function createMainWindow(app: App) {
if ("setUserTasks" in app) {
app.setUserTasks([

View File

@ -62,6 +62,7 @@
<% } %>
<link href="<%= assetPath %>/stylesheets/style.css" rel="stylesheet">
<link href="<%= assetPath %>/stylesheets/print.css" rel="stylesheet" media="print">
<script>
$("body").show();

View File

@ -129,6 +129,7 @@
<link href="<%= themeCssUrl %>" rel="stylesheet">
<% } %>
<link href="<%= assetPath %>/stylesheets/style.css" rel="stylesheet">
<link href="<%= assetPath %>/stylesheets/print.css" rel="stylesheet" media="print">
<link rel="stylesheet" type="text/css" href="<%= assetPath %>/node_modules/boxicons/css/boxicons.min.css">

View File

@ -90,7 +90,8 @@
"force-save-revision": "Force creating / saving new note revision of the active note",
"show-help": "Shows built-in Help / cheatsheet",
"toggle-book-properties": "Toggle Book Properties",
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar"
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar",
"export-as-pdf": "Exports the current note as a PDF"
},
"login": {
"title": "Login",
@ -253,5 +254,11 @@
},
"content_renderer": {
"note-cannot-be-displayed": "This note type cannot be displayed."
},
"pdf": {
"export_filter": "PDF Document (*.pdf)",
"unable-to-export-message": "The current note could not be exported as a PDF.",
"unable-to-export-title": "Unable to export as PDF",
"unable-to-save-message": "The selected file could not be written to. Try again or select another destination."
}
}

View File

@ -90,7 +90,8 @@
"unhoist": "Defocalizează complet",
"zoom-in": "Mărește zoom-ul",
"zoom-out": "Micșorează zoom-ul",
"toggle-classic-editor-toolbar": "Comută tab-ul „Formatare” pentru editorul cu bară fixă"
"toggle-classic-editor-toolbar": "Comută tab-ul „Formatare” pentru editorul cu bară fixă",
"export-as-pdf": "Exportă notița curentă ca PDF"
},
"login": {
"button": "Autentifică",
@ -254,5 +255,11 @@
},
"content_renderer": {
"note-cannot-be-displayed": "Acest tip de notiță nu poate fi afișat."
},
"pdf": {
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "Notița curentă nu a putut fi exportată ca PDF.",
"unable-to-export-title": "Nu s-a putut exporta ca PDF",
"unable-to-save-message": "Nu s-a putut scrie fișierul selectat. Încercați din nou sau selectați altă destinație."
}
}