Remove dummy concept in favor of editor group and editors
This commit is contained in:
138
app/assets/javascripts/views/abstract/pure_view_ctrl.ts
Normal file
138
app/assets/javascripts/views/abstract/pure_view_ctrl.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ApplicationEvent } from 'snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
export type CtrlState = Partial<Record<string, any>>
|
||||
export type CtrlProps = Partial<Record<string, any>>
|
||||
|
||||
export class PureViewCtrl {
|
||||
$timeout: ng.ITimeoutService
|
||||
/** Passed through templates */
|
||||
application?: WebApplication
|
||||
props: CtrlProps = {}
|
||||
state: CtrlState = {}
|
||||
private unsubApp: any
|
||||
private unsubState: any
|
||||
private stateTimeout: any
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
this.$timeout = $timeout;
|
||||
/* Allow caller constructor to finish setting instance variables */
|
||||
setImmediate(() => {
|
||||
this.state = this.getInitialState();
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.addAppEventObserver();
|
||||
this.addAppStateObserver();
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.unsubApp();
|
||||
this.unsubState();
|
||||
this.unsubApp = undefined;
|
||||
this.unsubState = undefined;
|
||||
this.application = undefined;
|
||||
if (this.stateTimeout) {
|
||||
this.$timeout.cancel(this.stateTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.deinit();
|
||||
}
|
||||
|
||||
public get appState() {
|
||||
return this.application!.getAppState();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async resetState() {
|
||||
this.state = this.getInitialState();
|
||||
await this.setState(this.state);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setState(state: CtrlState) {
|
||||
if (!this.$timeout) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.stateTimeout = this.$timeout(() => {
|
||||
this.state = Object.freeze(Object.assign({}, this.state, state));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateUI(func: () => void) {
|
||||
this.$timeout(func);
|
||||
}
|
||||
|
||||
initProps(props: CtrlProps) {
|
||||
if (Object.keys(this.props).length > 0) {
|
||||
throw 'Already init-ed props.';
|
||||
}
|
||||
this.props = Object.freeze(Object.assign({}, this.props, props));
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.unsubState = this.application!.getAppState().addObserver(
|
||||
async (eventName, data) => {
|
||||
this.onAppStateEvent(eventName, data);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onAppStateEvent(eventName: any, data: any) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
addAppEventObserver() {
|
||||
if (this.application!.isStarted()) {
|
||||
this.onAppStart();
|
||||
}
|
||||
if (this.application!.isLaunched()) {
|
||||
this.onAppLaunch();
|
||||
}
|
||||
this.unsubApp = this.application!.addEventObserver(async (eventName) => {
|
||||
this.onAppEvent(eventName);
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
await this.onAppStart();
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
await this.onAppLaunch();
|
||||
} else if (eventName === ApplicationEvent.CompletedSync) {
|
||||
this.onAppSync();
|
||||
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.onAppKeyChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppStart() {
|
||||
await this.resetState();
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppKeyChange() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.main-ui-view(
|
||||
ng-class='self.platformString'
|
||||
)
|
||||
#app.app(
|
||||
ng-class='self.state.appClass',
|
||||
ng-if='!self.state.needsUnlock && self.state.ready'
|
||||
)
|
||||
tags-view(application='self.application')
|
||||
notes-view(application='self.application')
|
||||
editor-group-view(
|
||||
application='self.application'
|
||||
)
|
||||
|
||||
footer-view(
|
||||
ng-if='!self.state.needsUnlock && self.state.ready'
|
||||
application='self.application'
|
||||
)
|
||||
318
app/assets/javascripts/views/application/application_view.ts
Normal file
318
app/assets/javascripts/views/application/application_view.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { WebDirective, PermissionsModalScope, ModalComponentScope } from '@/types';
|
||||
import { getPlatformString } from '@/utils';
|
||||
import template from './application-view.pug';
|
||||
import { AppStateEvent } from '@/services/state';
|
||||
import { ApplicationEvent, SNComponent } from 'snjs';
|
||||
import angular from 'angular';
|
||||
import {
|
||||
PANEL_NAME_NOTES,
|
||||
PANEL_NAME_TAGS
|
||||
} from '@/views/constants';
|
||||
import {
|
||||
STRING_SESSION_EXPIRED,
|
||||
STRING_DEFAULT_FILE_ERROR
|
||||
} from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { PermissionDialog } from '@/../../../../snjs/dist/@types/services/component_manager';
|
||||
|
||||
class ApplicationViewCtrl extends PureViewCtrl {
|
||||
private $compile?: ng.ICompileService
|
||||
private $location?: ng.ILocationService
|
||||
private $rootScope?: ng.IRootScopeService
|
||||
public platformString: string
|
||||
private completedInitialSync = false
|
||||
private syncStatus: any
|
||||
private notesCollapsed = false
|
||||
private tagsCollapsed = false
|
||||
private showingDownloadStatus = false
|
||||
private uploadSyncStatus: any
|
||||
private lastAlertShownDate?: Date
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$compile: ng.ICompileService,
|
||||
$location: ng.ILocationService,
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
this.$location = $location;
|
||||
this.$rootScope = $rootScope;
|
||||
this.$compile = $compile;
|
||||
this.platformString = getPlatformString();
|
||||
this.state = { appClass: '' };
|
||||
this.onDragDrop = this.onDragDrop.bind(this);
|
||||
this.onDragOver = this.onDragOver.bind(this);
|
||||
this.openModalComponent = this.openModalComponent.bind(this);
|
||||
this.presentPermissionsDialog = this.presentPermissionsDialog.bind(this);
|
||||
this.addDragDropHandlers();
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.$location = undefined;
|
||||
this.$rootScope = undefined;
|
||||
this.$compile = undefined;
|
||||
this.application = undefined;
|
||||
window.removeEventListener('dragover', this.onDragOver, true);
|
||||
window.removeEventListener('drop', this.onDragDrop, true);
|
||||
(this.onDragDrop as any) = undefined;
|
||||
(this.onDragOver as any) = undefined;
|
||||
(this.openModalComponent as any) = undefined;
|
||||
(this.presentPermissionsDialog as any) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.loadApplication();
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
await this.application!.prepareForLaunch({
|
||||
receiveChallenge: async (challenge, orchestrator) => {
|
||||
this.application!.promptForChallenge(challenge, orchestrator);
|
||||
}
|
||||
});
|
||||
await this.application!.launch();
|
||||
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
this.overrideComponentManagerFunctions();
|
||||
this.application!.componentManager!.setDesktopManager(
|
||||
this.application!.getDesktopService()
|
||||
);
|
||||
this.setState({
|
||||
ready: true,
|
||||
needsUnlock: this.application!.hasPasscode()
|
||||
});
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.setState({ needsUnlock: false });
|
||||
this.handleAutoSignInFromParams();
|
||||
}
|
||||
|
||||
onUpdateAvailable() {
|
||||
this.$rootScope!.$broadcast('new-update-available');
|
||||
};
|
||||
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName);
|
||||
if (eventName === ApplicationEvent.LocalDataIncrementalLoad) {
|
||||
this.updateLocalDataStatus();
|
||||
} else if (
|
||||
eventName === ApplicationEvent.SyncStatusChanged ||
|
||||
eventName === ApplicationEvent.FailedSync
|
||||
) {
|
||||
this.updateSyncStatus();
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.updateLocalDataStatus();
|
||||
} else if (eventName === ApplicationEvent.WillSync) {
|
||||
if (!this.completedInitialSync) {
|
||||
this.syncStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
"Syncing..."
|
||||
);
|
||||
}
|
||||
} else if (eventName === ApplicationEvent.CompletedSync) {
|
||||
if (!this.completedInitialSync) {
|
||||
this.syncStatus = this.application!.getStatusService().removeStatus(this.syncStatus);
|
||||
this.completedInitialSync = true;
|
||||
}
|
||||
} else if (eventName === ApplicationEvent.InvalidSyncSession) {
|
||||
this.showInvalidSessionAlert();
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseReadError) {
|
||||
this.application!.alertService!.alert(
|
||||
'Unable to load local database. Please restart the app and try again.'
|
||||
);
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
|
||||
this.application!.alertService!.alert(
|
||||
'Unable to write to local database. Please restart the app and try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppStateEvent(eventName: AppStateEvent, data?: any) {
|
||||
if (eventName === AppStateEvent.PanelResized) {
|
||||
if (data.panel === PANEL_NAME_NOTES) {
|
||||
this.notesCollapsed = data.collapsed;
|
||||
}
|
||||
if (data.panel === PANEL_NAME_TAGS) {
|
||||
this.tagsCollapsed = data.collapsed;
|
||||
}
|
||||
let appClass = "";
|
||||
if (this.notesCollapsed) { appClass += "collapsed-notes"; }
|
||||
if (this.tagsCollapsed) { appClass += " collapsed-tags"; }
|
||||
this.setState({ appClass });
|
||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
||||
if (!(await this.application!.isLocked())) {
|
||||
this.application!.sync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const syncStatus = this.application!.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
const encryption = this.application!.isEncryptionAvailable();
|
||||
if (stats.localDataDone) {
|
||||
this.syncStatus = this.application!.getStatusService().removeStatus(this.syncStatus);
|
||||
return;
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`;
|
||||
const loadingStatus = encryption
|
||||
? `Decrypting ${notesString}`
|
||||
: `Loading ${notesString}`;
|
||||
this.syncStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
loadingStatus
|
||||
);
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const syncStatus = this.application!.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
if (syncStatus.hasError()) {
|
||||
this.syncStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
'Unable to Sync'
|
||||
);
|
||||
} else if (stats.downloadCount > 20) {
|
||||
const text = `Downloading ${stats.downloadCount} items. Keep app open.`;
|
||||
this.syncStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
text
|
||||
);
|
||||
this.showingDownloadStatus = true;
|
||||
} else if (this.showingDownloadStatus) {
|
||||
this.showingDownloadStatus = false;
|
||||
const text = "Download Complete.";
|
||||
this.syncStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
text
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.syncStatus = this.application!.getStatusService().removeStatus(this.syncStatus);
|
||||
}, 2000);
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
this.uploadSyncStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.uploadSyncStatus,
|
||||
`Syncing ${stats.uploadCompletionCount}/${stats.uploadTotalCount} items...`
|
||||
);
|
||||
} else if (this.uploadSyncStatus) {
|
||||
this.uploadSyncStatus = this.application!.getStatusService().removeStatus(
|
||||
this.uploadSyncStatus
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
openModalComponent(component: SNComponent) {
|
||||
const scope = this.$rootScope!.$new(true) as ModalComponentScope;
|
||||
scope.component = component;
|
||||
const el = this.$compile!(
|
||||
"<component-modal component='component' class='sk-modal'></component-modal>"
|
||||
)(scope as any);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
|
||||
presentPermissionsDialog(dialog: PermissionDialog) {
|
||||
const scope = this.$rootScope!.$new(true) as PermissionsModalScope;
|
||||
scope.permissionsString = dialog.permissionsString;
|
||||
scope.component = dialog.component;
|
||||
scope.callback = dialog.callback;
|
||||
const el = this.$compile!(
|
||||
"<permissions-modal component='component' permissions-string='permissionsString'"
|
||||
+ " callback='callback' class='sk-modal'></permissions-modal>"
|
||||
)(scope as any);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
|
||||
overrideComponentManagerFunctions() {
|
||||
this.application!.componentManager!.openModalComponent = this.openModalComponent;
|
||||
this.application!.componentManager!.presentPermissionsDialog = this.presentPermissionsDialog;
|
||||
}
|
||||
|
||||
showInvalidSessionAlert() {
|
||||
/** Don't show repeatedly; at most 30 seconds in between */
|
||||
const SHOW_INTERVAL = 30;
|
||||
if (
|
||||
!this.lastAlertShownDate ||
|
||||
(new Date().getTime() - this.lastAlertShownDate!.getTime()) / 1000 > SHOW_INTERVAL
|
||||
) {
|
||||
this.lastAlertShownDate = new Date();
|
||||
setTimeout(() => {
|
||||
this.application!.alertService!.alert(
|
||||
STRING_SESSION_EXPIRED
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
addDragDropHandlers() {
|
||||
/**
|
||||
* Disable dragging and dropping of files (but allow text) into main SN interface.
|
||||
* both 'dragover' and 'drop' are required to prevent dropping of files.
|
||||
* This will not prevent extensions from receiving drop events.
|
||||
*/
|
||||
window.addEventListener('dragover', this.onDragOver, true);
|
||||
window.addEventListener('drop', this.onDragDrop, true);
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer!.files.length > 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onDragDrop(event: DragEvent) {
|
||||
if (event.dataTransfer!.files.length > 0) {
|
||||
event.preventDefault();
|
||||
this.application!.alertService!.alert(
|
||||
STRING_DEFAULT_FILE_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async handleAutoSignInFromParams() {
|
||||
const params = this.$location!.search();
|
||||
const server = params.server;
|
||||
const email = params.email;
|
||||
const password = params.pw;
|
||||
if (!server || !email || !password) return;
|
||||
|
||||
const user = this.application!.getUser();
|
||||
if (user) {
|
||||
if (user.email === email && await this.application!.getHost() === server) {
|
||||
/** Already signed in, return */
|
||||
return;
|
||||
} else {
|
||||
/** Sign out */
|
||||
await this.application!.signOut();
|
||||
}
|
||||
}
|
||||
await this.application!.setHost(server);
|
||||
this.application!.signIn(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = ApplicationViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
application-view(
|
||||
ng-repeat='application in self.applications',
|
||||
application='application'
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebDirective } from '@/types';
|
||||
import template from './application-group-view.pug';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
class ApplicationGroupViewCtrl {
|
||||
|
||||
private $timeout: ng.ITimeoutService
|
||||
private applicationGroup: ApplicationGroup
|
||||
public applications: WebApplication[] = []
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
this.applicationGroup = mainApplicationGroup;
|
||||
this.applicationGroup.addApplicationChangeObserver(() => {
|
||||
this.reload();
|
||||
});
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.$timeout(() => {
|
||||
this.applications = this.applicationGroup.getApplications();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationGroupView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = ApplicationGroupViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
2
app/assets/javascripts/views/constants.ts
Normal file
2
app/assets/javascripts/views/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const PANEL_NAME_NOTES = 'notes';
|
||||
export const PANEL_NAME_TAGS = 'tags';
|
||||
259
app/assets/javascripts/views/editor/editor-view.pug
Normal file
259
app/assets/javascripts/views/editor/editor-view.pug
Normal file
@@ -0,0 +1,259 @@
|
||||
#editor-column.section.editor.sn-component(aria-label='Note')
|
||||
.sn-component
|
||||
.sk-app-bar.no-edges(
|
||||
ng-if='self.noteLocked',
|
||||
ng-init="self.lockText = 'Note Locked'",
|
||||
ng-mouseleave="self.lockText = 'Note Locked'",
|
||||
ng-mouseover="self.lockText = 'Unlock'"
|
||||
)
|
||||
.left
|
||||
.sk-app-bar-item(ng-click='self.toggleLockNote()')
|
||||
.sk-label.warning
|
||||
i.icon.ion-locked
|
||||
| {{self.lockText}}
|
||||
#editor-title-bar.section-title-bar(
|
||||
ng-class="{'locked' : self.noteLocked}",
|
||||
ng-show='self.note && !self.note.errorDecrypting'
|
||||
)
|
||||
.title
|
||||
input#note-title-editor.input(
|
||||
ng-blur='self.onTitleBlur()',
|
||||
ng-change='self.onTitleChange()',
|
||||
ng-disabled='self.noteLocked',
|
||||
ng-focus='self.onTitleFocus()',
|
||||
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
|
||||
ng-model='self.editorValues.title',
|
||||
select-on-click='true',
|
||||
spellcheck='false')
|
||||
#save-status
|
||||
.message(
|
||||
ng-class="{'warning sk-bold': self.state.syncTakingTooLong, 'danger sk-bold': self.state.saveError}"
|
||||
) {{self.state.noteStatus.message}}
|
||||
.desc(ng-show='self.state.noteStatus.desc') {{self.state.noteStatus.desc}}
|
||||
.editor-tags
|
||||
#note-tags-component-container(ng-if='self.state.tagsComponent')
|
||||
component-view.component-view(
|
||||
component='self.state.tagsComponent',
|
||||
ng-class="{'locked' : self.noteLocked}",
|
||||
ng-style="self.noteLocked && {'pointer-events' : 'none'}",
|
||||
application='self.application'
|
||||
)
|
||||
input.tags-input(
|
||||
ng-blur='self.saveTagsFromStrings()',
|
||||
ng-disabled='self.noteLocked',
|
||||
ng-if='!(self.state.tagsComponent && self.state.tagsComponent.active)',
|
||||
ng-keyup='$event.keyCode == 13 && $event.target.blur();',
|
||||
ng-model='self.editorValues.tagsInputValue',
|
||||
placeholder='#tags',
|
||||
spellcheck='false',
|
||||
type='text'
|
||||
)
|
||||
.sn-component(ng-if='self.note')
|
||||
#editor-menu-bar.sk-app-bar.no-edges
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showOptionsMenu', false)`,
|
||||
is-open='self.state.showOptionsMenu',
|
||||
ng-class="{'selected' : self.state.showOptionsMenu}",
|
||||
ng-click="self.toggleMenu('showOptionsMenu')"
|
||||
)
|
||||
.sk-label Options
|
||||
.sk-menu-panel.dropdown-menu(ng-if='self.state.showOptionsMenu')
|
||||
.sk-menu-panel-section
|
||||
.sk-menu-panel-header
|
||||
.sk-menu-panel-header-title Note Options
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.togglePin()',
|
||||
desc="'Pin or unpin a note from the top of your list'",
|
||||
label="self.note.pinned ? 'Unpin' : 'Pin'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.toggleArchiveNote()',
|
||||
desc="'Archive or unarchive a note from your Archived system tag'",
|
||||
label="self.note.archived ? 'Unarchive' : 'Archive'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.toggleLockNote()',
|
||||
desc="'Locking notes prevents unintentional editing'",
|
||||
label="self.noteLocked ? 'Unlock' : 'Lock'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.toggleProtectNote()',
|
||||
desc=`'Protecting a note will require credentials to view
|
||||
it (Manage Privileges via Account menu)'`,
|
||||
label="self.note.protected ? 'Unprotect' : 'Protect'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.toggleNotePreview()',
|
||||
circle="self.note.hidePreview ? 'danger' : 'success'",
|
||||
circle-align="'right'",
|
||||
desc="'Hide or unhide the note preview from the list of notes'",
|
||||
label="'Preview'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(); self.deleteNote()',
|
||||
desc="'Send this note to the trash'",
|
||||
label="'Move to Trash'",
|
||||
ng-show='!self.state.altKeyDown && !self.note.trashed && !self.note.errorDecrypting',
|
||||
stylekit-class="'warning'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(); self.deleteNotePermanantely()',
|
||||
desc="'Delete this note permanently from all your devices'",
|
||||
label="'Delete Permanently'",
|
||||
ng-show='!self.note.trashed && self.note.errorDecrypting',
|
||||
stylekit-class="'danger'"
|
||||
)
|
||||
div(ng-if='self.note.trashed || self.state.altKeyDown')
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.restoreTrashedNote()',
|
||||
desc="'Undelete this note and restore it back into your notes'",
|
||||
label="'Restore'",
|
||||
ng-show='self.note.trashed',
|
||||
stylekit-class="'info'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.deleteNotePermanantely()',
|
||||
desc="'Delete this note permanently from all your devices'",
|
||||
label="'Delete Permanently'",
|
||||
stylekit-class="'danger'"
|
||||
)
|
||||
menu-row(
|
||||
action='self.selectedMenuItem(true); self.emptyTrash()',
|
||||
desc="'Permanently delete all notes in the trash'",
|
||||
label="'Empty Trash'",
|
||||
ng-show='self.note.trashed || !self.state.altKeyDown',
|
||||
stylekit-class="'danger'",
|
||||
subtitle="self.getTrashCount() + ' notes in trash'"
|
||||
)
|
||||
.sk-menu-panel-section
|
||||
.sk-menu-panel-header
|
||||
.sk-menu-panel-header-title Global Display
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(true); self.toggleWebPrefKey(self.prefKeyMonospace)",
|
||||
circle="self.state.monospaceEnabled ? 'success' : 'neutral'",
|
||||
desc="'Toggles the font style for the default editor'",
|
||||
disabled='self.state.selectedEditor',
|
||||
label="'Monospace Font'",
|
||||
subtitle="self.state.selectedEditor ? 'Not available with editor extensions' : null"
|
||||
)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(true); self.toggleWebPrefKey(self.prefKeySpellcheck)",
|
||||
circle="self.state.spellcheck ? 'success' : 'neutral'",
|
||||
desc="'Toggles spellcheck for the default editor'",
|
||||
disabled='self.state.selectedEditor',
|
||||
label="'Spellcheck'",
|
||||
subtitle=`
|
||||
self.state.selectedEditor
|
||||
? 'Not available with editor extensions'
|
||||
: (self.state.isDesktop ? 'May degrade editor performance' : null)
|
||||
`)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(true); self.toggleWebPrefKey(self.prefKeyMarginResizers)",
|
||||
circle="self.state.marginResizersEnabled ? 'success' : 'neutral'",
|
||||
desc="'Allows for editor left and right margins to be resized'",
|
||||
faded='!self.state.marginResizersEnabled',
|
||||
label="'Margin Resizers'"
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showEditorMenu', false)`
|
||||
is-open='self.state.showEditorMenu',
|
||||
ng-class="{'selected' : self.state.showEditorMenu}",
|
||||
ng-click="self.toggleMenu('showEditorMenu')"
|
||||
)
|
||||
.sk-label Editor
|
||||
editor-menu(
|
||||
callback='self.editorMenuOnSelect()',
|
||||
current-item='self.note',
|
||||
ng-if='self.state.showEditorMenu',
|
||||
selected-editor='self.state.selectedEditor',
|
||||
application='self.application'
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showExtensions', false)`,
|
||||
is-open='self.state.showExtensions',
|
||||
ng-class="{'selected' : self.state.showExtensions}",
|
||||
ng-click="self.toggleMenu('showExtensions')"
|
||||
)
|
||||
.sk-label Actions
|
||||
actions-menu(
|
||||
item='self.note',
|
||||
ng-if='self.state.showExtensions',
|
||||
application='self.application'
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
click-outside=`self.setMenuState('showSessionHistory', false)`,
|
||||
is-open='self.state.showSessionHistory',
|
||||
ng-click="self.toggleMenu('showSessionHistory')"
|
||||
)
|
||||
.sk-label Session History
|
||||
session-history-menu(
|
||||
item='self.note',
|
||||
ng-if='self.state.showSessionHistory',
|
||||
application='self.application'
|
||||
)
|
||||
#editor-content.editor-content(
|
||||
ng-if='self.state.noteReady && !self.note.errorDecrypting'
|
||||
)
|
||||
panel-resizer.left(
|
||||
control='self.leftPanelPuppet',
|
||||
hoverable='true',
|
||||
min-width='300',
|
||||
ng-if='self.state.marginResizersEnabled',
|
||||
on-resize-finish='self.onPanelResizeFinish()',
|
||||
panel-id="'editor-content'",
|
||||
property="'left'"
|
||||
)
|
||||
component-view.component-view(
|
||||
component='self.state.selectedEditor',
|
||||
ng-if='self.state.selectedEditor',
|
||||
on-load='self.onEditorLoad',
|
||||
application='self.application'
|
||||
)
|
||||
textarea#note-text-editor.editable(
|
||||
dir='auto',
|
||||
ng-attr-spellcheck='{{self.state.spellcheck}}',
|
||||
ng-change='self.contentChanged()',
|
||||
ng-click='self.clickedTextArea()',
|
||||
ng-focus='self.onContentFocus()',
|
||||
ng-if='!self.state.selectedEditor',
|
||||
ng-model='self.editorValues.text',
|
||||
ng-model-options='{ debounce: self.state.editorDebounce}',
|
||||
ng-readonly='self.noteLocked',
|
||||
ng-trim='false'
|
||||
)
|
||||
| {{self.onSystemEditorLoad()}}
|
||||
panel-resizer(
|
||||
control='self.rightPanelPuppet',
|
||||
hoverable='true', min-width='300',
|
||||
ng-if='self.state.marginResizersEnabled',
|
||||
on-resize-finish='self.onPanelResizeFinish()',
|
||||
panel-id="'editor-content'",
|
||||
property="'right'"
|
||||
)
|
||||
.section(ng-show='self.note.errorDecrypting')
|
||||
p.medium-padding(style='padding-top: 0 !important;')
|
||||
| There was an error decrypting this item. Ensure you are running the
|
||||
| latest version of this app, then sign out and sign back in to try again.
|
||||
#editor-pane-component-stack(ng-show='self.note')
|
||||
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.componentStack.length')
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
ng-click='self.toggleStackComponentForCurrentItem(component)',
|
||||
ng-repeat='component in self.state.componentStack track by component.uuid'
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-circle.small(
|
||||
ng-class="{'info' : !component.hidden && component.active, 'neutral' : component.hidden || !component.active}"
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-label {{component.name}}
|
||||
.sn-component
|
||||
component-view.component-view.component-stack-item(
|
||||
component='component',
|
||||
manual-dealloc='true',
|
||||
ng-if='component.active',
|
||||
ng-repeat='component in self.state.componentStack track by component.uuid',
|
||||
ng-show='!component.hidden',
|
||||
application='self.application'
|
||||
)
|
||||
1243
app/assets/javascripts/views/editor/editor_view.ts
Normal file
1243
app/assets/javascripts/views/editor/editor_view.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
editor-view(
|
||||
ng-repeat='editor in self.editors'
|
||||
application='self.application'
|
||||
editor='editor'
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from './editor-group-view.pug';
|
||||
import { Editor } from '@/ui_models/editor';
|
||||
|
||||
class EditorGroupViewCtrl {
|
||||
|
||||
private application!: WebApplication
|
||||
public editors: Editor[] = []
|
||||
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.application.editorGroup.addChangeObserver(() => {
|
||||
this.editors = this.application.editorGroup.editors;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorGroupView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.controller = EditorGroupViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
97
app/assets/javascripts/views/footer/footer-view.pug
Normal file
97
app/assets/javascripts/views/footer/footer-view.pug
Normal file
@@ -0,0 +1,97 @@
|
||||
.sn-component
|
||||
#footer-bar.sk-app-bar.no-edges.no-bottom-edge
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
click-outside='ctrl.clickOutsideAccountMenu()',
|
||||
is-open='ctrl.showAccountMenu',
|
||||
ng-click='ctrl.accountMenuPressed()'
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-circle.small(
|
||||
ng-class="ctrl.hasError ? 'danger' : (ctrl.user ? 'info' : 'neutral')"
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-label.title(ng-class='{red: ctrl.hasError}') Account
|
||||
account-menu(
|
||||
close-function='ctrl.closeAccountMenu()',
|
||||
ng-click='$event.stopPropagation()',
|
||||
ng-if='ctrl.showAccountMenu',
|
||||
application='ctrl.application'
|
||||
)
|
||||
.sk-app-bar-item
|
||||
a.no-decoration.sk-label.title(
|
||||
href='https://standardnotes.org/help',
|
||||
rel='noopener',
|
||||
target='_blank'
|
||||
)
|
||||
| Help
|
||||
.sk-app-bar-item.border
|
||||
.sk-app-bar-item(ng-repeat='room in ctrl.rooms track by room.uuid')
|
||||
.sk-app-bar-item-column(ng-click='ctrl.selectRoom(room)')
|
||||
.sk-label {{room.name}}
|
||||
component-modal(
|
||||
component='room',
|
||||
ng-if='ctrl.roomShowState[room.uuid]',
|
||||
on-dismiss='ctrl.onRoomDismiss()',
|
||||
application='ctrl.application'
|
||||
)
|
||||
.center
|
||||
.sk-app-bar-item(ng-if='ctrl.arbitraryStatusMessage')
|
||||
.sk-app-bar-item-column
|
||||
span.neutral.sk-label {{ctrl.arbitraryStatusMessage}}
|
||||
.right
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.openSecurityUpdate()',
|
||||
ng-if='ctrl.state.dataUpgradeAvailable'
|
||||
)
|
||||
span.success.sk-label Encryption upgrade available.
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.clickedNewUpdateAnnouncement()',
|
||||
ng-if='ctrl.newUpdateAvailable == true'
|
||||
)
|
||||
span.info.sk-label New update available.
|
||||
.sk-app-bar-item.no-pointer(
|
||||
ng-if='ctrl.lastSyncDate && !ctrl.isRefreshing'
|
||||
)
|
||||
.sk-label.subtle
|
||||
| Last refreshed {{ctrl.lastSyncDate}}
|
||||
.sk-app-bar-item(
|
||||
ng-click='ctrl.toggleSyncResolutionMenu()',
|
||||
ng-if='(ctrl.state.outOfSync && !ctrl.isRefreshing) || ctrl.showSyncResolution'
|
||||
)
|
||||
.sk-label.warning(ng-if='ctrl.state.outOfSync') Potentially Out of Sync
|
||||
sync-resolution-menu(
|
||||
close-function='ctrl.toggleSyncResolutionMenu()',
|
||||
ng-click='$event.stopPropagation();',
|
||||
ng-if='ctrl.showSyncResolution',
|
||||
application='ctrl.application'
|
||||
)
|
||||
.sk-app-bar-item(ng-if='ctrl.lastSyncDate && ctrl.isRefreshing')
|
||||
.sk-spinner.small
|
||||
.sk-app-bar-item(ng-if='ctrl.offline')
|
||||
.sk-label Offline
|
||||
.sk-app-bar-item(ng-click='ctrl.refreshData()', ng-if='!ctrl.offline')
|
||||
.sk-label Refresh
|
||||
.sk-app-bar-item.border(ng-if='ctrl.dockShortcuts.length > 0')
|
||||
.sk-app-bar-item.dock-shortcut(ng-repeat='shortcut in ctrl.dockShortcuts')
|
||||
.sk-app-bar-item-column(
|
||||
ng-class="{'underline': shortcut.component.active}",
|
||||
ng-click='ctrl.selectShortcut(shortcut)'
|
||||
)
|
||||
.div(ng-if="shortcut.icon.type == 'circle'", title='{{shortcut.name}}')
|
||||
.sk-circle.small(
|
||||
ng-style="{'background-color': shortcut.icon.background_color, 'border-color': shortcut.icon.border_color}"
|
||||
)
|
||||
.div(ng-if="shortcut.icon.type == 'svg'", title='{{shortcut.name}}')
|
||||
.svg-item(
|
||||
elem-ready='ctrl.initSvgForShortcut(shortcut)',
|
||||
ng-attr-id='dock-svg-{{shortcut.component.uuid}}'
|
||||
)
|
||||
.sk-app-bar-item.border(ng-if='ctrl.state.hasPasscode')
|
||||
#lock-item.sk-app-bar-item(
|
||||
ng-click='ctrl.lockApp()',
|
||||
ng-if='ctrl.state.hasPasscode',
|
||||
title='Locks application and wipes unencrypted data from memory.'
|
||||
)
|
||||
.sk-label
|
||||
i#footer-lock-icon.icon.ion-locked
|
||||
443
app/assets/javascripts/views/footer/footer_view.ts
Normal file
443
app/assets/javascripts/views/footer/footer_view.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { FooterStatus, WebDirective } from '@/types';
|
||||
import { dateToLocalizedString } from '@/utils';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
SyncQueueStrategy,
|
||||
ProtectedAction,
|
||||
ContentType,
|
||||
SNComponent,
|
||||
SNTheme,
|
||||
ComponentArea,
|
||||
ComponentAction
|
||||
} from 'snjs';
|
||||
import template from './footer-view.pug';
|
||||
import { AppStateEvent, EventSource } from '@/services/state';
|
||||
import {
|
||||
STRING_GENERIC_SYNC_ERROR,
|
||||
STRING_NEW_UPDATE_READY
|
||||
} from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { ComponentMutator } from '@/../../../../snjs/dist/@types/models';
|
||||
|
||||
type DockShortcut = {
|
||||
name: string,
|
||||
component: SNComponent,
|
||||
icon: {
|
||||
type: string
|
||||
background_color: string
|
||||
border_color: string
|
||||
}
|
||||
}
|
||||
|
||||
class FooterViewCtrl extends PureViewCtrl {
|
||||
|
||||
private $rootScope: ng.IRootScopeService
|
||||
private rooms: SNComponent[] = []
|
||||
private themesWithIcons: SNTheme[] = []
|
||||
private showSyncResolution = false
|
||||
private unregisterComponent: any
|
||||
private rootScopeListener1: any
|
||||
private rootScopeListener2: any
|
||||
public arbitraryStatusMessage?: string
|
||||
public user?: any
|
||||
private backupStatus?: FooterStatus
|
||||
private offline = true
|
||||
private showAccountMenu = false
|
||||
private queueExtReload = false
|
||||
private reloadInProgress = false
|
||||
public hasError = false
|
||||
public isRefreshing = false
|
||||
public lastSyncDate?: string
|
||||
public newUpdateAvailable = false
|
||||
public dockShortcuts: DockShortcut[] = []
|
||||
public roomShowState: Partial<Record<string, boolean>> = {}
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
) {
|
||||
super($timeout);
|
||||
this.$rootScope = $rootScope;
|
||||
this.addRootScopeListeners();
|
||||
this.toggleSyncResolutionMenu = this.toggleSyncResolutionMenu.bind(this);
|
||||
this.closeAccountMenu = this.closeAccountMenu.bind(this);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.rooms.length = 0;
|
||||
this.themesWithIcons.length = 0;
|
||||
this.unregisterComponent();
|
||||
this.unregisterComponent = undefined;
|
||||
this.rootScopeListener1();
|
||||
this.rootScopeListener2();
|
||||
this.rootScopeListener1 = undefined;
|
||||
this.rootScopeListener2 = undefined;
|
||||
(this.closeAccountMenu as any) = undefined;
|
||||
(this.toggleSyncResolutionMenu as any) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.application!.getStatusService().addStatusObserver((string: string) => {
|
||||
this.$timeout(() => {
|
||||
this.arbitraryStatusMessage = string;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
hasPasscode: false
|
||||
};
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application!.checkForSecurityUpdate().then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.reloadPasscodeStatus();
|
||||
this.reloadUpgradeStatus();
|
||||
this.user = this.application!.getUser();
|
||||
this.updateOfflineStatus();
|
||||
this.findErrors();
|
||||
this.streamItems();
|
||||
this.registerComponentHandler();
|
||||
}
|
||||
|
||||
async reloadPasscodeStatus() {
|
||||
const hasPasscode = this.application!.hasPasscode();
|
||||
this.setState({
|
||||
hasPasscode: hasPasscode
|
||||
});
|
||||
}
|
||||
|
||||
addRootScopeListeners() {
|
||||
this.rootScopeListener1 = this.$rootScope.$on("reload-ext-data", () => {
|
||||
this.reloadExtendedData();
|
||||
});
|
||||
this.rootScopeListener2 = this.$rootScope.$on("new-update-available", () => {
|
||||
this.$timeout(() => {
|
||||
this.onNewUpdateAvailable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
if (eventName === AppStateEvent.EditorFocused) {
|
||||
if (data.eventSource === EventSource.UserInteraction) {
|
||||
this.closeAllRooms();
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
} else if (eventName === AppStateEvent.BeganBackupDownload) {
|
||||
this.backupStatus = this.application!.getStatusService().addStatusFromString(
|
||||
"Saving local backup..."
|
||||
);
|
||||
} else if (eventName === AppStateEvent.EndedBackupDownload) {
|
||||
if (data.success) {
|
||||
this.backupStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.backupStatus!,
|
||||
"Successfully saved backup."
|
||||
);
|
||||
} else {
|
||||
this.backupStatus = this.application!.getStatusService().replaceStatusWithString(
|
||||
this.backupStatus!,
|
||||
"Unable to save local backup."
|
||||
);
|
||||
}
|
||||
this.$timeout(() => {
|
||||
this.backupStatus = this.application!.getStatusService().removeStatus(this.backupStatus!);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppKeyChange() {
|
||||
super.onAppKeyChange();
|
||||
this.reloadPasscodeStatus();
|
||||
}
|
||||
|
||||
|
||||
/** @override */
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.reloadUpgradeStatus();
|
||||
} else if (eventName === ApplicationEvent.EnteredOutOfSync) {
|
||||
this.setState({
|
||||
outOfSync: true
|
||||
});
|
||||
} else if (eventName === ApplicationEvent.ExitedOutOfSync) {
|
||||
this.setState({
|
||||
outOfSync: false
|
||||
});
|
||||
} else if (eventName === ApplicationEvent.CompletedSync) {
|
||||
if (this.offline && this.application!.getNoteCount() === 0) {
|
||||
this.showAccountMenu = true;
|
||||
}
|
||||
this.syncUpdated();
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
} else if (eventName === ApplicationEvent.FailedSync) {
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
}
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.application!.streamItems(
|
||||
ContentType.Component,
|
||||
async () => {
|
||||
const components = this.application!.getItems(ContentType.Component) as SNComponent[];
|
||||
this.rooms = components.filter((candidate) => {
|
||||
return candidate.area === ComponentArea.Rooms && !candidate.deleted;
|
||||
});
|
||||
if (this.queueExtReload) {
|
||||
this.queueExtReload = false;
|
||||
this.reloadExtendedData();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.application!.streamItems(
|
||||
ContentType.Theme,
|
||||
async () => {
|
||||
const themes = this.application!.getDisplayableItems(ContentType.Theme) as SNTheme[];
|
||||
const filteredThemes = themes.filter((candidate) => {
|
||||
return (
|
||||
!candidate.deleted &&
|
||||
candidate.package_info &&
|
||||
candidate.package_info.dock_icon
|
||||
);
|
||||
}).sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
const differ = filteredThemes.length !== this.themesWithIcons.length;
|
||||
this.themesWithIcons = filteredThemes;
|
||||
if (differ) {
|
||||
this.reloadDockShortcuts();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
registerComponentHandler() {
|
||||
this.unregisterComponent = this.application!.componentManager!.registerHandler({
|
||||
identifier: 'room-bar',
|
||||
areas: [ComponentArea.Rooms, ComponentArea.Modal],
|
||||
activationHandler: () => { },
|
||||
actionHandler: (component, action, data) => {
|
||||
if (action === ComponentAction.SetSize) {
|
||||
this.application!.changeItem(component.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.setLastSize(data);
|
||||
})
|
||||
}
|
||||
},
|
||||
focusHandler: (component, focused) => {
|
||||
if (component.isEditor() && focused) {
|
||||
this.closeAllRooms();
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reloadExtendedData() {
|
||||
if (this.reloadInProgress) {
|
||||
return;
|
||||
}
|
||||
this.reloadInProgress = true;
|
||||
|
||||
/**
|
||||
* A reload consists of opening the extensions manager,
|
||||
* then closing it after a short delay.
|
||||
*/
|
||||
const extWindow = this.rooms.find((room) => {
|
||||
return room.package_info.identifier === this.application!
|
||||
.getNativeExtService().extManagerId;
|
||||
});
|
||||
if (!extWindow) {
|
||||
this.queueExtReload = true;
|
||||
this.reloadInProgress = false;
|
||||
return;
|
||||
}
|
||||
this.selectRoom(extWindow);
|
||||
this.$timeout(() => {
|
||||
this.selectRoom(extWindow);
|
||||
this.reloadInProgress = false;
|
||||
this.$rootScope.$broadcast('ext-reload-complete');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
updateOfflineStatus() {
|
||||
this.offline = this.application!.noAccount();
|
||||
}
|
||||
|
||||
openSecurityUpdate() {
|
||||
this.application!.performProtocolUpgrade();
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.hasError = this.application!.getSyncStatus().hasError();
|
||||
}
|
||||
|
||||
accountMenuPressed() {
|
||||
this.showAccountMenu = !this.showAccountMenu;
|
||||
this.closeAllRooms();
|
||||
}
|
||||
|
||||
toggleSyncResolutionMenu() {
|
||||
this.showSyncResolution = !this.showSyncResolution;
|
||||
}
|
||||
|
||||
closeAccountMenu() {
|
||||
this.showAccountMenu = false;
|
||||
}
|
||||
|
||||
lockApp() {
|
||||
this.application!.lock();
|
||||
}
|
||||
|
||||
refreshData() {
|
||||
this.isRefreshing = true;
|
||||
this.application!.sync({
|
||||
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
||||
checkIntegrity: true
|
||||
}).then((response) => {
|
||||
this.$timeout(() => {
|
||||
this.isRefreshing = false;
|
||||
}, 200);
|
||||
if (response && response.error) {
|
||||
this.application!.alertService!.alert(
|
||||
STRING_GENERIC_SYNC_ERROR
|
||||
);
|
||||
} else {
|
||||
this.syncUpdated();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
syncUpdated() {
|
||||
this.lastSyncDate = dateToLocalizedString(this.application!.getLastSyncDate()!);
|
||||
}
|
||||
|
||||
onNewUpdateAvailable() {
|
||||
this.newUpdateAvailable = true;
|
||||
}
|
||||
|
||||
clickedNewUpdateAnnouncement() {
|
||||
this.newUpdateAvailable = false;
|
||||
this.application!.alertService!.alert(
|
||||
STRING_NEW_UPDATE_READY
|
||||
);
|
||||
}
|
||||
|
||||
reloadDockShortcuts() {
|
||||
const shortcuts = [];
|
||||
for (const theme of this.themesWithIcons) {
|
||||
const name = theme.package_info.name;
|
||||
const icon = theme.package_info.dock_icon;
|
||||
if (!icon) {
|
||||
continue;
|
||||
}
|
||||
shortcuts.push({
|
||||
name: name,
|
||||
component: theme,
|
||||
icon: icon
|
||||
} as DockShortcut);
|
||||
}
|
||||
|
||||
this.dockShortcuts = shortcuts.sort((a, b) => {
|
||||
/** Circles first, then images */
|
||||
const aType = a.icon.type;
|
||||
const bType = b.icon.type;
|
||||
if (aType === bType) {
|
||||
return 0;
|
||||
} else if (aType === 'circle' && bType === 'svg') {
|
||||
return -1;
|
||||
} else if (bType === 'circle' && aType === 'svg') {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initSvgForShortcut(shortcut: DockShortcut) {
|
||||
const id = 'dock-svg-' + shortcut.component.uuid;
|
||||
const element = document.getElementById(id)!;
|
||||
const parser = new DOMParser();
|
||||
const svg = shortcut.component.package_info.dock_icon.source;
|
||||
const doc = parser.parseFromString(svg, 'image/svg+xml');
|
||||
element.appendChild(doc.documentElement);
|
||||
}
|
||||
|
||||
selectShortcut(shortcut: DockShortcut) {
|
||||
this.application!.componentManager!.toggleComponent(shortcut.component);
|
||||
}
|
||||
|
||||
onRoomDismiss(room: SNComponent) {
|
||||
this.roomShowState[room.uuid] = false;
|
||||
}
|
||||
|
||||
closeAllRooms() {
|
||||
for (const room of this.rooms) {
|
||||
this.roomShowState[room.uuid] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectRoom(room: SNComponent) {
|
||||
const run = () => {
|
||||
this.$timeout(() => {
|
||||
this.roomShowState[room.uuid] = !this.roomShowState[room.uuid];
|
||||
});
|
||||
};
|
||||
|
||||
if (!this.roomShowState[room.uuid]) {
|
||||
const requiresPrivilege = await this.application!.privilegesService!
|
||||
.actionRequiresPrivilege(
|
||||
ProtectedAction.ManageExtensions
|
||||
);
|
||||
if (requiresPrivilege) {
|
||||
this.application!.presentPrivilegesModal(
|
||||
ProtectedAction.ManageExtensions,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
clickOutsideAccountMenu() {
|
||||
if (this.application && this.application!.authenticationInProgress()) {
|
||||
return;
|
||||
}
|
||||
this.showAccountMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class FooterView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = FooterViewCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
11
app/assets/javascripts/views/index.ts
Normal file
11
app/assets/javascripts/views/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { PureViewCtrl } from './abstract/pure_view_ctrl';
|
||||
|
||||
export { ApplicationGroupView } from './application_group/application_group_view';
|
||||
export { ApplicationView } from './application/application_view';
|
||||
|
||||
export { EditorGroupView } from './editor_group/editor_group_view';
|
||||
export { EditorView } from './editor/editor_view';
|
||||
|
||||
export { FooterView } from './footer/footer_view';
|
||||
export { NotesView } from './notes/notes_view';
|
||||
export { TagsView } from './tags/tags_view';
|
||||
151
app/assets/javascripts/views/notes/note_utils.ts
Normal file
151
app/assets/javascripts/views/notes/note_utils.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { SNNote, SNTag } from 'snjs';
|
||||
|
||||
export enum NoteSortKey {
|
||||
CreatedAt = 'created_at',
|
||||
UpdatedAt = 'updated_at',
|
||||
ClientUpdatedAt = 'client_updated_at',
|
||||
Title = 'title',
|
||||
}
|
||||
|
||||
export function filterAndSortNotes(
|
||||
notes: SNNote[],
|
||||
selectedTag: SNTag,
|
||||
showArchived: boolean,
|
||||
hidePinned: boolean,
|
||||
filterText: string,
|
||||
sortBy: string,
|
||||
reverse: boolean,
|
||||
) {
|
||||
const filtered = filterNotes(
|
||||
notes,
|
||||
selectedTag,
|
||||
showArchived,
|
||||
hidePinned,
|
||||
filterText,
|
||||
);
|
||||
const sorted = sortNotes(
|
||||
filtered,
|
||||
sortBy,
|
||||
reverse
|
||||
);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function filterNotes(
|
||||
notes: SNNote[],
|
||||
selectedTag: SNTag,
|
||||
showArchived: boolean,
|
||||
hidePinned: boolean,
|
||||
filterText: string
|
||||
) {
|
||||
return notes.filter((note) => {
|
||||
let canShowArchived = showArchived;
|
||||
const canShowPinned = !hidePinned;
|
||||
const isTrash = selectedTag.isTrashTag;
|
||||
if (!isTrash && note.trashed) {
|
||||
return false;
|
||||
}
|
||||
const isSmartTag = selectedTag.isSmartTag();
|
||||
if (isSmartTag) {
|
||||
canShowArchived = (
|
||||
canShowArchived ||
|
||||
selectedTag.isArchiveTag ||
|
||||
isTrash
|
||||
);
|
||||
}
|
||||
if (
|
||||
(note.archived && !canShowArchived) ||
|
||||
(note.pinned && !canShowPinned)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return noteMatchesQuery(note, filterText);
|
||||
});
|
||||
}
|
||||
|
||||
function noteMatchesQuery(
|
||||
note: SNNote,
|
||||
query: string
|
||||
) {
|
||||
if(query.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const title = note.safeTitle().toLowerCase();
|
||||
const text = note.safeText().toLowerCase();
|
||||
const lowercaseText = query.toLowerCase();
|
||||
|
||||
const quotedText = stringBetweenQuotes(lowercaseText);
|
||||
if(quotedText) {
|
||||
return title.includes(quotedText) || text.includes(quotedText);
|
||||
}
|
||||
|
||||
if (stringIsUuid(lowercaseText)) {
|
||||
return note.uuid === lowercaseText;
|
||||
}
|
||||
|
||||
const words = lowercaseText.split(" ");
|
||||
const matchesTitle = words.every((word) => {
|
||||
return title.indexOf(word) >= 0;
|
||||
});
|
||||
const matchesBody = words.every((word) => {
|
||||
return text.indexOf(word) >= 0;
|
||||
});
|
||||
|
||||
return matchesTitle || matchesBody;
|
||||
}
|
||||
|
||||
function stringBetweenQuotes(text: string) {
|
||||
const matches = text.match(/"(.*?)"/);
|
||||
return matches ? matches[1] : null;
|
||||
}
|
||||
|
||||
function stringIsUuid(text: string) {
|
||||
const matches = text.match(
|
||||
/\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/
|
||||
);
|
||||
// eslint-disable-next-line no-unneeded-ternary
|
||||
return matches ? true : false;
|
||||
}
|
||||
|
||||
export function sortNotes(
|
||||
notes: SNNote[] = [],
|
||||
sortBy: string,
|
||||
reverse: boolean
|
||||
) {
|
||||
const sortValueFn = (a: SNNote, b: SNNote, pinCheck = false): number => {
|
||||
if (!pinCheck) {
|
||||
if (a.pinned && b.pinned) {
|
||||
return sortValueFn(a, b, true);
|
||||
}
|
||||
if (a.pinned) { return -1; }
|
||||
if (b.pinned) { return 1; }
|
||||
}
|
||||
let aValue = (a as any)[sortBy] || '';
|
||||
let bValue = (b as any)[sortBy] || '';
|
||||
let vector = 1;
|
||||
if (reverse) {
|
||||
vector *= -1;
|
||||
}
|
||||
if (sortBy === NoteSortKey.Title) {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
if (aValue.length === 0 && bValue.length === 0) {
|
||||
return 0;
|
||||
} else if (aValue.length === 0 && bValue.length !== 0) {
|
||||
return 1 * vector;
|
||||
} else if (aValue.length !== 0 && bValue.length === 0) {
|
||||
return -1 * vector;
|
||||
} else {
|
||||
vector *= -1;
|
||||
}
|
||||
}
|
||||
if (aValue > bValue) { return -1 * vector; }
|
||||
else if (aValue < bValue) { return 1 * vector; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
const result = notes.sort(function (a, b) {
|
||||
return sortValueFn(a, b);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
149
app/assets/javascripts/views/notes/notes-view.pug
Normal file
149
app/assets/javascripts/views/notes/notes-view.pug
Normal file
@@ -0,0 +1,149 @@
|
||||
#notes-column.sn-component.section.notes(aria-label='Notes')
|
||||
.content
|
||||
#notes-title-bar.section-title-bar
|
||||
.padded
|
||||
.section-title-bar-header
|
||||
.title {{self.state.panelTitle}}
|
||||
.sk-button.contrast.wide(
|
||||
ng-click='self.createNewNote()',
|
||||
title='Create a new note in the selected tag'
|
||||
)
|
||||
.sk-label
|
||||
i.icon.ion-plus.add-button
|
||||
.filter-section(role='search')
|
||||
input#search-bar.filter-bar(
|
||||
ng-blur='self.onFilterEnter()',
|
||||
ng-change='self.filterTextChanged()',
|
||||
ng-keyup='$event.keyCode == 13 && self.onFilterEnter();',
|
||||
ng-model='self.state.noteFilter.text',
|
||||
placeholder='Search',
|
||||
select-on-click='true',
|
||||
title='Searches notes in the currently selected tag'
|
||||
)
|
||||
#search-clear-button(
|
||||
ng-click='self.clearFilterText();',
|
||||
ng-show='self.state.noteFilter.text'
|
||||
) ✕
|
||||
#notes-menu-bar.sn-component
|
||||
.sk-app-bar.no-edges
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
ng-class="{'selected' : self.state.mutable.showMenu}",
|
||||
ng-click='self.state.mutable.showMenu = !self.state.mutable.showMenu'
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-label
|
||||
| Options
|
||||
.sk-app-bar-item-column
|
||||
.sk-sublabel {{self.optionsSubtitle()}}
|
||||
#notes-options-menu.sk-menu-panel.dropdown-menu(
|
||||
ng-show='self.state.mutable.showMenu'
|
||||
)
|
||||
.sk-menu-panel-header
|
||||
.sk-menu-panel-header-title Sort By
|
||||
a.info.sk-h5(ng-click='self.toggleReverseSort()')
|
||||
| {{self.state.sortReverse === true ? 'Disable Reverse Sort' : 'Enable Reverse Sort'}}
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.selectedSortByCreated()"
|
||||
circle="self.state.sortBy == 'created_at' && 'success'"
|
||||
desc="'Sort notes by newest first'"
|
||||
label="'Date Added'"
|
||||
)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.selectedSortByUpdated()"
|
||||
circle="self.state.sortBy == 'client_updated_at' && 'success'"
|
||||
desc="'Sort notes with the most recently updated first'"
|
||||
label="'Date Modified'"
|
||||
)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.selectedSortByTitle()"
|
||||
circle="self.state.sortBy == 'title' && 'success'"
|
||||
desc="'Sort notes alphabetically by their title'"
|
||||
label="'Title'"
|
||||
)
|
||||
.sk-menu-panel-section
|
||||
.sk-menu-panel-header
|
||||
.sk-menu-panel-header-title Display
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.toggleWebPrefKey('showArchived')"
|
||||
circle="self.state.showArchived ? 'success' : 'danger'"
|
||||
desc=`'Archived notes are usually hidden.
|
||||
You can explicitly show them with this option.'`
|
||||
faded="!self.state.showArchived"
|
||||
label="'Archived Notes'"
|
||||
)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.toggleWebPrefKey('hidePinned')"
|
||||
circle="self.state.hidePinned ? 'danger' : 'success'"
|
||||
desc=`'Pinned notes always appear on top. You can hide them temporarily
|
||||
with this option so you can focus on other notes in the list.'`
|
||||
faded="self.state.hidePinned"
|
||||
label="'Pinned Notes'"
|
||||
)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.toggleWebPrefKey('hideNotePreview')"
|
||||
circle="self.state.hideNotePreview ? 'danger' : 'success'"
|
||||
desc="'Hide the note preview for a more condensed list of notes'"
|
||||
faded="self.state.hideNotePreview"
|
||||
label="'Note Preview'"
|
||||
)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.toggleWebPrefKey('hideDate')"
|
||||
circle="self.state.hideDate ? 'danger' : 'success'"
|
||||
desc="'Hide the date displayed in each row'"
|
||||
faded="self.state.hideDate"
|
||||
label="'Date'"
|
||||
)
|
||||
menu-row(
|
||||
action="self.selectedMenuItem(); self.toggleWebPrefKey('hideTags')"
|
||||
circle="self.state.hideTags ? 'danger' : 'success'"
|
||||
desc="'Hide the list of tags associated with each note'"
|
||||
faded="self.state.hideTags"
|
||||
label="'Tags'"
|
||||
)
|
||||
.scrollable
|
||||
#notes-scrollable.infinite-scroll(
|
||||
can-load='true',
|
||||
infinite-scroll='self.paginate()',
|
||||
threshold='200'
|
||||
)
|
||||
.note(
|
||||
ng-repeat='note in self.state.renderedNotes track by note.uuid'
|
||||
ng-class="{'selected' : self.activeEditorNote == note}"
|
||||
ng-click='self.selectNote(note)'
|
||||
)
|
||||
.note-flags(ng-show='self.noteFlags[note.uuid].length > 0')
|
||||
.flag(ng-class='flag.class', ng-repeat='flag in self.noteFlags[note.uuid]')
|
||||
.label {{flag.text}}
|
||||
.name(ng-show='note.title')
|
||||
| {{note.title}}
|
||||
.note-preview(
|
||||
ng-if=`
|
||||
!self.state.hideNotePreview &&
|
||||
!note.hidePreview &&
|
||||
!note.protected`
|
||||
)
|
||||
.html-preview(
|
||||
ng-bind-html='note.preview_html',
|
||||
ng-show='note.preview_html'
|
||||
)
|
||||
.plain-preview(
|
||||
ng-show='!note.preview_html && note.preview_plain'
|
||||
) {{note.preview_plain}}
|
||||
.default-preview(
|
||||
ng-show='!note.preview_html && !note.preview_plain'
|
||||
) {{note.text}}
|
||||
.date.faded(ng-show='!self.state.hideDate')
|
||||
span(ng-show="self.state.sortBy == 'client_updated_at'")
|
||||
| Modified {{note.updatedAtString || 'Now'}}
|
||||
span(ng-show="self.state.sortBy != 'client_updated_at'")
|
||||
| {{note.createdAtString || 'Now'}}
|
||||
|
||||
panel-resizer(
|
||||
collapsable="true"
|
||||
control="self.panelPuppet"
|
||||
default-width="300"
|
||||
hoverable="true"
|
||||
on-resize-finish="self.onPanelResize()"
|
||||
panel-id="'notes-column'"
|
||||
)
|
||||
732
app/assets/javascripts/views/notes/notes_view.ts
Normal file
732
app/assets/javascripts/views/notes/notes_view.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
import { PanelPuppet, WebDirective } from './../../types';
|
||||
import angular from 'angular';
|
||||
import template from './notes-view.pug';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
removeFromArray,
|
||||
SNNote,
|
||||
SNTag,
|
||||
WebPrefKey
|
||||
} from 'snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { AppStateEvent } from '@/services/state';
|
||||
import { KeyboardModifier, KeyboardKey } from '@/services/keyboardManager';
|
||||
import {
|
||||
PANEL_NAME_NOTES
|
||||
} from '@/views/constants';
|
||||
import {
|
||||
NoteSortKey,
|
||||
filterAndSortNotes
|
||||
} from './note_utils';
|
||||
import { UuidString } from '@/../../../../snjs/dist/@types/types';
|
||||
|
||||
type NotesState = {
|
||||
panelTitle: string
|
||||
tag?: SNTag
|
||||
notes?: SNNote[]
|
||||
renderedNotes?: SNNote[]
|
||||
sortBy?: string
|
||||
sortReverse?: boolean
|
||||
showArchived?: boolean
|
||||
hidePinned?: boolean
|
||||
hideNotePreview?: boolean
|
||||
hideDate?: boolean
|
||||
hideTags?: boolean
|
||||
noteFilter: { text: string }
|
||||
mutable: { showMenu: boolean }
|
||||
}
|
||||
|
||||
type NoteFlag = {
|
||||
text: string
|
||||
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the height of a note cell with nothing but the title,
|
||||
* which *is* a display option
|
||||
*/
|
||||
const MIN_NOTE_CELL_HEIGHT = 51.0;
|
||||
const DEFAULT_LIST_NUM_NOTES = 20;
|
||||
const ELEMENT_ID_SEARCH_BAR = 'search-bar';
|
||||
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable';
|
||||
|
||||
class NotesViewCtrl extends PureViewCtrl {
|
||||
|
||||
private panelPuppet?: PanelPuppet
|
||||
private reloadNotesPromise?: any
|
||||
private notesToDisplay = 0
|
||||
private pageSize = 0
|
||||
private searchSubmitted = false
|
||||
private newNoteKeyObserver: any
|
||||
private nextNoteKeyObserver: any
|
||||
private previousNoteKeyObserver: any
|
||||
private searchKeyObserver: any
|
||||
private noteFlags: Partial<Record<UuidString, NoteFlag[]>> = {}
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService, ) {
|
||||
super($timeout);
|
||||
this.resetPagination();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
angular.element(document).ready(() => {
|
||||
this.reloadPreferences();
|
||||
});
|
||||
this.panelPuppet = {
|
||||
onReady: () => this.reloadPreferences()
|
||||
};
|
||||
this.onWindowResize = this.onWindowResize.bind(this);
|
||||
this.onPanelResize = this.onPanelResize.bind(this);
|
||||
window.addEventListener('resize', this.onWindowResize, true);
|
||||
this.registerKeyboardShortcuts();
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
this.resetPagination(true);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.panelPuppet!.onReady = undefined;
|
||||
this.panelPuppet = undefined;
|
||||
window.removeEventListener('resize', this.onWindowResize, true);
|
||||
(this.onWindowResize as any) = undefined;
|
||||
(this.onPanelResize as any) = undefined;
|
||||
this.newNoteKeyObserver();
|
||||
this.nextNoteKeyObserver();
|
||||
this.previousNoteKeyObserver();
|
||||
this.searchKeyObserver();
|
||||
this.newNoteKeyObserver = undefined;
|
||||
this.nextNoteKeyObserver = undefined;
|
||||
this.previousNoteKeyObserver = undefined;
|
||||
this.searchKeyObserver = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state as NotesState;
|
||||
}
|
||||
|
||||
async setNotesState(state: Partial<NotesState>) {
|
||||
return this.setState(state);
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
notes: [],
|
||||
renderedNotes: [],
|
||||
mutable: { showMenu: false },
|
||||
noteFilter: { text: '' },
|
||||
} as Partial<NotesState>;
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.streamNotesAndTags();
|
||||
this.reloadPreferences();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data?: any) {
|
||||
if (eventName === AppStateEvent.TagChanged) {
|
||||
this.handleTagChange(
|
||||
this.application!.getAppState().getSelectedTag()!
|
||||
);
|
||||
} else if (eventName === AppStateEvent.ActiveEditorChanged) {
|
||||
this.handleEditorChange();
|
||||
} else if (eventName === AppStateEvent.PreferencesChanged) {
|
||||
this.reloadPreferences();
|
||||
this.reloadNotes();
|
||||
} else if (eventName === AppStateEvent.EditorFocused) {
|
||||
this.setShowMenuFalse();
|
||||
}
|
||||
}
|
||||
|
||||
get activeEditorNote() {
|
||||
const activeEditor = this.appState.getActiveEditor();
|
||||
return activeEditor && activeEditor.note;
|
||||
}
|
||||
|
||||
public get editorNotes() {
|
||||
return this.appState.getEditors().map((editor) => editor.note);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
if (eventName === ApplicationEvent.SignedIn) {
|
||||
this.appState.closeAllEditors();
|
||||
} else if (eventName === ApplicationEvent.CompletedSync) {
|
||||
this.getMostValidNotes().then((notes) => {
|
||||
if (notes.length === 0) {
|
||||
this.createPlaceholderNote();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @access private
|
||||
* Access the current state notes without awaiting any potential reloads
|
||||
* that may be in progress. This is the sync alternative to `async getMostValidNotes`
|
||||
*/
|
||||
getPossiblyStaleNotes() {
|
||||
return this.getState().notes!;
|
||||
}
|
||||
|
||||
/**
|
||||
* @access private
|
||||
* Access the current state notes after waiting for any pending reloads.
|
||||
* This returns the most up to date notes, but is the asyncronous counterpart
|
||||
* to `getPossiblyStaleNotes`
|
||||
*/
|
||||
async getMostValidNotes() {
|
||||
await this.reloadNotesPromise;
|
||||
return this.getPossiblyStaleNotes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered programatically to create a new placeholder note
|
||||
* when conditions allow for it. This is as opposed to creating a new note
|
||||
* as part of user interaction (pressing the + button).
|
||||
* @access private
|
||||
*/
|
||||
async createPlaceholderNote() {
|
||||
const selectedTag = this.application!.getAppState().getSelectedTag()!;
|
||||
if (selectedTag.isSmartTag() && !selectedTag.isAllTag) {
|
||||
return;
|
||||
}
|
||||
return this.createNewNote();
|
||||
}
|
||||
|
||||
streamNotesAndTags() {
|
||||
this.application!.streamItems(
|
||||
[ContentType.Note, ContentType.Tag],
|
||||
async (items) => {
|
||||
await this.reloadNotes();
|
||||
const activeNote = this.activeEditorNote;
|
||||
if (activeNote) {
|
||||
const discarded = activeNote.deleted || activeNote.trashed;
|
||||
if (discarded) {
|
||||
this.selectNextOrCreateNew();
|
||||
}
|
||||
} else {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
|
||||
/** Note has changed values, reset its flags */
|
||||
const notes = items.filter((item) => item.content_type === ContentType.Note) as SNNote[];
|
||||
for (const note of notes) {
|
||||
if (note.deleted) {
|
||||
continue;
|
||||
}
|
||||
this.loadFlagsForNote(note);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async selectNote(note: SNNote) {
|
||||
this.appState.openEditor(note.uuid);
|
||||
}
|
||||
|
||||
async createNewNote() {
|
||||
let title = `Note ${this.getState().notes!.length + 1}`;
|
||||
if (this.isFiltering()) {
|
||||
title = this.getState().noteFilter.text;
|
||||
}
|
||||
this.appState.createEditor(title);
|
||||
}
|
||||
|
||||
async handleTagChange(tag: SNTag) {
|
||||
await this.setNotesState({ tag });
|
||||
|
||||
this.resetScrollPosition();
|
||||
this.setShowMenuFalse();
|
||||
await this.setNoteFilterText('');
|
||||
this.application!.getDesktopService().searchText();
|
||||
this.resetPagination();
|
||||
|
||||
/* Capture db load state before beginning reloadNotes,
|
||||
since this status may change during reload */
|
||||
const dbLoaded = this.application!.isDatabaseLoaded();
|
||||
await this.reloadNotes();
|
||||
|
||||
if (this.getState().notes!.length > 0) {
|
||||
this.selectFirstNote();
|
||||
} else if (dbLoaded) {
|
||||
if (!tag.isSmartTag() || tag.isAllTag) {
|
||||
this.createPlaceholderNote();
|
||||
} else if (
|
||||
this.activeEditorNote &&
|
||||
!this.getState().notes!.includes(this.activeEditorNote!)
|
||||
) {
|
||||
this.appState.closeActiveEditor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetScrollPosition() {
|
||||
const scrollable = document.getElementById(ELEMENT_ID_SCROLL_CONTAINER);
|
||||
if (scrollable) {
|
||||
scrollable.scrollTop = 0;
|
||||
scrollable.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async removeNoteFromList(note: SNNote) {
|
||||
const notes = this.getState().notes!;
|
||||
removeFromArray(notes, note);
|
||||
await this.setNotesState({
|
||||
notes: notes,
|
||||
renderedNotes: notes.slice(0, this.notesToDisplay)
|
||||
});
|
||||
}
|
||||
|
||||
async reloadNotes() {
|
||||
this.reloadNotesPromise = this.performPeloadNotes();
|
||||
return this.reloadNotesPromise;
|
||||
}
|
||||
|
||||
async performPeloadNotes() {
|
||||
const tag = this.getState().tag!;
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
const tagNotes = this.appState.getTagNotes(tag);
|
||||
const notes = filterAndSortNotes(
|
||||
tagNotes,
|
||||
tag,
|
||||
this.getState().showArchived!,
|
||||
this.getState().hidePinned!,
|
||||
this.getState().noteFilter.text.toLowerCase(),
|
||||
this.getState().sortBy!,
|
||||
this.getState().sortReverse!
|
||||
);
|
||||
for (const note of notes) {
|
||||
if (note.errorDecrypting) {
|
||||
this.loadFlagsForNote(note);
|
||||
}
|
||||
}
|
||||
await this.setNotesState({
|
||||
notes: notes,
|
||||
renderedNotes: notes.slice(0, this.notesToDisplay)
|
||||
});
|
||||
this.reloadPanelTitle();
|
||||
}
|
||||
|
||||
setShowMenuFalse() {
|
||||
this.setNotesState({
|
||||
mutable: {
|
||||
...this.getState().mutable,
|
||||
showMenu: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleEditorChange() {
|
||||
const activeNote = this.appState.getActiveEditor().note;
|
||||
if (activeNote && activeNote.conflictOf) {
|
||||
this.application!.changeAndSaveItem(activeNote.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
})
|
||||
}
|
||||
if (this.isFiltering()) {
|
||||
this.application!.getDesktopService().searchText(this.getState().noteFilter.text);
|
||||
}
|
||||
}
|
||||
|
||||
reloadPreferences() {
|
||||
const viewOptions = {} as NotesState;
|
||||
const prevSortValue = this.getState().sortBy;
|
||||
let sortBy = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.SortNotesBy,
|
||||
NoteSortKey.CreatedAt
|
||||
);
|
||||
if (sortBy === NoteSortKey.UpdatedAt) {
|
||||
/** Use client_updated_at instead */
|
||||
sortBy = NoteSortKey.ClientUpdatedAt;
|
||||
}
|
||||
viewOptions.sortBy = sortBy;
|
||||
viewOptions.sortReverse = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.SortNotesReverse,
|
||||
false
|
||||
);
|
||||
viewOptions.showArchived = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.NotesShowArchived,
|
||||
false
|
||||
);
|
||||
viewOptions.hidePinned = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.NotesHidePinned,
|
||||
false
|
||||
);
|
||||
viewOptions.hideNotePreview = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.NotesHideNotePreview,
|
||||
false
|
||||
);
|
||||
viewOptions.hideDate = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.NotesHideDate,
|
||||
false
|
||||
);
|
||||
viewOptions.hideTags = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.NotesHideTags,
|
||||
false
|
||||
);
|
||||
this.setNotesState({
|
||||
...viewOptions
|
||||
});
|
||||
if (prevSortValue && prevSortValue !== sortBy) {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
const width = this.application!.getPrefsService().getValue(
|
||||
WebPrefKey.NotesPanelWidth
|
||||
);
|
||||
if (width && this.panelPuppet!.ready) {
|
||||
this.panelPuppet!.setWidth!(width);
|
||||
if (this.panelPuppet!.isCollapsed!()) {
|
||||
this.application!.getAppState().panelDidResize(
|
||||
PANEL_NAME_NOTES,
|
||||
this.panelPuppet!.isCollapsed!()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPanelResize(
|
||||
newWidth: number,
|
||||
lastLeft: number,
|
||||
isAtMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) {
|
||||
this.application!.getPrefsService().setUserPrefValue(
|
||||
WebPrefKey.NotesPanelWidth,
|
||||
newWidth
|
||||
);
|
||||
this.application!.getPrefsService().syncUserPreferences();
|
||||
this.application!.getAppState().panelDidResize(
|
||||
PANEL_NAME_NOTES,
|
||||
isCollapsed
|
||||
);
|
||||
}
|
||||
|
||||
paginate() {
|
||||
this.notesToDisplay += this.pageSize;
|
||||
this.reloadNotes();
|
||||
if (this.searchSubmitted) {
|
||||
this.application!.getDesktopService().searchText(this.getState().noteFilter.text);
|
||||
}
|
||||
}
|
||||
|
||||
resetPagination(keepCurrentIfLarger = false) {
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT);
|
||||
if (this.pageSize === 0) {
|
||||
this.pageSize = DEFAULT_LIST_NUM_NOTES;
|
||||
}
|
||||
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
|
||||
return;
|
||||
}
|
||||
this.notesToDisplay = this.pageSize;
|
||||
}
|
||||
|
||||
reloadPanelTitle() {
|
||||
let title;
|
||||
if (this.isFiltering()) {
|
||||
const resultCount = this.getState().notes!.length;
|
||||
title = `${resultCount} search results`;
|
||||
} else if (this.getState().tag) {
|
||||
title = `${this.getState().tag!.title}`;
|
||||
}
|
||||
this.setNotesState({
|
||||
panelTitle: title
|
||||
});
|
||||
}
|
||||
|
||||
optionsSubtitle() {
|
||||
let base = "";
|
||||
if (this.getState().sortBy === 'created_at') {
|
||||
base += " Date Added";
|
||||
} else if (this.getState().sortBy === 'client_updated_at') {
|
||||
base += " Date Modified";
|
||||
} else if (this.getState().sortBy === 'title') {
|
||||
base += " Title";
|
||||
}
|
||||
if (this.getState().showArchived) {
|
||||
base += " | + Archived";
|
||||
}
|
||||
if (this.getState().hidePinned) {
|
||||
base += " | – Pinned";
|
||||
}
|
||||
if (this.getState().sortReverse) {
|
||||
base += " | Reversed";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
loadFlagsForNote(note: SNNote) {
|
||||
const flags = [] as NoteFlag[];
|
||||
if (note.pinned) {
|
||||
flags.push({
|
||||
text: "Pinned",
|
||||
class: 'info'
|
||||
});
|
||||
}
|
||||
if (note.archived) {
|
||||
flags.push({
|
||||
text: "Archived",
|
||||
class: 'warning'
|
||||
});
|
||||
}
|
||||
if (note.protected) {
|
||||
flags.push({
|
||||
text: "Protected",
|
||||
class: 'success'
|
||||
});
|
||||
}
|
||||
if (note.locked) {
|
||||
flags.push({
|
||||
text: "Locked",
|
||||
class: 'neutral'
|
||||
});
|
||||
}
|
||||
if (note.trashed) {
|
||||
flags.push({
|
||||
text: "Deleted",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
if (note.conflictOf) {
|
||||
flags.push({
|
||||
text: "Conflicted Copy",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
if (note.errorDecrypting) {
|
||||
if (note.waitingForKey) {
|
||||
flags.push({
|
||||
text: "Waiting For Keys",
|
||||
class: 'info'
|
||||
});
|
||||
} else {
|
||||
flags.push({
|
||||
text: "Missing Keys",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
}
|
||||
if (note.deleted) {
|
||||
flags.push({
|
||||
text: "Deletion Pending Sync",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
this.noteFlags[note.uuid] = flags;
|
||||
return flags;
|
||||
}
|
||||
|
||||
displayableNotes() {
|
||||
return this.getState().notes!;
|
||||
}
|
||||
|
||||
getFirstNonProtectedNote() {
|
||||
const displayableNotes = this.displayableNotes();
|
||||
let index = 0;
|
||||
let note = displayableNotes[index];
|
||||
while (note && note.protected) {
|
||||
index++;
|
||||
if (index >= displayableNotes.length) {
|
||||
break;
|
||||
}
|
||||
note = displayableNotes[index];
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
selectFirstNote() {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note);
|
||||
}
|
||||
}
|
||||
|
||||
selectNextNote() {
|
||||
const displayableNotes = this.displayableNotes();
|
||||
const currentIndex = displayableNotes.findIndex((candidate) => {
|
||||
return candidate.uuid === this.activeEditorNote!.uuid
|
||||
});
|
||||
if (currentIndex + 1 < displayableNotes.length) {
|
||||
this.selectNote(displayableNotes[currentIndex + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
selectNextOrCreateNew() {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note);
|
||||
} else if (!this.getState().tag || !this.getState().tag!.isSmartTag()) {
|
||||
this.createPlaceholderNote();
|
||||
} else {
|
||||
this.appState.closeActiveEditor();
|
||||
}
|
||||
}
|
||||
|
||||
selectPreviousNote() {
|
||||
const displayableNotes = this.displayableNotes();
|
||||
const currentIndex = displayableNotes.indexOf(this.activeEditorNote!);
|
||||
if (currentIndex - 1 >= 0) {
|
||||
this.selectNote(displayableNotes[currentIndex - 1]);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isFiltering() {
|
||||
return this.getState().noteFilter.text &&
|
||||
this.getState().noteFilter.text.length > 0;
|
||||
}
|
||||
|
||||
async setNoteFilterText(text: string) {
|
||||
await this.setNotesState({
|
||||
noteFilter: {
|
||||
...this.getState().noteFilter,
|
||||
text: text
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clearFilterText() {
|
||||
await this.setNoteFilterText('');
|
||||
this.onFilterEnter();
|
||||
this.filterTextChanged();
|
||||
this.resetPagination();
|
||||
}
|
||||
|
||||
async filterTextChanged() {
|
||||
if (this.searchSubmitted) {
|
||||
this.searchSubmitted = false;
|
||||
}
|
||||
await this.reloadNotes();
|
||||
}
|
||||
|
||||
onFilterEnter() {
|
||||
/**
|
||||
* For Desktop, performing a search right away causes
|
||||
* input to lose focus. We wait until user explicity hits
|
||||
* enter before highlighting desktop search results.
|
||||
*/
|
||||
this.searchSubmitted = true;
|
||||
this.application!.getDesktopService().searchText(this.getState().noteFilter.text);
|
||||
}
|
||||
|
||||
selectedMenuItem() {
|
||||
this.setShowMenuFalse();
|
||||
}
|
||||
|
||||
toggleWebPrefKey(key: WebPrefKey) {
|
||||
this.application!.getPrefsService().setUserPrefValue(key, !this.state[key]);
|
||||
this.application!.getPrefsService().syncUserPreferences();
|
||||
}
|
||||
|
||||
selectedSortByCreated() {
|
||||
this.setSortBy(NoteSortKey.CreatedAt);
|
||||
}
|
||||
|
||||
selectedSortByUpdated() {
|
||||
this.setSortBy(NoteSortKey.ClientUpdatedAt);
|
||||
}
|
||||
|
||||
selectedSortByTitle() {
|
||||
this.setSortBy(NoteSortKey.Title);
|
||||
}
|
||||
|
||||
toggleReverseSort() {
|
||||
this.selectedMenuItem();
|
||||
this.application!.getPrefsService().setUserPrefValue(
|
||||
WebPrefKey.SortNotesReverse,
|
||||
!this.getState().sortReverse
|
||||
);
|
||||
this.application!.getPrefsService().syncUserPreferences();
|
||||
}
|
||||
|
||||
setSortBy(type: NoteSortKey) {
|
||||
this.application!.getPrefsService().setUserPrefValue(
|
||||
WebPrefKey.SortNotesBy,
|
||||
type
|
||||
);
|
||||
this.application!.getPrefsService().syncUserPreferences();
|
||||
}
|
||||
|
||||
getSearchBar() {
|
||||
return document.getElementById(ELEMENT_ID_SEARCH_BAR)!;
|
||||
}
|
||||
|
||||
registerKeyboardShortcuts() {
|
||||
/**
|
||||
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
||||
* use Control modifier as well. These rules don't apply to desktop, but
|
||||
* probably better to be consistent.
|
||||
*/
|
||||
this.newNoteKeyObserver = this.application!.getKeyboardService().addKeyObserver({
|
||||
key: 'n',
|
||||
modifiers: [
|
||||
KeyboardModifier.Meta,
|
||||
KeyboardModifier.Ctrl
|
||||
],
|
||||
onKeyDown: (event) => {
|
||||
event.preventDefault();
|
||||
this.createNewNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.nextNoteKeyObserver = this.application!.getKeyboardService().addKeyObserver({
|
||||
key: KeyboardKey.Down,
|
||||
elements: [
|
||||
document.body,
|
||||
this.getSearchBar()
|
||||
],
|
||||
onKeyDown: (event) => {
|
||||
const searchBar = this.getSearchBar();
|
||||
if (searchBar === document.activeElement) {
|
||||
searchBar.blur();
|
||||
}
|
||||
this.selectNextNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.previousNoteKeyObserver = this.application!.getKeyboardService().addKeyObserver({
|
||||
key: KeyboardKey.Up,
|
||||
element: document.body,
|
||||
onKeyDown: (event) => {
|
||||
this.selectPreviousNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.searchKeyObserver = this.application!.getKeyboardService().addKeyObserver({
|
||||
key: "f",
|
||||
modifiers: [
|
||||
KeyboardModifier.Meta,
|
||||
KeyboardModifier.Shift
|
||||
],
|
||||
onKeyDown: (event) => {
|
||||
const searchBar = this.getSearchBar();
|
||||
if (searchBar) { searchBar.focus(); };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotesView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = NotesViewCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
72
app/assets/javascripts/views/tags/tags-view.pug
Normal file
72
app/assets/javascripts/views/tags/tags-view.pug
Normal file
@@ -0,0 +1,72 @@
|
||||
#tags-column.sn-component.section.tags(aria-label='Tags')
|
||||
.component-view-container(ng-if='self.component.active')
|
||||
component-view.component-view(
|
||||
component='self.component',
|
||||
application='self.application'
|
||||
)
|
||||
#tags-content.content(ng-if='!(self.component && self.component.active)')
|
||||
.tags-title-section.section-title-bar
|
||||
.section-title-bar-header
|
||||
.sk-h3.title
|
||||
span.sk-bold Views
|
||||
.sk-button.sk-secondary-contrast.wide(
|
||||
ng-click='self.clickedAddNewTag()',
|
||||
title='Create a new tag'
|
||||
)
|
||||
.sk-label
|
||||
i.icon.ion-plus.add-button
|
||||
.scrollable
|
||||
.infinite-scroll
|
||||
.tag(
|
||||
ng-class="{'selected' : self.state.selectedTag == tag, 'faded' : !tag.isAllTag}",
|
||||
ng-click='self.selectTag(tag)',
|
||||
ng-repeat='tag in self.state.smartTags track by tag.uuid'
|
||||
)
|
||||
.tag-info
|
||||
input.title(
|
||||
ng-disabled='true',
|
||||
ng-change='self.onTagTitleChange(tag)'
|
||||
ng-model='tag.title'
|
||||
)
|
||||
.count(ng-show='tag.isAllTag') {{self.state.noteCounts[tag.uuid]}}
|
||||
.tags-title-section.section-title-bar
|
||||
.section-title-bar-header
|
||||
.sk-h3.title
|
||||
span.sk-bold Tags
|
||||
.tag(
|
||||
ng-class="{'selected' : self.state.selectedTag == tag}",
|
||||
ng-click='self.selectTag(tag)',
|
||||
ng-repeat='tag in self.state.tags track by tag.uuid'
|
||||
)
|
||||
.tag-info
|
||||
.tag-icon #
|
||||
input.title(
|
||||
ng-attr-id='tag-{{tag.uuid}}',
|
||||
ng-blur='self.saveTag($event, tag)'
|
||||
ng-change='self.onTagTitleChange(tag)',
|
||||
ng-model='self.titles[tag.uuid]',
|
||||
ng-class="{'editing' : self.state.editingTag == tag}",
|
||||
ng-click='self.selectTag(tag)',
|
||||
ng-keyup='$event.keyCode == 13 && $event.target.blur()',
|
||||
should-focus='self.state.newTag || self.state.editingTag == tag',
|
||||
sn-autofocus='true',
|
||||
spellcheck='false'
|
||||
)
|
||||
.count {{self.state.noteCounts[tag.uuid]}}
|
||||
.danger.small-text.bold(ng-show='tag.conflictOf') Conflicted Copy
|
||||
.danger.small-text.bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys
|
||||
.info.small-text.bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys
|
||||
.menu(ng-show='self.state.selectedTag == tag')
|
||||
a.item(ng-click='self.selectedRenameTag(tag)' ng-show='!self.state.editingTag') Rename
|
||||
a.item(ng-click='self.saveTag($event, tag)' ng-show='self.state.editingTag') Save
|
||||
a.item(ng-click='self.selectedDeleteTag(tag)') Delete
|
||||
.no-tags-placeholder(ng-show='self.state.tags.length == 0')
|
||||
| No tags. Create one using the add button above.
|
||||
panel-resizer(
|
||||
collapsable='true',
|
||||
control='self.panelPuppet',
|
||||
default-width='150',
|
||||
hoverable='true',
|
||||
on-resize-finish='self.onPanelResize()',
|
||||
panel-id="'tags-column'"
|
||||
)
|
||||
361
app/assets/javascripts/views/tags/tags_view.ts
Normal file
361
app/assets/javascripts/views/tags/tags_view.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { WebDirective, PanelPuppet } from '@/types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
SNTag,
|
||||
ContentType,
|
||||
ApplicationEvent,
|
||||
ComponentAction,
|
||||
SNSmartTag,
|
||||
ComponentArea,
|
||||
SNComponent,
|
||||
WebPrefKey
|
||||
} from 'snjs';
|
||||
import template from './tags-view.pug';
|
||||
import { AppStateEvent } from '@/services/state';
|
||||
import { PANEL_NAME_TAGS } from '@/views/constants';
|
||||
import { STRING_DELETE_TAG } from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { UuidString } from '@/../../../../snjs/dist/@types/types';
|
||||
import { TagMutator } from '@/../../../../snjs/dist/@types/models/app/tag';
|
||||
|
||||
type NoteCounts = Partial<Record<string, number>>
|
||||
|
||||
class TagsViewCtrl extends PureViewCtrl {
|
||||
|
||||
/** Passed through template */
|
||||
readonly application!: WebApplication
|
||||
private readonly panelPuppet: PanelPuppet
|
||||
private unregisterComponent?: any
|
||||
component?: SNComponent
|
||||
private editingOriginalName?: string
|
||||
formData: { tagTitle?: string } = {}
|
||||
titles: Partial<Record<UuidString, string>> = {}
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
) {
|
||||
super($timeout);
|
||||
this.panelPuppet = {
|
||||
onReady: () => this.loadPreferences()
|
||||
};
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.unregisterComponent();
|
||||
this.unregisterComponent = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
tags: [],
|
||||
smartTags: [],
|
||||
noteCounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
this.registerComponentHandler();
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.loadPreferences();
|
||||
this.beginStreamingItems();
|
||||
const smartTags = this.application.getSmartTags();
|
||||
this.setState({
|
||||
smartTags: smartTags,
|
||||
});
|
||||
this.selectTag(smartTags[0]);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppSync() {
|
||||
super.onAppSync();
|
||||
this.reloadNoteCounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all officially saved tags as reported by the model manager.
|
||||
* @access private
|
||||
*/
|
||||
getMappedTags() {
|
||||
const tags = this.application.getItems(ContentType.Tag) as SNTag[];
|
||||
return tags.sort((a, b) => {
|
||||
return a.title < b.title ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
beginStreamingItems() {
|
||||
this.application.streamItems(
|
||||
ContentType.Tag,
|
||||
async (items) => {
|
||||
await this.setState({
|
||||
tags: this.getMappedTags(),
|
||||
smartTags: this.application.getSmartTags(),
|
||||
});
|
||||
this.reloadTitles(items as SNTag[]);
|
||||
this.reloadNoteCounts();
|
||||
if (this.state.selectedTag) {
|
||||
/** If the selected tag has been deleted, revert to All view. */
|
||||
const matchingTag = items.find((tag) => {
|
||||
return tag.uuid === this.state.selectedTag.uuid;
|
||||
});
|
||||
if (!matchingTag || matchingTag.deleted) {
|
||||
this.selectTag(this.state.smartTags[0]);
|
||||
} else {
|
||||
this.setState({
|
||||
selectedTag: matchingTag
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
reloadTitles(tags: Array<SNTag | SNSmartTag>) {
|
||||
for(const tag of tags) {
|
||||
this.titles[tag.uuid] = tag.title;
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data?: any) {
|
||||
if (eventName === AppStateEvent.PreferencesChanged) {
|
||||
this.loadPreferences();
|
||||
} else if (eventName === AppStateEvent.TagChanged) {
|
||||
this.setState({
|
||||
selectedTag: this.application.getAppState().getSelectedTag()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName);
|
||||
if (eventName === ApplicationEvent.LocalDataIncrementalLoad) {
|
||||
this.reloadNoteCounts();
|
||||
} else if (eventName === ApplicationEvent.SyncStatusChanged) {
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
const stats = syncStatus.getStats();
|
||||
if (stats.downloadCount > 0) {
|
||||
this.reloadNoteCounts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reloadNoteCounts() {
|
||||
let allTags: Array<SNTag | SNSmartTag> = [];
|
||||
if (this.state.tags) {
|
||||
allTags = allTags.concat(this.state.tags);
|
||||
}
|
||||
if (this.state.smartTags) {
|
||||
allTags = allTags.concat(this.state.smartTags);
|
||||
}
|
||||
const noteCounts: NoteCounts = {};
|
||||
for (const tag of allTags) {
|
||||
if (tag.isSmartTag()) {
|
||||
const notes = this.application.notesMatchingSmartTag(tag as SNSmartTag);
|
||||
noteCounts[tag.uuid] = notes.length;
|
||||
} else {
|
||||
const notes = this.application.referencesForItem(tag, ContentType.Note)
|
||||
.filter((note) => {
|
||||
return !note.archived && !note.trashed;
|
||||
})
|
||||
noteCounts[tag.uuid] = notes.length;
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
noteCounts: noteCounts
|
||||
});
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
if (!this.panelPuppet.ready) {
|
||||
return;
|
||||
}
|
||||
const width = this.application.getPrefsService().getValue(WebPrefKey.TagsPanelWidth);
|
||||
if (width) {
|
||||
this.panelPuppet.setWidth!(width);
|
||||
if (this.panelPuppet.isCollapsed!()) {
|
||||
this.application.getAppState().panelDidResize(
|
||||
PANEL_NAME_TAGS,
|
||||
this.panelPuppet.isCollapsed!()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPanelResize = (
|
||||
newWidth: number,
|
||||
lastLeft: number,
|
||||
isAtMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => {
|
||||
this.application.getPrefsService().setUserPrefValue(
|
||||
WebPrefKey.TagsPanelWidth,
|
||||
newWidth,
|
||||
true
|
||||
);
|
||||
this.application.getAppState().panelDidResize(
|
||||
PANEL_NAME_TAGS,
|
||||
isCollapsed
|
||||
);
|
||||
}
|
||||
|
||||
registerComponentHandler() {
|
||||
this.unregisterComponent = this.application.componentManager!.registerHandler({
|
||||
identifier: 'tags',
|
||||
areas: [ComponentArea.TagsList],
|
||||
activationHandler: (component) => {
|
||||
this.component = component;
|
||||
},
|
||||
contextRequestHandler: () => {
|
||||
return undefined;
|
||||
},
|
||||
actionHandler: (_, action, data) => {
|
||||
if (action === ComponentAction.SelectItem) {
|
||||
if (data.item.content_type === ContentType.Tag) {
|
||||
const tag = this.application.findItem(data.item.uuid);
|
||||
if (tag) {
|
||||
this.selectTag(tag as SNTag);
|
||||
}
|
||||
} else if (data.item.content_type === ContentType.SmartTag) {
|
||||
this.application.createTemplateItem(
|
||||
ContentType.SmartTag,
|
||||
data.item.content
|
||||
).then(smartTag => {
|
||||
this.selectTag(smartTag as SNSmartTag);
|
||||
});
|
||||
}
|
||||
} else if (action === ComponentAction.ClearSelection) {
|
||||
this.selectTag(this.state.smartTags[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async selectTag(tag: SNTag) {
|
||||
if (tag.conflictOf) {
|
||||
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
})
|
||||
}
|
||||
this.application.getAppState().setSelectedTag(tag);
|
||||
}
|
||||
|
||||
async clickedAddNewTag() {
|
||||
if (this.state.editingTag) {
|
||||
return;
|
||||
}
|
||||
const newTag = await this.application.createTemplateItem(
|
||||
ContentType.Tag
|
||||
);
|
||||
this.setState({
|
||||
tags: [newTag].concat(this.state.tags),
|
||||
previousTag: this.state.selectedTag,
|
||||
selectedTag: newTag,
|
||||
editingTag: newTag,
|
||||
newTag: newTag
|
||||
});
|
||||
}
|
||||
|
||||
onTagTitleChange(tag: SNTag | SNSmartTag) {
|
||||
this.setState({
|
||||
editingTag: tag
|
||||
});
|
||||
}
|
||||
|
||||
async saveTag($event: Event, tag: SNTag) {
|
||||
($event.target! as HTMLInputElement).blur();
|
||||
await this.setState({
|
||||
editingTag: null,
|
||||
});
|
||||
|
||||
if (!tag.title || tag.title.length === 0) {
|
||||
let newSelectedTag = this.state.selectedTag;
|
||||
if (this.state.editingTag) {
|
||||
this.titles[tag.uuid] = this.editingOriginalName;
|
||||
this.editingOriginalName = undefined;
|
||||
} else if (this.state.newTag) {
|
||||
newSelectedTag = this.state.previousTag;
|
||||
}
|
||||
this.setState({
|
||||
newTag: null,
|
||||
selectedTag: newSelectedTag,
|
||||
tags: this.getMappedTags()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.editingOriginalName = undefined;
|
||||
|
||||
const matchingTag = this.application.findTagByTitle(tag.title);
|
||||
const alreadyExists = matchingTag && matchingTag !== tag;
|
||||
if (this.state.newTag === tag && alreadyExists) {
|
||||
this.application.alertService!.alert(
|
||||
"A tag with this name already exists."
|
||||
);
|
||||
this.setState({
|
||||
newTag: null,
|
||||
tags: this.getMappedTags(),
|
||||
selectedTag: this.state.previousTag
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
|
||||
const tagMutator = mutator as TagMutator;
|
||||
tagMutator.title = this.titles[tag.uuid]!;
|
||||
});
|
||||
this.selectTag(tag);
|
||||
this.setState({
|
||||
newTag: null
|
||||
});
|
||||
}
|
||||
|
||||
async selectedRenameTag(tag: SNTag) {
|
||||
this.editingOriginalName = tag.title;
|
||||
await this.setState({
|
||||
editingTag: tag
|
||||
});
|
||||
document.getElementById('tag-' + tag.uuid)!.focus();
|
||||
}
|
||||
|
||||
selectedDeleteTag(tag: SNTag) {
|
||||
this.removeTag(tag);
|
||||
}
|
||||
|
||||
removeTag(tag: SNTag) {
|
||||
this.application.alertService!.confirm(
|
||||
STRING_DELETE_TAG,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {
|
||||
/* On confirm */
|
||||
this.application.deleteItem(tag);
|
||||
this.selectTag(this.state.smartTags[0]);
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class TagsView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = TagsViewCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user