update and fix
This commit is contained in:
56
.github/workflows/lint.yml
vendored
56
.github/workflows/lint.yml
vendored
@@ -1,28 +1,28 @@
|
||||
name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint
|
||||
|
||||
name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint
|
||||
|
||||
|
||||
502
AGENTS.md
502
AGENTS.md
@@ -1,251 +1,251 @@
|
||||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **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`.
|
||||
- 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.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- **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).
|
||||
- **Example file structure**:
|
||||
```
|
||||
src/
|
||||
main.ts # Plugin entry point, lifecycle management
|
||||
settings.ts # Settings interface and defaults
|
||||
commands/ # Command implementations
|
||||
command1.ts
|
||||
command2.ts
|
||||
ui/ # UI components, modals, views
|
||||
modal.ts
|
||||
view.ts
|
||||
utils/ # Utility functions, helpers
|
||||
helpers.ts
|
||||
constants.ts
|
||||
types.ts # TypeScript interfaces and types
|
||||
```
|
||||
- **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.
|
||||
- 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`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- 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
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||
```
|
||||
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||
```
|
||||
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||
|
||||
## Commands & settings
|
||||
|
||||
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||
- Use stable command IDs; avoid renaming once released.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Add commands with stable IDs (don't rename once released).
|
||||
- Provide defaults and validation in settings.
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Organize code across multiple files
|
||||
|
||||
**main.ts** (minimal, lifecycle only):
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MySettings;
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
registerCommands(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**settings.ts**:
|
||||
```ts
|
||||
export interface MySettings {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MySettings = {
|
||||
enabled: true,
|
||||
apiKey: "",
|
||||
};
|
||||
```
|
||||
|
||||
**commands/index.ts**:
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { doSomething } from "./my-command";
|
||||
|
||||
export function registerCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: "do-something",
|
||||
name: "Do something",
|
||||
callback: () => doSomething(plugin),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Add a command
|
||||
|
||||
```ts
|
||||
this.addCommand({
|
||||
id: "your-command-id",
|
||||
name: "Do the thing",
|
||||
callback: () => this.doTheThing(),
|
||||
});
|
||||
```
|
||||
|
||||
### Persist settings
|
||||
|
||||
```ts
|
||||
interface MySettings { enabled: boolean }
|
||||
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
```
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## 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>/`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **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`.
|
||||
- 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.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- **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).
|
||||
- **Example file structure**:
|
||||
```
|
||||
src/
|
||||
main.ts # Plugin entry point, lifecycle management
|
||||
settings.ts # Settings interface and defaults
|
||||
commands/ # Command implementations
|
||||
command1.ts
|
||||
command2.ts
|
||||
ui/ # UI components, modals, views
|
||||
modal.ts
|
||||
view.ts
|
||||
utils/ # Utility functions, helpers
|
||||
helpers.ts
|
||||
constants.ts
|
||||
types.ts # TypeScript interfaces and types
|
||||
```
|
||||
- **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.
|
||||
- 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`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- 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
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||
```
|
||||
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||
```
|
||||
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||
|
||||
## Commands & settings
|
||||
|
||||
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||
- Use stable command IDs; avoid renaming once released.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Add commands with stable IDs (don't rename once released).
|
||||
- Provide defaults and validation in settings.
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Organize code across multiple files
|
||||
|
||||
**main.ts** (minimal, lifecycle only):
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MySettings;
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
registerCommands(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**settings.ts**:
|
||||
```ts
|
||||
export interface MySettings {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MySettings = {
|
||||
enabled: true,
|
||||
apiKey: "",
|
||||
};
|
||||
```
|
||||
|
||||
**commands/index.ts**:
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { doSomething } from "./my-command";
|
||||
|
||||
export function registerCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: "do-something",
|
||||
name: "Do something",
|
||||
callback: () => doSomething(plugin),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Add a command
|
||||
|
||||
```ts
|
||||
this.addCommand({
|
||||
id: "your-command-id",
|
||||
name: "Do the thing",
|
||||
callback: () => this.doTheThing(),
|
||||
});
|
||||
```
|
||||
|
||||
### Persist settings
|
||||
|
||||
```ts
|
||||
interface MySettings { enabled: boolean }
|
||||
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
```
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## 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>/`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
|
||||
8
LICENSE
8
LICENSE
@@ -1,5 +1,5 @@
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
390
README.md
@@ -1,196 +1,196 @@
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
### Better Formatting
|
||||
|
||||
Smart text wrapping that intelligently detects word boundaries and existing formatting. Toggle formatting on and off with a single command.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Toggle Bold (underscores) | Wrap or unwrap selection with `__` |
|
||||
| Toggle Bold (asterisks) | Wrap or unwrap selection with `**` |
|
||||
| Toggle Italics (underscore) | Wrap or unwrap selection with `_` |
|
||||
| Toggle Italics (asterisk) | Wrap or unwrap selection with `*` |
|
||||
| Toggle Code | Wrap or unwrap selection with backticks |
|
||||
| Toggle Comment | Wrap or unwrap selection with `%%` |
|
||||
| Toggle Highlight | Wrap or unwrap selection with `==` |
|
||||
| Toggle Strikethrough | Wrap or unwrap selection with `~~` |
|
||||
| Toggle Math (Inline) | Wrap or unwrap selection with `$` |
|
||||
| Toggle Math (Block) | Wrap or unwrap selection with `$$` |
|
||||
|
||||
### Directional Copy & Move
|
||||
|
||||
Copy or move text in any direction without using the clipboard.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Copy Up | Duplicate the current line(s) above |
|
||||
| Copy Down | Duplicate the current line(s) below |
|
||||
| Copy Left | Duplicate the current selection(s) to the left |
|
||||
| Copy Right | Duplicate the current selection(s) to the right |
|
||||
| Move Left | Shift the current selection one character left |
|
||||
| Move Right | Shift the current selection one character right |
|
||||
|
||||
### Heading Management
|
||||
|
||||
Toggle heading levels H1 through H6 with intelligent detection. If the line already has the target heading level, the heading is removed.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Toggle Heading 1-6 | Apply or remove heading level |
|
||||
| Toggle Heading 1-6 (Strip Formatting) | Apply heading and remove bold/italic formatting from content |
|
||||
|
||||
### Line Operations
|
||||
|
||||
Essential line manipulation commands for efficient editing.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 |
|
||||
| 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 End of Line | Remove all text from cursor to the end of the line |
|
||||
| Join Lines | Merge the current line with the next line |
|
||||
| Duplicate Line | Copy the current line and insert it below |
|
||||
| Copy Line Up | Duplicate the current line above (VSCode style) |
|
||||
| Copy Line Down | Duplicate the current line below (VSCode style) |
|
||||
|
||||
### Selection Commands
|
||||
|
||||
Quick selection tools for text manipulation.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Select Word | Select the word under the cursor |
|
||||
| Select Line | Select the entire current line |
|
||||
|
||||
### Case Transformation
|
||||
|
||||
Transform text case with various options.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Transform to Uppercase | Convert selection to UPPERCASE |
|
||||
| Transform to Lowercase | Convert selection to lowercase |
|
||||
| Transform to Title Case | Convert selection to Title Case |
|
||||
| Toggle Case | Cycle through: UPPERCASE -> lowercase -> Title Case |
|
||||
|
||||
### Navigation
|
||||
|
||||
Jump around your document quickly.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 First Line | Jump to the first 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 Next Heading | Jump to the next heading in the document |
|
||||
| Go to Previous Heading | Jump to the previous heading in the document |
|
||||
|
||||
### Cursor Movement
|
||||
|
||||
Basic cursor movement commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Move Cursor Up | Move cursor up one line |
|
||||
| Move Cursor Down | Move cursor down one line |
|
||||
| Move Cursor Left | Move cursor left 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 Next Word | Move cursor to the start of the next word |
|
||||
|
||||
### Multi-Cursor Support
|
||||
|
||||
Work with multiple cursors simultaneously.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Insert Cursor Above | Add a cursor on the line above |
|
||||
| Insert Cursor Below | Add a cursor on the line below |
|
||||
|
||||
### File Operations
|
||||
|
||||
Quick file management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 |
|
||||
|
||||
### Sentence Navigation
|
||||
|
||||
Navigate and manipulate text at the sentence level. Perfect for prose editing and working with paragraph-style content.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Select current sentence | Select the entire sentence where the cursor is located |
|
||||
|
||||
### Utility Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Toggle Line Numbers | Show or hide line numbers in the editor |
|
||||
| Undo | Undo the last action |
|
||||
| Redo | Redo the last undone action |
|
||||
|
||||
## Installation
|
||||
|
||||
### From Obsidian
|
||||
|
||||
1. Open Settings > Community plugins
|
||||
2. Disable Safe mode if enabled
|
||||
3. Click on "Browse" community plugins
|
||||
4. Search for "BindThem"
|
||||
5. Click Install, then Enable
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest release from the releases page
|
||||
2. Extract the files to your vault's `.obsidian/plugins/bindThem/` folder
|
||||
3. Reload Obsidian
|
||||
4. Enable the plugin in Settings > Community plugins
|
||||
|
||||
## Configuration
|
||||
|
||||
After enabling the plugin, go to Settings > BindThem to access:
|
||||
|
||||
- **Debug mode**: Enable detailed logging for troubleshooting
|
||||
|
||||
## Customizing Keyboard Shortcuts
|
||||
|
||||
All commands can be assigned custom keyboard shortcuts:
|
||||
|
||||
1. Go to Settings > Hotkeys
|
||||
2. Search for "BindThem"
|
||||
3. Click the `+` icon next to any command
|
||||
4. Press your desired key combination
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin merges and refactors functionality from:
|
||||
|
||||
- **obsidian-tweaks** - Better formatting, directional copy/move, file operations
|
||||
- **obsidian-editor-shortcuts** - Line operations, case transformation, navigation, multi-cursor support
|
||||
- **heading-toggler** - Heading toggle with formatting strip
|
||||
- **obsidian-sentence-navigator** - Sentence-level navigation and manipulation
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
### Better Formatting
|
||||
|
||||
Smart text wrapping that intelligently detects word boundaries and existing formatting. Toggle formatting on and off with a single command.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Toggle Bold (underscores) | Wrap or unwrap selection with `__` |
|
||||
| Toggle Bold (asterisks) | Wrap or unwrap selection with `**` |
|
||||
| Toggle Italics (underscore) | Wrap or unwrap selection with `_` |
|
||||
| Toggle Italics (asterisk) | Wrap or unwrap selection with `*` |
|
||||
| Toggle Code | Wrap or unwrap selection with backticks |
|
||||
| Toggle Comment | Wrap or unwrap selection with `%%` |
|
||||
| Toggle Highlight | Wrap or unwrap selection with `==` |
|
||||
| Toggle Strikethrough | Wrap or unwrap selection with `~~` |
|
||||
| Toggle Math (Inline) | Wrap or unwrap selection with `$` |
|
||||
| Toggle Math (Block) | Wrap or unwrap selection with `$$` |
|
||||
|
||||
### Directional Copy & Move
|
||||
|
||||
Copy or move text in any direction without using the clipboard.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Copy Up | Duplicate the current line(s) above |
|
||||
| Copy Down | Duplicate the current line(s) below |
|
||||
| Copy Left | Duplicate the current selection(s) to the left |
|
||||
| Copy Right | Duplicate the current selection(s) to the right |
|
||||
| Move Left | Shift the current selection one character left |
|
||||
| Move Right | Shift the current selection one character right |
|
||||
|
||||
### Heading Management
|
||||
|
||||
Toggle heading levels H1 through H6 with intelligent detection. If the line already has the target heading level, the heading is removed.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Toggle Heading 1-6 | Apply or remove heading level |
|
||||
| Toggle Heading 1-6 (Strip Formatting) | Apply heading and remove bold/italic formatting from content |
|
||||
|
||||
### Line Operations
|
||||
|
||||
Essential line manipulation commands for efficient editing.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 |
|
||||
| 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 End of Line | Remove all text from cursor to the end of the line |
|
||||
| Join Lines | Merge the current line with the next line |
|
||||
| Duplicate Line | Copy the current line and insert it below |
|
||||
| Copy Line Up | Duplicate the current line above (VSCode style) |
|
||||
| Copy Line Down | Duplicate the current line below (VSCode style) |
|
||||
|
||||
### Selection Commands
|
||||
|
||||
Quick selection tools for text manipulation.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Select Word | Select the word under the cursor |
|
||||
| Select Line | Select the entire current line |
|
||||
|
||||
### Case Transformation
|
||||
|
||||
Transform text case with various options.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Transform to Uppercase | Convert selection to UPPERCASE |
|
||||
| Transform to Lowercase | Convert selection to lowercase |
|
||||
| Transform to Title Case | Convert selection to Title Case |
|
||||
| Toggle Case | Cycle through: UPPERCASE -> lowercase -> Title Case |
|
||||
|
||||
### Navigation
|
||||
|
||||
Jump around your document quickly.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 First Line | Jump to the first 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 Next Heading | Jump to the next heading in the document |
|
||||
| Go to Previous Heading | Jump to the previous heading in the document |
|
||||
|
||||
### Cursor Movement
|
||||
|
||||
Basic cursor movement commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Move Cursor Up | Move cursor up one line |
|
||||
| Move Cursor Down | Move cursor down one line |
|
||||
| Move Cursor Left | Move cursor left 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 Next Word | Move cursor to the start of the next word |
|
||||
|
||||
### Multi-Cursor Support
|
||||
|
||||
Work with multiple cursors simultaneously.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Insert Cursor Above | Add a cursor on the line above |
|
||||
| Insert Cursor Below | Add a cursor on the line below |
|
||||
|
||||
### File Operations
|
||||
|
||||
Quick file management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 |
|
||||
|
||||
### Sentence Navigation
|
||||
|
||||
Navigate and manipulate text at the sentence level. Perfect for prose editing and working with paragraph-style content.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Select current sentence | Select the entire sentence where the cursor is located |
|
||||
|
||||
### Utility Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Toggle Line Numbers | Show or hide line numbers in the editor |
|
||||
| Undo | Undo the last action |
|
||||
| Redo | Redo the last undone action |
|
||||
|
||||
## Installation
|
||||
|
||||
### From Obsidian
|
||||
|
||||
1. Open Settings > Community plugins
|
||||
2. Disable Safe mode if enabled
|
||||
3. Click on "Browse" community plugins
|
||||
4. Search for "BindThem"
|
||||
5. Click Install, then Enable
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest release from the releases page
|
||||
2. Extract the files to your vault's `.obsidian/plugins/bindThem/` folder
|
||||
3. Reload Obsidian
|
||||
4. Enable the plugin in Settings > Community plugins
|
||||
|
||||
## Configuration
|
||||
|
||||
After enabling the plugin, go to Settings > BindThem to access:
|
||||
|
||||
- **Debug mode**: Enable detailed logging for troubleshooting
|
||||
|
||||
## Customizing Keyboard Shortcuts
|
||||
|
||||
All commands can be assigned custom keyboard shortcuts:
|
||||
|
||||
1. Go to Settings > Hotkeys
|
||||
2. Search for "BindThem"
|
||||
3. Click the `+` icon next to any command
|
||||
4. Press your desired key combination
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin merges and refactors functionality from:
|
||||
|
||||
- **obsidian-tweaks** - Better formatting, directional copy/move, file operations
|
||||
- **obsidian-editor-shortcuts** - Line operations, case transformation, navigation, multi-cursor support
|
||||
- **heading-toggler** - Heading toggle with formatting strip
|
||||
- **obsidian-sentence-navigator** - Sentence-level navigation and manipulation
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have feature requests, please open an issue on the GitHub repository.
|
||||
@@ -1,64 +1,64 @@
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === 'production');
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: [import.meta.dirname + '/src/main.ts'],
|
||||
bundle: true,
|
||||
external: [
|
||||
'obsidian',
|
||||
'electron',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/collab',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
'child_process',
|
||||
'fs',
|
||||
'path',
|
||||
'os',
|
||||
'util',
|
||||
'stream',
|
||||
'events',
|
||||
'buffer',
|
||||
'crypto',
|
||||
'http',
|
||||
'https',
|
||||
'url',
|
||||
'zlib',
|
||||
'tls',
|
||||
'net',
|
||||
'readline',
|
||||
'querystring',
|
||||
'string_decoder'],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : 'inline',
|
||||
treeShaking: true,
|
||||
outfile: import.meta.dirname + '/main.js',
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === 'production');
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: [import.meta.dirname + '/src/main.ts'],
|
||||
bundle: true,
|
||||
external: [
|
||||
'obsidian',
|
||||
'electron',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/collab',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
'child_process',
|
||||
'fs',
|
||||
'path',
|
||||
'os',
|
||||
'util',
|
||||
'stream',
|
||||
'events',
|
||||
'buffer',
|
||||
'crypto',
|
||||
'http',
|
||||
'https',
|
||||
'url',
|
||||
'zlib',
|
||||
'tls',
|
||||
'net',
|
||||
'readline',
|
||||
'querystring',
|
||||
'string_decoder'],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : 'inline',
|
||||
treeShaking: true,
|
||||
outfile: import.meta.dirname + '/main.js',
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
@@ -1,34 +1,34 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'eslint.config.js',
|
||||
'manifest.json'
|
||||
]
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: ['.json']
|
||||
},
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"eslint.config.js",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
]),
|
||||
);
|
||||
import tseslint from 'typescript-eslint';
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'eslint.config.js',
|
||||
'manifest.json'
|
||||
]
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: ['.json']
|
||||
},
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"eslint.config.js",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "bindthem",
|
||||
"name": "BindThem",
|
||||
"version": "1.0.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.",
|
||||
"author": "Olivier Legendre",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
{
|
||||
"id": "bindthem",
|
||||
"name": "BindThem",
|
||||
"version": "1.0.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.",
|
||||
"author": "Olivier Legendre",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
||||
10320
package-lock.json
generated
10320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
56
package.json
56
package.json
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"name": "bindthem",
|
||||
"version": "1.0.0",
|
||||
"description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"keywords": ["obsidian", "plugin", "editor", "shortcuts", "formatting"],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"@eslint/js": "9.30.1",
|
||||
"jiti": "2.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
}
|
||||
{
|
||||
"name": "bindthem",
|
||||
"version": "1.0.0",
|
||||
"description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"keywords": ["obsidian", "plugin", "editor", "shortcuts", "formatting"],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"@eslint/js": "9.30.1",
|
||||
"jiti": "2.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
@@ -1,298 +1,298 @@
|
||||
import {
|
||||
App,
|
||||
Editor,
|
||||
EditorChange,
|
||||
EditorPosition,
|
||||
EditorRange,
|
||||
EditorRangeOrCaret,
|
||||
EditorSelection,
|
||||
EditorTransaction,
|
||||
MarkdownFileInfo,
|
||||
MarkdownView,
|
||||
} from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { getMainSelection, selectionToRange } from './Utils'
|
||||
import { PRE_WIDOW_EXPAND_CHARS, POST_WIDOW_EXPAND_CHARS } from './Constants'
|
||||
|
||||
/**
|
||||
* BetterFormatting provides enhanced markdown formatting toggling
|
||||
* that intelligently expands selections to word boundaries and
|
||||
* handles widow characters.
|
||||
*/
|
||||
export class BetterFormatting {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a range by words, including intelligent handling of widow characters
|
||||
*/
|
||||
private expandRangeByWords(
|
||||
editor: Editor,
|
||||
range: EditorRange,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): EditorRange {
|
||||
// Expand to word boundaries
|
||||
let from = (editor.wordAt(range.from) || range).from
|
||||
let to = (editor.wordAt(range.to) || range).to
|
||||
|
||||
// Include leading 'widows' (characters that should be included before the word)
|
||||
while (true) {
|
||||
const newFrom: EditorPosition = {
|
||||
line: from.line,
|
||||
ch: from.ch - 1,
|
||||
}
|
||||
|
||||
const newTo: EditorPosition = {
|
||||
line: to.line,
|
||||
ch: to.ch + 1,
|
||||
}
|
||||
|
||||
const preChar = editor.getRange(newFrom, from)
|
||||
const postChar = editor.getRange(to, newTo)
|
||||
|
||||
// Don't do this if widows form a matched pair
|
||||
if (preChar === postChar) {
|
||||
break
|
||||
}
|
||||
|
||||
if (symbolStart.endsWith(preChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!PRE_WIDOW_EXPAND_CHARS.includes(preChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
from = newFrom
|
||||
}
|
||||
|
||||
// Include trailing 'widows'
|
||||
while (true) {
|
||||
const newFrom: EditorPosition = {
|
||||
line: from.line,
|
||||
ch: from.ch - 1,
|
||||
}
|
||||
|
||||
const newTo: EditorPosition = {
|
||||
line: to.line,
|
||||
ch: to.ch + 1,
|
||||
}
|
||||
|
||||
const preChar = editor.getRange(newFrom, from)
|
||||
const postChar = editor.getRange(to, newTo)
|
||||
|
||||
// Don't do this if widows form a matched pair
|
||||
if (preChar === postChar) {
|
||||
break
|
||||
}
|
||||
|
||||
if (symbolEnd.startsWith(postChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!POST_WIDOW_EXPAND_CHARS.includes(postChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
to = newTo
|
||||
}
|
||||
|
||||
// Include leading symbolStart
|
||||
const newFrom = { line: from.line, ch: from.ch - symbolStart.length }
|
||||
const preString = editor.getRange(newFrom, from)
|
||||
if (preString === symbolStart) {
|
||||
from = newFrom
|
||||
}
|
||||
|
||||
// Include following symbolEnd
|
||||
const newTo = { line: to.line, ch: to.ch + symbolEnd.length }
|
||||
const postString = editor.getRange(to, newTo)
|
||||
if (postString === symbolEnd) {
|
||||
to = newTo
|
||||
}
|
||||
|
||||
return {
|
||||
from: from,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text in the given range is already wrapped with the specified symbols
|
||||
*/
|
||||
private isWrapped(
|
||||
editor: Editor,
|
||||
range: EditorRange,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): boolean {
|
||||
const text = editor.getRange(range.from, range.to)
|
||||
return text.startsWith(symbolStart) && text.endsWith(symbolEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create changes to wrap text with symbols
|
||||
*/
|
||||
private wrap(
|
||||
editor: Editor,
|
||||
textRange: EditorRange,
|
||||
selection: EditorSelection,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): [Array<EditorChange>, EditorSelection] {
|
||||
const changes: Array<EditorChange> = [
|
||||
{
|
||||
from: textRange.from,
|
||||
text: symbolStart,
|
||||
},
|
||||
{
|
||||
from: textRange.to,
|
||||
text: symbolEnd,
|
||||
},
|
||||
]
|
||||
|
||||
const newSelection: EditorSelection = {
|
||||
anchor: { ...selection.anchor },
|
||||
head: { ...selection.head },
|
||||
}
|
||||
newSelection.anchor.ch += symbolStart.length
|
||||
newSelection.head.ch += symbolEnd.length
|
||||
|
||||
return [changes, newSelection]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create changes to unwrap text from symbols
|
||||
*/
|
||||
private unwrap(
|
||||
editor: Editor,
|
||||
textRange: EditorRange,
|
||||
selection: EditorSelection,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): [Array<EditorChange>, EditorSelection] {
|
||||
const changes: Array<EditorChange> = [
|
||||
{
|
||||
from: textRange.from,
|
||||
to: {
|
||||
line: textRange.from.line,
|
||||
ch: textRange.from.ch + symbolStart.length,
|
||||
},
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
from: {
|
||||
line: textRange.to.line,
|
||||
ch: textRange.to.ch - symbolEnd.length,
|
||||
},
|
||||
to: textRange.to,
|
||||
text: '',
|
||||
},
|
||||
]
|
||||
|
||||
const newSelection: EditorSelection = {
|
||||
anchor: { ...selection.anchor },
|
||||
head: { ...selection.head },
|
||||
}
|
||||
newSelection.anchor.ch -= symbolStart.length
|
||||
newSelection.head.ch -= symbolStart.length
|
||||
|
||||
return [changes, newSelection]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the wrap state for a selection
|
||||
*/
|
||||
private setSelectionWrapState(
|
||||
editor: Editor,
|
||||
selection: EditorSelection,
|
||||
wrapState: boolean,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): [Array<EditorChange>, EditorSelection] {
|
||||
const initialRange = selectionToRange(selection)
|
||||
const textRange: EditorRange = this.expandRangeByWords(
|
||||
editor,
|
||||
initialRange,
|
||||
symbolStart,
|
||||
symbolEnd
|
||||
)
|
||||
|
||||
// Check if already wrapped
|
||||
const alreadyWrapped = this.isWrapped(editor, textRange, symbolStart, symbolEnd)
|
||||
if (alreadyWrapped === wrapState) {
|
||||
return [[], selection]
|
||||
}
|
||||
|
||||
// Wrap or unwrap
|
||||
if (wrapState) {
|
||||
return this.wrap(editor, textRange, selection, symbolStart, symbolEnd)
|
||||
}
|
||||
|
||||
return this.unwrap(editor, textRange, selection, symbolStart, symbolEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle wrapper symbols around the current selection(s)
|
||||
*
|
||||
* Principle: Toggling twice == no-op
|
||||
*/
|
||||
public toggleWrapper(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
symbolStart: string,
|
||||
symbolEnd?: string
|
||||
): void {
|
||||
if (symbolEnd === undefined) {
|
||||
symbolEnd = symbolStart
|
||||
}
|
||||
|
||||
const selections = editor.listSelections()
|
||||
const mainSelection = getMainSelection(editor)
|
||||
|
||||
// Get wrapped state of main selection
|
||||
const mainRange: EditorRange = this.expandRangeByWords(
|
||||
editor,
|
||||
selectionToRange(mainSelection),
|
||||
symbolStart,
|
||||
symbolEnd
|
||||
)
|
||||
|
||||
// Check if already wrapped
|
||||
const isWrapped = this.isWrapped(editor, mainRange, symbolStart, symbolEnd)
|
||||
const targetWrapState = !isWrapped
|
||||
|
||||
// Process all selections
|
||||
const newChanges: Array<EditorChange> = []
|
||||
const newSelectionRanges: Array<EditorRangeOrCaret> = []
|
||||
|
||||
for (const selection of selections) {
|
||||
const [changes, newSelection] = this.setSelectionWrapState(
|
||||
editor,
|
||||
selection,
|
||||
targetWrapState,
|
||||
symbolStart,
|
||||
symbolEnd
|
||||
)
|
||||
|
||||
newChanges.push(...changes)
|
||||
newSelectionRanges.push(selectionToRange(newSelection))
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: newChanges,
|
||||
selections: newSelectionRanges,
|
||||
}
|
||||
|
||||
let origin = targetWrapState ? '+' : '-'
|
||||
origin += 'BetterFormatting_' + symbolStart + symbolEnd
|
||||
|
||||
editor.transaction(transaction, origin)
|
||||
}
|
||||
import {
|
||||
App,
|
||||
Editor,
|
||||
EditorChange,
|
||||
EditorPosition,
|
||||
EditorRange,
|
||||
EditorRangeOrCaret,
|
||||
EditorSelection,
|
||||
EditorTransaction,
|
||||
MarkdownFileInfo,
|
||||
MarkdownView,
|
||||
} from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { getMainSelection, selectionToRange } from './Utils'
|
||||
import { PRE_WIDOW_EXPAND_CHARS, POST_WIDOW_EXPAND_CHARS } from './Constants'
|
||||
|
||||
/**
|
||||
* BetterFormatting provides enhanced markdown formatting toggling
|
||||
* that intelligently expands selections to word boundaries and
|
||||
* handles widow characters.
|
||||
*/
|
||||
export class BetterFormatting {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a range by words, including intelligent handling of widow characters
|
||||
*/
|
||||
private expandRangeByWords(
|
||||
editor: Editor,
|
||||
range: EditorRange,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): EditorRange {
|
||||
// Expand to word boundaries
|
||||
let from = (editor.wordAt(range.from) || range).from
|
||||
let to = (editor.wordAt(range.to) || range).to
|
||||
|
||||
// Include leading 'widows' (characters that should be included before the word)
|
||||
while (true) {
|
||||
const newFrom: EditorPosition = {
|
||||
line: from.line,
|
||||
ch: from.ch - 1,
|
||||
}
|
||||
|
||||
const newTo: EditorPosition = {
|
||||
line: to.line,
|
||||
ch: to.ch + 1,
|
||||
}
|
||||
|
||||
const preChar = editor.getRange(newFrom, from)
|
||||
const postChar = editor.getRange(to, newTo)
|
||||
|
||||
// Don't do this if widows form a matched pair
|
||||
if (preChar === postChar) {
|
||||
break
|
||||
}
|
||||
|
||||
if (symbolStart.endsWith(preChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!PRE_WIDOW_EXPAND_CHARS.includes(preChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
from = newFrom
|
||||
}
|
||||
|
||||
// Include trailing 'widows'
|
||||
while (true) {
|
||||
const newFrom: EditorPosition = {
|
||||
line: from.line,
|
||||
ch: from.ch - 1,
|
||||
}
|
||||
|
||||
const newTo: EditorPosition = {
|
||||
line: to.line,
|
||||
ch: to.ch + 1,
|
||||
}
|
||||
|
||||
const preChar = editor.getRange(newFrom, from)
|
||||
const postChar = editor.getRange(to, newTo)
|
||||
|
||||
// Don't do this if widows form a matched pair
|
||||
if (preChar === postChar) {
|
||||
break
|
||||
}
|
||||
|
||||
if (symbolEnd.startsWith(postChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!POST_WIDOW_EXPAND_CHARS.includes(postChar)) {
|
||||
break
|
||||
}
|
||||
|
||||
to = newTo
|
||||
}
|
||||
|
||||
// Include leading symbolStart
|
||||
const newFrom = { line: from.line, ch: from.ch - symbolStart.length }
|
||||
const preString = editor.getRange(newFrom, from)
|
||||
if (preString === symbolStart) {
|
||||
from = newFrom
|
||||
}
|
||||
|
||||
// Include following symbolEnd
|
||||
const newTo = { line: to.line, ch: to.ch + symbolEnd.length }
|
||||
const postString = editor.getRange(to, newTo)
|
||||
if (postString === symbolEnd) {
|
||||
to = newTo
|
||||
}
|
||||
|
||||
return {
|
||||
from: from,
|
||||
to: to,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text in the given range is already wrapped with the specified symbols
|
||||
*/
|
||||
private isWrapped(
|
||||
editor: Editor,
|
||||
range: EditorRange,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): boolean {
|
||||
const text = editor.getRange(range.from, range.to)
|
||||
return text.startsWith(symbolStart) && text.endsWith(symbolEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create changes to wrap text with symbols
|
||||
*/
|
||||
private wrap(
|
||||
editor: Editor,
|
||||
textRange: EditorRange,
|
||||
selection: EditorSelection,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): [Array<EditorChange>, EditorSelection] {
|
||||
const changes: Array<EditorChange> = [
|
||||
{
|
||||
from: textRange.from,
|
||||
text: symbolStart,
|
||||
},
|
||||
{
|
||||
from: textRange.to,
|
||||
text: symbolEnd,
|
||||
},
|
||||
]
|
||||
|
||||
const newSelection: EditorSelection = {
|
||||
anchor: { ...selection.anchor },
|
||||
head: { ...selection.head },
|
||||
}
|
||||
newSelection.anchor.ch += symbolStart.length
|
||||
newSelection.head.ch += symbolEnd.length
|
||||
|
||||
return [changes, newSelection]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create changes to unwrap text from symbols
|
||||
*/
|
||||
private unwrap(
|
||||
editor: Editor,
|
||||
textRange: EditorRange,
|
||||
selection: EditorSelection,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): [Array<EditorChange>, EditorSelection] {
|
||||
const changes: Array<EditorChange> = [
|
||||
{
|
||||
from: textRange.from,
|
||||
to: {
|
||||
line: textRange.from.line,
|
||||
ch: textRange.from.ch + symbolStart.length,
|
||||
},
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
from: {
|
||||
line: textRange.to.line,
|
||||
ch: textRange.to.ch - symbolEnd.length,
|
||||
},
|
||||
to: textRange.to,
|
||||
text: '',
|
||||
},
|
||||
]
|
||||
|
||||
const newSelection: EditorSelection = {
|
||||
anchor: { ...selection.anchor },
|
||||
head: { ...selection.head },
|
||||
}
|
||||
newSelection.anchor.ch -= symbolStart.length
|
||||
newSelection.head.ch -= symbolStart.length
|
||||
|
||||
return [changes, newSelection]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the wrap state for a selection
|
||||
*/
|
||||
private setSelectionWrapState(
|
||||
editor: Editor,
|
||||
selection: EditorSelection,
|
||||
wrapState: boolean,
|
||||
symbolStart: string,
|
||||
symbolEnd: string
|
||||
): [Array<EditorChange>, EditorSelection] {
|
||||
const initialRange = selectionToRange(selection)
|
||||
const textRange: EditorRange = this.expandRangeByWords(
|
||||
editor,
|
||||
initialRange,
|
||||
symbolStart,
|
||||
symbolEnd
|
||||
)
|
||||
|
||||
// Check if already wrapped
|
||||
const alreadyWrapped = this.isWrapped(editor, textRange, symbolStart, symbolEnd)
|
||||
if (alreadyWrapped === wrapState) {
|
||||
return [[], selection]
|
||||
}
|
||||
|
||||
// Wrap or unwrap
|
||||
if (wrapState) {
|
||||
return this.wrap(editor, textRange, selection, symbolStart, symbolEnd)
|
||||
}
|
||||
|
||||
return this.unwrap(editor, textRange, selection, symbolStart, symbolEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle wrapper symbols around the current selection(s)
|
||||
*
|
||||
* Principle: Toggling twice == no-op
|
||||
*/
|
||||
public toggleWrapper(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
symbolStart: string,
|
||||
symbolEnd?: string
|
||||
): void {
|
||||
if (symbolEnd === undefined) {
|
||||
symbolEnd = symbolStart
|
||||
}
|
||||
|
||||
const selections = editor.listSelections()
|
||||
const mainSelection = getMainSelection(editor)
|
||||
|
||||
// Get wrapped state of main selection
|
||||
const mainRange: EditorRange = this.expandRangeByWords(
|
||||
editor,
|
||||
selectionToRange(mainSelection),
|
||||
symbolStart,
|
||||
symbolEnd
|
||||
)
|
||||
|
||||
// Check if already wrapped
|
||||
const isWrapped = this.isWrapped(editor, mainRange, symbolStart, symbolEnd)
|
||||
const targetWrapState = !isWrapped
|
||||
|
||||
// Process all selections
|
||||
const newChanges: Array<EditorChange> = []
|
||||
const newSelectionRanges: Array<EditorRangeOrCaret> = []
|
||||
|
||||
for (const selection of selections) {
|
||||
const [changes, newSelection] = this.setSelectionWrapState(
|
||||
editor,
|
||||
selection,
|
||||
targetWrapState,
|
||||
symbolStart,
|
||||
symbolEnd
|
||||
)
|
||||
|
||||
newChanges.push(...changes)
|
||||
newSelectionRanges.push(selectionToRange(newSelection))
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: newChanges,
|
||||
selections: newSelectionRanges,
|
||||
}
|
||||
|
||||
let origin = targetWrapState ? '+' : '-'
|
||||
origin += 'BetterFormatting_' + symbolStart + symbolEnd
|
||||
|
||||
editor.transaction(transaction, origin)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,42 @@
|
||||
// ============================================================
|
||||
// Debug Constants
|
||||
// ============================================================
|
||||
|
||||
export const DEBUG_HEAD = '[BindThem] '
|
||||
|
||||
// ============================================================
|
||||
// Better Formatting Constants
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Characters to expand selection before wrapper symbols
|
||||
*/
|
||||
export const PRE_WIDOW_EXPAND_CHARS = '#$@'
|
||||
|
||||
/**
|
||||
* Characters to expand selection after wrapper symbols
|
||||
*/
|
||||
export const POST_WIDOW_EXPAND_CHARS = '!?:'
|
||||
|
||||
// ============================================================
|
||||
// Sentence Navigator Constants
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Default regex for matching sentences
|
||||
*/
|
||||
export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)'
|
||||
|
||||
/**
|
||||
* Regex for matching list item prefixes
|
||||
*/
|
||||
export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/
|
||||
|
||||
// ============================================================
|
||||
// Heading Constants
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Regex for matching heading markers at the start of a line
|
||||
*/
|
||||
// ============================================================
|
||||
// Debug Constants
|
||||
// ============================================================
|
||||
|
||||
export const DEBUG_HEAD = '[BindThem] '
|
||||
|
||||
// ============================================================
|
||||
// Better Formatting Constants
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Characters to expand selection before wrapper symbols
|
||||
*/
|
||||
export const PRE_WIDOW_EXPAND_CHARS = '#$@'
|
||||
|
||||
/**
|
||||
* Characters to expand selection after wrapper symbols
|
||||
*/
|
||||
export const POST_WIDOW_EXPAND_CHARS = '!?:'
|
||||
|
||||
// ============================================================
|
||||
// Sentence Navigator Constants
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Default regex for matching sentences
|
||||
*/
|
||||
export const DEFAULT_SENTENCE_REGEX = '[^.!?\\s][^.!?]*(?:[.!?](?![\'"]?\\s|$)[^.!?]*)*[.!?]?[\'"]?(?=\\s|$)'
|
||||
|
||||
/**
|
||||
* Regex for matching list item prefixes
|
||||
*/
|
||||
export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?$/
|
||||
|
||||
// ============================================================
|
||||
// Heading Constants
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Regex for matching heading markers at the start of a line
|
||||
*/
|
||||
export const HEADING_REGEX = /^(#*)( *)(.*)/
|
||||
@@ -1,91 +1,91 @@
|
||||
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { Direction } from './Entities'
|
||||
import { selectionToLine, selectionToRange } from './Utils'
|
||||
|
||||
/**
|
||||
* DirectionalCopy provides commands to copy content in different directions
|
||||
*/
|
||||
export class DirectionalCopy {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current selection(s) in the specified direction
|
||||
*/
|
||||
public directionalCopy(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
direction: Direction
|
||||
): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const vertical: boolean = direction === Direction.Up || direction === Direction.Down
|
||||
|
||||
// If vertical we want to work with whole lines
|
||||
if (vertical) {
|
||||
selections.forEach((selection, idx, arr) => {
|
||||
arr[idx] = selectionToLine(editor, selection)
|
||||
})
|
||||
}
|
||||
|
||||
const changes: Array<EditorChange> = []
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
const content = editor.getRange(range.from, range.to)
|
||||
|
||||
let change: EditorChange
|
||||
switch (direction) {
|
||||
case Direction.Up: {
|
||||
change = {
|
||||
from: range.from,
|
||||
to: range.from,
|
||||
text: content + '\n',
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Down: {
|
||||
change = {
|
||||
from: range.to,
|
||||
to: range.to,
|
||||
text: '\n' + content,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Left: {
|
||||
change = {
|
||||
from: range.from,
|
||||
to: range.from,
|
||||
text: content,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Right: {
|
||||
change = {
|
||||
from: range.to,
|
||||
to: range.to,
|
||||
text: content,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
changes.push(change)
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: changes,
|
||||
}
|
||||
|
||||
const origin = 'DirectionalCopy_' + String(direction)
|
||||
editor.transaction(transaction, origin)
|
||||
}
|
||||
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { Direction } from './Entities'
|
||||
import { selectionToLine, selectionToRange } from './Utils'
|
||||
|
||||
/**
|
||||
* DirectionalCopy provides commands to copy content in different directions
|
||||
*/
|
||||
export class DirectionalCopy {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current selection(s) in the specified direction
|
||||
*/
|
||||
public directionalCopy(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
direction: Direction
|
||||
): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const vertical: boolean = direction === Direction.Up || direction === Direction.Down
|
||||
|
||||
// If vertical we want to work with whole lines
|
||||
if (vertical) {
|
||||
selections.forEach((selection, idx, arr) => {
|
||||
arr[idx] = selectionToLine(editor, selection)
|
||||
})
|
||||
}
|
||||
|
||||
const changes: Array<EditorChange> = []
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
const content = editor.getRange(range.from, range.to)
|
||||
|
||||
let change: EditorChange
|
||||
switch (direction) {
|
||||
case Direction.Up: {
|
||||
change = {
|
||||
from: range.from,
|
||||
to: range.from,
|
||||
text: content + '\n',
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Down: {
|
||||
change = {
|
||||
from: range.to,
|
||||
to: range.to,
|
||||
text: '\n' + content,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Left: {
|
||||
change = {
|
||||
from: range.from,
|
||||
to: range.from,
|
||||
text: content,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Right: {
|
||||
change = {
|
||||
from: range.to,
|
||||
to: range.to,
|
||||
text: content,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
changes.push(change)
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: changes,
|
||||
}
|
||||
|
||||
const origin = 'DirectionalCopy_' + String(direction)
|
||||
editor.transaction(transaction, origin)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,82 @@
|
||||
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { Direction } from './Entities'
|
||||
import { selectionToRange } from './Utils'
|
||||
|
||||
/**
|
||||
* DirectionalMove provides commands to move selection content left or right
|
||||
*/
|
||||
export class DirectionalMove {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the current selection(s) in the specified direction (left or right)
|
||||
*/
|
||||
public directionalMove(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
direction: Direction.Left | Direction.Right
|
||||
): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const changes: Array<EditorChange> = []
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
|
||||
let additionChange: EditorChange
|
||||
let deletionChange: EditorChange
|
||||
switch (direction) {
|
||||
case Direction.Left: {
|
||||
deletionChange = {
|
||||
from: {
|
||||
line: range.from.line,
|
||||
ch: range.from.ch - 1,
|
||||
},
|
||||
to: range.from,
|
||||
text: '',
|
||||
}
|
||||
|
||||
additionChange = {
|
||||
from: range.to,
|
||||
to: range.to,
|
||||
text: editor.getRange(deletionChange.from, deletionChange.to!),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Right: {
|
||||
deletionChange = {
|
||||
from: range.to,
|
||||
to: {
|
||||
line: range.to.line,
|
||||
ch: range.to.ch + 1,
|
||||
},
|
||||
text: '',
|
||||
}
|
||||
|
||||
additionChange = {
|
||||
from: range.from,
|
||||
to: range.from,
|
||||
text: editor.getRange(deletionChange.from, deletionChange.to!),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
changes.push(deletionChange, additionChange)
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: changes,
|
||||
}
|
||||
|
||||
const origin = 'DirectionalMove_' + String(direction)
|
||||
editor.transaction(transaction, origin)
|
||||
}
|
||||
import { App, Editor, EditorChange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { Direction } from './Entities'
|
||||
import { selectionToRange } from './Utils'
|
||||
|
||||
/**
|
||||
* DirectionalMove provides commands to move selection content left or right
|
||||
*/
|
||||
export class DirectionalMove {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the current selection(s) in the specified direction (left or right)
|
||||
*/
|
||||
public directionalMove(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
direction: Direction.Left | Direction.Right
|
||||
): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const changes: Array<EditorChange> = []
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
|
||||
let additionChange: EditorChange
|
||||
let deletionChange: EditorChange
|
||||
switch (direction) {
|
||||
case Direction.Left: {
|
||||
deletionChange = {
|
||||
from: {
|
||||
line: range.from.line,
|
||||
ch: range.from.ch - 1,
|
||||
},
|
||||
to: range.from,
|
||||
text: '',
|
||||
}
|
||||
|
||||
additionChange = {
|
||||
from: range.to,
|
||||
to: range.to,
|
||||
text: editor.getRange(deletionChange.from, deletionChange.to!),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case Direction.Right: {
|
||||
deletionChange = {
|
||||
from: range.to,
|
||||
to: {
|
||||
line: range.to.line,
|
||||
ch: range.to.ch + 1,
|
||||
},
|
||||
text: '',
|
||||
}
|
||||
|
||||
additionChange = {
|
||||
from: range.from,
|
||||
to: range.from,
|
||||
text: editor.getRange(deletionChange.from, deletionChange.to!),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
changes.push(deletionChange, additionChange)
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: changes,
|
||||
}
|
||||
|
||||
const origin = 'DirectionalMove_' + String(direction)
|
||||
editor.transaction(transaction, origin)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,43 @@
|
||||
import { EditorPosition, EditorRange, EditorSelection } from 'obsidian'
|
||||
|
||||
// ============================================================
|
||||
// Direction Enum
|
||||
// ============================================================
|
||||
|
||||
export enum Direction {
|
||||
Up = 'Up',
|
||||
Down = 'Down',
|
||||
Left = 'Left',
|
||||
Right = 'Right',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Heading Enum
|
||||
// ============================================================
|
||||
|
||||
export enum Heading {
|
||||
NORMAL = 0,
|
||||
H1 = 1,
|
||||
H2 = 2,
|
||||
H3 = 3,
|
||||
H4 = 4,
|
||||
H5 = 5,
|
||||
H6 = 6,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Case Type Enum
|
||||
// ============================================================
|
||||
|
||||
export enum CaseType {
|
||||
Upper,
|
||||
Lower,
|
||||
Title,
|
||||
Next,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Editor Types (re-exported from Obsidian for convenience)
|
||||
// ============================================================
|
||||
|
||||
import { EditorPosition, EditorRange, EditorSelection } from 'obsidian'
|
||||
|
||||
// ============================================================
|
||||
// Direction Enum
|
||||
// ============================================================
|
||||
|
||||
export enum Direction {
|
||||
Up = 'Up',
|
||||
Down = 'Down',
|
||||
Left = 'Left',
|
||||
Right = 'Right',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Heading Enum
|
||||
// ============================================================
|
||||
|
||||
export enum Heading {
|
||||
NORMAL = 0,
|
||||
H1 = 1,
|
||||
H2 = 2,
|
||||
H3 = 3,
|
||||
H4 = 4,
|
||||
H5 = 5,
|
||||
H6 = 6,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Case Type Enum
|
||||
// ============================================================
|
||||
|
||||
export enum CaseType {
|
||||
Upper,
|
||||
Lower,
|
||||
Title,
|
||||
Next,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Editor Types (re-exported from Obsidian for convenience)
|
||||
// ============================================================
|
||||
|
||||
export type { EditorPosition, EditorRange, EditorSelection }
|
||||
@@ -1,65 +1,65 @@
|
||||
import { App, Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
|
||||
/**
|
||||
* FileHelper provides commands for file operations
|
||||
*/
|
||||
export class FileHelper {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the current file
|
||||
*/
|
||||
public async duplicateFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
|
||||
if (activeFile === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const selections = editor.listSelections()
|
||||
const parentPath = activeFile.parent?.path ?? ''
|
||||
const newFilePath =
|
||||
parentPath + '/' + activeFile.basename + ' (copy).' + activeFile.extension
|
||||
|
||||
try {
|
||||
const newFile = await this.app.vault.copy(activeFile, newFilePath)
|
||||
if (view instanceof MarkdownView) {
|
||||
await view.leaf.openFile(newFile)
|
||||
}
|
||||
|
||||
editor.setSelections(selections)
|
||||
} catch (e) {
|
||||
new Notice(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new file in the same directory as the current file
|
||||
*/
|
||||
public async newAdjacentFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
|
||||
if (activeFile === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentPath = activeFile.parent?.path ?? ''
|
||||
const newFilePath = parentPath + '/' + 'Untitled.md'
|
||||
|
||||
try {
|
||||
const newFile = await this.app.vault.create(newFilePath, '')
|
||||
if (view instanceof MarkdownView) {
|
||||
await view.leaf.openFile(newFile)
|
||||
}
|
||||
} catch (e) {
|
||||
new Notice(String(e))
|
||||
}
|
||||
}
|
||||
import { App, Editor, MarkdownFileInfo, MarkdownView, Notice } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
|
||||
/**
|
||||
* FileHelper provides commands for file operations
|
||||
*/
|
||||
export class FileHelper {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the current file
|
||||
*/
|
||||
public async duplicateFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
|
||||
if (activeFile === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const selections = editor.listSelections()
|
||||
const parentPath = activeFile.parent?.path ?? ''
|
||||
const newFilePath =
|
||||
parentPath + '/' + activeFile.basename + ' (copy).' + activeFile.extension
|
||||
|
||||
try {
|
||||
const newFile = await this.app.vault.copy(activeFile, newFilePath)
|
||||
if (view instanceof MarkdownView) {
|
||||
await view.leaf.openFile(newFile)
|
||||
}
|
||||
|
||||
editor.setSelections(selections)
|
||||
} catch (e) {
|
||||
new Notice(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new file in the same directory as the current file
|
||||
*/
|
||||
public async newAdjacentFile(editor: Editor, view: MarkdownView | MarkdownFileInfo): Promise<void> {
|
||||
const activeFile = this.app.workspace.getActiveFile()
|
||||
|
||||
if (activeFile === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentPath = activeFile.parent?.path ?? ''
|
||||
const newFilePath = parentPath + '/' + 'Untitled.md'
|
||||
|
||||
try {
|
||||
const newFile = await this.app.vault.create(newFilePath, '')
|
||||
if (view instanceof MarkdownView) {
|
||||
await view.leaf.openFile(newFile)
|
||||
}
|
||||
} catch (e) {
|
||||
new Notice(String(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,63 @@
|
||||
import { App, Editor, EditorRange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { selectionToLine, selectionToRange } from './Utils'
|
||||
|
||||
/**
|
||||
* SelectionHelper provides commands for manipulating text selections
|
||||
*/
|
||||
export class SelectionHelper {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the current line(s)
|
||||
*/
|
||||
public selectLine(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const newSelectionRanges: Array<EditorRange> = []
|
||||
for (const selection of selections) {
|
||||
const newSelection = selectionToLine(editor, selection)
|
||||
newSelectionRanges.push(selectionToRange(newSelection))
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
selections: newSelectionRanges,
|
||||
}
|
||||
|
||||
editor.transaction(transaction, 'SelectionHelper_Line')
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the current word(s)
|
||||
*/
|
||||
public selectWord(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const newSelections: Array<EditorRange> = []
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
|
||||
const wordStart = editor.wordAt(range.from)?.from ?? range.from
|
||||
const wordEnd = editor.wordAt(range.to)?.to ?? range.from
|
||||
|
||||
const newSelection: EditorRange = {
|
||||
from: { line: wordStart.line, ch: wordStart.ch },
|
||||
to: { line: wordEnd.line, ch: wordEnd.ch },
|
||||
}
|
||||
|
||||
newSelections.push(newSelection)
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
selections: newSelections,
|
||||
}
|
||||
|
||||
editor.transaction(transaction, 'SelectionHelper_Word')
|
||||
}
|
||||
import { App, Editor, EditorRange, EditorTransaction, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { selectionToLine, selectionToRange } from './Utils'
|
||||
|
||||
/**
|
||||
* SelectionHelper provides commands for manipulating text selections
|
||||
*/
|
||||
export class SelectionHelper {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the current line(s)
|
||||
*/
|
||||
public selectLine(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const newSelectionRanges: Array<EditorRange> = []
|
||||
for (const selection of selections) {
|
||||
const newSelection = selectionToLine(editor, selection)
|
||||
newSelectionRanges.push(selectionToRange(newSelection))
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
selections: newSelectionRanges,
|
||||
}
|
||||
|
||||
editor.transaction(transaction, 'SelectionHelper_Line')
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the current word(s)
|
||||
*/
|
||||
public selectWord(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
const selections = editor.listSelections()
|
||||
|
||||
const newSelections: Array<EditorRange> = []
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
|
||||
const wordStart = editor.wordAt(range.from)?.from ?? range.from
|
||||
const wordEnd = editor.wordAt(range.to)?.to ?? range.from
|
||||
|
||||
const newSelection: EditorRange = {
|
||||
from: { line: wordStart.line, ch: wordStart.ch },
|
||||
to: { line: wordEnd.line, ch: wordEnd.ch },
|
||||
}
|
||||
|
||||
newSelections.push(newSelection)
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
selections: newSelections,
|
||||
}
|
||||
|
||||
editor.transaction(transaction, 'SelectionHelper_Word')
|
||||
}
|
||||
}
|
||||
328
src/SelectionOccurrence.ts
Normal file
328
src/SelectionOccurrence.ts
Normal 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)
|
||||
}
|
||||
@@ -1,276 +1,276 @@
|
||||
import { App, Editor, EditorPosition, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { LIST_CHARACTER_REGEX } from './Constants'
|
||||
|
||||
/**
|
||||
* SentenceNavigator provides commands for navigating and manipulating sentences
|
||||
* Based on obsidian-sentence-navigator functionality
|
||||
*/
|
||||
export class SentenceNavigator {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
private sentenceRegex: RegExp
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin, regexSource: string) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
this.sentenceRegex = new RegExp(regexSource, 'gm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the sentence regex pattern
|
||||
*/
|
||||
public updateRegex(regexSource: string): void {
|
||||
this.sentenceRegex = new RegExp(regexSource, 'gm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor position and current paragraph text
|
||||
*/
|
||||
private getCursorAndParagraphText(editor: Editor): {
|
||||
cursorPosition: EditorPosition
|
||||
paragraphText: string
|
||||
} {
|
||||
const cursorPosition = editor.getCursor()
|
||||
return { cursorPosition, paragraphText: editor.getLine(cursorPosition.line) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over all sentences in a paragraph
|
||||
*/
|
||||
private forEachSentence(
|
||||
paragraphText: string,
|
||||
callback: (sentence: RegExpMatchArray) => boolean | void
|
||||
): void {
|
||||
for (const sentence of paragraphText.matchAll(this.sentenceRegex)) {
|
||||
if (callback(sentence)) break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cursor at the next non-space character
|
||||
*/
|
||||
private setCursorAtNextWordCharacter(
|
||||
editor: Editor,
|
||||
cursorPosition: EditorPosition,
|
||||
paragraphText: string,
|
||||
direction: 'start' | 'end'
|
||||
): void {
|
||||
let ch = cursorPosition.ch
|
||||
if (direction === 'start') {
|
||||
while (ch > 0 && paragraphText.charAt(ch - 1) === ' ') ch--
|
||||
} else {
|
||||
while (ch < paragraphText.length && paragraphText.charAt(ch) === ' ') ch++
|
||||
}
|
||||
editor.setCursor({ ch, line: cursorPosition.line })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous non-empty line number
|
||||
*/
|
||||
private getPrevNonEmptyLine(editor: Editor, currentLine: number): number {
|
||||
let prevLine = currentLine - 1
|
||||
while (prevLine >= 0 && editor.getLine(prevLine).length === 0) prevLine--
|
||||
return prevLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next non-empty line number
|
||||
*/
|
||||
private getNextNonEmptyLine(editor: Editor, currentLine: number): number {
|
||||
let nextLine = currentLine + 1
|
||||
while (nextLine < editor.lineCount() && editor.getLine(nextLine).length === 0) nextLine++
|
||||
return nextLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cursor is at the start of a list item
|
||||
*/
|
||||
private isAtStartOfListItem(
|
||||
cursorPosition: EditorPosition,
|
||||
paragraphText: string
|
||||
): boolean {
|
||||
return LIST_CHARACTER_REGEX.test(paragraphText.slice(0, cursorPosition.ch))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete text from cursor to sentence boundary
|
||||
*/
|
||||
public deleteToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void {
|
||||
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
const originalCursorPosition = cursorPosition
|
||||
|
||||
if (
|
||||
paragraphText.charAt(cursorPosition.ch) === ' ' ||
|
||||
paragraphText.charAt(cursorPosition.ch - 1) === ' '
|
||||
) {
|
||||
this.setCursorAtNextWordCharacter(editor, cursorPosition, paragraphText, boundary)
|
||||
;({ cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor))
|
||||
}
|
||||
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
if (
|
||||
cursorPosition.ch >= idx &&
|
||||
cursorPosition.ch <= idx + sentence[0].length
|
||||
) {
|
||||
if (boundary === 'start') {
|
||||
const newText =
|
||||
paragraphText.substring(0, idx) +
|
||||
paragraphText.substring(originalCursorPosition.ch)
|
||||
const cutLength = paragraphText.length - newText.length
|
||||
editor.replaceRange(
|
||||
newText,
|
||||
{ line: cursorPosition.line, ch: 0 },
|
||||
{ line: cursorPosition.line, ch: paragraphText.length }
|
||||
)
|
||||
editor.setCursor({
|
||||
line: cursorPosition.line,
|
||||
ch: originalCursorPosition.ch - cutLength,
|
||||
})
|
||||
} else {
|
||||
const remainingLength = idx + sentence[0].length - cursorPosition.ch
|
||||
const newText =
|
||||
paragraphText.substring(0, originalCursorPosition.ch) +
|
||||
paragraphText.substring(cursorPosition.ch + remainingLength)
|
||||
editor.replaceRange(
|
||||
newText,
|
||||
{ line: cursorPosition.line, ch: 0 },
|
||||
{ line: cursorPosition.line, ch: paragraphText.length }
|
||||
)
|
||||
editor.setCursor(originalCursorPosition)
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select text from cursor to sentence boundary
|
||||
*/
|
||||
public selectToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void {
|
||||
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
if (
|
||||
cursorPosition.ch >= idx &&
|
||||
cursorPosition.ch <= idx + sentence[0].length
|
||||
) {
|
||||
if (
|
||||
editor.getSelection().length > 0 &&
|
||||
(cursorPosition.ch === idx || cursorPosition.ch === idx + sentence[0].length)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (boundary === 'start') {
|
||||
const precedingLength = cursorPosition.ch - idx
|
||||
editor.setSelection(cursorPosition, {
|
||||
line: cursorPosition.line,
|
||||
ch: cursorPosition.ch - precedingLength,
|
||||
})
|
||||
} else {
|
||||
editor.setSelection(cursorPosition, {
|
||||
line: cursorPosition.line,
|
||||
ch: idx + sentence[0].length,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to the start of the current sentence
|
||||
*/
|
||||
public moveToStartOfCurrentSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
|
||||
if (cursorPosition.ch === 0 || this.isAtStartOfListItem(cursorPosition, paragraphText)) {
|
||||
const prevLine = this.getPrevNonEmptyLine(editor, cursorPosition.line)
|
||||
if (prevLine >= 0) {
|
||||
editor.setCursor({ line: prevLine, ch: editor.getLine(prevLine).length })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
while (
|
||||
cursorPosition.ch > 0 &&
|
||||
paragraphText.charAt(cursorPosition.ch - 1) === ' '
|
||||
) {
|
||||
editor.setCursor({
|
||||
line: cursorPosition.line,
|
||||
ch: --cursorPosition.ch,
|
||||
})
|
||||
}
|
||||
if (cursorPosition.ch > idx && idx + sentence[0].length >= cursorPosition.ch) {
|
||||
editor.setCursor({ line: cursorPosition.line, ch: idx })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to the start of the next sentence
|
||||
*/
|
||||
public moveToStartOfNextSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
|
||||
if (cursorPosition.ch === paragraphText.length) {
|
||||
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
|
||||
if (nextLine < editor.lineCount()) {
|
||||
editor.setCursor({ line: nextLine, ch: 0 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
while (
|
||||
cursorPosition.ch < paragraphText.length &&
|
||||
paragraphText.charAt(cursorPosition.ch) === ' '
|
||||
) {
|
||||
editor.setCursor({
|
||||
line: cursorPosition.line,
|
||||
ch: ++cursorPosition.ch,
|
||||
})
|
||||
}
|
||||
if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) {
|
||||
const endPos = { line: cursorPosition.line, ch: idx + sentence[0].length }
|
||||
this.setCursorAtNextWordCharacter(editor, endPos, paragraphText, 'end')
|
||||
if (endPos.ch >= paragraphText.length) {
|
||||
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
|
||||
if (nextLine < editor.lineCount()) {
|
||||
editor.setCursor({ line: nextLine, ch: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the current sentence
|
||||
*/
|
||||
public selectSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
let offset = 0
|
||||
let text = paragraphText
|
||||
const matches = paragraphText.match(/^(\d+\.|[-*+]) /)
|
||||
if (matches) {
|
||||
offset = matches[0].length
|
||||
text = paragraphText.slice(offset)
|
||||
}
|
||||
|
||||
this.forEachSentence(text, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
if (cursorPosition.ch <= offset + idx + sentence[0].length) {
|
||||
editor.setSelection(
|
||||
{ line: cursorPosition.line, ch: offset + idx },
|
||||
{ line: cursorPosition.line, ch: offset + idx + sentence[0].length }
|
||||
)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
import { App, Editor, EditorPosition, MarkdownFileInfo, MarkdownView } from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { LIST_CHARACTER_REGEX } from './Constants'
|
||||
|
||||
/**
|
||||
* SentenceNavigator provides commands for navigating and manipulating sentences
|
||||
* Based on obsidian-sentence-navigator functionality
|
||||
*/
|
||||
export class SentenceNavigator {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
private sentenceRegex: RegExp
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin, regexSource: string) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
this.sentenceRegex = new RegExp(regexSource, 'gm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the sentence regex pattern
|
||||
*/
|
||||
public updateRegex(regexSource: string): void {
|
||||
this.sentenceRegex = new RegExp(regexSource, 'gm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor position and current paragraph text
|
||||
*/
|
||||
private getCursorAndParagraphText(editor: Editor): {
|
||||
cursorPosition: EditorPosition
|
||||
paragraphText: string
|
||||
} {
|
||||
const cursorPosition = editor.getCursor()
|
||||
return { cursorPosition, paragraphText: editor.getLine(cursorPosition.line) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over all sentences in a paragraph
|
||||
*/
|
||||
private forEachSentence(
|
||||
paragraphText: string,
|
||||
callback: (sentence: RegExpMatchArray) => boolean | void
|
||||
): void {
|
||||
for (const sentence of paragraphText.matchAll(this.sentenceRegex)) {
|
||||
if (callback(sentence)) break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cursor at the next non-space character
|
||||
*/
|
||||
private setCursorAtNextWordCharacter(
|
||||
editor: Editor,
|
||||
cursorPosition: EditorPosition,
|
||||
paragraphText: string,
|
||||
direction: 'start' | 'end'
|
||||
): void {
|
||||
let ch = cursorPosition.ch
|
||||
if (direction === 'start') {
|
||||
while (ch > 0 && paragraphText.charAt(ch - 1) === ' ') ch--
|
||||
} else {
|
||||
while (ch < paragraphText.length && paragraphText.charAt(ch) === ' ') ch++
|
||||
}
|
||||
editor.setCursor({ ch, line: cursorPosition.line })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous non-empty line number
|
||||
*/
|
||||
private getPrevNonEmptyLine(editor: Editor, currentLine: number): number {
|
||||
let prevLine = currentLine - 1
|
||||
while (prevLine >= 0 && editor.getLine(prevLine).length === 0) prevLine--
|
||||
return prevLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next non-empty line number
|
||||
*/
|
||||
private getNextNonEmptyLine(editor: Editor, currentLine: number): number {
|
||||
let nextLine = currentLine + 1
|
||||
while (nextLine < editor.lineCount() && editor.getLine(nextLine).length === 0) nextLine++
|
||||
return nextLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cursor is at the start of a list item
|
||||
*/
|
||||
private isAtStartOfListItem(
|
||||
cursorPosition: EditorPosition,
|
||||
paragraphText: string
|
||||
): boolean {
|
||||
return LIST_CHARACTER_REGEX.test(paragraphText.slice(0, cursorPosition.ch))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete text from cursor to sentence boundary
|
||||
*/
|
||||
public deleteToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void {
|
||||
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
const originalCursorPosition = cursorPosition
|
||||
|
||||
if (
|
||||
paragraphText.charAt(cursorPosition.ch) === ' ' ||
|
||||
paragraphText.charAt(cursorPosition.ch - 1) === ' '
|
||||
) {
|
||||
this.setCursorAtNextWordCharacter(editor, cursorPosition, paragraphText, boundary)
|
||||
;({ cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor))
|
||||
}
|
||||
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
if (
|
||||
cursorPosition.ch >= idx &&
|
||||
cursorPosition.ch <= idx + sentence[0].length
|
||||
) {
|
||||
if (boundary === 'start') {
|
||||
const newText =
|
||||
paragraphText.substring(0, idx) +
|
||||
paragraphText.substring(originalCursorPosition.ch)
|
||||
const cutLength = paragraphText.length - newText.length
|
||||
editor.replaceRange(
|
||||
newText,
|
||||
{ line: cursorPosition.line, ch: 0 },
|
||||
{ line: cursorPosition.line, ch: paragraphText.length }
|
||||
)
|
||||
editor.setCursor({
|
||||
line: cursorPosition.line,
|
||||
ch: originalCursorPosition.ch - cutLength,
|
||||
})
|
||||
} else {
|
||||
const remainingLength = idx + sentence[0].length - cursorPosition.ch
|
||||
const newText =
|
||||
paragraphText.substring(0, originalCursorPosition.ch) +
|
||||
paragraphText.substring(cursorPosition.ch + remainingLength)
|
||||
editor.replaceRange(
|
||||
newText,
|
||||
{ line: cursorPosition.line, ch: 0 },
|
||||
{ line: cursorPosition.line, ch: paragraphText.length }
|
||||
)
|
||||
editor.setCursor(originalCursorPosition)
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select text from cursor to sentence boundary
|
||||
*/
|
||||
public selectToBoundary(editor: Editor, view: MarkdownView | MarkdownFileInfo, boundary: 'start' | 'end'): void {
|
||||
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
if (
|
||||
cursorPosition.ch >= idx &&
|
||||
cursorPosition.ch <= idx + sentence[0].length
|
||||
) {
|
||||
if (
|
||||
editor.getSelection().length > 0 &&
|
||||
(cursorPosition.ch === idx || cursorPosition.ch === idx + sentence[0].length)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (boundary === 'start') {
|
||||
const precedingLength = cursorPosition.ch - idx
|
||||
editor.setSelection(cursorPosition, {
|
||||
line: cursorPosition.line,
|
||||
ch: cursorPosition.ch - precedingLength,
|
||||
})
|
||||
} else {
|
||||
editor.setSelection(cursorPosition, {
|
||||
line: cursorPosition.line,
|
||||
ch: idx + sentence[0].length,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to the start of the current sentence
|
||||
*/
|
||||
public moveToStartOfCurrentSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
|
||||
if (cursorPosition.ch === 0 || this.isAtStartOfListItem(cursorPosition, paragraphText)) {
|
||||
const prevLine = this.getPrevNonEmptyLine(editor, cursorPosition.line)
|
||||
if (prevLine >= 0) {
|
||||
editor.setCursor({ line: prevLine, ch: editor.getLine(prevLine).length })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
while (
|
||||
cursorPosition.ch > 0 &&
|
||||
paragraphText.charAt(cursorPosition.ch - 1) === ' '
|
||||
) {
|
||||
editor.setCursor({
|
||||
line: cursorPosition.line,
|
||||
ch: --cursorPosition.ch,
|
||||
})
|
||||
}
|
||||
if (cursorPosition.ch > idx && idx + sentence[0].length >= cursorPosition.ch) {
|
||||
editor.setCursor({ line: cursorPosition.line, ch: idx })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to the start of the next sentence
|
||||
*/
|
||||
public moveToStartOfNextSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
let { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
|
||||
if (cursorPosition.ch === paragraphText.length) {
|
||||
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
|
||||
if (nextLine < editor.lineCount()) {
|
||||
editor.setCursor({ line: nextLine, ch: 0 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.forEachSentence(paragraphText, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
while (
|
||||
cursorPosition.ch < paragraphText.length &&
|
||||
paragraphText.charAt(cursorPosition.ch) === ' '
|
||||
) {
|
||||
editor.setCursor({
|
||||
line: cursorPosition.line,
|
||||
ch: ++cursorPosition.ch,
|
||||
})
|
||||
}
|
||||
if (cursorPosition.ch >= idx && cursorPosition.ch <= idx + sentence[0].length) {
|
||||
const endPos = { line: cursorPosition.line, ch: idx + sentence[0].length }
|
||||
this.setCursorAtNextWordCharacter(editor, endPos, paragraphText, 'end')
|
||||
if (endPos.ch >= paragraphText.length) {
|
||||
const nextLine = this.getNextNonEmptyLine(editor, cursorPosition.line)
|
||||
if (nextLine < editor.lineCount()) {
|
||||
editor.setCursor({ line: nextLine, ch: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the current sentence
|
||||
*/
|
||||
public selectSentence(editor: Editor, view: MarkdownView | MarkdownFileInfo): void {
|
||||
const { cursorPosition, paragraphText } = this.getCursorAndParagraphText(editor)
|
||||
let offset = 0
|
||||
let text = paragraphText
|
||||
const matches = paragraphText.match(/^(\d+\.|[-*+]) /)
|
||||
if (matches) {
|
||||
offset = matches[0].length
|
||||
text = paragraphText.slice(offset)
|
||||
}
|
||||
|
||||
this.forEachSentence(text, (sentence) => {
|
||||
const idx = sentence.index ?? 0
|
||||
if (cursorPosition.ch <= offset + idx + sentence[0].length) {
|
||||
editor.setSelection(
|
||||
{ line: cursorPosition.line, ch: offset + idx },
|
||||
{ line: cursorPosition.line, ch: offset + idx + sentence[0].length }
|
||||
)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,140 +1,140 @@
|
||||
import {
|
||||
App,
|
||||
Editor,
|
||||
EditorChange,
|
||||
EditorPosition,
|
||||
EditorTransaction,
|
||||
MarkdownFileInfo,
|
||||
MarkdownView,
|
||||
} from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { Heading } from './Entities'
|
||||
import { getMainSelection, selectionToRange } from './Utils'
|
||||
import { HEADING_REGEX } from './Constants'
|
||||
|
||||
/**
|
||||
* ToggleHeading provides commands to toggle markdown heading levels
|
||||
*/
|
||||
export class ToggleHeading {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a change to set the heading level for a specific line
|
||||
*/
|
||||
private setHeading(
|
||||
editor: Editor,
|
||||
heading: Heading,
|
||||
line: number
|
||||
): EditorChange {
|
||||
const headingStr = '#'.repeat(heading)
|
||||
const text = editor.getLine(line)
|
||||
const matches = HEADING_REGEX.exec(text)!
|
||||
|
||||
const from: EditorPosition = {
|
||||
line: line,
|
||||
ch: 0,
|
||||
}
|
||||
const to: EditorPosition = {
|
||||
line: line,
|
||||
ch: matches[1].length + matches[2].length,
|
||||
}
|
||||
|
||||
const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' '
|
||||
|
||||
return {
|
||||
from: from,
|
||||
to: to,
|
||||
text: replacementStr,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the heading level of a specific line
|
||||
*/
|
||||
private getHeadingOfLine(editor: Editor, line: number): Heading {
|
||||
const text = editor.getLine(line)
|
||||
const matches = HEADING_REGEX.exec(text)!
|
||||
|
||||
return matches[1].length as Heading
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle heading for the current selection(s)
|
||||
* If the line already has the specified heading, remove it (set to NORMAL)
|
||||
*/
|
||||
public toggleHeading(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
heading: Heading
|
||||
): void {
|
||||
const selections = editor.listSelections()
|
||||
const mainSelection = getMainSelection(editor)
|
||||
const mainRange = selectionToRange(mainSelection)
|
||||
|
||||
const mainHeading = this.getHeadingOfLine(editor, mainRange.from.line)
|
||||
const targetHeading = heading === mainHeading ? Heading.NORMAL : heading
|
||||
|
||||
const changes: Array<EditorChange> = []
|
||||
|
||||
// Collect unique lines from all selections
|
||||
const linesToSet: Set<number> = new Set()
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
for (let line = range.from.line; line <= range.to.line; line++) {
|
||||
linesToSet.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Create changes for each line
|
||||
for (const line of linesToSet) {
|
||||
if (editor.getLine(line) === '') {
|
||||
continue
|
||||
}
|
||||
changes.push(this.setHeading(editor, targetHeading, line))
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: changes,
|
||||
}
|
||||
|
||||
editor.transaction(transaction, 'ToggleHeading' + heading.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle heading with stripping of inline formatting
|
||||
* This removes bold/italic formatting when toggling headings
|
||||
*/
|
||||
public toggleHeadingWithStrip(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
level: number
|
||||
): void {
|
||||
const cursor = editor.getCursor()
|
||||
const line = editor.getLine(cursor.line)
|
||||
const match = line.match(/^(#+)\s(.*)/)
|
||||
|
||||
let content = line
|
||||
let currentLevel = 0
|
||||
|
||||
if (match) {
|
||||
currentLevel = match[1].length
|
||||
content = match[2]
|
||||
}
|
||||
|
||||
// Strip bold and italic formatting
|
||||
content = content
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||
|
||||
editor.setLine(
|
||||
cursor.line,
|
||||
currentLevel === level ? content : '#'.repeat(level) + ' ' + content
|
||||
)
|
||||
}
|
||||
import {
|
||||
App,
|
||||
Editor,
|
||||
EditorChange,
|
||||
EditorPosition,
|
||||
EditorTransaction,
|
||||
MarkdownFileInfo,
|
||||
MarkdownView,
|
||||
} from 'obsidian'
|
||||
import BindThemPlugin from './main'
|
||||
import { Heading } from './Entities'
|
||||
import { getMainSelection, selectionToRange } from './Utils'
|
||||
import { HEADING_REGEX } from './Constants'
|
||||
|
||||
/**
|
||||
* ToggleHeading provides commands to toggle markdown heading levels
|
||||
*/
|
||||
export class ToggleHeading {
|
||||
public app: App
|
||||
private plugin: BindThemPlugin
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
this.app = app
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a change to set the heading level for a specific line
|
||||
*/
|
||||
private setHeading(
|
||||
editor: Editor,
|
||||
heading: Heading,
|
||||
line: number
|
||||
): EditorChange {
|
||||
const headingStr = '#'.repeat(heading)
|
||||
const text = editor.getLine(line)
|
||||
const matches = HEADING_REGEX.exec(text)!
|
||||
|
||||
const from: EditorPosition = {
|
||||
line: line,
|
||||
ch: 0,
|
||||
}
|
||||
const to: EditorPosition = {
|
||||
line: line,
|
||||
ch: matches[1].length + matches[2].length,
|
||||
}
|
||||
|
||||
const replacementStr = heading === Heading.NORMAL ? '' : headingStr + ' '
|
||||
|
||||
return {
|
||||
from: from,
|
||||
to: to,
|
||||
text: replacementStr,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the heading level of a specific line
|
||||
*/
|
||||
private getHeadingOfLine(editor: Editor, line: number): Heading {
|
||||
const text = editor.getLine(line)
|
||||
const matches = HEADING_REGEX.exec(text)!
|
||||
|
||||
return matches[1].length as Heading
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle heading for the current selection(s)
|
||||
* If the line already has the specified heading, remove it (set to NORMAL)
|
||||
*/
|
||||
public toggleHeading(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
heading: Heading
|
||||
): void {
|
||||
const selections = editor.listSelections()
|
||||
const mainSelection = getMainSelection(editor)
|
||||
const mainRange = selectionToRange(mainSelection)
|
||||
|
||||
const mainHeading = this.getHeadingOfLine(editor, mainRange.from.line)
|
||||
const targetHeading = heading === mainHeading ? Heading.NORMAL : heading
|
||||
|
||||
const changes: Array<EditorChange> = []
|
||||
|
||||
// Collect unique lines from all selections
|
||||
const linesToSet: Set<number> = new Set()
|
||||
for (const selection of selections) {
|
||||
const range = selectionToRange(selection)
|
||||
for (let line = range.from.line; line <= range.to.line; line++) {
|
||||
linesToSet.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Create changes for each line
|
||||
for (const line of linesToSet) {
|
||||
if (editor.getLine(line) === '') {
|
||||
continue
|
||||
}
|
||||
changes.push(this.setHeading(editor, targetHeading, line))
|
||||
}
|
||||
|
||||
const transaction: EditorTransaction = {
|
||||
changes: changes,
|
||||
}
|
||||
|
||||
editor.transaction(transaction, 'ToggleHeading' + heading.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle heading with stripping of inline formatting
|
||||
* This removes bold/italic formatting when toggling headings
|
||||
*/
|
||||
public toggleHeadingWithStrip(
|
||||
editor: Editor,
|
||||
view: MarkdownView | MarkdownFileInfo,
|
||||
level: number
|
||||
): void {
|
||||
const cursor = editor.getCursor()
|
||||
const line = editor.getLine(cursor.line)
|
||||
const match = line.match(/^(#+)\s(.*)/)
|
||||
|
||||
let content = line
|
||||
let currentLevel = 0
|
||||
|
||||
if (match) {
|
||||
currentLevel = match[1].length
|
||||
content = match[2]
|
||||
}
|
||||
|
||||
// Strip bold and italic formatting
|
||||
content = content
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||
|
||||
editor.setLine(
|
||||
cursor.line,
|
||||
currentLevel === level ? content : '#'.repeat(level) + ' ' + content
|
||||
)
|
||||
}
|
||||
}
|
||||
186
src/Utils.ts
186
src/Utils.ts
@@ -1,94 +1,94 @@
|
||||
import { Editor, EditorRange, EditorSelection } from 'obsidian'
|
||||
|
||||
// ============================================================
|
||||
// Selection Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the main (primary) selection from the editor
|
||||
*/
|
||||
export function getMainSelection(editor: Editor): EditorSelection {
|
||||
return {
|
||||
anchor: editor.getCursor('anchor'),
|
||||
head: editor.getCursor('head'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a selection to a range (from/to with sorted positions)
|
||||
*/
|
||||
export function selectionToRange(selection: EditorSelection): EditorRange {
|
||||
const sortedPositions = [selection.anchor, selection.head].sort((a, b) => {
|
||||
if (a.line !== b.line) return a.line - b.line
|
||||
return a.ch - b.ch
|
||||
})
|
||||
return {
|
||||
from: sortedPositions[0],
|
||||
to: sortedPositions[1],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a selection to encompass full lines
|
||||
*/
|
||||
export function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection {
|
||||
const range = selectionToRange(selection)
|
||||
return {
|
||||
anchor: { line: range.from.line, ch: 0 },
|
||||
head: { line: range.to.line, ch: editor.getLine(range.to.line).length },
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Text Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get leading whitespace (indentation) from a line
|
||||
*/
|
||||
export function getLeadingWhitespace(lineContent: string): string {
|
||||
const indentation = lineContent.match(/^\s+/)
|
||||
return indentation ? indentation[0] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character is a letter or digit
|
||||
*/
|
||||
export function isLetterOrDigit(char: string): boolean {
|
||||
return /\p{L}\p{M}*/u.test(char) || /\d/.test(char)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the word range at a given position
|
||||
*/
|
||||
export function wordRangeAtPos(
|
||||
pos: { line: number; ch: number },
|
||||
lineContent: string
|
||||
): { anchor: { line: number; ch: number }; head: { line: number; ch: number } } {
|
||||
let start = pos.ch
|
||||
let end = pos.ch
|
||||
while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) start--
|
||||
while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) end++
|
||||
return { anchor: { line: pos.line, ch: start }, head: { line: pos.line, ch: end } }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Case Transformation Utilities
|
||||
// ============================================================
|
||||
|
||||
const LOWERCASE_ARTICLES = ['the', 'a', 'an']
|
||||
|
||||
/**
|
||||
* Convert text to title case
|
||||
*/
|
||||
export function toTitleCase(text: string): string {
|
||||
return text
|
||||
.split(/(\s+)/)
|
||||
.map((w, i, all) => {
|
||||
if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(w.toLowerCase())) {
|
||||
return w.toLowerCase()
|
||||
}
|
||||
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
||||
})
|
||||
.join(' ')
|
||||
import { Editor, EditorRange, EditorSelection } from 'obsidian'
|
||||
|
||||
// ============================================================
|
||||
// Selection Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the main (primary) selection from the editor
|
||||
*/
|
||||
export function getMainSelection(editor: Editor): EditorSelection {
|
||||
return {
|
||||
anchor: editor.getCursor('anchor'),
|
||||
head: editor.getCursor('head'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a selection to a range (from/to with sorted positions)
|
||||
*/
|
||||
export function selectionToRange(selection: EditorSelection): EditorRange {
|
||||
const sortedPositions = [selection.anchor, selection.head].sort((a, b) => {
|
||||
if (a.line !== b.line) return a.line - b.line
|
||||
return a.ch - b.ch
|
||||
})
|
||||
return {
|
||||
from: sortedPositions[0],
|
||||
to: sortedPositions[1],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a selection to encompass full lines
|
||||
*/
|
||||
export function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection {
|
||||
const range = selectionToRange(selection)
|
||||
return {
|
||||
anchor: { line: range.from.line, ch: 0 },
|
||||
head: { line: range.to.line, ch: editor.getLine(range.to.line).length },
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Text Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get leading whitespace (indentation) from a line
|
||||
*/
|
||||
export function getLeadingWhitespace(lineContent: string): string {
|
||||
const indentation = lineContent.match(/^\s+/)
|
||||
return indentation ? indentation[0] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character is a letter or digit
|
||||
*/
|
||||
export function isLetterOrDigit(char: string): boolean {
|
||||
return /\p{L}\p{M}*/u.test(char) || /\d/.test(char)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the word range at a given position
|
||||
*/
|
||||
export function wordRangeAtPos(
|
||||
pos: { line: number; ch: number },
|
||||
lineContent: string
|
||||
): { anchor: { line: number; ch: number }; head: { line: number; ch: number } } {
|
||||
let start = pos.ch
|
||||
let end = pos.ch
|
||||
while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) start--
|
||||
while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) end++
|
||||
return { anchor: { line: pos.line, ch: start }, head: { line: pos.line, ch: end } }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Case Transformation Utilities
|
||||
// ============================================================
|
||||
|
||||
const LOWERCASE_ARTICLES = ['the', 'a', 'an']
|
||||
|
||||
/**
|
||||
* Convert text to title case
|
||||
*/
|
||||
export function toTitleCase(text: string): string {
|
||||
return text
|
||||
.split(/(\s+)/)
|
||||
.map((w, i, all) => {
|
||||
if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(w.toLowerCase())) {
|
||||
return w.toLowerCase()
|
||||
}
|
||||
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
1914
src/main.ts
1914
src/main.ts
File diff suppressed because it is too large
Load Diff
611
src/settings.ts
611
src/settings.ts
@@ -1,306 +1,307 @@
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import BindThemPlugin from './main';
|
||||
import { DEFAULT_SENTENCE_REGEX } from './Constants';
|
||||
|
||||
// ============================================================
|
||||
// Command Definitions - All commands organized by category
|
||||
// ============================================================
|
||||
|
||||
export interface CommandDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const COMMAND_CATEGORIES = {
|
||||
betterFormatting: 'Better Formatting',
|
||||
directionalCopyMove: 'Directional Copy/Move',
|
||||
toggleHeading: 'Toggle Heading',
|
||||
sentenceNavigator: 'Sentence Navigator',
|
||||
lineOperations: 'Line Operations',
|
||||
caseTransformation: 'Case Transformation',
|
||||
navigation: 'Navigation',
|
||||
cursorMovement: 'Cursor Movement',
|
||||
fileOperations: 'File Operations',
|
||||
utility: 'Utility'
|
||||
} as const;
|
||||
|
||||
export const COMMANDS: Record<keyof typeof COMMAND_CATEGORIES, CommandDefinition[]> = {
|
||||
betterFormatting: [
|
||||
{ id: 'toggle-bold-underscore', name: 'Toggle bold underscore' },
|
||||
{ id: 'toggle-bold-asterisk', name: 'Toggle bold asterisk' },
|
||||
{ id: 'toggle-italics-underscore', name: 'Toggle italics underscore' },
|
||||
{ id: 'toggle-italics-asterisk', name: 'Toggle italics asterisk' },
|
||||
{ id: 'toggle-code', name: 'Toggle code' },
|
||||
{ id: 'toggle-comment', name: 'Toggle comment' },
|
||||
{ id: 'toggle-highlight', name: 'Toggle highlight' },
|
||||
{ id: 'toggle-strikethrough', name: 'Toggle strikethrough' },
|
||||
{ id: 'toggle-math-inline', name: 'Toggle math inline' },
|
||||
{ id: 'toggle-math-block', name: 'Toggle math block' }
|
||||
],
|
||||
directionalCopyMove: [
|
||||
{ id: 'copy-up', name: 'Copy up' },
|
||||
{ id: 'copy-down', name: 'Copy down' },
|
||||
{ id: 'copy-left', name: 'Copy left' },
|
||||
{ id: 'copy-right', name: 'Copy right' },
|
||||
{ id: 'move-left', name: 'Move left' },
|
||||
{ id: 'move-right', name: 'Move right' }
|
||||
],
|
||||
toggleHeading: [
|
||||
{ id: 'toggle-heading-1', name: 'Toggle heading 1' },
|
||||
{ id: 'toggle-heading-2', name: 'Toggle heading 2' },
|
||||
{ id: 'toggle-heading-3', name: 'Toggle heading 3' },
|
||||
{ id: 'toggle-heading-4', name: 'Toggle heading 4' },
|
||||
{ id: 'toggle-heading-5', name: 'Toggle heading 5' },
|
||||
{ id: 'toggle-heading-6', name: 'Toggle heading 6' },
|
||||
{ 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-3', name: 'Toggle heading 3 (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-6', name: 'Toggle heading 6 (strip formatting)' }
|
||||
],
|
||||
sentenceNavigator: [
|
||||
{ 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: 'select-to-start-of-sentence', name: 'Select to start 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-next-sentence', name: 'Move to start of next sentence' },
|
||||
{ id: 'select-sentence', name: 'Select current sentence' }
|
||||
],
|
||||
lineOperations: [
|
||||
{ id: 'insert-line-above', name: 'Insert line above' },
|
||||
{ id: 'insert-line-below', name: 'Insert line below' },
|
||||
{ id: 'delete-line', name: 'Delete 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: 'join-lines', name: 'Join lines' },
|
||||
{ id: 'duplicate-line', name: 'Duplicate line' },
|
||||
{ id: 'copy-line-up', name: 'Copy line up' },
|
||||
{ id: 'copy-line-down', name: 'Copy line down' },
|
||||
{ id: 'select-word', name: 'Select word' },
|
||||
{ id: 'select-line', name: 'Select line' }
|
||||
],
|
||||
caseTransformation: [
|
||||
{ id: 'transform-uppercase', name: 'Transform to uppercase' },
|
||||
{ id: 'transform-lowercase', name: 'Transform to lowercase' },
|
||||
{ id: 'transform-titlecase', name: 'Transform to title case' },
|
||||
{ id: 'toggle-case', name: 'Toggle case' }
|
||||
],
|
||||
navigation: [
|
||||
{ id: 'go-to-line-start', name: 'Go to line start' },
|
||||
{ id: 'go-to-line-end', name: 'Go to line end' },
|
||||
{ id: 'go-to-first-line', name: 'Go to first line' },
|
||||
{ id: 'go-to-last-line', name: 'Go to last line' },
|
||||
{ id: 'go-to-line-number', name: 'Go to line number' },
|
||||
{ id: 'go-to-next-heading', name: 'Go to next heading' },
|
||||
{ id: 'go-to-prev-heading', name: 'Go to previous heading' }
|
||||
],
|
||||
cursorMovement: [
|
||||
{ id: 'move-cursor-up', name: 'Move cursor up' },
|
||||
{ id: 'move-cursor-down', name: 'Move cursor down' },
|
||||
{ id: 'move-cursor-left', name: 'Move cursor left' },
|
||||
{ id: 'move-cursor-right', name: 'Move cursor right' },
|
||||
{ id: 'go-to-prev-word', name: 'Go to previous word' },
|
||||
{ id: 'go-to-next-word', name: 'Go to next word' },
|
||||
{ id: 'insert-cursor-above', name: 'Insert cursor above' },
|
||||
{ id: 'insert-cursor-below', name: 'Insert cursor below' },
|
||||
{ id: 'select-next-occurrence', name: 'Select next occurrence' },
|
||||
{ id: 'select-prev-occurrence', name: 'Select previous occurrence' }
|
||||
],
|
||||
fileOperations: [
|
||||
{ id: 'duplicate-file', name: 'Duplicate file' },
|
||||
{ id: 'new-adjacent-file', name: 'New adjacent file' }
|
||||
],
|
||||
utility: [
|
||||
{ id: 'toggle-line-numbers', name: 'Toggle line numbers' },
|
||||
{ id: 'undo', name: 'Undo' },
|
||||
{ id: 'redo', name: 'Redo' }
|
||||
]
|
||||
};
|
||||
|
||||
// Get all command IDs as an array
|
||||
export function getAllCommandIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const commands of Object.values(COMMANDS)) {
|
||||
for (const cmd of commands) {
|
||||
ids.push(cmd.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Create default enabled settings (all enabled by default)
|
||||
export function createDefaultEnabledCommands(): Record<string, boolean> {
|
||||
const enabled: Record<string, boolean> = {};
|
||||
for (const cmdId of getAllCommandIds()) {
|
||||
enabled[cmdId] = true;
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings Interface
|
||||
// ============================================================
|
||||
|
||||
export interface BindThemSettings {
|
||||
debug: boolean;
|
||||
sentenceRegexSource: string;
|
||||
enabledCommands: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: BindThemSettings = {
|
||||
debug: false,
|
||||
sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
|
||||
enabledCommands: createDefaultEnabledCommands()
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Settings Tab
|
||||
// ============================================================
|
||||
|
||||
export class BindThemSettingTab extends PluginSettingTab {
|
||||
plugin: BindThemPlugin;
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// Hotkeys link at the top
|
||||
containerEl.createEl('a', {
|
||||
text: 'Configure keyboard shortcuts',
|
||||
cls: 'bindthem-hotkeys-link',
|
||||
attr: {
|
||||
href: '#',
|
||||
style: 'display: inline-block; margin-bottom: 1em; color: var(--text-accent); cursor: pointer;'
|
||||
}
|
||||
}, (el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys');
|
||||
if (hotkeysTab) {
|
||||
(hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// General settings
|
||||
new Setting(containerEl)
|
||||
.setName('Debug mode')
|
||||
.setDesc('Enable debug logging to console')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.debug)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.debug = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Sentence regex')
|
||||
.setDesc('Regular expression used to match sentences')
|
||||
.addText(text => text
|
||||
.setValue(this.plugin.settings.sentenceRegexSource)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.sentenceRegexSource = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Reset sentence regex')
|
||||
.addButton(button => button
|
||||
.setButtonText('Reset')
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// Commands section header
|
||||
new Setting(containerEl)
|
||||
.setName('Command availability')
|
||||
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.')
|
||||
.setHeading();
|
||||
|
||||
// Category toggle buttons
|
||||
new Setting(containerEl)
|
||||
.setName('Enable/disable all')
|
||||
.setDesc('Toggle all commands on or off')
|
||||
.addButton(button => button
|
||||
.setButtonText('Enable all')
|
||||
.onClick(async () => {
|
||||
for (const cmdId of getAllCommandIds()) {
|
||||
this.plugin.settings.enabledCommands[cmdId] = true;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}))
|
||||
.addButton(button => button
|
||||
.setButtonText('Disable all')
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
for (const cmdId of getAllCommandIds()) {
|
||||
this.plugin.settings.enabledCommands[cmdId] = false;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// Render each category
|
||||
for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) {
|
||||
this.renderCategory(containerEl, categoryKey, categoryName);
|
||||
}
|
||||
|
||||
// About section
|
||||
new Setting(containerEl)
|
||||
.setName('About')
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.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];
|
||||
if (!commands || commands.length === 0) return;
|
||||
|
||||
// Category header with toggle all
|
||||
const categorySetting = new Setting(containerEl)
|
||||
.setName(categoryName)
|
||||
.setHeading();
|
||||
|
||||
// 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 => {
|
||||
toggle
|
||||
.setValue(allEnabled)
|
||||
.setTooltip('Toggle all commands in this category')
|
||||
.onChange(async (value) => {
|
||||
for (const cmd of commands) {
|
||||
this.plugin.settings.enabledCommands[cmd.id] = value;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
// Render individual commands
|
||||
for (const cmd of commands) {
|
||||
new Setting(containerEl)
|
||||
.setName(cmd.name)
|
||||
.setDesc(cmd.description || `Command ID: ${cmd.id}`)
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enabledCommands[cmd.id] !== false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enabledCommands[cmd.id] = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import BindThemPlugin from './main';
|
||||
import { DEFAULT_SENTENCE_REGEX } from './Constants';
|
||||
|
||||
// ============================================================
|
||||
// Command Definitions - All commands organized by category
|
||||
// ============================================================
|
||||
|
||||
export interface CommandDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const COMMAND_CATEGORIES = {
|
||||
betterFormatting: 'Better Formatting',
|
||||
directionalCopyMove: 'Directional Copy/Move',
|
||||
toggleHeading: 'Toggle Heading',
|
||||
sentenceNavigator: 'Sentence Navigator',
|
||||
lineOperations: 'Line Operations',
|
||||
caseTransformation: 'Case Transformation',
|
||||
navigation: 'Navigation',
|
||||
cursorMovement: 'Cursor Movement',
|
||||
fileOperations: 'File Operations',
|
||||
utility: 'Utility'
|
||||
} as const;
|
||||
|
||||
export const COMMANDS: Record<keyof typeof COMMAND_CATEGORIES, CommandDefinition[]> = {
|
||||
betterFormatting: [
|
||||
{ id: 'toggle-bold-underscore', name: 'Toggle bold underscore' },
|
||||
{ id: 'toggle-bold-asterisk', name: 'Toggle bold asterisk' },
|
||||
{ id: 'toggle-italics-underscore', name: 'Toggle italics underscore' },
|
||||
{ id: 'toggle-italics-asterisk', name: 'Toggle italics asterisk' },
|
||||
{ id: 'toggle-code', name: 'Toggle code' },
|
||||
{ id: 'toggle-comment', name: 'Toggle comment' },
|
||||
{ id: 'toggle-highlight', name: 'Toggle highlight' },
|
||||
{ id: 'toggle-strikethrough', name: 'Toggle strikethrough' },
|
||||
{ id: 'toggle-math-inline', name: 'Toggle math inline' },
|
||||
{ id: 'toggle-math-block', name: 'Toggle math block' }
|
||||
],
|
||||
directionalCopyMove: [
|
||||
{ id: 'copy-up', name: 'Copy up' },
|
||||
{ id: 'copy-down', name: 'Copy down' },
|
||||
{ id: 'copy-left', name: 'Copy left' },
|
||||
{ id: 'copy-right', name: 'Copy right' },
|
||||
{ id: 'move-left', name: 'Move left' },
|
||||
{ id: 'move-right', name: 'Move right' }
|
||||
],
|
||||
toggleHeading: [
|
||||
{ id: 'toggle-heading-1', name: 'Toggle heading 1' },
|
||||
{ id: 'toggle-heading-2', name: 'Toggle heading 2' },
|
||||
{ id: 'toggle-heading-3', name: 'Toggle heading 3' },
|
||||
{ id: 'toggle-heading-4', name: 'Toggle heading 4' },
|
||||
{ id: 'toggle-heading-5', name: 'Toggle heading 5' },
|
||||
{ id: 'toggle-heading-6', name: 'Toggle heading 6' },
|
||||
{ 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-3', name: 'Toggle heading 3 (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-6', name: 'Toggle heading 6 (strip formatting)' }
|
||||
],
|
||||
sentenceNavigator: [
|
||||
{ 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: 'select-to-start-of-sentence', name: 'Select to start 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-next-sentence', name: 'Move to start of next sentence' },
|
||||
{ id: 'select-sentence', name: 'Select current sentence' }
|
||||
],
|
||||
lineOperations: [
|
||||
{ id: 'insert-line-above', name: 'Insert line above' },
|
||||
{ id: 'insert-line-below', name: 'Insert line below' },
|
||||
{ id: 'delete-line', name: 'Delete 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: 'join-lines', name: 'Join lines' },
|
||||
{ id: 'duplicate-line', name: 'Duplicate line' },
|
||||
{ id: 'copy-line-up', name: 'Copy line up' },
|
||||
{ id: 'copy-line-down', name: 'Copy line down' },
|
||||
{ id: 'select-word', name: 'Select word' },
|
||||
{ id: 'select-line', name: 'Select line' }
|
||||
],
|
||||
caseTransformation: [
|
||||
{ id: 'transform-uppercase', name: 'Transform to uppercase' },
|
||||
{ id: 'transform-lowercase', name: 'Transform to lowercase' },
|
||||
{ id: 'transform-titlecase', name: 'Transform to title case' },
|
||||
{ id: 'toggle-case', name: 'Toggle case' }
|
||||
],
|
||||
navigation: [
|
||||
{ id: 'go-to-line-start', name: 'Go to line start' },
|
||||
{ id: 'go-to-line-end', name: 'Go to line end' },
|
||||
{ id: 'go-to-first-line', name: 'Go to first line' },
|
||||
{ id: 'go-to-last-line', name: 'Go to last line' },
|
||||
{ id: 'go-to-line-number', name: 'Go to line number' },
|
||||
{ id: 'go-to-next-heading', name: 'Go to next heading' },
|
||||
{ id: 'go-to-prev-heading', name: 'Go to previous heading' }
|
||||
],
|
||||
cursorMovement: [
|
||||
{ id: 'move-cursor-up', name: 'Move cursor up' },
|
||||
{ id: 'move-cursor-down', name: 'Move cursor down' },
|
||||
{ id: 'move-cursor-left', name: 'Move cursor left' },
|
||||
{ id: 'move-cursor-right', name: 'Move cursor right' },
|
||||
{ id: 'go-to-prev-word', name: 'Go to previous word' },
|
||||
{ id: 'go-to-next-word', name: 'Go to next word' },
|
||||
{ id: 'insert-cursor-above', name: 'Insert cursor above' },
|
||||
{ id: 'insert-cursor-below', name: 'Insert cursor below' },
|
||||
{ id: 'select-next-occurrence', name: 'Select next occurrence' },
|
||||
{ id: 'select-prev-occurrence', name: 'Select previous occurrence' },
|
||||
{ id: 'select-all-occurrences', name: 'Select all occurrences' }
|
||||
],
|
||||
fileOperations: [
|
||||
{ id: 'duplicate-file', name: 'Duplicate file' },
|
||||
{ id: 'new-adjacent-file', name: 'New adjacent file' }
|
||||
],
|
||||
utility: [
|
||||
{ id: 'toggle-line-numbers', name: 'Toggle line numbers' },
|
||||
{ id: 'undo', name: 'Undo' },
|
||||
{ id: 'redo', name: 'Redo' }
|
||||
]
|
||||
};
|
||||
|
||||
// Get all command IDs as an array
|
||||
export function getAllCommandIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const commands of Object.values(COMMANDS)) {
|
||||
for (const cmd of commands) {
|
||||
ids.push(cmd.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Create default enabled settings (all enabled by default)
|
||||
export function createDefaultEnabledCommands(): Record<string, boolean> {
|
||||
const enabled: Record<string, boolean> = {};
|
||||
for (const cmdId of getAllCommandIds()) {
|
||||
enabled[cmdId] = true;
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings Interface
|
||||
// ============================================================
|
||||
|
||||
export interface BindThemSettings {
|
||||
debug: boolean;
|
||||
sentenceRegexSource: string;
|
||||
enabledCommands: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: BindThemSettings = {
|
||||
debug: false,
|
||||
sentenceRegexSource: DEFAULT_SENTENCE_REGEX,
|
||||
enabledCommands: createDefaultEnabledCommands()
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Settings Tab
|
||||
// ============================================================
|
||||
|
||||
export class BindThemSettingTab extends PluginSettingTab {
|
||||
plugin: BindThemPlugin;
|
||||
|
||||
constructor(app: App, plugin: BindThemPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// Hotkeys link at the top
|
||||
containerEl.createEl('a', {
|
||||
text: 'Configure keyboard shortcuts',
|
||||
cls: 'bindthem-hotkeys-link',
|
||||
attr: {
|
||||
href: '#',
|
||||
style: 'display: inline-block; margin-bottom: 1em; color: var(--text-accent); cursor: pointer;'
|
||||
}
|
||||
}, (el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys');
|
||||
if (hotkeysTab) {
|
||||
(hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// General settings
|
||||
new Setting(containerEl)
|
||||
.setName('Debug mode')
|
||||
.setDesc('Enable debug logging to console')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.debug)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.debug = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Sentence regex')
|
||||
.setDesc('Regular expression used to match sentences')
|
||||
.addText(text => text
|
||||
.setValue(this.plugin.settings.sentenceRegexSource)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.sentenceRegexSource = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Reset sentence regex')
|
||||
.addButton(button => button
|
||||
.setButtonText('Reset')
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// Commands section header
|
||||
new Setting(containerEl)
|
||||
.setName('Command availability')
|
||||
.setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.')
|
||||
.setHeading();
|
||||
|
||||
// Category toggle buttons
|
||||
new Setting(containerEl)
|
||||
.setName('Enable/disable all')
|
||||
.setDesc('Toggle all commands on or off')
|
||||
.addButton(button => button
|
||||
.setButtonText('Enable all')
|
||||
.onClick(async () => {
|
||||
for (const cmdId of getAllCommandIds()) {
|
||||
this.plugin.settings.enabledCommands[cmdId] = true;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}))
|
||||
.addButton(button => button
|
||||
.setButtonText('Disable all')
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
for (const cmdId of getAllCommandIds()) {
|
||||
this.plugin.settings.enabledCommands[cmdId] = false;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
// Render each category
|
||||
for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) {
|
||||
this.renderCategory(containerEl, categoryKey, categoryName);
|
||||
}
|
||||
|
||||
// About section
|
||||
new Setting(containerEl)
|
||||
.setName('About')
|
||||
// eslint-disable-next-line obsidianmd/ui/sentence-case
|
||||
.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];
|
||||
if (!commands || commands.length === 0) return;
|
||||
|
||||
// Category header with toggle all
|
||||
const categorySetting = new Setting(containerEl)
|
||||
.setName(categoryName)
|
||||
.setHeading();
|
||||
|
||||
// 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 => {
|
||||
toggle
|
||||
.setValue(allEnabled)
|
||||
.setTooltip('Toggle all commands in this category')
|
||||
.onChange(async (value) => {
|
||||
for (const cmd of commands) {
|
||||
this.plugin.settings.enabledCommands[cmd.id] = value;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
// Render individual commands
|
||||
for (const cmd of commands) {
|
||||
new Setting(containerEl)
|
||||
.setName(cmd.name)
|
||||
.setDesc(cmd.description || `Command ID: ${cmd.id}`)
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enabledCommands[cmd.id] !== false)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enabledCommands[cmd.id] = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
16
styles.css
16
styles.css
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
/*
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
{
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "5.0",
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2020"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user