From 6b2910c3c706f8a4cd83da07eba58ece88e426b3 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Mon, 1 Apr 2019 19:43:13 +0000 Subject: [PATCH] Desktop: Added Goto Anything dialog and implemented basic plugin system --- CliClient/tests/services_SearchEngine.js | 28 +- ElectronClient/app/app.js | 50 ++- ElectronClient/app/compile.js | 48 ++- ElectronClient/app/gui/MainScreen.jsx | 7 + .../app/gui/NotePropertiesDialog.jsx | 46 +-- ElectronClient/app/gui/NoteText.jsx | 2 +- ElectronClient/app/plugins/GotoAnything.jsx | 358 +++++++++++++++++ .../app/plugins/GotoAnything.min.js | 376 ++++++++++++++++++ ElectronClient/app/theme.js | 28 ++ ReactNativeClient/lib/models/Folder.js | 30 +- ReactNativeClient/lib/models/Tag.js | 14 +- ReactNativeClient/lib/reducer.js | 12 + .../lib/services/PluginManager.js | 105 +++++ .../lib/services/SearchEngine.js | 4 +- 14 files changed, 1018 insertions(+), 90 deletions(-) create mode 100644 ElectronClient/app/plugins/GotoAnything.jsx create mode 100644 ElectronClient/app/plugins/GotoAnything.min.js create mode 100644 ReactNativeClient/lib/services/PluginManager.js diff --git a/CliClient/tests/services_SearchEngine.js b/CliClient/tests/services_SearchEngine.js index ab9afaa78..7e4c53168 100644 --- a/CliClient/tests/services_SearchEngine.js +++ b/CliClient/tests/services_SearchEngine.js @@ -265,6 +265,7 @@ describe('services_SearchEngine', function() { ['title:abcd efgh', { _: ['efgh'], title: ['abcd'] }], ['title:abcd', { title: ['abcd'] }], ['"abcd efgh"', { _: ['abcd efgh'] }], + ['title:abcd title:efgh', { title: ['abcd', 'efgh'] }], ]; for (let i = 0; i < testCases.length; i++) { @@ -283,33 +284,6 @@ describe('services_SearchEngine', function() { } })); - // it('should parse query strings with wildcards', asyncTest(async () => { - // let rows; - - // const testCases = [ - // ['do*', ['do', 'dog', 'domino'], [] ], - // // "*" is a wildcard only when used at the end (to search for documents with the specified prefix) - // // If it's at the beginning, it's ignored, if it's in the middle, it's treated as a litteral "*". - // ['*an*', ['an', 'anneau'], ['piano', 'plan'] ], - // ['no*no', ['no*no'], ['nonono'] ], - // ]; - - // for (let i = 0; i < testCases.length; i++) { - // const t = testCases[i]; - // const input = t[0]; - // const shouldMatch = t[1]; - // const shouldNotMatch = t[2]; - // const regex = new RegExp(engine.parseQuery(input).terms._[0].value, 'gmi'); - - // for (let j = 0; j < shouldMatch.length; j++) { - // const r = shouldMatch[j].match(regex); - // expect(!!r).toBe(true, '"' + input + '" should match "' + shouldMatch[j] + '"'); - // } - // } - - // expect(engine.parseQuery('*').termCount).toBe(0); - // })); - it('should handle queries with special characters', asyncTest(async () => { let rows; diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index 0deb02074..87493705a 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -28,6 +28,11 @@ const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); const { bridge } = require('electron').remote.require('./bridge'); const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; +const PluginManager = require('lib/services/PluginManager'); + +const pluginClasses = [ + require('./plugins/GotoAnything.min'), +]; const appDefaultState = Object.assign({}, defaultState, { route: { @@ -441,7 +446,7 @@ class Application extends BaseApplication { const printItem = { label: _('Print'), - accelerator: 'CommandOrControl+P', + // accelerator: 'CommandOrControl+P', screens: ['Main'], click: () => { this.dispatch({ @@ -502,8 +507,8 @@ class Application extends BaseApplication { }); } - const template = [ - { + const rootMenus = { + macOsApp: { /* Using a dummy entry for macOS here, because first menu * becomes 'Joplin' and we need a nenu called 'File' later. */ label: shim.isMac() ? '&JoplinMainMenu' : _('&File'), @@ -572,7 +577,8 @@ class Application extends BaseApplication { accelerator: 'CommandOrControl+Q', click: () => { bridge().electronApp().quit() } }] - }, { + }, + file: { label: _('&File'), visible: shim.isMac() ? true : false, submenu: [ @@ -591,7 +597,8 @@ class Application extends BaseApplication { }, printItem ] - }, { + }, + edit: { label: _('&Edit'), submenu: [{ label: _('Copy'), @@ -692,7 +699,8 @@ class Application extends BaseApplication { }); }, }], - }, { + }, + view: { label: _('&View'), submenu: [{ label: _('Toggle sidebar'), @@ -749,11 +757,12 @@ class Application extends BaseApplication { screens: ['Main'], submenu: focusItems, }], - }, { + }, + tools: { label: _('&Tools'), - visible: shim.isMac() ? false : true, - submenu: toolsItems - }, { + submenu: shim.isMac() ? [] : toolsItems, + }, + help: { label: _('&Help'), submenu: [{ label: _('Website and documentation'), @@ -775,7 +784,22 @@ class Application extends BaseApplication { visible: shim.isMac() ? false : true, click: () => _showAbout() }] - }, + }, + }; + + const pluginMenuItems = PluginManager.instance().menuItems(); + for (const item of pluginMenuItems) { + let itemParent = rootMenus[item.parent] ? rootMenus[item.parent] : 'tools'; + itemParent.submenu.push(item); + } + + const template = [ + rootMenus.macOsApp, + rootMenus.file, + rootMenus.edit, + rootMenus.view, + rootMenus.tools, + rootMenus.help, ]; function isEmptyMenu(template) { @@ -891,6 +915,10 @@ class Application extends BaseApplication { bridge().window().webContents.openDevTools(); } + PluginManager.instance().dispatch_ = this.dispatch.bind(this); + PluginManager.instance().setLogger(reg.logger()); + PluginManager.instance().register(pluginClasses); + this.updateMenu('Main'); this.initRedux(); diff --git a/ElectronClient/app/compile.js b/ElectronClient/app/compile.js index 4c3253a52..aaff1a5ee 100644 --- a/ElectronClient/app/compile.js +++ b/ElectronClient/app/compile.js @@ -3,7 +3,6 @@ const spawnSync = require('child_process').spawnSync; const babelPath = __dirname + '/node_modules/.bin/babel' + (process.platform === 'win32' ? '.cmd' : ''); const basePath = __dirname + '/../..'; -const guiPath = __dirname + '/gui'; function fileIsNewerThan(path1, path2) { if (!fs.existsSync(path2)) return true; @@ -14,31 +13,36 @@ function fileIsNewerThan(path1, path2) { return stat1.mtime > stat2.mtime; } -fs.readdirSync(guiPath).forEach((filename) => { - const jsxPath = guiPath + '/' + filename; - const p = jsxPath.split('.'); - if (p.length <= 1) return; - const ext = p[p.length - 1]; - if (ext !== 'jsx') return; - p.pop(); +function convertJsx(path) { + fs.readdirSync(path).forEach((filename) => { + const jsxPath = path + '/' + filename; + const p = jsxPath.split('.'); + if (p.length <= 1) return; + const ext = p[p.length - 1]; + if (ext !== 'jsx') return; + p.pop(); - const basePath = p.join('.'); + const basePath = p.join('.'); - const jsPath = basePath + '.min.js'; + const jsPath = basePath + '.min.js'; - if (fileIsNewerThan(jsxPath, jsPath)) { - console.info('Compiling ' + jsxPath + '...'); - const result = spawnSync(babelPath, ['--presets', 'react', '--out-file', jsPath, jsxPath]); - if (result.status !== 0) { - const msg = []; - if (result.stdout) msg.push(result.stdout.toString()); - if (result.stderr) msg.push(result.stderr.toString()); - console.error(msg.join('\n')); - if (result.error) console.error(result.error); - process.exit(result.status); + if (fileIsNewerThan(jsxPath, jsPath)) { + console.info('Compiling ' + jsxPath + '...'); + const result = spawnSync(babelPath, ['--presets', 'react', '--out-file', jsPath, jsxPath]); + if (result.status !== 0) { + const msg = []; + if (result.stdout) msg.push(result.stdout.toString()); + if (result.stderr) msg.push(result.stderr.toString()); + console.error(msg.join('\n')); + if (result.error) console.error(result.error); + process.exit(result.status); + } } - } -}); + }); +} + +convertJsx(__dirname + '/gui'); +convertJsx(__dirname + '/plugins'); const libContent = [ fs.readFileSync(basePath + '/ReactNativeClient/lib/string-utils-common.js', 'utf8'), diff --git a/ElectronClient/app/gui/MainScreen.jsx b/ElectronClient/app/gui/MainScreen.jsx index 767b1764b..215721bf3 100644 --- a/ElectronClient/app/gui/MainScreen.jsx +++ b/ElectronClient/app/gui/MainScreen.jsx @@ -18,6 +18,7 @@ const layoutUtils = require('lib/layout-utils.js'); const { bridge } = require('electron').remote.require('./bridge'); const eventManager = require('../eventManager'); const VerticalResizer = require('./VerticalResizer.min'); +const PluginManager = require('lib/services/PluginManager'); class MainScreenComponent extends React.Component { @@ -458,6 +459,9 @@ class MainScreenComponent extends React.Component { ); } + const dialogInfo = PluginManager.instance().pluginDialogToShow(this.props.plugins); + const pluginDialog = !dialogInfo ? null : ; + const modalLayerStyle = Object.assign({}, styles.modalLayer, { display: this.state.modalLayer.visible ? 'block' : 'none' }); const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions; @@ -491,6 +495,8 @@ class MainScreenComponent extends React.Component { + + {pluginDialog} ); } @@ -512,6 +518,7 @@ const mapStateToProps = (state) => { sidebarWidth: state.settings['style.sidebar.width'], noteListWidth: state.settings['style.noteList.width'], selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, + plugins: state.plugins, }; }; diff --git a/ElectronClient/app/gui/NotePropertiesDialog.jsx b/ElectronClient/app/gui/NotePropertiesDialog.jsx index bfd9412ea..5c5c728c9 100644 --- a/ElectronClient/app/gui/NotePropertiesDialog.jsx +++ b/ElectronClient/app/gui/NotePropertiesDialog.jsx @@ -102,25 +102,25 @@ class NotePropertiesDialog extends React.Component { this.styles_ = {}; this.styleKey_ = styleKey; - this.styles_.modalLayer = { - zIndex: 9999, - display: 'flex', - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - backgroundColor: 'rgba(0,0,0,0.6)', - alignItems: 'flex-start', - justifyContent: 'center', - }; + // this.styles_.modalLayer = { + // zIndex: 9999, + // display: 'flex', + // position: 'absolute', + // top: 0, + // left: 0, + // width: '100%', + // height: '100%', + // backgroundColor: 'rgba(0,0,0,0.6)', + // alignItems: 'flex-start', + // justifyContent: 'center', + // }; - this.styles_.dialogBox = { - backgroundColor: theme.backgroundColor, - padding: 16, - boxShadow: '6px 6px 20px rgba(0,0,0,0.5)', - marginTop: 20, - } + // this.styles_.dialogBox = { + // backgroundColor: theme.backgroundColor, + // padding: 16, + // boxShadow: '6px 6px 20px rgba(0,0,0,0.5)', + // marginTop: 20, + // } this.styles_.controlBox = { marginBottom: '1em', @@ -153,7 +153,7 @@ class NotePropertiesDialog extends React.Component { borderColor: theme.dividerColor, }; - this.styles_.dialogTitle = Object.assign({}, theme.h1Style, { marginBottom: '1.2em' }); + // this.styles_.dialogTitle = Object.assign({}, theme.h1Style, { marginBottom: '1.2em' }); return this.styles_; } @@ -368,8 +368,6 @@ class NotePropertiesDialog extends React.Component { const noteComps = []; - const modalLayerStyle = Object.assign({}, styles.modalLayer); - if (formNote) { for (let key in formNote) { if (!formNote.hasOwnProperty(key)) continue; @@ -379,9 +377,9 @@ class NotePropertiesDialog extends React.Component { } return ( -
-
-
{_('Note properties')}
+
+
+
{_('Note properties')}
{noteComps}
{buttonComps} diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx index 2e3847b5d..682a1e5d1 100644 --- a/ElectronClient/app/gui/NoteText.jsx +++ b/ElectronClient/app/gui/NoteText.jsx @@ -800,7 +800,7 @@ class NoteTextComponent extends React.Component { }); if (Setting.value('env') === 'dev') { - this.webviewRef_.current.wrappedInstance.openDevTools(); + // this.webviewRef_.current.wrappedInstance.openDevTools(); } } diff --git a/ElectronClient/app/plugins/GotoAnything.jsx b/ElectronClient/app/plugins/GotoAnything.jsx new file mode 100644 index 000000000..9b863a63f --- /dev/null +++ b/ElectronClient/app/plugins/GotoAnything.jsx @@ -0,0 +1,358 @@ +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'); +const { ItemList } = require('../gui/ItemList.min'); +const { substrWithEllipsis, surroundKeywords } = require('lib/string-utils.js'); + +const PLUGIN_NAME = 'gotoAnything'; +const itemHeight = 60; + +class GotoAnything { + + onTrigger(event) { + 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); + 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 }), + row: {overflow: 'hidden', height:itemHeight, display: 'flex', justifyContent: 'center', flexDirection: 'column', paddingLeft: 10, paddingRight: 10}, + help: Object.assign({}, theme.textStyle, { marginBottom: 10 }), + inputHelpWrapper: {display: 'flex', flexDirection: 'row', alignItems: 'center'}, + helpIcon: {flex:0, width: 16, height: 16, marginLeft: 10}, + helpButton: {color: theme.color, textDecoration: 'none'}, + }; + + 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, + marginBottom: 5, + 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; + + 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, + }); + } + } + + helpButton_onClick(event) { + 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); + } + + makeSearchQuery(query) { + const splitted = query.split(' '); + const output = []; + for (let i = 0; i < splitted.length; i++) { + const s = splitted[i].trim(); + if (!s) continue; + + output.push('title:' + s + '*'); + } + + 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; + searchQuery = '*' + this.state.query.split(' ')[0].substr(1).trim() + '*'; + results = await Tag.searchAllWithNotes({ titlePattern: searchQuery }); + } else if (this.state.query.indexOf('@') === 0) { // FOLDERS + listType = BaseModel.TYPE_FOLDER; + searchQuery = '*' + this.state.query.split(' ')[0].substr(1).trim() + '*'; + 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 : '/' }); + } + } else { // NOTES + listType = BaseModel.TYPE_NOTE; + searchQuery = this.makeSearchQuery(this.state.query); + 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, + }); + } + } + + gotoItem(item) { + this.props.dispatch({ + pluginName: PLUGIN_NAME, + type: 'PLUGIN_DIALOG_SET', + open: false, + }); + + if (this.state.listType === BaseModel.TYPE_NOTE) { + this.props.dispatch({ + type: "FOLDER_AND_NOTE_SELECT", + folderId: item.parent_id, + noteId: item.id, + }); + } else if (this.state.listType === BaseModel.TYPE_TAG) { + this.props.dispatch({ + type: "TAG_SELECT", + id: item.id, + }); + } else if (this.state.listType === BaseModel.TYPE_FOLDER) { + this.props.dispatch({ + type: "FOLDER_SELECT", + id: item.id, + }); + } + } + + 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; + const titleHtml = surroundKeywords(this.state.keywords, item.title, '', ''); + + const pathComp = !item.path ? null :
{item.path}
+ + return ( +
+
+ {pathComp} +
+ ); + } + + 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 + + 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 ( + + ); + } + + render() { + const theme = themeStyle(this.props.theme); + const style = this.style(); + const helpComp = !this.state.showHelp ? null :
{_('Type a note title to jump to it. Or type # followed by a tag name, or @ followed by a notebook name.')}
+ + return ( +
+
+ {helpComp} +
+ + +
+ {this.renderList()} +
+
+ ); + } + +} + +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...'), + accelerator: 'CommandOrControl+P', + screens: ['Main'], + }, + ], + +}; + +module.exports = GotoAnything; \ No newline at end of file diff --git a/ElectronClient/app/plugins/GotoAnything.min.js b/ElectronClient/app/plugins/GotoAnything.min.js new file mode 100644 index 000000000..7f4db6aaf --- /dev/null +++ b/ElectronClient/app/plugins/GotoAnything.min.js @@ -0,0 +1,376 @@ +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'); +const { ItemList } = require('../gui/ItemList.min'); +const { substrWithEllipsis, surroundKeywords } = require('lib/string-utils.js'); + +const PLUGIN_NAME = 'gotoAnything'; +const itemHeight = 60; + +class GotoAnything { + + onTrigger(event) { + 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); + 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 }), + row: { overflow: 'hidden', height: itemHeight, display: 'flex', justifyContent: 'center', flexDirection: 'column', paddingLeft: 10, paddingRight: 10 }, + help: Object.assign({}, theme.textStyle, { marginBottom: 10 }), + inputHelpWrapper: { display: 'flex', flexDirection: 'row', alignItems: 'center' }, + helpIcon: { flex: 0, width: 16, height: 16, marginLeft: 10 }, + helpButton: { color: theme.color, textDecoration: 'none' } + }; + + 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, + marginBottom: 5, + 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; + + 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 + }); + } + } + + helpButton_onClick(event) { + 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); + } + + makeSearchQuery(query) { + const splitted = query.split(' '); + const output = []; + for (let i = 0; i < splitted.length; i++) { + const s = splitted[i].trim(); + if (!s) continue; + + output.push('title:' + s + '*'); + } + + 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; + searchQuery = '*' + this.state.query.split(' ')[0].substr(1).trim() + '*'; + results = await Tag.searchAllWithNotes({ titlePattern: searchQuery }); + } else if (this.state.query.indexOf('@') === 0) { + // FOLDERS + listType = BaseModel.TYPE_FOLDER; + searchQuery = '*' + this.state.query.split(' ')[0].substr(1).trim() + '*'; + 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 : '/' }); + } + } else { + // NOTES + listType = BaseModel.TYPE_NOTE; + searchQuery = this.makeSearchQuery(this.state.query); + 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 + }); + } + } + + gotoItem(item) { + this.props.dispatch({ + pluginName: PLUGIN_NAME, + type: 'PLUGIN_DIALOG_SET', + open: false + }); + + if (this.state.listType === BaseModel.TYPE_NOTE) { + this.props.dispatch({ + type: "FOLDER_AND_NOTE_SELECT", + folderId: item.parent_id, + noteId: item.id + }); + } else if (this.state.listType === BaseModel.TYPE_TAG) { + this.props.dispatch({ + type: "TAG_SELECT", + id: item.id + }); + } else if (this.state.listType === BaseModel.TYPE_FOLDER) { + this.props.dispatch({ + type: "FOLDER_SELECT", + id: item.id + }); + } + } + + 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; + const titleHtml = surroundKeywords(this.state.keywords, item.title, '', ''); + + const pathComp = !item.path ? null : React.createElement( + 'div', + { style: style.rowPath }, + item.path + ); + + return React.createElement( + 'div', + { key: item.id, style: rowStyle, onClick: this.listItem_onClick, 'data-id': item.id, 'data-parent-id': item.parent_id }, + React.createElement('div', { style: style.rowTitle, dangerouslySetInnerHTML: { __html: titleHtml } }), + pathComp + ); + } + + 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 + + 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 React.createElement(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(); + const helpComp = !this.state.showHelp ? null : React.createElement( + '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.') + ); + + return React.createElement( + 'div', + { style: theme.dialogModalLayer }, + React.createElement( + 'div', + { style: style.dialogBox }, + helpComp, + React.createElement( + 'div', + { style: style.inputHelpWrapper }, + React.createElement('input', { autoFocus: true, type: 'text', style: style.input, ref: this.inputRef, value: this.state.query, onChange: this.input_onChange, onKeyDown: this.input_onKeyDown }), + React.createElement( + 'a', + { href: '#', style: style.helpButton, onClick: this.helpButton_onClick }, + React.createElement('i', { style: style.helpIcon, className: "fa fa-question-circle" }) + ) + ), + this.renderList() + ) + ); + } + +} + +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...'), + accelerator: 'CommandOrControl+P', + screens: ['Main'] + }] + +}; + +module.exports = GotoAnything; diff --git a/ElectronClient/app/theme.js b/ElectronClient/app/theme.js index 207e096da..5bda0f828 100644 --- a/ElectronClient/app/theme.js +++ b/ElectronClient/app/theme.js @@ -42,6 +42,10 @@ globalStyle.headerStyle = { globalStyle.inputStyle = { border: '1px solid', + height: 24, + paddingLeft: 5, + paddingRight: 5, + boxSizing: 'border-box', }; globalStyle.containerStyle = { @@ -67,6 +71,7 @@ const lightStyle = { colorError: "red", colorWarn: "#9A5B00", colorFaded: "#777777", // For less important text + colorBright: "#000000", // For important text dividerColor: "#dddddd", selectedColor: '#e5e5e5', urlColor: '#155BDA', @@ -103,6 +108,7 @@ const darkStyle = { colorError: "red", colorWarn: "#9A5B00", colorFaded: "#777777", // For less important text + colorBright: "#ffffff", // For important text dividerColor: '#555555', selectedColor: '#333333', urlColor: '#4E87EE', @@ -198,6 +204,28 @@ function addExtraStyles(style) { } ); + style.dialogModalLayer = { + zIndex: 9999, + display: 'flex', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + backgroundColor: 'rgba(0,0,0,0.6)', + alignItems: 'flex-start', + justifyContent: 'center', + }; + + style.dialogBox = { + backgroundColor: style.backgroundColor, + padding: 16, + boxShadow: '6px 6px 20px rgba(0,0,0,0.5)', + marginTop: 20, + } + + style.dialogTitle = Object.assign({}, style.h1Style, { marginBottom: '1.2em' }); + return style; } diff --git a/ReactNativeClient/lib/models/Folder.js b/ReactNativeClient/lib/models/Folder.js index 22adb56d1..6369dc076 100644 --- a/ReactNativeClient/lib/models/Folder.js +++ b/ReactNativeClient/lib/models/Folder.js @@ -7,7 +7,7 @@ const { Database } = require('lib/database.js'); const { _ } = require('lib/locale.js'); const moment = require('moment'); const BaseItem = require('lib/models/BaseItem.js'); -const lodash = require('lodash'); +const { substrWithEllipsis } = require('lib/string-utils.js'); class Folder extends BaseItem { @@ -217,6 +217,34 @@ class Folder extends BaseItem { return getNestedChildren(all, ''); } + static folderPath(folders, folderId) { + const idToFolders = {}; + for (let i = 0; i < folders.length; i++) { + idToFolders[folders[i].id] = folders[i]; + } + + const path = []; + while (folderId) { + const folder = idToFolders[folderId]; + if (!folder) break; // Shouldn't happen + path.push(folder); + folderId = folder.parent_id; + } + + path.reverse(); + + return path; + } + + static folderPathString(folders, folderId) { + const path = this.folderPath(folders, folderId); + const output = []; + for (let i = 0; i < path.length; i++) { + output.push(substrWithEllipsis(path[i].title, 0, 16)); + } + return output.join(' / '); + } + static buildTree(folders) { const idToFolders = {}; for (let i = 0; i < folders.length; i++) { diff --git a/ReactNativeClient/lib/models/Tag.js b/ReactNativeClient/lib/models/Tag.js index 07fb782ea..8d74b012d 100644 --- a/ReactNativeClient/lib/models/Tag.js +++ b/ReactNativeClient/lib/models/Tag.js @@ -90,9 +90,19 @@ class Tag extends BaseItem { return !!r; } + static tagsWithNotesSql_() { + return 'select distinct tags.id from tags left join note_tags nt on nt.tag_id = tags.id left join notes on notes.id = nt.note_id where notes.id IS NOT NULL'; + } + static async allWithNotes() { - const tagIdSql = 'select distinct tags.id from tags left join note_tags nt on nt.tag_id = tags.id left join notes on notes.id = nt.note_id where notes.id IS NOT NULL'; - return await Tag.modelSelectAll('SELECT * FROM tags WHERE id IN (' + tagIdSql + ')'); + return await Tag.modelSelectAll('SELECT * FROM tags WHERE id IN (' + this.tagsWithNotesSql_() + ')'); + } + + static async searchAllWithNotes(options) { + if (!options) options = {}; + if (!options.conditions) options.conditions = []; + options.conditions.push('id IN (' + this.tagsWithNotesSql_() + ')'); + return this.search(options); } static async tagsByNoteId(noteId) { diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js index b30375deb..f75d8bb47 100644 --- a/ReactNativeClient/lib/reducer.js +++ b/ReactNativeClient/lib/reducer.js @@ -48,6 +48,7 @@ const defaultState = { toFetchCount: 0, }, historyNotes: [], + plugins: {}, }; const stateUtils = {}; @@ -691,6 +692,17 @@ const reducer = (state = defaultState, action) => { newState.selectedNoteTags = action.items; break; + case 'PLUGIN_DIALOG_SET': + + if (!action.pluginName) throw new Error('action.pluginName not specified'); + newState = Object.assign({}, state); + const newPlugins = Object.assign({}, newState.plugins); + const newPlugin = newState.plugins[action.pluginName] ? Object.assign({}, newState.plugins[action.pluginName]) : {}; + if ('open' in action) newPlugin.dialogOpen = action.open; + newPlugins[action.pluginName] = newPlugin; + newState.plugins = newPlugins; + break; + } } catch (error) { error.message = 'In reducer: ' + error.message + ' Action: ' + JSON.stringify(action); diff --git a/ReactNativeClient/lib/services/PluginManager.js b/ReactNativeClient/lib/services/PluginManager.js new file mode 100644 index 000000000..2f168cd66 --- /dev/null +++ b/ReactNativeClient/lib/services/PluginManager.js @@ -0,0 +1,105 @@ +const { Logger } = require('lib/logger.js'); + +class PluginManager { + + constructor() { + this.plugins_ = {}; + this.logger_ = new Logger(); + } + + setLogger(l) { + this.logger_ = l; + } + + logger() { + return this.logger_; + } + + static instance() { + if (this.instance_) return this.instance_; + this.instance_ = new PluginManager(); + return this.instance_; + } + + register(classes) { + if (!Array.isArray(classes)) classes = [classes]; + + for (let i = 0; i < classes.length; i++) { + const PluginClass = classes[i]; + + if (this.plugins_[PluginClass.manifest.name]) throw new Error('Already registered: ' + PluginClass.manifest.name); + + this.plugins_[PluginClass.manifest.name] = { + Class: PluginClass, + instance: null, + }; + } + } + + pluginInstance_(name) { + const p = this.plugins_[name]; + if (p.instance) return p.instance; + p.instance = new p.Class(); + p.instance.dispatch = (action) => this.dispatch_(action); + return p.instance; + } + + pluginClass_(name) { + return this.plugins_[name].Class; + } + + onPluginMenuItemTrigger_(event) { + const p = this.pluginInstance_(event.pluginName); + p.onTrigger({ + itemName: event.itemName, + }); + } + + pluginDialogToShow(pluginStates) { + for (const name in pluginStates) { + const p = pluginStates[name]; + if (!p.dialogOpen) continue; + + const Class = this.pluginClass_(name); + if (!Class.Dialog) continue; + + return { + Dialog: Class.Dialog, + props: this.dialogProps_(name), + } + } + + return null; + } + + dialogProps_(name) { + return { + dispatch: (action) => this.dispatch_(action), + plugin: this.pluginInstance_(name), + }; + } + + menuItems() { + let output = []; + for (const name in this.plugins_) { + const menuItems = this.plugins_[name].Class.manifest.menuItems; + if (!menuItems) continue; + + for (const item of menuItems) { + item.click = () => { + this.onPluginMenuItemTrigger_({ + pluginName: name, + itemName: item.name, + }); + } + } + + output = output.concat(menuItems); + } + + return output; + } + +} + +module.exports = PluginManager; \ No newline at end of file diff --git a/ReactNativeClient/lib/services/SearchEngine.js b/ReactNativeClient/lib/services/SearchEngine.js index ac7cc5277..d7e7d89f9 100644 --- a/ReactNativeClient/lib/services/SearchEngine.js +++ b/ReactNativeClient/lib/services/SearchEngine.js @@ -267,7 +267,7 @@ class SearchEngine { if (c === ':' && !inQuote) { currentCol = currentTerm; - terms[currentCol] = []; + if (!terms[currentCol]) terms[currentCol] = []; currentTerm = ''; continue; } @@ -368,7 +368,7 @@ class SearchEngine { return this.basicSearch(query); } else { const parsedQuery = this.parseQuery(query); - const sql = 'SELECT notes_fts.id, notes_fts.title, offsets(notes_fts) AS offsets, notes.user_updated_time, notes.is_todo, notes.todo_completed FROM notes_fts LEFT JOIN notes ON notes_fts.id = notes.id WHERE notes_fts MATCH ?' + const sql = 'SELECT notes_fts.id, notes_fts.title, offsets(notes_fts) AS offsets, notes.user_updated_time, notes.is_todo, notes.todo_completed, notes.parent_id FROM notes_fts LEFT JOIN notes ON notes_fts.id = notes.id WHERE notes_fts MATCH ?' try { const rows = await this.db().selectAll(sql, [query]); this.orderResults_(rows, parsedQuery);