feat: Text Formatting Toolbar integration (#197)

feat: Text Formatting Toolbar integration
This commit is contained in:
johnny1093 2026-06-13 19:48:26 -04:00 committed by GitHub
commit 092d6a534e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 19249 additions and 2 deletions

180
AGENTS.md Normal file
View file

@ -0,0 +1,180 @@
# Obsidian community plugin
## Project overview
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
- Entry point: `src/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 - `package.json` defines npm scripts and dependencies).
- **Bundler: esbuild** (required - `esbuild.config.mjs` and build scripts depend on it).
- Types: `obsidian` type definitions.
### Install
```bash
npm install
```
### Dev (watch)
```bash
npm run dev:esbuild
```
### Production build
```bash
npm run build:esbuild
```
## Linting
- ESLint is preconfigured.
- Run `npm run lint` (if available) to lint the project.
## File & folder conventions
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
- **Current structure**:
```
src/
main.ts # Plugin entry point, lifecycle management
types.ts # TypeScript interfaces and types
constants.ts # Default settings, static data
l10n.ts # Localization (t() function)
util.tsx # Utility functions and Preact helpers
manager/commands/ # Feature managers (one per UI location)
commandManager.ts # Abstract base class
leftRibbonManager.ts
statusBarManager.ts
pageHeaderManager.ts
menuManager.ts
explorerManager.ts
textToolbarManager.ts # Text selection toolbar (new)
index.ts
ui/
settingTab.ts # Registers settings tab with Obsidian
settingTabModal.ts
icons.ts
addCommandModal.ts
chooseIconModal.ts
chooseCustomNameModal.ts
confirmDeleteModal.ts
components/ # Preact components
settingTabComponent.tsx # Tab container, tab list
commandViewerComponent.tsx # Reusable command list with add/remove/reorder
commandComponent.tsx # Single command row
hidingViewer.tsx # Eye-toggle accordions for hiding native items
settingComponent.tsx # ToggleComponent, SliderComponent, EyeToggleComponent
TextToolbarSettings.tsx # Text toolbar tab content (new)
AdvancedToolbarSettings.tsx
MacroViewer.tsx
MacroBuilder.tsx
Accordion.tsx
About.tsx
styles/
styles.scss
advanced-toolbar.scss
locale/ # Translation JSON files (en.json is canonical)
```
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files.
## Key architectural patterns
### Manager pattern
Each UI location (ribbon, status bar, page header, editor menu, etc.) has a manager class that extends `CommandManagerBase`. Managers:
- Own the `CommandIconPair[]` array for their location
- Implement `addCommand`, `removeCommand`, `reorder`
- Handle DOM creation and updates for their location
- Are initialized in `main.ts` `onload()` and stored on `plugin.manager`
### Settings storage
Settings are persisted via `plugin.loadData()` / `plugin.saveData()`. The `CommanderSettings` interface in `types.ts` is the source of truth. Always add a nullish guard in `onload()` for any new optional array field so that users upgrading from older versions don't get errors:
```typescript
this.settings.hide.textToolbar ??= [];
```
### Localization
All user-visible strings should go through `t()` from `src/l10n.ts`. The canonical locale is `locale/en.json`. If you add a new UI string, add it to `en.json` (and ideally other locale files). Strings not in the locale fall back to `en.json`; if not found there either, `t()` returns `undefined`, which renders as blank text.
### Preact UI
The settings UI uses Preact (not React). Components live in `src/ui/components/`. Reuse existing components:
- `ToggleComponent` / `SliderComponent` / `EyeToggleComponent` from `settingComponent.tsx`
- `CommandViewer` from `commandViewerComponent.tsx` — takes a `CommandManagerBase`, renders the command list with add/remove/reorder
- `Accordion` from `Accordion.tsx`
To add a new settings tab:
1. Create a Preact component in `src/ui/components/`
2. Add an entry to the `tabs` array in `settingTabComponent.tsx`
3. The tab name must be a plain string (not `undefined`) — if using `t()`, ensure the key exists in `en.json`
## Adding a new feature (checklist)
1. **Types**: Add fields to `CommanderSettings` in `src/types.ts`
2. **Defaults**: Add corresponding defaults in `DEFAULT_SETTINGS` in `src/constants.ts`
3. **Manager**: Create `src/manager/commands/<feature>Manager.ts` extending `CommandManagerBase`; export from `index.ts`
4. **main.ts**: Add nullish guard for any new array fields; instantiate manager; add to `plugin.manager`
5. **Settings UI**: Create component in `src/ui/components/`; add tab to `settingTabComponent.tsx`
6. **Locale**: Add any new UI strings to `locale/en.json`
7. **Styles**: Add CSS classes to `src/styles/styles.scss` using `cmdr-` prefix
## Text Toolbar feature
The Text Toolbar (`src/manager/commands/textToolbarManager.ts`) shows a floating action bar above selected text in a markdown source editor. Key implementation notes:
- Enabled/disabled via `plugin.settings.textToolbarEnabled`
- Always instantiated; event handlers check the setting at runtime
- Toolbar element is appended to `document.body` with `position: fixed`
- Uses `mousedown` + `e.preventDefault()` on buttons to keep editor focus and preserve selection
- Checks that selection is inside `.cm-editor` before showing (prevents showing in UI panels)
- Desktop-only: mobile guard via `Platform.isMobile` in the manager
- Default commands (Bold, Italic, Strikethrough, Copy, Cut) can be individually hidden via `settings.hide.textToolbar`
- User-added commands are `CommandIconPair[]` stored in `settings.textToolbar`
## Manifest rules (`manifest.json`)
- `id`, `name`, `version` (SemVer), `minAppVersion`, `description`, `isDesktopOnly` are required
- Never change `id` after release
- Keep `minAppVersion` accurate when using newer APIs
## 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**
- This plugin's source folder IS the installed plugin folder (Dev Vault), so building in place is sufficient — just reload Obsidian after `npm run build:esbuild`
## Security, privacy, and compliance
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**:
- Default to local/offline operation. Only make network requests when essential.
- No hidden telemetry.
- Never execute remote code or auto-update plugin code outside of normal releases.
- Register and clean up all DOM, app, and interval listeners using `register*` helpers.
## Performance
- Keep startup light. Defer heavy work until `onLayoutReady`.
- Debounce expensive operations (e.g. selection-change handlers).
- Batch disk access; avoid excessive vault scans.
## Coding conventions
- TypeScript; existing codebase uses `"strict": false` but prefer strict-compatible code.
- CSS class prefix: `cmdr-` for all plugin-owned classes.
- Keep `main.ts` minimal — delegate all feature logic to managers and UI components.
- `this.register*` helpers for everything that needs cleanup.
- Prefer `async/await`; handle errors gracefully.
## 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

18989
app.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -88,6 +88,13 @@ const buildOptions = {
name: "Move output",
setup(build) {
build.onEnd(() => {
// Always copy main.css → styles.css locally so Obsidian loads current styles
try {
copyFileSync("main.css", "styles.css");
} catch (error) {
console.error("Failed to copy main.css → styles.css:", error);
}
setTimeout(
() => {
try {

View file

@ -14,6 +14,7 @@ export const DEFAULT_SETTINGS: CommanderSettings = {
pageHeader: [],
macros: [],
explorer: [],
textToolbarCommands: [],
hide: {
statusbar: [],
leftRibbon: [],

View file

@ -14,6 +14,7 @@ import {
FileMenuCommandManager,
PageHeaderManager,
StatusBarManager,
TextToolbarIntegrationManager,
} from "./manager/commands";
import { Action, CommanderSettings } from "./types";
import CommanderSettingTab from "./ui/settingTab";
@ -36,6 +37,7 @@ export default class CommanderPlugin extends Plugin {
statusBar: StatusBarManager;
pageHeader: PageHeaderManager;
explorerManager: ExplorerManager;
textToolbarIntegration: TextToolbarIntegrationManager;
};
public async executeStartupMacros(): Promise<void> {
@ -95,6 +97,7 @@ export default class CommanderPlugin extends Plugin {
statusBar: new StatusBarManager(this, this.settings.statusBar),
pageHeader: new PageHeaderManager(this, this.settings.pageHeader),
explorerManager: new ExplorerManager(this, this.settings.explorer),
textToolbarIntegration: new TextToolbarIntegrationManager(this, this.settings.textToolbarCommands),
};
this.addSettingTab(new CommanderSettingTab(this));
@ -127,6 +130,9 @@ export default class CommanderPlugin extends Plugin {
injectIcons(this.settings.advancedToolbar, this);
this.executeStartupMacros();
// Push saved commands into the Text Toolbar plugin if it is installed
this.manager.textToolbarIntegration.reorder();
});
}

View file

@ -1,4 +1,5 @@
import ExplorerManager from "./explorerManager";
import TextToolbarIntegrationManager from "./textToolbarIntegrationManager";
import {
EditorMenuCommandManager,
FileMenuCommandManager,
@ -12,6 +13,7 @@ export {
FileMenuCommandManager,
PageHeaderManager,
StatusBarManager,
TextToolbarIntegrationManager,
TitleBarManager,
ExplorerManager,
};

View file

@ -0,0 +1,44 @@
import CommanderPlugin from "src/main";
import { CommandIconPair } from "src/types";
import CommandManagerBase from "./commandManager";
interface TextToolbarAPI {
setCommands(cmds: { id: string; icon: string; name: string }[]): void;
}
function getTextToolbarAPI(plugin: CommanderPlugin): TextToolbarAPI | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (plugin.app as any).plugins?.plugins?.["text-formatting-toolbar"]?.api;
}
export default class TextToolbarIntegrationManager extends CommandManagerBase {
public constructor(plugin: CommanderPlugin, pairs: CommandIconPair[]) {
super(plugin, pairs);
}
private sync(): void {
const api = getTextToolbarAPI(this.plugin);
if (!api) return;
api.setCommands(this.pairs.map(p => ({ id: p.id, icon: p.icon, name: p.name })));
}
public static isAvailable(plugin: CommanderPlugin): boolean {
return getTextToolbarAPI(plugin) !== undefined;
}
public async addCommand(pair: CommandIconPair): Promise<void> {
this.pairs.push(pair);
await this.plugin.saveSettings();
this.sync();
}
public async removeCommand(pair: CommandIconPair): Promise<void> {
this.pairs.remove(pair);
await this.plugin.saveSettings();
this.sync();
}
public reorder(): void {
this.sync();
}
}

View file

@ -33,6 +33,7 @@ export interface CommanderSettings {
pageHeader: CommandIconPair[];
explorer: CommandIconPair[];
macros: Macro[];
textToolbarCommands: CommandIconPair[];
hide: {
statusbar: string[];
leftRibbon: string[];

View file

@ -11,6 +11,7 @@ import CommandViewer from "./commandViewerComponent";
import { LeftRibbonHider, StatusbarHider } from "./hidingViewer";
import MacroViewer from "./MacroViewer";
import { SliderComponent, ToggleComponent } from "./settingComponent";
import TextToolbarIntegrationManager from "src/manager/commands/textToolbarIntegrationManager";
export default function settingTabComponent({
plugin,
@ -113,6 +114,7 @@ export default function settingTabComponent({
await plugin.saveSettings();
}}
/>
</Fragment>
),
},
@ -254,6 +256,20 @@ export default function settingTabComponent({
</CommandViewer>
),
},
...(TextToolbarIntegrationManager.isAvailable(plugin)
? [
{
name: "Text Toolbar",
tab: (
<CommandViewer
manager={plugin.manager.textToolbarIntegration}
plugin={plugin}
/>
),
},
]
: []),
{
name: Platform.isMobile ? "Mobile Toolbar" : "Toolbar",
tab: <AdvancedToolbarSettings plugin={plugin} />,

View file

@ -3,5 +3,6 @@
"0.4.1": "0.16.0",
"0.1.1": "0.16.0",
"0.1.0": "0.15.0",
"0.5.2": "1.4.0"
}
"0.5.2": "1.4.0",
"0.5.6": "1.4.0"
}