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

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

6
package-lock.json generated
View File

@@ -1,13 +1,13 @@
{
"name": "obsidian-sample-plugin",
"name": "bindthem",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-sample-plugin",
"name": "bindthem",
"version": "1.0.0",
"license": "0-BSD",
"license": "MIT",
"dependencies": {
"obsidian": "latest"
},

328
src/SelectionOccurrence.ts Normal file
View File

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

View File

@@ -14,12 +14,18 @@ import { SelectionHelper } from './SelectionHelper';
import { FileHelper } from './FileHelper';
import { SentenceNavigator } from './SentenceNavigator';
import { Direction, Heading, CaseType } from './Entities';
import {
selectWordOrNextOccurrence,
selectAllOccurrences as selectAllOccurrencesFn,
setIsManualSelection,
setIsProgrammaticSelectionChange,
getIsProgrammaticSelectionChange,
} from './SelectionOccurrence';
import { DEBUG_HEAD } from './Constants';
import {
getLeadingWhitespace,
wordRangeAtPos,
toTitleCase,
selectionToRange,
} from './Utils';
// ============================================================
@@ -57,6 +63,8 @@ export default class BindThemPlugin extends Plugin {
// Register commands
this.registerCommands();
this.registerSelectionChangeListeners();
this.addSettingTab(new BindThemSettingTab(this.app, this));
}
@@ -625,14 +633,21 @@ export default class BindThemPlugin extends Plugin {
name: 'Select Next Occurrence',
icon: 'text-cursor',
editorCallback: (editor: Editor) => {
this.selectOccurrence(editor, 'next');
selectWordOrNextOccurrence(editor, 'next');
}});
this.addConditionalCommand({
id: 'select-prev-occurrence',
name: 'Select Previous Occurrence',
icon: 'text-cursor',
editorCallback: (editor: Editor) => {
this.selectOccurrence(editor, 'prev');
selectWordOrNextOccurrence(editor, 'prev');
}});
this.addConditionalCommand({
id: 'select-all-occurrences',
name: 'Select All Occurrences',
icon: 'text-cursor-input',
editorCallback: (editor: Editor) => {
selectAllOccurrencesFn(editor);
}});
// ============================================================
@@ -794,64 +809,6 @@ export default class BindThemPlugin extends Plugin {
editor.setSelections([...editor.listSelections(), ...newSels]);
}
private selectOccurrence(editor: Editor, direction: 'next' | 'prev'): void {
const selections = editor.listSelections();
if (selections.length === 0) return;
const lastSelection = direction === 'next' ? selections[selections.length - 1] : selections[0];
const range = selectionToRange(lastSelection);
const selectedText = editor.getRange(range.from, range.to);
if (!selectedText) {
this.selectionHelper.selectWord(editor, null as unknown as MarkdownView);
return;
}
const fullText = editor.getValue();
const occurrences: { from: number; to: number }[] = [];
let searchPos = 0;
while (true) {
const idx = fullText.indexOf(selectedText, searchPos);
if (idx === -1) break;
occurrences.push({ from: idx, to: idx + selectedText.length });
searchPos = idx + 1;
}
if (occurrences.length <= 1) return;
const currentOffset = editor.posToOffset(range.from);
let targetOccurrence: { from: number; to: number } | null = null;
if (direction === 'next') {
for (const occ of occurrences) {
if (occ.from > currentOffset) {
targetOccurrence = occ;
break;
}
}
if (!targetOccurrence) {
targetOccurrence = occurrences[0];
}
} else {
for (let i = occurrences.length - 1; i >= 0; i--) {
if (occurrences[i].from < currentOffset) {
targetOccurrence = occurrences[i];
break;
}
}
if (!targetOccurrence) {
targetOccurrence = occurrences[occurrences.length - 1];
}
}
if (targetOccurrence) {
const from = editor.offsetToPos(targetOccurrence.from);
const to = editor.offsetToPos(targetOccurrence.to);
const newSelections = [...selections, { anchor: from, head: to }];
editor.setSelections(newSelections);
}
}
private goToHeading(editor: Editor, direction: 'next' | 'prev'): void {
const file = this.app.workspace.getActiveFile();
const cache = file ? this.app.metadataCache.getFileCache(file) : null;
@@ -934,6 +891,45 @@ export default class BindThemPlugin extends Plugin {
inputEl.focus();
}
// ============================================================
// Selection Change Listeners
// ============================================================
/**
* Registers event listeners on CodeMirror editors to track whether a
* selection change was manual (mouse/keyboard) or programmatic.
* This controls the match behavior for select next/previous occurrence:
* - Manual changes → whole-word matching
* - Programmatic changes → within-word matching
*/
private registerSelectionChangeListeners(): void {
this.app.workspace.onLayoutReady(() => {
const MODIFIER_KEYS = [
'Control', 'Shift', 'Alt', 'Meta', 'CapsLock', 'Fn',
]
const handleSelectionChange = (evt: Event) => {
if (
evt instanceof KeyboardEvent &&
MODIFIER_KEYS.includes(evt.key)
) {
return
}
if (!getIsProgrammaticSelectionChange()) {
setIsManualSelection(true)
}
setIsProgrammaticSelectionChange(false)
}
// Observe CodeMirror 6 editors (new Obsidian editor)
document.querySelectorAll('.cm-content').forEach((el) => {
this.registerDomEvent(el as HTMLElement, 'keydown', handleSelectionChange)
this.registerDomEvent(el as HTMLElement, 'click', handleSelectionChange)
this.registerDomEvent(el as HTMLElement, 'dblclick', handleSelectionChange)
})
})
}
// ============================================================
// Settings
// ============================================================

View File

@@ -107,7 +107,8 @@ export const COMMANDS: Record<keyof typeof COMMAND_CATEGORIES, CommandDefinition
{ 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-prev-occurrence', name: 'Select previous occurrence' },
{ id: 'select-all-occurrences', name: 'Select all occurrences' }
],
fileOperations: [
{ id: 'duplicate-file', name: 'Duplicate file' },

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"ignoreDeprecations": "5.0",
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
@@ -14,9 +15,7 @@
"strict": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2020"
]
},
"include": [