Feature/small improvements (#9440)

This commit is contained in:
Elian Doran
2026-04-15 23:38:27 +03:00
committed by GitHub
37 changed files with 1630 additions and 293 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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"),

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,10 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"theme",
"codeBlockTheme",
"codeBlockWordWrap",
"codeBlockTabWidth",
"codeNoteTheme",
"codeNoteTabWidth",
"codeNoteIndentWithTabs",
"syncServerHost",
"syncServerTimeout",
"syncServerTimeoutTimeScale",

View File

@@ -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}/`

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" },

View File

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

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

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

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

View File

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