diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 1c74a14..c89f384 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,6 +1,5 @@ import esbuild from "esbuild"; import process from "process"; -import { builtinModules } from 'node:module'; 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({ banner: { js: banner, }, - entryPoints: ["src/main.ts"], + 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", - ...builtinModules], - format: "cjs", - target: "es2018", + '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", + sourcemap: prod ? false : 'inline', treeShaking: true, - outfile: "main.js", - minify: prod, + outfile: import.meta.dirname + '/main.js', }); if (prod) { @@ -46,4 +61,4 @@ if (prod) { process.exit(0); } else { await context.watch(); -} +} \ No newline at end of file diff --git a/manifest.json b/manifest.json index dfa940e..58a2bc8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,9 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", + "id": "bindthem", + "name": "BindThem", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Demonstrates some of the capabilities of the Obsidian API.", - "author": "Obsidian", - "authorUrl": "https://obsidian.md", - "fundingUrl": "https://obsidian.md/pricing", + "description": "A merged plugin combining obsidian-tweaks, obsidian-editor-shortcuts, and heading-toggler functionalities.", + "author": "Merged Plugin", "isDesktopOnly": false -} +} \ No newline at end of file diff --git a/package.json b/package.json index 17268d7..7992ca1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-sample-plugin", + "name": "bindthem", "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", "type": "module", "scripts": { @@ -10,8 +10,8 @@ "version": "node version-bump.mjs && git add manifest.json versions.json", "lint": "eslint ." }, - "keywords": [], - "license": "0-BSD", + "keywords": ["obsidian", "plugin", "editor", "shortcuts", "formatting"], + "license": "MIT", "devDependencies": { "@types/node": "^16.11.6", "esbuild": "0.25.5", @@ -26,4 +26,4 @@ "dependencies": { "obsidian": "latest" } -} +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 6fe0c83..7a0fbb9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,99 +1,570 @@ -import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian'; -import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings"; +import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, Modal, MarkdownFileInfo } from 'obsidian'; -// Remember to rename these classes and interfaces! +// ============================================================ +// Settings Interface +// ============================================================ -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; +interface BindThemSettings { + 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(); + 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 { await this.loadSettings(); - // This creates an icon in the left ribbon. - this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); - }); + // Better Formatting Commands + const formattingCommands: [string, string, string | undefined][] = [ + ['bold-underscore', '__', '__'], ['bold-asterisk', '**', '**'], + ['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. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status bar text'); + // Directional Copy Commands + for (const dir of [Direction.Up, Direction.Down, Direction.Left, Direction.Right]) { + 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 - this.addCommand({ - id: 'open-modal-simple', - name: 'Open modal (simple)', - callback: () => { - new SampleModal(this.app).open(); + // Directional Move Commands + for (const dir of [Direction.Left, Direction.Right]) { + this.addCommand({ + id: `move-${dir.toLowerCase()}`, + name: `Move ${dir}`, + 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 - 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)); - + } + editor.setSelections([...selections, ...newSelections]); } - 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() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); + private async duplicateFile(editor: Editor, view: MarkdownView): Promise { + 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() { - await this.saveData(this.settings); + private async newAdjacentFile(editor: Editor, view: MarkdownView): Promise { + 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 { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } + async saveSettings(): Promise { await this.saveData(this.settings); } } -class SampleModal extends Modal { - constructor(app: App) { - super(app); +// ============================================================ +// Settings Tab +// ============================================================ + +class BindThemSettingTab extends PluginSettingTab { + private plugin: BindThemPlugin; + + constructor(app: App, plugin: BindThemPlugin) { + super(app, plugin); + this.plugin = plugin; } - onOpen() { - let {contentEl} = this; - contentEl.setText('Woah!'); + display(): void { + const { containerEl } = this; + 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(); - } -} +} \ No newline at end of file diff --git a/src/settings.ts b/src/settings.ts index 352121e..56f9412 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,36 +1,43 @@ -import {App, PluginSettingTab, Setting} from "obsidian"; -import MyPlugin from "./main"; +import { App, PluginSettingTab, Setting } from 'obsidian'; +import BindThemPlugin from './main'; -export interface MyPluginSettings { - mySetting: string; +export interface BindThemSettings { + debug: boolean; } -export const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' +export const DEFAULT_SETTINGS: BindThemSettings = { + debug: false } -export class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; +export class BindThemSettingTab extends PluginSettingTab { + plugin: BindThemPlugin; - constructor(app: App, plugin: MyPlugin) { + constructor(app: App, plugin: BindThemPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { - const {containerEl} = this; - + const { containerEl } = this; containerEl.empty(); new Setting(containerEl) - .setName('Settings #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue(this.plugin.settings.mySetting) + .setName('Debug mode') + .setDesc('Enable debug logging to console') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.debug) .onChange(async (value) => { - this.plugin.settings.mySetting = value; + this.plugin.settings.debug = value; 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'); } -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 222535d..2c31914 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,25 @@ { - "compilerOptions": { - "baseUrl": "src", - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "ES6", - "allowJs": true, - "noImplicitAny": true, - "noImplicitThis": true, - "noImplicitReturns": true, - "moduleResolution": "node", - "importHelpers": true, - "noUncheckedIndexedAccess": true, - "isolatedModules": true, - "strictNullChecks": true, - "strictBindCallApply": true, - "allowSyntheticDefaultImports": true, - "useUnknownInCatchVariables": true, - "lib": [ - "DOM", - "ES5", - "ES6", - "ES7" - ] - }, - "include": [ - "src/**/*.ts" - ] -} + "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" + ] +} \ No newline at end of file