mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 19:17:34 +02:00
Feature/small improvements (#9440)
This commit is contained in:
@@ -24,6 +24,7 @@ import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
|
||||
import type { InfoProps } from "../widgets/dialogs/info.jsx";
|
||||
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||
import type { PrintPreviewData } from "../widgets/dialogs/print_preview.jsx";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import Component from "./component.js";
|
||||
@@ -330,6 +331,7 @@ export type CommandMappings = {
|
||||
toggleRightPane: CommandData;
|
||||
printActiveNote: CommandData;
|
||||
exportAsPdf: CommandData;
|
||||
showPrintPreview: PrintPreviewData;
|
||||
openNoteExternally: CommandData;
|
||||
openNoteCustom: CommandData;
|
||||
openNoteOnServer: CommandData;
|
||||
|
||||
@@ -24,6 +24,7 @@ import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
|
||||
import PrintPreviewDialog from "../widgets/dialogs/print_preview.jsx";
|
||||
import ToastContainer from "../widgets/Toast.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
@@ -51,6 +52,7 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(<PromptDialog />)
|
||||
.child(<IncorrectCpuArchDialog />)
|
||||
.child(<PopupEditorDialog />)
|
||||
.child(<PrintPreviewDialog />)
|
||||
.child(<CallToActionDialog />)
|
||||
.child(<ToastContainer />);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
--ck-content-color-image-caption-background: transparent !important;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 2cm;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
@@ -12,10 +16,6 @@ body {
|
||||
color: black;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 2cm;
|
||||
}
|
||||
|
||||
.note-list-widget.full-height,
|
||||
.note-list-widget.full-height .note-list-widget-content {
|
||||
height: unset !important;
|
||||
|
||||
@@ -2701,3 +2701,7 @@ iframe.print-iframe {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ck-content pre code {
|
||||
tab-size: var(--code-block-tab-width, 4);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,20 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c
|
||||
&.dropdown-toggle-split {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.btn-group > & {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.btn-group > &:first-child {
|
||||
border-start-start-radius: 6px;
|
||||
border-end-start-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-group > &:last-child {
|
||||
border-start-end-radius: 6px;
|
||||
border-end-end-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
button.btn.btn-primary:hover,
|
||||
|
||||
@@ -454,6 +454,8 @@
|
||||
"and_more": "... and {{count}} more.",
|
||||
"print_landscape": "When exporting to PDF, changes the orientation of the page to landscape instead of portrait.",
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"print_scale": "When exporting to PDF, changes the scale of the rendered content. Values range from 0.1 (10%) to 2 (200%), default is 1 (100%).",
|
||||
"print_margins": "When exporting to PDF, sets page margins. Use <code>default</code>, <code>none</code>, <code>minimum</code>, or custom values as <code>top,right,bottom,left</code> in millimeters.",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"attribute_editor": {
|
||||
@@ -693,6 +695,11 @@
|
||||
"move_right": "Move right"
|
||||
},
|
||||
"note_actions": {
|
||||
"word_wrap": "Word wrap",
|
||||
"word_wrap_auto": "Auto",
|
||||
"word_wrap_auto_description": "Follow the global setting",
|
||||
"word_wrap_on": "On",
|
||||
"word_wrap_off": "Off",
|
||||
"convert_into_attachment": "Convert into attachment",
|
||||
"re_render_note": "Re-render note",
|
||||
"search_in_note": "Search in note",
|
||||
@@ -1268,7 +1275,9 @@
|
||||
"unit": "characters"
|
||||
},
|
||||
"code-editor-options": {
|
||||
"title": "Editor"
|
||||
"title": "Editor",
|
||||
"tab_width": "Tab width",
|
||||
"tab_width_unit": "spaces"
|
||||
},
|
||||
"code_mime_types": {
|
||||
"title": "Available MIME types in the dropdown",
|
||||
@@ -1804,7 +1813,7 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Could not find typeWidget for type '{{type}}'",
|
||||
"printing": "Printing in progress...",
|
||||
"printing_pdf": "Exporting to PDF in progress...",
|
||||
"printing_pdf": "Preparing print preview...",
|
||||
"print_report_title": "Print report",
|
||||
"print_report_error_title": "Failed to print",
|
||||
"print_report_stack_trace": "Stack trace",
|
||||
@@ -1930,7 +1939,9 @@
|
||||
"theme_group_light": "Light themes",
|
||||
"theme_group_dark": "Dark themes",
|
||||
"copy_title": "Copy to clipboard",
|
||||
"click_to_copy": "Click to copy"
|
||||
"click_to_copy": "Click to copy",
|
||||
"tab_width": "Tab width",
|
||||
"tab_width_unit": "spaces"
|
||||
},
|
||||
"classic_editor_toolbar": {
|
||||
"title": "Formatting"
|
||||
@@ -2292,7 +2303,19 @@
|
||||
"note_paths_one": "{{count}} path",
|
||||
"note_paths_other": "{{count}} paths",
|
||||
"note_paths_title": "Note paths",
|
||||
"code_note_switcher": "Change language mode"
|
||||
"code_note_switcher": "Change language mode",
|
||||
"tab_width": "Tab Width: {{width}}",
|
||||
"tab_width_title": "Change tab width",
|
||||
"tab_width_spaces": "{{count}} spaces",
|
||||
"tab_width_spaces_short": "Spaces: {{width}}",
|
||||
"tab_width_tabs": "Tabs: {{width}}",
|
||||
"tab_width_use_default": "Use default ({{width}})",
|
||||
"tab_width_use_default_style": "Use default ({{style}})",
|
||||
"tab_width_display_header": "Display width",
|
||||
"tab_width_reindent_header": "Re-indent content to",
|
||||
"tab_width_style_header": "Indent using",
|
||||
"tab_width_style_spaces": "Spaces",
|
||||
"tab_width_style_tabs": "Tabs"
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "Note Attributes"
|
||||
@@ -2303,6 +2326,37 @@
|
||||
"toggle": "Toggle right panel",
|
||||
"custom_widget_go_to_source": "Go to source code"
|
||||
},
|
||||
"print_preview": {
|
||||
"title": "Print preview",
|
||||
"close": "Close",
|
||||
"save": "Save as PDF",
|
||||
"print": "Print",
|
||||
"export_pdf": "Export as PDF",
|
||||
"system_print": "Print using system dialog",
|
||||
"destination": "Destination",
|
||||
"destination_pdf": "Save as PDF",
|
||||
"destination_printers": "Printers",
|
||||
"destination_default": "Default",
|
||||
"orientation": "Orientation",
|
||||
"portrait": "Portrait",
|
||||
"landscape": "Landscape",
|
||||
"page_size": "Page size",
|
||||
"scale": "Scale",
|
||||
"margins": "Margins",
|
||||
"render_error": "Unable to render PDF with the current settings. Please check the margins and scale.",
|
||||
"margins_default": "Default",
|
||||
"margins_none": "None",
|
||||
"margins_minimum": "Minimum",
|
||||
"margins_custom": "Custom",
|
||||
"margin_top": "Top",
|
||||
"margin_right": "Right",
|
||||
"margin_bottom": "Bottom",
|
||||
"margin_left": "Left",
|
||||
"page_ranges": "Pages",
|
||||
"page_ranges_hint": "Leave empty to print all pages.",
|
||||
"page_ranges_invalid": "Invalid format. Use e.g. 1-5, 8, 11-13.",
|
||||
"page_ranges_placeholder": "e.g. 1-5, 8, 11-13"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} attachment",
|
||||
"attachments_other": "{{count}} attachments",
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from "clsx";
|
||||
import { isValidElement, VNode } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../components/app_context";
|
||||
import NoteContext from "../components/note_context";
|
||||
import FNote from "../entities/fnote";
|
||||
import type { PrintReport } from "../print";
|
||||
@@ -146,13 +147,21 @@ export default function NoteDetail() {
|
||||
toast.closePersistent("printing");
|
||||
handlePrintReport(printReport);
|
||||
};
|
||||
const onPreviewResult = (_e: any, { buffer, notePath }: { buffer: Uint8Array; notePath: string }) => {
|
||||
toast.closePersistent("printing");
|
||||
if (note) {
|
||||
appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, note, notePath });
|
||||
}
|
||||
};
|
||||
ipcRenderer.on("print-progress", onPrintProgress);
|
||||
ipcRenderer.on("print-done", onPrintDone);
|
||||
ipcRenderer.on("export-as-pdf-preview-result", onPreviewResult);
|
||||
return () => {
|
||||
ipcRenderer.off("print-progress", onPrintProgress);
|
||||
ipcRenderer.off("print-done", onPrintDone);
|
||||
ipcRenderer.off("export-as-pdf-preview-result", onPreviewResult);
|
||||
};
|
||||
}, []);
|
||||
}, [note]);
|
||||
|
||||
useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => {
|
||||
if (!noteContext?.isActive()) return;
|
||||
@@ -173,54 +182,51 @@ export default function NoteDetail() {
|
||||
useTriliumEvent("printActiveNote", () => {
|
||||
if (!noteContext?.isActive() || !note) return;
|
||||
|
||||
showToast("printing");
|
||||
|
||||
if (isElectron()) {
|
||||
// On Electron, open the print preview dialog. Actual print/PDF actions
|
||||
// are triggered from the dialog's footer buttons.
|
||||
showToast("exporting_pdf");
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("print-note", {
|
||||
notePath: noteContext.notePath
|
||||
ipcRenderer.send("export-as-pdf-preview", {
|
||||
title: note.title,
|
||||
notePath: noteContext.notePath,
|
||||
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
||||
landscape: note.hasAttribute("label", "printLandscape"),
|
||||
scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1,
|
||||
margins: note.getAttributeValue("label", "printMargins") ?? "default",
|
||||
pageRanges: ""
|
||||
});
|
||||
} else {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `?print#${noteContext.notePath}`;
|
||||
iframe.className = "print-iframe";
|
||||
document.body.appendChild(iframe);
|
||||
iframe.onload = () => {
|
||||
if (!iframe.contentWindow) {
|
||||
toast.closePersistent("printing");
|
||||
document.body.removeChild(iframe);
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser fallback: render the print page in a hidden iframe and use window.print().
|
||||
showToast("printing");
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `?print#${noteContext.notePath}`;
|
||||
iframe.className = "print-iframe";
|
||||
document.body.appendChild(iframe);
|
||||
iframe.onload = () => {
|
||||
if (!iframe.contentWindow) {
|
||||
toast.closePersistent("printing");
|
||||
document.body.removeChild(iframe);
|
||||
return;
|
||||
}
|
||||
|
||||
iframe.contentWindow.addEventListener("note-load-progress", (e) => {
|
||||
showToast("printing", e.detail.progress);
|
||||
});
|
||||
|
||||
iframe.contentWindow.addEventListener("note-ready", (e) => {
|
||||
toast.closePersistent("printing");
|
||||
|
||||
if ("detail" in e) {
|
||||
handlePrintReport(e.detail as PrintReport);
|
||||
}
|
||||
|
||||
iframe.contentWindow.addEventListener("note-load-progress", (e) => {
|
||||
showToast("printing", e.detail.progress);
|
||||
});
|
||||
|
||||
iframe.contentWindow.addEventListener("note-ready", (e) => {
|
||||
toast.closePersistent("printing");
|
||||
|
||||
if ("detail" in e) {
|
||||
handlePrintReport(e.detail as PrintReport);
|
||||
}
|
||||
|
||||
iframe.contentWindow?.print();
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useTriliumEvent("exportAsPdf", () => {
|
||||
if (!noteContext?.isActive() || !note) return;
|
||||
showToast("exporting_pdf");
|
||||
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("export-as-pdf", {
|
||||
title: note.title,
|
||||
notePath: noteContext.notePath,
|
||||
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
|
||||
landscape: note.hasAttribute("label", "printLandscape")
|
||||
});
|
||||
iframe.contentWindow?.print();
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -267,7 +267,9 @@ const ATTR_HELP: Record<string, Record<string, string>> = {
|
||||
newNotesOnTop: t("attribute_detail.new_notes_on_top"),
|
||||
hideHighlightWidget: t("attribute_detail.hide_highlight_widget"),
|
||||
printLandscape: t("attribute_detail.print_landscape"),
|
||||
printPageSize: t("attribute_detail.print_page_size")
|
||||
printPageSize: t("attribute_detail.print_page_size"),
|
||||
printScale: t("attribute_detail.print_scale"),
|
||||
printMargins: t("attribute_detail.print_margins")
|
||||
},
|
||||
relation: {
|
||||
runOnNoteCreation: t("attribute_detail.run_on_note_creation"),
|
||||
|
||||
480
apps/client/src/widgets/dialogs/print_preview.tsx
Normal file
480
apps/client/src/widgets/dialogs/print_preview.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import { t } from "../../services/i18n";
|
||||
import toast from "../../services/toast";
|
||||
import { dynamicRequire, isElectron } from "../../services/utils";
|
||||
import Button, { ButtonGroup } from "../react/Button";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import Slider from "../react/Slider";
|
||||
import PdfViewer from "../type_widgets/file/PdfViewer";
|
||||
import OptionsRow from "../type_widgets/options/components/OptionsRow";
|
||||
import OptionsSection from "../type_widgets/options/components/OptionsSection";
|
||||
|
||||
const PAGE_SIZES = ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "Legal", "Letter", "Tabloid", "Ledger"] as const;
|
||||
|
||||
/** Pseudo-printer name used to route the Print button to the PDF export flow. */
|
||||
const DESTINATION_PDF = "__pdf__";
|
||||
|
||||
interface PrinterInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
location: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** Builds the description line shown under a printer in the dropdown. */
|
||||
function buildPrinterDescription(printer: PrinterInfo): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (printer.isDefault) parts.push(t("print_preview.destination_default"));
|
||||
if (printer.location) parts.push(printer.location);
|
||||
else if (printer.description) parts.push(printer.description);
|
||||
return parts.length ? parts.join(" · ") : undefined;
|
||||
}
|
||||
const MARGIN_PRESETS = ["default", "none", "minimum"] as const;
|
||||
type MarginPreset = typeof MARGIN_PRESETS[number];
|
||||
|
||||
interface CustomMargins {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
function parseMarginValue(value: string): { preset: MarginPreset | "custom"; custom: CustomMargins } {
|
||||
if (MARGIN_PRESETS.includes(value as MarginPreset)) {
|
||||
return { preset: value as MarginPreset, custom: { top: 10, right: 10, bottom: 10, left: 10 } };
|
||||
}
|
||||
|
||||
const parts = value.split(",").map(Number);
|
||||
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
||||
return { preset: "custom", custom: { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] } };
|
||||
}
|
||||
|
||||
return { preset: "default", custom: { top: 10, right: 10, bottom: 10, left: 10 } };
|
||||
}
|
||||
|
||||
function serializeMargins(preset: MarginPreset | "custom", custom: CustomMargins): string {
|
||||
if (preset !== "custom") return preset;
|
||||
return `${custom.top},${custom.right},${custom.bottom},${custom.left}`;
|
||||
}
|
||||
|
||||
/** Validates a page-range string such as "1-5, 8, 11-13". Empty string is valid (= all pages). */
|
||||
function isValidPageRanges(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return true;
|
||||
return /^\s*\d+(\s*-\s*\d+)?(\s*,\s*\d+(\s*-\s*\d+)?)*\s*$/.test(trimmed);
|
||||
}
|
||||
|
||||
export interface PrintPreviewData {
|
||||
pdfBuffer: Uint8Array;
|
||||
note: FNote;
|
||||
notePath: string;
|
||||
}
|
||||
|
||||
interface PreviewOpts {
|
||||
landscape: boolean;
|
||||
pageSize: string;
|
||||
scale: number;
|
||||
margins: string;
|
||||
pageRanges: string;
|
||||
}
|
||||
|
||||
export default function PrintPreviewDialog() {
|
||||
const [shown, setShown] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string>();
|
||||
const [note, setNote] = useState<FNote>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const bufferRef = useRef<Uint8Array>();
|
||||
const notePathRef = useRef("");
|
||||
const pdfUrlRef = useRef<string>();
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const [landscape, setLandscape] = useNoteLabelBoolean(note, "printLandscape");
|
||||
const [pageSize, setPageSize] = useNoteLabelWithDefault(note, "printPageSize", "Letter");
|
||||
const [scaleStr, setScaleStr] = useNoteLabelWithDefault(note, "printScale", "1");
|
||||
const scale = parseFloat(scaleStr) || 1;
|
||||
const [marginsStr, setMarginsStr] = useNoteLabelWithDefault(note, "printMargins", "default");
|
||||
const { preset: marginPreset, custom: customMargins } = useMemo(() => parseMarginValue(marginsStr), [marginsStr]);
|
||||
|
||||
// Page ranges are kept local — they're one-off per export, not a persistent preference.
|
||||
const [pageRanges, setPageRanges] = useState("");
|
||||
const pageRangesValid = isValidPageRanges(pageRanges);
|
||||
|
||||
// Printer list and current destination. DESTINATION_PDF means "Save as PDF";
|
||||
// any other value is the system printer name to use for silent printing.
|
||||
const [printers, setPrinters] = useState<PrinterInfo[]>([]);
|
||||
const [destination, setDestination] = useState<string>(DESTINATION_PDF);
|
||||
|
||||
const skipNextRegenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown || !isElectron()) return;
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.invoke("get-printers").then((list: PrinterInfo[]) => {
|
||||
setPrinters(list ?? []);
|
||||
const defaultPrinter = list?.find((p) => p.isDefault);
|
||||
if (defaultPrinter) setDestination(defaultPrinter.name);
|
||||
});
|
||||
}, [shown]);
|
||||
|
||||
const updatePreview = useCallback((buffer: Uint8Array) => {
|
||||
bufferRef.current = buffer;
|
||||
|
||||
if (pdfUrlRef.current) {
|
||||
URL.revokeObjectURL(pdfUrlRef.current);
|
||||
}
|
||||
|
||||
const blob = new Blob([buffer as BlobPart], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
pdfUrlRef.current = url;
|
||||
setPdfUrl(url);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => {
|
||||
// When the dialog is already open, it manages its own regeneration via
|
||||
// a persistent IPC listener. Ignore duplicate events from NoteDetail's
|
||||
// listener to avoid overwriting the preview with stale data.
|
||||
if (shown) return;
|
||||
|
||||
skipNextRegenRef.current = true;
|
||||
setNote(data.note);
|
||||
notePathRef.current = data.notePath;
|
||||
updatePreview(data.pdfBuffer);
|
||||
setShown(true);
|
||||
});
|
||||
|
||||
// Handle regeneration results via a persistent listener scoped to the
|
||||
// dialog's lifecycle. A generation counter discards stale results when
|
||||
// multiple requests overlap.
|
||||
useEffect(() => {
|
||||
if (!shown || !isElectron()) return;
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
|
||||
const onResult = (_e: any, { buffer, error }: { buffer?: Uint8Array; error?: string }) => {
|
||||
if (generationRef.current <= 0) return;
|
||||
|
||||
toast.closePersistent("printing");
|
||||
if (error) {
|
||||
setLoading(false);
|
||||
if (pdfUrlRef.current) {
|
||||
URL.revokeObjectURL(pdfUrlRef.current);
|
||||
pdfUrlRef.current = undefined;
|
||||
setPdfUrl(undefined);
|
||||
}
|
||||
toast.showPersistent({
|
||||
id: "print-preview-error",
|
||||
icon: "bx bx-error-circle",
|
||||
message: `${t("print_preview.render_error")}\n\n${error}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.closePersistent("print-preview-error");
|
||||
if (buffer) {
|
||||
updatePreview(buffer);
|
||||
}
|
||||
};
|
||||
ipcRenderer.on("export-as-pdf-preview-result", onResult);
|
||||
return () => {
|
||||
ipcRenderer.off("export-as-pdf-preview-result", onResult);
|
||||
};
|
||||
}, [shown, updatePreview]);
|
||||
|
||||
const regeneratePreview = useCallback((opts: PreviewOpts) => {
|
||||
if (!isElectron()) return;
|
||||
|
||||
++generationRef.current;
|
||||
setLoading(true);
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("export-as-pdf-preview", {
|
||||
notePath: notePathRef.current,
|
||||
pageSize: opts.pageSize,
|
||||
landscape: opts.landscape,
|
||||
scale: opts.scale,
|
||||
margins: opts.margins,
|
||||
pageRanges: opts.pageRanges
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown || !pageRangesValid) return;
|
||||
if (skipNextRegenRef.current) {
|
||||
skipNextRegenRef.current = false;
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
regeneratePreview({ landscape, pageSize, scale, margins: marginsStr, pageRanges: pageRanges.trim() });
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
}, [shown, landscape, pageSize, scale, marginsStr, pageRanges, pageRangesValid, regeneratePreview]);
|
||||
|
||||
function handleClose() {
|
||||
setShown(false);
|
||||
toast.closePersistent("print-preview-error");
|
||||
if (pdfUrlRef.current) {
|
||||
URL.revokeObjectURL(pdfUrlRef.current);
|
||||
pdfUrlRef.current = undefined;
|
||||
setPdfUrl(undefined);
|
||||
}
|
||||
bufferRef.current = undefined;
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleExportPdf() {
|
||||
if (!bufferRef.current) return;
|
||||
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("save-pdf", {
|
||||
title: note?.title ?? "",
|
||||
buffer: bufferRef.current
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handlePrint(silent: boolean, deviceName?: string) {
|
||||
if (!isElectron()) return;
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("print-from-preview", {
|
||||
notePath: notePathRef.current,
|
||||
pageSize,
|
||||
landscape,
|
||||
scale,
|
||||
margins: marginsStr,
|
||||
pageRanges,
|
||||
silent,
|
||||
deviceName
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
|
||||
/** Primary action: route to PDF export or silent print based on the selected destination. */
|
||||
function handlePrimaryAction() {
|
||||
if (destination === DESTINATION_PDF) {
|
||||
handleExportPdf();
|
||||
} else {
|
||||
handlePrint(true, destination);
|
||||
}
|
||||
}
|
||||
|
||||
function handleScaleChange(newScale: number) {
|
||||
const clamped = Math.min(2, Math.max(0.1, Math.round(newScale * 10) / 10));
|
||||
setScaleStr(String(clamped));
|
||||
}
|
||||
|
||||
function handleCustomMarginChange(side: keyof CustomMargins, value: number) {
|
||||
const newCustom = { ...customMargins, [side]: Math.max(0, value) };
|
||||
setMarginsStr(serializeMargins("custom", newCustom));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="print-preview-dialog"
|
||||
title={t("print_preview.title")}
|
||||
size="xl"
|
||||
show={shown}
|
||||
onHidden={handleClose}
|
||||
bodyStyle={{ height: "78vh", padding: 0, display: "flex" }}
|
||||
footerAlignment="between"
|
||||
footer={
|
||||
<>
|
||||
<a
|
||||
href="#"
|
||||
class={loading ? "disabled" : ""}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
// When a specific printer is selected, pre-select it in the system dialog.
|
||||
const deviceName = destination === DESTINATION_PDF ? undefined : destination;
|
||||
handlePrint(false, deviceName);
|
||||
}}
|
||||
>
|
||||
{t("print_preview.system_print")}
|
||||
</a>
|
||||
<Button
|
||||
text={destination === DESTINATION_PDF ? t("print_preview.export_pdf") : t("print_preview.print")}
|
||||
icon={destination === DESTINATION_PDF ? "bx-file" : "bx-printer"}
|
||||
className="btn-primary"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={loading}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: "16px", minWidth: "250px", overflowY: "auto" }}>
|
||||
<OptionsSection>
|
||||
<OptionsRow name="destination" label={t("print_preview.destination")}>
|
||||
<Dropdown
|
||||
disabled={loading}
|
||||
text={<DestinationLabel destination={destination} printers={printers} />}
|
||||
>
|
||||
<FormListItem
|
||||
icon="bx bxs-file-pdf"
|
||||
selected={destination === DESTINATION_PDF}
|
||||
onClick={() => setDestination(DESTINATION_PDF)}
|
||||
>
|
||||
{t("print_preview.destination_pdf")}
|
||||
</FormListItem>
|
||||
{printers.length > 0 && <FormListHeader text={t("print_preview.destination_printers")} />}
|
||||
{printers.map((printer) => (
|
||||
<FormListItem
|
||||
key={printer.name}
|
||||
icon="bx bx-printer"
|
||||
selected={destination === printer.name}
|
||||
onClick={() => setDestination(printer.name)}
|
||||
description={buildPrinterDescription(printer)}
|
||||
>
|
||||
{printer.displayName || printer.name}
|
||||
</FormListItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="orientation" label={t("print_preview.orientation")}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
text={t("print_preview.portrait")}
|
||||
icon="bx-rectangle bx-rotate-90"
|
||||
className={!landscape ? "active" : ""}
|
||||
onClick={() => setLandscape(false)}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
text={t("print_preview.landscape")}
|
||||
icon="bx-rectangle"
|
||||
className={landscape ? "active" : ""}
|
||||
onClick={() => setLandscape(true)}
|
||||
disabled={loading}
|
||||
size="small"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="pageSize" label={t("print_preview.page_size")}>
|
||||
<select
|
||||
class="form-select form-select-sm"
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize((e.target as HTMLSelectElement).value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{PAGE_SIZES.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="scale" label={t("print_preview.scale")} description={`${Math.round(scale * 100)}%`}>
|
||||
<Slider
|
||||
value={scale}
|
||||
min={0.1}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={handleScaleChange}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="margins" label={t("print_preview.margins")}>
|
||||
<select
|
||||
class="form-select form-select-sm"
|
||||
value={marginPreset}
|
||||
onChange={(e) => setMarginsStr(serializeMargins((e.target as HTMLSelectElement).value as MarginPreset | "custom", customMargins))}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="default">{t("print_preview.margins_default")}</option>
|
||||
<option value="none">{t("print_preview.margins_none")}</option>
|
||||
<option value="minimum">{t("print_preview.margins_minimum")}</option>
|
||||
<option value="custom">{t("print_preview.margins_custom")}</option>
|
||||
</select>
|
||||
</OptionsRow>
|
||||
|
||||
{marginPreset === "custom" && (
|
||||
<MarginEditor margins={customMargins} onChange={handleCustomMarginChange} disabled={loading} />
|
||||
)}
|
||||
|
||||
<OptionsRow
|
||||
name="pageRanges"
|
||||
label={t("print_preview.page_ranges")}
|
||||
description={!pageRangesValid ? t("print_preview.page_ranges_invalid") : t("print_preview.page_ranges_hint")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class={`form-control form-control-sm ${!pageRangesValid ? "is-invalid" : ""}`}
|
||||
value={pageRanges}
|
||||
placeholder={t("print_preview.page_ranges_placeholder")}
|
||||
onInput={(e) => setPageRanges((e.target as HTMLInputElement).value)}
|
||||
disabled={loading}
|
||||
style={{ width: "140px" }}
|
||||
/>
|
||||
</OptionsRow>
|
||||
</OptionsSection>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
{loading && (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1, backgroundColor: "var(--modal-bg-color, rgba(255,255,255,0.8))" }}>
|
||||
<span class="bx bx-loader-circle bx-spin" style={{ fontSize: "2rem" }} />
|
||||
</div>
|
||||
)}
|
||||
{pdfUrl && <PdfViewer pdfUrl={pdfUrl} disableSelection />}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationLabel({ destination, printers }: { destination: string; printers: PrinterInfo[] }) {
|
||||
if (destination === DESTINATION_PDF) {
|
||||
return <><span class="bx bxs-file-pdf" /> {t("print_preview.destination_pdf")}</>;
|
||||
}
|
||||
const printer = printers.find((p) => p.name === destination);
|
||||
return <><span class="bx bx-printer" /> {printer?.displayName || printer?.name || destination}</>;
|
||||
}
|
||||
|
||||
function MarginEditor({ margins, onChange, disabled }: {
|
||||
margins: CustomMargins;
|
||||
onChange: (side: keyof CustomMargins, value: number) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const spinnerStyle = { width: "130px" };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "4px", padding: "8px 0" }}>
|
||||
<MarginSpinner label={t("print_preview.margin_top")} value={margins.top} onChange={(v) => onChange("top", v)} disabled={disabled} style={spinnerStyle} />
|
||||
<div style={{ display: "flex", gap: "24px", alignItems: "center" }}>
|
||||
<MarginSpinner label={t("print_preview.margin_left")} value={margins.left} onChange={(v) => onChange("left", v)} disabled={disabled} style={spinnerStyle} />
|
||||
<MarginSpinner label={t("print_preview.margin_right")} value={margins.right} onChange={(v) => onChange("right", v)} disabled={disabled} style={spinnerStyle} />
|
||||
</div>
|
||||
<MarginSpinner label={t("print_preview.margin_bottom")} value={margins.bottom} onChange={(v) => onChange("bottom", v)} disabled={disabled} style={spinnerStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarginSpinner({ label, value, onChange, disabled, style }: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled: boolean;
|
||||
style?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div class="input-group input-group-sm" style={style}>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
value={value}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={(e) => onChange(Math.min(100, (e.target as HTMLInputElement).valueAsNumber || 0))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span class="input-group-text">mm</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,9 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) {
|
||||
const isSourceView = this.noteContext?.viewScope?.viewMode === "source";
|
||||
|
||||
if (!isSourceView && !SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,7 +206,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
const isReadOnly = await this.noteContext?.isReadOnly();
|
||||
|
||||
let selectedText = "";
|
||||
if (this.note?.type === "code" && this.noteContext) {
|
||||
if ((this.note?.type === "code" || isSourceView) && this.noteContext) {
|
||||
const codeEditor = await this.noteContext.getCodeEditor();
|
||||
selectedText = codeEditor.getSelectedText();
|
||||
} else {
|
||||
@@ -249,6 +251,11 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
async getHandler() {
|
||||
// In source view, all note types render via a read-only CodeMirror editor.
|
||||
if (this.noteContext?.viewScope?.viewMode === "source") {
|
||||
return this.codeHandler;
|
||||
}
|
||||
|
||||
switch (this.note?.type) {
|
||||
case "render":
|
||||
return this.htmlHandler;
|
||||
@@ -362,7 +369,9 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "");
|
||||
return super.isEnabled()
|
||||
&& (SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")
|
||||
|| this.noteContext?.viewScope?.viewMode === "source");
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
|
||||
@@ -19,8 +19,8 @@ import { openInAppHelpFromUrl } from "../../services/utils";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions";
|
||||
import Dropdown, { DropdownProps } from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks";
|
||||
import { FormDropdownDivider, FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteLabelOptionalBool, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import LinkButton from "../react/LinkButton";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
@@ -33,6 +33,7 @@ import SimilarNotesTab from "../ribbon/SimilarNotesTab";
|
||||
import { useAttachments } from "../type_widgets/Attachment";
|
||||
import { useProcessedLocales } from "../type_widgets/options/components/LocaleSelector";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
import { convertIndentation } from "./reindentation";
|
||||
|
||||
interface StatusBarContext {
|
||||
note: FNote;
|
||||
@@ -69,6 +70,7 @@ export default function StatusBar() {
|
||||
|
||||
<div className="actions-row">
|
||||
<CodeNoteSwitcher {...context} />
|
||||
<TabWidthSwitcher {...context} />
|
||||
<LanguageSwitcher {...context} />
|
||||
{!isHiddenNote && <NotePaths {...context} />}
|
||||
<AttributesButton {...attributesContext} />
|
||||
@@ -436,6 +438,104 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) {
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Tab width switcher
|
||||
const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const;
|
||||
|
||||
function TabWidthSwitcher({ note, noteContext }: StatusBarContext) {
|
||||
const [ globalTabWidth ] = useTriliumOptionInt("codeNoteTabWidth");
|
||||
const [ globalUseTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs");
|
||||
const [ noteTabWidth, setNoteTabWidth ] = useNoteLabelInt(note, "tabWidth");
|
||||
const [ noteUseTabs, setNoteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs");
|
||||
const effectiveTabWidth = noteTabWidth ?? globalTabWidth ?? 4;
|
||||
const effectiveUseTabs = noteUseTabs ?? globalUseTabs;
|
||||
const hasWidthOverride = noteTabWidth != null;
|
||||
const hasStyleOverride = noteUseTabs != null;
|
||||
|
||||
const reindentTo = async (targetUseTabs: boolean, targetWidth: number) => {
|
||||
const editor = await noteContext.getCodeEditor();
|
||||
if (!editor) return;
|
||||
const converted = convertIndentation(
|
||||
editor.getText(),
|
||||
{ useTabs: effectiveUseTabs, width: effectiveTabWidth },
|
||||
{ useTabs: targetUseTabs, width: targetWidth }
|
||||
);
|
||||
if (converted !== editor.getText()) {
|
||||
editor.setText(converted);
|
||||
}
|
||||
setNoteTabWidth(targetWidth);
|
||||
setNoteUseTabs(targetUseTabs);
|
||||
};
|
||||
|
||||
const statusText = effectiveUseTabs
|
||||
? t("status_bar.tab_width_tabs", { width: effectiveTabWidth })
|
||||
: t("status_bar.tab_width_spaces_short", { width: effectiveTabWidth });
|
||||
|
||||
return (note.type === "code" &&
|
||||
<StatusBarDropdown
|
||||
icon="bx bx-right-indent"
|
||||
text={statusText}
|
||||
title={t("status_bar.tab_width_title")}
|
||||
>
|
||||
<FormListHeader text={t("status_bar.tab_width_style_header")} />
|
||||
<FormListItem
|
||||
checked={!effectiveUseTabs}
|
||||
onClick={() => setNoteUseTabs(false)}
|
||||
>
|
||||
{t("status_bar.tab_width_style_spaces")}
|
||||
</FormListItem>
|
||||
<FormListItem
|
||||
checked={effectiveUseTabs}
|
||||
onClick={() => setNoteUseTabs(true)}
|
||||
>
|
||||
{t("status_bar.tab_width_style_tabs")}
|
||||
</FormListItem>
|
||||
{hasStyleOverride &&
|
||||
<FormListItem icon="bx bx-x" onClick={() => setNoteUseTabs(null)}>
|
||||
{t("status_bar.tab_width_use_default_style", {
|
||||
style: globalUseTabs ? t("status_bar.tab_width_style_tabs") : t("status_bar.tab_width_style_spaces")
|
||||
})}
|
||||
</FormListItem>
|
||||
}
|
||||
|
||||
<FormDropdownDivider />
|
||||
<FormListHeader text={t("status_bar.tab_width_display_header")} />
|
||||
{TAB_WIDTH_OPTIONS.map(size => (
|
||||
<FormListItem
|
||||
key={`display-${size}`}
|
||||
checked={effectiveTabWidth === size}
|
||||
onClick={() => setNoteTabWidth(size)}
|
||||
>
|
||||
{t("status_bar.tab_width_spaces", { count: size })}
|
||||
</FormListItem>
|
||||
))}
|
||||
{hasWidthOverride &&
|
||||
<FormListItem icon="bx bx-x" onClick={() => setNoteTabWidth(null)}>
|
||||
{t("status_bar.tab_width_use_default", { width: globalTabWidth })}
|
||||
</FormListItem>
|
||||
}
|
||||
|
||||
<FormDropdownDivider />
|
||||
<FormListHeader text={t("status_bar.tab_width_reindent_header")} />
|
||||
{TAB_WIDTH_OPTIONS.map(size => (
|
||||
<FormListItem
|
||||
key={`reindent-spaces-${size}`}
|
||||
disabled={!effectiveUseTabs && effectiveTabWidth === size}
|
||||
onClick={() => reindentTo(false, size)}
|
||||
>
|
||||
{t("status_bar.tab_width_spaces", { count: size })}
|
||||
</FormListItem>
|
||||
))}
|
||||
<FormListItem
|
||||
disabled={effectiveUseTabs}
|
||||
onClick={() => reindentTo(true, effectiveTabWidth)}
|
||||
>
|
||||
{t("status_bar.tab_width_style_tabs")}
|
||||
</FormListItem>
|
||||
</StatusBarDropdown>
|
||||
);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Code note switcher
|
||||
function CodeNoteSwitcher({ note }: StatusBarContext) {
|
||||
const [ modalShown, setModalShown ] = useState(false);
|
||||
|
||||
98
apps/client/src/widgets/layout/reindentation.spec.ts
Normal file
98
apps/client/src/widgets/layout/reindentation.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertIndentation } from "./reindentation";
|
||||
|
||||
describe("convertIndentation", () => {
|
||||
it("returns content unchanged when source and target match", () => {
|
||||
const content = " const x = 1;\n const y = 2;\n";
|
||||
expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 4 })).toBe(content);
|
||||
});
|
||||
|
||||
it("returns content unchanged for zero or negative widths", () => {
|
||||
const content = " x\n";
|
||||
expect(convertIndentation(content, { useTabs: false, width: 0 }, { useTabs: false, width: 4 })).toBe(content);
|
||||
expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 0 })).toBe(content);
|
||||
});
|
||||
|
||||
it("converts spaces to a narrower width", () => {
|
||||
const input = " a\n b\n c\n";
|
||||
const expected = " a\n b\n c\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("converts spaces to a wider width", () => {
|
||||
const input = " a\n b\n";
|
||||
const expected = " a\n b\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 2 }, { useTabs: false, width: 4 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("converts spaces to tabs", () => {
|
||||
const input = " a\n b\n";
|
||||
const expected = "\ta\n\t\tb\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("converts tabs to spaces", () => {
|
||||
const input = "\ta\n\t\tb\n";
|
||||
const expected = " a\n b\n";
|
||||
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("converts tabs to a different tab display width", () => {
|
||||
// When both source and target are tabs, the content doesn't change (tab count is preserved)
|
||||
// regardless of visual tab width.
|
||||
const input = "\ta\n\t\tb\n";
|
||||
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: true, width: 2 })).toBe(input);
|
||||
});
|
||||
|
||||
it("handles mixed tabs and spaces on the same line (tab then spaces)", () => {
|
||||
// One tab (→ col 4) + 2 spaces → 6 columns → 1 level + 2 remainder spaces at width=4.
|
||||
// Target spaces width=4: 1 level (4 spaces) + 2 remainder = 6 spaces.
|
||||
const input = "\t statement;\n";
|
||||
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" statement;\n");
|
||||
});
|
||||
|
||||
it("preserves alignment remainder when converting spaces to tabs", () => {
|
||||
// 6 spaces at width=4 → 1 level + 2 remainder spaces.
|
||||
const input = " alignedText\n";
|
||||
const expected = "\t alignedText\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves alignment remainder when converting spaces to narrower spaces", () => {
|
||||
// 5 spaces at width=4 → 1 level + 1 remainder → 1 level at width 2 = 2 spaces + 1 remainder = 3 spaces.
|
||||
const input = " x\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(" x\n");
|
||||
});
|
||||
|
||||
it("handles interleaved spaces and tabs (space, then tab)", () => {
|
||||
// " \t" at tabWidth=4: 2 cols (spaces), then tab advances to next multiple of 4 = col 4.
|
||||
// Total 4 cols = 1 level.
|
||||
const input = " \tstmt\n";
|
||||
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" stmt\n");
|
||||
});
|
||||
|
||||
it("does not touch non-leading whitespace", () => {
|
||||
const input = "const a = \" \\t \";\n if (x)\n";
|
||||
const expected = "const a = \" \\t \";\n if (x)\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("leaves blank lines alone", () => {
|
||||
const input = " a\n\n b\n";
|
||||
const expected = " a\n\n b\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("handles mixed indentation styles across lines", () => {
|
||||
// Line 1 uses tabs, line 2 uses spaces.
|
||||
const input = "\t\tnested\n alsoNested\n";
|
||||
// At from=tabs,width=4: line 1 has 8 cols → 2 levels. Line 2 has 8 spaces → 8 cols → 2 levels.
|
||||
// Target = spaces width 2 → both lines become " " (4 spaces).
|
||||
expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 2 })).toBe(" nested\n alsoNested\n");
|
||||
});
|
||||
|
||||
it("preserves content without leading whitespace", () => {
|
||||
const input = "no indent\nalso no indent\n";
|
||||
expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(input);
|
||||
});
|
||||
});
|
||||
41
apps/client/src/widgets/layout/reindentation.ts
Normal file
41
apps/client/src/widgets/layout/reindentation.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface IndentStyle {
|
||||
useTabs: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the visual column span of a leading-whitespace run. Tabs advance to the next
|
||||
* multiple of `tabWidth`; spaces advance by 1.
|
||||
*/
|
||||
function measureLeadingColumns(leading: string, tabWidth: number): number {
|
||||
let cols = 0;
|
||||
for (const ch of leading) {
|
||||
if (ch === "\t") {
|
||||
cols += tabWidth - (cols % tabWidth);
|
||||
} else {
|
||||
cols += 1;
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the leading whitespace on every line, converting it from the `from` style to the `to`
|
||||
* style. Non-leading whitespace is preserved.
|
||||
*
|
||||
* Handles lines with mixed tabs and spaces in the leading whitespace by measuring the total visual
|
||||
* column span (using `from.width` as the tab stop) and then dividing into `to.width`-sized levels.
|
||||
* Any leftover columns that don't fit a whole level are emitted as spaces (alignment preserved).
|
||||
*/
|
||||
export function convertIndentation(content: string, from: IndentStyle, to: IndentStyle): string {
|
||||
if (from.useTabs === to.useTabs && from.width === to.width) return content;
|
||||
if (!Number.isFinite(from.width) || !Number.isFinite(to.width) || from.width <= 0 || to.width <= 0) return content;
|
||||
const toUnit = to.useTabs ? "\t" : " ".repeat(to.width);
|
||||
|
||||
return content.replace(/^[ \t]+/gm, (leading) => {
|
||||
const cols = measureLeadingColumns(leading, from.width);
|
||||
const levels = Math.floor(cols / from.width);
|
||||
const remainder = cols % from.width;
|
||||
return toUnit.repeat(levels) + " ".repeat(remainder);
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export default function FormTextBox({ inputRef, className, type, currentValue, o
|
||||
if (type === "number") {
|
||||
const { min, max } = rest;
|
||||
const currentValueNum = parseInt(value, 10);
|
||||
if (!Number.isFinite(currentValueNum)) {
|
||||
return String(min ?? "");
|
||||
}
|
||||
if (min && currentValueNum < parseInt(String(min), 10)) {
|
||||
return String(min);
|
||||
} else if (max && currentValueNum > parseInt(String(max), 10)) {
|
||||
|
||||
@@ -664,13 +664,28 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: F
|
||||
return [ labelValue, setter ] as const;
|
||||
}
|
||||
|
||||
export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType<number>): [ number | undefined, (newValue: number) => void] {
|
||||
//@ts-expect-error `useNoteLabel` only accepts string properties but we need to be able to read number ones.
|
||||
/**
|
||||
* Like {@link useNoteLabelBoolean} but returns `undefined` when the label is absent, allowing the caller
|
||||
* to distinguish between "explicitly false" and "not set" (for inheriting from a global default).
|
||||
*/
|
||||
export function useNoteLabelOptionalBool(note: FNote | undefined | null, labelName: FilterLabelsByType<boolean>): [ boolean | undefined, (newValue: boolean | null) => void] {
|
||||
//@ts-expect-error `useNoteLabel` only accepts string labels but we need to be able to read boolean ones.
|
||||
const [ value, setValue ] = useNoteLabel(note, labelName);
|
||||
useDebugValue(labelName);
|
||||
return [
|
||||
(value ? parseInt(value, 10) : undefined),
|
||||
(newValue) => setValue(String(newValue))
|
||||
(value == null ? undefined : value !== "false"),
|
||||
(newValue) => setValue(newValue === null ? null : String(newValue))
|
||||
];
|
||||
}
|
||||
|
||||
export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType<number>): [ number | undefined, (newValue: number | null) => void] {
|
||||
//@ts-expect-error `useNoteLabel` only accepts string properties but we need to be able to read number ones.
|
||||
const [ value, setValue ] = useNoteLabel(note, labelName);
|
||||
useDebugValue(labelName);
|
||||
const parsed = value ? parseInt(value, 10) : undefined;
|
||||
return [
|
||||
(Number.isFinite(parsed) ? parsed : undefined),
|
||||
(newValue) => setValue(newValue === null ? null : String(newValue))
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import MovePaneButton from "../buttons/move_pane_button";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem, FormListToggleableItem } from "../react/FormList";
|
||||
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption } from "../react/hooks";
|
||||
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteLabelOptionalBool, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption } from "../react/hooks";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
import { NoteTypeDropdownContent, useNoteBookmarkState, useShareState } from "./BasicPropertiesTab";
|
||||
import NoteActionsCustom from "./NoteActionsCustom";
|
||||
@@ -115,6 +115,8 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
>
|
||||
{itemsAtStart}
|
||||
|
||||
{note.type === "code" && <CodeProperties note={note} />}
|
||||
|
||||
{isReadOnly && <>
|
||||
<CommandItem icon="bx bx-pencil" text={t("read-only-info.edit-note")}
|
||||
command={() => enableEditing()} />
|
||||
@@ -143,7 +145,6 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
notePath: noteContext.notePath,
|
||||
defaultType: "single"
|
||||
})} />
|
||||
{isElectron && <CommandItem command="exportAsPdf" icon="bx bxs-file-pdf" disabled={!isPrintable} text={t("note_actions.print_pdf")} />}
|
||||
{isExportableToImage && isNormalViewMode && isContentAvailable && <ExportAsImage ntxId={noteContext.ntxId} parentComponent={parentComponent} />}
|
||||
<CommandItem command="printActiveNote" icon="bx bx-printer" disabled={!isPrintable} text={t("note_actions.print_note")} />
|
||||
|
||||
@@ -180,6 +181,27 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
|
||||
);
|
||||
}
|
||||
|
||||
function CodeProperties({ note }: { note: FNote }) {
|
||||
const [ wrapLines, setWrapLines ] = useNoteLabelOptionalBool(note, "wrapLines");
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormDropdownSubmenu title={t("note_actions.word_wrap")} icon="bx bx-align-justify" dropStart>
|
||||
<FormListItem checked={wrapLines == null} onClick={() => setWrapLines(null)} description={t("note_actions.word_wrap_auto_description")}>
|
||||
{t("note_actions.word_wrap_auto")}
|
||||
</FormListItem>
|
||||
<FormListItem checked={wrapLines === true} onClick={() => setWrapLines(true)}>
|
||||
{t("note_actions.word_wrap_on")}
|
||||
</FormListItem>
|
||||
<FormListItem checked={wrapLines === false} onClick={() => setWrapLines(false)}>
|
||||
{t("note_actions.word_wrap_off")}
|
||||
</FormListItem>
|
||||
</FormDropdownSubmenu>
|
||||
<FormDropdownDivider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteBasicProperties({ note, focus }: {
|
||||
note: FNote;
|
||||
focus: RefObject<ItemToFocus>;
|
||||
|
||||
@@ -8,7 +8,7 @@ import appContext, { CommandListenerData } from "../../../components/app_context
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { t } from "../../../services/i18n";
|
||||
import utils from "../../../services/utils";
|
||||
import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteLabelInt, useNoteLabelOptionalBool, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { refToJQuerySelector } from "../../react/react_utils";
|
||||
import TouchBar, { TouchBarButton } from "../../react/TouchBar";
|
||||
import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants";
|
||||
@@ -36,6 +36,9 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit<CodeEditorProps
|
||||
export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) {
|
||||
const [ content, setContent ] = useState("");
|
||||
const blob = useNoteBlob(note);
|
||||
const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth");
|
||||
const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs");
|
||||
const [ noteWrapLines ] = useNoteLabelOptionalBool(note, "wrapLines");
|
||||
|
||||
useEffect(() => {
|
||||
if (!blob) return;
|
||||
@@ -55,6 +58,9 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi
|
||||
content={content}
|
||||
mime={note.mime}
|
||||
readOnly
|
||||
{...(noteTabWidth != null && { indentSize: noteTabWidth })}
|
||||
{...(noteUseTabs != null && { useTabs: noteUseTabs })}
|
||||
{...(noteWrapLines != null && { lineWrapping: noteWrapLines })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +85,9 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
|
||||
const editorRef = useRef<VanillaCodeMirror>(null);
|
||||
const containerRef = useRef<HTMLPreElement>(null);
|
||||
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
|
||||
const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth");
|
||||
const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs");
|
||||
const [ noteWrapLines ] = useNoteLabelOptionalBool(note, "wrapLines");
|
||||
const mime = useNoteProperty(note, "mime");
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
@@ -129,6 +138,9 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
|
||||
}
|
||||
}}
|
||||
{...editorProps}
|
||||
{...(noteTabWidth != null && { indentSize: noteTabWidth })}
|
||||
{...(noteUseTabs != null && { useTabs: noteUseTabs })}
|
||||
{...(noteWrapLines != null && { lineWrapping: noteWrapLines })}
|
||||
/>
|
||||
|
||||
<TouchBar>
|
||||
@@ -146,6 +158,8 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta
|
||||
const initialized = useRef($.Deferred());
|
||||
const [ codeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
|
||||
const [ codeNoteTheme ] = useTriliumOption("codeNoteTheme");
|
||||
const [ codeNoteTabWidth ] = useTriliumOption("codeNoteTabWidth");
|
||||
const [ codeNoteIndentWithTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs");
|
||||
|
||||
// React to background color.
|
||||
const [ backgroundColor, setBackgroundColor ] = useState<string>();
|
||||
@@ -200,6 +214,8 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta
|
||||
editorRef={codeEditorRef}
|
||||
containerRef={containerRef}
|
||||
lineWrapping={lineWrapping ?? codeLineWrapEnabled}
|
||||
indentSize={editorProps.indentSize ?? (parseInt(codeNoteTabWidth) || 4)}
|
||||
useTabs={editorProps.useTabs ?? codeNoteIndentWithTabs}
|
||||
onInitialized={() => {
|
||||
if (externalContainerRef && containerRef.current) {
|
||||
externalContainerRef.current = containerRef.current;
|
||||
|
||||
@@ -49,6 +49,13 @@ export default function CodeMirror({ className, content, mime, editorRef: extern
|
||||
// React to line wrapping.
|
||||
useEffect(() => codeEditorRef.current?.setLineWrapping(!!lineWrapping), [ lineWrapping ]);
|
||||
|
||||
// React to indent size / style changes.
|
||||
useEffect(() => {
|
||||
if (extraOpts.indentSize != null || extraOpts.useTabs != null) {
|
||||
codeEditorRef.current?.setIndent(extraOpts.indentSize ?? 4, !!extraOpts.useTabs);
|
||||
}
|
||||
}, [ extraOpts.indentSize, extraOpts.useTabs ]);
|
||||
|
||||
return (
|
||||
<pre ref={parentRef} className={className} />
|
||||
)
|
||||
|
||||
@@ -22,16 +22,18 @@ interface PdfViewerProps extends Pick<HTMLAttributes<HTMLIFrameElement>, "tabInd
|
||||
* If set, enables editable mode which includes persistence of user settings, annotations as well as specific features such as sending table of contents data for the sidebar.
|
||||
*/
|
||||
editable?: boolean;
|
||||
/** If set, disables text selection in the rendered PDF. */
|
||||
disableSelection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component displaying a PDF. The PDF needs to be provided via a URL.
|
||||
*/
|
||||
export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable }: PdfViewerProps) {
|
||||
export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable, disableSelection }: PdfViewerProps) {
|
||||
const iframeRef = useSyncedRef(externalIframeRef, null);
|
||||
const [ locale ] = useTriliumOption("locale");
|
||||
const [ newLayout ] = useTriliumOptionBool("newLayout");
|
||||
const injectStyles = useStyleInjection(iframeRef);
|
||||
const injectStyles = useStyleInjection(iframeRef, disableSelection);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
@@ -47,7 +49,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad
|
||||
);
|
||||
}
|
||||
|
||||
function useStyleInjection(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
function useStyleInjection(iframeRef: RefObject<HTMLIFrameElement>, disableSelection?: boolean) {
|
||||
const styleRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
// First load.
|
||||
@@ -65,7 +67,13 @@ function useStyleInjection(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
fontStyles.textContent = FONTS.map(injectFont).join("\n");
|
||||
doc.head.appendChild(fontStyles);
|
||||
|
||||
}, [ iframeRef ]);
|
||||
if (disableSelection) {
|
||||
const selectionStyles = doc.createElement("style");
|
||||
selectionStyles.textContent = `.textLayer, .textLayer * { user-select: none !important; cursor: default !important; }`;
|
||||
doc.head.appendChild(selectionStyles);
|
||||
}
|
||||
|
||||
}, [ iframeRef, disableSelection ]);
|
||||
|
||||
// React to changes.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -21,11 +21,12 @@ const SAMPLE_MIME = "application/typescript";
|
||||
|
||||
export default function CodeNoteSettings() {
|
||||
const [codeLineWrapEnabled, setCodeLineWrapEnabled] = useTriliumOptionBool("codeLineWrapEnabled");
|
||||
const [codeNoteTabWidth] = useTriliumOption("codeNoteTabWidth");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor wordWrapping={codeLineWrapEnabled} setWordWrapping={setCodeLineWrapEnabled} />
|
||||
<Appearance wordWrapping={codeLineWrapEnabled} />
|
||||
<Appearance wordWrapping={codeLineWrapEnabled} indentSize={parseInt(codeNoteTabWidth) || 4} />
|
||||
<CodeMimeTypes />
|
||||
</>
|
||||
);
|
||||
@@ -39,6 +40,7 @@ interface EditorProps {
|
||||
function Editor({ wordWrapping, setWordWrapping }: EditorProps) {
|
||||
const [vimKeymapEnabled, setVimKeymapEnabled] = useTriliumOptionBool("vimKeymapEnabled");
|
||||
const [autoReadonlySize, setAutoReadonlySize] = useTriliumOption("autoReadonlySizeCode");
|
||||
const [codeNoteTabWidth, setCodeNoteTabWidth] = useTriliumOption("codeNoteTabWidth");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("code-editor-options.title")}>
|
||||
@@ -49,6 +51,17 @@ function Editor({ wordWrapping, setWordWrapping }: EditorProps) {
|
||||
onChange={setWordWrapping}
|
||||
/>
|
||||
|
||||
{/* Avoid using "code" in the name of numeric inputs to prevent KeepassXC from triggering. */}
|
||||
<OptionsRow name="editor-tab-width" label={t("code-editor-options.tab_width")}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min={1} max={16} step={1}
|
||||
unit={t("code-editor-options.tab_width_unit")}
|
||||
currentValue={codeNoteTabWidth}
|
||||
onChange={setCodeNoteTabWidth}
|
||||
onBlur={setCodeNoteTabWidth}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="source-readonly-threshold" label={t("code_auto_read_only_size.label")} description={t("text_auto_read_only_size.description")}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min={0}
|
||||
@@ -71,9 +84,10 @@ function Editor({ wordWrapping, setWordWrapping }: EditorProps) {
|
||||
|
||||
interface AppearanceProps {
|
||||
wordWrapping: boolean;
|
||||
indentSize: number;
|
||||
}
|
||||
|
||||
function Appearance({ wordWrapping }: AppearanceProps) {
|
||||
function Appearance({ wordWrapping, indentSize }: AppearanceProps) {
|
||||
const [codeNoteTheme, setCodeNoteTheme] = useTriliumOption("codeNoteTheme");
|
||||
|
||||
const themes = useMemo(() => {
|
||||
@@ -93,12 +107,12 @@ function Appearance({ wordWrapping }: AppearanceProps) {
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<CodeNotePreview wordWrapping={wordWrapping} themeName={codeNoteTheme} />
|
||||
<CodeNotePreview wordWrapping={wordWrapping} themeName={codeNoteTheme} indentSize={indentSize} />
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordWrapping: boolean }) {
|
||||
function CodeNotePreview({ themeName, wordWrapping, indentSize }: { themeName: string, wordWrapping: boolean, indentSize: number }) {
|
||||
const editorRef = useRef<CodeMirror>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -124,6 +138,13 @@ function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordW
|
||||
editorRef.current?.setLineWrapping(wordWrapping);
|
||||
}, [ wordWrapping ]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.setIndentSize(indentSize);
|
||||
editor.setText(reindentSample(codeNoteSample, indentSize));
|
||||
}, [ indentSize ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (themeName?.startsWith(DEFAULT_PREFIX)) {
|
||||
const theme = getThemeById(themeName.substring(DEFAULT_PREFIX.length));
|
||||
@@ -142,6 +163,15 @@ function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordW
|
||||
);
|
||||
}
|
||||
|
||||
const SAMPLE_BASE_INDENT = 4;
|
||||
|
||||
function reindentSample(sample: string, indentSize: number): string {
|
||||
return sample.replace(/^( +)/gm, (match) => {
|
||||
const level = match.length / SAMPLE_BASE_INDENT;
|
||||
return " ".repeat(Math.round(level) * indentSize);
|
||||
});
|
||||
}
|
||||
|
||||
function CodeMimeTypes() {
|
||||
return (
|
||||
<OptionsSection title={t("code_mime_types.title")}>
|
||||
|
||||
@@ -264,6 +264,7 @@ function CodeBlockStyle() {
|
||||
}, []);
|
||||
const [ codeBlockTheme, setCodeBlockTheme ] = useTriliumOption("codeBlockTheme");
|
||||
const [ codeBlockWordWrap, setCodeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
|
||||
const [ codeBlockTabWidth, setCodeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("highlighting.title")}>
|
||||
@@ -285,7 +286,18 @@ function CodeBlockStyle() {
|
||||
onChange={setCodeBlockWordWrap}
|
||||
/>
|
||||
|
||||
<CodeBlockPreview theme={codeBlockTheme} wordWrap={codeBlockWordWrap} />
|
||||
{/* Avoid using "code" in the name of numeric inputs to prevent KeepassXC from triggering. */}
|
||||
<OptionsRow name="block-tab-width" label={t("code_block.tab_width")}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min={1} max={16} step={1}
|
||||
unit={t("code_block.tab_width_unit")}
|
||||
currentValue={codeBlockTabWidth}
|
||||
onChange={setCodeBlockTabWidth}
|
||||
onBlur={setCodeBlockTabWidth}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<CodeBlockPreview theme={codeBlockTheme} wordWrap={codeBlockWordWrap} tabWidth={codeBlockTabWidth} />
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
@@ -301,13 +313,13 @@ greet(n); // Print "Hello World" for n times
|
||||
* @param {number} times The number of times to print the \`Hello World!\` message.
|
||||
*/
|
||||
function greet(times) {
|
||||
for (let i = 0; i++; i < times) {
|
||||
console.log("Hello World!");
|
||||
}
|
||||
\tfor (let i = 0; i++; i < times) {
|
||||
\t\tconsole.log("Hello World!");
|
||||
\t}
|
||||
}
|
||||
`;
|
||||
|
||||
function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolean }) {
|
||||
function CodeBlockPreview({ theme, wordWrap, tabWidth }: { theme: string, wordWrap: boolean, tabWidth: string }) {
|
||||
const [ code, setCode ] = useState<string>(SAMPLE_CODE);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -326,13 +338,12 @@ function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolea
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const codeStyle = useMemo<CSSProperties>(() => {
|
||||
if (wordWrap) {
|
||||
return { whiteSpace: "pre-wrap" };
|
||||
}
|
||||
return { whiteSpace: "pre"};
|
||||
|
||||
}, [ wordWrap ]);
|
||||
const codeStyle: CSSProperties = useMemo(() => {
|
||||
return {
|
||||
whiteSpace: wordWrap ? "pre-wrap" : "pre",
|
||||
tabSize: tabWidth || "4"
|
||||
};
|
||||
}, [ wordWrap, tabWidth ]);
|
||||
|
||||
return (
|
||||
<div className="note-detail-readonly-text-content ck-content code-sample-wrapper">
|
||||
@@ -407,4 +418,3 @@ export function HighlightsListOptions() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
|
||||
const [ language ] = useNoteLabel(note, "language");
|
||||
const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType");
|
||||
const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
|
||||
const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth");
|
||||
const isClassicEditor = isMobile() || textNoteEditorType === "ckeditor-classic";
|
||||
const initialized = useRef(deferred<void>());
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
@@ -219,6 +220,10 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
|
||||
|
||||
const onWatchdogStateChange = useWatchdogCrashHandling();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth || "4");
|
||||
}, [codeBlockTabWidth]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{note && !!templates && <CKEditorWithWatchdog
|
||||
|
||||
@@ -13,7 +13,7 @@ import { applyInlineMermaid, rewriteMermaidDiagramsInContainer } from "../../../
|
||||
import { getLocaleById } from "../../../services/i18n";
|
||||
import { renderMathInElement } from "../../../services/math";
|
||||
import { formatCodeBlocks } from "../../../services/syntax_highlight";
|
||||
import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { RawHtmlBlock } from "../../react/RawHtml";
|
||||
import TouchBar, { TouchBarButton, TouchBarSpacer } from "../../react/TouchBar";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
@@ -24,8 +24,13 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro
|
||||
const blob = useNoteBlob(note);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [ codeBlockWordWrap ] = useTriliumOptionBool("codeBlockWordWrap");
|
||||
const [ codeBlockTabWidth ] = useTriliumOption("codeBlockTabWidth");
|
||||
const { isRtl } = useNoteLanguage(note);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.setProperty("--code-block-tab-width", codeBlockTabWidth || "4");
|
||||
}, [codeBlockTabWidth]);
|
||||
|
||||
// Apply necessary transforms.
|
||||
useEffect(() => {
|
||||
const container = contentRef.current;
|
||||
|
||||
@@ -30,7 +30,10 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"theme",
|
||||
"codeBlockTheme",
|
||||
"codeBlockWordWrap",
|
||||
"codeBlockTabWidth",
|
||||
"codeNoteTheme",
|
||||
"codeNoteTabWidth",
|
||||
"codeNoteIndentWithTabs",
|
||||
"syncServerHost",
|
||||
"syncServerTimeout",
|
||||
"syncServerTimeoutTimeScale",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type serveStatic from "serve-static";
|
||||
|
||||
import { assetUrlFragment } from "../services/asset_path.js";
|
||||
import auth from "../services/auth.js";
|
||||
import port from "../services/port.js";
|
||||
import { getResourceDir, isDev } from "../services/utils.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
|
||||
@@ -35,7 +36,15 @@ async function register(app: express.Application) {
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const clientDir = path.join(srcRoot, "../client");
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
// Derive a unique HMR port from the application port so
|
||||
// multiple dev instances (e.g. server on 8080, desktop on
|
||||
// 37742) don't all fight over Vite's default port 24678.
|
||||
port: port + 10
|
||||
}
|
||||
},
|
||||
appType: "spa",
|
||||
configFile: path.join(clientDir, "vite.config.mts"),
|
||||
base: `/${assetUrlFragment}/`
|
||||
|
||||
@@ -131,6 +131,8 @@ const defaultOptions: DefaultOption[] = [
|
||||
{ name: "autoFixConsistencyIssues", value: "true", isSynced: false },
|
||||
{ name: "vimKeymapEnabled", value: "false", isSynced: false },
|
||||
{ name: "codeLineWrapEnabled", value: "true", isSynced: false },
|
||||
{ name: "codeNoteTabWidth", value: "4", isSynced: true },
|
||||
{ name: "codeNoteIndentWithTabs", value: "false", isSynced: true },
|
||||
{
|
||||
name: "codeNotesMimeTypes",
|
||||
value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]',
|
||||
@@ -216,6 +218,7 @@ const defaultOptions: DefaultOption[] = [
|
||||
isSynced: false
|
||||
},
|
||||
{ name: "codeBlockWordWrap", value: "false", isSynced: true },
|
||||
{ name: "codeBlockTabWidth", value: "4", isSynced: true },
|
||||
|
||||
// Text note configuration
|
||||
{ name: "textNoteEditorType", value: "ckeditor-balloon", isSynced: true },
|
||||
|
||||
385
apps/server/src/services/printing.ts
Normal file
385
apps/server/src/services/printing.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { default as electron, ipcMain, type IpcMainEvent } from "electron";
|
||||
import fs from "fs/promises";
|
||||
import { t } from "i18next";
|
||||
|
||||
import log from "./log.js";
|
||||
import port from "./port.js";
|
||||
import { formatDownloadTitle } from "./utils.js";
|
||||
|
||||
interface PrintOpts {
|
||||
notePath: string;
|
||||
printToPdf: boolean;
|
||||
}
|
||||
|
||||
interface ExportAsPdfOpts {
|
||||
notePath: string;
|
||||
title: string;
|
||||
landscape: boolean;
|
||||
pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger";
|
||||
scale: number;
|
||||
margins: string;
|
||||
pageRanges: string;
|
||||
}
|
||||
|
||||
interface PrintFromPreviewOpts extends ExportAsPdfOpts {
|
||||
silent: boolean;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
/** Parses the printMargins attribute into Electron margins.
|
||||
* Values are stored in mm and converted to inches for Electron.
|
||||
* Presets expand to explicit numeric margins since Electron's `marginType` aliases
|
||||
* (especially `none` and `printableArea`) behave inconsistently for PDF output. */
|
||||
function parseMargins(margins: string): Electron.Margins {
|
||||
const mmToInches = (mm: number) => mm / 25.4;
|
||||
const uniform = (mm: number): Electron.Margins => ({
|
||||
marginType: "custom",
|
||||
top: mmToInches(mm),
|
||||
right: mmToInches(mm),
|
||||
bottom: mmToInches(mm),
|
||||
left: mmToInches(mm)
|
||||
});
|
||||
|
||||
if (!margins || margins === "default") return uniform(20); // 2cm
|
||||
if (margins === "none") return uniform(0);
|
||||
if (margins === "minimum") return uniform(5);
|
||||
|
||||
const parts = margins.split(",").map(Number);
|
||||
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
||||
return {
|
||||
marginType: "custom",
|
||||
top: mmToInches(parts[0]),
|
||||
right: mmToInches(parts[1]),
|
||||
bottom: mmToInches(parts[2]),
|
||||
left: mmToInches(parts[3])
|
||||
};
|
||||
}
|
||||
|
||||
return uniform(10);
|
||||
}
|
||||
|
||||
/** Convert "1-5, 8, 11-13" into PageRanges array form expected by webContents.print. */
|
||||
function parsePageRangesForPrint(pageRanges: string): { from: number; to: number }[] | undefined {
|
||||
if (!pageRanges?.trim()) return undefined;
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
for (const part of pageRanges.split(",")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
const [fromStr, toStr] = trimmed.split("-").map(s => s.trim());
|
||||
const from = parseInt(fromStr, 10);
|
||||
const to = toStr ? parseInt(toStr, 10) : from;
|
||||
if (!isNaN(from) && !isNaN(to)) ranges.push({ from, to });
|
||||
}
|
||||
return ranges.length ? ranges : undefined;
|
||||
}
|
||||
|
||||
async function getBrowserWindowForPrinting(e: IpcMainEvent, notePath: string, action: "printing" | "exporting_pdf") {
|
||||
// Offscreen rendering crashes on Wayland due to a Chromium bug where the OSR surface
|
||||
// lacks a valid xdg_toplevel role, causing a fatal zxdg_exporter_v2 protocol error.
|
||||
// On Linux we work around this by creating a regular window positioned off-screen,
|
||||
// since `show: false` without OSR causes Chromium to skip rendering entirely.
|
||||
const useOffscreen = process.platform !== "linux";
|
||||
const browserWindow = new electron.BrowserWindow({
|
||||
show: !useOffscreen,
|
||||
...(useOffscreen ? {} : {
|
||||
width: 1,
|
||||
height: 1,
|
||||
frame: false,
|
||||
skipTaskbar: true,
|
||||
focusable: false,
|
||||
}),
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
offscreen: useOffscreen,
|
||||
devTools: false,
|
||||
session: e.sender.session
|
||||
},
|
||||
});
|
||||
|
||||
const progressCallback = (_e: IpcMainEvent, progress: number) => e.sender.send("print-progress", { progress, action });
|
||||
ipcMain.on("print-progress", progressCallback);
|
||||
|
||||
// Capture ALL console output (including errors) for debugging
|
||||
browserWindow.webContents.on("console-message", (event, message, line, sourceId) => {
|
||||
if (event.level === "debug") return;
|
||||
if (event.level === "error") {
|
||||
log.error(`[Print Window ${sourceId}:${line}] ${message}`);
|
||||
return;
|
||||
}
|
||||
log.info(`[Print Window ${sourceId}:${line}] ${message}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await browserWindow.loadURL(`http://127.0.0.1:${port}/?print#${notePath}`);
|
||||
} catch (err) {
|
||||
log.error(`Failed to load print window: ${err}`);
|
||||
ipcMain.off("print-progress", progressCallback);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Set up error tracking and logging in the renderer process
|
||||
await browserWindow.webContents.executeJavaScript(`
|
||||
(function() {
|
||||
window._printWindowErrors = [];
|
||||
window.addEventListener("error", (e) => {
|
||||
const errorMsg = "Uncaught error: " + e.message + " at " + e.filename + ":" + e.lineno + ":" + e.colno;
|
||||
console.error(errorMsg);
|
||||
if (e.error?.stack) console.error(e.error.stack);
|
||||
window._printWindowErrors.push({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
stack: e.error?.stack
|
||||
});
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const errorMsg = "Unhandled rejection: " + String(e.reason);
|
||||
console.error(errorMsg);
|
||||
if (e.reason?.stack) console.error(e.reason.stack);
|
||||
window._printWindowErrors.push({
|
||||
type: 'rejection',
|
||||
message: errorMsg,
|
||||
stack: e.reason?.stack
|
||||
});
|
||||
});
|
||||
})();
|
||||
`).catch(err => log.error(`Failed to set up error handlers in print window: ${err}`));
|
||||
|
||||
let printReport;
|
||||
try {
|
||||
printReport = await browserWindow.webContents.executeJavaScript(`
|
||||
new Promise((resolve, reject) => {
|
||||
if (window._noteReady) return resolve(window._noteReady);
|
||||
|
||||
// Check for errors periodically
|
||||
const errorChecker = setInterval(() => {
|
||||
if (window._printWindowErrors && window._printWindowErrors.length > 0) {
|
||||
clearInterval(errorChecker);
|
||||
const errors = window._printWindowErrors.map(e => e.message).join('; ');
|
||||
reject(new Error("Print window errors: " + errors));
|
||||
}
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("note-ready", (data) => {
|
||||
clearInterval(errorChecker);
|
||||
resolve(data.detail);
|
||||
});
|
||||
});
|
||||
`);
|
||||
} catch (err) {
|
||||
log.error(`Print window promise failed for ${notePath}: ${err}`);
|
||||
ipcMain.off("print-progress", progressCallback);
|
||||
throw err;
|
||||
}
|
||||
|
||||
ipcMain.off("print-progress", progressCallback);
|
||||
return { browserWindow, printReport };
|
||||
}
|
||||
|
||||
/** Registers all printing-related IPC handlers. Call once on Electron startup. */
|
||||
export function initPrintingHandlers() {
|
||||
electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
|
||||
browserWindow.webContents.print({}, (success, failureReason) => {
|
||||
if (!success && failureReason !== "Print job canceled") {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
|
||||
}
|
||||
e.sender.send("print-done", printReport);
|
||||
browserWindow.destroy();
|
||||
});
|
||||
} catch (err) {
|
||||
e.sender.send("print-done", {
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
|
||||
|
||||
async function print() {
|
||||
const filePath = electron.dialog.showSaveDialogSync(browserWindow, {
|
||||
defaultPath: formatDownloadTitle(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,
|
||||
pageSize,
|
||||
scale,
|
||||
margins: parseMargins(margins),
|
||||
pageRanges: pageRanges || undefined,
|
||||
preferCSSPageSize: false,
|
||||
generateDocumentOutline: true,
|
||||
generateTaggedPDF: true,
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: `<div></div>`,
|
||||
footerTemplate: `
|
||||
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
|
||||
</div>
|
||||
`
|
||||
});
|
||||
} catch (_e) {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, buffer);
|
||||
} catch (_e) {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
|
||||
return;
|
||||
}
|
||||
|
||||
electron.shell.openPath(filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
await print();
|
||||
} finally {
|
||||
e.sender.send("print-done", printReport);
|
||||
browserWindow.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
e.sender.send("print-done", {
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges }: ExportAsPdfOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
|
||||
|
||||
try {
|
||||
const buffer = await browserWindow.webContents.printToPDF({
|
||||
landscape,
|
||||
pageSize,
|
||||
scale,
|
||||
margins: parseMargins(margins),
|
||||
pageRanges: pageRanges || undefined,
|
||||
preferCSSPageSize: false,
|
||||
generateDocumentOutline: true,
|
||||
generateTaggedPDF: true,
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: `<div></div>`,
|
||||
footerTemplate: `
|
||||
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
e.sender.send("export-as-pdf-preview-result", { buffer, notePath });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
e.sender.send("export-as-pdf-preview-result", { notePath, error: message });
|
||||
} finally {
|
||||
e.sender.send("print-done", printReport);
|
||||
browserWindow.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
e.sender.send("print-done", {
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcMain.on("save-pdf", async (_e, { title, buffer }: { title: string; buffer: Buffer }) => {
|
||||
const focusedWindow = electron.BrowserWindow.getFocusedWindow();
|
||||
if (!focusedWindow) return;
|
||||
|
||||
const filePath = electron.dialog.showSaveDialogSync(focusedWindow, {
|
||||
defaultPath: formatDownloadTitle(title, "file", "application/pdf"),
|
||||
filters: [
|
||||
{
|
||||
name: t("pdf.export_filter"),
|
||||
extensions: ["pdf"]
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!filePath) return;
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, Buffer.from(buffer));
|
||||
} catch (_e) {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
|
||||
return;
|
||||
}
|
||||
|
||||
electron.shell.openPath(filePath);
|
||||
});
|
||||
|
||||
electron.ipcMain.handle("get-printers", async (e) => {
|
||||
try {
|
||||
const printers = await e.sender.getPrintersAsync();
|
||||
return printers.map((p) => {
|
||||
// Platform-specific: CUPS uses "printer-location", Windows/mac often expose "location".
|
||||
const opts = (p.options ?? {}) as Record<string, string>;
|
||||
return {
|
||||
name: p.name,
|
||||
displayName: p.displayName,
|
||||
description: p.description,
|
||||
location: opts["printer-location"] || opts.location || "",
|
||||
isDefault: (p as unknown as { isDefault?: boolean }).isDefault ?? false
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcMain.on("print-from-preview", async (e, { notePath, landscape, pageSize, scale, margins, pageRanges, silent, deviceName }: PrintFromPreviewOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
|
||||
|
||||
// print() accepts most of the same options as printToPDF, but typing differs
|
||||
// slightly (e.g. no "Ledger" pageSize). Cast to keep this concise.
|
||||
// "Ledger" and "Tabloid" are the same physical size (11×17 in); Electron's
|
||||
// print() API only recognises "Tabloid", so we map "Ledger" to "Tabloid".
|
||||
const printOpts: Electron.WebContentsPrintOptions = {
|
||||
silent,
|
||||
deviceName,
|
||||
landscape,
|
||||
pageSize: pageSize === "Ledger" ? "Tabloid" : pageSize,
|
||||
scaleFactor: Math.round(scale * 100),
|
||||
margins: parseMargins(margins),
|
||||
pageRanges: parsePageRangesForPrint(pageRanges),
|
||||
printBackground: true
|
||||
};
|
||||
|
||||
browserWindow.webContents.print(printOpts, (success, failureReason) => {
|
||||
if (!success && failureReason !== "Print job canceled") {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
|
||||
}
|
||||
e.sender.send("print-from-preview-done");
|
||||
e.sender.send("print-done", printReport);
|
||||
browserWindow.destroy();
|
||||
});
|
||||
} catch (err) {
|
||||
e.sender.send("print-from-preview-done");
|
||||
e.sender.send("print-done", {
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import { type App, type BrowserWindow, type BrowserWindowConstructorOptions, default as electron, ipcMain, type IpcMainEvent, type Session, type WebContents } from "electron";
|
||||
import fs from "fs/promises";
|
||||
import { t } from "i18next";
|
||||
import { type App, type BrowserWindow, type BrowserWindowConstructorOptions, default as electron, type Session, type WebContents } from "electron";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
import app_info from "./app_info.js";
|
||||
import cls from "./cls.js";
|
||||
import customDictionary from "./custom_dictionary.js";
|
||||
import { initPrintingHandlers } from "./printing.js";
|
||||
import keyboardActionsService from "./keyboard_actions.js";
|
||||
import log from "./log.js";
|
||||
import optionService from "./options.js";
|
||||
import port from "./port.js";
|
||||
import { RESOURCE_DIR } from "./resource_dir.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import { formatDownloadTitle, isMac, isWindows } from "./utils.js";
|
||||
import { isMac, isWindows } from "./utils.js";
|
||||
|
||||
// Prevent the window being garbage collected
|
||||
let mainWindow: BrowserWindow | null;
|
||||
@@ -76,200 +75,7 @@ electron.ipcMain.on("add-word-to-dictionary", (event, word: string) => {
|
||||
customDictionary.addWord(word);
|
||||
});
|
||||
|
||||
interface PrintOpts {
|
||||
notePath: string;
|
||||
printToPdf: boolean;
|
||||
}
|
||||
|
||||
interface ExportAsPdfOpts {
|
||||
notePath: string;
|
||||
title: string;
|
||||
landscape: boolean;
|
||||
pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger";
|
||||
}
|
||||
|
||||
electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "printing");
|
||||
browserWindow.webContents.print({}, (success, failureReason) => {
|
||||
if (!success && failureReason !== "Print job canceled") {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-print"), failureReason);
|
||||
}
|
||||
e.sender.send("print-done", printReport);
|
||||
browserWindow.destroy();
|
||||
});
|
||||
} catch (err) {
|
||||
e.sender.send("print-done", {
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize }: ExportAsPdfOpts) => {
|
||||
try {
|
||||
const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf");
|
||||
|
||||
async function print() {
|
||||
const filePath = electron.dialog.showSaveDialogSync(browserWindow, {
|
||||
defaultPath: formatDownloadTitle(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,
|
||||
pageSize,
|
||||
generateDocumentOutline: true,
|
||||
generateTaggedPDF: true,
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: `<div></div>`,
|
||||
footerTemplate: `
|
||||
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
|
||||
</div>
|
||||
`
|
||||
});
|
||||
} catch (_e) {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-export-message"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, buffer);
|
||||
} catch (_e) {
|
||||
electron.dialog.showErrorBox(t("pdf.unable-to-export-title"), t("pdf.unable-to-save-message"));
|
||||
return;
|
||||
}
|
||||
|
||||
electron.shell.openPath(filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
await print();
|
||||
} finally {
|
||||
e.sender.send("print-done", printReport);
|
||||
browserWindow.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
e.sender.send("print-done", {
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getBrowserWindowForPrinting(e: IpcMainEvent, notePath: string, action: "printing" | "exporting_pdf") {
|
||||
// Offscreen rendering crashes on Wayland due to a Chromium bug where the OSR surface
|
||||
// lacks a valid xdg_toplevel role, causing a fatal zxdg_exporter_v2 protocol error.
|
||||
// On Linux we work around this by creating a regular window positioned off-screen,
|
||||
// since `show: false` without OSR causes Chromium to skip rendering entirely.
|
||||
const useOffscreen = process.platform !== "linux";
|
||||
const browserWindow = new electron.BrowserWindow({
|
||||
show: !useOffscreen,
|
||||
...(useOffscreen ? {} : {
|
||||
width: 1,
|
||||
height: 1,
|
||||
frame: false,
|
||||
skipTaskbar: true,
|
||||
focusable: false,
|
||||
}),
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
offscreen: useOffscreen,
|
||||
devTools: false,
|
||||
session: e.sender.session
|
||||
},
|
||||
});
|
||||
|
||||
const progressCallback = (_e, progress: number) => e.sender.send("print-progress", { progress, action });
|
||||
ipcMain.on("print-progress", progressCallback);
|
||||
|
||||
// Capture ALL console output (including errors) for debugging
|
||||
browserWindow.webContents.on("console-message", (e, message, line, sourceId) => {
|
||||
if (e.level === "debug") return;
|
||||
if (e.level === "error") {
|
||||
log.error(`[Print Window ${sourceId}:${line}] ${message}`);
|
||||
return;
|
||||
}
|
||||
log.info(`[Print Window ${sourceId}:${line}] ${message}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await browserWindow.loadURL(`http://127.0.0.1:${port}/?print#${notePath}`);
|
||||
} catch (err) {
|
||||
log.error(`Failed to load print window: ${err}`);
|
||||
ipcMain.off("print-progress", progressCallback);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Set up error tracking and logging in the renderer process
|
||||
await browserWindow.webContents.executeJavaScript(`
|
||||
(function() {
|
||||
window._printWindowErrors = [];
|
||||
window.addEventListener("error", (e) => {
|
||||
const errorMsg = "Uncaught error: " + e.message + " at " + e.filename + ":" + e.lineno + ":" + e.colno;
|
||||
console.error(errorMsg);
|
||||
if (e.error?.stack) console.error(e.error.stack);
|
||||
window._printWindowErrors.push({
|
||||
type: 'error',
|
||||
message: errorMsg,
|
||||
stack: e.error?.stack
|
||||
});
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const errorMsg = "Unhandled rejection: " + String(e.reason);
|
||||
console.error(errorMsg);
|
||||
if (e.reason?.stack) console.error(e.reason.stack);
|
||||
window._printWindowErrors.push({
|
||||
type: 'rejection',
|
||||
message: errorMsg,
|
||||
stack: e.reason?.stack
|
||||
});
|
||||
});
|
||||
})();
|
||||
`).catch(err => log.error(`Failed to set up error handlers in print window: ${err}`));
|
||||
|
||||
let printReport;
|
||||
try {
|
||||
printReport = await browserWindow.webContents.executeJavaScript(`
|
||||
new Promise((resolve, reject) => {
|
||||
if (window._noteReady) return resolve(window._noteReady);
|
||||
|
||||
// Check for errors periodically
|
||||
const errorChecker = setInterval(() => {
|
||||
if (window._printWindowErrors && window._printWindowErrors.length > 0) {
|
||||
clearInterval(errorChecker);
|
||||
const errors = window._printWindowErrors.map(e => e.message).join('; ');
|
||||
reject(new Error("Print window errors: " + errors));
|
||||
}
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("note-ready", (data) => {
|
||||
clearInterval(errorChecker);
|
||||
resolve(data.detail);
|
||||
});
|
||||
});
|
||||
`);
|
||||
} catch (err) {
|
||||
log.error(`Print window promise failed for ${notePath}: ${err}`);
|
||||
ipcMain.off("print-progress", progressCallback);
|
||||
throw err;
|
||||
}
|
||||
|
||||
ipcMain.off("print-progress", progressCallback);
|
||||
return { browserWindow, printReport };
|
||||
}
|
||||
initPrintingHandlers();
|
||||
|
||||
async function createMainWindow(app: App) {
|
||||
if ("setUserTasks" in app) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { indentLess, indentMore } from "@codemirror/commands";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { EditorSelection, EditorState, SelectionRange, type Transaction, type ChangeSpec } from "@codemirror/state";
|
||||
import type { KeyBinding } from "@codemirror/view";
|
||||
|
||||
@@ -53,11 +54,12 @@ export default smartIndentWithTab;
|
||||
function handleSingleLineSelection(state: EditorState, dispatch: (transaction: Transaction) => void) {
|
||||
const changes: ChangeSpec[] = [];
|
||||
const newSelections: SelectionRange[] = [];
|
||||
const unit = state.facet(indentUnit);
|
||||
|
||||
// Single line selection, replace with tab.
|
||||
// Single line selection, replace with indent unit.
|
||||
for (let range of state.selection.ranges) {
|
||||
changes.push({ from: range.from, to: range.to, insert: "\t" });
|
||||
newSelections.push(EditorSelection.cursor(range.from + 1));
|
||||
changes.push({ from: range.from, to: range.to, insert: unit });
|
||||
newSelections.push(EditorSelection.cursor(range.from + unit.length));
|
||||
}
|
||||
|
||||
dispatch(
|
||||
@@ -75,6 +77,7 @@ function handleSingleLineSelection(state: EditorState, dispatch: (transaction: T
|
||||
function handleEmptySelections(state: EditorState, dispatch: (transaction: Transaction) => void) {
|
||||
const changes: ChangeSpec[] = [];
|
||||
const newSelections: SelectionRange[] = [];
|
||||
const unit = state.facet(indentUnit);
|
||||
|
||||
for (let range of state.selection.ranges) {
|
||||
const line = state.doc.lineAt(range.head);
|
||||
@@ -84,9 +87,9 @@ function handleEmptySelections(state: EditorState, dispatch: (transaction: Trans
|
||||
// Only whitespace before cursor → indent line
|
||||
return indentMore({ state, dispatch });
|
||||
} else {
|
||||
// Insert tab character at cursor
|
||||
changes.push({ from: range.head, to: range.head, insert: "\t" });
|
||||
newSelections.push(EditorSelection.cursor(range.head + 1));
|
||||
// Insert configured indent unit at cursor
|
||||
changes.push({ from: range.head, to: range.head, insert: unit });
|
||||
newSelections.push(EditorSelection.cursor(range.head + unit.length));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,17 @@ export interface EditorConfig {
|
||||
/** Disables some of the nice-to-have features (bracket matching, syntax highlighting, indentation markers) in order to improve performance. */
|
||||
preferPerformance?: boolean;
|
||||
tabIndex?: number;
|
||||
/** The number of spaces used for indentation (also used as the tab display width). Defaults to 4. */
|
||||
indentSize?: number;
|
||||
/** If true, indent using a tab character instead of spaces. Defaults to false. */
|
||||
useTabs?: boolean;
|
||||
onContentChanged?: ContentChangedListener;
|
||||
}
|
||||
|
||||
function buildIndentUnit(indentSize: number, useTabs: boolean) {
|
||||
return useTabs ? "\t" : " ".repeat(indentSize);
|
||||
}
|
||||
|
||||
export default class CodeMirror extends EditorView {
|
||||
|
||||
private config: EditorConfig;
|
||||
@@ -44,6 +52,7 @@ export default class CodeMirror extends EditorView {
|
||||
private historyCompartment: Compartment;
|
||||
private themeCompartment: Compartment;
|
||||
private lineWrappingCompartment: Compartment;
|
||||
private indentUnitCompartment: Compartment;
|
||||
private searchHighlightCompartment: Compartment;
|
||||
private searchPlugin?: SearchHighlighter | null;
|
||||
|
||||
@@ -52,6 +61,7 @@ export default class CodeMirror extends EditorView {
|
||||
const historyCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
const lineWrappingCompartment = new Compartment();
|
||||
const indentUnitCompartment = new Compartment();
|
||||
const searchHighlightCompartment = new Compartment();
|
||||
|
||||
let extensions: Extension[] = [];
|
||||
@@ -68,7 +78,10 @@ export default class CodeMirror extends EditorView {
|
||||
searchHighlightCompartment.of([]),
|
||||
highlightActiveLine(),
|
||||
lineNumbers(),
|
||||
indentUnit.of(" ".repeat(4)),
|
||||
indentUnitCompartment.of([
|
||||
indentUnit.of(buildIndentUnit(config.indentSize ?? 4, !!config.useTabs)),
|
||||
EditorState.tabSize.of(config.indentSize ?? 4)
|
||||
]),
|
||||
keymap.of([
|
||||
...preventCtrlEnterKeymap,
|
||||
...defaultKeymap,
|
||||
@@ -121,6 +134,7 @@ export default class CodeMirror extends EditorView {
|
||||
this.historyCompartment = historyCompartment;
|
||||
this.themeCompartment = themeCompartment;
|
||||
this.lineWrappingCompartment = lineWrappingCompartment;
|
||||
this.indentUnitCompartment = indentUnitCompartment;
|
||||
this.searchHighlightCompartment = searchHighlightCompartment;
|
||||
}
|
||||
|
||||
@@ -168,6 +182,27 @@ export default class CodeMirror extends EditorView {
|
||||
});
|
||||
}
|
||||
|
||||
setIndent(size: number, useTabs: boolean) {
|
||||
if (!Number.isFinite(size) || size < 1) size = 4;
|
||||
if (size > 16) size = 16;
|
||||
this.config.indentSize = size;
|
||||
this.config.useTabs = useTabs;
|
||||
this.dispatch({
|
||||
effects: [ this.indentUnitCompartment.reconfigure([
|
||||
indentUnit.of(buildIndentUnit(size, useTabs)),
|
||||
EditorState.tabSize.of(size)
|
||||
]) ]
|
||||
});
|
||||
}
|
||||
|
||||
setIndentSize(size: number) {
|
||||
this.setIndent(size, !!this.config.useTabs);
|
||||
}
|
||||
|
||||
setUseTabs(useTabs: boolean) {
|
||||
this.setIndent(this.config.indentSize ?? 4, useTabs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the history of undo/redo. Generally useful when changing to a new document.
|
||||
*/
|
||||
|
||||
@@ -60,10 +60,19 @@ type Labels = {
|
||||
"presentation:theme": string;
|
||||
"slide:background": string;
|
||||
|
||||
// Print/export
|
||||
printLandscape: boolean;
|
||||
printPageSize: string;
|
||||
printScale: string;
|
||||
printMargins: string;
|
||||
|
||||
// Note-type specific
|
||||
webViewSrc: string;
|
||||
"disabled:webViewSrc": string;
|
||||
readOnly: boolean;
|
||||
tabWidth: number;
|
||||
indentWithTabs: boolean;
|
||||
wrapLines: boolean;
|
||||
mapType: string;
|
||||
mapRootNoteId: string;
|
||||
|
||||
|
||||
@@ -83,8 +83,14 @@ export default [
|
||||
{ type: "label", name: "iconPack", isDangerous: true },
|
||||
{ type: "label", name: "docName", isDangerous: true },
|
||||
|
||||
{ type: "label", name: "tabWidth" },
|
||||
{ type: "label", name: "indentWithTabs" },
|
||||
{ type: "label", name: "wrapLines" },
|
||||
|
||||
{ type: "label", name: "printLandscape" },
|
||||
{ type: "label", name: "printPageSize" },
|
||||
{ type: "label", name: "printScale" },
|
||||
{ type: "label", name: "printMargins" },
|
||||
|
||||
// relation names
|
||||
{ type: "relation", name: "internalLink" },
|
||||
|
||||
@@ -160,6 +160,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
|
||||
disableTray: boolean;
|
||||
editedNotesOpenInRibbon: boolean;
|
||||
codeBlockWordWrap: boolean;
|
||||
codeBlockTabWidth: number;
|
||||
codeNoteTabWidth: number;
|
||||
codeNoteIndentWithTabs: boolean;
|
||||
textNoteEditorMultilineToolbar: boolean;
|
||||
/** Whether keyboard auto-completion for emojis is triggered when typing `:`. */
|
||||
textNoteEmojiCompletionEnabled: boolean;
|
||||
|
||||
68
scripts/electron-repros/printpdf-page-range/index.html
Normal file
68
scripts/electron-repros/printpdf-page-range/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>printToPDF Repro</title>
|
||||
<style>
|
||||
body { font-family: system-ui; padding: 20px; }
|
||||
#log { white-space: pre-wrap; font-family: monospace; background: #f0f0f0; padding: 12px; margin-top: 16px; border-radius: 4px; max-height: 400px; overflow-y: auto; }
|
||||
button { margin: 4px; padding: 8px 16px; cursor: pointer; }
|
||||
.pass { color: green; } .fail { color: red; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Electron printToPDF — Page Range State Corruption Repro</h2>
|
||||
<p>Click <b>Run Test Sequence</b> to reproduce the bug. It will:</p>
|
||||
<ol>
|
||||
<li>Print with no page range (should succeed)</li>
|
||||
<li>Print with page range "999" (should fail — page doesn't exist)</li>
|
||||
<li>Print with no page range again (does it still work?)</li>
|
||||
</ol>
|
||||
<button id="run">Run Test Sequence</button>
|
||||
<button id="run-fresh">Run Step 3 Only (fresh, no prior failure)</button>
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
const { ipcRenderer } = require("electron");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
function log(msg, cls) {
|
||||
const line = document.createElement("div");
|
||||
line.textContent = msg;
|
||||
if (cls) line.className = cls;
|
||||
logEl.appendChild(line);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
async function runStep(step, pageRanges, description) {
|
||||
log(`\n--- Step ${step}: ${description} (pageRanges=${JSON.stringify(pageRanges)}) ---`);
|
||||
const result = await ipcRenderer.invoke("print-test", { pageRanges, step });
|
||||
if (result.ok) {
|
||||
log(` ✓ SUCCESS (buffer: ${result.size} bytes)`, "pass");
|
||||
} else {
|
||||
log(` ✗ FAILED: ${result.error}`, "fail");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
document.getElementById("run").addEventListener("click", async () => {
|
||||
logEl.textContent = "";
|
||||
log("=== Test Sequence: state corruption after failed pageRanges ===");
|
||||
|
||||
await runStep(1, "", "No page range (baseline)");
|
||||
await runStep(2, "999", "Invalid page range (expect failure)");
|
||||
await runStep(3, "", "No page range again (does state persist?)");
|
||||
|
||||
log("\n=== Done ===");
|
||||
log("If Step 3 fails with 'Page range exceeds page count',");
|
||||
log("it confirms an Electron/Chromium state corruption bug.");
|
||||
});
|
||||
|
||||
document.getElementById("run-fresh").addEventListener("click", async () => {
|
||||
logEl.textContent = "";
|
||||
log("=== Control: single call with no page range ===");
|
||||
await runStep("C", "", "No page range (no prior failure)");
|
||||
log("\n=== Done ===");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
53
scripts/electron-repros/printpdf-page-range/main.js
Normal file
53
scripts/electron-repros/printpdf-page-range/main.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const { app, BrowserWindow, ipcMain } = require("electron");
|
||||
|
||||
let mainWindow;
|
||||
|
||||
app.whenReady().then(() => {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
mainWindow.loadFile("index.html");
|
||||
});
|
||||
|
||||
ipcMain.handle("print-test", async (_e, { pageRanges, step }) => {
|
||||
const printWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1,
|
||||
height: 1,
|
||||
webPreferences: { offscreen: process.platform !== "linux" },
|
||||
});
|
||||
|
||||
await printWindow.loadFile("print-page.html");
|
||||
|
||||
// Wait for content to be ready.
|
||||
await printWindow.webContents.executeJavaScript(
|
||||
`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`
|
||||
);
|
||||
|
||||
const opts = {
|
||||
landscape: false,
|
||||
pageSize: "A4",
|
||||
scale: 1,
|
||||
printBackground: true,
|
||||
// Only include pageRanges if truthy (non-empty string).
|
||||
...(pageRanges ? { pageRanges } : {}),
|
||||
};
|
||||
|
||||
console.log(`[Step ${step}] printToPDF called with:`, JSON.stringify(opts));
|
||||
|
||||
try {
|
||||
const buffer = await printWindow.webContents.printToPDF(opts);
|
||||
console.log(`[Step ${step}] SUCCESS - buffer size: ${buffer.length}`);
|
||||
printWindow.destroy();
|
||||
return { ok: true, size: buffer.length };
|
||||
} catch (err) {
|
||||
console.error(`[Step ${step}] FAILED: ${err.message}`);
|
||||
printWindow.destroy();
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
});
|
||||
13
scripts/electron-repros/printpdf-page-range/package.json
Normal file
13
scripts/electron-repros/printpdf-page-range/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "electron-printpdf-page-range-repro",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Minimal repro for Electron printToPDF state corruption after an invalid pageRanges failure",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^35.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Print Test</title></head>
|
||||
<body>
|
||||
<h1>Test Page</h1>
|
||||
<p>This is a single-page document used to reproduce the printToPDF bug.</p>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user