Template creation and management modals (#25)

This commit is contained in:
Danila Miadzvedz 2025-11-24 08:06:09 +01:00 committed by GitHub
parent b2b5d5d979
commit a8158117e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 708 additions and 28 deletions

BIN
.test.autosave.xopp Normal file

Binary file not shown.

View file

@ -13,6 +13,7 @@
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@types/pako": "^2.0.4",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
@ -20,5 +21,8 @@
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"pako": "^2.1.0"
}
}

View file

@ -12,7 +12,13 @@ export default class CreateXoppModalManager {
linksToInsert: string;
openAfterCreate = false;
constructor(app: App, plugin: XoppPlugin, filePath = "", editor: Editor | null = null, linksToInsert = "") {
constructor(
app: App,
plugin: XoppPlugin,
filePath = "",
editor: Editor | null = null,
linksToInsert = ""
) {
this.app = app;
this.plugin = plugin;
this.filePath = filePath;
@ -64,6 +70,7 @@ export default class CreateXoppModalManager {
fileName += ".xopp";
const newNotePath = folderPath === "/" ? fileName : `${folderPath}/${fileName}`;
const selectedTemplatePath =
templatePath && templatePath.length > 0
? templatePath

View file

@ -0,0 +1,144 @@
import XoppPlugin from "main";
import { App, normalizePath, Notice } from "obsidian";
import type { TemplateSpec } from "src/modals/TemplateCreationModal";
import * as pako from "pako";
const MM_TO_PT = 72 / 25.4;
export const PAGE_PRESETS = {
A3: { widthMm: 297, heightMm: 420 },
A4: { widthMm: 210, heightMm: 297 },
A5: { widthMm: 148, heightMm: 210 },
"US Legal": { widthMm: 215.9, heightMm: 355.6 },
"US Letter": { widthMm: 215.9, heightMm: 279.4 },
"16x9": { widthMm: 180, heightMm: 320 },
"4x3": { widthMm: 240, heightMm: 320 },
};
function getPageSizeToPt(spec: TemplateSpec): {
widthPt: number;
heightPt: number;
} {
let widthMm: number;
let heightMm: number;
if (spec.pageSizePreset === "Custom") {
if (spec.customWidthMm && spec.customHeightMm) {
widthMm = spec.customWidthMm;
heightMm = spec.customHeightMm;
} else {
const fallback = PAGE_PRESETS.A4;
widthMm = fallback.widthMm;
heightMm = fallback.heightMm;
}
} else {
const preset = PAGE_PRESETS[spec.pageSizePreset];
widthMm = preset.widthMm;
heightMm = preset.heightMm;
}
if (spec.orientation === "landscape") {
[widthMm, heightMm] = [heightMm, widthMm];
}
return {
widthPt: widthMm * MM_TO_PT,
heightPt: heightMm * MM_TO_PT,
};
}
function normalizeColor(raw: string | undefined | null): string {
if (!raw) return "#000000ff";
let c = raw.trim();
if (!c.startsWith("#")) c = "#" + c;
if (c.length === 4) {
const r = c[1],
g = c[2],
b = c[3];
c = `#${r}${r}${g}${g}${b}${b}`;
}
if (c.length === 7) {
c = c + "ff";
}
return c;
}
function buildBackgroundTag(spec: TemplateSpec): string {
const color = normalizeColor(spec.backgroundColor || "#FFFFFF");
const tag = `<background type="solid" color="${color}" style="${spec.backgroundStyle}"/>`;
return tag;
}
function buildTemplateXML(spec: TemplateSpec): string {
const { widthPt, heightPt } = getPageSizeToPt(spec);
const backgroundTag = buildBackgroundTag(spec);
return `<?xml version="1.0" standalone="no"?>
<xournal creator="xournalpp 1.2.5" fileversion="1">
<title>Xournal++ document - see https://xournalpp.github.io/</title>
<page width="${widthPt}" height="${heightPt}">
${backgroundTag}
<layer/>
</page>
</xournal>`;
}
export async function writeTemplateFile(
app: App,
folder: string,
spec: TemplateSpec
): Promise<string> {
const safeFolder = folder.replace(/^\/+/, "");
const fileName = spec.name.replace(/\.xopp$/i, "") + ".xopp";
const vaultPath = normalizePath(
safeFolder ? `${safeFolder}/${fileName}` : fileName
);
if (safeFolder && !app.vault.getAbstractFileByPath(safeFolder)) {
await app.vault.createFolder(safeFolder);
}
const xml = buildTemplateXML(spec);
if(spec.gzip) {
const gzipped = pako.gzip(xml);
const arrayBuffer = gzipped.buffer.slice(
gzipped.byteOffset,
gzipped.byteOffset + gzipped.byteLength
);
await app.vault.adapter.writeBinary(vaultPath, arrayBuffer);
} else {
await app.vault.adapter.write(vaultPath, xml);
}
return vaultPath;
}
export async function createTemplate(
plugin: XoppPlugin,
spec: TemplateSpec
): Promise<string> {
if (!spec.name) {
new Notice("Please enter a template name");
throw new Error("Missing template name");
}
const folder =
plugin.settings.templatesFolder?.trim() || "XournalTemplates";
if (!plugin.settings.templatesFolder) {
plugin.settings.templatesFolder = folder;
await plugin.saveSettings();
}
const vaultPath = await writeTemplateFile(plugin.app, folder, spec);
new Notice(`Xournal++ template created: ${vaultPath}`);
return vaultPath;
}

View file

@ -0,0 +1,154 @@
import { Notice, TFile, TFolder } from "obsidian";
import { ParsedTemplateEditing } from "./modals/TemplateEditingModal";
import { PAGE_PRESETS } from "./TemplateCreationModalManager";
import { TemplateBackgroundStyle, TemplateSpec } from "./modals/TemplateCreationModal";
import XoppPlugin from "main";
import * as pako from "pako";
type PagePresetName = keyof typeof PAGE_PRESETS;
const PT_TO_MM = 25.4 / 72;
async function readXoppXml (plugin: XoppPlugin, file: TFile): Promise<{ xml: string; isGzipped: boolean }> {
const buffer = await plugin.app.vault.adapter.readBinary(file.path);
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const isGzipped = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
if (isGzipped) {
const xmlBytes = pako.ungzip(bytes);
const xml = new TextDecoder("utf-8").decode(xmlBytes);
return { xml, isGzipped: true };
} else {
const xml = new TextDecoder("utf-8").decode(bytes);
return { xml, isGzipped: false };
}
}
function detectPageSizePreset(widthPt: number, heightPt: number) : {
preset: PagePresetName | "Custom";
widthMm: number;
heightMm: number;
orientation: "portrait" | "landscape";
} {
const widthMm = widthPt * PT_TO_MM;
const heightMm = heightPt * PT_TO_MM;
const presets = PAGE_PRESETS;
const orientation: "portrait" | "landscape" =
widthMm >= heightMm ? "landscape" : "portrait";
const w = Math.min(widthMm, heightMm);
const h = Math.max(widthMm, heightMm);
const toleranceMm = 1;
for (const [name, preset] of Object.entries(presets) as [PagePresetName, {widthMm:number;heightMm:number}][]) {
const pw = preset.widthMm;
const ph = preset.heightMm;
if (Math.abs(w - Math.min(pw, ph)) <= toleranceMm && Math.abs(h - Math.max(pw, ph)) <= toleranceMm) {
return { preset: name, widthMm, heightMm, orientation };
}
}
return { preset: "Custom", widthMm, heightMm, orientation };
}
function parseBackground(el: Element): {
backgroundStyle: TemplateBackgroundStyle;
backgroundColor: string;
spacingMm?: number;
marginMm?: number;
} {
const style = el.getAttribute("style") ?? "";
const backgroundStyle: TemplateBackgroundStyle = style as TemplateBackgroundStyle;
const rawColor = el.getAttribute("color") || "#ffffffff";
return {
backgroundStyle,
backgroundColor: rawColor
};
}
function parseXoppTemplateSpec(xml: string, fileName: string): TemplateSpec {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "application/xml");
const pageEl = doc.querySelector("page");
if (!pageEl) throw new Error("Invalid template: missing <page> element.");
const widthPt = parseFloat(pageEl.getAttribute("width") ?? "0");
const heightPt = parseFloat(pageEl.getAttribute("height") ?? "0");
const { preset: pageSizePreset, widthMm, heightMm, orientation } = detectPageSizePreset(widthPt, heightPt);
const bgEl = pageEl.querySelector("background");
const bg = bgEl
? parseBackground(bgEl)
: {
backgroundStyle: "plain" as TemplateBackgroundStyle,
backgroundColor: "#ffffffff",
spacingMm: undefined,
marginMm: undefined,
};
const baseName = fileName.replace(/^.*\//, "").replace(/\.xopp$/i, "");
const spec: TemplateSpec = {
name: baseName,
pageSizePreset,
orientation,
backgroundStyle: bg.backgroundStyle,
backgroundColor: bg.backgroundColor,
};
if (pageSizePreset === "Custom") {
spec.customWidthMm = widthMm;
spec.customHeightMm = heightMm;
}
return spec;
}
export async function parseTemplateFile(
plugin: XoppPlugin,
templatePath: string,
onFinished: () => void
): Promise<void> {
const file = plugin.app.vault.getAbstractFileByPath(templatePath);
if (!file || !(file instanceof TFile)) {
new Notice("Template file not found.");
console.error("Template file not found:", templatePath);
return;
}
const { xml, isGzipped } = await readXoppXml(plugin, file);
const spec: TemplateSpec = parseXoppTemplateSpec(xml, file.name);
spec.gzip = isGzipped;
new ParsedTemplateEditing(plugin.app, plugin, spec, onFinished).open();
}
export async function fetchTemplates(plugin: XoppPlugin): Promise<TFile[]> {
const folderPath = plugin.settings.templatesFolder?.trim();
if (!folderPath) {
return [];
}
const folder = plugin.app.vault.getAbstractFileByPath(folderPath);
if (!folder || !(folder instanceof TFolder)) {
return [];
}
const templates = folder.children.filter(
(child) => child instanceof TFile && child.name.endsWith(".xopp")
) as TFile[];
return templates;
}

View file

@ -2,6 +2,8 @@ import XoppPlugin from "main";
import { App, Setting, PluginSettingTab, TFile } from "obsidian";
import ConfirmationModal from "./modals/ConfirmationModal";
import { exportAllXoppToPDF } from "src/xopp2pdf";
import TemplateCreationModal from "src/modals/TemplateCreationModal";
import TemplateEditingModal from "./modals/TemplateEditingModal";
export class XoppSettingsTab extends PluginSettingTab {
plugin: XoppPlugin;
@ -16,18 +18,6 @@ export class XoppSettingsTab extends PluginSettingTab {
containerEl.empty();
const templatesFolder = this.plugin.settings.templatesFolder?.trim();
let templateFiles: TFile[] = [];
if (templatesFolder) {
templateFiles = this.app.vault
.getFiles()
.filter(
(f: TFile) =>
f.path.startsWith(templatesFolder + "/") &&
f.extension === "xopp"
);
}
new Setting(containerEl)
.setName("Auto export Xournal++ files")
.setDesc(
@ -85,21 +75,6 @@ export class XoppSettingsTab extends PluginSettingTab {
});
});
// new Setting(containerEl)
// .setName("Xournal++ template path")
// .setDesc(
// "The relative path of the template for any new Xournal++ file (leave empty to use the default template)."
// )
// .addText((toggle) => {
// toggle
// .setValue(this.plugin.settings.templatePath)
// .setPlaceholder("e.g. templates/template.xopp")
// .onChange(async (value) => {
// this.plugin.settings.templatePath = value;
// await this.plugin.saveSettings();
// });
// });
new Setting(containerEl)
.setName("Xournal++ templates folder")
.setDesc(
@ -131,6 +106,19 @@ export class XoppSettingsTab extends PluginSettingTab {
"The default template to use when creating new Xournal++ files from the templates folder."
)
.addDropdown((dropdown) => {
const templatesFolder =
this.plugin.settings.templatesFolder?.trim();
let templateFiles: TFile[] = [];
if (templatesFolder) {
templateFiles = this.app.vault
.getFiles()
.filter(
(f: TFile) =>
f.path.startsWith(templatesFolder + "/") &&
f.extension === "xopp"
);
}
if (templateFiles.length === 0) {
dropdown.addOption("", "No templates found");
} else {
@ -146,6 +134,44 @@ export class XoppSettingsTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName("Create a new Xournal++ template")
.setDesc(
"Create a new Xournal++ template file in the templates folder."
)
.addButton((button) => {
button
.setButtonText("Create Template")
.setCta()
.onClick(async () => {
new TemplateCreationModal(
this.app,
this.plugin,
async (createdPath) => {
if (!this.plugin.settings.defaultTemplatePath) {
this.plugin.settings.defaultTemplatePath =
createdPath;
}
await this.plugin.saveSettings();
this.display();
}
).open();
});
});
new Setting(containerEl)
.setName("Edit existing Xournal++ templates")
.setDesc("A GUI to edit or delete existing Xournal++ templates.")
.addButton((button) => {
button
.setButtonText("Manage Templates")
.setCta()
.onClick(async () => {
new TemplateEditingModal(this.app, this.plugin).open();
});
});
new Setting(containerEl)
.setName("Default path for new Xournal++ files")
.setDesc(

View file

@ -0,0 +1,177 @@
import XoppPlugin from "main";
import { App, ButtonComponent, Modal, Setting } from "obsidian";
import { createTemplate } from "src/TemplateCreationModalManager";
export type TemplateBackgroundStyle =
| "plain"
| "lined"
| "ruled"
| "staves"
| "graph"
| "dotted"
| "isodotted"
| "isograph";
export interface TemplateSpec {
name: string;
pageSizePreset:
| "A3"
| "A4"
| "A5"
| "US Letter"
| "US Legal"
| "16x9"
| "4x3"
| "Custom";
customWidthMm?: number;
customHeightMm?: number;
orientation: "portrait" | "landscape";
backgroundStyle: TemplateBackgroundStyle;
backgroundColor: string;
gzip? : boolean;
}
export default class TemplateCreationModal extends Modal {
plugin: XoppPlugin;
onCreated: (vaultPath: string) => void;
constructor(
app: App,
plugin: XoppPlugin,
onCreated: (vaultPath: string) => void
) {
super(app);
this.plugin = plugin;
this.onCreated = onCreated;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", { text: "Create Xournal++ template" });
const spec: TemplateSpec = {
name: "",
pageSizePreset: "A4",
orientation: "portrait",
backgroundStyle: "plain",
backgroundColor: "#ffffff",
gzip: false,
};
const doCreate = async () => {
try {
const vaultPath = await createTemplate(this.plugin, spec);
this.onCreated(vaultPath);
this.close();
} catch (e) {
console.error(e);
}
};
new Setting(contentEl)
.setName("Template Name")
.setDesc("Name of the new template file")
.addText((text) =>
text.onChange((value) => {
spec.name = value.trim();
})
);
new Setting(contentEl).setName("Page Size").addDropdown((dropdown) => {
dropdown
.addOption("A3", "A3")
.addOption("A4", "A4")
.addOption("A5", "A5")
.addOption("US Letter", "US Letter")
.addOption("US Legal", "US Legal")
.addOption("16x9", "16x9")
.addOption("4x3", "4x3")
.addOption("Custom", "Custom")
.onChange((value) => {
spec.pageSizePreset =
value as TemplateSpec["pageSizePreset"];
});
});
new Setting(contentEl)
.setName("Orientation")
.addDropdown((dropdown) => {
dropdown
.addOption("portrait", "Portrait")
.addOption("landscape", "Landscape")
.onChange((value) => {
spec.orientation = value as TemplateSpec["orientation"];
});
});
new Setting(contentEl)
.setName("Background Style")
.addDropdown((dropdown) => {
dropdown
.addOption("plain", "Plain")
.addOption("lined", "Lined")
.addOption("ruled", "Ruled")
.addOption("staves", "Staves")
.addOption("graph", "Graph")
.addOption("dotted", "Dotted")
.addOption("isodotted", "Isometric dotted")
.addOption("isograph", "Isometric graph")
.onChange((value) => {
spec.backgroundStyle =
value as TemplateSpec["backgroundStyle"];
});
});
new Setting(contentEl)
.setName("Background Color")
.setDesc("Hex color, e.g. #ffffff")
.addText((text) =>
text.setValue("#ffffff").onChange((value) => {
spec.backgroundColor = value.trim();
})
);
new Setting(contentEl)
.setName("Compress as gzipped .xopp")
.setDesc("Whether to gzip compress the template file")
.addToggle((toggle) =>
toggle.setValue(!!spec.gzip).onChange((value) => {
spec.gzip = value;
})
);
const buttonRow = contentEl.createDiv({
cls: "xopp-creation-editing-template-button-row",
});
new ButtonComponent(buttonRow)
.setButtonText("Create")
.setCta()
.onClick(async () => {
doCreate();
this.close();
});
new ButtonComponent(buttonRow)
.setButtonText("Cancel")
.onClick(() => this.close());
this.modalEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (!e.shiftKey) {
doCreate();
this.close();
}
}
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,161 @@
import XoppPlugin from "main";
import { App, ButtonComponent, Modal, Setting } from "obsidian";
import {
parseTemplateFile,
fetchTemplates,
} from "src/TemplateEditingModalManager";
import { TemplateSpec } from "./TemplateCreationModal";
import { createTemplate } from "src/TemplateCreationModalManager";
export default class TemplateEditingModal extends Modal {
plugin: XoppPlugin;
constructor(app: App, plugin: XoppPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", { text: "Edit Xournal++ templates" });
new Setting(contentEl)
.setName("Choose a template")
.addDropdown(async (dropdown) => {
const templates = await fetchTemplates(this.plugin);
if (templates.length === 0) {
dropdown.addOption("", "No templates found");
} else {
templates.forEach((template) => {
dropdown.addOption(template.path, template.name);
});
}
dropdown.setValue("");
dropdown.onChange(async (value) => {
await parseTemplateFile(this.plugin, value, () => { this.close()});
});
});
}
}
export class ParsedTemplateEditing extends Modal {
plugin: XoppPlugin;
initialSpec: TemplateSpec;
onFinished: () => void;
constructor(app: App, plugin: XoppPlugin, templateSpec: TemplateSpec, onFinished: () => void) {
super(app);
this.plugin = plugin;
this.initialSpec = templateSpec;
this.onFinished = onFinished;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", {
text: `Editing template: ${this.initialSpec.name}`,
});
const spec: TemplateSpec = { ...this.initialSpec };
new Setting(contentEl).setName("Page Size").addDropdown((dropdown) => {
dropdown
.addOption("A3", "A3")
.addOption("A4", "A4")
.addOption("A5", "A5")
.addOption("US Letter", "US Letter")
.addOption("US Legal", "US Legal")
.addOption("16x9", "16x9")
.addOption("4x3", "4x3")
.addOption("Custom", "Custom")
.setValue(this.initialSpec.pageSizePreset)
.onChange((value) => {
spec.pageSizePreset =
value as TemplateSpec["pageSizePreset"];
});
});
new Setting(contentEl)
.setName("Orientation")
.addDropdown((dropdown) => {
dropdown
.addOption("portrait", "Portrait")
.addOption("landscape", "Landscape")
.setValue(this.initialSpec.orientation)
.onChange((value) => {
spec.orientation = value as TemplateSpec["orientation"];
});
});
new Setting(contentEl)
.setName("Background Style")
.addDropdown((dropdown) => {
dropdown
.addOption("plain", "Plain")
.addOption("lined", "Lined")
.addOption("ruled", "Ruled")
.addOption("staves", "Staves")
.addOption("graph", "Graph")
.addOption("dotted", "Dotted")
.addOption("isodotted", "Isometric dotted")
.addOption("isograph", "Isometric graph")
.setValue(this.initialSpec.backgroundStyle)
.onChange((value) => {
spec.backgroundStyle =
value as TemplateSpec["backgroundStyle"];
});
});
new Setting(contentEl).setName("Background color").addText((text) => {
text.setPlaceholder("#ffffffff")
.setValue(spec.backgroundColor || "")
.onChange((value) => {
spec.backgroundColor = value.trim();
});
});
new Setting(contentEl)
.setName("Compress as gzipped .xopp")
.setDesc("Whether to gzip compress the template file")
.addToggle((toggle) =>
toggle.setValue(!!spec.gzip).onChange((value) => {
spec.gzip = value;
})
);
const buttonRow = contentEl.createDiv({
cls: "xopp-creation-editing-template-button-row",
});
new ButtonComponent(buttonRow)
.setButtonText("Confirm Edits")
.setCta()
.onClick(async () => {
await createTemplate(this.plugin, spec);
this.close();
this.onFinished();
});
new ButtonComponent(buttonRow)
.setButtonText("Cancel")
.onClick(() => this.close());
this.modalEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (!e.shiftKey) {
createTemplate(this.plugin, spec);
this.close();
this.onFinished();
}
}
});
}
}

View file

@ -71,6 +71,7 @@ export default class XoppFileNameModal extends Modal {
fileName = i;
});
textComponent.inputEl.focus();
// Using a timeout to ensure the focus is set after the modal is fully rendered.
setTimeout(() => textComponent.inputEl.focus(), 0);

View file

@ -17,3 +17,9 @@
.clickable-tag {
cursor: pointer;
}
.xopp-creation-editing-template-button-row {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}