Skip to content

Commit

Permalink
Add/inter note link searching (#2286)
Browse files Browse the repository at this point in the history
* WIP: Add inter-note link auto-complete

Adds an auto-complete provider to use Monaco's built-in functionality for
searching notes when linking. This patch presents an alternative approach
from creating a custom dialog.

Due to the way that the auto-complete always returns results even if none
are wanted it may be best to look for a custom dialog.

* trigger with bracket, move to editorInit

* allow escape to clear suggestions

* remove commented styles

* tweak some options

* restore singleton enforcement

* remove from ownState

* try incomplete flag

* search title only

* exclude the current note, dispose the completion provider on unmount

* rearrange and use onWillDispose

* pass note ID in on instantiate

* add command to allow triggering suggest after dismissal

* move completionProviderHandle

* move bracket finding into a helper function

* prevent suggestions widget from popping up in the wrong context

* use getTitle for title search

* let runSearch do the filtering

* make typescript happy

* change word boundaries

Co-authored-by: Kat Hagan <[email protected]>
  • Loading branch information
dmsnell and codebykat authored Oct 31, 2020
1 parent eb6bb16 commit 1d70830
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 19 deletions.
121 changes: 117 additions & 4 deletions lib/note-content-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import Monaco, {
EditorDidMount,
EditorWillMount,
} from 'react-monaco-editor';
import { editor as Editor, Selection, SelectionDirection } from 'monaco-editor';
import { search } from './state/ui/actions';
import {
editor as Editor,
languages,
Selection,
SelectionDirection,
} from 'monaco-editor';

import { searchNotes, tagsFromSearch } from './search';
import actions from './state/actions';
import * as selectors from './state/selectors';
import { getTerms } from './utils/filter-notes';
import { noteTitleAndPreview } from './utils/note-utils';
import { isSafari } from './utils/platform';
import {
withCheckboxCharacters,
Expand Down Expand Up @@ -50,6 +56,24 @@ const getEditorPadding = (lineLength: T.LineLength, width?: number) => {
}
};

const getTextAfterBracket = (line: string, column: number) => {
const precedingOpener = line.lastIndexOf('[', column);
if (-1 === precedingOpener) {
return { soFar: null, precedingBracket: null };
}

const precedingCloser = line.lastIndexOf(']', column);
const precedingBracket =
precedingOpener >= 0 && precedingCloser < precedingOpener
? precedingOpener
: -1;

const soFar =
precedingBracket >= 0 ? line.slice(precedingBracket + 1, column) : '';

return { soFar: soFar, precedingBracket: precedingBracket };
};

type OwnProps = {
storeFocusEditor: (focusSetter: () => any) => any;
storeHasFocus: (focusGetter: () => boolean) => any;
Expand Down Expand Up @@ -168,6 +192,82 @@ class NoteContentEditor extends Component<Props> {
}
};

completionProvider: (
selectedNoteId: T.EntityId | null,
editor: Editor.IStandaloneCodeEditor
) => languages.CompletionItemProvider = (selectedNoteId, editor) => {
return {
triggerCharacters: ['['],

provideCompletionItems(model, position, context, token) {
const line = model.getLineContent(position.lineNumber);
const precedingOpener = line.lastIndexOf('[', position.column);
const precedingCloser = line.lastIndexOf(']', position.column);
const precedingBracket =
precedingOpener >= 0 && precedingCloser < precedingOpener
? precedingOpener
: -1;
const soFar =
precedingBracket >= 0
? line.slice(precedingBracket + 1, position.column)
: '';

const notes = searchNotes(
{
searchTerms: getTerms(soFar),
excludeIDs: selectedNoteId ? [selectedNoteId] : [],
openedTag: null,
showTrash: false,
searchTags: tagsFromSearch(soFar),
titleOnly: true,
},
5
).map(([noteId, note]) => ({
noteId,
content: note.content,
isPinned: note.systemTags.includes('pinned'),
...noteTitleAndPreview(note),
}));

const additionalTextEdits =
precedingBracket >= 0
? [
{
text: '',
range: {
startLineNumber: position.lineNumber,
startColumn: precedingBracket,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
},
]
: [];

return {
incomplete: true,
suggestions: notes.map((note, index) => ({
additionalTextEdits,
kind: note.isPinned
? languages.CompletionItemKind.Snippet
: languages.CompletionItemKind.File,
label: note.title,
// detail: note.preview,
documentation: note.content,
insertText: `[${note.title}](simplenote://note/${note.noteId})`,
sortText: index.toString(),
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
})),
};
},
};
};

handleShortcut = (event: KeyboardEvent) => {
const { ctrlKey, metaKey, shiftKey } = event;
const key = event.key.toLowerCase();
Expand Down Expand Up @@ -491,7 +591,6 @@ class NoteContentEditor extends Component<Props> {
'editor.action.commentLine', // meta+/
'editor.action.jumpToBracket', // shift+meta+\
'editor.action.transposeLetters', // ctrl+T
'editor.action.triggerSuggest', // ctrl+space
'expandLineSelection', // meta+L
'editor.action.gotoLine', // ctrl+G
// search shortcuts
Expand Down Expand Up @@ -577,6 +676,7 @@ class NoteContentEditor extends Component<Props> {
id: 'cancel_selection',
label: 'Cancel Selection',
keybindings: [monaco.KeyCode.Escape],
keybindingContext: '!suggestWidgetVisible',
run: this.cancelSelectionOrSearch,
});

Expand Down Expand Up @@ -661,6 +761,18 @@ class NoteContentEditor extends Component<Props> {
this.setState({});
editor.onDidChangeModelContent(() => this.setDecorators());

// register completion provider for internal links
const completionProviderHandle = monaco.languages.registerCompletionItemProvider(
'plaintext',
this.completionProvider(this.state.noteId, editor)
);
editor.onDidDispose(() => completionProviderHandle?.dispose());
monaco.languages.setLanguageConfiguration('plaintext', {
// Allow any non-whitespace character to be part of a "word"
// This prevents the dictionary suggestions from taking over our autosuggest
wordPattern: /[^\s]+/g,
});

document.oncopy = (event) => {
// @TODO: This is selecting everything in the app but we should only
// need to intercept copy events coming from the editor
Expand Down Expand Up @@ -982,6 +1094,7 @@ class NoteContentEditor extends Component<Props> {
},
scrollBeyondLastLine: false,
selectionHighlight: false,
suggestOnTriggerCharacters: true,
wordWrap: 'bounded',
wrappingStrategy: isSafari ? 'simple' : 'advanced',
wordWrapColumn: 400,
Expand Down Expand Up @@ -1037,7 +1150,7 @@ const mapStateToProps: S.MapState<StateProps> = (state) => ({
});

const mapDispatchToProps: S.MapDispatch<DispatchProps> = {
clearSearch: () => dispatch(search('')),
clearSearch: () => actions.ui.search(''),
editNote: actions.data.editNote,
insertTask: () => ({ type: 'INSERT_TASK' }),
openNote: actions.ui.selectNote,
Expand Down
45 changes: 38 additions & 7 deletions lib/search/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { filterTags } from '../tag-suggestions';
import { getTerms } from '../utils/filter-notes';
import { tagHashOf as t } from '../utils/tag-hash';
import { getTitle } from '../utils/note-utils';

import type * as A from '../state/action-types';
import type * as S from '../state';
Expand All @@ -22,6 +23,7 @@ type SearchNote = {

type SearchState = {
hasSelectedFirstNote: boolean;
excludeIDs: Array<T.EntityId> | null;
notes: Map<T.EntityId, SearchNote>;
openedTag: T.TagHash | null;
searchQuery: string;
Expand All @@ -30,6 +32,7 @@ type SearchState = {
showTrash: boolean;
sortType: T.SortType;
sortReversed: boolean;
titleOnly: boolean | null;
};

const toSearchNote = (note: Partial<T.Note>): SearchNote => ({
Expand All @@ -42,7 +45,7 @@ const toSearchNote = (note: Partial<T.Note>): SearchNote => ({
isTrashed: !!note.deleted ?? false,
});

const tagsFromSearch = (query: string) => {
export const tagsFromSearch = (query: string) => {
const tagPattern = /(?:\btag:)([^\s,]+)/g;
const searchTags = new Set<T.TagHash>();
let match;
Expand All @@ -52,8 +55,14 @@ const tagsFromSearch = (query: string) => {
return searchTags;
};

export let searchNotes: (
args: Partial<SearchState>,
maxResults: number
) => [T.EntityId, T.Note | undefined][] = () => [];

export const middleware: S.Middleware = (store) => {
const searchState: SearchState = {
excludeIDs: [],
hasSelectedFirstNote: false,
notes: new Map(),
openedTag: null,
Expand All @@ -63,6 +72,7 @@ export const middleware: S.Middleware = (store) => {
showTrash: false,
sortType: store.getState().settings.sortType,
sortReversed: store.getState().settings.sortReversed,
titleOnly: false,
};

const indexAlphabetical: T.EntityId[] = [];
Expand Down Expand Up @@ -163,18 +173,24 @@ export const middleware: S.Middleware = (store) => {
window.searchState = searchState;
}

const runSearch = (): T.EntityId[] => {
const runSearch = (
args: Partial<SearchState> = {},
maxResults = Infinity
): T.EntityId[] => {
const {
excludeIDs,
notes,
openedTag,
searchTags,
searchTerms,
sortReversed,
sortType,
showTrash,
} = searchState;
titleOnly,
} = { ...searchState, ...args };
const matches = new Set<T.EntityId>();
const pinnedMatches = new Set<T.EntityId>();
const storeNotes = store.getState().data.notes;

const sortIndex =
sortType === 'alphabetical'
Expand All @@ -183,11 +199,18 @@ export const middleware: S.Middleware = (store) => {
? indexCreationDate
: indexModification;

for (let i = 0; i < sortIndex.length; i++) {
for (
let i = 0;
i < sortIndex.length && pinnedMatches.size + matches.size <= maxResults;
i++
) {
const noteId = sortIndex[sortReversed ? sortIndex.length - i - 1 : i];
const note = notes.get(noteId);
if (excludeIDs?.includes(noteId)) {
continue;
}

if (!note) {
const note = notes.get(noteId);
if (!note || !storeNotes.has(noteId)) {
continue;
}

Expand All @@ -210,10 +233,12 @@ export const middleware: S.Middleware = (store) => {
continue;
}

const searchText = titleOnly ? getTitle(note.content) : note.content;

if (
searchTerms.length > 0 &&
!searchTerms.every((term) =>
note.content.includes(term.toLocaleLowerCase())
searchText.includes(term.toLocaleLowerCase())
)
) {
continue;
Expand All @@ -229,6 +254,12 @@ export const middleware: S.Middleware = (store) => {
return [...pinnedMatches.values(), ...matches.values()];
};

searchNotes = (args, maxResults) =>
runSearch(args, maxResults).map((noteId) => [
noteId,
store.getState().data.notes.get(noteId),
]);

const setFilteredNotes = (
noteIds: T.EntityId[]
): { noteIds: T.EntityId[]; tagHashes: T.TagHash[] } => {
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/note-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const removeMarkdownWithFix = (inputString) => {
});
};

const getTitle = (content) => {
export const getTitle = (content) => {
const titlePattern = new RegExp(`\\s*([^\n]{1,${maxTitleChars}})`, 'g');
const titleMatch = titlePattern.exec(content);
if (!titleMatch) {
Expand Down
7 changes: 0 additions & 7 deletions scss/theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,6 @@ span[dir='ltr'] {
display: none !important;
}

/* Hide the suggestion popup
See: https://github.com/microsoft/monaco-editor/issues/1681#issuecomment-580751164
*/
.monaco-editor .suggest-widget {
display: none !important;
}

/* Safari requires that it be displayed absolute so that it takes the full height
*/
.note-content-editor-shell {
Expand Down

0 comments on commit 1d70830

Please sign in to comment.