diff --git a/docs/features/snippets.md b/docs/features/snippets.md index 2ce07d5..d86ffe0 100644 --- a/docs/features/snippets.md +++ b/docs/features/snippets.md @@ -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: ` -- 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 (`...` 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 diff --git a/src/features/settings/settings.ts b/src/features/settings/settings.ts index f5485fd..707e007 100644 --- a/src/features/settings/settings.ts +++ b/src/features/settings/settings.ts @@ -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 = { @@ -129,7 +116,4 @@ export const DEFAULT_SETTINGS: Required = { debug: false, enabledCss: false, concurrency: "5", - - // Snippets - snippets: [], }; diff --git a/src/features/settings/tab.ts b/src/features/settings/tab.ts index d348f26..787c1e5 100644 --- a/src/features/settings/tab.ts +++ b/src/features/settings/tab.ts @@ -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(); - })); - } } } diff --git a/src/features/snippets/manager.ts b/src/features/snippets/manager.ts index 59520a1..68e980b 100644 --- a/src/features/snippets/manager.ts +++ b/src/features/snippets/manager.ts @@ -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(); + } + }); } } diff --git a/src/features/snippets/modal.ts b/src/features/snippets/modal.ts index 964d075..d0affec 100644 --- a/src/features/snippets/modal.ts +++ b/src/features/snippets/modal.ts @@ -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 { - constructor(app: App, private plugin: LatexReferencer) { +export class TextTransformSuggestModal extends FuzzySuggestModal { + 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, 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}.`); } } diff --git a/src/features/snippets/transforms.ts b/src/features/snippets/transforms.ts new file mode 100644 index 0000000..c7ce6d5 --- /dev/null +++ b/src/features/snippets/transforms.ts @@ -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( + /]*>\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 ... 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", + }; +}