update and fix
Some checks failed
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled

This commit is contained in:
2026-05-15 16:23:28 -04:00
parent 358ddca2f0
commit fcbf1492b7
26 changed files with 8596 additions and 8272 deletions

View File

@@ -1,28 +1,28 @@
name: Node.js build name: Node.js build
on: on:
push: push:
branches: ["**"] branches: ["**"]
pull_request: pull_request:
branches: ["**"] branches: ["**"]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [20.x, 22.x] node-version: [20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: "npm" cache: "npm"
- run: npm ci - run: npm ci
- run: npm run build --if-present - run: npm run build --if-present
- run: npm run lint - run: npm run lint

502
AGENTS.md
View File

@@ -1,251 +1,251 @@
# Obsidian community plugin # Obsidian community plugin
## Project overview ## Project overview
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript). - Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian. - Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`. - Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
## Environment & tooling ## Environment & tooling
- Node.js: use current LTS (Node 18+ recommended). - Node.js: use current LTS (Node 18+ recommended).
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies). - **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`. - **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
- Types: `obsidian` type definitions. - Types: `obsidian` type definitions.
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly. **Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
### Install ### Install
```bash ```bash
npm install npm install
``` ```
### Dev (watch) ### Dev (watch)
```bash ```bash
npm run dev npm run dev
``` ```
### Production build ### Production build
```bash ```bash
npm run build npm run build
``` ```
## Linting ## Linting
- To use eslint install eslint from terminal: `npm install -g eslint` - To use eslint install eslint from terminal: `npm install -g eslint`
- To use eslint to analyze this project use this command: `eslint main.ts` - To use eslint to analyze this project use this command: `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number. - eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/` - If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
## File & folder conventions ## File & folder conventions
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`. - **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands). - Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
- **Example file structure**: - **Example file structure**:
``` ```
src/ src/
main.ts # Plugin entry point, lifecycle management main.ts # Plugin entry point, lifecycle management
settings.ts # Settings interface and defaults settings.ts # Settings interface and defaults
commands/ # Command implementations commands/ # Command implementations
command1.ts command1.ts
command2.ts command2.ts
ui/ # UI components, modals, views ui/ # UI components, modals, views
modal.ts modal.ts
view.ts view.ts
utils/ # Utility functions, helpers utils/ # Utility functions, helpers
helpers.ts helpers.ts
constants.ts constants.ts
types.ts # TypeScript interfaces and types types.ts # TypeScript interfaces and types
``` ```
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control. - **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages. - Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`). - Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
## Manifest rules (`manifest.json`) ## Manifest rules (`manifest.json`)
- Must include (non-exhaustive): - Must include (non-exhaustive):
- `id` (plugin ID; for local dev it should match the folder name) - `id` (plugin ID; for local dev it should match the folder name)
- `name` - `name`
- `version` (Semantic Versioning `x.y.z`) - `version` (Semantic Versioning `x.y.z`)
- `minAppVersion` - `minAppVersion`
- `description` - `description`
- `isDesktopOnly` (boolean) - `isDesktopOnly` (boolean)
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map) - Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
- Never change `id` after release. Treat it as stable API. - Never change `id` after release. Treat it as stable API.
- Keep `minAppVersion` accurate when using newer APIs. - Keep `minAppVersion` accurate when using newer APIs.
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml - Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
## Testing ## Testing
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to: - Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
``` ```
<Vault>/.obsidian/plugins/<plugin-id>/ <Vault>/.obsidian/plugins/<plugin-id>/
``` ```
- Reload Obsidian and enable the plugin in **Settings → Community plugins**. - Reload Obsidian and enable the plugin in **Settings → Community plugins**.
## Commands & settings ## Commands & settings
- Any user-facing commands should be added via `this.addCommand(...)`. - Any user-facing commands should be added via `this.addCommand(...)`.
- If the plugin has configuration, provide a settings tab and sensible defaults. - If the plugin has configuration, provide a settings tab and sensible defaults.
- Persist settings using `this.loadData()` / `this.saveData()`. - Persist settings using `this.loadData()` / `this.saveData()`.
- Use stable command IDs; avoid renaming once released. - Use stable command IDs; avoid renaming once released.
## Versioning & releases ## Versioning & releases
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version. - Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`. - Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets. - Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
- After the initial release, follow the process to add/update your plugin in the community catalog as required. - After the initial release, follow the process to add/update your plugin in the community catalog as required.
## Security, privacy, and compliance ## Security, privacy, and compliance
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular: Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
- Default to local/offline operation. Only make network requests when essential to the feature. - Default to local/offline operation. Only make network requests when essential to the feature.
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings. - No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases. - Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault. - Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
- Clearly disclose any external services used, data sent, and risks. - Clearly disclose any external services used, data sent, and risks.
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented. - Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
- Avoid deceptive patterns, ads, or spammy notifications. - Avoid deceptive patterns, ads, or spammy notifications.
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely. - Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
## UX & copy guidelines (for UI text, commands, settings) ## UX & copy guidelines (for UI text, commands, settings)
- Prefer sentence case for headings, buttons, and titles. - Prefer sentence case for headings, buttons, and titles.
- Use clear, action-oriented imperatives in step-by-step copy. - Use clear, action-oriented imperatives in step-by-step copy.
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions. - Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
- Use arrow notation for navigation: **Settings → Community plugins**. - Use arrow notation for navigation: **Settings → Community plugins**.
- Keep in-app strings short, consistent, and free of jargon. - Keep in-app strings short, consistent, and free of jargon.
## Performance ## Performance
- Keep startup light. Defer heavy work until needed. - Keep startup light. Defer heavy work until needed.
- Avoid long-running tasks during `onload`; use lazy initialization. - Avoid long-running tasks during `onload`; use lazy initialization.
- Batch disk access and avoid excessive vault scans. - Batch disk access and avoid excessive vault scans.
- Debounce/throttle expensive operations in response to file system events. - Debounce/throttle expensive operations in response to file system events.
## Coding conventions ## Coding conventions
- TypeScript with `"strict": true` preferred. - TypeScript with `"strict": true` preferred.
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules. - **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules. - **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility. - **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
- Bundle everything into `main.js` (no unbundled runtime deps). - Bundle everything into `main.js` (no unbundled runtime deps).
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly. - Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
- Prefer `async/await` over promise chains; handle errors gracefully. - Prefer `async/await` over promise chains; handle errors gracefully.
## Mobile ## Mobile
- Where feasible, test on iOS and Android. - Where feasible, test on iOS and Android.
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`. - Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
- Avoid large in-memory structures; be mindful of memory and storage constraints. - Avoid large in-memory structures; be mindful of memory and storage constraints.
## Agent do/don't ## Agent do/don't
**Do** **Do**
- Add commands with stable IDs (don't rename once released). - Add commands with stable IDs (don't rename once released).
- Provide defaults and validation in settings. - Provide defaults and validation in settings.
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals. - Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
- Use `this.register*` helpers for everything that needs cleanup. - Use `this.register*` helpers for everything that needs cleanup.
**Don't** **Don't**
- Introduce network calls without an obvious user-facing reason and documentation. - Introduce network calls without an obvious user-facing reason and documentation.
- Ship features that require cloud services without clear disclosure and explicit opt-in. - Ship features that require cloud services without clear disclosure and explicit opt-in.
- Store or transmit vault contents unless essential and consented. - Store or transmit vault contents unless essential and consented.
## Common tasks ## Common tasks
### Organize code across multiple files ### Organize code across multiple files
**main.ts** (minimal, lifecycle only): **main.ts** (minimal, lifecycle only):
```ts ```ts
import { Plugin } from "obsidian"; import { Plugin } from "obsidian";
import { MySettings, DEFAULT_SETTINGS } from "./settings"; import { MySettings, DEFAULT_SETTINGS } from "./settings";
import { registerCommands } from "./commands"; import { registerCommands } from "./commands";
export default class MyPlugin extends Plugin { export default class MyPlugin extends Plugin {
settings: MySettings; settings: MySettings;
async onload() { async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
registerCommands(this); registerCommands(this);
} }
} }
``` ```
**settings.ts**: **settings.ts**:
```ts ```ts
export interface MySettings { export interface MySettings {
enabled: boolean; enabled: boolean;
apiKey: string; apiKey: string;
} }
export const DEFAULT_SETTINGS: MySettings = { export const DEFAULT_SETTINGS: MySettings = {
enabled: true, enabled: true,
apiKey: "", apiKey: "",
}; };
``` ```
**commands/index.ts**: **commands/index.ts**:
```ts ```ts
import { Plugin } from "obsidian"; import { Plugin } from "obsidian";
import { doSomething } from "./my-command"; import { doSomething } from "./my-command";
export function registerCommands(plugin: Plugin) { export function registerCommands(plugin: Plugin) {
plugin.addCommand({ plugin.addCommand({
id: "do-something", id: "do-something",
name: "Do something", name: "Do something",
callback: () => doSomething(plugin), callback: () => doSomething(plugin),
}); });
} }
``` ```
### Add a command ### Add a command
```ts ```ts
this.addCommand({ this.addCommand({
id: "your-command-id", id: "your-command-id",
name: "Do the thing", name: "Do the thing",
callback: () => this.doTheThing(), callback: () => this.doTheThing(),
}); });
``` ```
### Persist settings ### Persist settings
```ts ```ts
interface MySettings { enabled: boolean } interface MySettings { enabled: boolean }
const DEFAULT_SETTINGS: MySettings = { enabled: true }; const DEFAULT_SETTINGS: MySettings = { enabled: true };
async onload() { async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
await this.saveData(this.settings); await this.saveData(this.settings);
} }
``` ```
### Register listeners safely ### Register listeners safely
```ts ```ts
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ })); this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
this.registerDomEvent(window, "resize", () => { /* ... */ }); this.registerDomEvent(window, "resize", () => { /* ... */ });
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000)); this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
``` ```
## Troubleshooting ## Troubleshooting
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`. - Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code. - Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique. - Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes. - Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust. - Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
## References ## References
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin - Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
- API documentation: https://docs.obsidian.md - API documentation: https://docs.obsidian.md
- Developer policies: https://docs.obsidian.md/Developer+policies - Developer policies: https://docs.obsidian.md/Developer+policies
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines - Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
- Style guide: https://help.obsidian.md/style-guide - Style guide: https://help.obsidian.md/style-guide

View File

@@ -1,5 +1,5 @@
Copyright (C) 2020-2025 by Dynalist Inc. Copyright (C) 2020-2025 by Dynalist Inc.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

390
README.md
View File

@@ -1,196 +1,196 @@
# BindThem # BindThem
A comprehensive Obsidian plugin that merges the best features from obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler into a single, unified solution. Enhanced editor shortcuts and formatting tools for power users. A comprehensive Obsidian plugin that merges the best features from obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler into a single, unified solution. Enhanced editor shortcuts and formatting tools for power users.
## Features ## Features
### Better Formatting ### Better Formatting
Smart text wrapping that intelligently detects word boundaries and existing formatting. Toggle formatting on and off with a single command. Smart text wrapping that intelligently detects word boundaries and existing formatting. Toggle formatting on and off with a single command.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Toggle Bold (underscores) | Wrap or unwrap selection with `__` | | Toggle Bold (underscores) | Wrap or unwrap selection with `__` |
| Toggle Bold (asterisks) | Wrap or unwrap selection with `**` | | Toggle Bold (asterisks) | Wrap or unwrap selection with `**` |
| Toggle Italics (underscore) | Wrap or unwrap selection with `_` | | Toggle Italics (underscore) | Wrap or unwrap selection with `_` |
| Toggle Italics (asterisk) | Wrap or unwrap selection with `*` | | Toggle Italics (asterisk) | Wrap or unwrap selection with `*` |
| Toggle Code | Wrap or unwrap selection with backticks | | Toggle Code | Wrap or unwrap selection with backticks |
| Toggle Comment | Wrap or unwrap selection with `%%` | | Toggle Comment | Wrap or unwrap selection with `%%` |
| Toggle Highlight | Wrap or unwrap selection with `==` | | Toggle Highlight | Wrap or unwrap selection with `==` |
| Toggle Strikethrough | Wrap or unwrap selection with `~~` | | Toggle Strikethrough | Wrap or unwrap selection with `~~` |
| Toggle Math (Inline) | Wrap or unwrap selection with `$` | | Toggle Math (Inline) | Wrap or unwrap selection with `$` |
| Toggle Math (Block) | Wrap or unwrap selection with `$$` | | Toggle Math (Block) | Wrap or unwrap selection with `$$` |
### Directional Copy & Move ### Directional Copy & Move
Copy or move text in any direction without using the clipboard. Copy or move text in any direction without using the clipboard.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Copy Up | Duplicate the current line(s) above | | Copy Up | Duplicate the current line(s) above |
| Copy Down | Duplicate the current line(s) below | | Copy Down | Duplicate the current line(s) below |
| Copy Left | Duplicate the current selection(s) to the left | | Copy Left | Duplicate the current selection(s) to the left |
| Copy Right | Duplicate the current selection(s) to the right | | Copy Right | Duplicate the current selection(s) to the right |
| Move Left | Shift the current selection one character left | | Move Left | Shift the current selection one character left |
| Move Right | Shift the current selection one character right | | Move Right | Shift the current selection one character right |
### Heading Management ### Heading Management
Toggle heading levels H1 through H6 with intelligent detection. If the line already has the target heading level, the heading is removed. Toggle heading levels H1 through H6 with intelligent detection. If the line already has the target heading level, the heading is removed.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Toggle Heading 1-6 | Apply or remove heading level | | Toggle Heading 1-6 | Apply or remove heading level |
| Toggle Heading 1-6 (Strip Formatting) | Apply heading and remove bold/italic formatting from content | | Toggle Heading 1-6 (Strip Formatting) | Apply heading and remove bold/italic formatting from content |
### Line Operations ### Line Operations
Essential line manipulation commands for efficient editing. Essential line manipulation commands for efficient editing.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Insert Line Above | Create a new line above the current line, preserving indentation | | Insert Line Above | Create a new line above the current line, preserving indentation |
| Insert Line Below | Create a new line below the current line, preserving indentation | | Insert Line Below | Create a new line below the current line, preserving indentation |
| Delete Line | Remove the current line entirely | | Delete Line | Remove the current line entirely |
| Delete to Start of Line | Remove all text from cursor to the beginning of the line | | Delete to Start of Line | Remove all text from cursor to the beginning of the line |
| Delete to End of Line | Remove all text from cursor to the end of the line | | Delete to End of Line | Remove all text from cursor to the end of the line |
| Join Lines | Merge the current line with the next line | | Join Lines | Merge the current line with the next line |
| Duplicate Line | Copy the current line and insert it below | | Duplicate Line | Copy the current line and insert it below |
| Copy Line Up | Duplicate the current line above (VSCode style) | | Copy Line Up | Duplicate the current line above (VSCode style) |
| Copy Line Down | Duplicate the current line below (VSCode style) | | Copy Line Down | Duplicate the current line below (VSCode style) |
### Selection Commands ### Selection Commands
Quick selection tools for text manipulation. Quick selection tools for text manipulation.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Select Word | Select the word under the cursor | | Select Word | Select the word under the cursor |
| Select Line | Select the entire current line | | Select Line | Select the entire current line |
### Case Transformation ### Case Transformation
Transform text case with various options. Transform text case with various options.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Transform to Uppercase | Convert selection to UPPERCASE | | Transform to Uppercase | Convert selection to UPPERCASE |
| Transform to Lowercase | Convert selection to lowercase | | Transform to Lowercase | Convert selection to lowercase |
| Transform to Title Case | Convert selection to Title Case | | Transform to Title Case | Convert selection to Title Case |
| Toggle Case | Cycle through: UPPERCASE -> lowercase -> Title Case | | Toggle Case | Cycle through: UPPERCASE -> lowercase -> Title Case |
### Navigation ### Navigation
Jump around your document quickly. Jump around your document quickly.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Go to Line Start | Move cursor to the beginning of the current line | | Go to Line Start | Move cursor to the beginning of the current line |
| Go to Line End | Move cursor to the end of the current line | | Go to Line End | Move cursor to the end of the current line |
| Go to First Line | Jump to the first line of the document | | Go to First Line | Jump to the first line of the document |
| Go to Last Line | Jump to the last line of the document | | Go to Last Line | Jump to the last line of the document |
| Go to Line Number | Open a modal to enter a specific line number | | Go to Line Number | Open a modal to enter a specific line number |
| Go to Next Heading | Jump to the next heading in the document | | Go to Next Heading | Jump to the next heading in the document |
| Go to Previous Heading | Jump to the previous heading in the document | | Go to Previous Heading | Jump to the previous heading in the document |
### Cursor Movement ### Cursor Movement
Basic cursor movement commands. Basic cursor movement commands.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Move Cursor Up | Move cursor up one line | | Move Cursor Up | Move cursor up one line |
| Move Cursor Down | Move cursor down one line | | Move Cursor Down | Move cursor down one line |
| Move Cursor Left | Move cursor left one character | | Move Cursor Left | Move cursor left one character |
| Move Cursor Right | Move cursor right one character | | Move Cursor Right | Move cursor right one character |
| Go to Previous Word | Move cursor to the start of the previous word | | Go to Previous Word | Move cursor to the start of the previous word |
| Go to Next Word | Move cursor to the start of the next word | | Go to Next Word | Move cursor to the start of the next word |
### Multi-Cursor Support ### Multi-Cursor Support
Work with multiple cursors simultaneously. Work with multiple cursors simultaneously.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Insert Cursor Above | Add a cursor on the line above | | Insert Cursor Above | Add a cursor on the line above |
| Insert Cursor Below | Add a cursor on the line below | | Insert Cursor Below | Add a cursor on the line below |
### File Operations ### File Operations
Quick file management commands. Quick file management commands.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Duplicate File | Create a copy of the current file in the same folder | | Duplicate File | Create a copy of the current file in the same folder |
| New Adjacent File | Create a new file in the same folder as the current file | | New Adjacent File | Create a new file in the same folder as the current file |
### Sentence Navigation ### Sentence Navigation
Navigate and manipulate text at the sentence level. Perfect for prose editing and working with paragraph-style content. Navigate and manipulate text at the sentence level. Perfect for prose editing and working with paragraph-style content.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Delete to start of sentence | Remove all text from cursor to the beginning of the current sentence | | Delete to start of sentence | Remove all text from cursor to the beginning of the current sentence |
| Delete to end of sentence | Remove all text from cursor to the end of the current sentence | | Delete to end of sentence | Remove all text from cursor to the end of the current sentence |
| Select to start of sentence | Select text from cursor to the beginning of the current sentence | | Select to start of sentence | Select text from cursor to the beginning of the current sentence |
| Select to end of sentence | Select text from cursor to the end of the current sentence | | Select to end of sentence | Select text from cursor to the end of the current sentence |
| Move to start of current sentence | Jump the cursor to the beginning of the current sentence | | Move to start of current sentence | Jump the cursor to the beginning of the current sentence |
| Move to start of next sentence | Jump the cursor to the beginning of the next sentence | | Move to start of next sentence | Jump the cursor to the beginning of the next sentence |
| Select current sentence | Select the entire sentence where the cursor is located | | Select current sentence | Select the entire sentence where the cursor is located |
### Utility Commands ### Utility Commands
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| Toggle Line Numbers | Show or hide line numbers in the editor | | Toggle Line Numbers | Show or hide line numbers in the editor |
| Undo | Undo the last action | | Undo | Undo the last action |
| Redo | Redo the last undone action | | Redo | Redo the last undone action |
## Installation ## Installation
### From Obsidian ### From Obsidian
1. Open Settings > Community plugins 1. Open Settings > Community plugins
2. Disable Safe mode if enabled 2. Disable Safe mode if enabled
3. Click on "Browse" community plugins 3. Click on "Browse" community plugins
4. Search for "BindThem" 4. Search for "BindThem"
5. Click Install, then Enable 5. Click Install, then Enable
### Manual Installation ### Manual Installation
1. Download the latest release from the releases page 1. Download the latest release from the releases page
2. Extract the files to your vault's `.obsidian/plugins/bindThem/` folder 2. Extract the files to your vault's `.obsidian/plugins/bindThem/` folder
3. Reload Obsidian 3. Reload Obsidian
4. Enable the plugin in Settings > Community plugins 4. Enable the plugin in Settings > Community plugins
## Configuration ## Configuration
After enabling the plugin, go to Settings > BindThem to access: After enabling the plugin, go to Settings > BindThem to access:
- **Debug mode**: Enable detailed logging for troubleshooting - **Debug mode**: Enable detailed logging for troubleshooting
## Customizing Keyboard Shortcuts ## Customizing Keyboard Shortcuts
All commands can be assigned custom keyboard shortcuts: All commands can be assigned custom keyboard shortcuts:
1. Go to Settings > Hotkeys 1. Go to Settings > Hotkeys
2. Search for "BindThem" 2. Search for "BindThem"
3. Click the `+` icon next to any command 3. Click the `+` icon next to any command
4. Press your desired key combination 4. Press your desired key combination
## Credits ## Credits
This plugin merges and refactors functionality from: This plugin merges and refactors functionality from:
- **obsidian-tweaks** - Better formatting, directional copy/move, file operations - **obsidian-tweaks** - Better formatting, directional copy/move, file operations
- **obsidian-editor-shortcuts** - Line operations, case transformation, navigation, multi-cursor support - **obsidian-editor-shortcuts** - Line operations, case transformation, navigation, multi-cursor support
- **heading-toggler** - Heading toggle with formatting strip - **heading-toggler** - Heading toggle with formatting strip
- **obsidian-sentence-navigator** - Sentence-level navigation and manipulation - **obsidian-sentence-navigator** - Sentence-level navigation and manipulation
## License ## License
MIT License - See LICENSE file for details. MIT License - See LICENSE file for details.
## Support ## Support
If you encounter any issues or have feature requests, please open an issue on the GitHub repository. If you encounter any issues or have feature requests, please open an issue on the GitHub repository.

View File

@@ -1,64 +1,64 @@
import esbuild from "esbuild"; import esbuild from "esbuild";
import process from "process"; import process from "process";
const banner = const banner =
`/* `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin if you want to view the source, please visit the github repository of this plugin
*/ */
`; `;
const prod = (process.argv[2] === 'production'); const prod = (process.argv[2] === 'production');
const context = await esbuild.context({ const context = await esbuild.context({
banner: { banner: {
js: banner, js: banner,
}, },
entryPoints: [import.meta.dirname + '/src/main.ts'], entryPoints: [import.meta.dirname + '/src/main.ts'],
bundle: true, bundle: true,
external: [ external: [
'obsidian', 'obsidian',
'electron', 'electron',
'@codemirror/autocomplete', '@codemirror/autocomplete',
'@codemirror/collab', '@codemirror/collab',
'@codemirror/commands', '@codemirror/commands',
'@codemirror/language', '@codemirror/language',
'@codemirror/lint', '@codemirror/lint',
'@codemirror/search', '@codemirror/search',
'@codemirror/state', '@codemirror/state',
'@codemirror/view', '@codemirror/view',
'@lezer/common', '@lezer/common',
'@lezer/highlight', '@lezer/highlight',
'@lezer/lr', '@lezer/lr',
'child_process', 'child_process',
'fs', 'fs',
'path', 'path',
'os', 'os',
'util', 'util',
'stream', 'stream',
'events', 'events',
'buffer', 'buffer',
'crypto', 'crypto',
'http', 'http',
'https', 'https',
'url', 'url',
'zlib', 'zlib',
'tls', 'tls',
'net', 'net',
'readline', 'readline',
'querystring', 'querystring',
'string_decoder'], 'string_decoder'],
format: 'cjs', format: 'cjs',
target: 'es2018', target: 'es2018',
logLevel: "info", logLevel: "info",
sourcemap: prod ? false : 'inline', sourcemap: prod ? false : 'inline',
treeShaking: true, treeShaking: true,
outfile: import.meta.dirname + '/main.js', outfile: import.meta.dirname + '/main.js',
}); });
if (prod) { if (prod) {
await context.rebuild(); await context.rebuild();
process.exit(0); process.exit(0);
} else { } else {
await context.watch(); await context.watch();
} }

View File

@@ -1,34 +1,34 @@
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import obsidianmd from "eslint-plugin-obsidianmd"; import obsidianmd from "eslint-plugin-obsidianmd";
import globals from "globals"; import globals from "globals";
import { globalIgnores } from "eslint/config"; import { globalIgnores } from "eslint/config";
export default tseslint.config( export default tseslint.config(
{ {
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
}, },
parserOptions: { parserOptions: {
projectService: { projectService: {
allowDefaultProject: [ allowDefaultProject: [
'eslint.config.js', 'eslint.config.js',
'manifest.json' 'manifest.json'
] ]
}, },
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json'] extraFileExtensions: ['.json']
}, },
}, },
}, },
...obsidianmd.configs.recommended, ...obsidianmd.configs.recommended,
globalIgnores([ globalIgnores([
"node_modules", "node_modules",
"dist", "dist",
"esbuild.config.mjs", "esbuild.config.mjs",
"eslint.config.js", "eslint.config.js",
"version-bump.mjs", "version-bump.mjs",
"versions.json", "versions.json",
"main.js", "main.js",
]), ]),
); );

View File

@@ -1,9 +1,9 @@
{ {
"id": "bindthem", "id": "bindthem",
"name": "BindThem", "name": "BindThem",
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Enhanced editor shortcuts and formatting tools. Combines smart text wrapping (bold, italic, code, etc.), directional copy/move, heading toggles (H1-H6), line operations, case transformation, multi-cursor support, and file utilities into one unified plugin.", "description": "Enhanced editor shortcuts and formatting tools. Combines smart text wrapping (bold, italic, code, etc.), directional copy/move, heading toggles (H1-H6), line operations, case transformation, multi-cursor support, and file utilities into one unified plugin.",
"author": "Olivier Legendre", "author": "Olivier Legendre",
"isDesktopOnly": false "isDesktopOnly": false
} }

10320
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,29 @@
{ {
"name": "bindthem", "name": "bindthem",
"version": "1.0.0", "version": "1.0.0",
"description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.", "description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json", "version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ." "lint": "eslint ."
}, },
"keywords": ["obsidian", "plugin", "editor", "shortcuts", "formatting"], "keywords": ["obsidian", "plugin", "editor", "shortcuts", "formatting"],
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"esbuild": "0.25.5", "esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9", "eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0", "globals": "14.0.0",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "8.35.1", "typescript-eslint": "8.35.1",
"@eslint/js": "9.30.1", "@eslint/js": "9.30.1",
"jiti": "2.6.1" "jiti": "2.6.1"
}, },
"dependencies": { "dependencies": {
"obsidian": "latest" "obsidian": "latest"
} }
} }

View File

@@ -1,298 +1,298 @@
import { import {
App, App,
Editor, Editor,
EditorChange, EditorChange,
EditorPosition, EditorPosition,
EditorRange, EditorRange,
EditorRangeOrCaret, EditorRangeOrCaret,
EditorSelection, EditorSelection,
EditorTransaction, EditorTransaction,
MarkdownFileInfo, MarkdownFileInfo,
MarkdownView, MarkdownView,
} from 'obsidian' } from 'obsidian'
import BindThemPlugin from './main' import BindThemPlugin from './main'
import { getMainSelection, selectionToRange } from './Utils' import { getMainSelection, selectionToRange } from './Utils'
import { PRE_WIDOW_EXPAND_CHARS, POST_WIDOW_EXPAND_CHARS } from './Constants' import { PRE_WIDOW_EXPAND_CHARS, POST_WIDOW_EXPAND_CHARS } from './Constants'
/** /**
* BetterFormatting provides enhanced markdown formatting toggling * BetterFormatting provides enhanced markdown formatting toggling
* that intelligently expands selections to word boundaries and * that intelligently expands selections to word boundaries and
* handles widow characters. * handles widow characters.
*/ */
export class BetterFormatting { export class BetterFormatting {
public app: App public app: App
private plugin: BindThemPlugin private plugin: BindThemPlugin
constructor(app: App, plugin: BindThemPlugin) { constructor(app: App, plugin: BindThemPlugin) {
this.app = app this.app = app
this.plugin = plugin this.plugin = plugin
} }
/** /**
* Expand a range by words, including intelligent handling of widow characters * Expand a range by words, including intelligent handling of widow characters
*/ */
private expandRangeByWords( private expandRangeByWords(
editor: Editor, editor: Editor,
range: EditorRange, range: EditorRange,
symbolStart: string, symbolStart: string,
symbolEnd: string symbolEnd: string
): EditorRange { ): EditorRange {
// Expand to word boundaries // Expand to word boundaries
let from = (editor.wordAt(range.from) || range).from let from = (editor.wordAt(range.from) || range).from
let to = (editor.wordAt(range.to) || range).to let to = (editor.wordAt(range.to) || range).to
// Include leading 'widows' (characters that should be included before the word) // Include leading 'widows' (characters that should be included before the word)
while (true) { while (true) {
const newFrom: EditorPosition = { const newFrom: EditorPosition = {
line: from.line, line: from.line,
ch: from.ch - 1, ch: from.ch - 1,
} }
const newTo: EditorPosition = { const newTo: EditorPosition = {
line: to.line, line: to.line,
ch: to.ch + 1, ch: to.ch + 1,
} }
const preChar = editor.getRange(newFrom, from) const preChar = editor.getRange(newFrom, from)
const postChar = editor.getRange(to, newTo) const postChar = editor.getRange(to, newTo)
// Don't do this if widows form a matched pair // Don't do this if widows form a matched pair
if (preChar === postChar) { if (preChar === postChar) {
break break
} }
if (symbolStart.endsWith(preChar)) { if (symbolStart.endsWith(preChar)) {
break break
} }
if (!PRE_WIDOW_EXPAND_CHARS.includes(preChar)) { if (!PRE_WIDOW_EXPAND_CHARS.includes(preChar)) {
break break
} }
from = newFrom from = newFrom
} }
// Include trailing 'widows' // Include trailing 'widows'
while (true) { while (true) {
const newFrom: EditorPosition = { const newFrom: EditorPosition = {
line: from.line, line: from.line,
ch: from.ch - 1, ch: from.ch - 1,
} }
const newTo: EditorPosition = { const newTo: EditorPosition = {
line: to.line, line: to.line,
ch: to.ch + 1, ch: to.ch + 1,
} }
const preChar = editor.getRange(newFrom, from) const preChar = editor.getRange(newFrom, from)
const postChar = editor.getRange(to, newTo) const postChar = editor.getRange(to, newTo)
// Don't do this if widows form a matched pair // Don't do this if widows form a matched pair
if (preChar === postChar) { if (preChar === postChar) {
break break
} }
if (symbolEnd.startsWith(postChar)) { if (symbolEnd.startsWith(postChar)) {
break break
} }
if (!POST_WIDOW_EXPAND_CHARS.includes(postChar)) { if (!POST_WIDOW_EXPAND_CHARS.includes(postChar)) {
break break
} }
to = newTo to = newTo
} }
// Include leading symbolStart // Include leading symbolStart
const newFrom = { line: from.line, ch: from.ch - symbolStart.length } const newFrom = { line: from.line, ch: from.ch - symbolStart.length }
const preString = editor.getRange(newFrom, from) const preString = editor.getRange(newFrom, from)
if (preString === symbolStart) { if (preString === symbolStart) {
from = newFrom from = newFrom
} }
// Include following symbolEnd // Include following symbolEnd
const newTo = { line: to.line, ch: to.ch + symbolEnd.length } const newTo = { line: to.line, ch: to.ch + symbolEnd.length }
const postString = editor.getRange(to, newTo) const postString = editor.getRange(to, newTo)
if (postString === symbolEnd) { if (postString === symbolEnd) {
to = newTo to = newTo
} }
return { return {
from: from, from: from,
to: to, to: to,
} }
} }
/** /**
* Check if text in the given range is already wrapped with the specified symbols * Check if text in the given range is already wrapped with the specified symbols
*/ */
private isWrapped( private isWrapped(
editor: Editor, editor: Editor,
range: EditorRange, range: EditorRange,
symbolStart: string, symbolStart: string,
symbolEnd: string symbolEnd: string
): boolean { ): boolean {
const text = editor.getRange(range.from, range.to) const text = editor.getRange(range.from, range.to)
return text.startsWith(symbolStart) && text.endsWith(symbolEnd) return text.startsWith(symbolStart) && text.endsWith(symbolEnd)
} }
/** /**
* Create changes to wrap text with symbols * Create changes to wrap text with symbols
*/ */
private wrap( private wrap(
editor: Editor, editor: Editor,
textRange: EditorRange, textRange: EditorRange,
selection: EditorSelection, selection: EditorSelection,
symbolStart: string, symbolStart: string,
symbolEnd: string symbolEnd: string
): [Array<EditorChange>, EditorSelection] { ): [Array<EditorChange>, EditorSelection] {
const changes: Array<EditorChange> = [ const changes: Array<EditorChange> = [
{ {
from: textRange.from, from: textRange.from,
text: symbolStart, text: symbolStart,
}, },
{ {
from: textRange.to, from: textRange.to,
text: symbolEnd, text: symbolEnd,
}, },
] ]
const newSelection: EditorSelection = { const newSelection: EditorSelection = {
anchor: { ...selection.anchor }, anchor: { ...selection.anchor },
head: { ...selection.head }, head: { ...selection.head },
} }
newSelection.anchor.ch += symbolStart.length newSelection.anchor.ch += symbolStart.length
newSelection.head.ch += symbolEnd.length newSelection.head.ch += symbolEnd.length
return [changes, newSelection] return [changes, newSelection]
} }
/** /**
* Create changes to unwrap text from symbols * Create changes to unwrap text from symbols
*/ */
private unwrap( private unwrap(
editor: Editor, editor: Editor,
textRange: EditorRange, textRange: EditorRange,
selection: EditorSelection, selection: EditorSelection,
symbolStart: string, symbolStart: string,
symbolEnd: string symbolEnd: string
): [Array<EditorChange>, EditorSelection] { ): [Array<EditorChange>, EditorSelection] {
const changes: Array<EditorChange> = [ const changes: Array<EditorChange> = [
{ {
from: textRange.from, from: textRange.from,
to: { to: {
line: textRange.from.line, line: textRange.from.line,
ch: textRange.from.ch + symbolStart.length, ch: textRange.from.ch + symbolStart.length,
}, },
text: '', text: '',
}, },
{ {
from: { from: {
line: textRange.to.line, line: textRange.to.line,
ch: textRange.to.ch - symbolEnd.length, ch: textRange.to.ch - symbolEnd.length,
}, },
to: textRange.to, to: textRange.to,
text: '', text: '',
}, },
] ]
const newSelection: EditorSelection = { const newSelection: EditorSelection = {
anchor: { ...selection.anchor }, anchor: { ...selection.anchor },
head: { ...selection.head }, head: { ...selection.head },
} }
newSelection.anchor.ch -= symbolStart.length newSelection.anchor.ch -= symbolStart.length
newSelection.head.ch -= symbolStart.length newSelection.head.ch -= symbolStart.length
return [changes, newSelection] return [changes, newSelection]
} }
/** /**
* Set the wrap state for a selection * Set the wrap state for a selection
*/ */
private setSelectionWrapState( private setSelectionWrapState(
editor: Editor, editor: Editor,
selection: EditorSelection, selection: EditorSelection,
wrapState: boolean, wrapState: boolean,
symbolStart: string, symbolStart: string,
symbolEnd: string symbolEnd: string
): [Array<EditorChange>, EditorSelection] { ): [Array<EditorChange>, EditorSelection] {
const initialRange = selectionToRange(selection) const initialRange = selectionToRange(selection)
const textRange: EditorRange = this.expandRangeByWords( const textRange: EditorRange = this.expandRangeByWords(
editor, editor,
initialRange, initialRange,
symbolStart, symbolStart,
symbolEnd symbolEnd
) )
// Check if already wrapped // Check if already wrapped
const alreadyWrapped = this.isWrapped(editor, textRange, symbolStart, symbolEnd) const alreadyWrapped = this.isWrapped(editor, textRange, symbolStart, symbolEnd)
if (alreadyWrapped === wrapState) { if (alreadyWrapped === wrapState) {
return [[], selection] return [[], selection]
} }
// Wrap or unwrap // Wrap or unwrap
if (wrapState) { if (wrapState) {
return this.wrap(editor, textRange, selection, symbolStart, symbolEnd) return this.wrap(editor, textRange, selection, symbolStart, symbolEnd)
} }
return this.unwrap(editor, textRange, selection, symbolStart, symbolEnd) return this.unwrap(editor, textRange, selection, symbolStart, symbolEnd)
} }
/** /**
* Toggle wrapper symbols around the current selection(s) * Toggle wrapper symbols around the current selection(s)
* *
* Principle: Toggling twice == no-op * Principle: Toggling twice == no-op
*/ */
public toggleWrapper( public toggleWrapper(
editor: Editor, editor: Editor,
view: MarkdownView | MarkdownFileInfo, view: MarkdownView | MarkdownFileInfo,
symbolStart: string, symbolStart: string,
symbolEnd?: string symbolEnd?: string
): void { ): void {
if (symbolEnd === undefined) { if (symbolEnd === undefined) {
symbolEnd = symbolStart symbolEnd = symbolStart
} }
const selections = editor.listSelections() const selections = editor.listSelections()
const mainSelection = getMainSelection(editor) const mainSelection = getMainSelection(editor)
// Get wrapped state of main selection // Get wrapped state of main selection
const mainRange: EditorRange = this.expandRangeByWords( const mainRange: EditorRange = this.expandRangeByWords(
editor, editor,
selectionToRange(mainSelection), selectionToRange(mainSelection),
symbolStart, symbolStart,
symbolEnd symbolEnd
) )
// Check if already wrapped // Check if already wrapped
const isWrapped = this.isWrapped(editor, mainRange, symbolStart, symbolEnd) const isWrapped = this.isWrapped(editor, mainRange, symbolStart, symbolEnd)
const targetWrapState = !isWrapped const targetWrapState = !isWrapped
// Process all selections // Process all selections
const newChanges: Array<EditorChange> = [] const newChanges: Array<EditorChange> = []
const newSelectionRanges: Array<EditorRangeOrCaret> = [] const newSelectionRanges: Array<EditorRangeOrCaret> = []
for (const selection of selections) { for (const selection of selections) {
const [changes, newSelection] = this.setSelectionWrapState( const [changes, newSelection] = this.setSelectionWrapState(
editor, editor,
selection, selection,
targetWrapState, targetWrapState,
symbolStart, symbolStart,
symbolEnd symbolEnd
) )
newChanges.push(...changes) newChanges.push(...changes)
newSelectionRanges.push(selectionToRange(newSelection)) newSelectionRanges.push(selectionToRange(newSelection))
} }
const transaction: EditorTransaction = { const transaction: EditorTransaction = {
changes: newChanges, changes: newChanges,
selections: newSelectionRanges, selections: newSelectionRanges,
} }
let origin = targetWrapState ? '+' : '-' let origin = targetWrapState ? '+' : '-'
origin += 'BetterFormatting_' + symbolStart + symbolEnd origin += 'BetterFormatting_' + symbolStart + symbolEnd
editor.transaction(transaction, origin) editor.transaction(transaction, origin)
} }
} }

View File

@@ -1,42 +1,42 @@
// ============================================================ // ============================================================
// Debug Constants // Debug Constants
// ============================================================ // ============================================================
export const DEBUG_HEAD = '[BindThem] ' export const DEBUG_HEAD = '[BindThem] '
// ============================================================ // ============================================================
// Better Formatting Constants // Better Formatting Constants
// ============================================================ // ============================================================
/** /**
* Characters to expand selection before wrapper symbols * Characters to expand selection before wrapper symbols
*/ */
export const PRE_WIDOW_EXPAND_CHARS = '#$@' export const PRE_WIDOW_EXPAND_CHARS = '#$@'
/** /**
* Characters to expand selection after wrapper symbols * Characters to expand selection after wrapper symbols
*/ */
export const POST_WIDOW_EXPAND_CHARS = '!?:' export const POST_WIDOW_EXPAND_CHARS = '!?:'
// ============================================================ // ============================================================
// Sentence Navigator Constants // Sentence Navigator Constants
// ============================================================ // ============================================================
/** /**
* Default regex for matching sentences * Default regex for matching sentences
*/ */
export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)' export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)'
/** /**
* Regex for matching list item prefixes * Regex for matching list item prefixes
*/ */
export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/ export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/
// ============================================================ // ============================================================
// Heading Constants // Heading Constants
// ============================================================ // ============================================================
/** /**
* Regex for matching heading markers at the start of a line * Regex for matching heading markers at the start of a line
*/ */
export const HEADING_REGEX = /^(#*)( *)(.*)/ export const HEADING_REGEX = /^(#*)( *)(.*)/

View File

@@ -1,91 +1,91 @@
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian' import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
import BindThemPlugin from './main' import BindThemPlugin from './main'
import { Direction } from './Entities' import { Direction } from './Entities'
import { selectionToLine, selectionToRange } from './Utils' import { selectionToLine, selectionToRange } from './Utils'
/** /**
* DirectionalCopy provides commands to copy content in different directions * DirectionalCopy provides commands to copy content in different directions
*/ */
export class DirectionalCopy { export class DirectionalCopy {
public app: App public app: App
private plugin: BindThemPlugin private plugin: BindThemPlugin
constructor(app: App, plugin: BindThemPlugin) { constructor(app: App, plugin: BindThemPlugin) {
this.app = app this.app = app
this.plugin = plugin this.plugin = plugin
} }
/** /**
* Copy the current selection(s) in the specified direction * Copy the current selection(s) in the specified direction
*/ */
public directionalCopy( public directionalCopy(
editor: Editor, editor: Editor,
view: MarkdownView | MarkdownFileInfo, view: MarkdownView | MarkdownFileInfo,
direction: Direction direction: Direction
): void { ): void {
const selections = editor.listSelections() const selections = editor.listSelections()
const vertical: boolean = direction === Direction.Up || direction === Direction.Down const vertical: boolean = direction === Direction.Up || direction === Direction.Down
// If vertical we want to work with whole lines // If vertical we want to work with whole lines
if (vertical) { if (vertical) {
selections.forEach((selection, idx, arr) => { selections.forEach((selection, idx, arr) => {
arr[idx] = selectionToLine(editor, selection) arr[idx] = selectionToLine(editor, selection)
}) })
} }
const changes: Array<EditorChange> = [] const changes: Array<EditorChange> = []
for (const selection of selections) { for (const selection of selections) {
const range = selectionToRange(selection) const range = selectionToRange(selection)
const content = editor.getRange(range.from, range.to) const content = editor.getRange(range.from, range.to)
let change: EditorChange let change: EditorChange
switch (direction) { switch (direction) {
case Direction.Up: { case Direction.Up: {
change = { change = {
from: range.from, from: range.from,
to: range.from, to: range.from,
text: content + '\n', text: content + '\n',
} }
break break
} }
case Direction.Down: { case Direction.Down: {
change = { change = {
from: range.to, from: range.to,
to: range.to, to: range.to,
text: '\n' + content, text: '\n' + content,
} }
break break
} }
case Direction.Left: { case Direction.Left: {
change = { change = {
from: range.from, from: range.from,
to: range.from, to: range.from,
text: content, text: content,
} }
break break
} }
case Direction.Right: { case Direction.Right: {
change = { change = {
from: range.to, from: range.to,
to: range.to, to: range.to,
text: content, text: content,
} }
break break
} }
} }
changes.push(change) changes.push(change)
} }
const transaction: EditorTransaction = { const transaction: EditorTransaction = {
changes: changes, changes: changes,
} }
const origin = 'DirectionalCopy_' + String(direction) const origin = 'DirectionalCopy_' + String(direction)
editor.transaction(transaction, origin) editor.transaction(transaction, origin)
} }
} }

View File

@@ -1,82 +1,82 @@
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian' import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
import BindThemPlugin from './main' import BindThemPlugin from './main'
import { Direction } from './Entities' import { Direction } from './Entities'
import { selectionToRange } from './Utils' import { selectionToRange } from './Utils'
/** /**
* DirectionalMove provides commands to move selection content left or right * DirectionalMove provides commands to move selection content left or right
*/ */
export class DirectionalMove { export class DirectionalMove {
public app: App public app: App
private plugin: BindThemPlugin private plugin: BindThemPlugin
constructor(app: App, plugin: BindThemPlugin) { constructor(app: App, plugin: BindThemPlugin) {
this.app = app this.app = app
this.plugin = plugin this.plugin = plugin
} }
/** /**
* Move the current selection(s) in the specified direction (left or right) * Move the current selection(s) in the specified direction (left or right)
*/ */
public directionalMove( public directionalMove(
editor: Editor, editor: Editor,
view: MarkdownView | MarkdownFileInfo, view: MarkdownView | MarkdownFileInfo,
direction: Direction.Left | Direction.Right direction: Direction.Left | Direction.Right
): void { ): void {
const selections = editor.listSelections() const selections = editor.listSelections()
const changes: Array<EditorChange> = [] const changes: Array<EditorChange> = []
for (const selection of selections) { for (const selection of selections) {
const range = selectionToRange(selection) const range = selectionToRange(selection)
let additionChange: EditorChange let additionChange: EditorChange
let deletionChange: EditorChange let deletionChange: EditorChange
switch (direction) { switch (direction) {
case Direction.Left: { case Direction.Left: {
deletionChange = { deletionChange = {
from: { from: {
line: range.from.line, line: range.from.line,
ch: range.from.ch - 1, ch: range.from.ch - 1,
}, },
to: range.from, to: range.from,
text: '', text: '',
} }
additionChange = { additionChange = {
from: range.to, from: range.to,
to: range.to, to: range.to,
text: editor.getRange(deletionChange.from, deletionChange.to!), text: editor.getRange(deletionChange.from, deletionChange.to!),
} }
break break
} }
case Direction.Right: { case Direction.Right: {
deletionChange = { deletionChange = {
from: range.to, from: range.to,
to: { to: {
line: range.to.line, line: range.to.line,
ch: range.to.ch + 1, ch: range.to.ch + 1,
}, },
text: '', text: '',
} }
additionChange = { additionChange = {
from: range.from, from: range.from,
to: range.from, to: range.from,
text: editor.getRange(deletionChange.from, deletionChange.to!), text: editor.getRange(deletionChange.from, deletionChange.to!),
} }
break break
} }
} }
changes.push(deletionChange, additionChange) changes.push(deletionChange, additionChange)
} }
const transaction: EditorTransaction = { const transaction: EditorTransaction = {
changes: changes, changes: changes,
} }
const origin = 'DirectionalMove_' + String(direction) const origin = 'DirectionalMove_' + String(direction)
editor.transaction(transaction, origin) editor.transaction(transaction, origin)
} }
} }

View File

@@ -1,43 +1,43 @@
import { EditorPosition, EditorRange, EditorSelection } from 'obsidian' import { EditorPosition, EditorRange, EditorSelection } from 'obsidian'
// ============================================================ // ============================================================
// Direction Enum // Direction Enum
// ============================================================ // ============================================================
export enum Direction { export enum Direction {
Up = 'Up', Up = 'Up',
Down = 'Down', Down = 'Down',
Left = 'Left', Left = 'Left',
Right = 'Right', Right = 'Right',
} }
// ============================================================ // ============================================================
// Heading Enum // Heading Enum
// ============================================================ // ============================================================
export enum Heading { export enum Heading {
NORMAL = 0, NORMAL = 0,
H1 = 1, H1 = 1,
H2 = 2, H2 = 2,
H3 = 3, H3 = 3,
H4 = 4, H4 = 4,
H5 = 5, H5 = 5,
H6 = 6, H6 = 6,
} }
// ============================================================ // ============================================================
// Case Type Enum // Case Type Enum
// ============================================================ // ============================================================
export enum CaseType { export enum CaseType {
Upper, Upper,
Lower, Lower,
Title, Title,
Next, Next,
} }
// ============================================================ // ============================================================
// Editor Types (re-exported from Obsidian for convenience) // Editor Types (re-exported from Obsidian for convenience)
// ============================================================ // ============================================================
export type { EditorPosition, EditorRange, EditorSelection } export type { EditorPosition, EditorRange, EditorSelection }

View File

@@ -1,65 +1,65 @@
import { App, Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian' import { App, Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian'
import BindThemPlugin from './main' import BindThemPlugin from './main'
/** /**
* FileHelper provides commands for file operations * FileHelper provides commands for file operations
*/ */
export class FileHelper { export class FileHelper {
public app: App public app: App
private plugin: BindThemPlugin private plugin: BindThemPlugin
constructor(app: App, plugin: BindThemPlugin) { constructor(app: App, plugin: BindThemPlugin) {
this.app = app this.app = app
this.plugin = plugin this.plugin = plugin
} }
/** /**
* Duplicate the current file * Duplicate the current file
*/ */
public async duplicateFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> { public async duplicateFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> {
const activeFile = this.app.workspace.getActiveFile() const activeFile = this.app.workspace.getActiveFile()
if (activeFile === null) { if (activeFile === null) {
return return
} }
const selections = editor.listSelections() const selections = editor.listSelections()
const parentPath = activeFile.parent?.path ?? '' const parentPath = activeFile.parent?.path ?? ''
const newFilePath = const newFilePath =
parentPath + '/' + activeFile.basename + ' (copy).' + activeFile.extension parentPath + '/' + activeFile.basename + ' (copy).' + activeFile.extension
try { try {
const newFile = await this.app.vault.copy(activeFile, newFilePath) const newFile = await this.app.vault.copy(activeFile, newFilePath)
if (view instanceof MarkdownView) { if (view instanceof MarkdownView) {
await view.leaf.openFile(newFile) await view.leaf.openFile(newFile)
} }
editor.setSelections(selections) editor.setSelections(selections)
} catch (e) { } catch (e) {
new Notice(String(e)) new Notice(String(e))
} }
} }
/** /**
* Create a new file in the same directory as the current file * Create a new file in the same directory as the current file
*/ */
public async newAdjacentFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> { public async newAdjacentFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> {
const activeFile = this.app.workspace.getActiveFile() const activeFile = this.app.workspace.getActiveFile()
if (activeFile === null) { if (activeFile === null) {
return return
} }
const parentPath = activeFile.parent?.path ?? '' const parentPath = activeFile.parent?.path ?? ''
const newFilePath = parentPath + '/' + 'Untitled.md' const newFilePath = parentPath + '/' + 'Untitled.md'
try { try {
const newFile = await this.app.vault.create(newFilePath, '') const newFile = await this.app.vault.create(newFilePath, '')
if (view instanceof MarkdownView) { if (view instanceof MarkdownView) {
await view.leaf.openFile(newFile) await view.leaf.openFile(newFile)
} }
} catch (e) { } catch (e) {
new Notice(String(e)) new Notice(String(e))
} }
} }
} }

View File

@@ -1,63 +1,63 @@
import { App, Editor, EditorRange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian' import { App, Editor, EditorRange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
import BindThemPlugin from './main' import BindThemPlugin from './main'
import { selectionToLine, selectionToRange } from './Utils' import { selectionToLine, selectionToRange } from './Utils'
/** /**
* SelectionHelper provides commands for manipulating text selections * SelectionHelper provides commands for manipulating text selections
*/ */
export class SelectionHelper { export class SelectionHelper {
public app: App public app: App
private plugin: BindThemPlugin private plugin: BindThemPlugin
constructor(app: App, plugin: BindThemPlugin) { constructor(app: App, plugin: BindThemPlugin) {
this.app = app this.app = app
this.plugin = plugin this.plugin = plugin
} }
/** /**
* Select the current line(s) * Select the current line(s)
*/ */
public selectLine(editor: Editor, view: MarkdownView | MarkdownFileInfo): void { public selectLine(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
const selections = editor.listSelections() const selections = editor.listSelections()
const newSelectionRanges: Array<EditorRange> = [] const newSelectionRanges: Array<EditorRange> = []
for (const selection of selections) { for (const selection of selections) {
const newSelection = selectionToLine(editor, selection) const newSelection = selectionToLine(editor, selection)
newSelectionRanges.push(selectionToRange(newSelection)) newSelectionRanges.push(selectionToRange(newSelection))
} }
const transaction: EditorTransaction = { const transaction: EditorTransaction = {
selections: newSelectionRanges, selections: newSelectionRanges,
} }
editor.transaction(transaction, 'SelectionHelper_Line') editor.transaction(transaction, 'SelectionHelper_Line')
} }
/** /**
* Select the current word(s) * Select the current word(s)
*/ */
public selectWord(editor: Editor, view: MarkdownView | MarkdownFileInfo): void { public selectWord(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
const selections = editor.listSelections() const selections = editor.listSelections()
const newSelections: Array<EditorRange> = [] const newSelections: Array<EditorRange> = []
for (const selection of selections) { for (const selection of selections) {
const range = selectionToRange(selection) const range = selectionToRange(selection)
const wordStart = editor.wordAt(range.from)?.from ?? range.from const wordStart = editor.wordAt(range.from)?.from ?? range.from
const wordEnd = editor.wordAt(range.to)?.to ?? range.from const wordEnd = editor.wordAt(range.to)?.to ?? range.from
const newSelection: EditorRange = { const newSelection: EditorRange = {
from: { line: wordStart.line, ch: wordStart.ch }, from: { line: wordStart.line, ch: wordStart.ch },
to: { line: wordEnd.line, ch: wordEnd.ch }, to: { line: wordEnd.line, ch: wordEnd.ch },
} }
newSelections.push(newSelection) newSelections.push(newSelection)
} }
const transaction: EditorTransaction = { const transaction: EditorTransaction = {
selections: newSelections, selections: newSelections,
} }
editor.transaction(transaction, 'SelectionHelper_Word') editor.transaction(transaction, 'SelectionHelper_Word')
} }
} }

328
src/SelectionOccurrence.ts Normal file
View File

@@ -0,0 +1,328 @@
import { Editor, EditorPosition, EditorSelection } from 'obsidian'
import { selectionToRange } from './Utils'
// ============================================================
// Selection occurrence tracking (ported from obsidian-editor-shortcuts)
// ============================================================
/*
Properties used to distinguish between selections that are programmatic
(expanding from a cursor selection) vs. manual (using a mouse / Shift + arrow
keys). This controls the match behaviour for selectWordOrNextOccurrence.
*/
let isManualSelection = true
let isProgrammaticSelectionChange = false
export const setIsManualSelection = (value: boolean) => {
isManualSelection = value
}
export const setIsProgrammaticSelectionChange = (value: boolean) => {
isProgrammaticSelectionChange = value
}
export const getIsManualSelection = () => isManualSelection
export const getIsProgrammaticSelectionChange = () => isProgrammaticSelectionChange
/**
* Check if all selections have the same text content
*/
function hasSameSelectionContent(
editor: Editor,
selections: EditorSelection[],
): boolean {
return (
new Set(
selections.map((selection) => {
const { from, to } = selectionToRange(selection)
return editor.getRange(from, to)
}),
).size === 1
)
}
/**
* Get the search text from the current selections.
* If autoExpand is true and no text is selected, expands to word at cursor.
*/
function getSearchText({
editor,
allSelections,
autoExpand,
}: {
editor: Editor
allSelections: EditorSelection[]
autoExpand: boolean
}): { searchText: string; singleSearchText: boolean } {
const singleSearchText = hasSameSelectionContent(editor, allSelections)
const firstSelection = allSelections[0]
const { from, to } = selectionToRange(firstSelection)
let searchText = editor.getRange(from, to)
if (searchText.length === 0 && autoExpand) {
const wordRange = editor.wordAt(from)
if (wordRange) {
searchText = editor.getRange(wordRange.from, wordRange.to)
}
}
return { searchText, singleSearchText }
}
/**
* Escapes any special regex characters in the given string.
*/
const escapeRegex = (input: string) =>
input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Constructs a custom regex query with word boundaries because `\b` in JS
* doesn't match word boundaries for unicode characters, even with the unicode flag.
*
* Adapted from https://shiba1014.medium.com/regex-word-boundaries-with-unicode-207794f6e7ed.
*/
const withWordBoundaries = (input: string) =>
`(?<=\\W|^)${input}(?=\\W|$)`
/**
* Find all match positions of searchText in the document content.
* When searchWithinWords is false, only whole-word matches are returned.
*/
function findAllMatches({
searchText,
searchWithinWords,
documentContent,
}: {
searchText: string
searchWithinWords: boolean
documentContent: string
}): RegExpMatchArray[] {
const escapedSearchText = escapeRegex(searchText)
const searchExpression = new RegExp(
searchWithinWords
? escapedSearchText
: withWordBoundaries(escapedSearchText),
'g',
)
return Array.from(documentContent.matchAll(searchExpression))
}
/**
* Find all match positions as editor selections.
*/
function findAllMatchPositions({
editor,
searchText,
searchWithinWords,
documentContent,
}: {
editor: Editor
searchText: string
searchWithinWords: boolean
documentContent: string
}): EditorSelection[] {
const matches = findAllMatches({
searchText,
searchWithinWords,
documentContent,
})
const matchPositions: EditorSelection[] = []
for (const match of matches) {
matchPositions.push({
anchor: editor.offsetToPos(match.index!),
head: editor.offsetToPos(match.index! + searchText.length),
})
}
return matchPositions
}
/**
* Find the next match position after a given position.
* If no match is found after, wraps around to the beginning.
* Already-selected positions are skipped.
*/
function findNextMatchPosition({
editor,
latestMatchPos,
searchText,
searchWithinWords,
documentContent,
}: {
editor: Editor
latestMatchPos: EditorPosition
searchText: string
searchWithinWords: boolean
documentContent: string
}): EditorSelection | null {
const latestMatchOffset = editor.posToOffset(latestMatchPos)
const matches = findAllMatches({
searchText,
searchWithinWords,
documentContent,
})
// Find first match after current position
for (const match of matches) {
if (match.index! > latestMatchOffset) {
return {
anchor: editor.offsetToPos(match.index!),
head: editor.offsetToPos(match.index! + searchText.length),
}
}
}
// Circle back to search from the top, skipping already-selected positions
const selectionIndexes = editor.listSelections().map((selection) => {
const { from } = selectionToRange(selection)
return editor.posToOffset(from)
})
for (const match of matches) {
if (!selectionIndexes.includes(match.index!)) {
return {
anchor: editor.offsetToPos(match.index!),
head: editor.offsetToPos(match.index! + searchText.length),
}
}
}
return null
}
/**
* Find the previous match position before a given position.
* If no match is found before, wraps around to the end.
* Already-selected positions are skipped.
*/
function findPrevMatchPosition({
editor,
latestMatchPos,
searchText,
searchWithinWords,
documentContent,
}: {
editor: Editor
latestMatchPos: EditorPosition
searchText: string
searchWithinWords: boolean
documentContent: string
}): EditorSelection | null {
const latestMatchOffset = editor.posToOffset(latestMatchPos)
const matches = findAllMatches({
searchText,
searchWithinWords,
documentContent,
})
// Find last match before current position
for (let i = matches.length - 1; i >= 0; i--) {
if (matches[i].index! < latestMatchOffset) {
return {
anchor: editor.offsetToPos(matches[i].index!),
head: editor.offsetToPos(matches[i].index! + searchText.length),
}
}
}
// Circle back from the bottom, skipping already-selected positions
const selectionIndexes = editor.listSelections().map((selection) => {
const { from } = selectionToRange(selection)
return editor.posToOffset(from)
})
for (let i = matches.length - 1; i >= 0; i--) {
if (!selectionIndexes.includes(matches[i].index!)) {
return {
anchor: editor.offsetToPos(matches[i].index!),
head: editor.offsetToPos(matches[i].index! + searchText.length),
}
}
}
return null
}
/**
* Select the next or previous occurrence of the current selection text.
* When nothing is selected, expands to the word under cursor first.
*
* Ported from obsidian-editor-shortcuts `selectWordOrNextOccurrence`.
*/
export function selectWordOrNextOccurrence(
editor: Editor,
direction: 'next' | 'prev',
): void {
setIsProgrammaticSelectionChange(true)
const allSelections = editor.listSelections()
const { searchText, singleSearchText } = getSearchText({
editor,
allSelections,
autoExpand: false,
})
if (searchText.length > 0 && singleSearchText) {
// All selections have the same content — find next/prev occurrence
const latestSelection =
direction === 'next'
? allSelections[allSelections.length - 1]
: allSelections[0]
const { from: latestMatchPos } = selectionToRange(latestSelection)
const findFn =
direction === 'next' ? findNextMatchPosition : findPrevMatchPosition
const nextMatch = findFn({
editor,
latestMatchPos,
searchText,
searchWithinWords: isManualSelection,
documentContent: editor.getValue(),
})
const newSelections = nextMatch
? allSelections.concat(nextMatch)
: allSelections
editor.setSelections(newSelections)
const lastSelection = newSelections[newSelections.length - 1]
editor.scrollIntoView(selectionToRange(lastSelection))
} else {
// No text selected — expand each cursor to its word
const newSelections: EditorSelection[] = []
for (const selection of allSelections) {
const { from, to } = selectionToRange(selection)
// Don't modify existing range selections
if (from.line !== to.line || from.ch !== to.ch) {
newSelections.push(selection)
} else {
const wordAt = editor.wordAt(from)
if (wordAt) {
newSelections.push({ anchor: wordAt.from, head: wordAt.to })
} else {
newSelections.push(selection)
}
setIsManualSelection(false)
}
}
editor.setSelections(newSelections)
}
}
/**
* Select all occurrences of the current selection text in the document.
* If nothing is selected, expands to the word under cursor first.
*
* Ported from obsidian-editor-shortcuts `selectAllOccurrences`.
*/
export function selectAllOccurrences(editor: Editor): void {
const allSelections = editor.listSelections()
const { searchText, singleSearchText } = getSearchText({
editor,
allSelections,
autoExpand: true,
})
if (!singleSearchText) {
return
}
const matches = findAllMatchPositions({
editor,
searchText,
searchWithinWords: true,
documentContent: editor.getValue(),
})
editor.setSelections(matches)
}

View File

@@ -1,276 +1,276 @@
import { App, Editor, EditorPosition, MarkdownFileInfo, MarkdownView } from 'obsidian' import { App, Editor, EditorPosition, MarkdownFileInfo, MarkdownView } from 'obsidian'
import BindThemPlugin from './main' import BindThemPlugin from './main'
import { LIST_CHARACTER_REGEX } from './Constants' import { LIST_CHARACTER_REGEX } from './Constants'
/** /**
* SentenceNavigator provides commands for navigating and manipulating sentences * SentenceNavigator provides commands for navigating and manipulating sentences
* Based on obsidian-sentence-navigator functionality * Based on obsidian-sentence-navigator functionality
*/ */
export class SentenceNavigator { export class SentenceNavigator {
public app: App public app: App
private plugin: BindThemPlugin private plugin: BindThemPlugin
private sentenceRegex: RegExp private sentenceRegex: RegExp
constructor(app: App, plugin: BindThemPlugin, regexSource: string) { constructor(app: App, plugin: BindThemPlugin, regexSource: string) {
this.app = app this.app = app
this.plugin = plugin this.plugin = plugin
this.sentenceRegex = new RegExp(regexSource, 'gm') this.sentenceRegex = new RegExp(regexSource, 'gm')
} }
/** /**
* Update the sentence regex pattern * Update the sentence regex pattern
*/ */
public updateRegex(regexSource: string): void { public updateRegex(regexSource: string): void {
this.sentenceRegex = new RegExp(regexSource, 'gm') this.sentenceRegex = new RegExp(regexSource, 'gm')
} }
/** /**
* Get cursor position and current paragraph text * Get cursor position and current paragraph text
*/ */
private getCursorAndParagraphText(editor: Editor): { private getCursorAndParagraphText(editor: Editor): {
cursorPosition: EditorPosition cursorPosition: EditorPosition
paragraphText: string paragraphText: string
} { } {
const cursorPosition = editor.getCursor() const cursorPosition = editor.getCursor()
return { cursorPosition, paragraphText: editor.getLine(cursorPosition.line) } return { cursorPosition, paragraphText: editor.getLine(cursorPosition.line) }
} }
/** /**
* Iterate over all sentences in a paragraph * Iterate over all sentences in a paragraph
*/ */
private forEachSentence( private forEachSentence(
paragraphText: string, paragraphText: string,
callback: (sentence: RegExpMatchArray) => boolean | void callback: (sentence: RegExpMatchArray) => boolean | void
): void { ): void {
for (const sentence of paragraphText.matchAll(this.sentenceRegex)) { for (const sentence of paragraphText.matchAll(this.sentenceRegex)) {
if (callback(sentence)) break if (callback(sentence)) break
} }
} }
/** /**
* Set cursor at the next non-space character * Set cursor at the next non-space character
*/ */
private setCursorAtNextWordCharacter( private setCursorAtNextWordCharacter(
editor: Editor, editor: Editor,
cursorPosition: EditorPosition, cursorPosition: EditorPosition,
paragraphText: string, paragraphText: string,
direction: 'start' | 'end' direction: 'start' | 'end'
): void { ): void {
let ch = cursorPosition.ch let ch = cursorPosition.ch
if (direction === 'start') { if (direction === 'start') {
while (ch > 0 && paragraphText.charAt(ch - 1) === ' ') ch-- while (ch > 0 && paragraphText.charAt(ch - 1) === ' ') ch--
} else { } else {
while (ch < paragraphText.length && paragraphText.charAt(ch) === ' ') ch++ while (ch < paragraphText.length && paragraphText.charAt(ch) === ' ') ch++
} }
editor.setCursor({ ch, line: cursorPosition.line }) editor.setCursor({ ch, line: cursorPosition.line })
} }
/** /**
* Get the previous non-empty line number * Get the previous non-empty line number
*/ */
private getPrevNonEmptyLine(editor: Editor, currentLine: number): number { private getPrevNonEmptyLine(editor: Editor, currentLine: number): number {
let prevLine = currentLine - 1 let prevLine = currentLine - 1
while (prevLine >= 0 && editor.getLine(prevLine).length === 0) prevLine-- while (prevLine >= 0 && editor.getLine(prevLine).length === 0) prevLine--
return prevLine return prevLine
} }
/** /**
* Get the next non-empty line number * Get the next non-empty line number
*/ */
private getNextNonEmptyLine(editor: Editor, currentLine: number): number { private getNextNonEmptyLine(editor: Editor, currentLine: number): number {
let nextLine = currentLine + 1 let nextLine = currentLine + 1
while (nextLine < editor.lineCount() && editor.getLine(nextLine).length === 0) nextLine++ while (nextLine < editor.lineCount() && editor.getLine(nextLine).length === 0) nextLine++
return nextLine return nextLine
} }
/** /**
* Check if cursor is at the start of a list item * Check if cursor is at the start of a list item
*/ */
private isAtStartOfListItem( private isAtStartOfListItem(
cursorPosition: EditorPosition, cursorPosition: EditorPosition,
paragraphText: string paragraphText: string
): boolean { ): boolean {
return LIST_CHARACTER_REGEX.test(paragraphText.slice(0, cursorPosition.ch)) return LIST_CHARACTER_REGEX.test(paragraphText.slice(0, cursorPosition.ch))
} }
/** /**
* Delete text from cursor to sentence boundary * Delete text from cursor to sentence boundary
*/ */
public deleteToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void { public deleteToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void {
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
const originalCursorPosition = cursorPosition const originalCursorPosition = cursorPosition
if ( if (
paragraphText.charAt(cursorPosition.ch) === ' ' || paragraphText.charAt(cursorPosition.ch) === ' ' ||
paragraphText.charAt(cursorPosition.ch - 1) === ' ' paragraphText.charAt(cursorPosition.ch - 1) === ' '
) { ) {
this.setCursorAtNextWordCharacter(editor, cursorPosition, paragraphText, boundary) this.setCursorAtNextWordCharacter(editor, cursorPosition, paragraphText, boundary)
;({ cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)) ;({ cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor))
} }
this.forEachSentence(paragraphText, (sentence) => { this.forEachSentence(paragraphText, (sentence) => {
const idx = sentence.index ?? 0 const idx = sentence.index ?? 0
if ( if (
cursorPosition.ch >= idx && cursorPosition.ch >= idx &&
cursorPosition.ch <= idx + sentence[0].length cursorPosition.ch <= idx + sentence[0].length
) { ) {
if (boundary === 'start') { if (boundary === 'start') {
const newText = const newText =
paragraphText.substring(0, idx) + paragraphText.substring(0, idx) +
paragraphText.substring(originalCursorPosition.ch) paragraphText.substring(originalCursorPosition.ch)
const cutLength = paragraphText.length - newText.length const cutLength = paragraphText.length - newText.length
editor.replaceRange( editor.replaceRange(
newText, newText,
{ line: cursorPosition.line, ch: 0 }, { line: cursorPosition.line, ch: 0 },
{ line: cursorPosition.line, ch: paragraphText.length } { line: cursorPosition.line, ch: paragraphText.length }
) )
editor.setCursor({ editor.setCursor({
line: cursorPosition.line, line: cursorPosition.line,
ch: originalCursorPosition.ch - cutLength, ch: originalCursorPosition.ch - cutLength,
}) })
} else { } else {
const remainingLength = idx + sentence[0].length - cursorPosition.ch const remainingLength = idx + sentence[0].length - cursorPosition.ch
const newText = const newText =
paragraphText.substring(0, originalCursorPosition.ch) + paragraphText.substring(0, originalCursorPosition.ch) +
paragraphText.substring(cursorPosition.ch + remainingLength) paragraphText.substring(cursorPosition.ch + remainingLength)
editor.replaceRange( editor.replaceRange(
newText, newText,
{ line: cursorPosition.line, ch: 0 }, { line: cursorPosition.line, ch: 0 },
{ line: cursorPosition.line, ch: paragraphText.length } { line: cursorPosition.line, ch: paragraphText.length }
) )
editor.setCursor(originalCursorPosition) editor.setCursor(originalCursorPosition)
} }
return true return true
} }
}) })
} }
/** /**
* Select text from cursor to sentence boundary * Select text from cursor to sentence boundary
*/ */
public selectToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void { public selectToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void {
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
this.forEachSentence(paragraphText, (sentence) => { this.forEachSentence(paragraphText, (sentence) => {
const idx = sentence.index ?? 0 const idx = sentence.index ?? 0
if ( if (
cursorPosition.ch >= idx && cursorPosition.ch >= idx &&
cursorPosition.ch <= idx + sentence[0].length cursorPosition.ch <= idx + sentence[0].length
) { ) {
if ( if (
editor.getSelection().length > 0 && editor.getSelection().length > 0 &&
(cursorPosition.ch === idx || cursorPosition.ch === idx + sentence[0].length) (cursorPosition.ch === idx || cursorPosition.ch === idx + sentence[0].length)
) { ) {
return true return true
} }
if (boundary === 'start') { if (boundary === 'start') {
const precedingLength = cursorPosition.ch - idx const precedingLength = cursorPosition.ch - idx
editor.setSelection(cursorPosition, { editor.setSelection(cursorPosition, {
line: cursorPosition.line, line: cursorPosition.line,
ch: cursorPosition.ch - precedingLength, ch: cursorPosition.ch - precedingLength,
}) })
} else { } else {
editor.setSelection(cursorPosition, { editor.setSelection(cursorPosition, {
line: cursorPosition.line, line: cursorPosition.line,
ch: idx + sentence[0].length, ch: idx + sentence[0].length,
}) })
} }
return true return true
} }
}) })
} }
/** /**
* Move cursor to the start of the current sentence * Move cursor to the start of the current sentence
*/ */
public moveToStartOfCurrentSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void { public moveToStartOfCurrentSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
if (cursorPosition.ch === 0 || this.isAtStartOfListItem(cursorPosition, paragraphText)) { if (cursorPosition.ch === 0 || this.isAtStartOfListItem(cursorPosition, paragraphText)) {
const prevLine = this.getPrevNonEmptyLine(editor, cursorPosition.line) const prevLine = this.getPrevNonEmptyLine(editor, cursorPosition.line)
if (prevLine >= 0) { if (prevLine >= 0) {
editor.setCursor({ line: prevLine, ch: editor.getLine(prevLine).length }) editor.setCursor({ line: prevLine, ch: editor.getLine(prevLine).length })
} }
return return
} }
this.forEachSentence(paragraphText, (sentence) => { this.forEachSentence(paragraphText, (sentence) => {
const idx = sentence.index ?? 0 const idx = sentence.index ?? 0
while ( while (
cursorPosition.ch > 0 && cursorPosition.ch > 0 &&
paragraphText.charAt(cursorPosition.ch - 1) === ' ' paragraphText.charAt(cursorPosition.ch - 1) === ' '
) { ) {
editor.setCursor({ editor.setCursor({
line: cursorPosition.line, line: cursorPosition.line,
ch: --cursorPosition.ch, ch: --cursorPosition.ch,
}) })
} }
if (cursorPosition.ch > idx && idx + sentence[0].length >= cursorPosition.ch) { if (cursorPosition.ch > idx && idx + sentence[0].length >= cursorPosition.ch) {
editor.setCursor({ line: cursorPosition.line, ch: idx }) editor.setCursor({ line: cursorPosition.line, ch: idx })
} }
}) })
} }
/** /**
* Move cursor to the start of the next sentence * Move cursor to the start of the next sentence
*/ */
public moveToStartOfNextSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void { public moveToStartOfNextSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
if (cursorPosition.ch === paragraphText.length) { if (cursorPosition.ch === paragraphText.length) {
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line) const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
if (nextLine < editor.lineCount()) { if (nextLine < editor.lineCount()) {
editor.setCursor({ line: nextLine, ch: 0 }) editor.setCursor({ line: nextLine, ch: 0 })
} }
return return
} }
this.forEachSentence(paragraphText, (sentence) => { this.forEachSentence(paragraphText, (sentence) => {
const idx = sentence.index ?? 0 const idx = sentence.index ?? 0
while ( while (
cursorPosition.ch < paragraphText.length && cursorPosition.ch < paragraphText.length &&
paragraphText.charAt(cursorPosition.ch) === ' ' paragraphText.charAt(cursorPosition.ch) === ' '
) { ) {
editor.setCursor({ editor.setCursor({
line: cursorPosition.line, line: cursorPosition.line,
ch: ++cursorPosition.ch, ch: ++cursorPosition.ch,
}) })
} }
if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) { if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) {
const endPos = { line: cursorPosition.line, ch: idx + sentence[0].length } const endPos = { line: cursorPosition.line, ch: idx + sentence[0].length }
this.setCursorAtNextWordCharacter(editor, endPos, paragraphText, 'end') this.setCursorAtNextWordCharacter(editor, endPos, paragraphText, 'end')
if (endPos.ch >= paragraphText.length) { if (endPos.ch >= paragraphText.length) {
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line) const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
if (nextLine < editor.lineCount()) { if (nextLine < editor.lineCount()) {
editor.setCursor({ line: nextLine, ch: 0 }) editor.setCursor({ line: nextLine, ch: 0 })
} }
} }
} }
}) })
} }
/** /**
* Select the current sentence * Select the current sentence
*/ */
public selectSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void { public selectSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor) const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
let offset = 0 let offset = 0
let text = paragraphText let text = paragraphText
const matches = paragraphText.match(/^(\d+\.|[-*+]) /) const matches = paragraphText.match(/^(\d+\.|[-*+]) /)
if (matches) { if (matches) {
offset = matches[0].length offset = matches[0].length
text = paragraphText.slice(offset) text = paragraphText.slice(offset)
} }
this.forEachSentence(text, (sentence) => { this.forEachSentence(text, (sentence) => {
const idx = sentence.index ?? 0 const idx = sentence.index ?? 0
if (cursorPosition.ch <= offset + idx + sentence[0].length) { if (cursorPosition.ch <= offset + idx + sentence[0].length) {
editor.setSelection( editor.setSelection(
{ line: cursorPosition.line, ch: offset + idx }, { line: cursorPosition.line, ch: offset + idx },
{ line: cursorPosition.line, ch: offset + idx + sentence[0].length } { line: cursorPosition.line, ch: offset + idx + sentence[0].length }
) )
return true return true
} }
}) })
} }
} }

View File

@@ -1,140 +1,140 @@
import { import {
App, App,
Editor, Editor,
EditorChange, EditorChange,
EditorPosition, EditorPosition,
EditorTransaction, EditorTransaction,
MarkdownFileInfo, MarkdownFileInfo,
MarkdownView, MarkdownView,
} from 'obsidian' } from 'obsidian'
import BindThemPlugin from './main' import BindThemPlugin from './main'
import { Heading } from './Entities' import { Heading } from './Entities'
import { getMainSelection, selectionToRange } from './Utils' import { getMainSelection, selectionToRange } from './Utils'
import { HEADING_REGEX } from './Constants' import { HEADING_REGEX } from './Constants'
/** /**
* ToggleHeading provides commands to toggle markdown heading levels * ToggleHeading provides commands to toggle markdown heading levels
*/ */
export class ToggleHeading { export class ToggleHeading {
public app: App public app: App
private plugin: BindThemPlugin private plugin: BindThemPlugin
constructor(app: App, plugin: BindThemPlugin) { constructor(app: App, plugin: BindThemPlugin) {
this.app = app this.app = app
this.plugin = plugin this.plugin = plugin
} }
/** /**
* Create a change to set the heading level for a specific line * Create a change to set the heading level for a specific line
*/ */
private setHeading( private setHeading(
editor: Editor, editor: Editor,
heading: Heading, heading: Heading,
line: number line: number
): EditorChange { ): EditorChange {
const headingStr = '#'.repeat(heading) const headingStr = '#'.repeat(heading)
const text = editor.getLine(line) const text = editor.getLine(line)
const matches = HEADING_REGEX.exec(text)! const matches = HEADING_REGEX.exec(text)!
const from: EditorPosition = { const from: EditorPosition = {
line: line, line: line,
ch: 0, ch: 0,
} }
const to: EditorPosition = { const to: EditorPosition = {
line: line, line: line,
ch: matches[1].length + matches[2].length, ch: matches[1].length + matches[2].length,
} }
const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' ' const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' '
return { return {
from: from, from: from,
to: to, to: to,
text: replacementStr, text: replacementStr,
} }
} }
/** /**
* Get the heading level of a specific line * Get the heading level of a specific line
*/ */
private getHeadingOfLine(editor: Editor, line: number): Heading { private getHeadingOfLine(editor: Editor, line: number): Heading {
const text = editor.getLine(line) const text = editor.getLine(line)
const matches = HEADING_REGEX.exec(text)! const matches = HEADING_REGEX.exec(text)!
return matches[1].length as Heading return matches[1].length as Heading
} }
/** /**
* Toggle heading for the current selection(s) * Toggle heading for the current selection(s)
* If the line already has the specified heading, remove it (set to NORMAL) * If the line already has the specified heading, remove it (set to NORMAL)
*/ */
public toggleHeading( public toggleHeading(
editor: Editor, editor: Editor,
view: MarkdownView | MarkdownFileInfo, view: MarkdownView | MarkdownFileInfo,
heading: Heading heading: Heading
): void { ): void {
const selections = editor.listSelections() const selections = editor.listSelections()
const mainSelection = getMainSelection(editor) const mainSelection = getMainSelection(editor)
const mainRange = selectionToRange(mainSelection) const mainRange = selectionToRange(mainSelection)
const mainHeading = this.getHeadingOfLine(editor, mainRange.from.line) const mainHeading = this.getHeadingOfLine(editor, mainRange.from.line)
const targetHeading = heading === mainHeading ? Heading.NORMAL : heading const targetHeading = heading === mainHeading ? Heading.NORMAL : heading
const changes: Array<EditorChange> = [] const changes: Array<EditorChange> = []
// Collect unique lines from all selections // Collect unique lines from all selections
const linesToSet: Set<number> = new Set() const linesToSet: Set<number> = new Set()
for (const selection of selections) { for (const selection of selections) {
const range = selectionToRange(selection) const range = selectionToRange(selection)
for (let line = range.from.line; line <= range.to.line; line++) { for (let line = range.from.line; line <= range.to.line; line++) {
linesToSet.add(line) linesToSet.add(line)
} }
} }
// Create changes for each line // Create changes for each line
for (const line of linesToSet) { for (const line of linesToSet) {
if (editor.getLine(line) === '') { if (editor.getLine(line) === '') {
continue continue
} }
changes.push(this.setHeading(editor, targetHeading, line)) changes.push(this.setHeading(editor, targetHeading, line))
} }
const transaction: EditorTransaction = { const transaction: EditorTransaction = {
changes: changes, changes: changes,
} }
editor.transaction(transaction, 'ToggleHeading' + heading.toString()) editor.transaction(transaction, 'ToggleHeading' + heading.toString())
} }
/** /**
* Toggle heading with stripping of inline formatting * Toggle heading with stripping of inline formatting
* This removes bold/italic formatting when toggling headings * This removes bold/italic formatting when toggling headings
*/ */
public toggleHeadingWithStrip( public toggleHeadingWithStrip(
editor: Editor, editor: Editor,
view: MarkdownView | MarkdownFileInfo, view: MarkdownView | MarkdownFileInfo,
level: number level: number
): void { ): void {
const cursor = editor.getCursor() const cursor = editor.getCursor()
const line = editor.getLine(cursor.line) const line = editor.getLine(cursor.line)
const match = line.match(/^(#+)\s(.*)/) const match = line.match(/^(#+)\s(.*)/)
let content = line let content = line
let currentLevel = 0 let currentLevel = 0
if (match) { if (match) {
currentLevel = match[1].length currentLevel = match[1].length
content = match[2] content = match[2]
} }
// Strip bold and italic formatting // Strip bold and italic formatting
content = content content = content
.replace(/(\*\*|__)(.*?)\1/g, '$2') .replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2') .replace(/(\*|_)(.*?)\1/g, '$2')
editor.setLine( editor.setLine(
cursor.line, cursor.line,
currentLevel === level ? content : '#'.repeat(level) + ' ' + content currentLevel === level ? content : '#'.repeat(level) + ' ' + content
) )
} }
} }

View File

@@ -1,94 +1,94 @@
import { Editor, EditorRange, EditorSelection } from 'obsidian' import { Editor, EditorRange, EditorSelection } from 'obsidian'
// ============================================================ // ============================================================
// Selection Utilities // Selection Utilities
// ============================================================ // ============================================================
/** /**
* Get the main (primary) selection from the editor * Get the main (primary) selection from the editor
*/ */
export function getMainSelection(editor: Editor): EditorSelection { export function getMainSelection(editor: Editor): EditorSelection {
return { return {
anchor: editor.getCursor('anchor'), anchor: editor.getCursor('anchor'),
head: editor.getCursor('head'), head: editor.getCursor('head'),
} }
} }
/** /**
* Convert a selection to a range (from/to with sorted positions) * Convert a selection to a range (from/to with sorted positions)
*/ */
export function selectionToRange(selection: EditorSelection): EditorRange { export function selectionToRange(selection: EditorSelection): EditorRange {
const sortedPositions = [selection.anchor, selection.head].sort((a, b) => { const sortedPositions = [selection.anchor, selection.head].sort((a, b) => {
if (a.line !== b.line) return a.line - b.line if (a.line !== b.line) return a.line - b.line
return a.ch - b.ch return a.ch - b.ch
}) })
return { return {
from: sortedPositions[0], from: sortedPositions[0],
to: sortedPositions[1], to: sortedPositions[1],
} }
} }
/** /**
* Convert a selection to encompass full lines * Convert a selection to encompass full lines
*/ */
export function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection { export function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection {
const range = selectionToRange(selection) const range = selectionToRange(selection)
return { return {
anchor: { line: range.from.line, ch: 0 }, anchor: { line: range.from.line, ch: 0 },
head: { line: range.to.line, ch: editor.getLine(range.to.line).length }, head: { line: range.to.line, ch: editor.getLine(range.to.line).length },
} }
} }
// ============================================================ // ============================================================
// Text Utilities // Text Utilities
// ============================================================ // ============================================================
/** /**
* Get leading whitespace (indentation) from a line * Get leading whitespace (indentation) from a line
*/ */
export function getLeadingWhitespace(lineContent: string): string { export function getLeadingWhitespace(lineContent: string): string {
const indentation = lineContent.match(/^\s+/) const indentation = lineContent.match(/^\s+/)
return indentation ? indentation[0] : '' return indentation ? indentation[0] : ''
} }
/** /**
* Check if a character is a letter or digit * Check if a character is a letter or digit
*/ */
export function isLetterOrDigit(char: string): boolean { export function isLetterOrDigit(char: string): boolean {
return /\p{L}\p{M}*/u.test(char) || /\d/.test(char) return /\p{L}\p{M}*/u.test(char) || /\d/.test(char)
} }
/** /**
* Get the word range at a given position * Get the word range at a given position
*/ */
export function wordRangeAtPos( export function wordRangeAtPos(
pos: { line: number; ch: number }, pos: { line: number; ch: number },
lineContent: string lineContent: string
): { anchor: { line: number; ch: number }; head: { line: number; ch: number } } { ): { anchor: { line: number; ch: number }; head: { line: number; ch: number } } {
let start = pos.ch let start = pos.ch
let end = pos.ch let end = pos.ch
while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) start-- while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) start--
while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) end++ while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) end++
return { anchor: { line: pos.line, ch: start }, head: { line: pos.line, ch: end } } return { anchor: { line: pos.line, ch: start }, head: { line: pos.line, ch: end } }
} }
// ============================================================ // ============================================================
// Case Transformation Utilities // Case Transformation Utilities
// ============================================================ // ============================================================
const LOWERCASE_ARTICLES = ['the', 'a', 'an'] const LOWERCASE_ARTICLES = ['the', 'a', 'an']
/** /**
* Convert text to title case * Convert text to title case
*/ */
export function toTitleCase(text: string): string { export function toTitleCase(text: string): string {
return text return text
.split(/(\s+)/) .split(/(\s+)/)
.map((w, i, all) => { .map((w, i, all) => {
if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(w.toLowerCase())) { if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(w.toLowerCase())) {
return w.toLowerCase() return w.toLowerCase()
} }
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
}) })
.join(' ') .join(' ')
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,306 +1,307 @@
import { App, PluginSettingTab, Setting } from 'obsidian'; import { App, PluginSettingTab, Setting } from 'obsidian';
import BindThemPlugin from './main'; import BindThemPlugin from './main';
import { DEFAULT_SENTENCE_REGEX } from './Constants'; import { DEFAULT_SENTENCE_REGEX } from './Constants';
// ============================================================ // ============================================================
// Command Definitions - All commands organized by category // Command Definitions - All commands organized by category
// ============================================================ // ============================================================
export interface CommandDefinition { export interface CommandDefinition {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
} }
export const COMMAND_CATEGORIES = { export const COMMAND_CATEGORIES = {
betterFormatting: 'Better Formatting', betterFormatting: 'Better Formatting',
directionalCopyMove: 'Directional Copy/Move', directionalCopyMove: 'Directional Copy/Move',
toggleHeading: 'Toggle Heading', toggleHeading: 'Toggle Heading',
sentenceNavigator: 'Sentence Navigator', sentenceNavigator: 'Sentence Navigator',
lineOperations: 'Line Operations', lineOperations: 'Line Operations',
caseTransformation: 'Case Transformation', caseTransformation: 'Case Transformation',
navigation: 'Navigation', navigation: 'Navigation',
cursorMovement: 'Cursor Movement', cursorMovement: 'Cursor Movement',
fileOperations: 'File Operations', fileOperations: 'File Operations',
utility: 'Utility' utility: 'Utility'
} as const; } as const;
export const COMMANDS: Record<keyof typeof COMMAND_CATEGORIES, CommandDefinition[]> = { export const COMMANDS: Record<keyof typeof COMMAND_CATEGORIES, CommandDefinition[]> = {
betterFormatting: [ betterFormatting: [
{ id: 'toggle-bold-underscore', name: 'Toggle bold underscore' }, { id: 'toggle-bold-underscore', name: 'Toggle bold underscore' },
{ id: 'toggle-bold-asterisk', name: 'Toggle bold asterisk' }, { id: 'toggle-bold-asterisk', name: 'Toggle bold asterisk' },
{ id: 'toggle-italics-underscore', name: 'Toggle italics underscore' }, { id: 'toggle-italics-underscore', name: 'Toggle italics underscore' },
{ id: 'toggle-italics-asterisk', name: 'Toggle italics asterisk' }, { id: 'toggle-italics-asterisk', name: 'Toggle italics asterisk' },
{ id: 'toggle-code', name: 'Toggle code' }, { id: 'toggle-code', name: 'Toggle code' },
{ id: 'toggle-comment', name: 'Toggle comment' }, { id: 'toggle-comment', name: 'Toggle comment' },
{ id: 'toggle-highlight', name: 'Toggle highlight' }, { id: 'toggle-highlight', name: 'Toggle highlight' },
{ id: 'toggle-strikethrough', name: 'Toggle strikethrough' }, { id: 'toggle-strikethrough', name: 'Toggle strikethrough' },
{ id: 'toggle-math-inline', name: 'Toggle math inline' }, { id: 'toggle-math-inline', name: 'Toggle math inline' },
{ id: 'toggle-math-block', name: 'Toggle math block' } { id: 'toggle-math-block', name: 'Toggle math block' }
], ],
directionalCopyMove: [ directionalCopyMove: [
{ id: 'copy-up', name: 'Copy up' }, { id: 'copy-up', name: 'Copy up' },
{ id: 'copy-down', name: 'Copy down' }, { id: 'copy-down', name: 'Copy down' },
{ id: 'copy-left', name: 'Copy left' }, { id: 'copy-left', name: 'Copy left' },
{ id: 'copy-right', name: 'Copy right' }, { id: 'copy-right', name: 'Copy right' },
{ id: 'move-left', name: 'Move left' }, { id: 'move-left', name: 'Move left' },
{ id: 'move-right', name: 'Move right' } { id: 'move-right', name: 'Move right' }
], ],
toggleHeading: [ toggleHeading: [
{ id: 'toggle-heading-1', name: 'Toggle heading 1' }, { id: 'toggle-heading-1', name: 'Toggle heading 1' },
{ id: 'toggle-heading-2', name: 'Toggle heading 2' }, { id: 'toggle-heading-2', name: 'Toggle heading 2' },
{ id: 'toggle-heading-3', name: 'Toggle heading 3' }, { id: 'toggle-heading-3', name: 'Toggle heading 3' },
{ id: 'toggle-heading-4', name: 'Toggle heading 4' }, { id: 'toggle-heading-4', name: 'Toggle heading 4' },
{ id: 'toggle-heading-5', name: 'Toggle heading 5' }, { id: 'toggle-heading-5', name: 'Toggle heading 5' },
{ id: 'toggle-heading-6', name: 'Toggle heading 6' }, { id: 'toggle-heading-6', name: 'Toggle heading 6' },
{ id: 'toggle-heading-strip-1', name: 'Toggle heading 1 (strip formatting)' }, { id: 'toggle-heading-strip-1', name: 'Toggle heading 1 (strip formatting)' },
{ id: 'toggle-heading-strip-2', name: 'Toggle heading 2 (strip formatting)' }, { id: 'toggle-heading-strip-2', name: 'Toggle heading 2 (strip formatting)' },
{ id: 'toggle-heading-strip-3', name: 'Toggle heading 3 (strip formatting)' }, { id: 'toggle-heading-strip-3', name: 'Toggle heading 3 (strip formatting)' },
{ id: 'toggle-heading-strip-4', name: 'Toggle heading 4 (strip formatting)' }, { id: 'toggle-heading-strip-4', name: 'Toggle heading 4 (strip formatting)' },
{ id: 'toggle-heading-strip-5', name: 'Toggle heading 5 (strip formatting)' }, { id: 'toggle-heading-strip-5', name: 'Toggle heading 5 (strip formatting)' },
{ id: 'toggle-heading-strip-6', name: 'Toggle heading 6 (strip formatting)' } { id: 'toggle-heading-strip-6', name: 'Toggle heading 6 (strip formatting)' }
], ],
sentenceNavigator: [ sentenceNavigator: [
{ id: 'delete-to-start-of-sentence', name: 'Delete to start of sentence' }, { id: 'delete-to-start-of-sentence', name: 'Delete to start of sentence' },
{ id: 'delete-to-end-of-sentence', name: 'Delete to end of sentence' }, { id: 'delete-to-end-of-sentence', name: 'Delete to end of sentence' },
{ id: 'select-to-start-of-sentence', name: 'Select to start of sentence' }, { id: 'select-to-start-of-sentence', name: 'Select to start of sentence' },
{ id: 'select-to-end-of-sentence', name: 'Select to end of sentence' }, { id: 'select-to-end-of-sentence', name: 'Select to end of sentence' },
{ id: 'move-to-start-of-current-sentence', name: 'Move to start of current sentence' }, { id: 'move-to-start-of-current-sentence', name: 'Move to start of current sentence' },
{ id: 'move-to-start-of-next-sentence', name: 'Move to start of next sentence' }, { id: 'move-to-start-of-next-sentence', name: 'Move to start of next sentence' },
{ id: 'select-sentence', name: 'Select current sentence' } { id: 'select-sentence', name: 'Select current sentence' }
], ],
lineOperations: [ lineOperations: [
{ id: 'insert-line-above', name: 'Insert line above' }, { id: 'insert-line-above', name: 'Insert line above' },
{ id: 'insert-line-below', name: 'Insert line below' }, { id: 'insert-line-below', name: 'Insert line below' },
{ id: 'delete-line', name: 'Delete line' }, { id: 'delete-line', name: 'Delete line' },
{ id: 'delete-to-start-of-line', name: 'Delete to start of line' }, { id: 'delete-to-start-of-line', name: 'Delete to start of line' },
{ id: 'delete-to-end-of-line', name: 'Delete to end of line' }, { id: 'delete-to-end-of-line', name: 'Delete to end of line' },
{ id: 'join-lines', name: 'Join lines' }, { id: 'join-lines', name: 'Join lines' },
{ id: 'duplicate-line', name: 'Duplicate line' }, { id: 'duplicate-line', name: 'Duplicate line' },
{ id: 'copy-line-up', name: 'Copy line up' }, { id: 'copy-line-up', name: 'Copy line up' },
{ id: 'copy-line-down', name: 'Copy line down' }, { id: 'copy-line-down', name: 'Copy line down' },
{ id: 'select-word', name: 'Select word' }, { id: 'select-word', name: 'Select word' },
{ id: 'select-line', name: 'Select line' } { id: 'select-line', name: 'Select line' }
], ],
caseTransformation: [ caseTransformation: [
{ id: 'transform-uppercase', name: 'Transform to uppercase' }, { id: 'transform-uppercase', name: 'Transform to uppercase' },
{ id: 'transform-lowercase', name: 'Transform to lowercase' }, { id: 'transform-lowercase', name: 'Transform to lowercase' },
{ id: 'transform-titlecase', name: 'Transform to title case' }, { id: 'transform-titlecase', name: 'Transform to title case' },
{ id: 'toggle-case', name: 'Toggle case' } { id: 'toggle-case', name: 'Toggle case' }
], ],
navigation: [ navigation: [
{ id: 'go-to-line-start', name: 'Go to line start' }, { id: 'go-to-line-start', name: 'Go to line start' },
{ id: 'go-to-line-end', name: 'Go to line end' }, { id: 'go-to-line-end', name: 'Go to line end' },
{ id: 'go-to-first-line', name: 'Go to first line' }, { id: 'go-to-first-line', name: 'Go to first line' },
{ id: 'go-to-last-line', name: 'Go to last line' }, { id: 'go-to-last-line', name: 'Go to last line' },
{ id: 'go-to-line-number', name: 'Go to line number' }, { id: 'go-to-line-number', name: 'Go to line number' },
{ id: 'go-to-next-heading', name: 'Go to next heading' }, { id: 'go-to-next-heading', name: 'Go to next heading' },
{ id: 'go-to-prev-heading', name: 'Go to previous heading' } { id: 'go-to-prev-heading', name: 'Go to previous heading' }
], ],
cursorMovement: [ cursorMovement: [
{ id: 'move-cursor-up', name: 'Move cursor up' }, { id: 'move-cursor-up', name: 'Move cursor up' },
{ id: 'move-cursor-down', name: 'Move cursor down' }, { id: 'move-cursor-down', name: 'Move cursor down' },
{ id: 'move-cursor-left', name: 'Move cursor left' }, { id: 'move-cursor-left', name: 'Move cursor left' },
{ id: 'move-cursor-right', name: 'Move cursor right' }, { id: 'move-cursor-right', name: 'Move cursor right' },
{ id: 'go-to-prev-word', name: 'Go to previous word' }, { id: 'go-to-prev-word', name: 'Go to previous word' },
{ id: 'go-to-next-word', name: 'Go to next word' }, { id: 'go-to-next-word', name: 'Go to next word' },
{ id: 'insert-cursor-above', name: 'Insert cursor above' }, { id: 'insert-cursor-above', name: 'Insert cursor above' },
{ id: 'insert-cursor-below', name: 'Insert cursor below' }, { id: 'insert-cursor-below', name: 'Insert cursor below' },
{ id: 'select-next-occurrence', name: 'Select next occurrence' }, { id: 'select-next-occurrence', name: 'Select next occurrence' },
{ id: 'select-prev-occurrence', name: 'Select previous occurrence' } { id: 'select-prev-occurrence', name: 'Select previous occurrence' },
], { id: 'select-all-occurrences', name: 'Select all occurrences' }
fileOperations: [ ],
{ id: 'duplicate-file', name: 'Duplicate file' }, fileOperations: [
{ id: 'new-adjacent-file', name: 'New adjacent file' } { id: 'duplicate-file', name: 'Duplicate file' },
], { id: 'new-adjacent-file', name: 'New adjacent file' }
utility: [ ],
{ id: 'toggle-line-numbers', name: 'Toggle line numbers' }, utility: [
{ id: 'undo', name: 'Undo' }, { id: 'toggle-line-numbers', name: 'Toggle line numbers' },
{ id: 'redo', name: 'Redo' } { id: 'undo', name: 'Undo' },
] { id: 'redo', name: 'Redo' }
}; ]
};
// Get all command IDs as an array
export function getAllCommandIds(): string[] { // Get all command IDs as an array
const ids: string[] = []; export function getAllCommandIds(): string[] {
for (const commands of Object.values(COMMANDS)) { const ids: string[] = [];
for (const cmd of commands) { for (const commands of Object.values(COMMANDS)) {
ids.push(cmd.id); for (const cmd of commands) {
} ids.push(cmd.id);
} }
return ids; }
} return ids;
}
// Create default enabled settings (all enabled by default)
export function createDefaultEnabledCommands(): Record<string, boolean> { // Create default enabled settings (all enabled by default)
const enabled: Record<string, boolean> = {}; export function createDefaultEnabledCommands(): Record<string, boolean> {
for (const cmdId of getAllCommandIds()) { const enabled: Record<string, boolean> = {};
enabled[cmdId] = true; for (const cmdId of getAllCommandIds()) {
} enabled[cmdId] = true;
return enabled; }
} return enabled;
}
// ============================================================
// Settings Interface // ============================================================
// ============================================================ // Settings Interface
// ============================================================
export interface BindThemSettings {
debug: boolean; export interface BindThemSettings {
sentenceRegexSource: string; debug: boolean;
enabledCommands: Record<string, boolean>; sentenceRegexSource: string;
} enabledCommands: Record<string, boolean>;
}
export const DEFAULT_SETTINGS: BindThemSettings = {
debug: false, export const DEFAULT_SETTINGS: BindThemSettings = {
sentenceRegexSource: DEFAULT_SENTENCE_REGEX, debug: false,
enabledCommands: createDefaultEnabledCommands() sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
}; enabledCommands: createDefaultEnabledCommands()
};
// ============================================================
// Settings Tab // ============================================================
// ============================================================ // Settings Tab
// ============================================================
export class BindThemSettingTab extends PluginSettingTab {
plugin: BindThemPlugin; export class BindThemSettingTab extends PluginSettingTab {
plugin: BindThemPlugin;
constructor(app: App, plugin: BindThemPlugin) {
super(app, plugin); constructor(app: App, plugin: BindThemPlugin) {
this.plugin = plugin; super(app, plugin);
} this.plugin = plugin;
}
display(): void {
const { containerEl } = this; display(): void {
containerEl.empty(); const { containerEl } = this;
containerEl.empty();
// Hotkeys link at the top
containerEl.createEl('a', { // Hotkeys link at the top
text: 'Configure keyboard shortcuts', containerEl.createEl('a', {
cls: 'bindthem-hotkeys-link', text: 'Configure keyboard shortcuts',
attr: { cls: 'bindthem-hotkeys-link',
href: '#', attr: {
style: 'display: inline-block; margin-bottom: 1em; color: var(--text-accent); cursor: pointer;' href: '#',
} style: 'display: inline-block; margin-bottom: 1em; color: var(--text-accent); cursor: pointer;'
}, (el) => { }
el.addEventListener('click', (e) => { }, (el) => {
e.preventDefault(); el.addEventListener('click', (e) => {
const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys'); e.preventDefault();
if (hotkeysTab) { const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys');
(hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem'); if (hotkeysTab) {
} (hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem');
}); }
}); });
});
// General settings
new Setting(containerEl) // General settings
.setName('Debug mode') new Setting(containerEl)
.setDesc('Enable debug logging to console') .setName('Debug mode')
.addToggle(toggle => toggle .setDesc('Enable debug logging to console')
.setValue(this.plugin.settings.debug) .addToggle(toggle => toggle
.onChange(async (value) => { .setValue(this.plugin.settings.debug)
this.plugin.settings.debug = value; .onChange(async (value) => {
await this.plugin.saveSettings(); this.plugin.settings.debug = value;
})); await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Sentence regex') new Setting(containerEl)
.setDesc('Regular expression used to match sentences') .setName('Sentence regex')
.addText(text => text .setDesc('Regular expression used to match sentences')
.setValue(this.plugin.settings.sentenceRegexSource) .addText(text => text
.onChange(async (value) => { .setValue(this.plugin.settings.sentenceRegexSource)
this.plugin.settings.sentenceRegexSource = value; .onChange(async (value) => {
await this.plugin.saveSettings(); this.plugin.settings.sentenceRegexSource = value;
})); await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Reset sentence regex') new Setting(containerEl)
.addButton(button => button .setName('Reset sentence regex')
.setButtonText('Reset') .addButton(button => button
.onClick(async () => { .setButtonText('Reset')
this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX; .onClick(async () => {
await this.plugin.saveSettings(); this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX;
this.display(); await this.plugin.saveSettings();
})); this.display();
}));
// Commands section header
new Setting(containerEl) // Commands section header
.setName('Command availability') new Setting(containerEl)
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.') .setName('Command availability')
.setHeading(); .setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.')
.setHeading();
// Category toggle buttons
new Setting(containerEl) // Category toggle buttons
.setName('Enable/disable all') new Setting(containerEl)
.setDesc('Toggle all commands on or off') .setName('Enable/disable all')
.addButton(button => button .setDesc('Toggle all commands on or off')
.setButtonText('Enable all') .addButton(button => button
.onClick(async () => { .setButtonText('Enable all')
for (const cmdId of getAllCommandIds()) { .onClick(async () => {
this.plugin.settings.enabledCommands[cmdId] = true; for (const cmdId of getAllCommandIds()) {
} this.plugin.settings.enabledCommands[cmdId] = true;
await this.plugin.saveSettings(); }
this.display(); await this.plugin.saveSettings();
})) this.display();
.addButton(button => button }))
.setButtonText('Disable all') .addButton(button => button
.setWarning() .setButtonText('Disable all')
.onClick(async () => { .setWarning()
for (const cmdId of getAllCommandIds()) { .onClick(async () => {
this.plugin.settings.enabledCommands[cmdId] = false; for (const cmdId of getAllCommandIds()) {
} this.plugin.settings.enabledCommands[cmdId] = false;
await this.plugin.saveSettings(); }
this.display(); await this.plugin.saveSettings();
})); this.display();
}));
// Render each category
for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) { // Render each category
this.renderCategory(containerEl, categoryKey, categoryName); for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) {
} this.renderCategory(containerEl, categoryKey, categoryName);
}
// About section
new Setting(containerEl) // About section
.setName('About') new Setting(containerEl)
// eslint-disable-next-line obsidianmd/ui/sentence-case .setName('About')
.setDesc('Combines features from obsidian-tweaks, obsidian-editor-shortcuts, heading-toggler, and obsidian-sentence-navigator.') // eslint-disable-next-line obsidianmd/ui/sentence-case
.setHeading(); .setDesc('Combines features from obsidian-tweaks, obsidian-editor-shortcuts, heading-toggler, and obsidian-sentence-navigator.')
} .setHeading();
}
private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void {
const commands = COMMANDS[categoryKey as keyof typeof COMMANDS]; private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void {
if (!commands || commands.length === 0) return; const commands = COMMANDS[categoryKey as keyof typeof COMMANDS];
if (!commands || commands.length === 0) return;
// Category header with toggle all
const categorySetting = new Setting(containerEl) // Category header with toggle all
.setName(categoryName) const categorySetting = new Setting(containerEl)
.setHeading(); .setName(categoryName)
.setHeading();
// Check if all commands in this category are enabled
const allEnabled = commands.every(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false); // Check if all commands in this category are enabled
const allEnabled = commands.every(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false);
// Add toggle all button for this category
categorySetting.addToggle(toggle => { // Add toggle all button for this category
toggle categorySetting.addToggle(toggle => {
.setValue(allEnabled) toggle
.setTooltip('Toggle all commands in this category') .setValue(allEnabled)
.onChange(async (value) => { .setTooltip('Toggle all commands in this category')
for (const cmd of commands) { .onChange(async (value) => {
this.plugin.settings.enabledCommands[cmd.id] = value; for (const cmd of commands) {
} this.plugin.settings.enabledCommands[cmd.id] = value;
await this.plugin.saveSettings(); }
this.display(); await this.plugin.saveSettings();
}); this.display();
}); });
});
// Render individual commands
for (const cmd of commands) { // Render individual commands
new Setting(containerEl) for (const cmd of commands) {
.setName(cmd.name) new Setting(containerEl)
.setDesc(cmd.description || `Command ID: ${cmd.id}`) .setName(cmd.name)
.addToggle(toggle => toggle .setDesc(cmd.description || `Command ID: ${cmd.id}`)
.setValue(this.plugin.settings.enabledCommands[cmd.id] !== false) .addToggle(toggle => toggle
.onChange(async (value) => { .setValue(this.plugin.settings.enabledCommands[cmd.id] !== false)
this.plugin.settings.enabledCommands[cmd.id] = value; .onChange(async (value) => {
await this.plugin.saveSettings(); this.plugin.settings.enabledCommands[cmd.id] = value;
})); await this.plugin.saveSettings();
} }));
} }
}
} }

View File

@@ -1,8 +1,8 @@
/* /*
This CSS file will be included with your plugin, and This CSS file will be included with your plugin, and
available in the app when your plugin is enabled. available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file. If your plugin does not need CSS, delete this file.
*/ */

View File

@@ -1,25 +1,24 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "ignoreDeprecations": "5.0",
"inlineSourceMap": true, "baseUrl": ".",
"inlineSources": true, "inlineSourceMap": true,
"module": "ESNext", "inlineSources": true,
"target": "ES6", "module": "ESNext",
"allowJs": true, "target": "ES6",
"noImplicitAny": true, "allowJs": true,
"moduleResolution": "node", "noImplicitAny": true,
"importHelpers": true, "moduleResolution": "node",
"isolatedModules": true, "importHelpers": true,
"strictNullChecks": true, "isolatedModules": true,
"strict": true, "strictNullChecks": true,
"lib": [ "strict": true,
"DOM", "lib": [
"ES5", "DOM",
"ES6", "ES2020"
"ES7" ]
] },
}, "include": [
"include": [ "src/**/*.ts"
"src/**/*.ts" ]
]
} }

View File

@@ -1,17 +1,17 @@
import { readFileSync, writeFileSync } from "fs"; import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version; const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version // read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest; const { minAppVersion } = manifest;
manifest.version = targetVersion; manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json // update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json // but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8')); const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) { if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion; versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
} }

View File

@@ -1,3 +1,3 @@
{ {
"1.0.0": "0.15.0" "1.0.0": "0.15.0"
} }