{buttonComps}
diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx
index 2e3847b5dc..682a1e5d19 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 0000000000..9b863a63fa
--- /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 (
+
+ );
+ }
+
+ 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 0000000000..7f4db6aaff
--- /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 207e096dad..5bda0f8288 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 22adb56d13..6369dc076a 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 07fb782ea5..8d74b012de 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 b30375deba..f75d8bb472 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 0000000000..2f168cd663
--- /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 ac7cc5277c..d7e7d89f96 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);