diff --git a/CliClient/app/app-gui.js b/CliClient/app/app-gui.js index 98424b6e46..c839f297e5 100644 --- a/CliClient/app/app-gui.js +++ b/CliClient/app/app-gui.js @@ -167,7 +167,7 @@ class AppGui { }); this.rootWidget_.connect(noteList, (state) => { return { - selectedNoteId: state.selectedNoteId, + selectedNoteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, items: state.notes, }; }); @@ -181,7 +181,7 @@ class AppGui { }; this.rootWidget_.connect(noteText, (state) => { return { - noteId: state.selectedNoteId, + noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, notes: state.notes, }; }); @@ -195,7 +195,7 @@ class AppGui { borderRightWidth: 1, }; this.rootWidget_.connect(noteMetadata, (state) => { - return { noteId: state.selectedNoteId }; + return { noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null }; }); noteMetadata.hide(); diff --git a/ElectronClient/app/gui/NoteList.jsx b/ElectronClient/app/gui/NoteList.jsx index 5266b1f40d..fc2b8faf88 100644 --- a/ElectronClient/app/gui/NoteList.jsx +++ b/ElectronClient/app/gui/NoteList.jsx @@ -52,28 +52,30 @@ class NoteListComponent extends React.Component { } itemContextMenu(event) { - const noteId = event.target.getAttribute('data-id'); - if (!noteId) throw new Error('No data-id on element'); + const noteIds = this.props.selectedNoteIds; + if (!noteIds.length) return; const menu = new Menu() - menu.append(new MenuItem({label: _('Add or remove tags'), click: async () => { + menu.append(new MenuItem({label: _('Add or remove tags'), enabled: noteIds.length === 1, click: async () => { this.props.dispatch({ type: 'WINDOW_COMMAND', name: 'setTags', - noteId: noteId, + noteId: noteIds[0], }); }})); - menu.append(new MenuItem({label: _('Switch between note and to-do'), click: async () => { - const note = await Note.load(noteId); - await Note.save(Note.toggleIsTodo(note)); + menu.append(new MenuItem({label: _('Switch between note and to-do type'), click: async () => { + for (let i = 0; i < noteIds.length; i++) { + const note = await Note.load(noteIds[i]); + await Note.save(Note.toggleIsTodo(note)); + } }})); menu.append(new MenuItem({label: _('Delete'), click: async () => { - const ok = bridge().showConfirmMessageBox(_('Delete note?')); + const ok = bridge().showConfirmMessageBox(noteIds.length > 1 ? _('Delete notes?') : _('Delete note?')); if (!ok) return; - await Note.delete(noteId); + await Note.batchDelete(noteIds); }})); menu.popup(bridge().window()); @@ -81,10 +83,23 @@ class NoteListComponent extends React.Component { itemRenderer(item, theme, width) { const onTitleClick = async (event, item) => { - this.props.dispatch({ - type: 'NOTE_SELECT', - id: item.id, - }); + event.preventDefault(); + if (event.ctrlKey) { + this.props.dispatch({ + type: 'NOTE_SELECT_TOGGLE', + id: item.id, + }); + } else if (event.shiftKey) { + this.props.dispatch({ + type: 'NOTE_SELECT_EXTEND', + id: item.id, + }); + } else { + this.props.dispatch({ + type: 'NOTE_SELECT', + id: item.id, + }); + } } const onCheckboxClick = async (event) => { @@ -99,7 +114,7 @@ class NoteListComponent extends React.Component { const hPadding = 10; let style = Object.assign({ width: width }, this.style().listItem); - if (this.props.selectedNoteId === item.id) style = Object.assign(style, this.style().listItemSelected); + if (this.props.selectedNoteIds.indexOf(item.id) >= 0) style = Object.assign(style, this.style().listItemSelected); // Setting marginBottom = 1 because it makes the checkbox looks more centered, at least on Windows // but don't know how it will look in other OSes. @@ -118,7 +133,6 @@ class NoteListComponent extends React.Component { return
{checkbox} this.itemContextMenu(event)} href="#" @@ -164,7 +178,7 @@ class NoteListComponent extends React.Component { const mapStateToProps = (state) => { return { notes: state.notes, - selectedNoteId: state.selectedNoteId, + selectedNoteIds: state.selectedNoteIds, theme: state.settings.theme, // uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop, }; diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx index f5bbf40610..15ac652cdd 100644 --- a/ElectronClient/app/gui/NoteText.jsx +++ b/ElectronClient/app/gui/NoteText.jsx @@ -504,7 +504,7 @@ class NoteTextComponent extends React.Component { const mapStateToProps = (state) => { return { - noteId: state.selectedNoteId, + noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, folderId: state.selectedFolderId, itemType: state.selectedItemType, folders: state.folders, diff --git a/ReactNativeClient/lib/ArrayUtils.js b/ReactNativeClient/lib/ArrayUtils.js new file mode 100644 index 0000000000..74e0797f81 --- /dev/null +++ b/ReactNativeClient/lib/ArrayUtils.js @@ -0,0 +1,9 @@ +const ArrayUtils = {}; + +ArrayUtils.unique = function(array) { + return array.filter(function(elem, index, self) { + return index === self.indexOf(elem); + }); +} + +module.exports = ArrayUtils; \ No newline at end of file diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index 40cb26c67d..8fba64f1c1 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -152,9 +152,6 @@ class BaseApplication { if (parentType === 'Folder') { parentId = state.selectedFolderId; parentType = BaseModel.TYPE_FOLDER; - } else if (parentType === 'Note') { - parentId = state.selectedNoteId; - parentType = BaseModel.TYPE_NOTE; } else if (parentType === 'Tag') { parentId = state.selectedTagId; parentType = BaseModel.TYPE_TAG; diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js index d1e38d70f9..31c338b627 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/note.js @@ -550,7 +550,7 @@ class NoteScreenComponent extends BaseScreenComponent { const NoteScreen = connect( (state) => { return { - noteId: state.selectedNoteId, + noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, folderId: state.selectedFolderId, itemType: state.selectedItemType, folders: state.folders, diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js index cd3589ab14..02d0b9e92b 100644 --- a/ReactNativeClient/lib/reducer.js +++ b/ReactNativeClient/lib/reducer.js @@ -1,5 +1,6 @@ const { Note } = require('lib/models/note.js'); const { Folder } = require('lib/models/folder.js'); +const ArrayUtils = require('lib/ArrayUtils.js'); const defaultState = { notes: [], @@ -8,7 +9,7 @@ const defaultState = { folders: [], tags: [], searches: [], - selectedNoteId: null, + selectedNoteIds: [], selectedFolderId: null, selectedTagId: null, selectedSearchId: null, @@ -33,7 +34,7 @@ function handleItemDelete(state, action) { const map = { 'FOLDER_DELETE': ['folders', 'selectedFolderId'], - 'NOTE_DELETE': ['notes', 'selectedNoteId'], + 'NOTE_DELETE': ['notes', 'selectedNoteIds'], 'TAG_DELETE': ['tags', 'selectedTagId'], 'SEARCH_DELETE': ['searches', 'selectedSearchId'], }; @@ -60,10 +61,10 @@ function handleItemDelete(state, action) { previousIndex = newItems.length - 1; } - const newIndex = previousIndex >= 0 ? newItems[previousIndex].id : null; - newState[selectedItemKey] = newIndex; + const newId = previousIndex >= 0 ? newItems[previousIndex].id : null; + newState[selectedItemKey] = action.type === 'NOTE_DELETE' ? [newId] : newId; - if (!newIndex && newState.notesParentType !== 'Folder') { + if (!newId && newState.notesParentType !== 'Folder') { newState.notesParentType = 'Folder'; } @@ -111,6 +112,51 @@ function defaultNotesParentType(state, exclusion) { return newNotesParentType; } +function changeSelectedNotes(state, action) { + const noteIds = 'id' in action ? (action.id ? [action.id] : []) : action.ids; + let newState = Object.assign({}, state); + + if (action.type === 'NOTE_SELECT') { + newState.selectedNoteIds = noteIds; + return newState; + } + + if (action.type === 'NOTE_SELECT_ADD') { + if (!noteIds.length) return state; + newState.selectedNoteIds = ArrayUtils.unique(newState.selectedNoteIds.concat(noteIds)); + return newState; + } + + if (action.type === 'NOTE_SELECT_REMOVE') { + if (!noteIds.length) return state; // Nothing to unselect + if (state.selectedNoteIds.length <= 1) return state; // Cannot unselect the last note + + let newSelectedNoteIds = []; + for (let i = 0; i < newState.selectedNoteIds.length; i++) { + const id = newState.selectedNoteIds[i]; + if (noteIds.indexOf(id) >= 0) continue; + newSelectedNoteIds.push(id); + } + newState.selectedNoteIds = newSelectedNoteIds; + + return newState; + } + + if (action.type === 'NOTE_SELECT_TOGGLE') { + if (!noteIds.length) return state; + + if (newState.selectedNoteIds.indexOf(noteIds[0]) >= 0) { + newState = changeSelectedNotes(state, { type: 'NOTE_SELECT_REMOVE', id: noteIds[0] }); + } else { + newState = changeSelectedNotes(state, { type: 'NOTE_SELECT_ADD', id: noteIds[0] }); + } + + return newState; + } + + throw new Error('Unreachable'); +} + const reducer = (state = defaultState, action) => { let newState = state; @@ -118,9 +164,44 @@ const reducer = (state = defaultState, action) => { switch (action.type) { case 'NOTE_SELECT': + case 'NOTE_SELECT_ADD': + case 'NOTE_SELECT_REMOVE': + case 'NOTE_SELECT_TOGGLE': + + newState = changeSelectedNotes(state, action); + break; + + case 'NOTE_SELECT_EXTEND': newState = Object.assign({}, state); - newState.selectedNoteId = action.id; + + if (!newState.selectedNoteIds.length) { + newState.selectedNoteIds = [action.id]; + } else { + const selectRangeId1 = state.selectedNoteIds[state.selectedNoteIds.length - 1]; + const selectRangeId2 = action.id; + if (selectRangeId1 === selectRangeId2) return state; + + let newSelectedNoteIds = state.selectedNoteIds.slice(); + let selectionStarted = false; + for (let i = 0; i < state.notes.length; i++) { + const id = state.notes[i].id; + + if (!selectionStarted && (id === selectRangeId1 || id === selectRangeId2)) { + selectionStarted = true; + if (newSelectedNoteIds.indexOf(id) < 0) newSelectedNoteIds.push(id); + continue; + } else if (selectionStarted && (id === selectRangeId1 || id === selectRangeId2)) { + if (newSelectedNoteIds.indexOf(id) < 0) newSelectedNoteIds.push(id); + break; + } + + if (selectionStarted && newSelectedNoteIds.indexOf(id) < 0) { + newSelectedNoteIds.push(id); + } + } + newState.selectedNoteIds = newSelectedNoteIds; + } break; case 'FOLDER_SELECT': @@ -208,7 +289,7 @@ const reducer = (state = defaultState, action) => { newState.notes = newNotes; if (noteFolderHasChanged) { - newState.selectedNoteId = newNotes.length ? newNotes[0].id : null; + newState.selectedNoteIds = newNotes.length ? [newNotes[0].id] : null; } break; diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 3fc3e8cec6..a4a142e9e6 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -142,7 +142,7 @@ const appReducer = (state = appDefaultState, action) => { newState = Object.assign({}, state); if ('noteId' in action) { - newState.selectedNoteId = action.noteId; + newState.selectedNoteIds = action.noteId ? [action.noteId] : []; } if ('folderId' in action) {