From 0f533616897c0437e4fb513639a5fbd50e98e6fa Mon Sep 17 00:00:00 2001 From: Baptiste Grob <60621355+baptiste-grob@users.noreply.github.com> Date: Tue, 6 Apr 2021 18:09:40 +0200 Subject: [PATCH 01/78] feat: select multiple notes in list --- app/assets/javascripts/services/index.ts | 8 - app/assets/javascripts/services/ioService.ts | 171 ++++++++++++++++++ .../javascripts/services/keyboardManager.ts | 147 --------------- .../ui_models/app_state/app_state.ts | 32 +--- .../ui_models/app_state/notes_state.ts | 66 +++++++ .../javascripts/ui_models/application.ts | 29 ++- .../ui_models/application_group.ts | 85 +++++---- .../views/abstract/pure_view_ctrl.ts | 29 ++- .../javascripts/views/editor/editor_view.ts | 8 +- .../javascripts/views/notes/notes-view.pug | 2 +- .../javascripts/views/notes/notes_view.ts | 50 ++--- 11 files changed, 354 insertions(+), 273 deletions(-) delete mode 100644 app/assets/javascripts/services/index.ts create mode 100644 app/assets/javascripts/services/ioService.ts delete mode 100644 app/assets/javascripts/services/keyboardManager.ts create mode 100644 app/assets/javascripts/ui_models/app_state/notes_state.ts diff --git a/app/assets/javascripts/services/index.ts b/app/assets/javascripts/services/index.ts deleted file mode 100644 index 1099f9088..000000000 --- a/app/assets/javascripts/services/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { AlertService } from './alertService'; -export { ArchiveManager } from './archiveManager'; -export { DesktopManager } from './desktopManager'; -export { KeyboardManager } from './keyboardManager'; -export { AutolockService } from './autolock_service'; -export { NativeExtManager } from './nativeExtManager'; -export { StatusManager } from './statusManager'; -export { ThemeManager } from './themeManager'; diff --git a/app/assets/javascripts/services/ioService.ts b/app/assets/javascripts/services/ioService.ts new file mode 100644 index 000000000..5138fbb85 --- /dev/null +++ b/app/assets/javascripts/services/ioService.ts @@ -0,0 +1,171 @@ +import { removeFromArray } from '@standardnotes/snjs'; +export enum KeyboardKey { + Tab = 'Tab', + Backspace = 'Backspace', + Up = 'ArrowUp', + Down = 'ArrowDown', +} + +export enum KeyboardModifier { + Shift = 'Shift', + Ctrl = 'Control', + /** ⌘ key on Mac, ⊞ key on Windows */ + Meta = 'Meta', + Alt = 'Alt', +} + +enum KeyboardKeyEvent { + Down = 'KeyEventDown', + Up = 'KeyEventUp', +} + +type KeyboardObserver = { + key?: KeyboardKey | string; + modifiers?: KeyboardModifier[]; + onKeyDown?: (event: KeyboardEvent) => void; + onKeyUp?: (event: KeyboardEvent) => void; + element?: HTMLElement; + elements?: HTMLElement[]; + notElement?: HTMLElement; + notElementIds?: string[]; +}; + +export class IOService { + readonly activeModifiers = new Set(); + private observers: KeyboardObserver[] = []; + + constructor(private isMac: boolean) { + window.addEventListener('keydown', this.handleKeyDown); + window.addEventListener('keyup', this.handleKeyUp); + } + + public deinit() { + this.observers.length = 0; + window.removeEventListener('keydown', this.handleKeyDown); + window.removeEventListener('keyup', this.handleKeyUp); + (this.handleKeyDown as unknown) = undefined; + (this.handleKeyUp as unknown) = undefined; + } + + handleKeyDown = (event: KeyboardEvent) => { + for (const modifier of this.modifiersForEvent(event)) { + switch (modifier) { + case KeyboardModifier.Meta: { + if (this.isMac) { + this.activeModifiers.add(modifier); + } + break; + } + case KeyboardModifier.Ctrl: { + if (!this.isMac) { + this.activeModifiers.add(modifier); + } + break; + } + default: { + this.activeModifiers.add(modifier); + break; + } + } + } + this.notifyObserver(event, KeyboardKeyEvent.Down); + }; + + handleKeyUp = (event: KeyboardEvent) => { + for (const modifier of this.modifiersForEvent(event)) { + this.activeModifiers.delete(modifier); + } + this.notifyObserver(event, KeyboardKeyEvent.Up); + }; + + modifiersForEvent(event: KeyboardEvent) { + const allModifiers = Object.values(KeyboardModifier); + const eventModifiers = allModifiers.filter((modifier) => { + // For a modifier like ctrlKey, must check both event.ctrlKey and event.key. + // That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true. + const matches = + ((event.ctrlKey || event.key === KeyboardModifier.Ctrl) && + modifier === KeyboardModifier.Ctrl) || + ((event.metaKey || event.key === KeyboardModifier.Meta) && + modifier === KeyboardModifier.Meta) || + ((event.altKey || event.key === KeyboardModifier.Alt) && + modifier === KeyboardModifier.Alt) || + ((event.shiftKey || event.key === KeyboardModifier.Shift) && + modifier === KeyboardModifier.Shift); + + return matches; + }); + + return eventModifiers; + } + + eventMatchesKeyAndModifiers( + event: KeyboardEvent, + key: KeyboardKey | string, + modifiers: KeyboardModifier[] = [] + ) { + const eventModifiers = this.modifiersForEvent(event); + if (eventModifiers.length !== modifiers.length) { + return false; + } + for (const modifier of modifiers) { + if (!eventModifiers.includes(modifier)) { + return false; + } + } + // Modifers match, check key + if (!key) { + return true; + } + // In the browser, shift + f results in key 'f', but in Electron, shift + f results in 'F' + // In our case we don't differentiate between the two. + return key.toLowerCase() === event.key.toLowerCase(); + } + + notifyObserver(event: KeyboardEvent, keyEvent: KeyboardKeyEvent) { + const target = event.target as HTMLElement; + for (const observer of this.observers) { + if (observer.element && event.target !== observer.element) { + continue; + } + + if (observer.elements && !observer.elements.includes(target)) { + continue; + } + + if (observer.notElement && observer.notElement === event.target) { + continue; + } + + if ( + observer.notElementIds && + observer.notElementIds.includes(target.id) + ) { + continue; + } + + if ( + this.eventMatchesKeyAndModifiers( + event, + observer.key!, + observer.modifiers + ) + ) { + const callback = + keyEvent === KeyboardKeyEvent.Down + ? observer.onKeyDown + : observer.onKeyUp; + if (callback) { + callback(event); + } + } + } + } + + addKeyObserver(observer: KeyboardObserver) { + this.observers.push(observer); + return () => { + removeFromArray(this.observers, observer); + }; + } +} diff --git a/app/assets/javascripts/services/keyboardManager.ts b/app/assets/javascripts/services/keyboardManager.ts deleted file mode 100644 index b786bcd43..000000000 --- a/app/assets/javascripts/services/keyboardManager.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { removeFromArray } from '@standardnotes/snjs'; -export enum KeyboardKey { - Tab = "Tab", - Backspace = "Backspace", - Up = "ArrowUp", - Down = "ArrowDown", -} - -export enum KeyboardModifier { - Shift = "Shift", - Ctrl = "Control", - /** ⌘ key on Mac, ⊞ key on Windows */ - Meta = "Meta", - Alt = "Alt", -} - -enum KeyboardKeyEvent { - Down = "KeyEventDown", - Up = "KeyEventUp" -} - -type KeyboardObserver = { - key?: KeyboardKey | string - modifiers?: KeyboardModifier[] - onKeyDown?: (event: KeyboardEvent) => void - onKeyUp?: (event: KeyboardEvent) => void - element?: HTMLElement - elements?: HTMLElement[] - notElement?: HTMLElement - notElementIds?: string[] -} - -export class KeyboardManager { - - private observers: KeyboardObserver[] = [] - private handleKeyDown: any - private handleKeyUp: any - - constructor() { - this.handleKeyDown = (event: KeyboardEvent) => { - this.notifyObserver(event, KeyboardKeyEvent.Down); - }; - this.handleKeyUp = (event: KeyboardEvent) => { - this.notifyObserver(event, KeyboardKeyEvent.Up); - }; - window.addEventListener('keydown', this.handleKeyDown); - window.addEventListener('keyup', this.handleKeyUp); - } - - public deinit() { - this.observers.length = 0; - window.removeEventListener('keydown', this.handleKeyDown); - window.removeEventListener('keyup', this.handleKeyUp); - this.handleKeyDown = undefined; - this.handleKeyUp = undefined; - } - - modifiersForEvent(event: KeyboardEvent) { - const allModifiers = Object.values(KeyboardModifier); - const eventModifiers = allModifiers.filter((modifier) => { - // For a modifier like ctrlKey, must check both event.ctrlKey and event.key. - // That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true. - const matches = ( - ( - (event.ctrlKey || event.key === KeyboardModifier.Ctrl) - && modifier === KeyboardModifier.Ctrl - ) || - ( - (event.metaKey || event.key === KeyboardModifier.Meta) - && modifier === KeyboardModifier.Meta - ) || - ( - (event.altKey || event.key === KeyboardModifier.Alt) - && modifier === KeyboardModifier.Alt - ) || - ( - (event.shiftKey || event.key === KeyboardModifier.Shift) - && modifier === KeyboardModifier.Shift - ) - ); - - return matches; - }); - - return eventModifiers; - } - - eventMatchesKeyAndModifiers( - event: KeyboardEvent, - key: KeyboardKey | string, - modifiers: KeyboardModifier[] = [] - ) { - const eventModifiers = this.modifiersForEvent(event); - if (eventModifiers.length !== modifiers.length) { - return false; - } - for (const modifier of modifiers) { - if (!eventModifiers.includes(modifier)) { - return false; - } - } - // Modifers match, check key - if (!key) { - return true; - } - // In the browser, shift + f results in key 'f', but in Electron, shift + f results in 'F' - // In our case we don't differentiate between the two. - return key.toLowerCase() === event.key.toLowerCase(); - } - - notifyObserver(event: KeyboardEvent, keyEvent: KeyboardKeyEvent) { - const target = event.target as HTMLElement; - for (const observer of this.observers) { - if (observer.element && event.target !== observer.element) { - continue; - } - - if (observer.elements && !observer.elements.includes(target)) { - continue; - } - - if (observer.notElement && observer.notElement === event.target) { - continue; - } - - if (observer.notElementIds && observer.notElementIds.includes(target.id)) { - continue; - } - - if (this.eventMatchesKeyAndModifiers(event, observer.key!, observer.modifiers)) { - const callback = keyEvent === KeyboardKeyEvent.Down - ? observer.onKeyDown - : observer.onKeyUp; - if (callback) { - callback(event); - } - } - } - } - - addKeyObserver(observer: KeyboardObserver) { - this.observers.push(observer); - return () => { - removeFromArray(this.observers, observer); - }; - } -} diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 55024497f..e3d72344a 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -19,6 +19,7 @@ import { ActionsMenuState } from './actions_menu_state'; import { NoAccountWarningState } from './no_account_warning_state'; import { SyncState } from './sync_state'; import { SearchOptionsState } from './search_options_state'; +import { NotesState } from './notes_state'; export enum AppStateEvent { TagChanged, @@ -62,7 +63,8 @@ export class AppState { readonly actionsMenu = new ActionsMenuState(); readonly noAccountWarning: NoAccountWarningState; readonly sync = new SyncState(); - readonly searchOptions; + readonly searchOptions: SearchOptionsState; + readonly notes: NotesState; isSessionsModalVisible = false; private appEventObserverRemovers: (() => void)[] = []; @@ -77,6 +79,12 @@ export class AppState { this.$timeout = $timeout; this.$rootScope = $rootScope; this.application = application; + this.notes = new NotesState( + this.application, + async () => { + await this.notifyEvent(AppStateEvent.ActiveEditorChanged); + } + ); this.noAccountWarning = new NoAccountWarningState( application, this.appEventObserverRemovers @@ -175,28 +183,6 @@ export class AppState { } } - async openEditor(noteUuid: string): Promise { - if (this.getActiveEditor()?.note?.uuid === noteUuid) { - return; - } - - const note = this.application.findItem(noteUuid) as SNNote; - if (!note) { - console.warn('Tried accessing a non-existant note of UUID ' + noteUuid); - return; - } - - if (await this.application.authorizeNoteAccess(note)) { - const activeEditor = this.getActiveEditor(); - if (!activeEditor) { - this.application.editorGroup.createEditor(noteUuid); - } else { - activeEditor.setNote(note); - } - await this.notifyEvent(AppStateEvent.ActiveEditorChanged); - } - } - getActiveEditor() { return this.application.editorGroup.editors[0]; } diff --git a/app/assets/javascripts/ui_models/app_state/notes_state.ts b/app/assets/javascripts/ui_models/app_state/notes_state.ts new file mode 100644 index 000000000..dfba6bf71 --- /dev/null +++ b/app/assets/javascripts/ui_models/app_state/notes_state.ts @@ -0,0 +1,66 @@ +import { KeyboardModifier } from "@/services/ioService"; +import { UuidString, SNNote } from "@standardnotes/snjs"; +import { makeObservable, observable, action } from "mobx"; +import { WebApplication } from "../application"; +import { Editor } from "../editor"; + +export class NotesState { + selectedNotes: Record = {}; + + constructor( + private application: WebApplication, + private onActiveEditorChanged: () => Promise + ) { + makeObservable(this, { + selectedNotes: observable, + selectNote: action, + }); + } + + get activeEditor(): Editor | undefined { + return this.application.editorGroup.editors[0]; + } + + async selectNote(note: SNNote): Promise { + if ( + this.io.activeModifiers.has(KeyboardModifier.Meta) || + this.io.activeModifiers.has(KeyboardModifier.Ctrl) + ) { + this.selectedNotes[note.uuid] = note; + } else { + this.selectedNotes = { + [note.uuid]: note, + }; + } + await this.openEditor(note.uuid); + } + + async openEditor(noteUuid: string): Promise { + if (this.activeEditor?.note?.uuid === noteUuid) { + return; + } + + const note = this.application.findItem(noteUuid) as SNNote | undefined; + if (!note) { + console.warn('Tried accessing a non-existant note of UUID ' + noteUuid); + return; + } + + if (await this.application.authorizeNoteAccess(note)) { + if (!this.activeEditor) { + this.application.editorGroup.createEditor(noteUuid); + } else { + this.activeEditor.setNote(note); + } + await this.onActiveEditorChanged(); + + if (note.waitingForKey) { + this.application.presentKeyRecoveryWizard(); + } + } + } + + private get io() { + return this.application.io; + } +} diff --git a/app/assets/javascripts/ui_models/application.ts b/app/assets/javascripts/ui_models/application.ts index 07256a10e..95c729049 100644 --- a/app/assets/javascripts/ui_models/application.ts +++ b/app/assets/javascripts/ui_models/application.ts @@ -9,23 +9,21 @@ import { SNComponent, PermissionDialog, DeinitSource, + Platform, } from '@standardnotes/snjs'; import angular from 'angular'; -import { getPlatform } from '@/utils'; -import { AlertService } from '@/services/alertService'; import { WebDeviceInterface } from '@/web_device_interface'; -import { - DesktopManager, - AutolockService, - ArchiveManager, - NativeExtManager, - StatusManager, - ThemeManager, - KeyboardManager -} from '@/services'; import { AppState } from '@/ui_models/app_state'; import { Bridge } from '@/services/bridge'; import { WebCrypto } from '@/crypto'; +import { AlertService } from '@/services/alertService'; +import { AutolockService } from '@/services/autolock_service'; +import { ArchiveManager } from '@/services/archiveManager'; +import { DesktopManager } from '@/services/desktopManager'; +import { IOService } from '@/services/ioService'; +import { NativeExtManager } from '@/services/nativeExtManager'; +import { StatusManager } from '@/services/statusManager'; +import { ThemeManager } from '@/services/themeManager'; type WebServices = { appState: AppState; @@ -35,7 +33,7 @@ type WebServices = { nativeExtService: NativeExtManager; statusManager: StatusManager; themeService: ThemeManager; - keyboardService: KeyboardManager; + io: IOService; } export class WebApplication extends SNApplication { @@ -49,6 +47,7 @@ export class WebApplication extends SNApplication { /* @ngInject */ constructor( deviceInterface: WebDeviceInterface, + platform: Platform, identifier: string, private $compile: angular.ICompileService, scope: angular.IScope, @@ -57,7 +56,7 @@ export class WebApplication extends SNApplication { ) { super( bridge.environment, - getPlatform(), + platform, deviceInterface, WebCrypto, new AlertService(), @@ -139,8 +138,8 @@ export class WebApplication extends SNApplication { return this.webServices.themeService; } - public getKeyboardService() { - return this.webServices.keyboardService; + public get io() { + return this.webServices.io; } async checkForSecurityUpdate() { diff --git a/app/assets/javascripts/ui_models/application_group.ts b/app/assets/javascripts/ui_models/application_group.ts index dd89019cc..c2676bf92 100644 --- a/app/assets/javascripts/ui_models/application_group.ts +++ b/app/assets/javascripts/ui_models/application_group.ts @@ -1,24 +1,26 @@ import { WebDeviceInterface } from '@/web_device_interface'; import { WebApplication } from './application'; -import { ApplicationDescriptor, SNApplicationGroup, DeviceInterface } from '@standardnotes/snjs'; import { - ArchiveManager, - DesktopManager, - KeyboardManager, - AutolockService, - NativeExtManager, - StatusManager, - ThemeManager -} from '@/services'; + ApplicationDescriptor, + SNApplicationGroup, + DeviceInterface, + Platform, +} from '@standardnotes/snjs'; import { AppState } from '@/ui_models/app_state'; import { Bridge } from '@/services/bridge'; -import { isDesktopApplication } from '@/utils'; +import { getPlatform, isDesktopApplication } from '@/utils'; +import { ArchiveManager } from '@/services/archiveManager'; +import { DesktopManager } from '@/services/desktopManager'; +import { IOService } from '@/services/ioService'; +import { AutolockService } from '@/services/autolock_service'; +import { StatusManager } from '@/services/statusManager'; +import { NativeExtManager } from '@/services/nativeExtManager'; +import { ThemeManager } from '@/services/themeManager'; export class ApplicationGroup extends SNApplicationGroup { - - $compile: ng.ICompileService - $rootScope: ng.IRootScopeService - $timeout: ng.ITimeoutService + $compile: ng.ICompileService; + $rootScope: ng.IRootScopeService; + $timeout: ng.ITimeoutService; /* @ngInject */ constructor( @@ -26,75 +28,72 @@ export class ApplicationGroup extends SNApplicationGroup { $rootScope: ng.IRootScopeService, $timeout: ng.ITimeoutService, private defaultSyncServerHost: string, - private bridge: Bridge, + private bridge: Bridge ) { - super(new WebDeviceInterface( - $timeout, - bridge - )); + super(new WebDeviceInterface($timeout, bridge)); this.$compile = $compile; this.$timeout = $timeout; this.$rootScope = $rootScope; } - async initialize(callback?: any) { + async initialize(callback?: any): Promise { await super.initialize({ - applicationCreator: this.createApplication + applicationCreator: this.createApplication, }); if (isDesktopApplication()) { Object.defineProperty(window, 'desktopManager', { - get: () => (this.primaryApplication as WebApplication).getDesktopService() + get: () => + (this.primaryApplication as WebApplication).getDesktopService(), }); } } - private createApplication = (descriptor: ApplicationDescriptor, deviceInterface: DeviceInterface) => { + private createApplication = ( + descriptor: ApplicationDescriptor, + deviceInterface: DeviceInterface + ) => { const scope = this.$rootScope.$new(true); + const platform = getPlatform(); const application = new WebApplication( deviceInterface as WebDeviceInterface, + platform, descriptor.identifier, this.$compile, scope, this.defaultSyncServerHost, - this.bridge, + this.bridge ); const appState = new AppState( this.$rootScope, this.$timeout, application, - this.bridge, - ); - const archiveService = new ArchiveManager( - application + this.bridge ); + const archiveService = new ArchiveManager(application); const desktopService = new DesktopManager( this.$rootScope, this.$timeout, application, - this.bridge, + this.bridge ); - const keyboardService = new KeyboardManager(); - const autolockService = new AutolockService( - application - ); - const nativeExtService = new NativeExtManager( - application - ); - const statusService = new StatusManager(); - const themeService = new ThemeManager( - application, + const io = new IOService( + platform === Platform.MacWeb || platform === Platform.MacDesktop ); + const autolockService = new AutolockService(application); + const nativeExtService = new NativeExtManager(application); + const statusManager = new StatusManager(); + const themeService = new ThemeManager(application); application.setWebServices({ appState, archiveService, desktopService, - keyboardService, + io, autolockService, nativeExtService, - statusManager: statusService, - themeService + statusManager, + themeService, }); return application; - } + }; } diff --git a/app/assets/javascripts/views/abstract/pure_view_ctrl.ts b/app/assets/javascripts/views/abstract/pure_view_ctrl.ts index 0c104d7dd..6f2a24297 100644 --- a/app/assets/javascripts/views/abstract/pure_view_ctrl.ts +++ b/app/assets/javascripts/views/abstract/pure_view_ctrl.ts @@ -1,5 +1,7 @@ import { ApplicationEvent } from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; +import { AppState } from '@/ui_models/app_state'; +import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'; export type CtrlState = Partial> export type CtrlProps = Partial> @@ -17,6 +19,7 @@ export class PureViewCtrl

{ * no Angular handlebars/syntax render in the UI before display data is ready. */ protected templateReady = false + private reactionDisposers: IReactionDisposer[] = []; /* @ngInject */ constructor( @@ -26,7 +29,7 @@ export class PureViewCtrl

{ this.$timeout = $timeout; } - $onInit() { + $onInit(): void { this.state = { ...this.getInitialState(), ...this.state, @@ -36,9 +39,13 @@ export class PureViewCtrl

{ this.templateReady = true; } - deinit() { + deinit(): void { this.unsubApp(); this.unsubState(); + for (const disposer of this.reactionDisposers) { + disposer(); + } + this.reactionDisposers.length = 0; this.unsubApp = undefined; this.unsubState = undefined; if (this.stateTimeout) { @@ -46,16 +53,16 @@ export class PureViewCtrl

{ } } - $onDestroy() { + $onDestroy(): void { this.deinit(); } - public get appState() { - return this.application!.getAppState(); + public get appState(): AppState { + return this.application.getAppState(); } /** @private */ - async resetState() { + async resetState(): Promise { this.state = this.getInitialState(); await this.setState(this.state); } @@ -65,7 +72,7 @@ export class PureViewCtrl

{ return {} as any; } - async setState(state: Partial) { + async setState(state: Partial): Promise { if (!this.$timeout) { return; } @@ -88,17 +95,21 @@ export class PureViewCtrl

{ } /** @returns a promise that resolves after the UI has been updated. */ - flushUI() { + flushUI(): angular.IPromise { return this.$timeout(); } - initProps(props: CtrlProps) { + initProps(props: CtrlProps): void { if (Object.keys(this.props).length > 0) { throw 'Already init-ed props.'; } this.props = Object.freeze(Object.assign({}, this.props, props)); } + autorun(view: (r: IReactionPublic) => void): void { + this.reactionDisposers.push(autorun(view)); + } + addAppStateObserver() { this.unsubState = this.application!.getAppState().addObserver( async (eventName, data) => { diff --git a/app/assets/javascripts/views/editor/editor_view.ts b/app/assets/javascripts/views/editor/editor_view.ts index 96aa4fc04..df6834ab6 100644 --- a/app/assets/javascripts/views/editor/editor_view.ts +++ b/app/assets/javascripts/views/editor/editor_view.ts @@ -24,7 +24,7 @@ import { } from '@standardnotes/snjs'; import find from 'lodash/find'; import { isDesktopApplication } from '@/utils'; -import { KeyboardModifier, KeyboardKey } from '@/services/keyboardManager'; +import { KeyboardModifier, KeyboardKey } from '@/services/ioService'; import template from './editor-view.pug'; import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; import { EventSource } from '@/ui_models/app_state'; @@ -1106,7 +1106,7 @@ class EditorViewCtrl extends PureViewCtrl { registerKeyboardShortcuts() { this.removeAltKeyObserver = this.application - .getKeyboardService() + .io .addKeyObserver({ modifiers: [KeyboardModifier.Alt], onKeyDown: () => { @@ -1122,7 +1122,7 @@ class EditorViewCtrl extends PureViewCtrl { }); this.removeTrashKeyObserver = this.application - .getKeyboardService() + .io .addKeyObserver({ key: KeyboardKey.Backspace, notElementIds: [ElementIds.NoteTextEditor, ElementIds.NoteTitleEditor], @@ -1147,7 +1147,7 @@ class EditorViewCtrl extends PureViewCtrl { ElementIds.NoteTextEditor )! as HTMLInputElement; this.removeTabObserver = this.application - .getKeyboardService() + .io .addKeyObserver({ element: editor, key: KeyboardKey.Tab, diff --git a/app/assets/javascripts/views/notes/notes-view.pug b/app/assets/javascripts/views/notes/notes-view.pug index 1b1e3efc7..b369e49f0 100644 --- a/app/assets/javascripts/views/notes/notes-view.pug +++ b/app/assets/javascripts/views/notes/notes-view.pug @@ -127,7 +127,7 @@ ) .note( ng-repeat='note in self.state.renderedNotes track by note.uuid' - ng-class="{'selected' : self.activeEditorNote.uuid == note.uuid}" + ng-class="{'selected' : self.isNoteSelected(note.uuid) }" ng-click='self.selectNote(note)' ) .note-flags(ng-show='self.noteFlags[note.uuid].length > 0') diff --git a/app/assets/javascripts/views/notes/notes_view.ts b/app/assets/javascripts/views/notes/notes_view.ts index 7d04358a6..bf7264ac9 100644 --- a/app/assets/javascripts/views/notes/notes_view.ts +++ b/app/assets/javascripts/views/notes/notes_view.ts @@ -14,17 +14,17 @@ import { } from '@standardnotes/snjs'; import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; import { AppStateEvent } from '@/ui_models/app_state'; -import { KeyboardModifier, KeyboardKey } from '@/services/keyboardManager'; +import { KeyboardKey, KeyboardModifier } from '@/services/ioService'; import { PANEL_NAME_NOTES } from '@/views/constants'; -import { autorun, IReactionDisposer } from 'mobx'; -type NotesState = { +type NotesCtrlState = { panelTitle: string notes: SNNote[] renderedNotes: SNNote[] renderedNotesTags: string[], + selectedNotes: Record, sortBy?: string sortReverse?: boolean showArchived?: boolean @@ -65,7 +65,7 @@ const DEFAULT_LIST_NUM_NOTES = 20; const ELEMENT_ID_SEARCH_BAR = 'search-bar'; const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable'; -class NotesViewCtrl extends PureViewCtrl { +class NotesViewCtrl extends PureViewCtrl { private panelPuppet?: PanelPuppet private reloadNotesPromise?: any @@ -78,7 +78,6 @@ class NotesViewCtrl extends PureViewCtrl { private searchKeyObserver: any private noteFlags: Partial> = {} private removeObservers: Array<() => void> = []; - private appStateObserver?: IReactionDisposer; /* @ngInject */ constructor($timeout: ng.ITimeoutService,) { @@ -95,7 +94,7 @@ class NotesViewCtrl extends PureViewCtrl { this.onPanelResize = this.onPanelResize.bind(this); window.addEventListener('resize', this.onWindowResize, true); this.registerKeyboardShortcuts(); - this.appStateObserver = autorun(async () => { + this.autorun(async () => { const { includeProtectedContents, includeArchived, @@ -113,6 +112,11 @@ class NotesViewCtrl extends PureViewCtrl { this.reloadNotes(); } }); + this.autorun(() => { + this.setState({ + selectedNotes: this.appState.notes.selectedNotes, + }); + }); } onWindowResize() { @@ -131,7 +135,6 @@ class NotesViewCtrl extends PureViewCtrl { this.nextNoteKeyObserver(); this.previousNoteKeyObserver(); this.searchKeyObserver(); - this.appStateObserver?.(); this.newNoteKeyObserver = undefined; this.nextNoteKeyObserver = undefined; this.previousNoteKeyObserver = undefined; @@ -139,15 +142,16 @@ class NotesViewCtrl extends PureViewCtrl { super.deinit(); } - async setNotesState(state: Partial) { + async setNotesState(state: Partial) { return this.setState(state); } - getInitialState(): NotesState { + getInitialState(): NotesCtrlState { return { notes: [], renderedNotes: [], renderedNotesTags: [], + selectedNotes: {}, mutable: { showMenu: false }, noteFilter: { text: '', @@ -180,9 +184,13 @@ class NotesViewCtrl extends PureViewCtrl { } } + private get activeEditorNote() { + return this.appState.notes.activeEditor?.note; + } + /** @template */ - public get activeEditorNote() { - return this.appState?.getActiveEditor()?.note; + public isNoteSelected(uuid: UuidString) { + return !!this.state.selectedNotes[uuid]; } public get editorNotes() { @@ -288,12 +296,8 @@ class NotesViewCtrl extends PureViewCtrl { )); } - async selectNote(note: SNNote) { - await this.appState.openEditor(note.uuid); - if (note.waitingForKey) { - this.application.presentKeyRecoveryWizard(); - } - this.reloadNotes(); + selectNote(note: SNNote): Promise { + return this.appState.notes.selectNote(note); } async createNewNote() { @@ -461,7 +465,7 @@ class NotesViewCtrl extends PureViewCtrl { } async reloadPreferences() { - const viewOptions = {} as NotesState; + const viewOptions = {} as NotesCtrlState; const prevSortValue = this.state.sortBy; let sortBy = this.application.getPreference( PrefKey.SortNotesBy, @@ -673,7 +677,7 @@ class NotesViewCtrl extends PureViewCtrl { selectNextNote() { const displayableNotes = this.state.notes; const currentIndex = displayableNotes.findIndex((candidate) => { - return candidate.uuid === this.activeEditorNote.uuid; + return candidate.uuid === this.activeEditorNote?.uuid; }); if (currentIndex + 1 < displayableNotes.length) { this.selectNote(displayableNotes[currentIndex + 1]); @@ -791,7 +795,7 @@ class NotesViewCtrl extends PureViewCtrl { * use Control modifier as well. These rules don't apply to desktop, but * probably better to be consistent. */ - this.newNoteKeyObserver = this.application.getKeyboardService().addKeyObserver({ + this.newNoteKeyObserver = this.application.io.addKeyObserver({ key: 'n', modifiers: [ KeyboardModifier.Meta, @@ -803,7 +807,7 @@ class NotesViewCtrl extends PureViewCtrl { } }); - this.nextNoteKeyObserver = this.application.getKeyboardService().addKeyObserver({ + this.nextNoteKeyObserver = this.application.io.addKeyObserver({ key: KeyboardKey.Down, elements: [ document.body, @@ -818,7 +822,7 @@ class NotesViewCtrl extends PureViewCtrl { } }); - this.previousNoteKeyObserver = this.application.getKeyboardService().addKeyObserver({ + this.previousNoteKeyObserver = this.application.io.addKeyObserver({ key: KeyboardKey.Up, element: document.body, onKeyDown: () => { @@ -826,7 +830,7 @@ class NotesViewCtrl extends PureViewCtrl { } }); - this.searchKeyObserver = this.application.getKeyboardService().addKeyObserver({ + this.searchKeyObserver = this.application.io.addKeyObserver({ key: "f", modifiers: [ KeyboardModifier.Meta, From abfc588368a71d7ee5fc753253d82d2ab5ee9ad4 Mon Sep 17 00:00:00 2001 From: Baptiste Grob <60621355+baptiste-grob@users.noreply.github.com> Date: Thu, 8 Apr 2021 11:30:56 +0200 Subject: [PATCH 02/78] feat: multiple selected notes panel --- app/assets/icons/ic-archive.svg | 4 + .../icons/{ic_close.svg => ic-close.svg} | 0 app/assets/icons/ic-more.svg | 3 + app/assets/icons/ic-pencil-off.svg | 3 + app/assets/icons/ic-pin-off.svg | 3 + app/assets/icons/ic-pin.svg | 3 + app/assets/icons/ic-text-rich.svg | 3 + app/assets/icons/ic-trash.svg | 3 + app/assets/icons/{ic_tune.svg => ic-tune.svg} | 0 app/assets/icons/ic-unarchive.svg | 3 + app/assets/icons/il-notes.svg | 35 ++++ app/assets/javascripts/app.ts | 4 +- .../components/MultipleSelectedNotes.tsx | 192 ++++++++++++++++++ .../components/NoAccountWarning.tsx | 13 +- .../javascripts/components/SearchOptions.tsx | 48 ++--- .../javascripts/components/SessionsModal.tsx | 13 +- app/assets/javascripts/components/Switch.tsx | 5 +- app/assets/javascripts/components/utils.ts | 37 +++- .../directives/views/accountMenu.ts | 10 +- .../directives/views/actionsMenu.ts | 8 +- app/assets/javascripts/strings.ts | 4 +- .../ui_models/app_state/app_state.ts | 3 +- .../ui_models/app_state/notes_state.ts | 125 +++++++++++- .../views/application/application-view.pug | 4 +- .../javascripts/views/editor/editor_view.ts | 2 +- .../views/editor_group/editor-group-view.pug | 17 +- .../views/editor_group/editor_group_view.ts | 20 +- .../javascripts/views/footer/footer_view.ts | 5 +- .../javascripts/views/notes/notes-view.pug | 2 +- .../javascripts/views/notes/notes_view.ts | 2 +- app/assets/stylesheets/_notes.scss | 1 - app/assets/stylesheets/_sn.scss | 76 +++++-- app/assets/stylesheets/_stylekit-sub.scss | 4 + app/assets/stylesheets/_ui.scss | 9 - package.json | 1 + yarn.lock | 5 + 36 files changed, 542 insertions(+), 128 deletions(-) create mode 100644 app/assets/icons/ic-archive.svg rename app/assets/icons/{ic_close.svg => ic-close.svg} (100%) create mode 100644 app/assets/icons/ic-more.svg create mode 100644 app/assets/icons/ic-pencil-off.svg create mode 100644 app/assets/icons/ic-pin-off.svg create mode 100644 app/assets/icons/ic-pin.svg create mode 100644 app/assets/icons/ic-text-rich.svg create mode 100644 app/assets/icons/ic-trash.svg rename app/assets/icons/{ic_tune.svg => ic-tune.svg} (100%) create mode 100644 app/assets/icons/ic-unarchive.svg create mode 100644 app/assets/icons/il-notes.svg create mode 100644 app/assets/javascripts/components/MultipleSelectedNotes.tsx diff --git a/app/assets/icons/ic-archive.svg b/app/assets/icons/ic-archive.svg new file mode 100644 index 000000000..baebb7932 --- /dev/null +++ b/app/assets/icons/ic-archive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/icons/ic_close.svg b/app/assets/icons/ic-close.svg similarity index 100% rename from app/assets/icons/ic_close.svg rename to app/assets/icons/ic-close.svg diff --git a/app/assets/icons/ic-more.svg b/app/assets/icons/ic-more.svg new file mode 100644 index 000000000..7512c412f --- /dev/null +++ b/app/assets/icons/ic-more.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-pencil-off.svg b/app/assets/icons/ic-pencil-off.svg new file mode 100644 index 000000000..2ebdc93de --- /dev/null +++ b/app/assets/icons/ic-pencil-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-pin-off.svg b/app/assets/icons/ic-pin-off.svg new file mode 100644 index 000000000..aff807dae --- /dev/null +++ b/app/assets/icons/ic-pin-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-pin.svg b/app/assets/icons/ic-pin.svg new file mode 100644 index 000000000..c19d600d8 --- /dev/null +++ b/app/assets/icons/ic-pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-text-rich.svg b/app/assets/icons/ic-text-rich.svg new file mode 100644 index 000000000..4fcb62677 --- /dev/null +++ b/app/assets/icons/ic-text-rich.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-trash.svg b/app/assets/icons/ic-trash.svg new file mode 100644 index 000000000..4bbb76144 --- /dev/null +++ b/app/assets/icons/ic-trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic_tune.svg b/app/assets/icons/ic-tune.svg similarity index 100% rename from app/assets/icons/ic_tune.svg rename to app/assets/icons/ic-tune.svg diff --git a/app/assets/icons/ic-unarchive.svg b/app/assets/icons/ic-unarchive.svg new file mode 100644 index 000000000..0506ec471 --- /dev/null +++ b/app/assets/icons/ic-unarchive.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/il-notes.svg b/app/assets/icons/il-notes.svg new file mode 100644 index 000000000..2d9cfc0bc --- /dev/null +++ b/app/assets/icons/il-notes.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index a2bcbd446..1db921aef 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -59,6 +59,7 @@ import { SessionsModalDirective } from './components/SessionsModal'; import { NoAccountWarningDirective } from './components/NoAccountWarning'; import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNoteWarning'; import { SearchOptionsDirective } from './components/SearchOptions'; +import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; function reloadHiddenFirefoxTab(): boolean { /** @@ -147,7 +148,8 @@ const startApplication: StartApplication = async function startApplication( .directive('sessionsModal', SessionsModalDirective) .directive('noAccountWarning', NoAccountWarningDirective) .directive('protectedNotePanel', NoProtectionsdNoteWarningDirective) - .directive('searchOptions', SearchOptionsDirective); + .directive('searchOptions', SearchOptionsDirective) + .directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective); // Filters angular.module('app').filter('trusted', ['$sce', trusted]); diff --git a/app/assets/javascripts/components/MultipleSelectedNotes.tsx b/app/assets/javascripts/components/MultipleSelectedNotes.tsx new file mode 100644 index 000000000..d28da4a1d --- /dev/null +++ b/app/assets/javascripts/components/MultipleSelectedNotes.tsx @@ -0,0 +1,192 @@ +import { AppState } from '@/ui_models/app_state'; +import VisuallyHidden from '@reach/visually-hidden'; +import { toDirective, useCloseOnBlur } from './utils'; +import { + Disclosure, + DisclosureButton, + DisclosurePanel, +} from '@reach/disclosure'; +import MoreIcon from '../../icons/ic-more.svg'; +import PencilOffIcon from '../../icons/ic-pencil-off.svg'; +import RichTextIcon from '../../icons/ic-text-rich.svg'; +import TrashIcon from '../../icons/ic-trash.svg'; +import PinIcon from '../../icons/ic-pin.svg'; +import UnpinIcon from '../../icons/ic-pin-off.svg'; +import ArchiveIcon from '../../icons/ic-archive.svg'; +import UnarchiveIcon from '../../icons/ic-unarchive.svg'; +import NotesIcon from '../../icons/il-notes.svg'; +import { useRef, useState } from 'preact/hooks'; +import { Switch } from './Switch'; +import { observer } from 'mobx-react-lite'; +import { SNApplication } from '@standardnotes/snjs'; + +type Props = { + application: SNApplication; + appState: AppState; +}; + +const MultipleSelectedNotes = observer(({ appState }: Props) => { + const count = appState.notes.selectedNotesCount; + const [open, setOpen] = useState(false); + const [optionsPanelPosition, setOptionsPanelPosition] = useState({ + top: 0, + right: 0, + }); + const buttonRef = useRef(); + const panelRef = useRef(); + const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen); + + const notes = Object.values(appState.notes.selectedNotes); + const hidePreviews = !notes.some((note) => !note.hidePreview); + const locked = !notes.some((note) => !note.locked); + const archived = !notes.some((note) => !note.archived); + const trashed = !notes.some((note) => !note.trashed); + const pinned = !notes.some((note) => !note.pinned); + + const iconClass = 'fill-current color-neutral mr-2.5'; + const buttonClass = + 'flex items-center border-0 capitalize focus:inner-ring-info ' + + 'cursor-pointer hover:bg-contrast color-text bg-transparent h-10 px-3 ' + + 'text-left'; + + return ( +

+
+

{count} selected notes

+ { + const rect = buttonRef.current.getBoundingClientRect(); + setOptionsPanelPosition({ + top: rect.bottom, + right: document.body.clientWidth - rect.right, + }); + setOpen((prevOpen) => !prevOpen); + }} + > + { + if (event.key === 'Escape') { + setOpen(false); + } + }} + onBlur={closeOnBlur} + ref={buttonRef} + className={ + 'bg-transparent border-solid border-1 border-gray-300 ' + + 'cursor-pointer w-32px h-32px rounded-full p-0 ' + + 'flex justify-center items-center' + } + > + Actions + + + { + if (event.key === 'Escape') { + setOpen(false); + buttonRef.current.focus(); + } + }} + ref={panelRef} + style={{ + ...optionsPanelPosition, + }} + className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 select-none" + > + { + appState.notes.setLockSelectedNotes(!locked); + }} + > + + + Prevent editing + + + { + appState.notes.setHideSelectedNotePreviews(!hidePreviews); + }} + > + + + Show Preview + + +
+ + + +
+
+
+
+ +

+ {count} selected notes +

+

+ Actions will be performed on all selected notes. +

+
+
+ ); +}); + +export const MultipleSelectedNotesDirective = toDirective( + MultipleSelectedNotes +); diff --git a/app/assets/javascripts/components/NoAccountWarning.tsx b/app/assets/javascripts/components/NoAccountWarning.tsx index 680393604..0b24927e7 100644 --- a/app/assets/javascripts/components/NoAccountWarning.tsx +++ b/app/assets/javascripts/components/NoAccountWarning.tsx @@ -1,13 +1,12 @@ -import { toDirective, useAutorunValue } from './utils'; -import Close from '../../icons/ic_close.svg'; +import { toDirective } from './utils'; +import Close from '../../icons/ic-close.svg'; import { AppState } from '@/ui_models/app_state'; +import { observer } from 'mobx-react-lite'; type Props = { appState: AppState }; -function NoAccountWarning({ appState }: Props) { - const canShow = useAutorunValue(() => appState.noAccountWarning.show, [ - appState, - ]); +const NoAccountWarning = observer(({ appState }: Props) => { + const canShow = appState.noAccountWarning.show; if (!canShow) { return null; } @@ -39,6 +38,6 @@ function NoAccountWarning({ appState }: Props) { ); -} +}); export const NoAccountWarningDirective = toDirective(NoAccountWarning); diff --git a/app/assets/javascripts/components/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index 2c24e6c44..91d7f6409 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -1,5 +1,5 @@ import { AppState } from '@/ui_models/app_state'; -import { toDirective, useAutorunValue } from './utils'; +import { toDirective, useCloseOnBlur } from './utils'; import { useRef, useState } from 'preact/hooks'; import { WebApplication } from '@/ui_models/application'; import VisuallyHidden from '@reach/visually-hidden'; @@ -10,54 +10,35 @@ import { } from '@reach/disclosure'; import { FocusEvent } from 'react'; import { Switch } from './Switch'; -import TuneIcon from '../../icons/ic_tune.svg'; +import TuneIcon from '../../icons/ic-tune.svg'; +import { observer } from 'mobx-react-lite'; type Props = { appState: AppState; application: WebApplication; }; -function SearchOptions({ appState }: Props) { +const SearchOptions = observer(({ appState }: Props) => { const { searchOptions } = appState; const { includeProtectedContents, includeArchived, includeTrashed, - } = useAutorunValue( - () => ({ - includeProtectedContents: searchOptions.includeProtectedContents, - includeArchived: searchOptions.includeArchived, - includeTrashed: searchOptions.includeTrashed, - }), - [searchOptions] - ); - - const [ - togglingIncludeProtectedContents, - setTogglingIncludeProtectedContents, - ] = useState(false); - - async function toggleIncludeProtectedContents() { - setTogglingIncludeProtectedContents(true); - try { - await searchOptions.toggleIncludeProtectedContents(); - } finally { - setTogglingIncludeProtectedContents(false); - } - } + } = searchOptions; const [open, setOpen] = useState(false); const [optionsPanelTop, setOptionsPanelTop] = useState(0); const buttonRef = useRef(); const panelRef = useRef(); + const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen); - function closeOnBlur(event: FocusEvent) { - if ( - !togglingIncludeProtectedContents && - !panelRef.current.contains(event.relatedTarget as Node) - ) { - setOpen(false); + async function toggleIncludeProtectedContents() { + setLockCloseOnBlur(true); + try { + await searchOptions.toggleIncludeProtectedContents(); + } finally { + setLockCloseOnBlur(false); } } @@ -86,6 +67,7 @@ function SearchOptions({ appState }: Props) { className="sn-dropdown sn-dropdown-anchor-right grid gap-2 py-2" > Include protected contents

Include archived notes

); -} +}); export const SearchOptionsDirective = toDirective(SearchOptions); diff --git a/app/assets/javascripts/components/SessionsModal.tsx b/app/assets/javascripts/components/SessionsModal.tsx index b8c82daf4..facaa3dc4 100644 --- a/app/assets/javascripts/components/SessionsModal.tsx +++ b/app/assets/javascripts/components/SessionsModal.tsx @@ -15,8 +15,9 @@ import { AlertDialogDescription, AlertDialogLabel, } from '@reach/alert-dialog'; -import { toDirective, useAutorunValue } from './utils'; +import { toDirective } from './utils'; import { WebApplication } from '@/ui_models/application'; +import { observer } from 'mobx-react-lite'; type Session = RemoteSession & { revoking?: true; @@ -242,16 +243,12 @@ const SessionsModal: FunctionComponent<{ const Sessions: FunctionComponent<{ appState: AppState; application: WebApplication; -}> = ({ appState, application }) => { - const showModal = useAutorunValue(() => appState.isSessionsModalVisible, [ - appState, - ]); - - if (showModal) { +}> = observer(({ appState, application }) => { + if (appState.isSessionsModalVisible) { return ; } else { return null; } -}; +}); export const SessionsModalDirective = toDirective(Sessions); diff --git a/app/assets/javascripts/components/Switch.tsx b/app/assets/javascripts/components/Switch.tsx index 44dbf7dc5..1f4185021 100644 --- a/app/assets/javascripts/components/Switch.tsx +++ b/app/assets/javascripts/components/Switch.tsx @@ -11,6 +11,7 @@ import '@reach/checkbox/styles.css'; export type SwitchProps = HTMLProps & { checked?: boolean; onChange: (checked: boolean) => void; + className?: string; children: ComponentChildren; }; @@ -19,8 +20,9 @@ export const Switch: FunctionalComponent = ( ) => { const [checkedState, setChecked] = useState(props.checked || false); const checked = props.checked ?? checkedState; + const className = props.className ?? ''; return ( -