feat(snippets): streamline snippet management and introduce text transformation features

This commit is contained in:
JK 2026-04-24 18:28:17 +02:00
parent d1d809f029
commit 80f7042a57
6 changed files with 223 additions and 312 deletions

View file

@ -1,126 +1,51 @@
# LaTeX Snippets
# Command Snippets
Create reusable LaTeX code snippets for quick insertion via command palette.
Use command palette snippets for fast note metadata and text transformations.
## Overview
## Available Commands
The snippet system allows you to:
### Add Tags
- Save frequently used LaTeX code
- Insert snippets via command palette
- Each snippet becomes its own command
Adds this frontmatter block at the top of the active note:
## Managing Snippets
```yaml
---
tags:
-
aliases:
-
---
```
### Adding a Snippet
1. Open **Settings****LaTeX Equation Referencer** → **Snippets**
2. Click **Add Snippet**
3. Enter:
- **Name**: Display name (e.g., "Matrix 2x2")
- **Content**: The LaTeX code to insert
### Editing a Snippet
Click the pencil icon next to any snippet to edit its name or content.
### Deleting a Snippet
Click the trash icon next to a snippet and confirm deletion.
!!! note "Reload Required"
After adding or editing snippets, you may need to reload Obsidian for command palette changes to take effect.
## Using Snippets
### Main Snippet Command
How to use:
1. Open Command Palette (`Ctrl/Cmd + P`)
2. Search for "Insert LaTeX Snippet"
3. Select from the suggestion modal
4. Snippet content is inserted at cursor
2. Search for "Add Tags"
3. Run the command
### Individual Snippet Commands
Behavior:
Each snippet also creates its own command:
- Inserts the frontmatter block at the top of the file
- If frontmatter already exists at the top of the note, no change is made
- Command name: `Insert Snippet: <snippet name>`
- Example: `Insert Snippet: Matrix 2x2`
### Run Text Transform Snippet
This allows you to:
Applies a built-in transformation to the selected text, or to the current line if no text is selected.
- Assign hotkeys to specific snippets
- Access frequently used snippets directly
How to use:
## Example Snippets
1. Open Command Palette (`Ctrl/Cmd + P`)
2. Search for "Run Text Transform Snippet"
3. Choose a transform from the list
### Fraction Template
```latex
\frac{numerator}{denominator}
```
Built-in transforms:
### Matrix 2×2
```latex
\begin{pmatrix}
a & b \\
c & d
\end{pmatrix}
```
- Kebab Case (`my-selected-text`)
- Title Kebab Case (`My-Selected-Text`)
- Title Case (`My Selected Text`)
- Clean Zotero Highlight Line (`<mark>...</mark>` to quote + source format)
### Aligned Equations
```latex
\begin{aligned}
a &= b + c \\
d &= e + f
\end{aligned}
```
## Notes
### Summation
```latex
\sum_{i=1}^{n} x_i
```
### Integral
```latex
\int_{a}^{b} f(x) \, dx
```
### Equation with ID Template
```latex
$$
% equation here
% id: eq-
$$
```
### Greek Letters Set
```latex
\alpha, \beta, \gamma, \delta, \epsilon, \theta, \lambda, \mu, \pi, \sigma, \omega
```
## Snippet Tips
### Cursor Placement
Currently, snippets insert content as-is. For multi-part snippets, use placeholder text:
```latex
\frac{NUM}{DEN}
```
### Complex Snippets
For multi-line content, the full text is inserted. Example output:
```latex
\begin{cases}
x & \text{if } x > 0 \\
-x & \text{if } x \leq 0
\end{cases}
```
### Integration with Equations
Combine snippets with the equation system:
1. Insert equation template snippet
2. Fill in the math content
3. Add your `% id: eq-xxx` comment
4. Reference with `\eqref` autocomplete
- Multi-cursor selections are supported for text transforms
- Transform commands show a notice indicating whether changes were applied

View file

@ -60,19 +60,6 @@ export interface PluginSettings {
debug: boolean;
enabledCss: boolean;
concurrency: string;
// Snippets
snippets: Snippet[];
}
export interface Snippet {
id: string;
name: string;
content: string;
replacement?: string; // Optional: for future regex replacement support
options?: {
mode?: "insert" | "replace"; // Future proofing
};
}
export const DEFAULT_SETTINGS: Required<PluginSettings> = {
@ -129,7 +116,4 @@ export const DEFAULT_SETTINGS: Required<PluginSettings> = {
debug: false,
enabledCss: false,
concurrency: "5",
// Snippets
snippets: [],
};

View file

@ -1,7 +1,6 @@
import { App, PluginSettingTab, Setting, TextAreaComponent } from "obsidian";
import LatexReferencer from "../../main";
import { NUMBER_STYLES } from "./settings";
import { SnippetEditModal } from "../snippets/modal";
export class MathSettingTab extends PluginSettingTab {
constructor(app: App, public plugin: LatexReferencer) {
@ -285,46 +284,6 @@ export class MathSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
});
// Snippets Section
containerEl.createEl("h2", { text: "Snippets" });
new Setting(containerEl)
.setName("Manage Snippets")
.setDesc("Add or edit your LaTeX snippets.")
.addButton(btn => btn
.setButtonText("Add Snippet")
.setCta()
.onClick(() => {
new SnippetEditModal(this.plugin.app, async (result) => {
await this.plugin.snippetManager.addSnippet(result);
this.display(); // Refresh settings
}).open();
}));
// List existing snippets
for (const snippet of this.plugin.settings.snippets) {
new Setting(containerEl)
.setName(snippet.name)
.setDesc(snippet.content.length > 50 ? snippet.content.substring(0, 50) + "..." : snippet.content)
.addButton(btn => btn
.setIcon("pencil")
.setTooltip("Edit")
.onClick(() => {
new SnippetEditModal(this.plugin.app, async (updated) => {
await this.plugin.snippetManager.updateSnippet(snippet.id, updated);
this.display();
}, snippet).open();
}))
.addButton(btn => btn
.setIcon("trash")
.setTooltip("Delete")
.setWarning()
.onClick(async () => {
await this.plugin.snippetManager.deleteSnippet(snippet.id);
this.display();
}));
}
}
}

View file

@ -1,69 +1,34 @@
import { MarkdownView } from "obsidian";
import { Notice } from "obsidian";
import type LatexReferencer from "../../main";
import type { Snippet } from "../settings/settings";
import { SnippetSuggestModal } from "./modal";
import { TextTransformSuggestModal } from "./modal";
export class SnippetManager {
constructor(private plugin: LatexReferencer) { }
async addSnippet(snippet: Snippet) {
this.plugin.settings.snippets.push(snippet);
await this.plugin.saveSettings();
this.registerSnippetCommand(snippet);
}
async updateSnippet(id: string, updatedSnippet: Snippet) {
const index = this.plugin.settings.snippets.findIndex(s => s.id === id);
if (index !== -1) {
this.plugin.settings.snippets[index] = updatedSnippet;
await this.plugin.saveSettings();
// Re-register command likely requires a reload or sophisticated command management
// For now, prompt user to reload or rely on simple command overwriting if ID is stable?
// Obsidian doesn't easily allow unregistering commands dynamically without private APIs.
// However, we can update the callback if we keep track of it, but easiest is to just
// keep using the ID. Since ID is typically stable, the command ID won't change.
// But the Name might change.
}
}
async deleteSnippet(id: string) {
const index = this.plugin.settings.snippets.findIndex(s => s.id === id);
if (index !== -1) {
this.plugin.settings.snippets.splice(index, 1);
await this.plugin.saveSettings();
// Again, dynamic unregistering is hard.
}
}
getSnippets(): Snippet[] {
return this.plugin.settings.snippets;
}
registerSnippetCommand(snippet: Snippet) {
const commandId = `insert-snippet-${snippet.id}`;
this.plugin.addCommand({
id: commandId,
name: `Insert Snippet: ${snippet.name}`,
editorCallback: (editor, view) => {
editor.replaceSelection(snippet.content);
}
});
}
onLoad() {
// Register main command
this.plugin.addCommand({
id: "insert-latex-snippet",
name: "Insert LaTeX Snippet",
callback: () => {
new SnippetSuggestModal(this.plugin.app, this.plugin).open();
id: "add-tags-frontmatter",
name: "Add Tags",
editorCallback: (editor) => {
const content = editor.getValue().replace(/\r\n/g, "\n");
const frontmatterPattern = /^---\n[\s\S]*?\n---\n?/;
if (frontmatterPattern.test(content)) {
new Notice("Frontmatter already exists at the top of this note.");
return;
}
const tagsFrontmatter = "---\ntags:\n - \naliases:\n - \n---\n\n";
editor.replaceRange(tagsFrontmatter, { line: 0, ch: 0 });
new Notice("Added tags frontmatter.");
}
});
// Register individual commands
for (const snippet of this.plugin.settings.snippets) {
this.registerSnippetCommand(snippet);
}
this.plugin.addCommand({
id: "run-text-transform-snippet",
name: "Run Text Transform Snippet",
editorCallback: (editor) => {
new TextTransformSuggestModal(this.plugin.app, editor).open();
}
});
}
}

View file

@ -1,101 +1,31 @@
import { App, FuzzySuggestModal, Modal, Setting, Notice, MarkdownView } from "obsidian";
import type { Snippet } from "../settings/settings";
import type LatexReferencer from "../../main";
import { App, Editor, FuzzyMatch, FuzzySuggestModal, Notice } from "obsidian";
import { BUILTIN_TEXT_TRANSFORM_SNIPPETS, runTextTransformSnippet, TextTransformSnippet } from "./transforms";
export class SnippetSuggestModal extends FuzzySuggestModal<Snippet> {
constructor(app: App, private plugin: LatexReferencer) {
export class TextTransformSuggestModal extends FuzzySuggestModal<TextTransformSnippet> {
constructor(app: App, private editor: Editor) {
super(app);
this.setPlaceholder("Select a snippet to insert...");
this.setPlaceholder("Select a text transform snippet...");
}
getItems(): Snippet[] {
return this.plugin.settings.snippets;
getItems(): TextTransformSnippet[] {
return BUILTIN_TEXT_TRANSFORM_SNIPPETS;
}
getItemText(item: Snippet): string {
return item.name;
getItemText(item: TextTransformSnippet): string {
return `${item.name} ${item.keywords.join(" ")}`;
}
onChooseItem(item: Snippet, evt: MouseEvent | KeyboardEvent): void {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
const editor = view.editor;
editor.replaceSelection(item.content);
renderSuggestion(item: FuzzyMatch<TextTransformSnippet>, el: HTMLElement): void {
el.createEl("div", { text: item.item.name });
el.createEl("small", { text: item.item.description });
}
onChooseItem(item: TextTransformSnippet, evt: MouseEvent | KeyboardEvent): void {
const result = runTextTransformSnippet(this.editor, item);
if (result.changedCount > 0) {
new Notice(`Applied ${item.name} to ${result.changedCount} ${result.appliedOn}(s).`);
return;
}
}
}
export class SnippetEditModal extends Modal {
name: string;
content: string;
editingSnippet: Snippet | null = null;
onSubmit: (result: Snippet) => void;
constructor(app: App, onSubmit: (result: Snippet) => void, snippet: Snippet | null = null) {
super(app);
this.onSubmit = onSubmit;
this.editingSnippet = snippet;
this.name = snippet?.name || "";
this.content = snippet?.content || "";
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: this.editingSnippet ? "Edit Snippet" : "Add Snippet" });
new Setting(contentEl)
.setName("Name")
.setDesc("The name of the snippet (used for searching and command palette)")
.addText(text => text
.setValue(this.name)
.onChange(value => {
this.name = value;
}));
const contentSetting = new Setting(contentEl)
.setName("Content")
.setDesc("The LaTeX content to insert");
// Use a larger text area for content
const textArea = contentSetting.controlEl.createEl("textarea", {
attr: {
rows: "5",
cols: "50",
style: "width: 100%; min-height: 150px; font-family: monospace;"
}
});
textArea.value = this.content;
textArea.addEventListener("input", (e) => {
this.content = (e.target as HTMLTextAreaElement).value;
});
// Align textarea properly
contentSetting.settingEl.style.display = "block";
contentSetting.infoEl.style.marginBottom = "10px";
new Setting(contentEl)
.addButton(btn => btn
.setButtonText("Save")
.setCta()
.onClick(() => {
if (!this.name || !this.content) {
new Notice("Name and content are required.");
return;
}
this.onSubmit({
id: this.editingSnippet?.id || crypto.randomUUID(),
name: this.name,
content: this.content,
options: this.editingSnippet?.options
});
this.close();
}));
}
onClose() {
const { contentEl } = this;
contentEl.empty();
new Notice(`${item.name} made no changes to the current ${result.appliedOn}.`);
}
}

View file

@ -0,0 +1,148 @@
import type { Editor, EditorPosition, EditorSelection } from "obsidian";
export interface TextTransformSnippet {
id: string;
name: string;
description: string;
keywords: string[];
transform: (input: string) => string;
}
export interface TextTransformResult {
changedCount: number;
appliedOn: "selection" | "line";
}
function comparePositionsDesc(a: EditorPosition, b: EditorPosition): number {
if (a.line !== b.line) {
return b.line - a.line;
}
return b.ch - a.ch;
}
function isSelectionEmpty(selection: EditorSelection): boolean {
return selection.anchor.line === selection.head.line && selection.anchor.ch === selection.head.ch;
}
function getSelectionRange(selection: EditorSelection): { from: EditorPosition; to: EditorPosition } {
const anchorFirst =
selection.anchor.line < selection.head.line ||
(selection.anchor.line === selection.head.line && selection.anchor.ch <= selection.head.ch);
return anchorFirst
? { from: selection.anchor, to: selection.head }
: { from: selection.head, to: selection.anchor };
}
function normalizeWords(input: string): string[] {
const trimmed = input.replace(/^[^a-zA-Z0-9]+/, "").replace(/[^a-zA-Z0-9]+$/, "");
if (!trimmed) {
return [];
}
return trimmed.split(/[^a-zA-Z0-9]+/g).filter(Boolean);
}
function toKebabCase(input: string): string {
return normalizeWords(input).map((w) => w.toLowerCase()).join("-");
}
function toTitleKebabCase(input: string): string {
return normalizeWords(input)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join("-");
}
function toTitleCase(input: string): string {
return normalizeWords(input)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
function cleanZoteroHighlightLine(input: string): string {
return input.replace(
/<mark[^>]*>\s*[\u0022\u201C\u201D]?(.*?)[\u0022\u201C\u201D]?\s*<\/mark>\s*(.*)/g,
(_match, highlightedText: string, trailingText: string) => {
const normalized = highlightedText.replace(/[\.,]$/, "");
return `\"${normalized}.\" \u2014 ${trailingText}`;
},
);
}
export const BUILTIN_TEXT_TRANSFORM_SNIPPETS: TextTransformSnippet[] = [
{
id: "kebab-case",
name: "Kebab Case",
description: "my-selected-text",
keywords: ["kebab", "slug", "lower"],
transform: toKebabCase,
},
{
id: "title-kebab-case",
name: "Title Kebab Case",
description: "My-Selected-Text",
keywords: ["title kebab", "slug", "dash"],
transform: toTitleKebabCase,
},
{
id: "title-case",
name: "Title Case",
description: "My Selected Text",
keywords: ["title", "heading", "capitalize"],
transform: toTitleCase,
},
{
id: "clean-zotero-highlight-line",
name: "Clean Zotero Highlight Line",
description: "Format <mark>...</mark> highlights into quote + source",
keywords: ["zotero", "mark", "highlight", "citation"],
transform: cleanZoteroHighlightLine,
},
];
export function runTextTransformSnippet(editor: Editor, snippet: TextTransformSnippet): TextTransformResult {
const allSelections = editor.listSelections();
const nonEmptySelections = allSelections.filter((selection) => !isSelectionEmpty(selection));
if (nonEmptySelections.length > 0) {
const ranges = nonEmptySelections
.map((selection) => getSelectionRange(selection))
.sort((a, b) => comparePositionsDesc(a.from, b.from));
let changedCount = 0;
for (const range of ranges) {
const original = editor.getRange(range.from, range.to);
const transformed = snippet.transform(original);
if (transformed !== original) {
editor.replaceRange(transformed, range.from, range.to);
changedCount += 1;
}
}
return {
changedCount,
appliedOn: "selection",
};
}
const cursor = editor.getCursor();
const lineNumber = cursor.line;
const originalLine = editor.getLine(lineNumber);
const transformedLine = snippet.transform(originalLine);
if (transformedLine !== originalLine) {
editor.replaceRange(
transformedLine,
{ line: lineNumber, ch: 0 },
{ line: lineNumber, ch: originalLine.length },
);
return {
changedCount: 1,
appliedOn: "line",
};
}
return {
changedCount: 0,
appliedOn: "line",
};
}