Skip to content

Commit 1e2051a

Browse files
Brandon Huntclaude
authored andcommitted
feat(settings): group margins by axis with inline validation warnings
- Groups Top/Bottom and Left/Right into labelled sections - Warns when combined margins meet or exceed the page dimension - Warns when less than 10% of page height/width remains for content - Shows available content space in warning message - Accounts for landscape orientation when checking dimensions - Soft warning styling via CSS (muted text + orange icon, no harsh box) - Adds margin unit dropdown at top of section; re-renders on change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 245a63e commit 1e2051a

3 files changed

Lines changed: 235 additions & 38 deletions

File tree

src/main.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
1-
import { Plugin } from "obsidian";
1+
import { Notice, Plugin, TFile } from "obsidian";
22
import { TypesetSettings } from "./types";
33
import {
44
loadSettings,
55
saveSettings,
66
TypesetSettingTab,
77
} from "./settings";
88

9-
// TypesetPlugin is the entry point Obsidian calls when your plugin loads.
10-
// Every Obsidian plugin must export a single class that extends Plugin.
11-
//
12-
// Obsidian calls two lifecycle methods:
13-
// onload() — plugin is enabled (Obsidian starts, or user enables it in settings)
14-
// onunload() — plugin is disabled (Obsidian quits, or user disables it in settings)
15-
//
16-
// Future issues will add commands, settings, and views inside onload().
17-
// Resources registered via this.addCommand(), this.registerEvent(), etc. are
18-
// automatically cleaned up by Obsidian when onunload() fires — no manual teardown
19-
// needed for those. Anything we allocate manually must be cleaned up in onunload().
20-
219
export default class TypesetPlugin extends Plugin {
2210
settings!: TypesetSettings;
2311

@@ -30,6 +18,40 @@ export default class TypesetPlugin extends Plugin {
3018
),
3119
);
3220

21+
// -----------------------------------------------------------------------
22+
// Export helper — shared by both the command and the ribbon button.
23+
// Lazily imports PdfExporter so the Electron/fs code is only loaded when
24+
// the user actually triggers an export, not on every plugin load.
25+
// -----------------------------------------------------------------------
26+
const runExport = async () => {
27+
const file = this.app.workspace.getActiveFile();
28+
if (!(file instanceof TFile)) {
29+
new Notice("No active note to export.");
30+
return;
31+
}
32+
const { PdfExporter } = await import("./pdf-exporter");
33+
const exporter = new PdfExporter(this.app, this.settings);
34+
await exporter.export(file);
35+
};
36+
37+
// -----------------------------------------------------------------------
38+
// Command Palette entry
39+
// Registered with addCommand() — Obsidian auto-cleans it on unload.
40+
// Users can assign a hotkey via Settings → Hotkeys → "Typeset".
41+
// -----------------------------------------------------------------------
42+
this.addCommand({
43+
id: "export-to-pdf",
44+
name: "Export current note to PDF",
45+
callback: runExport,
46+
});
47+
48+
// -----------------------------------------------------------------------
49+
// Ribbon button (left sidebar icon)
50+
// "lucide-printer" is one of Obsidian's built-in Lucide icons.
51+
// The returned element is ignored — Obsidian cleans it up on unload.
52+
// -----------------------------------------------------------------------
53+
this.addRibbonIcon("lucide-printer", "Export to PDF", runExport);
54+
3355
console.log(`Typeset: plugin loaded (v${this.manifest.version})`);
3456
}
3557

src/settings.ts

Lines changed: 167 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,62 @@ export const DEFAULT_SETTINGS: TypesetSettings = {
4646
outputFolder: "",
4747
};
4848

49+
// Page dimensions in mm — used to warn when margins exceed the page size.
50+
const PAGE_DIMENSIONS_MM: Record<
51+
Exclude<PageSize, PageSize.Custom>,
52+
{ w: number; h: number }
53+
> = {
54+
[PageSize.A4]: { w: 210, h: 297 },
55+
[PageSize.Letter]: { w: 215.9, h: 279.4 },
56+
[PageSize.Legal]: { w: 215.9, h: 355.6 },
57+
[PageSize.A5]: { w: 148, h: 210 },
58+
};
59+
60+
function toMm(value: number, unit: "mm" | "in" | "px"): number {
61+
if (unit === "mm") return value;
62+
if (unit === "in") return value * 25.4;
63+
return value * 0.264583; // 96 dpi
64+
}
65+
66+
// Deep-merge saved data onto defaults so nested objects (e.g. margins) are
67+
// merged field-by-field rather than replaced wholesale.
68+
function deepMerge<T>(defaults: T, saved: Partial<T>): T {
69+
const result = { ...defaults } as Record<string, unknown>;
70+
for (const key in saved) {
71+
const savedVal = saved[key];
72+
const defaultVal = (defaults as Record<string, unknown>)[key];
73+
if (
74+
savedVal !== null &&
75+
typeof savedVal === "object" &&
76+
!Array.isArray(savedVal) &&
77+
typeof defaultVal === "object"
78+
) {
79+
result[key] = deepMerge(
80+
defaultVal as Record<string, unknown>,
81+
savedVal as Record<string, unknown>,
82+
);
83+
} else if (savedVal !== undefined) {
84+
result[key] = savedVal;
85+
}
86+
}
87+
return result as T;
88+
}
89+
90+
// Fix NaN values that could come from corrupted saves — but never clamp,
91+
// so the user's intentional values (even unusual ones) are always preserved.
92+
function sanitizeSettings(s: TypesetSettings): TypesetSettings {
93+
const { margins } = s.defaultLayout;
94+
const fix = (v: number) => (isNaN(v) ? 0 : v);
95+
margins.top = fix(margins.top);
96+
margins.right = fix(margins.right);
97+
margins.bottom = fix(margins.bottom);
98+
margins.left = fix(margins.left);
99+
return s;
100+
}
101+
49102
export async function loadSettings(plugin: Plugin): Promise<TypesetSettings> {
50-
return Object.assign({}, DEFAULT_SETTINGS, await plugin.loadData());
103+
const saved = (await plugin.loadData()) ?? {};
104+
return sanitizeSettings(deepMerge(DEFAULT_SETTINGS, saved));
51105
}
52106

53107
export async function saveSettings(
@@ -169,30 +223,6 @@ export class TypesetSettingTab extends PluginSettingTab {
169223
// --- Margins ---
170224
containerEl.createEl("h3", { text: "Margins" });
171225

172-
const marginUnit = this.settings.defaultLayout.margins.unit;
173-
174-
for (const side of ["top", "right", "bottom", "left"] as const) {
175-
new Setting(containerEl)
176-
.setName(`Margin — ${side}`)
177-
.setDesc(`${side.charAt(0).toUpperCase() + side.slice(1)} margin (${marginUnit})`)
178-
.addText(text =>
179-
text
180-
.setPlaceholder("20")
181-
.setValue(
182-
String(this.settings.defaultLayout.margins[side]),
183-
)
184-
.onChange(async value => {
185-
const parsed = parseFloat(value);
186-
this.settings.defaultLayout.margins[side] = isNaN(
187-
parsed,
188-
)
189-
? 0
190-
: Math.max(0, parsed);
191-
await this.save(this.settings);
192-
}),
193-
);
194-
}
195-
196226
new Setting(containerEl)
197227
.setName("Margin unit")
198228
.setDesc("Unit applied to all four margin values.")
@@ -206,9 +236,121 @@ export class TypesetSettingTab extends PluginSettingTab {
206236
| "in"
207237
| "px";
208238
await this.save(this.settings);
239+
this.display();
209240
}),
210241
);
211242

243+
const layout = this.settings.defaultLayout;
244+
const m = layout.margins;
245+
const u = m.unit;
246+
247+
// Resolve page dimensions (mm), swapped for landscape.
248+
let pageW = 0, pageH = 0;
249+
if (layout.size !== PageSize.Custom) {
250+
const base = PAGE_DIMENSIONS_MM[layout.size as Exclude<PageSize, PageSize.Custom>];
251+
if (base) {
252+
const isLandscape = layout.orientation === PageOrientation.Landscape;
253+
pageW = isLandscape ? base.h : base.w;
254+
pageH = isLandscape ? base.w : base.h;
255+
}
256+
}
257+
258+
// Raw text the user is typing — lets us flag non-numeric input without
259+
// clobbering the saved numeric value mid-keystroke.
260+
const raw: Partial<Record<"top" | "bottom" | "left" | "right", string>> = {};
261+
262+
// Builds a margin text field and returns its onChange hook so the
263+
// paired warning can be updated whenever either field changes.
264+
const makeField = (
265+
side: "top" | "bottom" | "left" | "right",
266+
updateWarning: () => void,
267+
) => {
268+
new Setting(containerEl)
269+
.setName(side.charAt(0).toUpperCase() + side.slice(1))
270+
.setDesc(`${side.charAt(0).toUpperCase() + side.slice(1)} margin (${u})`)
271+
.addText(text =>
272+
text
273+
.setPlaceholder("20")
274+
.setValue(String(m[side]))
275+
.onChange(async value => {
276+
raw[side] = value;
277+
const parsed = parseFloat(value);
278+
if (!isNaN(parsed)) {
279+
m[side] = parsed;
280+
await this.save(this.settings);
281+
}
282+
updateWarning();
283+
}),
284+
);
285+
};
286+
287+
// Converts mm back to the user's chosen unit for display.
288+
const fromMm = (mm: number, unit: "mm" | "in" | "px"): number => {
289+
if (unit === "in") return mm / 25.4;
290+
if (unit === "px") return mm / 0.264583;
291+
return mm;
292+
};
293+
294+
// Renders warnings for a pair of sides into the given element.
295+
const renderPairWarnings = (
296+
el: HTMLDivElement,
297+
sideA: "top" | "bottom" | "left" | "right",
298+
sideB: "top" | "bottom" | "left" | "right",
299+
pageLimit: number, // mm; 0 = custom page (skip size check)
300+
dimension: "height" | "width",
301+
) => {
302+
el.empty();
303+
const msgs: string[] = [];
304+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
305+
306+
for (const side of [sideA, sideB]) {
307+
const r = raw[side];
308+
if (r !== undefined && r.trim() !== "" && isNaN(parseFloat(r)))
309+
msgs.push(`${cap(side)}: "${r}" is not a valid number.`);
310+
}
311+
for (const side of [sideA, sideB]) {
312+
if (m[side] < 0)
313+
msgs.push(`${cap(side)} margin is negative — content may overflow the page edge.`);
314+
}
315+
if (pageLimit > 0) {
316+
const combined = toMm(m[sideA], u) + toMm(m[sideB], u);
317+
const available = pageLimit - combined;
318+
const availableDisplay = fromMm(Math.max(available, 0), u).toFixed(u === "in" ? 2 : 1);
319+
const threshold = pageLimit * 0.10;
320+
321+
if (available <= 0) {
322+
msgs.push(
323+
`${cap(sideA)} + ${sideB} leaves no room for content — reduce margins to fit within the page ${dimension}.`,
324+
);
325+
} else if (available < threshold) {
326+
msgs.push(
327+
`Only ${availableDisplay} ${u} available for content (less than 10% of page ${dimension}).`,
328+
);
329+
}
330+
}
331+
332+
for (const msg of msgs)
333+
el.createEl("p", { text: msg, cls: "typeset-margin-warning" });
334+
};
335+
336+
// ── Top & Bottom ──────────────────────────────────────────────────────
337+
containerEl.createEl("p", { text: "Top & Bottom", cls: "typeset-margin-group-label" });
338+
let tbWarning!: HTMLDivElement;
339+
const updateTB = () => renderPairWarnings(tbWarning, "top", "bottom", pageH, "height");
340+
makeField("top", updateTB);
341+
makeField("bottom", updateTB);
342+
tbWarning = containerEl.createDiv({ cls: "typeset-margin-pair-warning" });
343+
updateTB();
344+
345+
// ── Left & Right ──────────────────────────────────────────────────────
346+
containerEl.createEl("p", { text: "Left & Right", cls: "typeset-margin-group-label" });
347+
let lrWarning!: HTMLDivElement;
348+
const updateLR = () => renderPairWarnings(lrWarning, "left", "right", pageW, "width");
349+
makeField("left", updateLR);
350+
makeField("right", updateLR);
351+
lrWarning = containerEl.createDiv({ cls: "typeset-margin-pair-warning" });
352+
updateLR();
353+
212354
// --- Export ---
213355
containerEl.createEl("h3", { text: "Export" });
214356

styles.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/* ============================================================
2+
Typeset — Plugin Styles
3+
============================================================ */
4+
5+
/* --- Margin validation warnings ---------------------------- */
6+
7+
.typeset-margin-group-label {
8+
font-size: 0.8em;
9+
font-weight: 600;
10+
text-transform: uppercase;
11+
letter-spacing: 0.06em;
12+
color: var(--text-muted);
13+
margin: 1.25rem 0 0 0;
14+
padding: 0;
15+
}
16+
17+
.typeset-margin-pair-warning {
18+
margin: 0 0 0.5rem 0;
19+
padding: 0;
20+
}
21+
22+
.typeset-margin-warning {
23+
margin: 0.15rem 0;
24+
padding: 0;
25+
font-size: 0.82em;
26+
line-height: 1.4;
27+
color: var(--text-muted);
28+
}
29+
30+
.typeset-margin-warning::before {
31+
content: "⚠ ";
32+
color: var(--color-orange);
33+
}

0 commit comments

Comments
 (0)