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