faire bindthem

This commit is contained in:
2026-02-24 14:57:44 -05:00
parent dc2fa22c4d
commit bfa95bb700
6 changed files with 647 additions and 161 deletions

View File

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

View File

@@ -1,11 +1,9 @@
{ {
"id": "sample-plugin", "id": "bindthem",
"name": "Sample Plugin", "name": "BindThem",
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.", "description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.",
"author": "Obsidian", "author": "Merged Plugin",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": false "isDesktopOnly": false
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "obsidian-sample-plugin", "name": "bindthem",
"version": "1.0.0", "version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)", "description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,8 +10,8 @@
"version": "node version-bump.mjs && git add manifest.json versions.json", "version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ." "lint": "eslint ."
}, },
"keywords": [], "keywords": ["obsidian", "plugin", "editor", "shortcuts", "formatting"],
"license": "0-BSD", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"esbuild": "0.25.5", "esbuild": "0.25.5",
@@ -26,4 +26,4 @@
"dependencies": { "dependencies": {
"obsidian": "latest" "obsidian": "latest"
} }
} }

View File

@@ -1,99 +1,570 @@
import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian'; import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, Modal, MarkdownFileInfo } from 'obsidian';
import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings";
// Remember to rename these classes and interfaces! // ============================================================
// Settings Interface
// ============================================================
export default class MyPlugin extends Plugin { interface BindThemSettings {
settings: MyPluginSettings; debug: boolean;
}
async onload() { const DEFAULT_SETTINGS: BindThemSettings = {
debug: false
};
// ============================================================
// Utility Types and Functions
// ============================================================
interface EditorPosition {
line: number;
ch: number;
}
interface EditorSelection {
anchor: EditorPosition;
head: EditorPosition;
}
interface EditorRange {
from: EditorPosition;
to: EditorPosition;
}
function getMainSelection(editor: Editor): EditorSelection {
return {
anchor: editor.getCursor('anchor'),
head: editor.getCursor('head')
};
}
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] };
}
function selectionToLine(editor: Editor, selection: EditorSelection): EditorSelection {
const range = selectionToRange(selection);
const toLength = editor.getLine(range.to.line).length;
return {
anchor: { line: range.from.line, ch: 0 },
head: { line: range.to.line, ch: toLength }
};
}
function getSelectionBoundaries(selection: EditorSelection): EditorRange & { hasTrailingNewline: boolean } {
let { anchor: from, head: to } = selection;
if (from.line > to.line || (from.line === to.line && from.ch > to.ch)) {
[from, to] = [to, from];
}
return { from, to, hasTrailingNewline: to.line > from.line && to.ch === 0 };
}
function getLineStartPos(line: number): EditorPosition {
return { line, ch: 0 };
}
function getLineEndPos(line: number, editor: Editor): EditorPosition {
return { line, ch: editor.getLine(line).length };
}
function getLeadingWhitespace(lineContent: string): string {
const indentation = lineContent.match(/^\s+/);
return indentation ? indentation[0] : '';
}
function isLetterOrDigit(char: string): boolean {
return /\p{L}\p{M}*/u.test(char) || /\d/.test(char);
}
function wordRangeAtPos(pos: EditorPosition, lineContent: string): EditorSelection {
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 } };
}
// ============================================================
// Better Formatting (from obsidian-tweaks)
// ============================================================
class BetterFormatting {
static PRE_WIDOW_EXPAND_CHARS = '#$@';
static POST_WIDOW_EXPAND_CHARS = '!?::';
private expandRangeByWords(editor: Editor, range: EditorRange, symbolStart: string, symbolEnd: string): EditorRange {
const wordAtFrom = editor.wordAt(range.from);
const wordAtTo = editor.wordAt(range.to);
let from = wordAtFrom ? wordAtFrom.from : range.from;
let to = wordAtTo ? wordAtTo.to : range.to;
// Expand left
while (from.ch > 0) {
const newFrom = { line: from.line, ch: from.ch - 1 };
const preChar = editor.getRange(newFrom, from);
if (symbolStart.includes(preChar) || !BetterFormatting.PRE_WIDOW_EXPAND_CHARS.includes(preChar)) break;
from = newFrom;
}
// Expand right
while (to.ch < editor.getLine(to.line).length) {
const newTo = { line: to.line, ch: to.ch + 1 };
const postChar = editor.getRange(to, newTo);
if (symbolEnd.includes(postChar) || !BetterFormatting.POST_WIDOW_EXPAND_CHARS.includes(postChar)) break;
to = newTo;
}
// Check for existing wrapping symbols
if (from.ch >= symbolStart.length) {
const newFrom = { line: from.line, ch: from.ch - symbolStart.length };
if (editor.getRange(newFrom, from) === symbolStart) from = newFrom;
}
const newTo = { line: to.line, ch: to.ch + symbolEnd.length };
if (editor.getRange(to, newTo) === symbolEnd) to = newTo;
return { from, to };
}
toggleWrapper(editor: Editor, symbolStart: string, symbolEnd?: string): void {
if (symbolEnd === undefined) symbolEnd = symbolStart;
const selections = editor.listSelections();
const mainSelection = getMainSelection(editor);
const mainRange = this.expandRangeByWords(editor, selectionToRange(mainSelection), symbolStart, symbolEnd);
const text = editor.getRange(mainRange.from, mainRange.to);
const isWrapped = text.startsWith(symbolStart) && text.endsWith(symbolEnd);
const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = [];
if (isWrapped) {
changes.push(
{ from: mainRange.from, to: { line: mainRange.from.line, ch: mainRange.from.ch + symbolStart.length }, text: '' },
{ from: { line: mainRange.to.line, ch: mainRange.to.ch - symbolEnd.length }, to: mainRange.to, text: '' }
);
} else {
changes.push(
{ from: mainRange.from, text: symbolStart },
{ from: mainRange.to, text: symbolEnd }
);
}
editor.transaction({ changes });
}
}
// ============================================================
// Directional Copy & Move (from obsidian-tweaks)
// ============================================================
enum Direction { Up = 'Up', Down = 'Down', Left = 'Left', Right = 'Right' }
class DirectionalCopy {
directionalCopy(editor: Editor, direction: Direction): void {
const selections = editor.listSelections();
const vertical = direction === Direction.Up || direction === Direction.Down;
const processedSelections = vertical ? selections.map(s => selectionToLine(editor, s)) : selections;
const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = [];
for (const selection of processedSelections) {
const range = selectionToRange(selection);
const content = editor.getRange(range.from, range.to);
switch (direction) {
case Direction.Up: changes.push({ from: range.from, text: content + '\n' }); break;
case Direction.Down: changes.push({ from: range.to, text: '\n' + content }); break;
case Direction.Left: changes.push({ from: range.from, text: content }); break;
case Direction.Right: changes.push({ from: range.to, text: content }); break;
}
}
editor.transaction({ changes });
}
}
class DirectionalMove {
directionalMove(editor: Editor, direction: Direction): void {
const selections = editor.listSelections();
const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = [];
for (const selection of selections) {
const range = selectionToRange(selection);
if (direction === Direction.Left && range.from.ch > 0) {
const delFrom = { line: range.from.line, ch: range.from.ch - 1 };
const char = editor.getRange(delFrom, range.from);
changes.push({ from: delFrom, to: range.from, text: '' });
changes.push({ from: range.to, text: char });
} else if (direction === Direction.Right) {
const delTo = { line: range.to.line, ch: range.to.ch + 1 };
const char = editor.getRange(range.to, delTo);
changes.push({ from: range.to, to: delTo, text: '' });
changes.push({ from: range.from, text: char });
}
}
editor.transaction({ changes });
}
}
// ============================================================
// Toggle Heading (merged from obsidian-tweaks and heading-toggler)
// ============================================================
class ToggleHeading {
private static HEADING_REGEX = /^(#*)( *)(.*)/;
toggleHeading(editor: Editor, heading: number): void {
const selections = editor.listSelections();
const mainRange = selectionToRange(getMainSelection(editor));
const mainText = editor.getLine(mainRange.from.line);
const mainMatch = mainText.match(ToggleHeading.HEADING_REGEX);
const mainHeading = mainMatch ? mainMatch[1].length : 0;
const targetHeading = heading === mainHeading ? 0 : heading;
const changes: { from: EditorPosition; to?: EditorPosition; text: string }[] = [];
const linesSet = new Set<number>();
for (const sel of selections) {
const range = selectionToRange(sel);
for (let line = range.from.line; line <= range.to.line; line++) linesSet.add(line);
}
for (const line of Array.from(linesSet).sort((a, b) => a - b)) {
const text = editor.getLine(line);
if (text === '') continue;
const match = text.match(ToggleHeading.HEADING_REGEX);
if (!match) continue;
const from = { line, ch: 0 };
const to = { line, ch: match[1].length + match[2].length };
changes.push({ from, to, text: targetHeading === 0 ? '' : '#'.repeat(targetHeading) + ' ' });
}
editor.transaction({ changes });
}
toggleHeadingWithStrip(editor: Editor, level: number): void {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
const headingRegex = /^(#+)\s(.*)/;
const match = line.match(headingRegex);
let content = line;
let currentLevel = 0;
if (match) {
currentLevel = match[1].length;
content = match[2];
}
content = content.replace(/(\*\*|__)(.*?)\1/g, '$2').replace(/(\*|_)(.*?)\1/g, '$2');
const newText = currentLevel === level ? content : '#'.repeat(level) + ' ' + content;
editor.setLine(cursor.line, newText);
}
}
// ============================================================
// Editor Shortcuts (from obsidian-editor-shortcuts)
// ============================================================
enum CaseType { Upper = 'upper', Lower = 'lower', Title = 'title', Next = 'next' }
const LOWERCASE_ARTICLES = ['the', 'a', 'an'];
function toTitleCase(text: string): string {
return text.split(/(\s+)/).map((word, i, all) => {
if (i > 0 && i < all.length - 1 && LOWERCASE_ARTICLES.includes(word.toLowerCase())) return word.toLowerCase();
return word.charAt(0).toUpperCase() + word.substring(1).toLowerCase();
}).join('');
}
// ============================================================
// Go To Line Modal
// ============================================================
class GoToLineModal extends Modal {
private onSubmit: (line: number) => void;
private lineCount: number;
constructor(app: App, lineCount: number, onSubmit: (line: number) => void) {
super(app);
this.lineCount = lineCount;
this.onSubmit = onSubmit;
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Go to line' });
const input = contentEl.createEl('input', { attr: { type: 'number', min: '1', max: String(this.lineCount), placeholder: `Enter line number (1-${this.lineCount})` } });
const button = contentEl.createEl('button', { text: 'Go' });
button.onclick = () => {
const line = parseInt(input.value) - 1;
if (line >= 0 && line < this.lineCount) { this.onSubmit(line); this.close(); }
};
input.onkeydown = (e) => { if (e.key === 'Enter') button.click(); };
input.focus();
}
onClose(): void { this.contentEl.empty(); }
}
// ============================================================
// Main Plugin Class
// ============================================================
export default class BindThemPlugin extends Plugin {
settings!: BindThemSettings;
private betterFormatting = new BetterFormatting();
private directionalCopy = new DirectionalCopy();
private directionalMove = new DirectionalMove();
private toggleHeading = new ToggleHeading();
async onload(): Promise<void> {
await this.loadSettings(); await this.loadSettings();
// This creates an icon in the left ribbon. // Better Formatting Commands
this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => { const formattingCommands: [string, string, string | undefined][] = [
// Called when the user clicks the icon. ['bold-underscore', '__', '__'], ['bold-asterisk', '**', '**'],
new Notice('This is a notice!'); ['italics-underscore', '_', '_'], ['italics-asterisk', '*', '*'],
}); ['code', '`', '`'], ['comment', '%%', '%%'], ['highlight', '==', '=='],
['strikethrough', '~~', '~~'], ['math-inline', '$', '$'], ['math-block', '$$', '$$']
];
for (const [id, start, end] of formattingCommands) {
this.addCommand({
id: `toggle-${id}`,
name: `Toggle ${id.replace('-', ' ')}`,
editorCallback: (editor) => this.betterFormatting.toggleWrapper(editor, start, end)
});
}
// This adds a status bar item to the bottom of the app. Does not work on mobile apps. // Directional Copy Commands
const statusBarItemEl = this.addStatusBarItem(); for (const dir of [Direction.Up, Direction.Down, Direction.Left, Direction.Right]) {
statusBarItemEl.setText('Status bar text'); this.addCommand({
id: `copy-${dir.toLowerCase()}`,
name: `Copy ${dir}`,
editorCallback: (editor) => this.directionalCopy.directionalCopy(editor, dir)
});
}
// This adds a simple command that can be triggered anywhere // Directional Move Commands
this.addCommand({ for (const dir of [Direction.Left, Direction.Right]) {
id: 'open-modal-simple', this.addCommand({
name: 'Open modal (simple)', id: `move-${dir.toLowerCase()}`,
callback: () => { name: `Move ${dir}`,
new SampleModal(this.app).open(); editorCallback: (editor) => this.directionalMove.directionalMove(editor, dir)
});
}
// Toggle Heading Commands
for (let i = 1; i <= 6; i++) {
this.addCommand({
id: `toggle-heading-${i}`,
name: `Toggle heading ${i}`,
editorCallback: (editor) => this.toggleHeading.toggleHeading(editor, i)
});
this.addCommand({
id: `toggle-heading-strip-${i}`,
name: `Toggle heading ${i} (strip formatting)`,
editorCallback: (editor) => this.toggleHeading.toggleHeadingWithStrip(editor, i)
});
}
// Editor Shortcuts
this.addCommand({ id: 'insert-line-above', name: 'Insert line above', editorCallback: (e) => this.insertLine(e, 'above') });
this.addCommand({ id: 'insert-line-below', name: 'Insert line below', editorCallback: (e) => this.insertLine(e, 'below') });
this.addCommand({ id: 'delete-line', name: 'Delete line', editorCallback: (e) => this.deleteLine(e) });
this.addCommand({ id: 'delete-to-start-of-line', name: 'Delete to start of line', editorCallback: (e) => this.deleteToBoundary(e, 'start') });
this.addCommand({ id: 'delete-to-end-of-line', name: 'Delete to end of line', editorCallback: (e) => this.deleteToBoundary(e, 'end') });
this.addCommand({ id: 'join-lines', name: 'Join lines', editorCallback: (e) => this.joinLines(e) });
this.addCommand({ id: 'duplicate-line', name: 'Duplicate line', editorCallback: (e) => this.copyLine(e, 'down') });
this.addCommand({ id: 'copy-line-up', name: 'Copy line up', editorCallback: (e) => this.copyLine(e, 'up') });
this.addCommand({ id: 'copy-line-down', name: 'Copy line down', editorCallback: (e) => this.copyLine(e, 'down') });
this.addCommand({ id: 'select-word', name: 'Select word', editorCallback: (e) => this.selectWord(e) });
this.addCommand({ id: 'select-line', name: 'Select line', editorCallback: (e) => this.selectLine(e) });
// Case Transformation
this.addCommand({ id: 'transform-uppercase', name: 'Transform to uppercase', editorCallback: (e) => this.transformCase(e, CaseType.Upper) });
this.addCommand({ id: 'transform-lowercase', name: 'Transform to lowercase', editorCallback: (e) => this.transformCase(e, CaseType.Lower) });
this.addCommand({ id: 'transform-titlecase', name: 'Transform to title case', editorCallback: (e) => this.transformCase(e, CaseType.Title) });
this.addCommand({ id: 'toggle-case', name: 'Toggle case', editorCallback: (e) => this.transformCase(e, CaseType.Next) });
// Navigation
this.addCommand({ id: 'go-to-line-start', name: 'Go to line start', editorCallback: (e) => e.setCursor({ line: e.getCursor().line, ch: 0 }) });
this.addCommand({ id: 'go-to-line-end', name: 'Go to line end', editorCallback: (e) => e.setCursor({ line: e.getCursor().line, ch: e.getLine(e.getCursor().line).length }) });
this.addCommand({ id: 'go-to-first-line', name: 'Go to first line', editorCallback: (e) => e.setCursor({ line: 0, ch: 0 }) });
this.addCommand({ id: 'go-to-last-line', name: 'Go to last line', editorCallback: (e) => e.setCursor({ line: e.lineCount() - 1, ch: 0 }) });
this.addCommand({ id: 'go-to-line-number', name: 'Go to line number', editorCallback: (e) => new GoToLineModal(this.app, e.lineCount(), (line) => e.setCursor({ line, ch: 0 })).open() });
this.addCommand({ id: 'go-to-next-heading', name: 'Go to next heading', editorCallback: (e) => this.goToHeading(e, 'next') });
this.addCommand({ id: 'go-to-prev-heading', name: 'Go to previous heading', editorCallback: (e) => this.goToHeading(e, 'prev') });
// Cursor Movement
this.addCommand({ id: 'move-cursor-up', name: 'Move cursor up', editorCallback: (e) => e.exec('goUp') });
this.addCommand({ id: 'move-cursor-down', name: 'Move cursor down', editorCallback: (e) => e.exec('goDown') });
this.addCommand({ id: 'move-cursor-left', name: 'Move cursor left', editorCallback: (e) => e.exec('goLeft') });
this.addCommand({ id: 'move-cursor-right', name: 'Move cursor right', editorCallback: (e) => e.exec('goRight') });
this.addCommand({ id: 'go-to-prev-word', name: 'Go to previous word', editorCallback: (e) => e.exec('goWordLeft') });
this.addCommand({ id: 'go-to-next-word', name: 'Go to next word', editorCallback: (e) => e.exec('goWordRight') });
this.addCommand({ id: 'insert-cursor-above', name: 'Insert cursor above', editorCallback: (e) => this.insertCursor(e, -1) });
this.addCommand({ id: 'insert-cursor-below', name: 'Insert cursor below', editorCallback: (e) => this.insertCursor(e, 1) });
// File Operations
this.addCommand({ id: 'duplicate-file', name: 'Duplicate file', editorCallback: (e, v) => this.duplicateFile(e, v as MarkdownView) });
this.addCommand({ id: 'new-adjacent-file', name: 'New adjacent file', editorCallback: (e, v) => this.newAdjacentFile(e, v as MarkdownView) });
// Settings
this.addCommand({ id: 'toggle-line-numbers', name: 'Toggle line numbers', callback: () => { const val = this.app.vault.getConfig('showLineNumber'); (this.app.vault as unknown as { setConfig: (k: string, v: unknown) => void }).setConfig('showLineNumber', !val); } });
this.addCommand({ id: 'undo', name: 'Undo', editorCallback: (e) => e.undo() });
this.addCommand({ id: 'redo', name: 'Redo', editorCallback: (e) => e.redo() });
this.addSettingTab(new BindThemSettingTab(this.app, this));
}
// Editor Helper Methods
private insertLine(editor: Editor, where: 'above' | 'below'): void {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
const indent = getLeadingWhitespace(line);
if (where === 'above') {
editor.replaceRange(indent + '\n', { line: cursor.line, ch: 0 });
editor.setCursor({ line: cursor.line, ch: indent.length });
} else {
editor.replaceRange('\n' + indent, { line: cursor.line, ch: line.length });
editor.setCursor({ line: cursor.line + 1, ch: indent.length });
}
}
private deleteLine(editor: Editor): void {
const cursor = editor.getCursor();
const from = { line: cursor.line, ch: 0 };
const to = { line: cursor.line + 1, ch: 0 };
editor.replaceRange('', from, cursor.line === editor.lineCount() - 1 ? { line: cursor.line, ch: editor.getLine(cursor.line).length } : to);
}
private deleteToBoundary(editor: Editor, boundary: 'start' | 'end'): void {
const cursor = editor.getCursor();
if (boundary === 'start') {
editor.replaceRange('', { line: cursor.line, ch: 0 }, cursor);
} else {
editor.replaceRange('', cursor, { line: cursor.line, ch: editor.getLine(cursor.line).length });
}
}
private joinLines(editor: Editor): void {
const cursor = editor.getCursor();
if (cursor.line >= editor.lineCount() - 1) return;
const currentLine = editor.getLine(cursor.line);
const nextLine = editor.getLine(cursor.line + 1).replace(/^\s*[-+*>\d.]+\s*/, '');
const separator = currentLine.endsWith(' ') || nextLine.startsWith(' ') ? '' : ' ';
editor.replaceRange(separator + nextLine, { line: cursor.line, ch: currentLine.length }, { line: cursor.line + 1, ch: editor.getLine(cursor.line + 1).length });
editor.setCursor({ line: cursor.line, ch: currentLine.length + separator.length });
}
private copyLine(editor: Editor, direction: 'up' | 'down'): void {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
if (direction === 'up') {
editor.replaceRange(line + '\n', { line: cursor.line, ch: 0 });
} else {
editor.replaceRange('\n' + line, { line: cursor.line, ch: line.length });
editor.setCursor({ line: cursor.line + 1, ch: cursor.ch });
}
}
private selectWord(editor: Editor): void {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
const wordRange = wordRangeAtPos(cursor, line);
editor.setSelection(wordRange.anchor, wordRange.head);
}
private selectLine(editor: Editor): void {
const cursor = editor.getCursor();
editor.setSelection({ line: cursor.line, ch: 0 }, { line: cursor.line, ch: editor.getLine(cursor.line).length });
}
private transformCase(editor: Editor, caseType: CaseType): void {
const selection = editor.getSelection();
const cursor = editor.getCursor();
let text = selection || editor.getRange(...Object.values(wordRangeAtPos(cursor, editor.getLine(cursor.line))) as [EditorPosition, EditorPosition]);
let result: string;
switch (caseType) {
case CaseType.Upper: result = text.toUpperCase(); break;
case CaseType.Lower: result = text.toLowerCase(); break;
case CaseType.Title: result = toTitleCase(text); break;
case CaseType.Next: result = text === text.toUpperCase() ? text.toLowerCase() : text === text.toLowerCase() ? toTitleCase(text) : text.toUpperCase(); break;
}
if (selection) editor.replaceSelection(result);
else { const range = wordRangeAtPos(cursor, editor.getLine(cursor.line)); editor.replaceRange(result, range.anchor, range.head); }
}
private insertCursor(editor: Editor, offset: number): void {
const selections = editor.listSelections();
const newSelections: EditorSelection[] = [];
for (const sel of selections) {
const newLine = sel.head.line + offset;
if (newLine >= 0 && newLine < editor.lineCount()) {
newSelections.push({ anchor: { line: sel.anchor.line + offset, ch: Math.min(sel.anchor.ch, editor.getLine(newLine).length) }, head: { line: newLine, ch: Math.min(sel.head.ch, editor.getLine(newLine).length) } });
} }
}); }
// This adds an editor command that can perform some operation on the current editor instance editor.setSelections([...selections, ...newSelections]);
this.addCommand({
id: 'replace-selected',
name: 'Replace selected content',
editorCallback: (editor: Editor, view: MarkdownView) => {
editor.replaceSelection('Sample editor command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-modal-complex',
name: 'Open modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
return false;
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
new Notice("Click");
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
} }
onunload() { private goToHeading(editor: Editor, direction: 'next' | 'prev'): void {
const file = this.app.workspace.getActiveFile();
if (!file) return;
const cache = this.app.metadataCache.getFileCache(file);
if (!cache?.headings?.length) return;
const cursorLine = editor.getCursor().line;
let targetLine = direction === 'next' ? editor.lineCount() - 1 : 0;
for (const h of cache.headings) {
const hLine = h.position.end.line;
if (direction === 'next' && hLine > cursorLine && hLine < targetLine) targetLine = hLine;
if (direction === 'prev' && hLine < cursorLine && hLine > targetLine) targetLine = hLine;
}
editor.setCursor({ line: targetLine, ch: 0 });
editor.scrollIntoView({ from: { line: targetLine, ch: 0 }, to: { line: targetLine, ch: 0 } });
} }
async loadSettings() { private async duplicateFile(editor: Editor, view: MarkdownView): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<MyPluginSettings>); const file = this.app.workspace.getActiveFile();
if (!file) return;
const newPath = (file.parent?.path ?? '') + '/' + file.basename + ' (copy).' + file.extension;
try {
const newFile = await this.app.vault.copy(file, newPath);
await view.leaf.openFile(newFile);
} catch (e) { new Notice(String(e)); }
} }
async saveSettings() { private async newAdjacentFile(editor: Editor, view: MarkdownView): Promise<void> {
await this.saveData(this.settings); const file = this.app.workspace.getActiveFile();
if (!file) return;
const newPath = (file.parent?.path ?? '') + '/Untitled.md';
try {
const newFile = await this.app.vault.create(newPath, '');
await view.leaf.openFile(newFile);
} catch (e) { new Notice(String(e)); }
} }
async loadSettings(): Promise<void> { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); }
async saveSettings(): Promise<void> { await this.saveData(this.settings); }
} }
class SampleModal extends Modal { // ============================================================
constructor(app: App) { // Settings Tab
super(app); // ============================================================
class BindThemSettingTab extends PluginSettingTab {
private plugin: BindThemPlugin;
constructor(app: App, plugin: BindThemPlugin) {
super(app, plugin);
this.plugin = plugin;
} }
onOpen() { display(): void {
let {contentEl} = this; const { containerEl } = this;
contentEl.setText('Woah!'); containerEl.empty();
new Setting(containerEl).setName('Debug mode').setDesc('Enable debug logging').addToggle(t => t.setValue(this.plugin.settings.debug).onChange(async v => { this.plugin.settings.debug = v; await this.plugin.saveSettings(); }));
new Setting(containerEl).setName('About').setDesc('BindThem combines features from obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler.').setHeading();
} }
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}

View File

@@ -1,36 +1,43 @@
import {App, PluginSettingTab, Setting} from "obsidian"; import { App, PluginSettingTab, Setting } from 'obsidian';
import MyPlugin from "./main"; import BindThemPlugin from './main';
export interface MyPluginSettings { export interface BindThemSettings {
mySetting: string; debug: boolean;
} }
export const DEFAULT_SETTINGS: MyPluginSettings = { export const DEFAULT_SETTINGS: BindThemSettings = {
mySetting: 'default' debug: false
} }
export class SampleSettingTab extends PluginSettingTab { export class BindThemSettingTab extends PluginSettingTab {
plugin: MyPlugin; plugin: BindThemPlugin;
constructor(app: App, plugin: MyPlugin) { constructor(app: App, plugin: BindThemPlugin) {
super(app, plugin); super(app, plugin);
this.plugin = plugin; this.plugin = plugin;
} }
display(): void { display(): void {
const {containerEl} = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
new Setting(containerEl) new Setting(containerEl)
.setName('Settings #1') .setName('Debug mode')
.setDesc('It\'s a secret') .setDesc('Enable debug logging to console')
.addText(text => text .addToggle(toggle => toggle
.setPlaceholder('Enter your secret') .setValue(this.plugin.settings.debug)
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.mySetting = value; this.plugin.settings.debug = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }));
new Setting(containerEl)
.setName('About')
.setDesc('BindThem combines features from obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler into a single plugin.')
.setHeading();
new Setting(containerEl)
.setName('Included features')
.setDesc('• Better Formatting - Smart toggle for bold, italic, code, highlight, etc.\n• Directional Copy/Move - Copy or move text in any direction\n• Toggle Headings (H1-H6) - Including strip formatting option\n• Editor Shortcuts - Insert/delete lines, join lines, case transform\n• Navigation - Go to line, headings, word navigation\n• Multi-cursor support - Insert cursor above/below\n• File operations - Duplicate file, create adjacent file');
} }
} }

View File

@@ -1,30 +1,25 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "src", "baseUrl": ".",
"inlineSourceMap": true, "inlineSourceMap": true,
"inlineSources": true, "inlineSources": true,
"module": "ESNext", "module": "ESNext",
"target": "ES6", "target": "ES6",
"allowJs": true, "allowJs": true,
"noImplicitAny": true, "noImplicitAny": true,
"noImplicitThis": true, "moduleResolution": "node",
"noImplicitReturns": true, "importHelpers": true,
"moduleResolution": "node", "isolatedModules": true,
"importHelpers": true, "strictNullChecks": true,
"noUncheckedIndexedAccess": true, "strict": true,
"isolatedModules": true, "lib": [
"strictNullChecks": true, "DOM",
"strictBindCallApply": true, "ES5",
"allowSyntheticDefaultImports": true, "ES6",
"useUnknownInCatchVariables": true, "ES7"
"lib": [ ]
"DOM", },
"ES5", "include": [
"ES6", "src/**/*.ts"
"ES7" ]
] }
},
"include": [
"src/**/*.ts"
]
}