diff --git a/README.md b/README.md index 5a633d1..b4b01ce 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This [Obsidian](https://obsidian.md) plugin adds keyboard shortcuts (hotkeys) co | Join line below to current line | `Ctrl` + `J` | | Select line (repeat to keep expanding selection) | `Ctrl` + `L` | | Add cursors to selection ends | `Alt` + `Shift` + `I` | -| Select word | Not set | +| Select word or next occurrence of selection | `Ctrl` + `D` | | Move cursor left | Not set | | Move cursor right | Not set | | Go to start of line | Not set | diff --git a/src/__tests__/actions-cursor.spec.ts b/src/__tests__/actions-cursor.spec.ts index 85f971f..0c6c942 100644 --- a/src/__tests__/actions-cursor.spec.ts +++ b/src/__tests__/actions-cursor.spec.ts @@ -8,7 +8,7 @@ import { deleteToEndOfLine, joinLines, copyLine, - selectWord, + selectWordOrNextOccurrence, selectLine, goToLineBoundary, navigateLine, @@ -228,9 +228,9 @@ describe('Code Editor Shortcuts: actions - single cursor selection', () => { }); }); - describe('selectWord', () => { + describe('selectWordOrNextOccurrence', () => { it('should select word', () => { - withMultipleSelections(editor as any, selectWord); + selectWordOrNextOccurrence(editor as any); const { doc, selectedText } = getDocumentAndSelection(editor); expect(doc).toEqual(originalDoc); @@ -241,7 +241,7 @@ describe('Code Editor Shortcuts: actions - single cursor selection', () => { editor.setValue('café'); editor.setCursor({ line: 0, ch: 2 }); - withMultipleSelections(editor as any, selectWord); + selectWordOrNextOccurrence(editor as any); const { selectedText } = getDocumentAndSelection(editor); expect(selectedText).toEqual('café'); diff --git a/src/__tests__/actions-multi.spec.ts b/src/__tests__/actions-multi.spec.ts index c86e24c..1c27bb0 100644 --- a/src/__tests__/actions-multi.spec.ts +++ b/src/__tests__/actions-multi.spec.ts @@ -8,7 +8,7 @@ import { deleteToEndOfLine, joinLines, copyLine, - selectWord, + selectWordOrNextOccurrence, selectLine, goToLineBoundary, navigateLine, @@ -51,6 +51,10 @@ describe('Code Editor Shortcuts: actions - multiple mixed selections', () => { // To make cm.operation() work, since editor here already refers to the // CodeMirror object (editor as any).cm = editor; + + // Assign the CodeMirror equivalents of posToOffset and offsetToPos + (editor as any).posToOffset = editor.indexFromPos; + (editor as any).offsetToPos = editor.posFromIndex; }); const originalSelectionRanges = [ @@ -345,9 +349,23 @@ describe('Code Editor Shortcuts: actions - multiple mixed selections', () => { }); }); - describe('selectWord', () => { + describe('selectWordOrNextOccurrence', () => { it('should select words', () => { - withMultipleSelections(editor as any, selectWord); + selectWordOrNextOccurrence(editor as any); + + const { doc, selectedTextMultiple } = getDocumentAndSelection(editor); + expect(doc).toEqual(originalDoc); + expect(selectedTextMultiple).toEqual([ + 'ipsum\ndolor', + 'amet', + 'dip', + 'elit', + ]); + }); + + it('should not select next occurrence if multiple selection contents are not identical', () => { + selectWordOrNextOccurrence(editor as any); + selectWordOrNextOccurrence(editor as any); const { doc, selectedTextMultiple } = getDocumentAndSelection(editor); expect(doc).toEqual(originalDoc); @@ -366,7 +384,7 @@ describe('Code Editor Shortcuts: actions - multiple mixed selections', () => { { anchor: { line: 0, ch: 8 }, head: { line: 0, ch: 8 } }, ]); - withMultipleSelections(editor as any, selectWord); + selectWordOrNextOccurrence(editor as any); const { selectedTextMultiple } = getDocumentAndSelection(editor); expect(selectedTextMultiple[0]).toEqual('café'); diff --git a/src/__tests__/actions-range.spec.ts b/src/__tests__/actions-range.spec.ts index 5987845..be1d4e3 100644 --- a/src/__tests__/actions-range.spec.ts +++ b/src/__tests__/actions-range.spec.ts @@ -8,7 +8,7 @@ import { deleteToEndOfLine, joinLines, copyLine, - selectWord, + selectWordOrNextOccurrence, selectLine, goToLineBoundary, navigateLine, @@ -42,9 +42,14 @@ describe('Code Editor Shortcuts: actions - single range selection', () => { beforeAll(() => { editor = CodeMirror(document.body); + // To make cm.operation() work, since editor here already refers to the // CodeMirror object (editor as any).cm = editor; + + // Assign the CodeMirror equivalents of posToOffset and offsetToPos + (editor as any).posToOffset = editor.indexFromPos; + (editor as any).offsetToPos = editor.posFromIndex; }); beforeEach(() => { @@ -156,14 +161,108 @@ describe('Code Editor Shortcuts: actions - single range selection', () => { }); }); - describe('selectWord', () => { + describe('selectWordOrNextOccurrence', () => { + const originalDocRepeated = `${originalDoc}\n${originalDoc}\n${originalDoc}`; + it('should not select additional words', () => { - withMultipleSelections(editor as any, selectWord); + selectWordOrNextOccurrence(editor as any); const { doc, selectedText } = getDocumentAndSelection(editor); expect(doc).toEqual(originalDoc); expect(selectedText).toEqual('ipsum\ndolor'); }); + + it('should select next occurrence of selection', () => { + editor.setValue(originalDocRepeated); + editor.setSelection({ line: 1, ch: 6 }, { line: 1, ch: 9 }); + + selectWordOrNextOccurrence(editor as any); + selectWordOrNextOccurrence(editor as any); + + const { doc, selections } = getDocumentAndSelection(editor); + expect(doc).toEqual(originalDocRepeated); + expect(selections).toEqual([ + { + anchor: expect.objectContaining({ line: 1, ch: 6 }), + head: expect.objectContaining({ line: 1, ch: 9 }), + }, + { + anchor: expect.objectContaining({ line: 4, ch: 6 }), + head: expect.objectContaining({ line: 4, ch: 9 }), + }, + { + anchor: expect.objectContaining({ line: 7, ch: 6 }), + head: expect.objectContaining({ line: 7, ch: 9 }), + }, + ]); + }); + + it('should select next occurrence of selection across newlines', () => { + editor.setValue(originalDocRepeated); + editor.setSelection({ line: 1, ch: 5 }, { line: 0, ch: 6 }); + + selectWordOrNextOccurrence(editor as any); + selectWordOrNextOccurrence(editor as any); + + const { doc, selections } = getDocumentAndSelection(editor); + expect(doc).toEqual(originalDocRepeated); + expect(selections).toEqual([ + { + anchor: expect.objectContaining({ line: 1, ch: 5 }), + head: expect.objectContaining({ line: 0, ch: 6 }), + }, + { + anchor: expect.objectContaining({ line: 3, ch: 6 }), + head: expect.objectContaining({ line: 4, ch: 5 }), + }, + { + anchor: expect.objectContaining({ line: 6, ch: 6 }), + head: expect.objectContaining({ line: 7, ch: 5 }), + }, + ]); + }); + + it('should only select whole words', () => { + editor.setValue(originalDocRepeated); + editor.setSelection({ line: 1, ch: 2 }, { line: 1, ch: 7 }); + + selectWordOrNextOccurrence(editor as any); + selectWordOrNextOccurrence(editor as any); + + const { doc, selections } = getDocumentAndSelection(editor); + expect(doc).toEqual(originalDocRepeated); + expect(selections).toEqual([ + { + anchor: expect.objectContaining({ line: 1, ch: 2 }), + head: expect.objectContaining({ line: 1, ch: 7 }), + }, + ]); + }); + + it('should loop around to beginning when selecting next occurrence', () => { + editor.setValue(originalDocRepeated); + editor.setSelection({ line: 4, ch: 0 }, { line: 4, ch: 5 }); + + selectWordOrNextOccurrence(editor as any); + selectWordOrNextOccurrence(editor as any); + + const { doc, selections } = getDocumentAndSelection(editor); + expect(doc).toEqual(originalDocRepeated); + expect(selections).toEqual([ + { + anchor: expect.objectContaining({ line: 1, ch: 0 }), + head: expect.objectContaining({ line: 1, ch: 5 }), + }, + { + anchor: expect.objectContaining({ line: 4, ch: 0 }), + head: expect.objectContaining({ line: 4, ch: 5 }), + }, + { + anchor: expect.objectContaining({ line: 7, ch: 0 }), + head: expect.objectContaining({ line: 7, ch: 5 }), + }, + ]); + }); }); describe('selectLine', () => { diff --git a/src/actions.ts b/src/actions.ts index baba187..a364421 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -11,11 +11,13 @@ import { } from './constants'; import { CheckCharacter, + findNextMatch, findPosOfNextCharacter, getLeadingWhitespace, getLineEndPos, getLineStartPos, getSelectionBoundaries, + hasSameSelectionContent, wordRangeAtPos, } from './utils'; @@ -114,14 +116,42 @@ export const copyLine = ( } }; -export const selectWord = (editor: Editor, selection: EditorSelection) => { - const { from, to } = getSelectionBoundaries(selection); - const selectedText = editor.getRange(from, to); - // Do not modify selection if something is selected - if (selectedText.length !== 0) { - return selection; +export const selectWordOrNextOccurrence = (editor: Editor) => { + const allSelections = editor.listSelections(); + + // Don't search if multiple selection contents are not identical + const singleSearchText = hasSameSelectionContent(editor, allSelections); + + const firstSelection = allSelections[0]; + const { from, to } = getSelectionBoundaries(firstSelection); + const searchText = editor.getRange(from, to); + if (searchText.length > 0 && singleSearchText) { + const { from: latestMatchPos } = getSelectionBoundaries( + allSelections[allSelections.length - 1], + ); + const nextMatch = findNextMatch({ + editor, + latestMatchPos, + searchText, + searchWithinWords: false, + documentContent: editor.getValue(), + }); + const newSelections = nextMatch + ? allSelections.concat(nextMatch) + : allSelections; + editor.setSelections(newSelections); } else { - return wordRangeAtPos(from, editor.getLine(from.line)); + const newSelections = []; + for (const selection of allSelections) { + const { from, to } = getSelectionBoundaries(selection); + // Don't modify existing range selections + if (from.line !== to.line || from.ch !== to.ch) { + newSelections.push(selection); + } else { + newSelections.push(wordRangeAtPos(from, editor.getLine(from.line))); + } + } + editor.setSelections(newSelections); } }; diff --git a/src/main.ts b/src/main.ts index 02ea105..c734cf2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,7 @@ import { moveCursor, navigateLine, selectLine, - selectWord, + selectWordOrNextOccurrence, transformCase, } from './actions'; import { @@ -141,9 +141,15 @@ export default class CodeEditorShortcuts extends Plugin { }); this.addCommand({ - id: 'selectWord', - name: 'Select word', - editorCallback: (editor) => withMultipleSelections(editor, selectWord), + id: 'selectWordOrNextOccurrence', + name: 'Select word or next occurrence', + hotkeys: [ + { + modifiers: ['Mod'], + key: 'D', + }, + ], + editorCallback: (editor) => selectWordOrNextOccurrence(editor), }); this.addCommand({ diff --git a/src/utils.ts b/src/utils.ts index 57adbec..d8c5e41 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -217,3 +217,64 @@ export const findPosOfNextCharacter = ({ } : null; }; + +export const hasSameSelectionContent = ( + editor: Editor, + selections: EditorSelection[], +) => + new Set( + selections.map((selection) => { + const { from, to } = getSelectionBoundaries(selection); + return editor.getRange(from, to); + }), + ).size === 1; + +export const findNextMatch = ({ + editor, + latestMatchPos, + searchText, + searchWithinWords, + documentContent, +}: { + editor: Editor; + latestMatchPos: EditorPosition; + searchText: string; + searchWithinWords: boolean; + documentContent: string; +}) => { + const latestMatchOffset = editor.posToOffset(latestMatchPos); + const searchExpression = new RegExp( + searchWithinWords ? searchText : `\\b${searchText}\\b`, + 'g', + ); + + let nextMatch: EditorSelection | null = null; + const matches = Array.from(documentContent.matchAll(searchExpression)); + const selectionIndexes = editor.listSelections().map((selection) => { + const { from } = getSelectionBoundaries(selection); + return editor.posToOffset(from); + }); + for (const match of matches) { + if (match.index > latestMatchOffset) { + nextMatch = { + anchor: editor.offsetToPos(match.index), + head: editor.offsetToPos(match.index + searchText.length), + }; + break; + } + } + // Circle back to search from the top + if (!nextMatch) { + for (const match of matches) { + if (!selectionIndexes.includes(match.index)) { + nextMatch = { + anchor: editor.offsetToPos(match.index), + head: editor.offsetToPos(match.index + searchText.length), + }; + break; + } + } + } + + return nextMatch; +};