From 9295e8055af22c26f9cc9ba1603f3de3a6976520 Mon Sep 17 00:00:00 2001 From: Sayuop Date: Wed, 26 Nov 2025 10:14:58 +0100 Subject: [PATCH] Initial commit: Counter plugin for Obsidian - Add counter syntax: ~ (number) label - Implement interactive +/- buttons - Add CSS styling for counter UI - Include build configuration and dependencies --- .claude/settings.local.json | 11 +++ .gitignore | 17 ++++ README.md | 58 ++++++++++++++ esbuild.config.mjs | 48 ++++++++++++ main.ts | 150 ++++++++++++++++++++++++++++++++++++ manifest.json | 10 +++ package.json | 28 +++++++ styles.css | 61 +++++++++++++++ tsconfig.json | 24 ++++++ 9 files changed, 407 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 package.json create mode 100644 styles.css create mode 100644 tsconfig.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..cc8e03b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(git init:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2549c4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Node +node_modules +package-lock.json + +# Build +main.js +*.js.map + +# macOS +.DS_Store + +# IDE +.vscode +.idea + +# Obsidian +data.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc64846 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Counter Plugin for Obsidian + +A simple Obsidian plugin that creates interactive number counters with +/- buttons. + +## Usage + +To create a counter, use the following syntax in your notes: + +``` +~ (0) counter label +``` + +This will render as an interactive counter starting at 0, with minus and plus buttons to decrease or increase the value. + +## Examples + +``` +~ (0) Tasks completed +~ (5) Days streak +~ (100) Points earned +``` + +Each counter is independent and maintains its own value in your markdown file. + +## Installation + +### From Release + +1. Download `main.js`, `manifest.json`, and `styles.css` from the latest release +2. Create a folder named `counter-plugin` in your vault's `.obsidian/plugins/` directory +3. Copy the downloaded files into this folder +4. Reload Obsidian +5. Enable the plugin in Settings → Community plugins + +### Manual Installation + +1. Clone this repository into your vault's `.obsidian/plugins/` directory +2. Run `npm install` to install dependencies +3. Run `npm run build` to build the plugin +4. Reload Obsidian +5. Enable the plugin in Settings → Community plugins + +## Development + +```bash +# Install dependencies +npm install + +# Build the plugin +npm run build + +# Development mode (auto-rebuild on changes) +npm run dev +``` + +## License + +MIT diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..e45242b --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,48 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === 'production'); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ['main.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtins], + format: 'cjs', + target: 'es2018', + logLevel: "info", + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..64e3caf --- /dev/null +++ b/main.ts @@ -0,0 +1,150 @@ +import { Plugin, MarkdownPostProcessorContext, MarkdownView } from 'obsidian'; + +export default class CounterPlugin extends Plugin { + async onload() { + console.log('Loading Counter Plugin'); + + this.registerMarkdownPostProcessor((element, context) => { + this.processCounters(element, context); + }); + + this.registerEvent( + this.app.workspace.on('editor-change', () => { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (view) { + // Refresh the preview to update counters + view.previewMode.rerender(true); + } + }) + ); + } + + onunload() { + console.log('Unloading Counter Plugin'); + } + + processCounters(element: HTMLElement, context: MarkdownPostProcessorContext) { + const counterRegex = /^~\s*\(\s*(\d+)\s*\)\s*(.*)$/; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const nodesToReplace: Array<{ node: Node; parent: Node }> = []; + + let node; + while ((node = walker.nextNode())) { + const text = node.textContent || ''; + const lines = text.split('\n'); + + lines.forEach((line, index) => { + const match = line.match(counterRegex); + if (match) { + nodesToReplace.push({ node, parent: node.parentNode! }); + } + }); + } + + nodesToReplace.forEach(({ node, parent }) => { + const text = node.textContent || ''; + const lines = text.split('\n'); + const fragment = document.createDocumentFragment(); + + lines.forEach((line, index) => { + const match = line.match(counterRegex); + + if (match) { + const currentValue = parseInt(match[1], 10); + const label = match[2].trim(); + + const counterContainer = this.createCounterElement( + currentValue, + label, + node, + context + ); + + fragment.appendChild(counterContainer); + } else { + if (line) { + fragment.appendChild(document.createTextNode(line)); + } + } + + if (index < lines.length - 1) { + fragment.appendChild(document.createTextNode('\n')); + } + }); + + parent.replaceChild(fragment, node); + }); + } + + createCounterElement( + value: number, + label: string, + sourceNode: Node, + context: MarkdownPostProcessorContext + ): HTMLElement { + const container = document.createElement('div'); + container.className = 'counter-container'; + + const minusButton = document.createElement('button'); + minusButton.className = 'counter-button counter-minus'; + minusButton.textContent = '−'; + minusButton.setAttribute('aria-label', 'Decrease counter'); + + const counterDisplay = document.createElement('span'); + counterDisplay.className = 'counter-display'; + counterDisplay.textContent = value.toString(); + + const plusButton = document.createElement('button'); + plusButton.className = 'counter-button counter-plus'; + plusButton.textContent = '+'; + plusButton.setAttribute('aria-label', 'Increase counter'); + + const labelSpan = document.createElement('span'); + labelSpan.className = 'counter-label'; + labelSpan.textContent = label; + + const updateSource = (newValue: number) => { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view) return; + + const editor = view.editor; + const content = editor.getValue(); + const counterRegex = /^~\s*\(\s*\d+\s*\)\s*(.*)$/gm; + + let matchIndex = 0; + const newContent = content.replace(counterRegex, (match, capturedLabel) => { + const currentLabel = capturedLabel.trim(); + if (currentLabel === label) { + matchIndex++; + return `~ (${newValue}) ${label}`; + } + return match; + }); + + if (content !== newContent) { + editor.setValue(newContent); + } + }; + + minusButton.addEventListener('click', () => { + const newValue = value - 1; + counterDisplay.textContent = newValue.toString(); + updateSource(newValue); + }); + + plusButton.addEventListener('click', () => { + const newValue = value + 1; + counterDisplay.textContent = newValue.toString(); + updateSource(newValue); + }); + + container.appendChild(minusButton); + container.appendChild(counterDisplay); + container.appendChild(plusButton); + if (label) { + container.appendChild(labelSpan); + } + + return container; + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..67343a6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "counter-plugin", + "name": "Counter Plugin", + "version": "1.0.0", + "minAppVersion": "0.15.0", + "description": "Create interactive counters with +/- buttons using ~ ( ) syntax", + "author": "crib", + "authorUrl": "https://git.cribdev.com/crib", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ec004a --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "obsidian-counter-plugin", + "version": "1.0.0", + "description": "A simple counter plugin for Obsidian that creates interactive number counters with +/- buttons", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [ + "obsidian", + "plugin", + "counter" + ], + "author": "crib", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "5.29.0", + "@typescript-eslint/parser": "5.29.0", + "builtin-modules": "3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..55bad88 --- /dev/null +++ b/styles.css @@ -0,0 +1,61 @@ +.counter-container { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background-color: var(--background-secondary); + border-radius: 6px; + margin: 4px 0; + font-family: var(--font-interface); +} + +.counter-button { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--background-modifier-border); + background-color: var(--interactive-normal); + color: var(--text-normal); + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.counter-button:hover { + background-color: var(--interactive-hover); + border-color: var(--interactive-accent); +} + +.counter-button:active { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + transform: scale(0.95); +} + +.counter-display { + min-width: 32px; + text-align: center; + font-weight: 600; + font-size: 14px; + color: var(--text-normal); + font-variant-numeric: tabular-nums; +} + +.counter-label { + margin-left: 4px; + color: var(--text-muted); + font-size: 14px; +} + +.counter-minus { + line-height: 1; +} + +.counter-plus { + line-height: 1; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c44b729 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ] + }, + "include": [ + "**/*.ts" + ] +}