Skip to content

Commit

Permalink
Implement select next occurrence of current selection
Browse files Browse the repository at this point in the history
  • Loading branch information
timhor committed Apr 24, 2022
1 parent e2d2a1f commit c88b861
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 23 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/actions-cursor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
deleteToEndOfLine,
joinLines,
copyLine,
selectWord,
selectWordOrNextOccurrence,
selectLine,
goToLineBoundary,
navigateLine,
Expand Down Expand Up @@ -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);
Expand All @@ -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é');
Expand Down
26 changes: 22 additions & 4 deletions src/__tests__/actions-multi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
deleteToEndOfLine,
joinLines,
copyLine,
selectWord,
selectWordOrNextOccurrence,
selectLine,
goToLineBoundary,
navigateLine,
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
Expand All @@ -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é');
Expand Down
105 changes: 102 additions & 3 deletions src/__tests__/actions-range.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
deleteToEndOfLine,
joinLines,
copyLine,
selectWord,
selectWordOrNextOccurrence,
selectLine,
goToLineBoundary,
navigateLine,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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', () => {
Expand Down
44 changes: 37 additions & 7 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import {
} from './constants';
import {
CheckCharacter,
findNextMatch,
findPosOfNextCharacter,
getLeadingWhitespace,
getLineEndPos,
getLineStartPos,
getSelectionBoundaries,
hasSameSelectionContent,
wordRangeAtPos,
} from './utils';

Expand Down Expand Up @@ -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);
}
};

Expand Down
14 changes: 10 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
moveCursor,
navigateLine,
selectLine,
selectWord,
selectWordOrNextOccurrence,
transformCase,
} from './actions';
import {
Expand Down Expand Up @@ -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({
Expand Down
61 changes: 61 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

0 comments on commit c88b861

Please sign in to comment.