2019-04-01 21:43:13 +02:00
const React = require ( 'react' ) ;
const { connect } = require ( 'react-redux' ) ;
const { _ } = require ( 'lib/locale.js' ) ;
const { themeStyle } = require ( '../theme.js' ) ;
const SearchEngine = require ( 'lib/services/SearchEngine' ) ;
const BaseModel = require ( 'lib/BaseModel' ) ;
const Tag = require ( 'lib/models/Tag' ) ;
2019-07-30 09:35:42 +02:00
const Folder = require ( 'lib/models/Folder' ) ;
2020-03-28 15:05:00 +02:00
const Note = require ( 'lib/models/Note' ) ;
2019-04-01 21:43:13 +02:00
const { ItemList } = require ( '../gui/ItemList.min' ) ;
2019-05-06 22:35:29 +02:00
const HelpButton = require ( '../gui/HelpButton.min' ) ;
2020-03-28 15:05:00 +02:00
const { surroundKeywords , nextWhitespaceIndex } = require ( 'lib/string-utils.js' ) ;
const { mergeOverlappingIntervals } = require ( 'lib/ArrayUtils.js' ) ;
2019-04-01 21:43:13 +02:00
const PLUGIN _NAME = 'gotoAnything' ;
const itemHeight = 60 ;
class GotoAnything {
2019-09-13 00:16:42 +02:00
onTrigger ( ) {
2019-04-01 21:43:13 +02:00
this . dispatch ( {
type : 'PLUGIN_DIALOG_SET' ,
open : true ,
pluginName : PLUGIN _NAME ,
} ) ;
}
}
class Dialog extends React . PureComponent {
constructor ( ) {
super ( ) ;
this . state = {
query : '' ,
results : [ ] ,
selectedItemId : null ,
keywords : [ ] ,
listType : BaseModel . TYPE _NOTE ,
showHelp : false ,
} ;
this . styles _ = { } ;
this . inputRef = React . createRef ( ) ;
this . itemListRef = React . createRef ( ) ;
this . onKeyDown = this . onKeyDown . bind ( this ) ;
this . input _onChange = this . input _onChange . bind ( this ) ;
this . input _onKeyDown = this . input _onKeyDown . bind ( this ) ;
2020-04-05 09:55:00 +02:00
this . modalLayer _onClick = this . modalLayer _onClick . bind ( this ) ;
2019-04-01 21:43:13 +02:00
this . listItemRenderer = this . listItemRenderer . bind ( this ) ;
this . listItem _onClick = this . listItem _onClick . bind ( this ) ;
this . helpButton _onClick = this . helpButton _onClick . bind ( this ) ;
}
style ( ) {
if ( this . styles _ [ this . props . theme ] ) return this . styles _ [ this . props . theme ] ;
const theme = themeStyle ( this . props . theme ) ;
this . styles _ [ this . props . theme ] = {
dialogBox : Object . assign ( { } , theme . dialogBox , { minWidth : '50%' , maxWidth : '50%' } ) ,
input : Object . assign ( { } , theme . inputStyle , { flex : 1 } ) ,
2020-02-05 00:09:34 +02:00
row : { overflow : 'hidden' , height : itemHeight , display : 'flex' , justifyContent : 'center' , flexDirection : 'column' , paddingLeft : 10 , paddingRight : 10 } ,
2019-04-01 21:43:13 +02:00
help : Object . assign ( { } , theme . textStyle , { marginBottom : 10 } ) ,
2020-02-05 00:09:34 +02:00
inputHelpWrapper : { display : 'flex' , flexDirection : 'row' , alignItems : 'center' } ,
2019-04-01 21:43:13 +02:00
} ;
const rowTextStyle = {
fontSize : theme . fontSize ,
color : theme . color ,
fontFamily : theme . fontFamily ,
whiteSpace : 'nowrap' ,
opacity : 0.7 ,
userSelect : 'none' ,
} ;
const rowTitleStyle = Object . assign ( { } , rowTextStyle , {
fontSize : rowTextStyle . fontSize * 1.4 ,
2020-03-28 15:05:00 +02:00
marginBottom : 4 ,
color : theme . colorFaded ,
} ) ;
const rowFragmentsStyle = Object . assign ( { } , rowTextStyle , {
fontSize : rowTextStyle . fontSize * 1.2 ,
marginBottom : 4 ,
2019-04-01 21:43:13 +02:00
color : theme . colorFaded ,
} ) ;
this . styles _ [ this . props . theme ] . rowSelected = Object . assign ( { } , this . styles _ [ this . props . theme ] . row , { backgroundColor : theme . selectedColor } ) ;
this . styles _ [ this . props . theme ] . rowPath = rowTextStyle ;
this . styles _ [ this . props . theme ] . rowTitle = rowTitleStyle ;
2020-03-28 15:05:00 +02:00
this . styles _ [ this . props . theme ] . rowFragments = rowFragmentsStyle ;
2019-04-01 21:43:13 +02:00
return this . styles _ [ this . props . theme ] ;
}
componentDidMount ( ) {
document . addEventListener ( 'keydown' , this . onKeyDown ) ;
}
componentWillUnmount ( ) {
if ( this . listUpdateIID _ ) clearTimeout ( this . listUpdateIID _ ) ;
document . removeEventListener ( 'keydown' , this . onKeyDown ) ;
}
onKeyDown ( event ) {
if ( event . keyCode === 27 ) { // ESCAPE
this . props . dispatch ( {
pluginName : PLUGIN _NAME ,
type : 'PLUGIN_DIALOG_SET' ,
open : false ,
} ) ;
}
}
2020-03-30 19:33:36 +02:00
modalLayer _onClick ( ) {
this . props . dispatch ( {
pluginName : PLUGIN _NAME ,
type : 'PLUGIN_DIALOG_SET' ,
open : false ,
} ) ;
}
2019-09-13 00:16:42 +02:00
helpButton _onClick ( ) {
2019-04-01 21:43:13 +02:00
this . setState ( { showHelp : ! this . state . showHelp } ) ;
}
input _onChange ( event ) {
this . setState ( { query : event . target . value } ) ;
this . scheduleListUpdate ( ) ;
}
scheduleListUpdate ( ) {
if ( this . listUpdateIID _ ) return ;
this . listUpdateIID _ = setTimeout ( async ( ) => {
await this . updateList ( ) ;
this . listUpdateIID _ = null ;
} , 10 ) ;
}
2020-03-28 15:05:00 +02:00
makeSearchQuery ( query , field ) {
2019-04-01 21:43:13 +02:00
const output = [ ] ;
2020-03-28 15:05:00 +02:00
const splitted = ( field === 'title' )
? query . split ( ' ' )
: query . substr ( 1 ) . trim ( ) . split ( ' ' ) ; // body
2019-04-01 21:43:13 +02:00
for ( let i = 0 ; i < splitted . length ; i ++ ) {
const s = splitted [ i ] . trim ( ) ;
if ( ! s ) continue ;
2020-03-28 15:05:00 +02:00
output . push ( field === 'title' ? ` title: ${ s } * ` : ` body: ${ s } * ` ) ;
2019-04-01 21:43:13 +02:00
}
return output . join ( ' ' ) ;
}
keywords ( searchQuery ) {
const parsedQuery = SearchEngine . instance ( ) . parseQuery ( searchQuery ) ;
return SearchEngine . instance ( ) . allParsedQueryTerms ( parsedQuery ) ;
}
async updateList ( ) {
if ( ! this . state . query ) {
this . setState ( { results : [ ] , keywords : [ ] } ) ;
} else {
let results = [ ] ;
let listType = null ;
let searchQuery = '' ;
if ( this . state . query . indexOf ( '#' ) === 0 ) { // TAGS
listType = BaseModel . TYPE _TAG ;
2019-09-19 23:51:18 +02:00
searchQuery = ` * ${ this . state . query . split ( ' ' ) [ 0 ] . substr ( 1 ) . trim ( ) } * ` ;
2019-04-01 21:43:13 +02:00
results = await Tag . searchAllWithNotes ( { titlePattern : searchQuery } ) ;
} else if ( this . state . query . indexOf ( '@' ) === 0 ) { // FOLDERS
listType = BaseModel . TYPE _FOLDER ;
2019-09-19 23:51:18 +02:00
searchQuery = ` * ${ this . state . query . split ( ' ' ) [ 0 ] . substr ( 1 ) . trim ( ) } * ` ;
2019-04-01 21:43:13 +02:00
results = await Folder . search ( { titlePattern : searchQuery } ) ;
for ( let i = 0 ; i < results . length ; i ++ ) {
const row = results [ i ] ;
const path = Folder . folderPathString ( this . props . folders , row . parent _id ) ;
results [ i ] = Object . assign ( { } , row , { path : path ? path : '/' } ) ;
}
2020-03-28 15:05:00 +02:00
} else if ( this . state . query . indexOf ( '/' ) === 0 ) { // BODY
listType = BaseModel . TYPE _NOTE ;
searchQuery = this . makeSearchQuery ( this . state . query , 'body' ) ;
results = await SearchEngine . instance ( ) . search ( searchQuery ) ;
const limit = 20 ;
const searchKeywords = this . keywords ( searchQuery ) ;
const notes = await Note . byIds ( results . map ( result => result . id ) . slice ( 0 , limit ) , { fields : [ 'id' , 'body' ] } ) ;
const notesById = notes . reduce ( ( obj , { id , body } ) => ( ( obj [ [ id ] ] = body ) , obj ) , { } ) ;
for ( let i = 0 ; i < results . length ; i ++ ) {
const row = results [ i ] ;
let fragments = '...' ;
if ( i < limit ) { // Display note fragments of search keyword matches
const indices = [ ] ;
const body = notesById [ row . id ] ;
// Iterate over all matches in the body for each search keyword
for ( const { valueRegex } of searchKeywords ) {
for ( const match of body . matchAll ( new RegExp ( valueRegex , 'ig' ) ) ) {
// Populate 'indices' with [begin index, end index] of each note fragment
// Begins at the regex matching index, ends at the next whitespace after seeking 15 characters to the right
indices . push ( [ match . index , nextWhitespaceIndex ( body , match . index + match [ 0 ] . length + 15 ) ] ) ;
if ( indices . length > 20 ) break ;
}
}
// Merge multiple overlapping fragments into a single fragment to prevent repeated content
// e.g. 'Joplin is a free, open source' and 'open source note taking application'
// will result in 'Joplin is a free, open source note taking application'
const mergedIndices = mergeOverlappingIntervals ( indices , 3 ) ;
fragments = mergedIndices . map ( f => body . slice ( f [ 0 ] , f [ 1 ] ) ) . join ( ' ... ' ) ;
// Add trailing ellipsis if the final fragment doesn't end where the note is ending
if ( mergedIndices [ mergedIndices . length - 1 ] [ 1 ] !== body . length ) fragments += ' ...' ;
}
const path = Folder . folderPathString ( this . props . folders , row . parent _id ) ;
results [ i ] = Object . assign ( { } , row , { path , fragments } ) ;
}
} else { // TITLE
2019-04-01 21:43:13 +02:00
listType = BaseModel . TYPE _NOTE ;
2020-03-28 15:05:00 +02:00
searchQuery = this . makeSearchQuery ( this . state . query , 'title' ) ;
2019-04-01 21:43:13 +02:00
results = await SearchEngine . instance ( ) . search ( searchQuery ) ;
for ( let i = 0 ; i < results . length ; i ++ ) {
const row = results [ i ] ;
const path = Folder . folderPathString ( this . props . folders , row . parent _id ) ;
results [ i ] = Object . assign ( { } , row , { path : path } ) ;
}
}
let selectedItemId = null ;
const itemIndex = this . selectedItemIndex ( results , this . state . selectedItemId ) ;
if ( itemIndex > 0 ) {
selectedItemId = this . state . selectedItemId ;
} else if ( results . length > 0 ) {
selectedItemId = results [ 0 ] . id ;
}
this . setState ( {
listType : listType ,
results : results ,
keywords : this . keywords ( searchQuery ) ,
selectedItemId : selectedItemId ,
} ) ;
}
}
2019-05-14 01:11:27 +02:00
async gotoItem ( item ) {
2019-04-01 21:43:13 +02:00
this . props . dispatch ( {
pluginName : PLUGIN _NAME ,
type : 'PLUGIN_DIALOG_SET' ,
open : false ,
} ) ;
2019-05-14 01:11:27 +02:00
if ( this . state . listType === BaseModel . TYPE _NOTE || this . state . listType === BaseModel . TYPE _FOLDER ) {
const folderPath = await Folder . folderPath ( this . props . folders , item . parent _id ) ;
for ( const folder of folderPath ) {
this . props . dispatch ( {
2019-07-30 09:35:42 +02:00
type : 'FOLDER_SET_COLLAPSED' ,
2019-05-14 01:11:27 +02:00
id : folder . id ,
collapsed : false ,
} ) ;
}
2019-07-30 09:35:42 +02:00
}
2019-05-14 01:11:27 +02:00
2019-04-01 21:43:13 +02:00
if ( this . state . listType === BaseModel . TYPE _NOTE ) {
this . props . dispatch ( {
2019-07-30 09:35:42 +02:00
type : 'FOLDER_AND_NOTE_SELECT' ,
2019-04-01 21:43:13 +02:00
folderId : item . parent _id ,
noteId : item . id ,
2020-03-15 11:40:57 +02:00
historyAction : 'goto' ,
2019-04-01 21:43:13 +02:00
} ) ;
} else if ( this . state . listType === BaseModel . TYPE _TAG ) {
this . props . dispatch ( {
2019-07-30 09:35:42 +02:00
type : 'TAG_SELECT' ,
2019-04-01 21:43:13 +02:00
id : item . id ,
} ) ;
} else if ( this . state . listType === BaseModel . TYPE _FOLDER ) {
this . props . dispatch ( {
2019-07-30 09:35:42 +02:00
type : 'FOLDER_SELECT' ,
2019-04-01 21:43:13 +02:00
id : item . id ,
2020-03-15 11:40:57 +02:00
historyAction : 'goto' ,
2019-04-01 21:43:13 +02:00
} ) ;
}
}
listItem _onClick ( event ) {
const itemId = event . currentTarget . getAttribute ( 'data-id' ) ;
const parentId = event . currentTarget . getAttribute ( 'data-parent-id' ) ;
this . gotoItem ( {
id : itemId ,
parent _id : parentId ,
} ) ;
}
listItemRenderer ( item ) {
const theme = themeStyle ( this . props . theme ) ;
const style = this . style ( ) ;
const rowStyle = item . id === this . state . selectedItemId ? style . rowSelected : style . row ;
2020-03-28 15:05:00 +02:00
const titleHtml = item . fragments
? ` <span style="font-weight: bold; color: ${ theme . colorBright } ;"> ${ item . title } </span> `
: surroundKeywords ( this . state . keywords , item . title , ` <span style="font-weight: bold; color: ${ theme . colorBright } ;"> ` , '</span>' ) ;
2019-04-01 21:43:13 +02:00
2020-03-28 15:05:00 +02:00
const fragmentsHtml = ! item . fragments ? null : surroundKeywords ( this . state . keywords , item . fragments , ` <span style="font-weight: bold; color: ${ theme . colorBright } ;"> ` , '</span>' ) ;
2019-07-30 09:35:42 +02:00
const pathComp = ! item . path ? null : < div style = { style . rowPath } > { item . path } < / div > ;
2019-04-01 21:43:13 +02:00
return (
< div key = { item . id } style = { rowStyle } onClick = { this . listItem _onClick } data - id = { item . id } data - parent - id = { item . parent _id } >
2020-02-05 00:09:34 +02:00
< div style = { style . rowTitle } dangerouslySetInnerHTML = { { _ _html : titleHtml } } > < / div >
2020-03-28 15:05:00 +02:00
< div style = { style . rowFragments } dangerouslySetInnerHTML = { { _ _html : fragmentsHtml } } > < / div >
2019-04-01 21:43:13 +02:00
{ pathComp }
< / div >
) ;
}
selectedItemIndex ( results , itemId ) {
if ( typeof results === 'undefined' ) results = this . state . results ;
if ( typeof itemId === 'undefined' ) itemId = this . state . selectedItemId ;
for ( let i = 0 ; i < results . length ; i ++ ) {
const r = results [ i ] ;
if ( r . id === itemId ) return i ;
}
return - 1 ;
}
selectedItem ( ) {
const index = this . selectedItemIndex ( ) ;
if ( index < 0 ) return null ;
return this . state . results [ index ] ;
}
input _onKeyDown ( event ) {
const keyCode = event . keyCode ;
if ( this . state . results . length > 0 && ( keyCode === 40 || keyCode === 38 ) ) { // DOWN / UP
event . preventDefault ( ) ;
const inc = keyCode === 38 ? - 1 : + 1 ;
let index = this . selectedItemIndex ( ) ;
if ( index < 0 ) return ; // Not possible, but who knows
2019-07-30 09:35:42 +02:00
2019-04-01 21:43:13 +02:00
index += inc ;
if ( index < 0 ) index = 0 ;
if ( index >= this . state . results . length ) index = this . state . results . length - 1 ;
const newId = this . state . results [ index ] . id ;
this . itemListRef . current . makeItemIndexVisible ( index ) ;
this . setState ( { selectedItemId : newId } ) ;
}
if ( keyCode === 13 ) { // ENTER
event . preventDefault ( ) ;
const item = this . selectedItem ( ) ;
if ( ! item ) return ;
this . gotoItem ( item ) ;
}
}
renderList ( ) {
const style = {
marginTop : 5 ,
height : Math . min ( itemHeight * this . state . results . length , 7 * itemHeight ) ,
} ;
return (
< ItemList
ref = { this . itemListRef }
itemHeight = { itemHeight }
items = { this . state . results }
style = { style }
itemRenderer = { this . listItemRenderer }
/ >
) ;
}
render ( ) {
const theme = themeStyle ( this . props . theme ) ;
const style = this . style ( ) ;
2020-03-28 15:05:00 +02:00
const helpComp = ! this . state . showHelp ? null : < div style = { style . help } > { _ ( 'Type a note title to jump to it. Or type # followed by a tag name, or @ followed by a notebook name, or / followed by note content.' ) } < / div > ;
2019-04-01 21:43:13 +02:00
return (
2020-03-30 19:33:36 +02:00
< div onClick = { this . modalLayer _onClick } style = { theme . dialogModalLayer } >
2019-04-01 21:43:13 +02:00
< div style = { style . dialogBox } >
{ helpComp }
< div style = { style . inputHelpWrapper } >
< input autoFocus type = "text" style = { style . input } ref = { this . inputRef } value = { this . state . query } onChange = { this . input _onChange } onKeyDown = { this . input _onKeyDown } / >
2019-05-06 22:35:29 +02:00
< HelpButton onClick = { this . helpButton _onClick } / >
2019-04-01 21:43:13 +02:00
< / div >
{ this . renderList ( ) }
< / div >
< / div >
) ;
}
}
const mapStateToProps = ( state ) => {
return {
folders : state . folders ,
theme : state . settings . theme ,
} ;
} ;
GotoAnything . Dialog = connect ( mapStateToProps ) ( Dialog ) ;
GotoAnything . manifest = {
name : PLUGIN _NAME ,
menuItems : [
{
name : 'main' ,
parent : 'tools' ,
label : _ ( 'Goto Anything...' ) ,
2019-04-20 13:02:43 +02:00
accelerator : 'CommandOrControl+G' ,
2019-04-01 21:43:13 +02:00
screens : [ 'Main' ] ,
} ,
] ,
} ;
2019-07-30 09:35:42 +02:00
module . exports = GotoAnything ;