diff --git a/lib/note-content-editor.tsx b/lib/note-content-editor.tsx index bf63f4fce..f48761cb4 100644 --- a/lib/note-content-editor.tsx +++ b/lib/note-content-editor.tsx @@ -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, @@ -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; @@ -168,6 +192,82 @@ class NoteContentEditor extends Component { } }; + 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(); @@ -491,7 +591,6 @@ class NoteContentEditor extends Component { '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 @@ -577,6 +676,7 @@ class NoteContentEditor extends Component { id: 'cancel_selection', label: 'Cancel Selection', keybindings: [monaco.KeyCode.Escape], + keybindingContext: '!suggestWidgetVisible', run: this.cancelSelectionOrSearch, }); @@ -661,6 +761,18 @@ class NoteContentEditor extends Component { 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 @@ -982,6 +1094,7 @@ class NoteContentEditor extends Component { }, scrollBeyondLastLine: false, selectionHighlight: false, + suggestOnTriggerCharacters: true, wordWrap: 'bounded', wrappingStrategy: isSafari ? 'simple' : 'advanced', wordWrapColumn: 400, @@ -1037,7 +1150,7 @@ const mapStateToProps: S.MapState = (state) => ({ }); const mapDispatchToProps: S.MapDispatch = { - clearSearch: () => dispatch(search('')), + clearSearch: () => actions.ui.search(''), editNote: actions.data.editNote, insertTask: () => ({ type: 'INSERT_TASK' }), openNote: actions.ui.selectNote, diff --git a/lib/search/index.ts b/lib/search/index.ts index d8d2b4937..d1aa332b5 100644 --- a/lib/search/index.ts +++ b/lib/search/index.ts @@ -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'; @@ -22,6 +23,7 @@ type SearchNote = { type SearchState = { hasSelectedFirstNote: boolean; + excludeIDs: Array | null; notes: Map; openedTag: T.TagHash | null; searchQuery: string; @@ -30,6 +32,7 @@ type SearchState = { showTrash: boolean; sortType: T.SortType; sortReversed: boolean; + titleOnly: boolean | null; }; const toSearchNote = (note: Partial): SearchNote => ({ @@ -42,7 +45,7 @@ const toSearchNote = (note: Partial): SearchNote => ({ isTrashed: !!note.deleted ?? false, }); -const tagsFromSearch = (query: string) => { +export const tagsFromSearch = (query: string) => { const tagPattern = /(?:\btag:)([^\s,]+)/g; const searchTags = new Set(); let match; @@ -52,8 +55,14 @@ const tagsFromSearch = (query: string) => { return searchTags; }; +export let searchNotes: ( + args: Partial, + maxResults: number +) => [T.EntityId, T.Note | undefined][] = () => []; + export const middleware: S.Middleware = (store) => { const searchState: SearchState = { + excludeIDs: [], hasSelectedFirstNote: false, notes: new Map(), openedTag: null, @@ -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[] = []; @@ -163,8 +173,12 @@ export const middleware: S.Middleware = (store) => { window.searchState = searchState; } - const runSearch = (): T.EntityId[] => { + const runSearch = ( + args: Partial = {}, + maxResults = Infinity + ): T.EntityId[] => { const { + excludeIDs, notes, openedTag, searchTags, @@ -172,9 +186,11 @@ export const middleware: S.Middleware = (store) => { sortReversed, sortType, showTrash, - } = searchState; + titleOnly, + } = { ...searchState, ...args }; const matches = new Set(); const pinnedMatches = new Set(); + const storeNotes = store.getState().data.notes; const sortIndex = sortType === 'alphabetical' @@ -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; } @@ -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; @@ -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[] } => { diff --git a/lib/utils/note-utils.ts b/lib/utils/note-utils.ts index 19ab5b9ae..a6f3544c1 100644 --- a/lib/utils/note-utils.ts +++ b/lib/utils/note-utils.ts @@ -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) { diff --git a/scss/theme.scss b/scss/theme.scss index 41210b6f1..72cfb25cb 100644 --- a/scss/theme.scss +++ b/scss/theme.scss @@ -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 {