Remove dummy concept in favor of editor group and editors

This commit is contained in:
Mo Bitar
2020-04-14 15:01:32 -05:00
parent ef66170ba4
commit 9cf99896a5
81 changed files with 8136 additions and 7179 deletions

View 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 */
}
}

View File

@@ -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'
)

View 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: '='
};
}
}

View File

@@ -0,0 +1,4 @@
application-view(
ng-repeat='application in self.applications',
application='application'
)

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
export const PANEL_NAME_NOTES = 'notes';
export const PANEL_NAME_TAGS = 'tags';

View 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'
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
editor-view(
ng-repeat='editor in self.editors'
application='self.application'
editor='editor'
)

View File

@@ -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: '='
};
}
}

View 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

View 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: '='
};
}
}

View 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';

View 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;
}

View 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'"
)

View 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: '='
};
}
}

View 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'"
)

View 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;
}
}