refactor: migrate remaining angular components to react (#833)

* refactor: menuRow directive to MenuRow component

* refactor: migrate footer to react

* refactor: migrate actions menu to react

* refactor: migrate history menu to react

* fix: click outside handler use capture to trigger event before re-render occurs which would otherwise cause node.contains to return incorrect result (specifically for the account menu)

* refactor: migrate revision preview modal to react

* refactor: migrate permissions modal to react

* refactor: migrate password wizard to react

* refactor: remove unused input modal directive

* refactor: remove unused delay hide component

* refactor: remove unused filechange directive

* refactor: remove unused elemReady directive

* refactor: remove unused sn-enter directive

* refactor: remove unused lowercase directive

* refactor: remove unused autofocus directive

* refactor(wip): note view to react

* refactor: use mutation observer to deinit textarea listeners

* refactor: migrate challenge modal to react

* refactor: migrate note group view to react

* refactor(wip): migrate remaining classes

* fix: navigation parent ref

* refactor: fully remove angular assets

* fix: account switcher

* fix: application view state

* refactor: remove unused password wizard type

* fix: revision preview and permissions modal

* fix: remove angular comment

* refactor: react panel resizers for editor

* feat: simple panel resizer

* fix: use simple panel resizer everywhere

* fix: simplify panel resizer state

* chore: rename simple panel resizer to panel resizer

* refactor: simplify column layout

* fix: editor mount safety check

* fix: use inline onLoad callback for iframe, as setting onload after it loads will never call it

* chore: fix note view test

* chore(deps): upgrade snjs
This commit is contained in:
Mo
2022-01-30 19:01:30 -06:00
committed by GitHub
parent 0ecbde6bac
commit 50c92619ce
117 changed files with 4715 additions and 5309 deletions

View File

@@ -1,176 +0,0 @@
import { ApplicationEvent } from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx';
export type CtrlState = Partial<Record<string, any>>;
export type CtrlProps = Partial<Record<string, any>>;
export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
$timeout: ng.ITimeoutService;
/** Passed through templates */
application!: WebApplication;
state: S = {} as any;
private unsubApp: any;
private unsubState: any;
private stateTimeout?: ng.IPromise<void>;
/**
* Subclasses can optionally add an ng-if=ctrl.templateReady to make sure that
* no Angular handlebars/syntax render in the UI before display data is ready.
*/
protected templateReady = false;
private reactionDisposers: IReactionDisposer[] = [];
/* @ngInject */
constructor($timeout: ng.ITimeoutService, public props: P = {} as any) {
this.$timeout = $timeout;
}
$onInit(): void {
this.state = {
...this.getInitialState(),
...this.state,
};
this.addAppEventObserver();
this.addAppStateObserver();
this.templateReady = true;
}
deinit(): void {
this.unsubApp?.();
this.unsubState?.();
for (const disposer of this.reactionDisposers) {
disposer();
}
this.reactionDisposers.length = 0;
this.unsubApp = undefined;
this.unsubState = undefined;
if (this.stateTimeout) {
this.$timeout.cancel(this.stateTimeout);
}
}
$onDestroy(): void {
this.deinit();
}
public get appState(): AppState {
return this.application.getAppState();
}
/** @private */
async resetState(): Promise<void> {
this.state = this.getInitialState();
await this.setState(this.state);
}
/** @override */
getInitialState(): S {
return {} as any;
}
async setState(state: Partial<S>): Promise<void> {
if (!this.$timeout) {
return;
}
return new Promise<void>((resolve) => {
this.stateTimeout = this.$timeout(() => {
/**
* State changes must be *inside* the timeout block for them to be affected in the UI
* Otherwise UI controllers will need to use $timeout everywhere
*/
this.state = Object.freeze(Object.assign({}, this.state, state));
resolve();
this.afterStateChange();
});
});
}
/** @override */
// eslint-disable-next-line @typescript-eslint/no-empty-function
afterStateChange(): void {}
/** @returns a promise that resolves after the UI has been updated. */
flushUI(): angular.IPromise<void> {
return this.$timeout();
}
initProps(props: CtrlProps): void {
if (Object.keys(this.props).length > 0) {
throw 'Already init-ed props.';
}
this.props = Object.freeze(Object.assign({}, this.props, props));
}
autorun(view: (r: IReactionPublic) => void): void {
this.reactionDisposers.push(autorun(view));
}
addAppStateObserver() {
this.unsubState = this.application!.getAppState().addObserver(
async (eventName, data) => {
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, data: any) => {
this.onAppEvent(eventName, data);
if (eventName === ApplicationEvent.Started) {
await this.onAppStart();
} else if (eventName === ApplicationEvent.Launched) {
await this.onAppLaunch();
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
this.onAppIncrementalSync();
} else if (eventName === ApplicationEvent.CompletedFullSync) {
this.onAppFullSync();
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
this.onAppKeyChange();
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
this.onLocalDataLoaded();
}
}
);
}
onAppEvent(eventName: ApplicationEvent, data?: any) {
/** Optional override */
}
/** @override */
async onAppStart() {
await this.resetState();
}
onLocalDataLoaded() {
/** Optional override */
}
async onAppLaunch() {
/** Optional override */
}
async onAppKeyChange() {
/** Optional override */
}
onAppIncrementalSync() {
/** Optional override */
}
onAppFullSync() {
/** Optional override */
}
}

View File

@@ -1,31 +0,0 @@
.sk-modal-background(ng-click="ctrl.dismiss()")
#account-switcher.sk-modal-content
.sn-component
.sk-menu-panel#menu-panel
.sk-menu-panel-header
.sk-menu-panel-column
.sk-menu-panel-header-title Account Switcher
.sk-menu-panel-column
a.sk-label.info(ng-click='ctrl.addNewApplication()') Add Account
.sk-menu-panel-row(
ng-repeat='descriptor in ctrl.state.descriptors track by descriptor.identifier'
ng-click='ctrl.selectDescriptor(descriptor)'
)
.sk-menu-panel-column.stretch
.left
.sk-menu-panel-column(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
.sk-circle.small.success
.sk-menu-panel-column.stretch
input.sk-label.clickable(
ng-model='descriptor.label'
ng-disabled='descriptor != ctrl.state.editingDescriptor'
ng-keyup='$event.keyCode == 13 && ctrl.submitRename($event)',
ng-attr-id='input-{{descriptor.identifier}}'
spellcheck="false"
)
.sk-sublabel(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
| Current Application
.sk-menu-panel-column(ng-if='descriptor.identifier == ctrl.activeApplication.identifier')
button.sn-button.success(
ng-click='ctrl.renameDescriptor($event, descriptor)'
) Rename

View File

@@ -1,105 +0,0 @@
import { ApplicationGroup } from '@/ui_models/application_group';
import { WebApplication } from '@/ui_models/application';
import template from './account-switcher.pug';
import {
ApplicationDescriptor,
} from '@standardnotes/snjs';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { WebDirective } from '@/types';
class AccountSwitcherCtrl extends PureViewCtrl<unknown, {
descriptors: ApplicationDescriptor[];
editingDescriptor?: ApplicationDescriptor
}> {
private $element: JQLite
application!: WebApplication
private removeAppGroupObserver: any;
/** @template */
activeApplication!: WebApplication
/* @ngInject */
constructor(
$element: JQLite,
$timeout: ng.ITimeoutService,
private mainApplicationGroup: ApplicationGroup
) {
super($timeout);
this.$element = $element;
this.removeAppGroupObserver = mainApplicationGroup.addApplicationChangeObserver(() => {
this.activeApplication = mainApplicationGroup.primaryApplication as WebApplication;
this.reloadApplications();
});
}
$onInit() {
super.$onInit();
}
reloadApplications() {
this.setState({
descriptors: this.mainApplicationGroup.getDescriptors()
});
}
/** @template */
addNewApplication() {
this.dismiss();
this.mainApplicationGroup.addNewApplication();
}
/** @template */
selectDescriptor(descriptor: ApplicationDescriptor) {
this.dismiss();
this.mainApplicationGroup.loadApplicationForDescriptor(descriptor);
}
inputForDescriptor(descriptor: ApplicationDescriptor) {
return document.getElementById(`input-${descriptor.identifier}`);
}
/** @template */
renameDescriptor($event: Event, descriptor: ApplicationDescriptor) {
$event.stopPropagation();
this.setState({ editingDescriptor: descriptor }).then(() => {
const input = this.inputForDescriptor(descriptor);
input?.focus();
});
}
/** @template */
submitRename() {
this.mainApplicationGroup.renameDescriptor(
this.state.editingDescriptor!,
this.state.editingDescriptor!.label
);
this.setState({ editingDescriptor: undefined });
}
deinit() {
(this.application as any) = undefined;
super.deinit();
this.removeAppGroupObserver();
this.removeAppGroupObserver = undefined;
}
dismiss() {
const elem = this.$element;
const scope = elem.scope();
scope.$destroy();
elem.remove();
}
}
export class AccountSwitcher extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = AccountSwitcherCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
application: '='
};
}
}

View File

@@ -1,50 +0,0 @@
.main-ui-view.sn-component(
ng-class='self.platformString'
)
#app.app(
ng-class='self.state.appClass',
ng-if='!self.state.needsUnlock && self.state.launched'
)
navigation(application='self.application', appState='self.appState')
notes-view(
application='self.application'
app-state='self.appState'
)
note-group-view.flex-grow(application='self.application')
footer-view(
ng-if='!self.state.needsUnlock && self.state.launched'
application='self.application'
)
svg(data-ionicons="5.1.2", style="display: none")
symbol#people-circle-outline.ionicon(viewbox="0 0 512 512")
path(d="M256 464c-114.69 0-208-93.31-208-208S141.31 48 256 48s208 93.31 208 208-93.31 208-208 208zm0-384c-97 0-176 79-176 176s79 176 176 176 176-78.95 176-176S353.05 80 256 80z")
path(d="M323.67 292c-17.4 0-34.21-7.72-47.34-21.73a83.76 83.76 0 01-22-51.32c-1.47-20.7 4.88-39.75 17.88-53.62S303.38 144 323.67 144c20.14 0 38.37 7.62 51.33 21.46s19.47 33 18 53.51a84 84 0 01-22 51.3C357.86 284.28 341.06 292 323.67 292zm55.81-74zM163.82 295.36c-29.76 0-55.93-27.51-58.33-61.33-1.23-17.32 4.15-33.33 15.17-45.08s26.22-18 43.15-18 32.12 6.44 43.07 18.14 16.5 27.82 15.25 45c-2.44 33.77-28.6 61.27-58.31 61.27zM420.37 355.28c-1.59-4.7-5.46-9.71-13.22-14.46-23.46-14.33-52.32-21.91-83.48-21.91-30.57 0-60.23 7.9-83.53 22.25-26.25 16.17-43.89 39.75-51 68.18-1.68 6.69-4.13 19.14-1.51 26.11a192.18 192.18 0 00232.75-80.17zM163.63 401.37c7.07-28.21 22.12-51.73 45.47-70.75a8 8 0 00-2.59-13.77c-12-3.83-25.7-5.88-42.69-5.88-23.82 0-49.11 6.45-68.14 18.17-5.4 3.33-10.7 4.61-14.78 5.75a192.84 192.84 0 0077.78 86.64l1.79-.14a102.82 102.82 0 013.16-20.02z")
symbol#layers-sharp.ionicon(viewbox="0 0 512 512")
path(d="M480 150L256 48 32 150l224 104 224-104zM255.71 392.95l-144.81-66.2L32 362l224 102 224-102-78.69-35.3-145.6 66.25z")
path(d="M480 256l-75.53-33.53L256.1 290.6l-148.77-68.17L32 256l224 102 224-102z")
sessions-modal(
application='self.application'
app-state='self.appState'
)
preferences(
app-state='self.appState'
application='self.application'
)
challenge-modal(
ng-repeat="challenge in self.challenges track by challenge.id"
class="sk-modal"
application="self.application"
challenge="challenge"
on-dismiss="self.removeChallenge(challenge)"
)
notes-context-menu(
application='self.application'
app-state='self.appState'
)
purchase-flow(
application='self.application'
app-state='self.appState'
)

View File

@@ -1,207 +0,0 @@
import { RootScopeMessages } from './../../messages';
import { WebDirective } from '@/types';
import { getPlatformString } from '@/utils';
import template from './application-view.pug';
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
import {
ApplicationEvent,
Challenge,
removeFromArray,
} from '@standardnotes/snjs';
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { alertDialog } from '@/services/alertService';
class ApplicationViewCtrl extends PureViewCtrl<
unknown,
{
started?: boolean;
launched?: boolean;
needsUnlock?: boolean;
appClass: string;
}
> {
public platformString: string;
private notesCollapsed = false;
private navigationCollapsed = false;
/**
* To prevent stale state reads (setState is async),
* challenges is a mutable array
*/
private challenges: Challenge[] = [];
/* @ngInject */
constructor(
private $location: ng.ILocationService,
private $rootScope: ng.IRootScopeService,
$timeout: ng.ITimeoutService
) {
super($timeout);
this.$location = $location;
this.$rootScope = $rootScope;
this.platformString = getPlatformString();
this.state = this.getInitialState();
this.onDragDrop = this.onDragDrop.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.addDragDropHandlers();
}
deinit() {
(this.$location as unknown) = undefined;
(this.$rootScope as unknown) = undefined;
(this.application as unknown) = undefined;
window.removeEventListener('dragover', this.onDragOver, true);
window.removeEventListener('drop', this.onDragDrop, true);
(this.onDragDrop as unknown) = undefined;
(this.onDragOver as unknown) = undefined;
super.deinit();
}
$onInit() {
super.$onInit();
this.loadApplication();
}
getInitialState() {
return {
appClass: '',
challenges: [],
};
}
async loadApplication() {
this.application.componentManager.setDesktopManager(
this.application.getDesktopService()
);
await this.application.prepareForLaunch({
receiveChallenge: async (challenge) => {
this.$timeout(() => {
this.challenges.push(challenge);
});
},
});
await this.application.launch();
}
public async removeChallenge(challenge: Challenge) {
this.$timeout(() => {
removeFromArray(this.challenges, challenge);
});
}
async onAppStart() {
super.onAppStart();
this.setState({
started: true,
needsUnlock: this.application.hasPasscode(),
});
}
async onAppLaunch() {
super.onAppLaunch();
this.setState({
launched: true,
needsUnlock: false,
});
this.handleDemoSignInFromParams();
}
onUpdateAvailable() {
this.$rootScope.$broadcast(RootScopeMessages.NewUpdateAvailable);
}
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.',
});
break;
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.',
});
break;
}
}
/** @override */
async onAppStateEvent(eventName: AppStateEvent, data?: unknown) {
if (eventName === AppStateEvent.PanelResized) {
const { panel, collapsed } = data as PanelResizedData;
if (panel === PANEL_NAME_NOTES) {
this.notesCollapsed = collapsed;
}
if (panel === PANEL_NAME_NAVIGATION) {
this.navigationCollapsed = collapsed;
}
let appClass = '';
if (this.notesCollapsed) {
appClass += 'collapsed-notes';
}
if (this.navigationCollapsed) {
appClass += ' collapsed-navigation';
}
this.setState({ appClass });
} else if (eventName === AppStateEvent.WindowDidFocus) {
if (!(await this.application.isLocked())) {
this.application.sync();
}
}
}
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) {
event.preventDefault();
}
}
onDragDrop(event: DragEvent) {
if (event.dataTransfer?.files.length) {
event.preventDefault();
void alertDialog({
text: STRING_DEFAULT_FILE_ERROR,
});
}
}
async handleDemoSignInFromParams() {
if (
this.$location.search().demo === 'true' &&
!this.application.hasAccount()
) {
await this.application.setCustomHost(
'https://syncing-server-demo.standardnotes.com'
);
this.application.signIn('demo@standardnotes.org', '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

@@ -1,6 +0,0 @@
application-view(
ng-repeat='application in self.applications',
ng-if='application == self.activeApplication'
application='application'
ng-attr-id='{{application.identifier}}'
)

View File

@@ -1,43 +0,0 @@
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
applications!: WebApplication[]
activeApplication!: WebApplication
/* @ngInject */
constructor(
$timeout: ng.ITimeoutService,
mainApplicationGroup: ApplicationGroup
) {
this.$timeout = $timeout;
this.applicationGroup = mainApplicationGroup;
this.applicationGroup.addApplicationChangeObserver(() => {
this.reload();
});
this.applicationGroup.initialize();
}
reload() {
this.$timeout(() => {
this.activeApplication = this.applicationGroup.primaryApplication as WebApplication;
this.applications = this.applicationGroup.getApplications() as WebApplication[];
});
}
}
export class ApplicationGroupView extends WebDirective {
constructor() {
super();
this.template = template;
this.controller = ApplicationGroupViewCtrl;
this.replace = false;
this.controllerAs = 'self';
this.bindToController = true;
}
}

View File

@@ -1,410 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { Dialog } from '@reach/dialog';
import {
ChallengeValue,
removeFromArray,
Challenge,
ChallengeReason,
ChallengePrompt,
ChallengeValidation,
ProtectionSessionDurations,
} from '@standardnotes/snjs';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { WebDirective } from '@/types';
import { confirmDialog } from '@/services/alertService';
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
import { Ref, render } from 'preact';
import { useRef } from 'preact/hooks';
import ng from 'angular';
type InputValue = {
prompt: ChallengePrompt;
value: string | number | boolean;
invalid: boolean;
};
type Values = Record<number, InputValue>;
type ChallengeModalState = {
prompts: ChallengePrompt[];
values: Partial<Values>;
processing: boolean;
forgotPasscode: boolean;
showForgotPasscodeLink: boolean;
processingPrompts: ChallengePrompt[];
hasAccount: boolean;
protectedNoteAccessDuration: number;
};
class ChallengeModalCtrl extends PureViewCtrl<unknown, ChallengeModalState> {
application!: WebApplication;
challenge!: Challenge;
onDismiss!: () => void;
submitting = false;
/** @template */
protectionsSessionDurations = ProtectionSessionDurations;
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
/* @ngInject */
constructor(
private $element: ng.IRootElementService,
$timeout: ng.ITimeoutService
) {
super($timeout);
}
getState() {
return this.state as ChallengeModalState;
}
$onInit() {
super.$onInit();
const values = {} as Values;
const prompts = this.challenge.prompts;
for (const prompt of prompts) {
values[prompt.id] = {
prompt,
value: prompt.initialValue ?? '',
invalid: false,
};
}
const showForgotPasscodeLink = [
ChallengeReason.ApplicationUnlock,
ChallengeReason.Migration,
].includes(this.challenge.reason);
this.setState({
prompts,
values,
processing: false,
forgotPasscode: false,
showForgotPasscodeLink,
hasAccount: this.application.hasAccount(),
processingPrompts: [],
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
});
this.application.addChallengeObserver(this.challenge, {
onValidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = false;
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
/** Trigger UI update */
this.afterStateChange();
},
onInvalidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = true;
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
this.setState({ processingPrompts: [], processing: false });
} else {
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
}
/** Trigger UI update */
this.afterStateChange();
},
onComplete: () => {
this.dismiss();
},
onCancel: () => {
this.dismiss();
},
});
}
deinit() {
(this.application as any) = undefined;
(this.challenge as any) = undefined;
super.deinit();
}
reloadProcessingStatus() {
return this.setState({
processing: this.state.processingPrompts.length > 0,
});
}
async destroyLocalData() {
if (
await confirmDialog({
text: STRING_SIGN_OUT_CONFIRMATION,
confirmButtonStyle: 'danger',
})
) {
await this.application.signOut();
this.dismiss();
}
}
/** @template */
cancel() {
if (this.challenge.cancelable) {
this.application!.cancelChallenge(this.challenge);
}
}
onForgotPasscodeClick() {
this.setState({
forgotPasscode: true,
});
}
onTextValueChange(prompt: ChallengePrompt) {
const values = this.getState().values;
values[prompt.id]!.invalid = false;
this.setState({ values });
}
onNumberValueChange(prompt: ChallengePrompt, value: number) {
const values = this.state.values;
values[prompt.id]!.invalid = false;
values[prompt.id]!.value = value;
this.setState({ values });
}
validate() {
let failed = 0;
for (const prompt of this.state.prompts) {
const value = this.state.values[prompt.id]!;
if (typeof value.value === 'string' && value.value.length === 0) {
this.state.values[prompt.id]!.invalid = true;
failed++;
}
}
return failed === 0;
}
async submit() {
if (!this.validate()) {
return;
}
if (this.submitting || this.state.processing) {
return;
}
this.submitting = true;
await this.setState({ processing: true });
const values: ChallengeValue[] = [];
for (const inputValue of Object.values(this.getState().values)) {
const rawValue = inputValue!.value;
const value = new ChallengeValue(inputValue!.prompt, rawValue);
values.push(value);
}
const processingPrompts = values.map((v) => v.prompt);
await this.setState({
processingPrompts: processingPrompts,
processing: processingPrompts.length > 0,
});
/**
* Unfortunately neccessary to wait 50ms so that the above setState call completely
* updates the UI to change processing state, before we enter into UI blocking operation
* (crypto key generation)
*/
this.$timeout(() => {
if (values.length > 0) {
this.application.submitValuesForChallenge(this.challenge, values);
} else {
this.setState({ processing: false });
}
this.submitting = false;
}, 50);
}
afterStateChange() {
this.render();
}
dismiss() {
this.onDismiss();
}
$onDestroy() {
render(<></>, this.$element[0]);
super.$onDestroy();
}
private render() {
if (!this.state.prompts) return;
render(<ChallengeModalView ctrl={this} />, this.$element[0]);
}
}
export class ChallengeModal extends WebDirective {
constructor() {
super();
this.restrict = 'E';
// this.template = template;
this.controller = ChallengeModalCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
challenge: '=',
application: '=',
onDismiss: '&',
};
}
}
function ChallengeModalView({ ctrl }: { ctrl: ChallengeModalCtrl }) {
const initialFocusRef = useRef<HTMLInputElement>(null);
return (
<Dialog
initialFocusRef={initialFocusRef}
onDismiss={() => {
if (ctrl.challenge.cancelable) {
ctrl.cancel();
}
}}
>
<div className="challenge-modal sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">
{ctrl.challenge.modalTitle}
</div>
</div>
<div className="sk-panel-content">
<div className="sk-panel-section">
<div className="sk-p sk-panel-row centered prompt">
<strong>{ctrl.challenge.heading}</strong>
</div>
{ctrl.challenge.subheading && (
<div className="sk-p sk-panel-row centered subprompt">
{ctrl.challenge.subheading}
</div>
)}
</div>
<div className="sk-panel-section">
{ChallengePrompts({ ctrl, initialFocusRef })}
</div>
</div>
<div className="sk-panel-footer extra-padding">
<button
className={
'sn-button w-full ' +
(ctrl.state.processing ? 'neutral' : 'info')
}
disabled={ctrl.state.processing}
onClick={() => ctrl.submit()}
>
{ctrl.state.processing ? 'Generating Keys…' : 'Submit'}
</button>
{ctrl.challenge.cancelable && (
<>
<div className="sk-panel-row"></div>
<a
className="sk-panel-row sk-a info centered text-sm"
onClick={() => ctrl.cancel()}
>
Cancel
</a>
</>
)}
</div>
{ctrl.state.showForgotPasscodeLink && (
<div className="sk-panel-footer">
{ctrl.state.forgotPasscode ? (
<>
<p className="sk-panel-row sk-p">
{ctrl.state.hasAccount
? 'If you forgot your application passcode, your ' +
'only option is to clear your local data from this ' +
'device and sign back in to your account.'
: 'If you forgot your application passcode, your ' +
'only option is to delete your data.'}
</p>
<a
className="sk-panel-row sk-a danger centered"
onClick={() => {
ctrl.destroyLocalData();
}}
>
Delete Local Data
</a>
</>
) : (
<a
className="sk-panel-row sk-a info centered"
onClick={() => ctrl.onForgotPasscodeClick()}
>
Forgot your passcode?
</a>
)}
<div className="sk-panel-row"></div>
</div>
)}
</div>
</div>
</div>
</Dialog>
);
}
function ChallengePrompts({
ctrl,
initialFocusRef,
}: {
ctrl: ChallengeModalCtrl;
initialFocusRef: Ref<HTMLInputElement>;
}) {
return ctrl.state.prompts.map((prompt, index) => (
<>
{/** ProtectionSessionDuration can't just be an input field */}
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div key={prompt.id} className="sk-panel-row">
<div className="sk-horizontal-group mt-3">
<div className="sk-p sk-bold">Allow protected access for</div>
{ProtectionSessionDurations.map((option) => (
<a
className={
'sk-a info ' +
(option.valueInSeconds === ctrl.state.values[prompt.id]!.value
? 'boxed'
: '')
}
onClick={(event) => {
event.preventDefault();
ctrl.onNumberValueChange(prompt, option.valueInSeconds);
}}
>
{option.label}
</a>
))}
</div>
</div>
) : (
<div key={prompt.id} className="sk-panel-row">
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault();
ctrl.submit();
}}
>
<input
className="sk-input contrast"
value={ctrl.state.values[prompt.id]!.value as string | number}
onChange={(event) => {
const value = (event.target as HTMLInputElement).value;
ctrl.state.values[prompt.id]!.value = value;
ctrl.onTextValueChange(prompt);
}}
ref={index === 0 ? initialFocusRef : undefined}
placeholder={prompt.title}
type={prompt.secureTextEntry ? 'password' : 'text'}
/>
</form>
</div>
)}
{ctrl.state.values[prompt.id]!.invalid && (
<div className="sk-panel-row centered">
<label className="sk-label danger">
Invalid authentication. Please try again.
</label>
</div>
)}
</>
));
}

View File

@@ -1,90 +0,0 @@
.sn-component
#footer-bar.sk-app-bar.no-edges.no-bottom-edge
.left
.sk-app-bar-item.ml-0(
click-outside='ctrl.clickOutsideAccountMenu()',
is-open='ctrl.showAccountMenu',
ng-click='ctrl.accountMenuPressed()'
)
.w-8.h-full.flex.items-center.justify-center.cursor-pointer.rounded-full(
ng-class="ctrl.showAccountMenu ? 'bg-border' : '' "
)
.w-5.h-5(
ng-class="ctrl.hasError ? 'danger' : (ctrl.user ? 'info' : 'neutral')"
)
icon(
type="account-circle"
class-name="hover:color-info w-5 h-5 max-h-5"
)
account-menu(
ng-click='$event.stopPropagation()',
app-state='ctrl.appState'
application='ctrl.application'
ng-if='ctrl.showAccountMenu',
)
.sk-app-bar-item.ml-0-important(
click-outside='ctrl.clickOutsideQuickSettingsMenu()',
is-open='ctrl.showQuickSettingsMenu',
ng-click='ctrl.quickSettingsPressed()'
)
.w-8.h-full.flex.items-center.justify-center.cursor-pointer
.h-5
icon(
type="tune"
class-name="rounded hover:color-info"
ng-class="{'color-info': ctrl.showQuickSettingsMenu}"
)
quick-settings-menu(
ng-click='$event.stopPropagation()',
app-state='ctrl.appState'
application='ctrl.application'
ng-if='ctrl.showQuickSettingsMenu',)
.sk-app-bar-item.border(ng-if="ctrl.state.showBetaWarning")
.sk-app-bar-item(ng-if="ctrl.state.showBetaWarning")
a.no-decoration.sk-label.title(
ng-click="ctrl.displayBetaDialog()"
) You are using a beta version of the app
.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(
ng-click='ctrl.toggleSyncResolutionMenu()',
ng-if='(ctrl.state.outOfSync) || 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.offline')
.sk-label Offline
.sk-app-bar-item.border(ng-if='ctrl.state.hasAccountSwitcher')
.sk-app-bar-item(
ng-if='ctrl.state.hasAccountSwitcher'
ng-click='ctrl.openAccountSwitcher()',
)
#account-switcher-icon.flex.items-center(ng-class='{"alone": !ctrl.state.hasPasscode}')
svg.info.ionicon.w-5.h-5
use(href="#layers-sharp")
.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

@@ -1,410 +0,0 @@
import { RootScopeMessages } from './../../messages';
import { ApplicationGroup } from '@/ui_models/application_group';
import { WebDirective } from '@/types';
import { preventRefreshing } from '@/utils';
import {
ApplicationEvent,
ContentType,
SNTheme,
CollectionSort,
} from '@standardnotes/snjs';
import template from './footer-view.pug';
import { AppStateEvent, EventSource } from '@/ui_models/app_state';
import {
STRING_NEW_UPDATE_READY,
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
} from '@/strings';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { alertDialog, confirmDialog } from '@/services/alertService';
import { AccountMenuPane } from '@/components/AccountMenu';
/**
* Disable before production release.
* Anyone who used the beta will still have access to
* the account switcher in production via local storage flag
*/
const ACCOUNT_SWITCHER_ENABLED = false;
const ACCOUNT_SWITCHER_FEATURE_KEY = 'account_switcher';
class FooterViewCtrl extends PureViewCtrl<
unknown,
{
outOfSync: boolean;
hasPasscode: boolean;
dataUpgradeAvailable: boolean;
hasAccountSwitcher: boolean;
showBetaWarning: boolean;
showDataUpgrade: boolean;
}
> {
private $rootScope: ng.IRootScopeService;
private showSyncResolution = false;
private rootScopeListener2: any;
public arbitraryStatusMessage?: string;
public user?: any;
private offline = true;
public showAccountMenu = false;
public showQuickSettingsMenu = false;
private didCheckForOffline = false;
public hasError = false;
public newUpdateAvailable = false;
private observerRemovers: Array<() => void> = [];
private completedInitialSync = false;
private showingDownloadStatus = false;
/* @ngInject */
constructor(
$rootScope: ng.IRootScopeService,
$timeout: ng.ITimeoutService,
private mainApplicationGroup: ApplicationGroup
) {
super($timeout);
this.$rootScope = $rootScope;
this.addRootScopeListeners();
this.toggleSyncResolutionMenu = this.toggleSyncResolutionMenu.bind(this);
this.closeAccountMenu = this.closeAccountMenu.bind(this);
}
deinit() {
for (const remove of this.observerRemovers) remove();
this.observerRemovers.length = 0;
this.rootScopeListener2();
this.rootScopeListener2 = undefined;
(this.closeAccountMenu as unknown) = undefined;
(this.toggleSyncResolutionMenu as unknown) = undefined;
super.deinit();
}
$onInit() {
super.$onInit();
this.application.getStatusManager().onStatusChange((message) => {
this.$timeout(() => {
this.arbitraryStatusMessage = message;
});
});
this.loadAccountSwitcherState();
this.autorun(() => {
const showBetaWarning = this.appState.showBetaWarning;
this.showAccountMenu = this.appState.accountMenu.show;
this.showQuickSettingsMenu = this.appState.quickSettingsMenu.open;
this.setState({
showBetaWarning: showBetaWarning,
showDataUpgrade: !showBetaWarning,
});
});
}
loadAccountSwitcherState() {
const stringValue = localStorage.getItem(ACCOUNT_SWITCHER_FEATURE_KEY);
if (!stringValue && ACCOUNT_SWITCHER_ENABLED) {
/** Enable permanently for this user so they don't lose the feature after its disabled */
localStorage.setItem(ACCOUNT_SWITCHER_FEATURE_KEY, JSON.stringify(true));
}
const hasAccountSwitcher = stringValue
? JSON.parse(stringValue)
: ACCOUNT_SWITCHER_ENABLED;
this.setState({ hasAccountSwitcher });
}
getInitialState() {
return {
outOfSync: false,
dataUpgradeAvailable: false,
hasPasscode: false,
descriptors: this.mainApplicationGroup.getDescriptors(),
hasAccountSwitcher: false,
showBetaWarning: false,
showDataUpgrade: false,
};
}
reloadUpgradeStatus() {
this.application.checkForSecurityUpdate().then((available) => {
this.setState({
dataUpgradeAvailable: available,
});
});
}
/** @template */
openAccountSwitcher() {
this.application.openAccountSwitcher();
}
async onAppLaunch() {
super.onAppLaunch();
this.reloadPasscodeStatus();
this.reloadUser();
this.reloadUpgradeStatus();
this.updateOfflineStatus();
this.findErrors();
this.streamItems();
}
reloadUser() {
this.user = this.application.getUser();
}
async reloadPasscodeStatus() {
const hasPasscode = this.application.hasPasscode();
this.setState({
hasPasscode: hasPasscode,
});
}
addRootScopeListeners() {
this.rootScopeListener2 = this.$rootScope.$on(
RootScopeMessages.NewUpdateAvailable,
() => {
this.$timeout(() => {
this.onNewUpdateAvailable();
});
}
);
}
/** @override */
onAppStateEvent(eventName: AppStateEvent, data: any) {
const statusService = this.application.getStatusManager();
switch (eventName) {
case AppStateEvent.EditorFocused:
if (data.eventSource === EventSource.UserInteraction) {
this.closeAccountMenu();
}
break;
case AppStateEvent.BeganBackupDownload:
statusService.setMessage('Saving local backup…');
break;
case AppStateEvent.EndedBackupDownload: {
const successMessage = 'Successfully saved backup.';
const errorMessage = 'Unable to save local backup.';
statusService.setMessage(data.success ? successMessage : errorMessage);
const twoSeconds = 2000;
this.$timeout(() => {
if (
statusService.message === successMessage ||
statusService.message === errorMessage
) {
statusService.setMessage('');
}
}, twoSeconds);
break;
}
}
}
/** @override */
async onAppKeyChange() {
super.onAppKeyChange();
this.reloadPasscodeStatus();
}
/** @override */
onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.KeyStatusChanged:
this.reloadUpgradeStatus();
break;
case ApplicationEvent.EnteredOutOfSync:
this.setState({
outOfSync: true,
});
break;
case ApplicationEvent.ExitedOutOfSync:
this.setState({
outOfSync: false,
});
break;
case ApplicationEvent.CompletedFullSync:
if (!this.completedInitialSync) {
this.application.getStatusManager().setMessage('');
this.completedInitialSync = true;
}
if (!this.didCheckForOffline) {
this.didCheckForOffline = true;
if (this.offline && this.application.getNoteCount() === 0) {
this.appState.accountMenu.setShow(true);
}
}
this.findErrors();
this.updateOfflineStatus();
break;
case ApplicationEvent.SyncStatusChanged:
this.updateSyncStatus();
break;
case ApplicationEvent.FailedSync:
this.updateSyncStatus();
this.findErrors();
this.updateOfflineStatus();
break;
case ApplicationEvent.LocalDataIncrementalLoad:
case ApplicationEvent.LocalDataLoaded:
this.updateLocalDataStatus();
break;
case ApplicationEvent.SignedIn:
case ApplicationEvent.SignedOut:
this.reloadUser();
break;
case ApplicationEvent.WillSync:
if (!this.completedInitialSync) {
this.application.getStatusManager().setMessage('Syncing…');
}
break;
}
}
streamItems() {
this.application.setDisplayOptions(
ContentType.Theme,
CollectionSort.Title,
'asc',
(theme: SNTheme) => {
return !theme.errorDecrypting;
}
);
}
updateSyncStatus() {
const statusManager = this.application.getStatusManager();
const syncStatus = this.application.getSyncStatus();
const stats = syncStatus.getStats();
if (syncStatus.hasError()) {
statusManager.setMessage('Unable to Sync');
} else if (stats.downloadCount > 20) {
const text = `Downloading ${stats.downloadCount} items. Keep app open.`;
statusManager.setMessage(text);
this.showingDownloadStatus = true;
} else if (this.showingDownloadStatus) {
this.showingDownloadStatus = false;
statusManager.setMessage('Download Complete.');
setTimeout(() => {
statusManager.setMessage('');
}, 2000);
} else if (stats.uploadTotalCount > 20) {
const completionPercentage =
stats.uploadCompletionCount === 0
? 0
: stats.uploadCompletionCount / stats.uploadTotalCount;
const stringPercentage = completionPercentage.toLocaleString(undefined, {
style: 'percent',
});
statusManager.setMessage(
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`
);
} else {
statusManager.setMessage('');
}
}
updateLocalDataStatus() {
const statusManager = this.application.getStatusManager();
const syncStatus = this.application.getSyncStatus();
const stats = syncStatus.getStats();
const encryption = this.application.isEncryptionAvailable();
if (stats.localDataDone) {
statusManager.setMessage('');
return;
}
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`;
const loadingStatus = encryption
? `Decrypting ${notesString}`
: `Loading ${notesString}`;
statusManager.setMessage(loadingStatus);
}
updateOfflineStatus() {
this.offline = this.application.noAccount();
}
async openSecurityUpdate() {
if (
await confirmDialog({
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
})
) {
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
await this.application.upgradeProtocolVersion();
});
}
}
findErrors() {
this.hasError = this.application.getSyncStatus().hasError();
}
accountMenuPressed() {
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
this.appState.accountMenu.toggleShow();
}
quickSettingsPressed() {
this.appState.accountMenu.closeAccountMenu();
this.appState.quickSettingsMenu.toggle();
}
toggleSyncResolutionMenu() {
this.showSyncResolution = !this.showSyncResolution;
}
closeAccountMenu() {
this.appState.accountMenu.setShow(false);
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
}
lockApp() {
this.application.lock();
}
onNewUpdateAvailable() {
this.newUpdateAvailable = true;
}
clickedNewUpdateAnnouncement() {
this.newUpdateAvailable = false;
this.application.alertService.alert(STRING_NEW_UPDATE_READY);
}
displayBetaDialog() {
alertDialog({
title: 'You are using a beta version of the app',
text:
'If you wish to go back to a stable version, make sure to sign out ' +
'of this beta app first.<br>You can silence this warning from the ' +
'<em>Account</em> menu.',
});
}
clickOutsideAccountMenu() {
if (this.application && this.application.authenticationInProgress()) {
return;
}
this.appState.accountMenu.closeAccountMenu();
}
clickOutsideQuickSettingsMenu() {
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
}
}
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

@@ -1,7 +0,0 @@
export { PureViewCtrl } from './abstract/pure_view_ctrl';
export { ApplicationGroupView } from './application_group/application_group_view';
export { ApplicationView } from './application/application_view';
export { NoteGroupViewDirective } from './note_group_view/note_group_view';
export { NoteViewDirective } from './note_view/note_view';
export { FooterView } from './footer/footer_view';
export { ChallengeModal } from './challenge_modal/challenge_modal';

View File

@@ -1,14 +0,0 @@
.h-full
multiple-selected-notes-panel.h-full(
application='self.application'
app-state='self.appState'
ng-if='self.state.showMultipleSelectedNotes'
)
.flex-grow.h-full(
ng-if='!self.state.showMultipleSelectedNotes'
ng-repeat='controller in self.controllers'
)
note-view(
application='self.application'
controller='controller'
)

View File

@@ -1,47 +0,0 @@
import { WebDirective } from './../../types';
import template from './note-group-view.pug';
import { PureViewCtrl } from '../abstract/pure_view_ctrl';
import { NoteViewController } from '@standardnotes/snjs';
class NoteGroupView extends PureViewCtrl<
unknown,
{
showMultipleSelectedNotes: boolean;
}
> {
public controllers: NoteViewController[] = [];
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
super($timeout);
this.state = {
showMultipleSelectedNotes: false,
};
}
$onInit() {
this.application.noteControllerGroup.addActiveControllerChangeObserver(
() => {
this.controllers = this.application.noteControllerGroup.noteControllers;
}
);
this.autorun(() => {
this.setState({
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
});
});
}
}
export class NoteGroupViewDirective extends WebDirective {
constructor() {
super();
this.template = template;
this.controller = NoteGroupView;
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
application: '=',
};
}
}

View File

@@ -1,163 +0,0 @@
#editor-column.section.editor.sn-component(aria-label='Note')
protected-note-panel.h-full.flex.justify-center.items-center(
ng-if='self.state.showProtectedWarning'
app-state='self.appState'
has-protection-sources='self.application.hasProtectionSources()'
on-view-note='self.dismissProtectedWarning()'
)
.flex-grow.flex.flex-col(
ng-if='!self.appState.notes.showProtectedWarning'
)
.sn-component
.sk-app-bar.no-edges(
ng-if='self.noteLocked',
ng-init="self.lockText = 'Note Editing Disabled'; self.showLockedIcon = true",
ng-mouseleave="self.lockText = 'Note Editing Disabled'; self.showLockedIcon = true",
ng-mouseover="self.lockText = 'Enable editing'; self.showLockedIcon = false"
)
.sk-app-bar-item(
ng-click='self.appState.notes.setLockSelectedNotes(!self.noteLocked)'
)
.sk-label.warning.flex.items-center
icon.flex(
type="pencil-off"
class-name="fill-current mr-2"
ng-if="self.showLockedIcon"
)
| {{self.lockText}}
#editor-title-bar.section-title-bar.w-full(
ng-show='self.note && !self.note.errorDecrypting'
)
div.flex.items-center.justify-between.h-8
div.flex-grow(
ng-class="{'locked' : self.noteLocked}"
)
.title.overflow-auto
input#note-title-editor.input(
ng-change='self.onTitleChange()',
ng-disabled='self.noteLocked',
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
ng-model='self.editorValues.title',
select-on-focus='true',
spellcheck='false'
)
div.flex.items-center
#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}}
pin-note-button(
class='mr-3'
app-state='self.appState',
ng-if='self.appState.notes.selectedNotesCount > 0'
)
notes-options-panel(
application='self.application',
app-state='self.appState',
ng-if='self.appState.notes.selectedNotesCount > 0'
)
note-tags-container(
app-state='self.appState'
)
.sn-component(ng-if='self.note')
#editor-menu-bar.sk-app-bar.no-edges
.left
.sk-app-bar-item(
click-outside=`self.setMenuState('showActionsMenu', false)`,
is-open='self.state.showActionsMenu',
ng-class="{'selected' : self.state.showActionsMenu}",
ng-click="self.toggleMenu('showActionsMenu')"
)
.sk-label Actions
actions-menu(
item='self.note',
ng-if='self.state.showActionsMenu',
application='self.application'
)
.sk-app-bar-item(
click-outside=`self.setMenuState('showHistoryMenu', false)`,
is-open='self.state.showHistoryMenu',
ng-class="{'selected' : self.state.showHistoryMenu}",
ng-click="self.toggleMenu('showHistoryMenu')"
)
.sk-label History
history-menu(
item='self.note',
ng-if='self.state.showHistoryMenu',
application='self.application'
)
#editor-content.editor-content(ng-if='!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-viewer='self.state.editorComponentViewer',
ng-if='self.state.editorComponentViewer',
on-load='self.onEditorComponentLoad',
request-reload='self.editorComponentViewerRequestsReload'
application='self.application'
app-state='self.appState'
)
textarea#note-text-editor.editable.font-editor(
dir='auto',
ng-attr-spellcheck='{{self.state.spellcheck}}',
ng-change='self.contentChanged()',
ng-click='self.clickedTextArea()',
ng-focus='self.onContentFocus()',
ng-if='self.state.editorStateDidLoad && !self.state.editorComponentViewer && !self.state.textareaUnloading',
ng-model='self.editorValues.text',
ng-model-options='{ debounce: self.state.editorDebounce}',
ng-readonly='self.noteLocked',
ng-trim='false'
autocomplete='off'
)
| {{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')
.sn-component#error-decrypting-container
.sk-panel#error-decrypting-panel
.sk-panel-header
.sk-panel-header-title {{self.note.waitingForKey ? 'Waiting for Key' : 'Unable to Decrypt'}}
.sk-panel-content
.sk-panel-section
p.sk-p(ng-if='self.note.waitingForKey')
| This note is awaiting its encryption key to be ready. Please wait for syncing to complete
| for this note to be decrypted.
p.sk-p(ng-if='!self.note.waitingForKey')
| 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-if='!self.note.errorDecrypting' ng-show='self.note')
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.availableStackComponents.length')
.left
.sk-app-bar-item(
ng-repeat='component in self.state.availableStackComponents track by component.uuid'
ng-click='self.toggleStackComponent(component)',
)
.sk-app-bar-item-column
.sk-circle.small(
ng-class="{'info' : self.stackComponentExpanded(component) && component.active, 'neutral' : !self.stackComponentExpanded(component)}"
)
.sk-app-bar-item-column
.sk-label {{component.name}}
.sn-component
component-view.component-view.component-stack-item(
ng-repeat='viewer in self.state.stackComponentViewers track by viewer.componentUuid',
component-viewer='viewer',
manual-dealloc='true',
application='self.application'
app-state='self.appState'
)

View File

@@ -1,196 +0,0 @@
/**
* @jest-environment jsdom
*/
import { NoteView } from '@Views/note_view/note_view';
import {
ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs/';
describe('editor-view', () => {
let ctrl: NoteView;
let setShowProtectedWarningSpy: jest.SpyInstance;
beforeEach(() => {
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
ctrl = new NoteView($timeout);
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
Object.defineProperties(ctrl, {
application: {
value: {
getAppState: () => {
return {
notes: {
setShowProtectedWarning: jest.fn(),
},
};
},
hasProtectionSources: () => true,
authorizeNoteAccess: jest.fn(),
},
},
removeComponentsObserver: {
value: jest.fn(),
writable: true,
},
removeTrashKeyObserver: {
value: jest.fn(),
writable: true,
},
unregisterComponent: {
value: jest.fn(),
writable: true,
},
editor: {
value: {
clearNoteChangeListener: jest.fn(),
},
},
});
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
afterEach(() => {
ctrl.deinit();
});
describe('note is protected', () => {
beforeEach(() => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: true,
},
});
});
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
jest
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
.mockImplementation(
() =>
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction +
5
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
const secondsElapsedSinceLastEdit =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
const secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
const secondsElapsedSinceLastModification = 3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(
Date.now() - secondsElapsedSinceLastModification * 1000
),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
let secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastModification;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
// A new modification has just happened
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(),
configurable: true,
});
secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
});
describe('note is unprotected', () => {
it('should not call any hiding logic', async () => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: false,
},
});
const hideProtectedNoteIfInactiveSpy = jest.spyOn(
ctrl,
'hideProtectedNoteIfInactive'
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled();
});
});
describe('dismissProtectedWarning', () => {
describe('the note has protection sources', () => {
it('should reveal note contents if the authorization has been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(true));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
it('should not reveal note contents if the authorization has not been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(false));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
});
});
describe('the note does not have protection sources', () => {
it('should reveal note contents', async () => {
jest
.spyOn(ctrl.application, 'hasProtectionSources')
.mockImplementation(() => false);
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
});
});
});

View File

@@ -1,920 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { PanelPuppet, WebDirective } from '@/types';
import angular from 'angular';
import {
ApplicationEvent,
isPayloadSourceRetrieved,
isPayloadSourceInternalChange,
ContentType,
SNComponent,
SNNote,
ComponentArea,
PrefKey,
ComponentMutator,
PayloadSource,
ComponentViewer,
ComponentManagerEvent,
TransactionalMutation,
ItemMutator,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
NoteViewController,
} from '@standardnotes/snjs';
import { debounce, isDesktopApplication } from '@/utils';
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
import template from './note-view.pug';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { EventSource } from '@/ui_models/app_state';
import {
STRING_DELETE_PLACEHOLDER_ATTEMPT,
STRING_DELETE_LOCKED_ATTEMPT,
StringDeleteNote,
} from '@/strings';
import { confirmDialog } from '@/services/alertService';
const MINIMUM_STATUS_DURATION = 400;
const EDITOR_DEBOUNCE = 100;
const ElementIds = {
NoteTextEditor: 'note-text-editor',
NoteTitleEditor: 'note-title-editor',
EditorContent: 'editor-content',
};
type NoteStatus = {
message?: string;
desc?: string;
};
type EditorState = {
availableStackComponents: SNComponent[];
stackComponentViewers: ComponentViewer[];
editorComponentViewer?: ComponentViewer;
editorStateDidLoad: boolean;
saveError?: any;
noteStatus?: NoteStatus;
marginResizersEnabled?: boolean;
monospaceFont?: boolean;
isDesktop?: boolean;
syncTakingTooLong: boolean;
showActionsMenu: boolean;
showHistoryMenu: boolean;
spellcheck: boolean;
/** Setting to true then false will allow the main content textarea to be destroyed
* then re-initialized. Used when reloading spellcheck status. */
textareaUnloading: boolean;
showProtectedWarning: boolean;
};
type EditorValues = {
title: string;
text: string;
};
function copyEditorValues(values: EditorValues) {
return Object.assign({}, values);
}
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
return array.sort((a, b) =>
a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
);
}
export const transactionForAssociateComponentWithCurrentNote = (
component: SNComponent,
note: SNNote
) => {
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeDisassociatedItemId(note.uuid);
mutator.associateWithItem(note.uuid);
},
};
return transaction;
};
export const transactionForDisassociateComponentWithCurrentNote = (
component: SNComponent,
note: SNNote
) => {
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeAssociatedItemId(note.uuid);
mutator.disassociateWithItem(note.uuid);
},
};
return transaction;
};
export const reloadFont = (monospaceFont?: boolean) => {
const root = document.querySelector(':root') as HTMLElement;
const propertyName = '--sn-stylekit-editor-font-family';
if (monospaceFont) {
root.style.setProperty(propertyName, 'var(--sn-stylekit-monospace-font)');
} else {
root.style.setProperty(propertyName, 'var(--sn-stylekit-sans-serif-font)');
}
};
export class NoteView extends PureViewCtrl<unknown, EditorState> {
/** Passed through template */
readonly application!: WebApplication;
readonly controller!: NoteViewController;
private leftPanelPuppet?: PanelPuppet;
private rightPanelPuppet?: PanelPuppet;
private statusTimeout?: ng.IPromise<void>;
private lastEditorFocusEventSource?: EventSource;
public editorValues: EditorValues = { title: '', text: '' };
onEditorComponentLoad?: () => void;
private scrollPosition = 0;
private removeTrashKeyObserver?: () => void;
private removeTabObserver?: () => void;
private removeComponentStreamObserver?: () => void;
private removeComponentManagerObserver?: () => void;
private removeInnerNoteObserver?: () => void;
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
super($timeout);
this.leftPanelPuppet = {
onReady: () => this.reloadPreferences(),
};
this.rightPanelPuppet = {
onReady: () => this.reloadPreferences(),
};
this.onPanelResizeFinish = this.onPanelResizeFinish.bind(this);
this.setScrollPosition = this.setScrollPosition.bind(this);
this.resetScrollPosition = this.resetScrollPosition.bind(this);
this.editorComponentViewerRequestsReload =
this.editorComponentViewerRequestsReload.bind(this);
this.onEditorComponentLoad = () => {
this.application.getDesktopService().redoSearch();
};
this.debounceReloadEditorComponent = debounce(
this.debounceReloadEditorComponent.bind(this),
25
);
}
deinit() {
this.removeComponentStreamObserver?.();
(this.removeComponentStreamObserver as unknown) = undefined;
this.removeInnerNoteObserver?.();
(this.removeInnerNoteObserver as unknown) = undefined;
this.removeComponentManagerObserver?.();
(this.removeComponentManagerObserver as unknown) = undefined;
this.removeTrashKeyObserver?.();
this.removeTrashKeyObserver = undefined;
this.clearNoteProtectionInactivityTimer();
this.removeTabObserver?.();
this.removeTabObserver = undefined;
this.leftPanelPuppet = undefined;
this.rightPanelPuppet = undefined;
this.onEditorComponentLoad = undefined;
this.statusTimeout = undefined;
(this.onPanelResizeFinish as unknown) = undefined;
super.deinit();
}
getState() {
return this.state as EditorState;
}
get note() {
return this.controller.note;
}
$onInit() {
super.$onInit();
this.registerKeyboardShortcuts();
this.removeInnerNoteObserver =
this.controller.addNoteInnerValueChangeObserver((note, source) => {
this.onNoteInnerChange(note, source);
});
this.autorun(() => {
this.setState({
showProtectedWarning: this.appState.notes.showProtectedWarning,
});
});
this.reloadEditorComponent();
this.reloadStackComponents();
const showProtectedWarning =
this.note.protected && !this.application.hasProtectionSources();
this.setShowProtectedOverlay(showProtectedWarning);
this.reloadPreferences();
if (this.controller.isTemplateNote) {
this.$timeout(() => {
this.focusTitle();
});
}
}
private onNoteInnerChange(note: SNNote, source: PayloadSource): void {
if (note.uuid !== this.note.uuid) {
throw Error('Editor received changes for non-current note');
}
if (isPayloadSourceRetrieved(source)) {
this.editorValues.title = note.title;
this.editorValues.text = note.text;
}
if (!this.editorValues.title) {
this.editorValues.title = note.title;
}
if (!this.editorValues.text) {
this.editorValues.text = note.text;
}
this.reloadSpellcheck();
const isTemplateNoteInsertedToBeInteractableWithEditor =
source === PayloadSource.Constructor && note.dirty;
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
return;
}
if (note.lastSyncBegan || note.dirty) {
if (note.lastSyncEnd) {
if (
note.dirty ||
note.lastSyncBegan!.getTime() > note.lastSyncEnd!.getTime()
) {
this.showSavingStatus();
} else if (
this.state.noteStatus &&
note.lastSyncEnd!.getTime() > note.lastSyncBegan!.getTime()
) {
this.showAllChangesSavedStatus();
}
} else {
this.showSavingStatus();
}
}
}
$onDestroy(): void {
if (this.state.editorComponentViewer) {
this.application.componentManager?.destroyComponentViewer(
this.state.editorComponentViewer
);
}
super.$onDestroy();
}
/** @override */
getInitialState() {
return {
availableStackComponents: [],
stackComponentViewers: [],
editorStateDidLoad: false,
editorDebounce: EDITOR_DEBOUNCE,
isDesktop: isDesktopApplication(),
spellcheck: true,
syncTakingTooLong: false,
showActionsMenu: false,
showHistoryMenu: false,
noteStatus: undefined,
textareaUnloading: false,
showProtectedWarning: false,
} as EditorState;
}
async onAppLaunch() {
await super.onAppLaunch();
this.streamItems();
this.registerComponentManagerEventObserver();
}
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.PreferencesChanged:
this.reloadPreferences();
break;
case ApplicationEvent.HighLatencySync:
this.setState({ syncTakingTooLong: true });
break;
case ApplicationEvent.CompletedFullSync: {
this.setState({ syncTakingTooLong: false });
const isInErrorState = this.state.saveError;
/** if we're still dirty, don't change status, a sync is likely upcoming. */
if (!this.note.dirty && isInErrorState) {
this.showAllChangesSavedStatus();
}
break;
}
case ApplicationEvent.FailedSync:
/**
* Only show error status in editor if the note is dirty.
* Otherwise, it means the originating sync came from somewhere else
* and we don't want to display an error here.
*/
if (this.note.dirty) {
this.showErrorStatus();
}
break;
case ApplicationEvent.LocalDatabaseWriteError:
this.showErrorStatus({
message: 'Offline Saving Issue',
desc: 'Changes not saved',
});
break;
case ApplicationEvent.UnprotectedSessionBegan: {
this.setShowProtectedOverlay(false);
break;
}
case ApplicationEvent.UnprotectedSessionExpired: {
if (this.note.protected) {
this.hideProtectedNoteIfInactive();
}
break;
}
}
}
getSecondsElapsedSinceLastEdit(): number {
return (Date.now() - this.note.userModifiedDate.getTime()) / 1000;
}
hideProtectedNoteIfInactive(): void {
const secondsElapsedSinceLastEdit = this.getSecondsElapsedSinceLastEdit();
if (
secondsElapsedSinceLastEdit >=
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction
) {
this.setShowProtectedOverlay(true);
} else {
const secondsUntilTheNextCheck =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit;
this.startNoteProtectionInactivityTimer(secondsUntilTheNextCheck);
}
}
startNoteProtectionInactivityTimer(timerDurationInSeconds: number): void {
this.clearNoteProtectionInactivityTimer();
this.protectionTimeoutId = setTimeout(() => {
this.hideProtectedNoteIfInactive();
}, timerDurationInSeconds * 1000);
}
clearNoteProtectionInactivityTimer(): void {
if (this.protectionTimeoutId) {
clearTimeout(this.protectionTimeoutId);
}
}
async dismissProtectedWarning() {
let showNoteContents = true;
if (this.application.hasProtectionSources()) {
showNoteContents = await this.application.authorizeNoteAccess(this.note);
}
if (!showNoteContents) {
return;
}
this.setShowProtectedOverlay(false);
this.focusTitle();
}
/**
* Because note.locked accesses note.content.appData,
* we do not want to expose the template to direct access to note.locked,
* otherwise an exception will occur when trying to access note.locked if the note
* is deleted. There is potential for race conditions to occur with setState, where a
* previous setState call may have queued a digest cycle, and the digest cycle triggers
* on a deleted note.
*/
get noteLocked() {
if (!this.note || this.note.deleted) {
return false;
}
return this.note.locked;
}
streamItems() {
this.removeComponentStreamObserver = this.application.streamItems(
ContentType.Component,
async (_items, source) => {
if (
isPayloadSourceInternalChange(source) ||
source === PayloadSource.InitialObserverRegistrationPush
) {
return;
}
if (!this.note) return;
await this.reloadStackComponents();
this.debounceReloadEditorComponent();
}
);
}
private createComponentViewer(component: SNComponent) {
const viewer = this.application.componentManager.createComponentViewer(
component,
this.note.uuid
);
return viewer;
}
public async editorComponentViewerRequestsReload(
viewer: ComponentViewer
): Promise<void> {
const component = viewer.component;
this.application.componentManager.destroyComponentViewer(viewer);
await this.setState({
editorComponentViewer: undefined,
});
await this.setState({
editorComponentViewer: this.createComponentViewer(component),
editorStateDidLoad: true,
});
}
/**
* Calling reloadEditorComponent successively without waiting for state to settle
* can result in componentViewers being dealloced twice
*/
debounceReloadEditorComponent() {
this.reloadEditorComponent();
}
private async reloadEditorComponent() {
const newEditor = this.application.componentManager.editorForNote(
this.note
);
/** Editors cannot interact with template notes so the note must be inserted */
if (newEditor && this.controller.isTemplateNote) {
await this.controller.insertTemplatedNote();
this.associateComponentWithCurrentNote(newEditor);
}
const currentComponentViewer = this.state.editorComponentViewer;
if (currentComponentViewer?.componentUuid !== newEditor?.uuid) {
if (currentComponentViewer) {
this.application.componentManager.destroyComponentViewer(
currentComponentViewer
);
}
await this.setState({
editorComponentViewer: undefined,
});
if (newEditor) {
await this.setState({
editorComponentViewer: this.createComponentViewer(newEditor),
editorStateDidLoad: true,
});
}
reloadFont(this.state.monospaceFont);
} else {
await this.setState({
editorStateDidLoad: true,
});
}
}
setMenuState(menu: string, state: boolean) {
this.setState({
[menu]: state,
});
this.closeAllMenus(menu);
}
toggleMenu(menu: keyof EditorState) {
this.setMenuState(menu, !this.state[menu]);
this.application.getAppState().notes.setContextMenuOpen(false);
}
closeAllMenus(exclude?: string) {
const allMenus = ['showActionsMenu', 'showHistoryMenu'];
const menuState: any = {};
for (const candidate of allMenus) {
if (candidate !== exclude) {
menuState[candidate] = false;
}
}
this.setState(menuState);
}
hasAvailableExtensions() {
return (
this.application.actionsManager.extensionsInContextOfItem(this.note)
.length > 0
);
}
showSavingStatus() {
this.setStatus({ message: 'Saving…' }, false);
}
showAllChangesSavedStatus() {
this.setState({
saveError: false,
syncTakingTooLong: false,
});
this.setStatus({
message:
'All changes saved' + (this.application.noAccount() ? ' offline' : ''),
});
}
showErrorStatus(error?: NoteStatus) {
if (!error) {
error = {
message: 'Sync Unreachable',
desc: 'Changes saved offline',
};
}
this.setState({
saveError: true,
syncTakingTooLong: false,
});
this.setStatus(error);
}
setStatus(status: NoteStatus, wait = true) {
if (this.statusTimeout) {
this.$timeout.cancel(this.statusTimeout);
}
if (wait) {
this.statusTimeout = this.$timeout(() => {
this.setState({
noteStatus: status,
});
}, MINIMUM_STATUS_DURATION);
} else {
this.setState({
noteStatus: status,
});
}
}
cancelPendingSetStatus() {
if (this.statusTimeout) {
this.$timeout.cancel(this.statusTimeout);
}
}
contentChanged() {
this.controller.save({
editorValues: copyEditorValues(this.editorValues),
isUserModified: true,
});
}
onTitleEnter($event: Event) {
($event.target as HTMLInputElement).blur();
this.onTitleChange();
this.focusEditor();
}
onTitleChange() {
this.controller.save({
editorValues: copyEditorValues(this.editorValues),
isUserModified: true,
dontUpdatePreviews: true,
});
}
focusEditor() {
const element = document.getElementById(ElementIds.NoteTextEditor);
if (element) {
this.lastEditorFocusEventSource = EventSource.Script;
element.focus();
}
}
focusTitle() {
document.getElementById(ElementIds.NoteTitleEditor)?.focus();
}
clickedTextArea() {
this.closeAllMenus();
}
onContentFocus() {
this.application
.getAppState()
.editorDidFocus(this.lastEditorFocusEventSource!);
this.lastEditorFocusEventSource = undefined;
}
setShowProtectedOverlay(show: boolean) {
this.appState.notes.setShowProtectedWarning(show);
}
async deleteNote(permanently: boolean) {
if (this.controller.isTemplateNote) {
this.application.alertService.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT);
return;
}
if (this.note.locked) {
this.application.alertService.alert(STRING_DELETE_LOCKED_ATTEMPT);
return;
}
const title = this.note.safeTitle().length
? `'${this.note.title}'`
: 'this note';
const text = StringDeleteNote(title, permanently);
if (
await confirmDialog({
text,
confirmButtonStyle: 'danger',
})
) {
if (permanently) {
this.performNoteDeletion(this.note);
} else {
this.controller.save({
editorValues: copyEditorValues(this.editorValues),
bypassDebouncer: true,
dontUpdatePreviews: true,
customMutate: (mutator) => {
mutator.trashed = true;
},
});
}
}
}
performNoteDeletion(note: SNNote) {
this.application.deleteItem(note);
}
async onPanelResizeFinish(width: number, left: number, isMaxWidth: boolean) {
if (isMaxWidth) {
await this.application.setPreference(PrefKey.EditorWidth, null);
} else {
if (width !== undefined && width !== null) {
await this.application.setPreference(PrefKey.EditorWidth, width);
this.leftPanelPuppet!.setWidth!(width);
}
}
if (left !== undefined && left !== null) {
await this.application.setPreference(PrefKey.EditorLeft, left);
this.rightPanelPuppet!.setLeft!(left);
}
this.application.sync();
}
async reloadSpellcheck() {
const spellcheck = this.appState.notes.getSpellcheckStateForNote(this.note);
if (spellcheck !== this.state.spellcheck) {
await this.setState({ textareaUnloading: true });
await this.setState({ textareaUnloading: false });
reloadFont(this.state.monospaceFont);
await this.setState({
spellcheck,
});
}
}
async reloadPreferences() {
const monospaceFont = this.application.getPreference(
PrefKey.EditorMonospaceEnabled,
true
);
const marginResizersEnabled = this.application.getPreference(
PrefKey.EditorResizersEnabled,
true
);
await this.reloadSpellcheck();
await this.setState({
monospaceFont,
marginResizersEnabled,
});
if (!document.getElementById(ElementIds.EditorContent)) {
/** Elements have not yet loaded due to ng-if around wrapper */
return;
}
reloadFont(this.state.monospaceFont);
if (
this.state.marginResizersEnabled &&
this.leftPanelPuppet?.ready &&
this.rightPanelPuppet?.ready
) {
const width = this.application.getPreference(PrefKey.EditorWidth, null);
if (width != null) {
this.leftPanelPuppet!.setWidth!(width);
this.rightPanelPuppet!.setWidth!(width);
}
const left = this.application.getPreference(PrefKey.EditorLeft, null);
if (left != null) {
this.leftPanelPuppet!.setLeft!(left);
this.rightPanelPuppet!.setLeft!(left);
}
}
}
/** @components */
registerComponentManagerEventObserver() {
this.removeComponentManagerObserver =
this.application.componentManager.addEventObserver((eventName, data) => {
if (eventName === ComponentManagerEvent.ViewerDidFocus) {
const viewer = data?.componentViewer;
if (viewer?.component.isEditor) {
this.closeAllMenus();
}
}
});
}
async reloadStackComponents() {
const stackComponents = sortAlphabetically(
this.application.componentManager
.componentsForArea(ComponentArea.EditorStack)
.filter((component) => component.active)
);
const enabledComponents = stackComponents.filter((component) => {
return component.isExplicitlyEnabledForItem(this.note.uuid);
});
const needsNewViewer = enabledComponents.filter((component) => {
const hasExistingViewer = this.state.stackComponentViewers.find(
(viewer) => viewer.componentUuid === component.uuid
);
return !hasExistingViewer;
});
const needsDestroyViewer = this.state.stackComponentViewers.filter(
(viewer) => {
const viewerComponentExistsInEnabledComponents = enabledComponents.find(
(component) => {
return component.uuid === viewer.componentUuid;
}
);
return !viewerComponentExistsInEnabledComponents;
}
);
const newViewers: ComponentViewer[] = [];
for (const component of needsNewViewer) {
newViewers.push(
this.application.componentManager.createComponentViewer(
component,
this.note.uuid
)
);
}
for (const viewer of needsDestroyViewer) {
this.application.componentManager.destroyComponentViewer(viewer);
}
await this.setState({
availableStackComponents: stackComponents,
stackComponentViewers: newViewers,
});
}
stackComponentExpanded(component: SNComponent): boolean {
return !!this.state.stackComponentViewers.find(
(viewer) => viewer.componentUuid === component.uuid
);
}
async toggleStackComponent(component: SNComponent) {
if (!component.isExplicitlyEnabledForItem(this.note.uuid)) {
await this.associateComponentWithCurrentNote(component);
} else {
await this.disassociateComponentWithCurrentNote(component);
}
this.application.sync();
}
async disassociateComponentWithCurrentNote(component: SNComponent) {
return this.application.runTransactionalMutation(
transactionForDisassociateComponentWithCurrentNote(component, this.note)
);
}
async associateComponentWithCurrentNote(component: SNComponent) {
return this.application.runTransactionalMutation(
transactionForAssociateComponentWithCurrentNote(component, this.note)
);
}
registerKeyboardShortcuts() {
this.removeTrashKeyObserver = this.application.io.addKeyObserver({
key: KeyboardKey.Backspace,
notTags: ['INPUT', 'TEXTAREA'],
modifiers: [KeyboardModifier.Meta],
onKeyDown: () => {
this.deleteNote(false);
},
});
}
setScrollPosition() {
const editor = document.getElementById(
ElementIds.NoteTextEditor
) as HTMLInputElement;
this.scrollPosition = editor.scrollTop;
}
resetScrollPosition() {
const editor = document.getElementById(
ElementIds.NoteTextEditor
) as HTMLInputElement;
editor.scrollTop = this.scrollPosition;
}
onSystemEditorLoad() {
if (this.removeTabObserver) {
return;
}
/**
* Insert 4 spaces when a tab key is pressed,
* only used when inside of the text editor.
* If the shift key is pressed first, this event is
* not fired.
*/
const editor = document.getElementById(
ElementIds.NoteTextEditor
)! as HTMLInputElement;
this.removeTabObserver = this.application.io.addKeyObserver({
element: editor,
key: KeyboardKey.Tab,
onKeyDown: (event) => {
if (document.hidden || this.note.locked || event.shiftKey) {
return;
}
event.preventDefault();
/** Using document.execCommand gives us undo support */
const insertSuccessful = document.execCommand(
'insertText',
false,
'\t'
);
if (!insertSuccessful) {
/** document.execCommand works great on Chrome/Safari but not Firefox */
const start = editor.selectionStart!;
const end = editor.selectionEnd!;
const spaces = ' ';
/** Insert 4 spaces */
editor.value =
editor.value.substring(0, start) +
spaces +
editor.value.substring(end);
/** Place cursor 4 spaces away from where the tab key was pressed */
editor.selectionStart = editor.selectionEnd = start + 4;
}
this.editorValues.text = editor.value;
this.controller.save({
editorValues: copyEditorValues(this.editorValues),
bypassDebouncer: true,
});
},
});
editor.addEventListener('scroll', this.setScrollPosition);
editor.addEventListener('input', this.resetScrollPosition);
/**
* Handles when the editor is destroyed,
* (and not when our controller is destroyed.)
*/
angular.element(editor).one('$destroy', () => {
this.removeTabObserver?.();
this.removeTabObserver = undefined;
editor.removeEventListener('scroll', this.setScrollPosition);
editor.removeEventListener('scroll', this.resetScrollPosition);
this.scrollPosition = 0;
});
}
}
export class NoteViewDirective extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.scope = {
controller: '=',
application: '=',
};
this.template = template;
this.replace = true;
this.controller = NoteView;
this.controllerAs = 'self';
this.bindToController = true;
}
}