Merge branch 'release/10.10.0'
This commit is contained in:
1
.babelrc
1
.babelrc
@@ -4,7 +4,6 @@
|
||||
"@babel/preset-env"
|
||||
],
|
||||
"plugins": [
|
||||
"angularjs-annotate",
|
||||
["@babel/plugin-transform-react-jsx", {
|
||||
"pragma": "h",
|
||||
"pragmaFrag": "Fragment"
|
||||
|
||||
3
app/assets/icons/ic-editor.svg
Normal file
3
app/assets/icons/ic-editor.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.1667 3.60714V8.42857L12.5 6.82143L10.8333 8.42857V3.60714H7.5V16.4643H15.8333V3.60714H14.1667ZM2.5 6.01786V4.41071H4.16667V3.60714C4.16667 2.71518 4.91667 2 5.83333 2H15.8333C16.7083 2 17.5 2.76339 17.5 3.60714V16.4643C17.5 17.308 16.7083 18.0714 15.8333 18.0714H5.83333C4.95833 18.0714 4.16667 17.308 4.16667 16.4643V15.6607H2.5V14.0536H4.16667V10.8393H2.5V9.23214H4.16667V6.01786H2.5ZM4.16667 4.41071V6.01786H5.83333V4.41071H4.16667ZM4.16667 15.6607H5.83333V14.0536H4.16667V15.6607ZM4.16667 10.8393H5.83333V9.23214H4.16667V10.8393Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 632 B |
3
app/assets/icons/ic-user-switch.svg
Normal file
3
app/assets/icons/ic-user-switch.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.8334 17.4999V15.8333H12.5001V14.1666H15.8334V12.4999L18.3334 14.9999L15.8334 17.4999ZM10.8334 14.9999C10.8334 15.5916 10.9584 16.1583 11.1834 16.6666H1.66675V14.1666C1.66675 12.3249 4.65008 10.8333 8.33341 10.8333C9.16675 10.8333 9.96675 10.9083 10.7084 11.0499C11.4001 11.1833 12.0334 11.3666 12.5917 11.5999C11.5251 12.3583 10.8334 13.5999 10.8334 14.9999ZM3.33341 14.1666V14.9999H9.16675C9.16675 14.1333 9.35841 13.3083 9.70008 12.5666L8.33341 12.4999C5.57508 12.4999 3.33341 13.2499 3.33341 14.1666ZM8.33341 3.33325C9.21747 3.33325 10.0653 3.68444 10.6904 4.30956C11.3156 4.93468 11.6667 5.78253 11.6667 6.66659C11.6667 7.55064 11.3156 8.39849 10.6904 9.02361C10.0653 9.64873 9.21747 9.99992 8.33341 9.99992C7.44936 9.99992 6.60151 9.64873 5.97639 9.02361C5.35127 8.39849 5.00008 7.55064 5.00008 6.66659C5.00008 5.78253 5.35127 4.93468 5.97639 4.30956C6.60151 3.68444 7.44936 3.33325 8.33341 3.33325ZM8.33341 4.99992C7.89139 4.99992 7.46746 5.17551 7.1549 5.48807C6.84234 5.80063 6.66675 6.22456 6.66675 6.66659C6.66675 7.10861 6.84234 7.53254 7.1549 7.8451C7.46746 8.15766 7.89139 8.33325 8.33341 8.33325C8.77544 8.33325 9.19936 8.15766 9.51193 7.8451C9.82449 7.53254 10.0001 7.10861 10.0001 6.66659C10.0001 6.22456 9.82449 5.80063 9.51193 5.48807C9.19936 5.17551 8.77544 4.99992 8.33341 4.99992Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
5
app/assets/icons/ic-warning.svg
Normal file
5
app/assets/icons/ic-warning.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 20 20" fill="#fff" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M10.8333 10.8337H9.16663V5.83366H10.8333V10.8337ZM10.8333 14.167H9.16663V12.5003H10.8333V14.167ZM9.99996 1.66699C8.90561 1.66699 7.82198 1.88254 6.81093 2.30133C5.79988 2.72012 4.88122 3.33395 4.1074 4.10777C2.5446 5.67057 1.66663 7.79019 1.66663 10.0003C1.66663 12.2105 2.5446 14.3301 4.1074 15.8929C4.88122 16.6667 5.79988 17.2805 6.81093 17.6993C7.82198 18.1181 8.90561 18.3337 9.99996 18.3337C12.2101 18.3337 14.3297 17.4557 15.8925 15.8929C17.4553 14.3301 18.3333 12.2105 18.3333 10.0003C18.3333 8.90598 18.1177 7.82234 17.699 6.8113C17.2802 5.80025 16.6663 4.88159 15.8925 4.10777C15.1187 3.33395 14.2 2.72012 13.189 2.30133C12.1779 1.88254 11.0943 1.66699 9.99996 1.66699Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 802 B |
@@ -1,226 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// eslint-disable-next-line camelcase
|
||||
_bugsnag_api_key?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_purchase_url?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_plans_url?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_dashboard_url?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_default_sync_server: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_enable_unfinished_features: boolean;
|
||||
// eslint-disable-next-line camelcase
|
||||
_websocket_url: string;
|
||||
startApplication?: StartApplication;
|
||||
|
||||
_devAccountEmail?: string;
|
||||
_devAccountPassword?: string;
|
||||
_devAccountServer?: string;
|
||||
}
|
||||
}
|
||||
|
||||
import { ComponentViewDirective } from '@/components/ComponentView';
|
||||
import { NavigationDirective } from '@/components/Navigation';
|
||||
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
|
||||
import { IsWebPlatform, WebAppVersion } from '@/version';
|
||||
import {
|
||||
ApplicationGroupView,
|
||||
ApplicationView,
|
||||
ChallengeModal,
|
||||
FooterView,
|
||||
NoteGroupViewDirective,
|
||||
NoteViewDirective,
|
||||
} from '@/views';
|
||||
import { SNLog } from '@standardnotes/snjs';
|
||||
import angular from 'angular';
|
||||
import { AccountMenuDirective } from './components/AccountMenu';
|
||||
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
|
||||
import { IconDirective } from './components/Icon';
|
||||
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
|
||||
import { NoAccountWarningDirective } from './components/NoAccountWarning';
|
||||
import { NotesContextMenuDirective } from './components/NotesContextMenu';
|
||||
import { NotesListOptionsDirective } from './components/NotesListOptionsMenu';
|
||||
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
|
||||
import { NotesViewDirective } from './components/NotesView';
|
||||
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
|
||||
import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
|
||||
import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { SearchOptionsDirective } from './components/SearchOptions';
|
||||
import { SessionsModalDirective } from './components/SessionsModal';
|
||||
import {
|
||||
autofocus,
|
||||
clickOutside,
|
||||
delayHide,
|
||||
elemReady,
|
||||
fileChange,
|
||||
lowercase,
|
||||
selectOnFocus,
|
||||
snEnter,
|
||||
} from './directives/functional';
|
||||
import {
|
||||
ActionsMenu,
|
||||
EditorMenu,
|
||||
HistoryMenu,
|
||||
InputModal,
|
||||
MenuRow,
|
||||
PanelResizer,
|
||||
PasswordWizard,
|
||||
PermissionsModal,
|
||||
RevisionPreviewModal,
|
||||
SyncResolutionMenu,
|
||||
} from './directives/views';
|
||||
import { trusted } from './filters';
|
||||
import { PreferencesDirective } from './preferences';
|
||||
import { PurchaseFlowDirective } from './purchaseFlow';
|
||||
import { configRoutes } from './routes';
|
||||
import { Bridge } from './services/bridge';
|
||||
import { BrowserBridge } from './services/browserBridge';
|
||||
import { startErrorReporting } from './services/errorReporting';
|
||||
import { StartApplication } from './startApplication';
|
||||
import { ApplicationGroup } from './ui_models/application_group';
|
||||
import { isDev } from './utils';
|
||||
import { AccountSwitcher } from './views/account_switcher/account_switcher';
|
||||
|
||||
function reloadHiddenFirefoxTab(): boolean {
|
||||
/**
|
||||
* For Firefox pinned tab issue:
|
||||
* When a new browser session is started, and SN is in a pinned tab,
|
||||
* SN exhibits strange behavior until the tab is reloaded.
|
||||
*/
|
||||
if (
|
||||
document.hidden &&
|
||||
navigator.userAgent.toLowerCase().includes('firefox')
|
||||
) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const startApplication: StartApplication = async function startApplication(
|
||||
defaultSyncServerHost: string,
|
||||
bridge: Bridge,
|
||||
enableUnfinishedFeatures: boolean,
|
||||
webSocketUrl: string
|
||||
) {
|
||||
if (reloadHiddenFirefoxTab()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SNLog.onLog = console.log;
|
||||
startErrorReporting();
|
||||
|
||||
angular.module('app', []);
|
||||
|
||||
// Config
|
||||
angular
|
||||
.module('app')
|
||||
.config(configRoutes)
|
||||
.constant('bridge', bridge)
|
||||
.constant('defaultSyncServerHost', defaultSyncServerHost)
|
||||
.constant('appVersion', bridge.appVersion)
|
||||
.constant('enableUnfinishedFeatures', enableUnfinishedFeatures)
|
||||
.constant('webSocketUrl', webSocketUrl);
|
||||
|
||||
// Controllers
|
||||
angular
|
||||
.module('app')
|
||||
.directive('applicationGroupView', () => new ApplicationGroupView())
|
||||
.directive('applicationView', () => new ApplicationView())
|
||||
.directive('noteGroupView', () => new NoteGroupViewDirective())
|
||||
.directive('noteView', () => new NoteViewDirective())
|
||||
.directive('footerView', () => new FooterView());
|
||||
|
||||
// Directives - Functional
|
||||
angular
|
||||
.module('app')
|
||||
.directive('snAutofocus', ['$timeout', autofocus])
|
||||
.directive('clickOutside', ['$document', clickOutside])
|
||||
.directive('delayHide', delayHide)
|
||||
.directive('elemReady', elemReady)
|
||||
.directive('fileChange', fileChange)
|
||||
.directive('lowercase', lowercase)
|
||||
.directive('selectOnFocus', ['$window', selectOnFocus])
|
||||
.directive('snEnter', snEnter);
|
||||
|
||||
// Directives - Views
|
||||
angular
|
||||
.module('app')
|
||||
.directive('accountSwitcher', () => new AccountSwitcher())
|
||||
.directive('actionsMenu', () => new ActionsMenu())
|
||||
.directive('challengeModal', () => new ChallengeModal())
|
||||
.directive('componentView', ComponentViewDirective)
|
||||
.directive('editorMenu', () => new EditorMenu())
|
||||
.directive('inputModal', () => new InputModal())
|
||||
.directive('menuRow', () => new MenuRow())
|
||||
.directive('panelResizer', () => new PanelResizer())
|
||||
.directive('passwordWizard', () => new PasswordWizard())
|
||||
.directive('permissionsModal', () => new PermissionsModal())
|
||||
.directive('revisionPreviewModal', () => new RevisionPreviewModal())
|
||||
.directive('historyMenu', () => new HistoryMenu())
|
||||
.directive('syncResolutionMenu', () => new SyncResolutionMenu())
|
||||
.directive('sessionsModal', SessionsModalDirective)
|
||||
.directive('accountMenu', AccountMenuDirective)
|
||||
.directive('quickSettingsMenu', QuickSettingsMenuDirective)
|
||||
.directive('noAccountWarning', NoAccountWarningDirective)
|
||||
.directive('protectedNotePanel', ProtectedNoteOverlayDirective)
|
||||
.directive('searchOptions', SearchOptionsDirective)
|
||||
.directive('confirmSignout', ConfirmSignoutDirective)
|
||||
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
|
||||
.directive('notesContextMenu', NotesContextMenuDirective)
|
||||
.directive('notesOptionsPanel', NotesOptionsPanelDirective)
|
||||
.directive('notesListOptionsMenu', NotesListOptionsDirective)
|
||||
.directive('icon', IconDirective)
|
||||
.directive('noteTagsContainer', NoteTagsContainerDirective)
|
||||
.directive('navigation', NavigationDirective)
|
||||
.directive('preferences', PreferencesDirective)
|
||||
.directive('purchaseFlow', PurchaseFlowDirective)
|
||||
.directive('notesView', NotesViewDirective)
|
||||
.directive('pinNoteButton', PinNoteButtonDirective);
|
||||
|
||||
// Filters
|
||||
angular.module('app').filter('trusted', ['$sce', trusted]);
|
||||
|
||||
// Services
|
||||
angular.module('app').service('mainApplicationGroup', ApplicationGroup);
|
||||
|
||||
// Debug
|
||||
if (isDev) {
|
||||
Object.defineProperties(window, {
|
||||
application: {
|
||||
get: () =>
|
||||
(
|
||||
angular
|
||||
.element(document)
|
||||
.injector()
|
||||
.get('mainApplicationGroup') as any
|
||||
).primaryApplication,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
angular.element(document).ready(() => {
|
||||
angular.bootstrap(document, ['app']);
|
||||
});
|
||||
};
|
||||
|
||||
if (IsWebPlatform) {
|
||||
startApplication(
|
||||
window._default_sync_server,
|
||||
new BrowserBridge(WebAppVersion),
|
||||
window._enable_unfinished_features,
|
||||
window._websocket_url
|
||||
);
|
||||
} else {
|
||||
window.startApplication = startApplication;
|
||||
}
|
||||
89
app/assets/javascripts/app.tsx
Normal file
89
app/assets/javascripts/app.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// eslint-disable-next-line camelcase
|
||||
_bugsnag_api_key?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_purchase_url?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_plans_url?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_dashboard_url?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_default_sync_server: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
_enable_unfinished_features: boolean;
|
||||
// eslint-disable-next-line camelcase
|
||||
_websocket_url: string;
|
||||
startApplication?: StartApplication;
|
||||
|
||||
_devAccountEmail?: string;
|
||||
_devAccountPassword?: string;
|
||||
_devAccountServer?: string;
|
||||
}
|
||||
}
|
||||
|
||||
import { IsWebPlatform, WebAppVersion } from '@/version';
|
||||
import { Runtime, SNLog } from '@standardnotes/snjs';
|
||||
import { render } from 'preact';
|
||||
import { ApplicationGroupView } from './components/ApplicationGroupView';
|
||||
import { Bridge } from './services/bridge';
|
||||
import { BrowserBridge } from './services/browserBridge';
|
||||
import { startErrorReporting } from './services/errorReporting';
|
||||
import { StartApplication } from './startApplication';
|
||||
import { ApplicationGroup } from './ui_models/application_group';
|
||||
import { isDev } from './utils';
|
||||
|
||||
const startApplication: StartApplication = async function startApplication(
|
||||
defaultSyncServerHost: string,
|
||||
bridge: Bridge,
|
||||
enableUnfinishedFeatures: boolean,
|
||||
webSocketUrl: string
|
||||
) {
|
||||
SNLog.onLog = console.log;
|
||||
startErrorReporting();
|
||||
|
||||
const mainApplicationGroup = new ApplicationGroup(
|
||||
defaultSyncServerHost,
|
||||
bridge,
|
||||
enableUnfinishedFeatures ? Runtime.Dev : Runtime.Prod,
|
||||
webSocketUrl
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
Object.defineProperties(window, {
|
||||
application: {
|
||||
get: () => mainApplicationGroup.primaryApplication,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const renderApp = () => {
|
||||
render(
|
||||
<ApplicationGroupView mainApplicationGroup={mainApplicationGroup} />,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
const domReady =
|
||||
document.readyState === 'complete' || document.readyState === 'interactive';
|
||||
if (domReady) {
|
||||
renderApp();
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
renderApp();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (IsWebPlatform) {
|
||||
startApplication(
|
||||
window._default_sync_server,
|
||||
new BrowserBridge(WebAppVersion),
|
||||
window._enable_unfinished_features,
|
||||
window._websocket_url
|
||||
);
|
||||
} else {
|
||||
window.startApplication = startApplication;
|
||||
}
|
||||
@@ -2,38 +2,27 @@ import { ApplicationEvent } from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx';
|
||||
import { Component } from 'preact';
|
||||
import { findDOMNode, unmountComponentAtNode } from 'preact/compat';
|
||||
|
||||
export type CtrlState = Partial<Record<string, any>>;
|
||||
export type CtrlProps = Partial<Record<string, any>>;
|
||||
export type PureComponentState = Partial<Record<string, any>>;
|
||||
export type PureComponentProps = 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;
|
||||
export abstract class PureComponent<
|
||||
P = PureComponentProps,
|
||||
S = PureComponentState
|
||||
> extends Component<P, S> {
|
||||
private unsubApp!: () => void;
|
||||
private unsubState!: () => void;
|
||||
private reactionDisposers: IReactionDisposer[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService, public props: P = {} as any) {
|
||||
this.$timeout = $timeout;
|
||||
constructor(props: P, protected application: WebApplication) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
$onInit(): void {
|
||||
this.state = {
|
||||
...this.getInitialState(),
|
||||
...this.state,
|
||||
};
|
||||
componentDidMount() {
|
||||
this.addAppEventObserver();
|
||||
this.addAppStateObserver();
|
||||
this.templateReady = true;
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
@@ -43,63 +32,38 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
|
||||
disposer();
|
||||
}
|
||||
this.reactionDisposers.length = 0;
|
||||
this.unsubApp = undefined;
|
||||
this.unsubState = undefined;
|
||||
if (this.stateTimeout) {
|
||||
this.$timeout.cancel(this.stateTimeout);
|
||||
}
|
||||
(this.unsubApp as unknown) = undefined;
|
||||
(this.unsubState as unknown) = undefined;
|
||||
}
|
||||
|
||||
$onDestroy(): void {
|
||||
protected dismissModal(): void {
|
||||
const elem = this.getElement();
|
||||
if (!elem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = elem.parentElement;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
parent.remove();
|
||||
unmountComponentAtNode(parent);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.deinit();
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>Must override</div>;
|
||||
}
|
||||
|
||||
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));
|
||||
protected getElement(): Element | null {
|
||||
return findDOMNode(this);
|
||||
}
|
||||
|
||||
autorun(view: (r: IReactionPublic) => void): void {
|
||||
@@ -151,7 +115,7 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
|
||||
|
||||
/** @override */
|
||||
async onAppStart() {
|
||||
await this.resetState();
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onLocalDataLoaded() {
|
||||
@@ -1,9 +1,8 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { isDev } from '@/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
import { Icon } from '../Icon';
|
||||
import { InputWithIcon } from '../InputWithIcon';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { toDirective } from '@/components/utils';
|
||||
import { useCloseOnClickOutside } from '@/components/utils';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { GeneralAccountMenu } from './GeneralAccountMenu';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { SignInPane } from './SignIn';
|
||||
@@ -21,9 +21,12 @@ export enum AccountMenuPane {
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
onClickOutside: () => void;
|
||||
};
|
||||
|
||||
type PaneSelectorProps = Props & {
|
||||
type PaneSelectorProps = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
menuPane: AccountMenuPane;
|
||||
setMenuPane: (pane: AccountMenuPane) => void;
|
||||
closeMenu: () => void;
|
||||
@@ -79,8 +82,8 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
|
||||
}
|
||||
);
|
||||
|
||||
const AccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
export const AccountMenu: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickOutside }) => {
|
||||
const {
|
||||
currentPane,
|
||||
setCurrentPane,
|
||||
@@ -88,6 +91,11 @@ const AccountMenu: FunctionComponent<Props> = observer(
|
||||
closeAccountMenu,
|
||||
} = appState.accountMenu;
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useCloseOnClickOutside(ref, () => {
|
||||
onClickOutside();
|
||||
});
|
||||
|
||||
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (
|
||||
event
|
||||
) => {
|
||||
@@ -105,7 +113,7 @@ const AccountMenu: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='sn-component'>
|
||||
<div ref={ref} id="account-menu" className="sn-component">
|
||||
<div
|
||||
className={`sn-menu-border sn-account-menu sn-dropdown ${
|
||||
shouldAnimateCloseMenu
|
||||
@@ -130,5 +138,3 @@ const AccountMenu: FunctionComponent<Props> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const AccountMenuDirective = toDirective<Props>(AccountMenu);
|
||||
|
||||
169
app/assets/javascripts/components/AccountSwitcher.tsx
Normal file
169
app/assets/javascripts/components/AccountSwitcher.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ApplicationDescriptor } from '@standardnotes/snjs';
|
||||
import { PureComponent } from '@/components/Abstract/PureComponent';
|
||||
import { JSX } from 'preact';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
};
|
||||
|
||||
type State = {
|
||||
descriptors: ApplicationDescriptor[];
|
||||
editingDescriptor?: ApplicationDescriptor;
|
||||
};
|
||||
|
||||
export class AccountSwitcher extends PureComponent<Props, State> {
|
||||
private removeAppGroupObserver: any;
|
||||
activeApplication!: WebApplication;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.removeAppGroupObserver =
|
||||
props.mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
this.activeApplication = props.mainApplicationGroup
|
||||
.primaryApplication as WebApplication;
|
||||
this.reloadApplications();
|
||||
});
|
||||
}
|
||||
|
||||
reloadApplications() {
|
||||
this.setState({
|
||||
descriptors: this.props.mainApplicationGroup.getDescriptors(),
|
||||
});
|
||||
}
|
||||
|
||||
addNewApplication = () => {
|
||||
this.dismiss();
|
||||
this.props.mainApplicationGroup.addNewApplication();
|
||||
};
|
||||
|
||||
selectDescriptor = (descriptor: ApplicationDescriptor) => {
|
||||
this.dismiss();
|
||||
this.props.mainApplicationGroup.loadApplicationForDescriptor(descriptor);
|
||||
};
|
||||
|
||||
inputForDescriptor(descriptor: ApplicationDescriptor) {
|
||||
return document.getElementById(`input-${descriptor.identifier}`);
|
||||
}
|
||||
|
||||
renameDescriptor = (event: Event, descriptor: ApplicationDescriptor) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.setState({ editingDescriptor: descriptor });
|
||||
|
||||
setTimeout(() => {
|
||||
this.inputForDescriptor(descriptor)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
submitRename = () => {
|
||||
this.props.mainApplicationGroup.renameDescriptor(
|
||||
this.state.editingDescriptor!,
|
||||
this.state.editingDescriptor!.label
|
||||
);
|
||||
this.setState({ editingDescriptor: undefined });
|
||||
};
|
||||
|
||||
deinit() {
|
||||
super.deinit();
|
||||
this.removeAppGroupObserver();
|
||||
this.removeAppGroupObserver = undefined;
|
||||
}
|
||||
|
||||
onDescriptorInputChange = (
|
||||
descriptor: ApplicationDescriptor,
|
||||
{ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>
|
||||
) => {
|
||||
descriptor.label = currentTarget.value;
|
||||
};
|
||||
|
||||
dismiss = () => {
|
||||
this.dismissModal();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<div onClick={this.dismiss} className="sk-modal-background" />
|
||||
<div id="account-switcher" className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div id="menu-panel" className="sk-menu-panel">
|
||||
<div className="sk-menu-panel-header">
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-menu-panel-header-title">
|
||||
Account Switcher
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-menu-panel-column">
|
||||
<a onClick={this.addNewApplication} className="sk-label info">
|
||||
Add Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.descriptors.map((descriptor) => {
|
||||
return (
|
||||
<div
|
||||
key={descriptor.identifier}
|
||||
onClick={() => this.selectDescriptor(descriptor)}
|
||||
className="sk-menu-panel-row"
|
||||
>
|
||||
<div className="sk-menu-panel-column stretch">
|
||||
<div className="left">
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-circle small success" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sk-menu-panel-column stretch">
|
||||
<input
|
||||
value={descriptor.label}
|
||||
disabled={
|
||||
descriptor !== this.state.editingDescriptor
|
||||
}
|
||||
onChange={(event) =>
|
||||
this.onDescriptorInputChange(descriptor, event)
|
||||
}
|
||||
onKeyUp={(event) =>
|
||||
event.keyCode == 13 && this.submitRename()
|
||||
}
|
||||
id={`input-${descriptor.identifier}`}
|
||||
spellcheck={false}
|
||||
className="sk-label clickable"
|
||||
/>
|
||||
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-sublabel">
|
||||
Current Application
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{descriptor.identifier ==
|
||||
this.activeApplication.identifier && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<button
|
||||
onClick={(event) =>
|
||||
this.renameDescriptor(event, descriptor)
|
||||
}
|
||||
className="sn-button success"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
329
app/assets/javascripts/components/ActionsMenu.tsx
Normal file
329
app/assets/javascripts/components/ActionsMenu.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
Action,
|
||||
SNActionsExtension,
|
||||
UuidString,
|
||||
SNNote,
|
||||
ListedAccount,
|
||||
} from '@standardnotes/snjs';
|
||||
import { ActionResponse } from '@standardnotes/snjs';
|
||||
import { render } from 'preact';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { MenuRow } from './MenuRow';
|
||||
import { RevisionPreviewModal } from './RevisionPreviewModal';
|
||||
|
||||
type ActionRow = Action & {
|
||||
running?: boolean;
|
||||
spinnerClass?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
type MenuSection = {
|
||||
uuid: UuidString;
|
||||
name: string;
|
||||
loading?: boolean;
|
||||
error?: boolean;
|
||||
hidden?: boolean;
|
||||
deprecation?: string;
|
||||
extension?: SNActionsExtension;
|
||||
rows?: ActionRow[];
|
||||
listedAccount?: ListedAccount;
|
||||
};
|
||||
|
||||
type State = {
|
||||
menuSections: MenuSection[];
|
||||
selectedActionIdentifier?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
note: SNNote;
|
||||
};
|
||||
|
||||
export class ActionsMenu extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
|
||||
this.state = {
|
||||
menuSections: [],
|
||||
};
|
||||
|
||||
this.loadExtensions();
|
||||
}
|
||||
|
||||
private async loadExtensions(): Promise<void> {
|
||||
const unresolvedListedSections =
|
||||
await this.getNonresolvedListedMenuSections();
|
||||
const unresolvedGenericSections =
|
||||
await this.getNonresolvedGenericMenuSections();
|
||||
this.setState(
|
||||
{
|
||||
menuSections: unresolvedListedSections.concat(
|
||||
unresolvedGenericSections
|
||||
),
|
||||
},
|
||||
() => {
|
||||
this.state.menuSections.forEach((menuSection) => {
|
||||
this.resolveMenuSection(menuSection);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async getNonresolvedGenericMenuSections(): Promise<MenuSection[]> {
|
||||
const genericExtensions = this.props.application.actionsManager
|
||||
.getExtensions()
|
||||
.sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
|
||||
return genericExtensions.map((extension) => {
|
||||
const menuSection: MenuSection = {
|
||||
uuid: extension.uuid,
|
||||
name: extension.name,
|
||||
extension: extension,
|
||||
loading: true,
|
||||
hidden: this.appState.actionsMenu.hiddenSections[extension.uuid],
|
||||
};
|
||||
return menuSection;
|
||||
});
|
||||
}
|
||||
|
||||
private async getNonresolvedListedMenuSections(): Promise<MenuSection[]> {
|
||||
const listedAccountEntries =
|
||||
await this.props.application.getListedAccounts();
|
||||
return listedAccountEntries.map((entry) => {
|
||||
const menuSection: MenuSection = {
|
||||
uuid: entry.authorId,
|
||||
name: `Listed ${entry.authorId}`,
|
||||
loading: true,
|
||||
listedAccount: entry,
|
||||
hidden: this.appState.actionsMenu.hiddenSections[entry.authorId],
|
||||
};
|
||||
return menuSection;
|
||||
});
|
||||
}
|
||||
|
||||
private resolveMenuSection(menuSection: MenuSection): void {
|
||||
if (menuSection.listedAccount) {
|
||||
this.props.application
|
||||
.getListedAccountInfo(menuSection.listedAccount, this.props.note.uuid)
|
||||
.then((accountInfo) => {
|
||||
if (!accountInfo) {
|
||||
this.promoteMenuSection({
|
||||
...menuSection,
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existingMenuSection = this.state.menuSections.find(
|
||||
(item) => item.uuid === menuSection.listedAccount?.authorId
|
||||
) as MenuSection;
|
||||
const resolvedMenuSection: MenuSection = {
|
||||
...existingMenuSection,
|
||||
loading: false,
|
||||
error: false,
|
||||
name: accountInfo.display_name,
|
||||
rows: accountInfo?.actions,
|
||||
};
|
||||
this.promoteMenuSection(resolvedMenuSection);
|
||||
});
|
||||
} else if (menuSection.extension) {
|
||||
this.props.application.actionsManager
|
||||
.loadExtensionInContextOfItem(menuSection.extension, this.props.note)
|
||||
.then((resolvedExtension) => {
|
||||
if (!resolvedExtension) {
|
||||
this.promoteMenuSection({
|
||||
...menuSection,
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = resolvedExtension.actionsWithContextForItem(
|
||||
this.props.note
|
||||
);
|
||||
|
||||
const resolvedMenuSection: MenuSection = {
|
||||
...menuSection,
|
||||
rows: actions,
|
||||
deprecation: resolvedExtension.deprecation,
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
this.promoteMenuSection(resolvedMenuSection);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private promoteMenuSection(newItem: MenuSection): void {
|
||||
const menuSections = this.state.menuSections.map((menuSection) => {
|
||||
if (menuSection.uuid === newItem.uuid) {
|
||||
return newItem;
|
||||
} else {
|
||||
return menuSection;
|
||||
}
|
||||
});
|
||||
this.setState({ menuSections });
|
||||
}
|
||||
|
||||
private promoteAction(newAction: Action, section: MenuSection): void {
|
||||
const newSection: MenuSection = {
|
||||
...section,
|
||||
rows: section.rows?.map((action) => {
|
||||
if (action.url === newAction.url) {
|
||||
return newAction;
|
||||
} else {
|
||||
return action;
|
||||
}
|
||||
}),
|
||||
};
|
||||
this.promoteMenuSection(newSection);
|
||||
}
|
||||
|
||||
private idForAction(action: Action) {
|
||||
return `${action.label}:${action.verb}:${action.desc}`;
|
||||
}
|
||||
|
||||
executeAction = async (action: Action, section: MenuSection) => {
|
||||
const isLegacyNoteHistoryExt = action.verb === 'nested';
|
||||
if (isLegacyNoteHistoryExt) {
|
||||
const showRevisionAction = action.subactions![0];
|
||||
action = showRevisionAction;
|
||||
}
|
||||
|
||||
this.promoteAction(
|
||||
{
|
||||
...action,
|
||||
running: true,
|
||||
},
|
||||
section
|
||||
);
|
||||
|
||||
const response = await this.props.application.actionsManager.runAction(
|
||||
action,
|
||||
this.props.note
|
||||
);
|
||||
|
||||
this.promoteAction(
|
||||
{
|
||||
...action,
|
||||
running: false,
|
||||
},
|
||||
section
|
||||
);
|
||||
|
||||
if (!response || response.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleActionResponse(action, response);
|
||||
this.resolveMenuSection(section);
|
||||
};
|
||||
|
||||
handleActionResponse(action: Action, result: ActionResponse) {
|
||||
switch (action.verb) {
|
||||
case 'render': {
|
||||
const item = result.item;
|
||||
render(
|
||||
<RevisionPreviewModal
|
||||
application={this.application}
|
||||
uuid={item.uuid}
|
||||
content={item.content}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public toggleSectionVisibility(menuSection: MenuSection) {
|
||||
this.appState.actionsMenu.toggleSectionVisibility(menuSection.uuid);
|
||||
this.promoteMenuSection({
|
||||
...menuSection,
|
||||
hidden: !menuSection.hidden,
|
||||
});
|
||||
}
|
||||
|
||||
renderMenuSection(section: MenuSection) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
key={section.uuid}
|
||||
className="sk-menu-panel-header"
|
||||
onClick={($event) => {
|
||||
this.toggleSectionVisibility(section);
|
||||
$event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="sk-menu-panel-header-title">{section.name}</div>
|
||||
{section.hidden && <div>…</div>}
|
||||
{section.deprecation && !section.hidden && (
|
||||
<div className="sk-menu-panel-header-subtitle">
|
||||
{section.deprecation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{section.loading && <div className="sk-spinner small loading" />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{section.error && !section.hidden && (
|
||||
<MenuRow
|
||||
faded={true}
|
||||
label="Error loading actions"
|
||||
subtitle="Please try again later."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!section.rows?.length && !section.hidden && (
|
||||
<MenuRow faded={true} label="No Actions Available" />
|
||||
)}
|
||||
|
||||
{!section.hidden &&
|
||||
!section.loading &&
|
||||
!section.error &&
|
||||
section.rows?.map((action, index) => {
|
||||
return (
|
||||
<MenuRow
|
||||
key={index}
|
||||
action={() => {
|
||||
this.executeAction(action, section);
|
||||
}}
|
||||
label={action.label}
|
||||
disabled={action.running}
|
||||
spinnerClass={action.running ? 'info' : undefined}
|
||||
subtitle={action.desc}
|
||||
>
|
||||
{action.access_type && (
|
||||
<div className="sk-sublabel">
|
||||
{'Uses '}
|
||||
<strong>{action.access_type}</strong>
|
||||
{' access to this note.'}
|
||||
</div>
|
||||
)}
|
||||
</MenuRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div className="sk-menu-panel dropdown-menu">
|
||||
{this.state.menuSections.length == 0 && (
|
||||
<MenuRow label="No Actions" />
|
||||
)}
|
||||
{this.state.menuSections.map((extension) =>
|
||||
this.renderMenuSection(extension)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/assets/javascripts/components/ApplicationGroupView.tsx
Normal file
42
app/assets/javascripts/components/ApplicationGroupView.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Component } from 'preact';
|
||||
import { ApplicationView } from './ApplicationView';
|
||||
|
||||
type State = {
|
||||
activeApplication?: WebApplication;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
};
|
||||
|
||||
export class ApplicationGroupView extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
props.mainApplicationGroup.addApplicationChangeObserver(() => {
|
||||
const activeApplication = props.mainApplicationGroup
|
||||
.primaryApplication as WebApplication;
|
||||
this.setState({ activeApplication });
|
||||
});
|
||||
|
||||
props.mainApplicationGroup.initialize();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
{this.state.activeApplication && (
|
||||
<div id={this.state.activeApplication.identifier}>
|
||||
<ApplicationView
|
||||
key={this.state.activeApplication.ephemeralIdentifier}
|
||||
mainApplicationGroup={this.props.mainApplicationGroup}
|
||||
application={this.state.activeApplication}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
274
app/assets/javascripts/components/ApplicationView.tsx
Normal file
274
app/assets/javascripts/components/ApplicationView.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { getPlatformString } from '@/utils';
|
||||
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
Challenge,
|
||||
PermissionDialog,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
|
||||
import { alertDialog } from '@/services/alertService';
|
||||
import { WebAppEvent, WebApplication } from '@/ui_models/application';
|
||||
import { PureComponent } from '@/components/Abstract/PureComponent';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { NotesView } from '@/components/NotesView';
|
||||
import { NoteGroupView } from '@/components/NoteGroupView';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { SessionsModal } from '@/components/SessionsModal';
|
||||
import { PreferencesViewWrapper } from '@/preferences/PreferencesViewWrapper';
|
||||
import { ChallengeModal } from '@/components/ChallengeModal';
|
||||
import { NotesContextMenu } from '@/components/NotesContextMenu';
|
||||
import { PurchaseFlowWrapper } from '@/purchaseFlow/PurchaseFlowWrapper';
|
||||
import { render } from 'preact';
|
||||
import { PermissionsModal } from './PermissionsModal';
|
||||
import { PremiumModalProvider } from './Premium';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
mainApplicationGroup: ApplicationGroup;
|
||||
};
|
||||
|
||||
type State = {
|
||||
started?: boolean;
|
||||
launched?: boolean;
|
||||
needsUnlock?: boolean;
|
||||
appClass: string;
|
||||
challenges: Challenge[];
|
||||
};
|
||||
|
||||
export class ApplicationView extends PureComponent<Props, State> {
|
||||
public readonly platformString = getPlatformString();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.state = {
|
||||
appClass: '',
|
||||
challenges: [],
|
||||
};
|
||||
this.onDragDrop = this.onDragDrop.bind(this);
|
||||
this.onDragOver = this.onDragOver.bind(this);
|
||||
this.addDragDropHandlers();
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(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();
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.loadApplication();
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
this.application.componentManager.setDesktopManager(
|
||||
this.application.getDesktopService()
|
||||
);
|
||||
await this.application.prepareForLaunch({
|
||||
receiveChallenge: async (challenge) => {
|
||||
const challenges = this.state.challenges.slice();
|
||||
challenges.push(challenge);
|
||||
this.setState({ challenges: challenges });
|
||||
},
|
||||
});
|
||||
await this.application.launch();
|
||||
}
|
||||
|
||||
public removeChallenge = async (challenge: Challenge) => {
|
||||
const challenges = this.state.challenges.slice();
|
||||
removeFromArray(challenges, challenge);
|
||||
this.setState({ challenges: challenges });
|
||||
};
|
||||
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
this.setState({
|
||||
started: true,
|
||||
needsUnlock: this.application.hasPasscode(),
|
||||
});
|
||||
|
||||
this.application.componentManager.presentPermissionsDialog =
|
||||
this.presentPermissionsDialog;
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.setState({
|
||||
launched: true,
|
||||
needsUnlock: false,
|
||||
});
|
||||
this.handleDemoSignInFromParams();
|
||||
}
|
||||
|
||||
onUpdateAvailable() {
|
||||
this.application.notifyWebEvent(WebAppEvent.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;
|
||||
let appClass = '';
|
||||
if (panel === PANEL_NAME_NOTES && collapsed) {
|
||||
appClass += 'collapsed-notes';
|
||||
}
|
||||
if (panel === PANEL_NAME_NAVIGATION && collapsed) {
|
||||
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 (
|
||||
window.location.href.includes('demo') &&
|
||||
!this.application.hasAccount()
|
||||
) {
|
||||
await this.application.setCustomHost(
|
||||
'https://syncing-server-demo.standardnotes.com'
|
||||
);
|
||||
this.application.signIn('demo@standardnotes.org', 'password');
|
||||
}
|
||||
}
|
||||
|
||||
presentPermissionsDialog = (dialog: PermissionDialog) => {
|
||||
render(
|
||||
<PermissionsModal
|
||||
application={this.application}
|
||||
callback={dialog.callback}
|
||||
component={dialog.component}
|
||||
permissionsString={dialog.permissionsString}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.application['dealloced'] === true) {
|
||||
console.error('Attempting to render dealloced application');
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
const renderAppContents = !this.state.needsUnlock && this.state.launched;
|
||||
|
||||
return (
|
||||
<PremiumModalProvider state={this.appState?.features}>
|
||||
<div className={this.platformString + ' main-ui-view sn-component'}>
|
||||
{renderAppContents && (
|
||||
<div
|
||||
id="app"
|
||||
className={this.state.appClass + ' app app-column-container'}
|
||||
>
|
||||
<Navigation application={this.application} />
|
||||
|
||||
<NotesView
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
|
||||
<NoteGroupView application={this.application} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderAppContents && (
|
||||
<>
|
||||
<Footer
|
||||
application={this.application}
|
||||
applicationGroup={this.props.mainApplicationGroup}
|
||||
/>
|
||||
|
||||
<SessionsModal
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
|
||||
<PreferencesViewWrapper
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{this.state.challenges.map((challenge) => {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<ChallengeModal
|
||||
key={challenge.id}
|
||||
application={this.application}
|
||||
challenge={challenge}
|
||||
onDismiss={this.removeChallenge}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{renderAppContents && (
|
||||
<>
|
||||
<NotesContextMenu
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
|
||||
<PurchaseFlowWrapper
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PremiumModalProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
371
app/assets/javascripts/components/ChallengeModal.tsx
Normal file
371
app/assets/javascripts/components/ChallengeModal.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Dialog } from '@reach/dialog';
|
||||
import {
|
||||
ChallengeValue,
|
||||
removeFromArray,
|
||||
Challenge,
|
||||
ChallengeReason,
|
||||
ChallengePrompt,
|
||||
ChallengeValidation,
|
||||
ProtectionSessionDurations,
|
||||
} from '@standardnotes/snjs';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
|
||||
import { createRef } from 'preact';
|
||||
import { PureComponent } from '@/components/Abstract/PureComponent';
|
||||
|
||||
type InputValue = {
|
||||
prompt: ChallengePrompt;
|
||||
value: string | number | boolean;
|
||||
invalid: boolean;
|
||||
};
|
||||
|
||||
type Values = Record<number, InputValue>;
|
||||
|
||||
type State = {
|
||||
prompts: ChallengePrompt[];
|
||||
values: Partial<Values>;
|
||||
processing: boolean;
|
||||
forgotPasscode: boolean;
|
||||
showForgotPasscodeLink: boolean;
|
||||
processingPrompts: ChallengePrompt[];
|
||||
hasAccount: boolean;
|
||||
protectedNoteAccessDuration: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
challenge: Challenge;
|
||||
application: WebApplication;
|
||||
onDismiss: (challenge: Challenge) => void;
|
||||
};
|
||||
|
||||
export class ChallengeModal extends PureComponent<Props, State> {
|
||||
submitting = false;
|
||||
protectionsSessionDurations = ProtectionSessionDurations;
|
||||
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
|
||||
private initialFocusRef = createRef<HTMLInputElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
|
||||
const values = {} as Values;
|
||||
const prompts = this.props.challenge.prompts;
|
||||
for (const prompt of prompts) {
|
||||
values[prompt.id] = {
|
||||
prompt,
|
||||
value: prompt.initialValue ?? '',
|
||||
invalid: false,
|
||||
};
|
||||
}
|
||||
const showForgotPasscodeLink = [
|
||||
ChallengeReason.ApplicationUnlock,
|
||||
ChallengeReason.Migration,
|
||||
].includes(this.props.challenge.reason);
|
||||
this.state = {
|
||||
prompts,
|
||||
values,
|
||||
processing: false,
|
||||
forgotPasscode: false,
|
||||
showForgotPasscodeLink,
|
||||
hasAccount: this.application.hasAccount(),
|
||||
processingPrompts: [],
|
||||
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
|
||||
this.application.addChallengeObserver(this.props.challenge, {
|
||||
onValidValue: (value) => {
|
||||
this.state.values[value.prompt.id]!.invalid = false;
|
||||
removeFromArray(this.state.processingPrompts, value.prompt);
|
||||
this.reloadProcessingStatus();
|
||||
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();
|
||||
}
|
||||
this.afterStateChange();
|
||||
},
|
||||
onComplete: () => {
|
||||
this.dismiss();
|
||||
},
|
||||
onCancel: () => {
|
||||
this.dismiss();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deinit() {
|
||||
(this.application as unknown) = undefined;
|
||||
(this.props.challenge as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
reloadProcessingStatus() {
|
||||
return this.setState({
|
||||
processing: this.state.processingPrompts.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
destroyLocalData = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: STRING_SIGN_OUT_CONFIRMATION,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.dismiss();
|
||||
this.application.signOut();
|
||||
}
|
||||
};
|
||||
|
||||
cancel = () => {
|
||||
if (this.props.challenge.cancelable) {
|
||||
this.application!.cancelChallenge(this.props.challenge);
|
||||
}
|
||||
};
|
||||
|
||||
onForgotPasscodeClick = () => {
|
||||
this.setState({
|
||||
forgotPasscode: true,
|
||||
});
|
||||
};
|
||||
|
||||
onTextValueChange = (prompt: ChallengePrompt) => {
|
||||
const values = this.state.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;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
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.state.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)
|
||||
*/
|
||||
setTimeout(() => {
|
||||
if (values.length > 0) {
|
||||
this.application.submitValuesForChallenge(this.props.challenge, values);
|
||||
} else {
|
||||
this.setState({ processing: false });
|
||||
}
|
||||
this.submitting = false;
|
||||
}, 50);
|
||||
};
|
||||
|
||||
afterStateChange() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
dismiss = () => {
|
||||
this.props.onDismiss(this.props.challenge);
|
||||
};
|
||||
|
||||
private renderChallengePrompts() {
|
||||
return this.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 ===
|
||||
this.state.values[prompt.id]!.value
|
||||
? 'boxed'
|
||||
: '')
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
this.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();
|
||||
this.submit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
value={this.state.values[prompt.id]!.value as string | number}
|
||||
onChange={(event) => {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.state.values[prompt.id]!.value = value;
|
||||
this.onTextValueChange(prompt);
|
||||
}}
|
||||
ref={index === 0 ? this.initialFocusRef : undefined}
|
||||
placeholder={prompt.title}
|
||||
type={prompt.secureTextEntry ? 'password' : 'text'}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.values[prompt.id]!.invalid && (
|
||||
<div className="sk-panel-row centered">
|
||||
<label className="sk-label danger">
|
||||
Invalid authentication. Please try again.
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.prompts) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
initialFocusRef={this.initialFocusRef}
|
||||
onDismiss={() => {
|
||||
if (this.props.challenge.cancelable) {
|
||||
this.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">
|
||||
{this.props.challenge.modalTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-p sk-panel-row centered prompt">
|
||||
<strong>{this.props.challenge.heading}</strong>
|
||||
</div>
|
||||
{this.props.challenge.subheading && (
|
||||
<div className="sk-p sk-panel-row centered subprompt">
|
||||
{this.props.challenge.subheading}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sk-panel-section">
|
||||
{this.renderChallengePrompts()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer extra-padding">
|
||||
<button
|
||||
className={
|
||||
'sn-button w-full ' +
|
||||
(this.state.processing ? 'neutral' : 'info')
|
||||
}
|
||||
disabled={this.state.processing}
|
||||
onClick={() => this.submit()}
|
||||
>
|
||||
{this.state.processing ? 'Generating Keys…' : 'Submit'}
|
||||
</button>
|
||||
{this.props.challenge.cancelable && (
|
||||
<>
|
||||
<div className="sk-panel-row"></div>
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered text-sm"
|
||||
onClick={() => this.cancel()}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{this.state.showForgotPasscodeLink && (
|
||||
<div className="sk-panel-footer">
|
||||
{this.state.forgotPasscode ? (
|
||||
<>
|
||||
<p className="sk-panel-row sk-p text-center">
|
||||
{this.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={() => {
|
||||
this.destroyLocalData();
|
||||
}}
|
||||
>
|
||||
Delete Local Data
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
className="sk-panel-row sk-a info centered"
|
||||
onClick={() => this.onForgotPasscodeClick()}
|
||||
>
|
||||
Forgot your passcode?
|
||||
</a>
|
||||
)}
|
||||
<div className="sk-panel-row"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,13 @@ import {
|
||||
} from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { toDirective } from '@/components/utils';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { OfflineRestricted } from '@/components/ComponentView/OfflineRestricted';
|
||||
import { UrlMissing } from '@/components/ComponentView/UrlMissing';
|
||||
@@ -24,7 +29,7 @@ interface IProps {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
componentViewer: ComponentViewer;
|
||||
requestReload?: (viewer: ComponentViewer) => void;
|
||||
requestReload?: (viewer: ComponentViewer, force?: boolean) => void;
|
||||
onLoad?: (component: SNComponent) => void;
|
||||
manualDealloc?: boolean;
|
||||
}
|
||||
@@ -66,20 +71,6 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
openSubscriptionDashboard(application);
|
||||
}, [application]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTimeout = setTimeout(() => {
|
||||
handleIframeTakingTooLongToLoad();
|
||||
}, MaxLoadThreshold);
|
||||
|
||||
excessiveLoadingTimeout.current = loadTimeout;
|
||||
|
||||
return () => {
|
||||
excessiveLoadingTimeout.current &&
|
||||
clearTimeout(excessiveLoadingTimeout.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const reloadValidityStatus = useCallback(() => {
|
||||
setFeatureStatus(componentViewer.getFeatureStatus());
|
||||
if (!componentViewer.lockReadonly) {
|
||||
@@ -128,28 +119,35 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
} else {
|
||||
document.addEventListener(VisibilityChangeKey, onVisibilityChange);
|
||||
}
|
||||
}, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]);
|
||||
}, [didAttemptReload, onVisibilityChange, componentViewer, requestReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) {
|
||||
return;
|
||||
}
|
||||
useMemo(() => {
|
||||
const loadTimeout = setTimeout(() => {
|
||||
handleIframeTakingTooLongToLoad();
|
||||
}, MaxLoadThreshold);
|
||||
|
||||
const iframe = iframeRef.current as HTMLIFrameElement;
|
||||
iframe.onload = () => {
|
||||
const contentWindow = iframe.contentWindow as Window;
|
||||
excessiveLoadingTimeout.current = loadTimeout;
|
||||
|
||||
return () => {
|
||||
excessiveLoadingTimeout.current &&
|
||||
clearTimeout(excessiveLoadingTimeout.current);
|
||||
|
||||
componentViewer.setWindow(contentWindow);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setHasIssueLoading(false);
|
||||
onLoad?.(component);
|
||||
}, MSToWaitAfterIframeLoadToAvoidFlicker);
|
||||
};
|
||||
}, [onLoad, component, componentViewer]);
|
||||
}, [handleIframeTakingTooLongToLoad]);
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current as HTMLIFrameElement;
|
||||
const contentWindow = iframe.contentWindow as Window;
|
||||
excessiveLoadingTimeout.current &&
|
||||
clearTimeout(excessiveLoadingTimeout.current);
|
||||
|
||||
componentViewer.setWindow(contentWindow);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setHasIssueLoading(false);
|
||||
onLoad?.(component);
|
||||
}, MSToWaitAfterIframeLoadToAvoidFlicker);
|
||||
}, [componentViewer, onLoad, component, excessiveLoadingTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeFeaturesChangedObserver = componentViewer.addEventObserver(
|
||||
@@ -208,7 +206,7 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
<IssueOnLoading
|
||||
componentName={component.name}
|
||||
reloadIframe={() => {
|
||||
reloadValidityStatus(), requestReload?.(componentViewer);
|
||||
reloadValidityStatus(), requestReload?.(componentViewer, true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -236,6 +234,7 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
{component.uuid && isComponentValid && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={onIframeLoad}
|
||||
data-component-viewer-id={componentViewer.identifier}
|
||||
frameBorder={0}
|
||||
src={componentViewer.url || ''}
|
||||
@@ -249,10 +248,3 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ComponentViewDirective = toDirective<IProps>(ComponentView, {
|
||||
onLoad: '=',
|
||||
componentViewer: '=',
|
||||
requestReload: '=',
|
||||
manualDealloc: '=',
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@reach/alert-dialog';
|
||||
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { toDirective } from './utils';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
@@ -22,96 +21,94 @@ export const ConfirmSignoutContainer = observer((props: Props) => {
|
||||
return <ConfirmSignoutModal {...props} />;
|
||||
});
|
||||
|
||||
const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
|
||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
|
||||
export const ConfirmSignoutModal = observer(
|
||||
({ application, appState }: Props) => {
|
||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setSigningOut(false);
|
||||
}
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
function closeDialog() {
|
||||
appState.accountMenu.setSigningOut(false);
|
||||
}
|
||||
|
||||
const [localBackupsCount, setLocalBackupsCount] = useState(0);
|
||||
const [localBackupsCount, setLocalBackupsCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
application.bridge.localBackupsCount().then(setLocalBackupsCount);
|
||||
}, [appState.accountMenu.signingOut, application.bridge]);
|
||||
useEffect(() => {
|
||||
application.bridge.localBackupsCount().then(setLocalBackupsCount);
|
||||
}, [appState.accountMenu.signingOut, application.bridge]);
|
||||
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
|
||||
End your session?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
{STRING_SIGN_OUT_CONFIRMATION}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
{localBackupsCount > 0 && (
|
||||
<div className="flex">
|
||||
<div className="sk-panel-row"></div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalBackups}
|
||||
onChange={(event) => {
|
||||
setDeleteLocalBackups(
|
||||
(event.target as HTMLInputElement).checked
|
||||
);
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
|
||||
End your session?
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
{STRING_SIGN_OUT_CONFIRMATION}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
{localBackupsCount > 0 && (
|
||||
<div className="flex">
|
||||
<div className="sk-panel-row"></div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalBackups}
|
||||
onChange={(event) => {
|
||||
setDeleteLocalBackups(
|
||||
(event.target as HTMLInputElement).checked
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Delete {localBackupsCount} local backup file
|
||||
{localBackupsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
|
||||
onClick={() => {
|
||||
application.bridge.viewlocalBackups();
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Delete {localBackupsCount} local backup file
|
||||
{localBackupsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</label>
|
||||
>
|
||||
View backup files
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex my-1 mt-4">
|
||||
<button
|
||||
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
|
||||
className="sn-button small neutral"
|
||||
ref={cancelRef}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
application.bridge.viewlocalBackups();
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups();
|
||||
} else {
|
||||
application.signOut();
|
||||
}
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
View backup files
|
||||
{application.hasAccount()
|
||||
? 'Sign Out'
|
||||
: 'Clear Session Data'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex my-1 mt-4">
|
||||
<button
|
||||
className="sn-button small neutral"
|
||||
ref={cancelRef}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups();
|
||||
} else {
|
||||
application.signOut();
|
||||
}
|
||||
closeDialog();
|
||||
}}
|
||||
>
|
||||
{application.hasAccount()
|
||||
? 'Sign Out'
|
||||
: 'Clear Session Data'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
);
|
||||
});
|
||||
|
||||
export const ConfirmSignoutDirective = toDirective<Props>(
|
||||
ConfirmSignoutContainer
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from '@reach/listbox';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { IconType, Icon } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
|
||||
export type DropdownItem = {
|
||||
icon?: IconType;
|
||||
|
||||
560
app/assets/javascripts/components/Footer.tsx
Normal file
560
app/assets/javascripts/components/Footer.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { WebAppEvent, WebApplication } from '@/ui_models/application';
|
||||
import { ApplicationGroup } from '@/ui_models/application_group';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { preventRefreshing } from '@/utils';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
SNTheme,
|
||||
CollectionSort,
|
||||
ApplicationDescriptor,
|
||||
} from '@standardnotes/snjs';
|
||||
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 { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
import { AccountMenu, AccountMenuPane } from '@/components/AccountMenu';
|
||||
import { AppStateEvent, EventSource } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import { QuickSettingsMenu } from './QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { SyncResolutionMenu } from './SyncResolutionMenu';
|
||||
import { Fragment, render } from 'preact';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
applicationGroup: ApplicationGroup;
|
||||
};
|
||||
|
||||
type State = {
|
||||
outOfSync: boolean;
|
||||
dataUpgradeAvailable: boolean;
|
||||
hasPasscode: boolean;
|
||||
descriptors: ApplicationDescriptor[];
|
||||
hasAccountSwitcher: boolean;
|
||||
showBetaWarning: boolean;
|
||||
showSyncResolution: boolean;
|
||||
newUpdateAvailable: boolean;
|
||||
showAccountMenu: boolean;
|
||||
showQuickSettingsMenu: boolean;
|
||||
offline: boolean;
|
||||
hasError: boolean;
|
||||
arbitraryStatusMessage?: string;
|
||||
};
|
||||
|
||||
export class Footer extends PureComponent<Props, State> {
|
||||
public user?: unknown;
|
||||
private didCheckForOffline = false;
|
||||
private completedInitialSync = false;
|
||||
private showingDownloadStatus = false;
|
||||
private webEventListenerDestroyer: () => void;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
offline: true,
|
||||
outOfSync: false,
|
||||
dataUpgradeAvailable: false,
|
||||
hasPasscode: false,
|
||||
descriptors: props.applicationGroup.getDescriptors(),
|
||||
hasAccountSwitcher: false,
|
||||
showBetaWarning: false,
|
||||
showSyncResolution: false,
|
||||
newUpdateAvailable: false,
|
||||
showAccountMenu: false,
|
||||
showQuickSettingsMenu: false,
|
||||
};
|
||||
|
||||
this.webEventListenerDestroyer = props.application.addWebEventObserver(
|
||||
(event) => {
|
||||
if (event === WebAppEvent.NewUpdateAvailable) {
|
||||
this.onNewUpdateAvailable();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.webEventListenerDestroyer();
|
||||
(this.webEventListenerDestroyer as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.application.getStatusManager().onStatusChange((message) => {
|
||||
this.setState({
|
||||
arbitraryStatusMessage: message,
|
||||
});
|
||||
});
|
||||
this.loadAccountSwitcherState();
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.appState.showBetaWarning;
|
||||
this.setState({
|
||||
showBetaWarning: showBetaWarning,
|
||||
showAccountMenu: this.appState.accountMenu.show,
|
||||
showQuickSettingsMenu: this.appState.quickSettingsMenu.open,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application.checkForSecurityUpdate().then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** @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;
|
||||
setTimeout(() => {
|
||||
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.state.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.setState({
|
||||
offline: this.application.noAccount(),
|
||||
});
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.setState({
|
||||
hasError: this.application.getSyncStatus().hasError(),
|
||||
});
|
||||
}
|
||||
|
||||
securityUpdateClickHandler = async () => {
|
||||
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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
accountSwitcherClickHandler = () => {
|
||||
render(
|
||||
<AccountSwitcher
|
||||
application={this.application}
|
||||
mainApplicationGroup={this.props.applicationGroup}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
accountMenuClickHandler = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
this.appState.accountMenu.toggleShow();
|
||||
};
|
||||
|
||||
quickSettingsClickHandler = () => {
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
this.appState.quickSettingsMenu.toggle();
|
||||
};
|
||||
|
||||
syncResolutionClickHandler = () => {
|
||||
this.setState({
|
||||
showSyncResolution: !this.state.showSyncResolution,
|
||||
});
|
||||
};
|
||||
|
||||
closeAccountMenu = () => {
|
||||
this.appState.accountMenu.setShow(false);
|
||||
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
|
||||
};
|
||||
|
||||
lockClickHandler = () => {
|
||||
this.application.lock();
|
||||
};
|
||||
|
||||
onNewUpdateAvailable = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: true,
|
||||
});
|
||||
};
|
||||
|
||||
newUpdateClickHandler = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: false,
|
||||
});
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY);
|
||||
};
|
||||
|
||||
betaMessageClickHandler = () => {
|
||||
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.',
|
||||
});
|
||||
};
|
||||
|
||||
clickOutsideAccountMenu = () => {
|
||||
this.appState.accountMenu.closeAccountMenu();
|
||||
};
|
||||
|
||||
clickOutsideQuickSettingsMenu = () => {
|
||||
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
|
||||
<div className="left">
|
||||
<div className="sk-app-bar-item ml-0">
|
||||
<div
|
||||
onClick={this.accountMenuClickHandler}
|
||||
className={
|
||||
(this.state.showAccountMenu ? 'bg-border' : '') +
|
||||
' w-8 h-full flex items-center justify-center cursor-pointer rounded-full'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
this.state.hasError
|
||||
? 'danger'
|
||||
: (this.user ? 'info' : 'neutral') + ' w-5 h-5'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
type="account-circle"
|
||||
className="hover:color-info w-5 h-5 max-h-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showAccountMenu && (
|
||||
<AccountMenu
|
||||
onClickOutside={this.clickOutsideAccountMenu}
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-app-bar-item ml-0-important">
|
||||
<div
|
||||
onClick={this.quickSettingsClickHandler}
|
||||
className="w-8 h-full flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<div className="h-5">
|
||||
<Icon
|
||||
type="tune"
|
||||
className={
|
||||
(this.state.showQuickSettingsMenu ? 'color-info' : '') +
|
||||
' rounded hover:color-info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showQuickSettingsMenu && (
|
||||
<QuickSettingsMenu
|
||||
onClickOutside={this.clickOutsideQuickSettingsMenu}
|
||||
appState={this.appState}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{this.state.showBetaWarning && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div className="sk-app-bar-item">
|
||||
<a
|
||||
onClick={this.betaMessageClickHandler}
|
||||
className="no-decoration sk-label title"
|
||||
>
|
||||
You are using a beta version of the app
|
||||
</a>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div className="center">
|
||||
{this.state.arbitraryStatusMessage && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-app-bar-item-column">
|
||||
<span className="neutral sk-label">
|
||||
{this.state.arbitraryStatusMessage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="right">
|
||||
{this.state.dataUpgradeAvailable && (
|
||||
<div
|
||||
onClick={this.securityUpdateClickHandler}
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<span className="success sk-label">
|
||||
Encryption upgrade available.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{this.state.newUpdateAvailable && (
|
||||
<div
|
||||
onClick={this.newUpdateClickHandler}
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<span className="info sk-label">New update available.</span>
|
||||
</div>
|
||||
)}
|
||||
{(this.state.outOfSync || this.state.showSyncResolution) && (
|
||||
<div className="sk-app-bar-item">
|
||||
{this.state.outOfSync && (
|
||||
<div
|
||||
onClick={this.syncResolutionClickHandler}
|
||||
className="sk-label warning"
|
||||
>
|
||||
Potentially Out of Sync
|
||||
</div>
|
||||
)}
|
||||
{this.state.showSyncResolution && (
|
||||
<SyncResolutionMenu
|
||||
close={this.syncResolutionClickHandler}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{this.state.offline && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-label">Offline</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.hasAccountSwitcher && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div
|
||||
onClick={this.accountSwitcherClickHandler}
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
(this.state.hasPasscode ? 'alone' : '') +
|
||||
' flex items-center'
|
||||
}
|
||||
>
|
||||
<Icon type="user-switch" />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
{this.state.hasPasscode && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div
|
||||
id="lock-item"
|
||||
onClick={this.lockClickHandler}
|
||||
title="Locks application and wipes unencrypted data from memory."
|
||||
className="sk-app-bar-item"
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i id="footer-lock-icon" className="icon ion-locked" />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
309
app/assets/javascripts/components/HistoryMenu.tsx
Normal file
309
app/assets/javascripts/components/HistoryMenu.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { NoteHistoryEntry, PayloadContent, SNNote } from '@standardnotes/snjs';
|
||||
import { RevisionListEntry } from '@standardnotes/snjs';
|
||||
import { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { MenuRow } from './MenuRow';
|
||||
import { render } from 'preact';
|
||||
import { RevisionPreviewModal } from './RevisionPreviewModal';
|
||||
|
||||
type HistoryState = {
|
||||
sessionHistory?: NoteHistoryEntry[];
|
||||
remoteHistory?: RevisionListEntry[];
|
||||
fetchingRemoteHistory: boolean;
|
||||
autoOptimize: boolean;
|
||||
diskEnabled: boolean;
|
||||
showRemoteOptions?: boolean;
|
||||
showSessionOptions?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
item: SNNote;
|
||||
};
|
||||
|
||||
export class HistoryMenu extends PureComponent<Props, HistoryState> {
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
|
||||
this.state = {
|
||||
fetchingRemoteHistory: false,
|
||||
autoOptimize: this.props.application.historyManager.autoOptimize,
|
||||
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
|
||||
sessionHistory:
|
||||
this.props.application.historyManager.sessionHistoryForItem(
|
||||
this.props.item
|
||||
) as NoteHistoryEntry[],
|
||||
};
|
||||
}
|
||||
|
||||
reloadState() {
|
||||
this.setState({
|
||||
fetchingRemoteHistory: this.state.fetchingRemoteHistory,
|
||||
autoOptimize: this.props.application.historyManager.autoOptimize,
|
||||
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
|
||||
sessionHistory:
|
||||
this.props.application.historyManager.sessionHistoryForItem(
|
||||
this.props.item
|
||||
) as NoteHistoryEntry[],
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.fetchRemoteHistory();
|
||||
}
|
||||
|
||||
fetchRemoteHistory = async () => {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
try {
|
||||
const remoteHistory =
|
||||
await this.props.application.historyManager.remoteHistoryForItem(
|
||||
this.props.item
|
||||
);
|
||||
this.setState({ remoteHistory });
|
||||
} finally {
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
}
|
||||
};
|
||||
|
||||
private presentRevisionPreviewModal = (
|
||||
uuid: string,
|
||||
content: PayloadContent,
|
||||
title: string
|
||||
) => {
|
||||
render(
|
||||
<RevisionPreviewModal
|
||||
application={this.application}
|
||||
uuid={uuid}
|
||||
content={content}
|
||||
title={title}
|
||||
/>,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
openSessionRevision = (revision: NoteHistoryEntry) => {
|
||||
this.presentRevisionPreviewModal(
|
||||
revision.payload.uuid,
|
||||
revision.payload.content,
|
||||
revision.previewTitle()
|
||||
);
|
||||
};
|
||||
|
||||
openRemoteRevision = async (revision: RevisionListEntry) => {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
|
||||
const remoteRevision =
|
||||
await this.props.application.historyManager.fetchRemoteRevision(
|
||||
this.props.item.uuid,
|
||||
revision
|
||||
);
|
||||
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
|
||||
if (!remoteRevision) {
|
||||
alertDialog({
|
||||
text: 'The remote revision could not be loaded. Please try again later.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.presentRevisionPreviewModal(
|
||||
remoteRevision.payload.uuid,
|
||||
remoteRevision.payload.content,
|
||||
this.previewRemoteHistoryTitle(revision)
|
||||
);
|
||||
};
|
||||
|
||||
classForSessionRevision = (revision: NoteHistoryEntry) => {
|
||||
const vector = revision.operationVector();
|
||||
if (vector === 0) {
|
||||
return 'default';
|
||||
} else if (vector === 1) {
|
||||
return 'success';
|
||||
} else if (vector === -1) {
|
||||
return 'danger';
|
||||
}
|
||||
};
|
||||
|
||||
clearItemSessionHistory = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: 'Are you sure you want to delete the local session history for this note?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.props.application.historyManager.clearHistoryForItem(
|
||||
this.props.item
|
||||
);
|
||||
this.reloadState();
|
||||
}
|
||||
};
|
||||
|
||||
clearAllSessionHistory = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: 'Are you sure you want to delete the local session history for all notes?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
await this.props.application.historyManager.clearAllHistory();
|
||||
this.reloadState();
|
||||
}
|
||||
};
|
||||
|
||||
toggleSessionHistoryDiskSaving = async () => {
|
||||
if (!this.state.diskEnabled) {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to save history to disk? This will decrease general ' +
|
||||
'performance, especially as you type. You are advised to disable this feature ' +
|
||||
'if you experience any lagging.',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.props.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
} else {
|
||||
this.props.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
this.reloadState();
|
||||
};
|
||||
|
||||
toggleSessionHistoryAutoOptimize = () => {
|
||||
this.props.application.historyManager.toggleAutoOptimize();
|
||||
this.reloadState();
|
||||
};
|
||||
|
||||
previewRemoteHistoryTitle(revision: RevisionListEntry) {
|
||||
return new Date(revision.created_at).toLocaleString();
|
||||
}
|
||||
|
||||
toggleShowRemoteOptions = ($event: Event) => {
|
||||
$event.stopPropagation();
|
||||
this.setState({
|
||||
showRemoteOptions: !this.state.showRemoteOptions,
|
||||
});
|
||||
};
|
||||
|
||||
toggleShowSessionOptions = ($event: Event) => {
|
||||
$event.stopPropagation();
|
||||
this.setState({
|
||||
showSessionOptions: !this.state.showSessionOptions,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="history-menu" className="sn-component">
|
||||
<div className="sk-menu-panel dropdown-menu">
|
||||
<div className="sk-menu-panel-header">
|
||||
<div className="sk-menu-panel-header-title">
|
||||
Session
|
||||
<div className="sk-menu-panel-header-subtitle">
|
||||
{this.state.sessionHistory?.length || 'No'} revisions
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
className="sk-a info sk-h5"
|
||||
onClick={this.toggleShowSessionOptions}
|
||||
>
|
||||
Options
|
||||
</a>
|
||||
</div>
|
||||
{this.state.showSessionOptions && (
|
||||
<div>
|
||||
<MenuRow
|
||||
action={this.clearItemSessionHistory}
|
||||
label="Clear note local history"
|
||||
/>
|
||||
<MenuRow
|
||||
action={this.clearAllSessionHistory}
|
||||
label="Clear all local history"
|
||||
/>
|
||||
<MenuRow
|
||||
action={this.toggleSessionHistoryAutoOptimize}
|
||||
label={
|
||||
(this.state.autoOptimize ? 'Disable' : 'Enable') +
|
||||
' auto cleanup'
|
||||
}
|
||||
>
|
||||
<div className="sk-sublabel">
|
||||
Automatically cleans up small revisions to conserve space.
|
||||
</div>
|
||||
</MenuRow>
|
||||
<MenuRow
|
||||
action={this.toggleSessionHistoryDiskSaving}
|
||||
label={
|
||||
(this.state.diskEnabled ? 'Disable' : 'Enable') +
|
||||
' saving history to disk'
|
||||
}
|
||||
>
|
||||
<div className="sk-sublabel">
|
||||
Saving to disk is not recommended. Decreases performance and
|
||||
increases app loading time and memory footprint.
|
||||
</div>
|
||||
</MenuRow>
|
||||
</div>
|
||||
)}
|
||||
{this.state.sessionHistory?.map((revision, index) => {
|
||||
return (
|
||||
<MenuRow
|
||||
key={index}
|
||||
action={() => this.openSessionRevision(revision)}
|
||||
label={revision.previewTitle()}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
this.classForSessionRevision(revision) +
|
||||
' sk-sublabel opaque'
|
||||
}
|
||||
>
|
||||
{revision.previewSubTitle()}
|
||||
</div>
|
||||
</MenuRow>
|
||||
);
|
||||
})}
|
||||
<div className="sk-menu-panel-header">
|
||||
<div className="sk-menu-panel-header-title">
|
||||
Remote
|
||||
<div className="sk-menu-panel-header-subtitle">
|
||||
{this.state.remoteHistory?.length || 'No'} revisions
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
onClick={this.toggleShowRemoteOptions}
|
||||
className="sk-a info sk-h5"
|
||||
>
|
||||
Options
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{this.state.showRemoteOptions && (
|
||||
<MenuRow
|
||||
action={this.fetchRemoteHistory}
|
||||
label="Refresh"
|
||||
disabled={this.state.fetchingRemoteHistory}
|
||||
spinnerClass={
|
||||
this.state.fetchingRemoteHistory ? 'info' : undefined
|
||||
}
|
||||
>
|
||||
<div className="sk-sublabel">Fetch history from server.</div>
|
||||
</MenuRow>
|
||||
)}
|
||||
{this.state.remoteHistory?.map((revision, index) => {
|
||||
return (
|
||||
<MenuRow
|
||||
key={index}
|
||||
action={() => this.openRemoteRevision(revision)}
|
||||
label={this.previewRemoteHistoryTitle(revision)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,141 +1,143 @@
|
||||
import PremiumFeatureIcon from '../../icons/ic-premium-feature.svg';
|
||||
import PencilOffIcon from '../../icons/ic-pencil-off.svg';
|
||||
import PlainTextIcon from '../../icons/ic-text-paragraph.svg';
|
||||
import RichTextIcon from '../../icons/ic-text-rich.svg';
|
||||
import TrashIcon from '../../icons/ic-trash.svg';
|
||||
import TrashFilledIcon from '../../icons/ic-trash-filled.svg';
|
||||
import PinIcon from '../../icons/ic-pin.svg';
|
||||
import PinFilledIcon from '../../icons/ic-pin-filled.svg';
|
||||
import UnpinIcon from '../../icons/ic-pin-off.svg';
|
||||
import ArchiveIcon from '../../icons/ic-archive.svg';
|
||||
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
|
||||
import HashtagIcon from '../../icons/ic-hashtag.svg';
|
||||
import ChevronRightIcon from '../../icons/ic-chevron-right.svg';
|
||||
import RestoreIcon from '../../icons/ic-restore.svg';
|
||||
import CloseIcon from '../../icons/ic-close.svg';
|
||||
import PasswordIcon from '../../icons/ic-textbox-password.svg';
|
||||
import TrashSweepIcon from '../../icons/ic-trash-sweep.svg';
|
||||
import MoreIcon from '../../icons/ic-more.svg';
|
||||
import TuneIcon from '../../icons/ic-tune.svg';
|
||||
import MenuArrowDownIcon from '../../icons/ic-menu-arrow-down.svg';
|
||||
import MenuCloseIcon from '../../icons/ic-menu-close.svg';
|
||||
import AuthenticatorIcon from '../../icons/ic-authenticator.svg';
|
||||
import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg';
|
||||
import TasksIcon from '../../icons/ic-tasks.svg';
|
||||
import MarkdownIcon from '../../icons/ic-markdown.svg';
|
||||
import NotesIcon from '../../icons/ic-notes.svg';
|
||||
import CodeIcon from '../../icons/ic-code.svg';
|
||||
|
||||
import AccessibilityIcon from '../../icons/ic-accessibility.svg';
|
||||
import AccountCircleIcon from '../../icons/ic-account-circle.svg';
|
||||
import AddIcon from '../../icons/ic-add.svg';
|
||||
import HelpIcon from '../../icons/ic-help.svg';
|
||||
import KeyboardIcon from '../../icons/ic-keyboard.svg';
|
||||
import ListBulleted from '../../icons/ic-list-bulleted.svg';
|
||||
import ListedIcon from '../../icons/ic-listed.svg';
|
||||
import SecurityIcon from '../../icons/ic-security.svg';
|
||||
import SettingsIcon from '../../icons/ic-settings.svg';
|
||||
import StarIcon from '../../icons/ic-star.svg';
|
||||
import ThemesIcon from '../../icons/ic-themes.svg';
|
||||
import UserIcon from '../../icons/ic-user.svg';
|
||||
import ArchiveIcon from '../../icons/ic-archive.svg';
|
||||
import ArrowLeftIcon from '../../icons/ic-arrow-left.svg';
|
||||
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
|
||||
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
|
||||
import AuthenticatorIcon from '../../icons/ic-authenticator.svg';
|
||||
import CheckBoldIcon from '../../icons/ic-check-bold.svg';
|
||||
import CheckCircleIcon from '../../icons/ic-check-circle.svg';
|
||||
import CheckIcon from '../../icons/ic-check.svg';
|
||||
import ChevronDownIcon from '../../icons/ic-chevron-down.svg';
|
||||
import ChevronRightIcon from '../../icons/ic-chevron-right.svg';
|
||||
import CloseIcon from '../../icons/ic-close.svg';
|
||||
import CloudOffIcon from '../../icons/ic-cloud-off.svg';
|
||||
import CodeIcon from '../../icons/ic-code.svg';
|
||||
import CopyIcon from '../../icons/ic-copy.svg';
|
||||
import DownloadIcon from '../../icons/ic-download.svg';
|
||||
import InfoIcon from '../../icons/ic-info.svg';
|
||||
import CheckIcon from '../../icons/ic-check.svg';
|
||||
import CheckBoldIcon from '../../icons/ic-check-bold.svg';
|
||||
import AccountCircleIcon from '../../icons/ic-account-circle.svg';
|
||||
import CloudOffIcon from '../../icons/ic-cloud-off.svg';
|
||||
import SignInIcon from '../../icons/ic-signin.svg';
|
||||
import SignOutIcon from '../../icons/ic-signout.svg';
|
||||
import CheckCircleIcon from '../../icons/ic-check-circle.svg';
|
||||
import SyncIcon from '../../icons/ic-sync.svg';
|
||||
import ArrowLeftIcon from '../../icons/ic-arrow-left.svg';
|
||||
import ChevronDownIcon from '../../icons/ic-chevron-down.svg';
|
||||
import EditorIcon from '../../icons/ic-editor.svg';
|
||||
import EmailIcon from '../../icons/ic-email.svg';
|
||||
import ServerIcon from '../../icons/ic-server.svg';
|
||||
import EyeIcon from '../../icons/ic-eye.svg';
|
||||
import EyeOffIcon from '../../icons/ic-eye-off.svg';
|
||||
import LockIcon from '../../icons/ic-lock.svg';
|
||||
import LockFilledIcon from '../../icons/ic-lock-filled.svg';
|
||||
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
|
||||
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
|
||||
import WindowIcon from '../../icons/ic-window.svg';
|
||||
import HashtagIcon from '../../icons/ic-hashtag.svg';
|
||||
import HelpIcon from '../../icons/ic-help.svg';
|
||||
import InfoIcon from '../../icons/ic-info.svg';
|
||||
import KeyboardIcon from '../../icons/ic-keyboard.svg';
|
||||
import LinkOffIcon from '../../icons/ic-link-off.svg';
|
||||
|
||||
import ListBulleted from '../../icons/ic-list-bulleted.svg';
|
||||
import ListedIcon from '../../icons/ic-listed.svg';
|
||||
import LockFilledIcon from '../../icons/ic-lock-filled.svg';
|
||||
import LockIcon from '../../icons/ic-lock.svg';
|
||||
import MarkdownIcon from '../../icons/ic-markdown.svg';
|
||||
import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg';
|
||||
import MenuArrowDownIcon from '../../icons/ic-menu-arrow-down.svg';
|
||||
import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg';
|
||||
import MenuCloseIcon from '../../icons/ic-menu-close.svg';
|
||||
import MoreIcon from '../../icons/ic-more.svg';
|
||||
import NotesIcon from '../../icons/ic-notes.svg';
|
||||
import PasswordIcon from '../../icons/ic-textbox-password.svg';
|
||||
import PencilOffIcon from '../../icons/ic-pencil-off.svg';
|
||||
import PinFilledIcon from '../../icons/ic-pin-filled.svg';
|
||||
import PinIcon from '../../icons/ic-pin.svg';
|
||||
import PlainTextIcon from '../../icons/ic-text-paragraph.svg';
|
||||
import PremiumFeatureIcon from '../../icons/ic-premium-feature.svg';
|
||||
import RestoreIcon from '../../icons/ic-restore.svg';
|
||||
import RichTextIcon from '../../icons/ic-text-rich.svg';
|
||||
import SecurityIcon from '../../icons/ic-security.svg';
|
||||
import ServerIcon from '../../icons/ic-server.svg';
|
||||
import SettingsIcon from '../../icons/ic-settings.svg';
|
||||
import SignInIcon from '../../icons/ic-signin.svg';
|
||||
import SignOutIcon from '../../icons/ic-signout.svg';
|
||||
import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg';
|
||||
import StarIcon from '../../icons/ic-star.svg';
|
||||
import SyncIcon from '../../icons/ic-sync.svg';
|
||||
import TasksIcon from '../../icons/ic-tasks.svg';
|
||||
import ThemesIcon from '../../icons/ic-themes.svg';
|
||||
import TrashFilledIcon from '../../icons/ic-trash-filled.svg';
|
||||
import TrashIcon from '../../icons/ic-trash.svg';
|
||||
import TrashSweepIcon from '../../icons/ic-trash-sweep.svg';
|
||||
import TuneIcon from '../../icons/ic-tune.svg';
|
||||
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
|
||||
import UnpinIcon from '../../icons/ic-pin-off.svg';
|
||||
import UserIcon from '../../icons/ic-user.svg';
|
||||
import UserSwitch from '../../icons/ic-user-switch.svg';
|
||||
import WarningIcon from '../../icons/ic-warning.svg';
|
||||
import WindowIcon from '../../icons/ic-window.svg';
|
||||
|
||||
import { toDirective } from './utils';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
|
||||
const ICONS = {
|
||||
'menu-arrow-down-alt': MenuArrowDownAlt,
|
||||
'menu-arrow-right': MenuArrowRight,
|
||||
notes: NotesIcon,
|
||||
'arrows-sort-up': ArrowsSortUpIcon,
|
||||
'arrows-sort-down': ArrowsSortDownIcon,
|
||||
lock: LockIcon,
|
||||
'lock-filled': LockFilledIcon,
|
||||
eye: EyeIcon,
|
||||
'eye-off': EyeOffIcon,
|
||||
server: ServerIcon,
|
||||
email: EmailIcon,
|
||||
'chevron-down': ChevronDownIcon,
|
||||
'account-circle': AccountCircleIcon,
|
||||
'arrow-left': ArrowLeftIcon,
|
||||
sync: SyncIcon,
|
||||
'arrows-sort-down': ArrowsSortDownIcon,
|
||||
'arrows-sort-up': ArrowsSortUpIcon,
|
||||
'check-bold': CheckBoldIcon,
|
||||
'check-circle': CheckCircleIcon,
|
||||
signIn: SignInIcon,
|
||||
signOut: SignOutIcon,
|
||||
'cloud-off': CloudOffIcon,
|
||||
'pencil-off': PencilOffIcon,
|
||||
'plain-text': PlainTextIcon,
|
||||
'rich-text': RichTextIcon,
|
||||
code: CodeIcon,
|
||||
markdown: MarkdownIcon,
|
||||
authenticator: AuthenticatorIcon,
|
||||
spreadsheets: SpreadsheetsIcon,
|
||||
tasks: TasksIcon,
|
||||
trash: TrashIcon,
|
||||
'trash-filled': TrashFilledIcon,
|
||||
pin: PinIcon,
|
||||
'pin-filled': PinFilledIcon,
|
||||
unpin: UnpinIcon,
|
||||
archive: ArchiveIcon,
|
||||
unarchive: UnarchiveIcon,
|
||||
hashtag: HashtagIcon,
|
||||
'chevron-down': ChevronDownIcon,
|
||||
'chevron-right': ChevronRightIcon,
|
||||
restore: RestoreIcon,
|
||||
close: CloseIcon,
|
||||
password: PasswordIcon,
|
||||
'cloud-off': CloudOffIcon,
|
||||
'eye-off': EyeOffIcon,
|
||||
'link-off': LinkOffIcon,
|
||||
'list-bulleted': ListBulleted,
|
||||
'lock-filled': LockFilledIcon,
|
||||
'menu-arrow-down-alt': MenuArrowDownAlt,
|
||||
'menu-arrow-down': MenuArrowDownIcon,
|
||||
'menu-arrow-right': MenuArrowRight,
|
||||
'menu-close': MenuCloseIcon,
|
||||
'pencil-off': PencilOffIcon,
|
||||
'pin-filled': PinFilledIcon,
|
||||
'plain-text': PlainTextIcon,
|
||||
'premium-feature': PremiumFeatureIcon,
|
||||
'rich-text': RichTextIcon,
|
||||
'trash-filled': TrashFilledIcon,
|
||||
'trash-sweep': TrashSweepIcon,
|
||||
more: MoreIcon,
|
||||
tune: TuneIcon,
|
||||
'user-switch': UserSwitch,
|
||||
accessibility: AccessibilityIcon,
|
||||
add: AddIcon,
|
||||
help: HelpIcon,
|
||||
keyboard: KeyboardIcon,
|
||||
spellcheck: NotesIcon,
|
||||
'list-bulleted': ListBulleted,
|
||||
'link-off': LinkOffIcon,
|
||||
listed: ListedIcon,
|
||||
security: SecurityIcon,
|
||||
settings: SettingsIcon,
|
||||
star: StarIcon,
|
||||
themes: ThemesIcon,
|
||||
user: UserIcon,
|
||||
archive: ArchiveIcon,
|
||||
authenticator: AuthenticatorIcon,
|
||||
check: CheckIcon,
|
||||
close: CloseIcon,
|
||||
code: CodeIcon,
|
||||
copy: CopyIcon,
|
||||
download: DownloadIcon,
|
||||
editor: EditorIcon,
|
||||
email: EmailIcon,
|
||||
eye: EyeIcon,
|
||||
hashtag: HashtagIcon,
|
||||
help: HelpIcon,
|
||||
info: InfoIcon,
|
||||
check: CheckIcon,
|
||||
'check-bold': CheckBoldIcon,
|
||||
'account-circle': AccountCircleIcon,
|
||||
'menu-arrow-down': MenuArrowDownIcon,
|
||||
'menu-close': MenuCloseIcon,
|
||||
keyboard: KeyboardIcon,
|
||||
listed: ListedIcon,
|
||||
lock: LockIcon,
|
||||
markdown: MarkdownIcon,
|
||||
more: MoreIcon,
|
||||
notes: NotesIcon,
|
||||
password: PasswordIcon,
|
||||
pin: PinIcon,
|
||||
restore: RestoreIcon,
|
||||
security: SecurityIcon,
|
||||
server: ServerIcon,
|
||||
settings: SettingsIcon,
|
||||
signIn: SignInIcon,
|
||||
signOut: SignOutIcon,
|
||||
spellcheck: NotesIcon,
|
||||
spreadsheets: SpreadsheetsIcon,
|
||||
star: StarIcon,
|
||||
sync: SyncIcon,
|
||||
tasks: TasksIcon,
|
||||
themes: ThemesIcon,
|
||||
trash: TrashIcon,
|
||||
tune: TuneIcon,
|
||||
unarchive: UnarchiveIcon,
|
||||
unpin: UnpinIcon,
|
||||
user: UserIcon,
|
||||
warning: WarningIcon,
|
||||
window: WindowIcon,
|
||||
'premium-feature': PremiumFeatureIcon,
|
||||
};
|
||||
|
||||
export type IconType = keyof typeof ICONS;
|
||||
|
||||
type Props = {
|
||||
type: IconType;
|
||||
className?: string;
|
||||
@@ -147,7 +149,10 @@ export const Icon: FunctionalComponent<Props> = ({
|
||||
className = '',
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const IconComponent = ICONS[type];
|
||||
const IconComponent = ICONS[type as keyof typeof ICONS];
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<IconComponent
|
||||
className={`sn-icon ${className}`}
|
||||
@@ -156,8 +161,3 @@ export const Icon: FunctionalComponent<Props> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconDirective = toDirective<Props>(Icon, {
|
||||
type: '@',
|
||||
className: '@',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon, IconType } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { FunctionComponent, Ref } from 'preact';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import { Icon, IconType } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
|
||||
type ToggleProps = {
|
||||
toggleOnIcon: IconType;
|
||||
|
||||
115
app/assets/javascripts/components/MenuRow.tsx
Normal file
115
app/assets/javascripts/components/MenuRow.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Component } from 'preact';
|
||||
|
||||
export type MenuRowProps = {
|
||||
action?: () => void;
|
||||
buttonAction?: () => void;
|
||||
buttonClass?: string;
|
||||
buttonText?: string;
|
||||
desc?: string;
|
||||
disabled?: boolean;
|
||||
circle?: string;
|
||||
circleAlign?: string;
|
||||
faded?: boolean;
|
||||
hasButton?: boolean;
|
||||
label: string;
|
||||
spinnerClass?: string;
|
||||
stylekitClass?: string;
|
||||
subRows?: MenuRowProps[];
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
type Props = MenuRowProps;
|
||||
|
||||
export class MenuRow extends Component<Props> {
|
||||
onClick = ($event: Event) => {
|
||||
if (this.props.disabled || !this.props.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event.stopPropagation();
|
||||
|
||||
this.props.action();
|
||||
};
|
||||
|
||||
clickAccessoryButton = ($event: Event) => {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
$event.stopPropagation();
|
||||
this.props.buttonAction?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
title={this.props.desc}
|
||||
onClick={this.onClick}
|
||||
className="sk-menu-panel-row row"
|
||||
>
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className="left">
|
||||
{this.props.circle &&
|
||||
(!this.props.circleAlign || this.props.circleAlign == 'left') && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className={this.props.circle + ' sk-circle small'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
(this.props.faded || this.props.disabled ? 'faded' : '') +
|
||||
' sk-menu-panel-column'
|
||||
}
|
||||
>
|
||||
<div className={this.props.stylekitClass + ' sk-label'}>
|
||||
{this.props.label}
|
||||
</div>
|
||||
{this.props.subtitle && (
|
||||
<div className="sk-sublabel">{this.props.subtitle}</div>
|
||||
)}
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.props.subRows && this.props.subRows.length > 0 && (
|
||||
<div className="sk-menu-panel-subrows">
|
||||
{this.props.subRows.map((row) => {
|
||||
return (
|
||||
<MenuRow
|
||||
action={row.action}
|
||||
label={row.label}
|
||||
spinnerClass={row.spinnerClass}
|
||||
subtitle={row.subtitle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{this.props.circle && this.props.circleAlign == 'right' && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className={this.props.circle + ' sk-circle small'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.props.hasButton && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<button
|
||||
className={this.props.buttonClass + ' sn-button small'}
|
||||
onClick={this.props.buttonAction!}
|
||||
>
|
||||
{this.props.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.props.spinnerClass && (
|
||||
<div className="sk-menu-panel-column">
|
||||
<div className={this.props.spinnerClass + ' sk-spinner small'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective } from './utils';
|
||||
import NotesIcon from '../../icons/il-notes.svg';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { NotesOptionsPanel } from './NotesOptionsPanel';
|
||||
@@ -11,31 +10,31 @@ type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
|
||||
const count = appState.notes.selectedNotesCount;
|
||||
export const MultipleSelectedNotes = observer(
|
||||
({ application, appState }: Props) => {
|
||||
const count = appState.notes.selectedNotesCount;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<PinNoteButton appState={appState} />
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<PinNoteButton appState={appState} />
|
||||
</div>
|
||||
<NotesOptionsPanel application={application} appState={appState} />
|
||||
</div>
|
||||
<NotesOptionsPanel application={application} appState={appState} />
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<NotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">
|
||||
{count} selected notes
|
||||
</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">
|
||||
Actions will be performed on all selected notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<NotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">
|
||||
Actions will be performed on all selected notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const MultipleSelectedNotesDirective = toDirective<Props>(
|
||||
MultipleSelectedNotes
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,57 +1,48 @@
|
||||
import { ComponentView } from '@/components/ComponentView';
|
||||
import { PanelResizer } from '@/components/PanelResizer';
|
||||
import { SmartTagsSection } from '@/components/Tags/SmartTagsSection';
|
||||
import { TagsSection } from '@/components/Tags/TagsSection';
|
||||
import { toDirective } from '@/components/utils';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
} from '@/directives/views/panelResizer';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useMemo, useState } from 'preact/hooks';
|
||||
import { PremiumModalProvider } from './Premium';
|
||||
PanelResizer,
|
||||
PanelResizeType,
|
||||
} from './PanelResizer';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
const NAVIGATION_SELECTOR = 'navigation';
|
||||
|
||||
const useNavigationPanelRef = (): [HTMLDivElement | null, () => void] => {
|
||||
const [panelRef, setPanelRefInternal] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const setPanelRefPublic = useCallback(() => {
|
||||
const elem = document.querySelector(
|
||||
NAVIGATION_SELECTOR
|
||||
) as HTMLDivElement | null;
|
||||
setPanelRefInternal(elem);
|
||||
}, [setPanelRefInternal]);
|
||||
|
||||
return [panelRef, setPanelRefPublic];
|
||||
};
|
||||
|
||||
export const Navigation: FunctionComponent<Props> = observer(
|
||||
({ application }) => {
|
||||
const appState = useMemo(() => application.getAppState(), [application]);
|
||||
const componentViewer = appState.foldersComponentViewer;
|
||||
const enableNativeSmartTagsFeature =
|
||||
appState.features.enableNativeSmartTagsFeature;
|
||||
const [panelRef, setPanelRef] = useNavigationPanelRef();
|
||||
const [ref, setRef] = useState<HTMLDivElement | null>();
|
||||
const [panelWidth, setPanelWidth] = useState<number>(0);
|
||||
|
||||
const onCreateNewTag = useCallback(() => {
|
||||
appState.tags.createNewTemplate();
|
||||
}, [appState]);
|
||||
useEffect(() => {
|
||||
const removeObserver = application.addEventObserver(async () => {
|
||||
const width = application.getPreference(PrefKey.TagsPanelWidth);
|
||||
if (width) {
|
||||
setPanelWidth(width);
|
||||
}
|
||||
}, ApplicationEvent.PreferencesChanged);
|
||||
|
||||
return () => {
|
||||
removeObserver();
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||
(_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
application.setPreference(PrefKey.TagsPanelWidth, width);
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed);
|
||||
},
|
||||
[appState]
|
||||
[application, appState]
|
||||
);
|
||||
|
||||
const panelWidthEventCallback = useCallback(() => {
|
||||
@@ -59,65 +50,40 @@ export const Navigation: FunctionComponent<Props> = observer(
|
||||
}, [appState]);
|
||||
|
||||
return (
|
||||
<PremiumModalProvider state={appState.features}>
|
||||
<div
|
||||
id="navigation"
|
||||
className="sn-component section"
|
||||
data-aria-label="Navigation"
|
||||
ref={setPanelRef}
|
||||
>
|
||||
{componentViewer ? (
|
||||
<div className="component-view-container">
|
||||
<div className="component-view">
|
||||
<ComponentView
|
||||
componentViewer={componentViewer}
|
||||
application={application}
|
||||
appState={appState}
|
||||
/>
|
||||
<div
|
||||
id="navigation"
|
||||
className="sn-component section app-column app-column-first"
|
||||
data-aria-label="Navigation"
|
||||
ref={setRef}
|
||||
>
|
||||
<div id="navigation-content" className="content">
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Views</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div id="navigation-content" className="content">
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Views</span>
|
||||
</div>
|
||||
{!enableNativeSmartTagsFeature && (
|
||||
<div
|
||||
className="sk-button sk-secondary-contrast wide"
|
||||
onClick={onCreateNewTag}
|
||||
title="Create a new tag"
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i className="icon ion-plus add-button" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrollable">
|
||||
<SmartTagsSection appState={appState} />
|
||||
<TagsSection appState={appState} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{panelRef && (
|
||||
<PanelResizer
|
||||
application={application}
|
||||
collapsable={true}
|
||||
defaultWidth={150}
|
||||
panel={panelRef}
|
||||
prefKey={PrefKey.TagsPanelWidth}
|
||||
side={PanelSide.Right}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="scrollable">
|
||||
<SmartTagsSection appState={appState} />
|
||||
<TagsSection appState={appState} />
|
||||
</div>
|
||||
</div>
|
||||
</PremiumModalProvider>
|
||||
{ref && (
|
||||
<PanelResizer
|
||||
collapsable={true}
|
||||
defaultWidth={150}
|
||||
panel={ref}
|
||||
hoverable={true}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NavigationDirective = toDirective<Props>(Navigation);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toDirective } from './utils';
|
||||
import { Icon } from './Icon';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
@@ -39,5 +38,3 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const NoAccountWarningDirective = toDirective<Props>(NoAccountWarning);
|
||||
|
||||
70
app/assets/javascripts/components/NoteGroupView.tsx
Normal file
70
app/assets/javascripts/components/NoteGroupView.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NoteViewController } from '@standardnotes/snjs';
|
||||
import { PureComponent } from '@/components/Abstract/PureComponent';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { MultipleSelectedNotes } from '@/components/MultipleSelectedNotes';
|
||||
import { NoteView } from '@/components/NoteView/NoteView';
|
||||
import { ElementIds } from '@/element_ids';
|
||||
|
||||
type State = {
|
||||
showMultipleSelectedNotes: boolean;
|
||||
controllers: NoteViewController[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
export class NoteGroupView extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.state = {
|
||||
showMultipleSelectedNotes: false,
|
||||
controllers: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.application.noteControllerGroup.addActiveControllerChangeObserver(
|
||||
() => {
|
||||
this.setState({
|
||||
controllers: this.application.noteControllerGroup.noteControllers,
|
||||
});
|
||||
}
|
||||
);
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
id={ElementIds.EditorColumn}
|
||||
className="h-full app-column app-column-third"
|
||||
>
|
||||
{this.state.showMultipleSelectedNotes && (
|
||||
<MultipleSelectedNotes
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!this.state.showMultipleSelectedNotes && (
|
||||
<>
|
||||
{this.state.controllers.map((controller) => {
|
||||
return (
|
||||
<NoteView
|
||||
application={this.application}
|
||||
controller={controller}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { toDirective } from './utils';
|
||||
import { AutocompleteTagInput } from './AutocompleteTagInput';
|
||||
import { NoteTag } from './NoteTag';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
@@ -9,33 +8,24 @@ type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const NoteTagsContainer = observer(({ appState }: Props) => {
|
||||
const {
|
||||
tags,
|
||||
tagsContainerMaxWidth,
|
||||
} = appState.noteTags;
|
||||
export const NoteTagsContainer = observer(({ appState }: Props) => {
|
||||
const { tags, tagsContainerMaxWidth } = appState.noteTags;
|
||||
|
||||
useEffect(() => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
}, [appState.noteTags]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
|
||||
style={{
|
||||
maxWidth: tagsContainerMaxWidth,
|
||||
}}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<NoteTag
|
||||
key={tag.uuid}
|
||||
appState={appState}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
<AutocompleteTagInput appState={appState} />
|
||||
</div>
|
||||
<div
|
||||
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
|
||||
style={{
|
||||
maxWidth: tagsContainerMaxWidth,
|
||||
}}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<NoteTag key={tag.uuid} appState={appState} tag={tag} />
|
||||
))}
|
||||
<AutocompleteTagInput appState={appState} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const NoteTagsContainerDirective = toDirective<Props>(NoteTagsContainer);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { NoteView } from '@Views/note_view/note_view';
|
||||
import { NoteView } from './NoteView';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
@@ -13,8 +13,7 @@ describe('editor-view', () => {
|
||||
let setShowProtectedWarningSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
|
||||
ctrl = new NoteView($timeout);
|
||||
ctrl = new NoteView({} as any);
|
||||
|
||||
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
|
||||
|
||||
@@ -162,7 +161,7 @@ describe('editor-view', () => {
|
||||
describe('the note has protection sources', () => {
|
||||
it('should reveal note contents if the authorization has been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.spyOn(ctrl['application'], 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(true));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
@@ -172,7 +171,7 @@ describe('editor-view', () => {
|
||||
|
||||
it('should not reveal note contents if the authorization has not been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.spyOn(ctrl['application'], 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(false));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
@@ -184,7 +183,7 @@ describe('editor-view', () => {
|
||||
describe('the note does not have protection sources', () => {
|
||||
it('should reveal note contents', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'hasProtectionSources')
|
||||
.spyOn(ctrl['application'], 'hasProtectionSources')
|
||||
.mockImplementation(() => false);
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
1302
app/assets/javascripts/components/NoteView/NoteView.tsx
Normal file
1302
app/assets/javascripts/components/NoteView/NoteView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils';
|
||||
import { useCloseOnBlur, useCloseOnClickOutside } from './utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { NotesOptions } from './NotesOptions';
|
||||
import { NotesOptions } from './NotesOptions/NotesOptions';
|
||||
import { useCallback, useEffect, useRef } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
@@ -10,22 +10,17 @@ type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
const {
|
||||
contextMenuOpen,
|
||||
contextMenuPosition,
|
||||
contextMenuMaxHeight,
|
||||
} = appState.notes;
|
||||
export const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } =
|
||||
appState.notes;
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [closeOnBlur] = useCloseOnBlur(
|
||||
contextMenuRef as any,
|
||||
(open: boolean) => appState.notes.setContextMenuOpen(open)
|
||||
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) =>
|
||||
appState.notes.setContextMenuOpen(open)
|
||||
);
|
||||
|
||||
useCloseOnClickOutside(
|
||||
contextMenuRef as any,
|
||||
(open: boolean) => appState.notes.setContextMenuOpen(open)
|
||||
useCloseOnClickOutside(contextMenuRef, () =>
|
||||
appState.notes.setContextMenuOpen(false)
|
||||
);
|
||||
|
||||
const reloadContextMenuLayout = useCallback(() => {
|
||||
@@ -42,7 +37,7 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
return contextMenuOpen ? (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
|
||||
className="sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
|
||||
style={{
|
||||
...contextMenuPosition,
|
||||
maxHeight: contextMenuMaxHeight,
|
||||
@@ -56,5 +51,3 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
|
||||
</div>
|
||||
) : null;
|
||||
});
|
||||
|
||||
export const NotesContextMenuDirective = toDirective<Props>(NotesContextMenu);
|
||||
|
||||
@@ -6,6 +6,10 @@ import { SNNote } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { NotesListItem } from './NotesListItem';
|
||||
import {
|
||||
FOCUSABLE_BUT_NOT_TABBABLE,
|
||||
NOTES_LIST_SCROLL_THRESHOLD,
|
||||
} from '@/views/constants';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
@@ -16,9 +20,6 @@ type Props = {
|
||||
paginate: () => void;
|
||||
};
|
||||
|
||||
const FOCUSABLE_BUT_NOT_TABBABLE = -1;
|
||||
const NOTES_LIST_SCROLL_THRESHOLD = 200;
|
||||
|
||||
export const NotesList: FunctionComponent<Props> = observer(
|
||||
({
|
||||
application,
|
||||
@@ -44,7 +45,7 @@ export const NotesList: FunctionComponent<Props> = observer(
|
||||
if (!selectedTag.isSmartTag && tags.length === 1) {
|
||||
return [];
|
||||
}
|
||||
return tags.map((tag) => tag.title);
|
||||
return tags.map((tag) => tag.title).sort();
|
||||
};
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
@@ -84,7 +85,7 @@ export const NotesList: FunctionComponent<Props> = observer(
|
||||
|
||||
return (
|
||||
<div
|
||||
className="infinite-scroll"
|
||||
className="infinite-scroll focus:shadow-none focus:outline-none"
|
||||
id="notes-scrollable"
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getIconAndTintForEditor } from '@/preferences/panes/general-segments';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
CollectionSort,
|
||||
@@ -74,7 +73,9 @@ export const NotesListItem: FunctionComponent<Props> = ({
|
||||
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
|
||||
const editorForNote = application.componentManager.editorForNote(note);
|
||||
const editorName = editorForNote?.name ?? 'Plain editor';
|
||||
const [icon, tint] = getIconAndTintForEditor(editorForNote?.identifier);
|
||||
const [icon, tint] = application.iconsController.getIconAndTintForEditor(
|
||||
editorForNote?.identifier
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -93,46 +94,8 @@ export const NotesListItem: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
<div className={`meta ${hideEditorIcon ? 'icon-hidden' : ''}`}>
|
||||
<div className="name">
|
||||
<div>{note.title}</div>
|
||||
<div className="flag-icons">
|
||||
{note.locked && (
|
||||
<span title="Editing Disabled">
|
||||
<Icon
|
||||
ariaLabel="Editing Disabled"
|
||||
type="pencil-off"
|
||||
className="sn-icon--small color-info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.trashed && (
|
||||
<span title="Trashed">
|
||||
<Icon
|
||||
ariaLabel="Trashed"
|
||||
type="trash-filled"
|
||||
className="sn-icon--small color-danger"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.archived && (
|
||||
<span title="Archived">
|
||||
<Icon
|
||||
ariaLabel="Archived"
|
||||
type="archive"
|
||||
className="sn-icon--mid color-accessory-tint-3"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.pinned && (
|
||||
<span title="Pinned">
|
||||
<Icon
|
||||
ariaLabel="Pinned"
|
||||
type="pin-filled"
|
||||
className="sn-icon--small color-info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="name-container">
|
||||
{note.title.length ? <div className="name">{note.title}</div> : null}
|
||||
</div>
|
||||
{!hidePreview && !note.hidePreview && !note.protected && (
|
||||
<div className="note-preview">
|
||||
@@ -186,6 +149,44 @@ export const NotesListItem: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flag-icons">
|
||||
{note.locked && (
|
||||
<span title="Editing Disabled">
|
||||
<Icon
|
||||
ariaLabel="Editing Disabled"
|
||||
type="pencil-off"
|
||||
className="sn-icon--small color-info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.trashed && (
|
||||
<span title="Trashed">
|
||||
<Icon
|
||||
ariaLabel="Trashed"
|
||||
type="trash-filled"
|
||||
className="sn-icon--small color-danger"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.archived && (
|
||||
<span title="Archived">
|
||||
<Icon
|
||||
ariaLabel="Archived"
|
||||
type="archive"
|
||||
className="sn-icon--mid color-accessory-tint-3"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.pinned && (
|
||||
<span title="Pinned">
|
||||
<Icon
|
||||
ariaLabel="Pinned"
|
||||
type="pin-filled"
|
||||
className="sn-icon--small color-info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,19 +6,19 @@ import { useRef, useState } from 'preact/hooks';
|
||||
import { Icon } from './Icon';
|
||||
import { Menu } from './menu/Menu';
|
||||
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
|
||||
import { toDirective, useCloseOnClickOutside } from './utils';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
closeDisplayOptionsMenu: () => void;
|
||||
};
|
||||
|
||||
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
|
||||
({ closeDisplayOptionsMenu, application }) => {
|
||||
({ closeDisplayOptionsMenu, closeOnBlur, application }) => {
|
||||
const menuClassName =
|
||||
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
|
||||
border-1 border-solid border-main text-sm z-index-dropdown-menu \
|
||||
flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
flex flex-col py-2 top-full bottom-0 left-2 absolute';
|
||||
const [sortBy, setSortBy] = useState(() =>
|
||||
application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)
|
||||
);
|
||||
@@ -118,12 +118,6 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useCloseOnClickOutside(menuRef, (open: boolean) => {
|
||||
if (!open) {
|
||||
closeDisplayOptionsMenu();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={menuClassName}>
|
||||
<Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}>
|
||||
@@ -135,6 +129,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByDateModified}
|
||||
checked={sortBy === CollectionSort.UpdatedAt}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
<span>Date modified</span>
|
||||
@@ -152,6 +147,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByCreationDate}
|
||||
checked={sortBy === CollectionSort.CreatedAt}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
<span>Creation date</span>
|
||||
@@ -169,6 +165,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByTitle}
|
||||
checked={sortBy === CollectionSort.Title}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
<span>Title</span>
|
||||
@@ -190,6 +187,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hidePreview}
|
||||
onChange={toggleHidePreview}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-col max-w-3/4">Show note preview</div>
|
||||
</MenuItem>
|
||||
@@ -198,6 +196,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideDate}
|
||||
onChange={toggleHideDate}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show date
|
||||
</MenuItem>
|
||||
@@ -206,6 +205,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideTags}
|
||||
onChange={toggleHideTags}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show tags
|
||||
</MenuItem>
|
||||
@@ -214,6 +214,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideEditorIcon}
|
||||
onChange={toggleEditorIcon}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show editor icon
|
||||
</MenuItem>
|
||||
@@ -226,6 +227,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hidePinned}
|
||||
onChange={toggleHidePinned}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show pinned notes
|
||||
</MenuItem>
|
||||
@@ -234,6 +236,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideProtected}
|
||||
onChange={toggleHideProtected}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show protected notes
|
||||
</MenuItem>
|
||||
@@ -242,6 +245,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={showArchived}
|
||||
onChange={toggleShowArchived}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show archived notes
|
||||
</MenuItem>
|
||||
@@ -250,6 +254,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={showTrashed}
|
||||
onChange={toggleShowTrashed}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show trashed notes
|
||||
</MenuItem>
|
||||
@@ -258,11 +263,3 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NotesListOptionsDirective = toDirective<Props>(
|
||||
NotesListOptionsMenu,
|
||||
{
|
||||
closeDisplayOptionsMenu: '=',
|
||||
state: '&',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import {
|
||||
MENU_MARGIN_FROM_APP_BORDER,
|
||||
MAX_MENU_SIZE_MULTIPLIER,
|
||||
} from '@/views/constants';
|
||||
import {
|
||||
reloadFont,
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
transactionForDisassociateComponentWithCurrentNote,
|
||||
} from '@/components/NoteView/NoteView';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import {
|
||||
ComponentArea,
|
||||
IconType,
|
||||
ItemMutator,
|
||||
NoteMutator,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
TransactionalMutation,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Icon } from '../Icon';
|
||||
import { createEditorMenuGroups } from './changeEditor/createEditorMenuGroups';
|
||||
import { EditorAccordionMenu } from './changeEditor/EditorAccordionMenu';
|
||||
|
||||
type ChangeEditorOptionProps = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
note: SNNote;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
};
|
||||
|
||||
type AccordionMenuGroup<T> = {
|
||||
icon?: IconType;
|
||||
iconClassName?: string;
|
||||
title: string;
|
||||
items: Array<T>;
|
||||
};
|
||||
|
||||
export type EditorMenuItem = {
|
||||
name: string;
|
||||
component?: SNComponent;
|
||||
isPremiumFeature?: boolean;
|
||||
};
|
||||
|
||||
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>;
|
||||
|
||||
type MenuPositionStyle = {
|
||||
top?: number | 'auto';
|
||||
right?: number | 'auto';
|
||||
bottom: number | 'auto';
|
||||
left?: number | 'auto';
|
||||
};
|
||||
|
||||
const calculateMenuPosition = (
|
||||
button: HTMLButtonElement | null,
|
||||
menu?: HTMLDivElement | null
|
||||
): MenuPositionStyle | undefined => {
|
||||
const defaultFontSize = window.getComputedStyle(
|
||||
document.documentElement
|
||||
).fontSize;
|
||||
|
||||
const maxChangeEditorMenuSize =
|
||||
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
|
||||
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
|
||||
const buttonRect = button?.getBoundingClientRect();
|
||||
|
||||
const buttonParentRect = button?.parentElement?.getBoundingClientRect();
|
||||
|
||||
const menuBoundingRect = menu?.getBoundingClientRect();
|
||||
|
||||
const footerElementRect = document
|
||||
.getElementById('footer-bar')
|
||||
?.getBoundingClientRect();
|
||||
|
||||
const footerHeightInPx = footerElementRect?.height ?? 0;
|
||||
|
||||
let position: MenuPositionStyle = {
|
||||
bottom: 'auto',
|
||||
};
|
||||
|
||||
if (buttonRect && buttonParentRect) {
|
||||
let positionBottom =
|
||||
clientHeight - buttonRect.bottom - buttonRect.height / 2;
|
||||
|
||||
if (positionBottom < footerHeightInPx) {
|
||||
positionBottom = footerHeightInPx + MENU_MARGIN_FROM_APP_BORDER;
|
||||
}
|
||||
|
||||
if (buttonRect.right + maxChangeEditorMenuSize > clientWidth) {
|
||||
position = {
|
||||
bottom: positionBottom,
|
||||
right: clientWidth - buttonRect.left,
|
||||
};
|
||||
} else {
|
||||
position = {
|
||||
bottom: positionBottom,
|
||||
left: buttonRect.right,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (menuBoundingRect && menuBoundingRect.height && buttonRect) {
|
||||
if (menuBoundingRect.y < MENU_MARGIN_FROM_APP_BORDER) {
|
||||
if (
|
||||
buttonRect.right + maxChangeEditorMenuSize >
|
||||
document.documentElement.clientWidth
|
||||
) {
|
||||
return {
|
||||
...position,
|
||||
top: MENU_MARGIN_FROM_APP_BORDER + buttonRect.top - buttonRect.height,
|
||||
bottom: 'auto',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...position,
|
||||
top: MENU_MARGIN_FROM_APP_BORDER,
|
||||
bottom: 'auto',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
const TIME_IN_MS_TO_WAIT_BEFORE_REPAINT = 1;
|
||||
|
||||
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
application,
|
||||
appState,
|
||||
closeOnBlur,
|
||||
note,
|
||||
}) => {
|
||||
const [changeEditorMenuOpen, setChangeEditorMenuOpen] = useState(false);
|
||||
const [changeEditorMenuPosition, setChangeEditorMenuPosition] =
|
||||
useState<MenuPositionStyle>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
});
|
||||
const changeEditorMenuRef = useRef<HTMLDivElement>(null);
|
||||
const changeEditorButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [editors] = useState<SNComponent[]>(() =>
|
||||
application.componentManager
|
||||
.componentsForArea(ComponentArea.Editor)
|
||||
.sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
})
|
||||
);
|
||||
const [editorMenuGroups, setEditorMenuGroups] = useState<EditorMenuGroup[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedEditor, setSelectedEditor] = useState(() =>
|
||||
application.componentManager.editorForNote(note)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditorMenuGroups(createEditorMenuGroups(editors));
|
||||
}, [editors]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEditor(application.componentManager.editorForNote(note));
|
||||
}, [application, note]);
|
||||
|
||||
const toggleChangeEditorMenu = () => {
|
||||
if (!changeEditorMenuOpen) {
|
||||
const menuPosition = calculateMenuPosition(changeEditorButtonRef.current);
|
||||
if (menuPosition) {
|
||||
setChangeEditorMenuPosition(menuPosition);
|
||||
}
|
||||
}
|
||||
|
||||
setChangeEditorMenuOpen(!changeEditorMenuOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (changeEditorMenuOpen) {
|
||||
setTimeout(() => {
|
||||
const newMenuPosition = calculateMenuPosition(
|
||||
changeEditorButtonRef.current,
|
||||
changeEditorMenuRef.current
|
||||
);
|
||||
|
||||
if (newMenuPosition) {
|
||||
setChangeEditorMenuPosition(newMenuPosition);
|
||||
}
|
||||
}, TIME_IN_MS_TO_WAIT_BEFORE_REPAINT);
|
||||
}
|
||||
}, [changeEditorMenuOpen]);
|
||||
|
||||
const selectComponent = async (component: SNComponent | null) => {
|
||||
if (component) {
|
||||
if (component.conflictOf) {
|
||||
application.changeAndSaveItem(component.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const transactions: TransactionalMutation[] = [];
|
||||
|
||||
if (appState.getActiveNoteController()?.isTemplateNote) {
|
||||
await appState.getActiveNoteController().insertTemplatedNote();
|
||||
}
|
||||
|
||||
if (note.locked) {
|
||||
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!component) {
|
||||
if (!note.prefersPlainEditor) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
const currentEditor = application.componentManager.editorForNote(note);
|
||||
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
transactions.push(
|
||||
transactionForDisassociateComponentWithCurrentNote(
|
||||
currentEditor,
|
||||
note
|
||||
)
|
||||
);
|
||||
}
|
||||
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled));
|
||||
} else if (component.area === ComponentArea.Editor) {
|
||||
const currentEditor = application.componentManager.editorForNote(note);
|
||||
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
||||
transactions.push(
|
||||
transactionForDisassociateComponentWithCurrentNote(
|
||||
currentEditor,
|
||||
note
|
||||
)
|
||||
);
|
||||
}
|
||||
const prefersPlain = note.prefersPlainEditor;
|
||||
if (prefersPlain) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
transactions.push(
|
||||
transactionForAssociateComponentWithCurrentNote(component, note)
|
||||
);
|
||||
}
|
||||
|
||||
await application.runTransactionalMutations(transactions);
|
||||
/** Dirtying can happen above */
|
||||
application.sync();
|
||||
|
||||
setSelectedEditor(application.componentManager.editorForNote(note));
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure open={changeEditorMenuOpen} onChange={toggleChangeEditorMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setChangeEditorMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={changeEditorButtonRef}
|
||||
className="sn-dropdown-item justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="editor" className="color-neutral mr-2" />
|
||||
Change editor
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={changeEditorMenuRef}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setChangeEditorMenuOpen(false);
|
||||
changeEditorButtonRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...changeEditorMenuPosition,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown flex flex-col max-h-120 min-w-68 fixed overflow-y-auto"
|
||||
>
|
||||
<EditorAccordionMenu
|
||||
application={application}
|
||||
closeOnBlur={closeOnBlur}
|
||||
currentEditor={selectedEditor}
|
||||
groups={editorMenuGroups}
|
||||
isOpen={changeEditorMenuOpen}
|
||||
selectComponent={selectComponent}
|
||||
/>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import { Switch } from './Switch';
|
||||
import { Icon } from '../Icon';
|
||||
import { Switch } from '../Switch';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useRef, useState, useEffect, useMemo } from 'preact/hooks';
|
||||
import {
|
||||
@@ -8,12 +8,18 @@ import {
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import { SNApplication, SNNote } from '@standardnotes/snjs/dist/@types';
|
||||
import { SNApplication, SNNote } from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { KeyboardModifier } from '@/services/ioService';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { ChangeEditorOption } from './ChangeEditorOption';
|
||||
import {
|
||||
MENU_MARGIN_FROM_APP_BORDER,
|
||||
MAX_MENU_SIZE_MULTIPLIER,
|
||||
BYTES_IN_ONE_MEGABYTE,
|
||||
} from '@/views/constants';
|
||||
|
||||
type Props = {
|
||||
export type NotesOptionsProps = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
@@ -21,7 +27,7 @@ type Props = {
|
||||
};
|
||||
|
||||
type DeletePermanentlyButtonProps = {
|
||||
closeOnBlur: Props['closeOnBlur'];
|
||||
closeOnBlur: NotesOptionsProps['closeOnBlur'];
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
@@ -86,7 +92,10 @@ const formatDate = (date: Date | undefined) => {
|
||||
return `${date.toDateString()} ${date.toLocaleTimeString()}`;
|
||||
};
|
||||
|
||||
const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNote }> = ({ application, note }) => {
|
||||
const NoteAttributes: FunctionComponent<{
|
||||
application: SNApplication;
|
||||
note: SNNote;
|
||||
}> = ({ application, note }) => {
|
||||
const { words, characters, paragraphs } = useMemo(
|
||||
() => countNoteAttributes(note.text),
|
||||
[note.text]
|
||||
@@ -111,7 +120,7 @@ const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNo
|
||||
const format = editor?.package_info?.file_type || 'txt';
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-1.5 pb-1 text-xs color-neutral font-medium">
|
||||
<div className="px-3 pt-1.5 pb-2.5 text-xs color-neutral font-medium">
|
||||
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
||||
<>
|
||||
<div className="mb-1">
|
||||
@@ -136,40 +145,72 @@ const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNo
|
||||
};
|
||||
|
||||
const SpellcheckOptions: FunctionComponent<{
|
||||
appState: AppState, note: SNNote
|
||||
appState: AppState;
|
||||
note: SNNote;
|
||||
}> = ({ appState, note }) => {
|
||||
|
||||
const editor = appState.application.componentManager.editorForNote(note);
|
||||
const spellcheckControllable = Boolean(
|
||||
!editor ||
|
||||
appState.application.getFeature(editor.identifier)?.spellcheckControl
|
||||
!editor || editor.package_info.spellcheckControl
|
||||
);
|
||||
const noteSpellcheck = !spellcheckControllable ? true : note ? appState.notes.getSpellcheckStateForNote(note) : undefined;
|
||||
const noteSpellcheck = !spellcheckControllable
|
||||
? true
|
||||
: note
|
||||
? appState.notes.getSpellcheckStateForNote(note)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3 py-1.5">
|
||||
<Switch
|
||||
className="px-0 py-0"
|
||||
checked={noteSpellcheck}
|
||||
disabled={!spellcheckControllable}
|
||||
onChange={() => {
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
className="sn-dropdown-item justify-between px-3 py-1"
|
||||
onClick={() => {
|
||||
appState.notes.toggleGlobalSpellcheckForNote(note);
|
||||
}}
|
||||
disabled={!spellcheckControllable}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type='spellcheck' className={iconClass} />
|
||||
<Icon type="spellcheck" className={iconClass} />
|
||||
Spellcheck
|
||||
</span>
|
||||
</Switch>
|
||||
<Switch
|
||||
className="px-0"
|
||||
checked={noteSpellcheck}
|
||||
disabled={!spellcheckControllable}
|
||||
/>
|
||||
</button>
|
||||
{!spellcheckControllable && (
|
||||
<p className="text-xs pt-1.5">Spellcheck cannot be controlled for this editor.</p>
|
||||
<p className="text-xs px-3 py-1.5">
|
||||
Spellcheck cannot be controlled for this editor.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE;
|
||||
|
||||
const NoteSizeWarning: FunctionComponent<{
|
||||
note: SNNote;
|
||||
}> = ({ note }) =>
|
||||
new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
|
||||
<div className="flex items-center px-3 py-3.5 relative bg-note-size-warning">
|
||||
<Icon
|
||||
type="warning"
|
||||
className="color-accessory-tint-3 flex-shrink-0 mr-3"
|
||||
/>
|
||||
<div className="color-grey-0 select-none leading-140% max-w-80%">
|
||||
This note may have trouble syncing to the mobile application due to its
|
||||
size.
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
export const NotesOptions = observer(
|
||||
({ application, appState, closeOnBlur, onSubmenuChange }: Props) => {
|
||||
({
|
||||
application,
|
||||
appState,
|
||||
closeOnBlur,
|
||||
onSubmenuChange,
|
||||
}: NotesOptionsProps) => {
|
||||
const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
|
||||
const [tagsMenuPosition, setTagsMenuPosition] = useState<{
|
||||
top: number;
|
||||
@@ -232,25 +273,39 @@ export const NotesOptions = observer(
|
||||
const defaultFontSize = window.getComputedStyle(
|
||||
document.documentElement
|
||||
).fontSize;
|
||||
const maxTagsMenuSize = parseFloat(defaultFontSize) * 30;
|
||||
const maxTagsMenuSize =
|
||||
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
const buttonRect = tagsButtonRef.current!.getBoundingClientRect();
|
||||
const footerHeight = 32;
|
||||
const buttonRect = tagsButtonRef.current?.getBoundingClientRect();
|
||||
const footerElementRect = document
|
||||
.getElementById('footer-bar')
|
||||
?.getBoundingClientRect();
|
||||
const footerHeightInPx = footerElementRect?.height;
|
||||
|
||||
if (buttonRect.top + maxTagsMenuSize > clientHeight - footerHeight) {
|
||||
setTagsMenuMaxHeight(clientHeight - buttonRect.top - footerHeight - 2);
|
||||
}
|
||||
if (buttonRect && footerHeightInPx) {
|
||||
if (
|
||||
buttonRect.top + maxTagsMenuSize >
|
||||
clientHeight - footerHeightInPx
|
||||
) {
|
||||
setTagsMenuMaxHeight(
|
||||
clientHeight -
|
||||
buttonRect.top -
|
||||
footerHeightInPx -
|
||||
MENU_MARGIN_FROM_APP_BORDER
|
||||
);
|
||||
}
|
||||
|
||||
if (buttonRect.right + maxTagsMenuSize > clientWidth) {
|
||||
setTagsMenuPosition({
|
||||
top: buttonRect.top,
|
||||
right: clientWidth - buttonRect.left,
|
||||
});
|
||||
} else {
|
||||
setTagsMenuPosition({
|
||||
top: buttonRect.top,
|
||||
left: buttonRect.right,
|
||||
});
|
||||
if (buttonRect.right + maxTagsMenuSize > clientWidth) {
|
||||
setTagsMenuPosition({
|
||||
top: buttonRect.top,
|
||||
right: clientWidth - buttonRect.left,
|
||||
});
|
||||
} else {
|
||||
setTagsMenuPosition({
|
||||
top: buttonRect.top,
|
||||
left: buttonRect.right,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTagsMenuOpen(!tagsMenuOpen);
|
||||
@@ -298,45 +353,56 @@ export const NotesOptions = observer(
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="px-3 py-1.5"
|
||||
checked={locked}
|
||||
onChange={() => {
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setLockSelectedNotes(!locked);
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="pencil-off" className={iconClass} />
|
||||
Prevent editing
|
||||
</span>
|
||||
</Switch>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="px-3 py-1.5"
|
||||
checked={!hidePreviews}
|
||||
onChange={() => {
|
||||
<Switch className="px-0" checked={locked} />
|
||||
</button>
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setHideSelectedNotePreviews(!hidePreviews);
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="rich-text" className={iconClass} />
|
||||
Show preview
|
||||
</span>
|
||||
</Switch>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="px-3 py-1.5"
|
||||
checked={protect}
|
||||
onChange={() => {
|
||||
<Switch className="px-0" checked={!hidePreviews} />
|
||||
</button>
|
||||
<button
|
||||
className="sn-dropdown-item justify-between"
|
||||
onClick={() => {
|
||||
appState.notes.setProtectSelectedNotes(!protect);
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="password" className={iconClass} />
|
||||
Protect
|
||||
</span>
|
||||
</Switch>
|
||||
<Switch className="px-0" checked={protect} />
|
||||
</button>
|
||||
{notes.length === 1 && (
|
||||
<>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<ChangeEditorOption
|
||||
appState={appState}
|
||||
application={application}
|
||||
closeOnBlur={closeOnBlur}
|
||||
note={notes[0]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
{appState.tags.tagsCount > 0 && (
|
||||
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
|
||||
@@ -360,7 +426,7 @@ export const NotesOptions = observer(
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setTagsMenuOpen(false);
|
||||
tagsButtonRef.current!.focus();
|
||||
tagsButtonRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
@@ -383,12 +449,13 @@ export const NotesOptions = observer(
|
||||
>
|
||||
<span
|
||||
className={`whitespace-nowrap overflow-hidden overflow-ellipsis
|
||||
${appState.notes.isTagInSelectedNotes(tag)
|
||||
? 'font-bold'
|
||||
: ''
|
||||
${
|
||||
appState.notes.isTagInSelectedNotes(tag)
|
||||
? 'font-bold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{tag.title}
|
||||
{appState.noteTags.getLongTitle(tag)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -516,17 +583,13 @@ export const NotesOptions = observer(
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{notes.length === 1 ? (
|
||||
<>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
|
||||
<SpellcheckOptions appState={appState} note={notes[0]} />
|
||||
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
|
||||
<NoteAttributes application={application} note={notes[0]} />
|
||||
<NoteSizeWarning note={notes[0]} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/views/constants';
|
||||
import { SNComponent } from '@standardnotes/snjs';
|
||||
import { Fragment, FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
|
||||
import { PLAIN_EDITOR_NAME } from './createEditorMenuGroups';
|
||||
|
||||
type EditorAccordionMenuProps = {
|
||||
application: WebApplication;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
groups: EditorMenuGroup[];
|
||||
isOpen: boolean;
|
||||
selectComponent: (component: SNComponent | null) => Promise<void>;
|
||||
currentEditor: SNComponent | undefined;
|
||||
};
|
||||
|
||||
const getGroupId = (group: EditorMenuGroup) =>
|
||||
group.title.toLowerCase().replace(/\s/, '-');
|
||||
|
||||
const getGroupBtnId = (groupId: string) => groupId + '-button';
|
||||
|
||||
const isElementHidden = (element: Element) => !element.clientHeight;
|
||||
|
||||
const isElementFocused = (element: Element | null) =>
|
||||
element === document.activeElement;
|
||||
|
||||
export const EditorAccordionMenu: FunctionComponent<
|
||||
EditorAccordionMenuProps
|
||||
> = ({
|
||||
application,
|
||||
closeOnBlur,
|
||||
groups,
|
||||
isOpen,
|
||||
selectComponent,
|
||||
currentEditor,
|
||||
}) => {
|
||||
const [activeGroupId, setActiveGroupId] = useState('');
|
||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const premiumModal = usePremiumModal();
|
||||
|
||||
const addRefToMenuItems = (button: HTMLButtonElement | null) => {
|
||||
if (!menuItemRefs.current?.includes(button) && button) {
|
||||
menuItemRefs.current.push(button);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelectedEditor = useCallback(
|
||||
(item: EditorMenuItem) => {
|
||||
if (currentEditor) {
|
||||
if (item?.component?.identifier === currentEditor.identifier) {
|
||||
return true;
|
||||
}
|
||||
} else if (item.name === PLAIN_EDITOR_NAME) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[currentEditor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = groups.find((group) => {
|
||||
return group.items.some(isSelectedEditor);
|
||||
});
|
||||
|
||||
if (activeGroup) {
|
||||
const newActiveGroupId = getGroupId(activeGroup);
|
||||
setActiveGroupId(newActiveGroupId);
|
||||
}
|
||||
}, [groups, currentEditor, isSelectedEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !menuItemRefs.current.some(isElementFocused)) {
|
||||
const selectedEditor = groups
|
||||
.map((group) => group.items)
|
||||
.flat()
|
||||
.find((item) => isSelectedEditor(item));
|
||||
|
||||
if (selectedEditor) {
|
||||
const editorButton = menuItemRefs.current.find(
|
||||
(btn) => btn?.dataset.itemName === selectedEditor.name
|
||||
);
|
||||
editorButton?.focus();
|
||||
}
|
||||
}
|
||||
}, [groups, isOpen, isSelectedEditor]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Down || e.key === KeyboardKey.Up) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let items = menuItemRefs.current;
|
||||
|
||||
if (!activeGroupId) {
|
||||
items = items.filter((btn) => btn?.id);
|
||||
}
|
||||
|
||||
const currentItemIndex = items.findIndex(isElementFocused) ?? 0;
|
||||
|
||||
if (e.key === KeyboardKey.Up) {
|
||||
let previousItemIndex = currentItemIndex - 1;
|
||||
if (previousItemIndex < 0) {
|
||||
previousItemIndex = items.length - 1;
|
||||
}
|
||||
const previousItem = items[previousItemIndex];
|
||||
if (previousItem) {
|
||||
if (isElementHidden(previousItem)) {
|
||||
const previousItemGroupId = previousItem.closest(
|
||||
'[data-accordion-group]'
|
||||
)?.id;
|
||||
if (previousItemGroupId) {
|
||||
setActiveGroupId(previousItemGroupId);
|
||||
}
|
||||
setTimeout(() => {
|
||||
previousItem.focus();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
previousItem.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === KeyboardKey.Down) {
|
||||
let nextItemIndex = currentItemIndex + 1;
|
||||
if (nextItemIndex > items.length - 1) {
|
||||
nextItemIndex = 0;
|
||||
}
|
||||
const nextItem = items[nextItemIndex];
|
||||
if (nextItem) {
|
||||
if (isElementHidden(nextItem)) {
|
||||
const nextItemGroupId = nextItem.closest(
|
||||
'[data-accordion-group]'
|
||||
)?.id;
|
||||
if (nextItemGroupId) {
|
||||
setActiveGroupId(nextItemGroupId);
|
||||
}
|
||||
setTimeout(() => {
|
||||
nextItem.focus();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
nextItem?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
|
||||
let shouldSelectEditor = true;
|
||||
|
||||
if (itemToBeSelected.component) {
|
||||
const changeRequiresAlert =
|
||||
application.componentManager.doesEditorChangeRequireAlert(
|
||||
currentEditor,
|
||||
itemToBeSelected.component
|
||||
);
|
||||
|
||||
if (changeRequiresAlert) {
|
||||
shouldSelectEditor =
|
||||
await application.componentManager.showEditorChangeAlert();
|
||||
}
|
||||
}
|
||||
|
||||
if (itemToBeSelected.isPremiumFeature) {
|
||||
premiumModal.activate(itemToBeSelected.name);
|
||||
shouldSelectEditor = false;
|
||||
}
|
||||
|
||||
if (shouldSelectEditor) {
|
||||
selectComponent(itemToBeSelected.component ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => {
|
||||
if (!group.items || !group.items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupId = getGroupId(group);
|
||||
const buttonId = getGroupBtnId(groupId);
|
||||
const contentId = `${groupId}-content`;
|
||||
|
||||
const toggleGroup = () => {
|
||||
if (activeGroupId !== groupId) {
|
||||
setActiveGroupId(groupId);
|
||||
} else {
|
||||
setActiveGroupId('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={groupId}>
|
||||
<div
|
||||
id={groupId}
|
||||
data-accordion-group
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<h3 className="m-0">
|
||||
<button
|
||||
aria-controls={contentId}
|
||||
aria-expanded={activeGroupId === groupId}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop justify-between py-3"
|
||||
id={buttonId}
|
||||
type="button"
|
||||
onClick={toggleGroup}
|
||||
onBlur={closeOnBlur}
|
||||
ref={addRefToMenuItems}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{group.icon && (
|
||||
<Icon
|
||||
type={group.icon}
|
||||
className={`mr-2 ${group.iconClassName}`}
|
||||
/>
|
||||
)}
|
||||
<div className="font-semibold text-input">
|
||||
{group.title}
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
type="chevron-down"
|
||||
className={`sn-dropdown-arrow color-grey-1 ${
|
||||
activeGroupId === groupId && 'sn-dropdown-arrow-flipped'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</h3>
|
||||
<div
|
||||
id={contentId}
|
||||
aria-labelledby={buttonId}
|
||||
className={activeGroupId !== groupId ? 'hidden' : ''}
|
||||
>
|
||||
<div role="radiogroup">
|
||||
{group.items.map((item) => {
|
||||
const onClickEditorItem = () => {
|
||||
selectEditor(item);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
role="radio"
|
||||
data-item-name={item.name}
|
||||
onClick={onClickEditorItem}
|
||||
className={`sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none ${
|
||||
item.isPremiumFeature && 'justify-between'
|
||||
}`}
|
||||
aria-checked={false}
|
||||
onBlur={closeOnBlur}
|
||||
ref={addRefToMenuItems}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`pseudo-radio-btn ${
|
||||
isSelectedEditor(item)
|
||||
? 'pseudo-radio-btn--checked'
|
||||
: ''
|
||||
} ml-0.5 mr-2`}
|
||||
></div>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.isPremiumFeature && (
|
||||
<Icon type="premium-feature" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-1px bg-border hide-if-last-child"></div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
ComponentArea,
|
||||
FeatureDescription,
|
||||
GetFeatures,
|
||||
NoteType,
|
||||
} from '@standardnotes/features';
|
||||
import { ContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
|
||||
|
||||
/** @todo Implement interchangeable alert */
|
||||
|
||||
export const PLAIN_EDITOR_NAME = 'Plain Editor';
|
||||
|
||||
type EditorGroup = NoteType | 'plain' | 'others';
|
||||
|
||||
const getEditorGroup = (
|
||||
featureDescription: FeatureDescription
|
||||
): EditorGroup => {
|
||||
if (featureDescription.note_type) {
|
||||
return featureDescription.note_type;
|
||||
} else if (featureDescription.file_type) {
|
||||
switch (featureDescription.file_type) {
|
||||
case 'txt':
|
||||
return 'plain';
|
||||
case 'html':
|
||||
return NoteType.RichText;
|
||||
case 'md':
|
||||
return NoteType.Markdown;
|
||||
default:
|
||||
return 'others';
|
||||
}
|
||||
}
|
||||
return 'others';
|
||||
};
|
||||
|
||||
export const createEditorMenuGroups = (editors: SNComponent[]) => {
|
||||
const editorItems: Record<EditorGroup, EditorMenuItem[]> = {
|
||||
plain: [
|
||||
{
|
||||
name: PLAIN_EDITOR_NAME,
|
||||
},
|
||||
],
|
||||
'rich-text': [],
|
||||
markdown: [],
|
||||
task: [],
|
||||
code: [],
|
||||
spreadsheet: [],
|
||||
authentication: [],
|
||||
others: [],
|
||||
};
|
||||
|
||||
GetFeatures()
|
||||
.filter(
|
||||
(feature) =>
|
||||
feature.content_type === ContentType.Component &&
|
||||
feature.area === ComponentArea.Editor
|
||||
)
|
||||
.forEach((editorFeature) => {
|
||||
if (
|
||||
!editors.find(
|
||||
(editor) => editor.identifier === editorFeature.identifier
|
||||
)
|
||||
) {
|
||||
editorItems[getEditorGroup(editorFeature)].push({
|
||||
name: editorFeature.name as string,
|
||||
isPremiumFeature: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editors.forEach((editor) => {
|
||||
const editorItem: EditorMenuItem = {
|
||||
name: editor.name,
|
||||
component: editor,
|
||||
};
|
||||
|
||||
editorItems[getEditorGroup(editor.package_info)].push(editorItem);
|
||||
});
|
||||
|
||||
const editorMenuGroups: EditorMenuGroup[] = [
|
||||
{
|
||||
icon: 'plain-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Plain text',
|
||||
items: editorItems.plain,
|
||||
},
|
||||
{
|
||||
icon: 'rich-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Rich text',
|
||||
items: editorItems['rich-text'],
|
||||
},
|
||||
{
|
||||
icon: 'markdown',
|
||||
iconClassName: 'color-accessory-tint-2',
|
||||
title: 'Markdown text',
|
||||
items: editorItems.markdown,
|
||||
},
|
||||
{
|
||||
icon: 'tasks',
|
||||
iconClassName: 'color-accessory-tint-3',
|
||||
title: 'Todo',
|
||||
items: editorItems.task,
|
||||
},
|
||||
{
|
||||
icon: 'code',
|
||||
iconClassName: 'color-accessory-tint-4',
|
||||
title: 'Code',
|
||||
items: editorItems.code,
|
||||
},
|
||||
{
|
||||
icon: 'spreadsheets',
|
||||
iconClassName: 'color-accessory-tint-5',
|
||||
title: 'Spreadsheet',
|
||||
items: editorItems.spreadsheet,
|
||||
},
|
||||
{
|
||||
icon: 'authenticator',
|
||||
iconClassName: 'color-accessory-tint-6',
|
||||
title: 'Authentication',
|
||||
items: editorItems.authentication,
|
||||
},
|
||||
{
|
||||
icon: 'editor',
|
||||
iconClassName: 'color-neutral',
|
||||
title: 'Others',
|
||||
items: editorItems.others,
|
||||
},
|
||||
];
|
||||
|
||||
return editorMenuGroups;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { toDirective, useCloseOnBlur } from './utils';
|
||||
import { useCloseOnBlur } from './utils';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
@@ -9,84 +9,96 @@ import {
|
||||
} from '@reach/disclosure';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { NotesOptions } from './NotesOptions';
|
||||
import { NotesOptions } from './NotesOptions/NotesOptions';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
onClickPreprocessing?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const NotesOptionsPanel = observer(({ application, appState }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
});
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [closeOnBlur] = useCloseOnBlur(panelRef as any, setOpen);
|
||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||
export const NotesOptionsPanel = observer(
|
||||
({ application, appState, onClickPreprocessing }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
});
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen);
|
||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||
|
||||
const onSubmenuChange = (open: boolean) => {
|
||||
setSubmenuOpen(open);
|
||||
};
|
||||
const onSubmenuChange = (open: boolean) => {
|
||||
setSubmenuOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
open={open}
|
||||
onChange={() => {
|
||||
const rect = buttonRef.current!.getBoundingClientRect();
|
||||
const { clientHeight } = document.documentElement;
|
||||
const footerHeight = 32;
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeight - 2);
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
});
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !submenuOpen) {
|
||||
setOpen(false);
|
||||
return (
|
||||
<Disclosure
|
||||
open={open}
|
||||
onChange={async () => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement;
|
||||
const footerElementRect = document
|
||||
.getElementById('footer-bar')
|
||||
?.getBoundingClientRect();
|
||||
const footerHeightInPx = footerElementRect?.height;
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2);
|
||||
}
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
});
|
||||
const newOpenState = !open;
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing();
|
||||
}
|
||||
setOpen(newOpenState);
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className="sn-icon-button"
|
||||
>
|
||||
<VisuallyHidden>Actions</VisuallyHidden>
|
||||
<Icon type="more" className="block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !submenuOpen) {
|
||||
setOpen(false);
|
||||
buttonRef.current!.focus();
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...position,
|
||||
maxHeight,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
{open && (
|
||||
<NotesOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
closeOnBlur={closeOnBlur}
|
||||
onSubmenuChange={onSubmenuChange}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
});
|
||||
|
||||
export const NotesOptionsPanelDirective = toDirective<Props>(NotesOptionsPanel);
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !submenuOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className="sn-icon-button"
|
||||
>
|
||||
<VisuallyHidden>Actions</VisuallyHidden>
|
||||
<Icon type="more" className="block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !submenuOpen) {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...position,
|
||||
maxHeight,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
{open && (
|
||||
<NotesOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
closeOnBlur={closeOnBlur}
|
||||
onSubmenuChange={onSubmenuChange}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
} from '@/directives/views/panelResizer';
|
||||
import { KeyboardKey, KeyboardModifier } from '@/services/ioService';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
@@ -9,22 +5,33 @@ import { PANEL_NAME_NOTES } from '@/views/constants';
|
||||
import { PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { NoAccountWarning } from './NoAccountWarning';
|
||||
import { NotesList } from './NotesList';
|
||||
import { NotesListOptionsMenu } from './NotesListOptionsMenu';
|
||||
import { PanelResizer } from './PanelResizer';
|
||||
import { SearchOptions } from './SearchOptions';
|
||||
import { toDirective } from './utils';
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
PanelResizer,
|
||||
PanelResizeType,
|
||||
} from './PanelResizer';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import { useCloseOnBlur } from './utils';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const NotesView: FunctionComponent<Props> = observer(
|
||||
export const NotesView: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
const notesViewPanelRef = useRef<HTMLDivElement>(null);
|
||||
const displayOptionsMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
completedFullSync,
|
||||
@@ -36,8 +43,6 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
renderedNotes,
|
||||
selectedNotes,
|
||||
setNoteFilterText,
|
||||
showDisplayOptionsMenu,
|
||||
toggleDisplayOptionsMenu,
|
||||
searchBarElement,
|
||||
selectNextNote,
|
||||
selectPreviousNote,
|
||||
@@ -46,8 +51,16 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
onSearchInputBlur,
|
||||
clearFilterText,
|
||||
paginate,
|
||||
panelWidth,
|
||||
} = appState.notesView;
|
||||
|
||||
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false);
|
||||
|
||||
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
|
||||
displayOptionsMenuRef,
|
||||
setShowDisplayOptionsMenu
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleFilterTextChanged();
|
||||
}, [noteFilterText, handleFilterTextChanged]);
|
||||
@@ -124,11 +137,12 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = (
|
||||
_lastWidth,
|
||||
width,
|
||||
_lastLeft,
|
||||
_isMaxWidth,
|
||||
isCollapsed
|
||||
) => {
|
||||
application.setPreference(PrefKey.NotesPanelWidth, width);
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed);
|
||||
};
|
||||
@@ -137,16 +151,20 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
};
|
||||
|
||||
const toggleDisplayOptionsMenu = () => {
|
||||
setShowDisplayOptionsMenu(!showDisplayOptionsMenu);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="notes-column"
|
||||
className="sn-component section notes"
|
||||
className="sn-component section notes app-column app-column-second"
|
||||
aria-label="Notes"
|
||||
ref={notesViewPanelRef}
|
||||
>
|
||||
<div className="content">
|
||||
<div id="notes-title-bar" className="section-title-bar">
|
||||
<div className="p-4">
|
||||
<div id="notes-title-bar-container">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h2 font-semibold title">{panelTitle}</div>
|
||||
<button
|
||||
@@ -190,34 +208,42 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
<NoAccountWarning appState={appState} />
|
||||
</div>
|
||||
<div id="notes-menu-bar" className="sn-component">
|
||||
<div
|
||||
id="notes-menu-bar"
|
||||
className="sn-component"
|
||||
ref={displayOptionsMenuRef}
|
||||
>
|
||||
<div className="sk-app-bar no-edges">
|
||||
<div className="left">
|
||||
<div
|
||||
className={`sk-app-bar-item ${
|
||||
showDisplayOptionsMenu ? 'selected' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
toggleDisplayOptionsMenu(!showDisplayOptionsMenu)
|
||||
}
|
||||
<Disclosure
|
||||
open={showDisplayOptionsMenu}
|
||||
onChange={toggleDisplayOptionsMenu}
|
||||
>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-label">Options</div>
|
||||
</div>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-sublabel">{optionsSubtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DisclosureButton
|
||||
className={`sk-app-bar-item bg-contrast color-text border-0 focus:shadow-none ${
|
||||
showDisplayOptionsMenu ? 'selected' : ''
|
||||
}`}
|
||||
onBlur={closeDisplayOptMenuOnBlur}
|
||||
>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-label">Options</div>
|
||||
</div>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-sublabel">{optionsSubtitle}</div>
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
|
||||
{showDisplayOptionsMenu && (
|
||||
<NotesListOptionsMenu
|
||||
application={application}
|
||||
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
|
||||
closeOnBlur={closeDisplayOptMenuOnBlur}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
{showDisplayOptionsMenu && (
|
||||
<NotesListOptionsMenu
|
||||
application={application}
|
||||
closeDisplayOptionsMenu={() =>
|
||||
toggleDisplayOptionsMenu(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{completedFullSync && !renderedNotes.length ? (
|
||||
@@ -239,19 +265,19 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
</div>
|
||||
{notesViewPanelRef.current && (
|
||||
<PanelResizer
|
||||
application={application}
|
||||
collapsable={true}
|
||||
hoverable={true}
|
||||
defaultWidth={300}
|
||||
panel={document.querySelector('notes-view') as HTMLDivElement}
|
||||
prefKey={PrefKey.NotesPanelWidth}
|
||||
panel={notesViewPanelRef.current}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NotesViewDirective = toDirective<Props>(NotesView);
|
||||
|
||||
@@ -1,60 +1,340 @@
|
||||
import {
|
||||
PanelResizerProps,
|
||||
PanelResizerState,
|
||||
} from '@/ui_models/panel_resizer';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Component, createRef } from 'preact';
|
||||
import { debounce } from '@/utils';
|
||||
|
||||
export const PanelResizer: FunctionComponent<PanelResizerProps> = observer(
|
||||
({
|
||||
alwaysVisible,
|
||||
application,
|
||||
defaultWidth,
|
||||
hoverable,
|
||||
collapsable,
|
||||
minWidth,
|
||||
panel,
|
||||
prefKey,
|
||||
resizeFinishCallback,
|
||||
side,
|
||||
widthEventCallback,
|
||||
}) => {
|
||||
const [panelResizerState] = useState(
|
||||
() =>
|
||||
new PanelResizerState({
|
||||
alwaysVisible,
|
||||
application,
|
||||
defaultWidth,
|
||||
hoverable,
|
||||
collapsable,
|
||||
minWidth,
|
||||
panel,
|
||||
prefKey,
|
||||
resizeFinishCallback,
|
||||
side,
|
||||
widthEventCallback,
|
||||
})
|
||||
export type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => void;
|
||||
|
||||
export enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
|
||||
export enum PanelResizeType {
|
||||
WidthOnly = 'WidthOnly',
|
||||
OffsetAndWidth = 'OffsetAndWidth',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
left: number;
|
||||
alwaysVisible?: boolean;
|
||||
collapsable?: boolean;
|
||||
defaultWidth?: number;
|
||||
hoverable?: boolean;
|
||||
minWidth?: number;
|
||||
panel: HTMLDivElement;
|
||||
side: PanelSide;
|
||||
type: PanelResizeType;
|
||||
resizeFinishCallback?: ResizeFinishCallback;
|
||||
widthEventCallback?: () => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean;
|
||||
pressed: boolean;
|
||||
};
|
||||
|
||||
export class PanelResizer extends Component<Props, State> {
|
||||
private overlay?: HTMLDivElement;
|
||||
private resizerElementRef = createRef<HTMLDivElement>();
|
||||
private debouncedResizeHandler: () => void;
|
||||
private startLeft: number;
|
||||
private startWidth: number;
|
||||
private lastDownX: number;
|
||||
private lastLeft: number;
|
||||
private lastWidth: number;
|
||||
private widthBeforeLastDblClick: number;
|
||||
private minWidth: number;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false,
|
||||
pressed: false,
|
||||
};
|
||||
|
||||
this.minWidth = props.minWidth || 5;
|
||||
this.startLeft = props.panel.offsetLeft;
|
||||
this.startWidth = props.panel.scrollWidth;
|
||||
this.lastDownX = 0;
|
||||
this.lastLeft = props.panel.offsetLeft;
|
||||
this.lastWidth = props.panel.scrollWidth;
|
||||
this.widthBeforeLastDblClick = 0;
|
||||
|
||||
this.setWidth(this.props.width);
|
||||
this.setLeft(this.props.left);
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
this.debouncedResizeHandler = debounce(this.handleResize, 250);
|
||||
if (this.props.type === PanelResizeType.OffsetAndWidth) {
|
||||
window.addEventListener('resize', this.debouncedResizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.width != prevProps.width) {
|
||||
this.setWidth(this.props.width);
|
||||
}
|
||||
if (this.props.left !== prevProps.left) {
|
||||
this.setLeft(this.props.left);
|
||||
this.setWidth(this.props.width);
|
||||
}
|
||||
|
||||
const isCollapsed = this.isCollapsed();
|
||||
if (isCollapsed !== this.state.collapsed) {
|
||||
this.setState({ collapsed: isCollapsed });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
window.removeEventListener('resize', this.debouncedResizeHandler);
|
||||
}
|
||||
|
||||
get appFrame() {
|
||||
return document.getElementById('app')?.getBoundingClientRect() as DOMRect;
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
return (this.props.panel.parentNode as HTMLElement).getBoundingClientRect();
|
||||
}
|
||||
|
||||
isAtMaxWidth = () => {
|
||||
const marginOfError = 5;
|
||||
const difference = Math.abs(
|
||||
Math.round(this.lastWidth + this.lastLeft) -
|
||||
Math.round(this.getParentRect().width)
|
||||
);
|
||||
const panelResizerRef = useRef<HTMLDivElement>(null);
|
||||
return difference < marginOfError;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (panelResizerRef.current) {
|
||||
panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2);
|
||||
isCollapsed() {
|
||||
return this.lastWidth <= this.minWidth;
|
||||
}
|
||||
|
||||
finishSettingWidth = () => {
|
||||
if (!this.props.collapsable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
collapsed: this.isCollapsed(),
|
||||
});
|
||||
};
|
||||
|
||||
setWidth = (width: number, finish = false): void => {
|
||||
if (width === 0) {
|
||||
width = this.computeMaxWidth();
|
||||
}
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth;
|
||||
}
|
||||
|
||||
const parentRect = this.getParentRect();
|
||||
if (width > parentRect.width) {
|
||||
width = parentRect.width;
|
||||
}
|
||||
|
||||
const maxWidth =
|
||||
this.appFrame.width - this.props.panel.getBoundingClientRect().x;
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
const isFullWidth =
|
||||
Math.round(width + this.lastLeft) === Math.round(parentRect.width);
|
||||
if (isFullWidth) {
|
||||
if (this.props.type === PanelResizeType.WidthOnly) {
|
||||
this.props.panel.style.removeProperty('width');
|
||||
} else {
|
||||
this.props.panel.style.width = `calc(100% - ${this.lastLeft}px)`;
|
||||
}
|
||||
}, [panelResizerState]);
|
||||
} else {
|
||||
this.props.panel.style.width = width + 'px';
|
||||
}
|
||||
this.lastWidth = width;
|
||||
if (finish) {
|
||||
this.finishSettingWidth();
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLeft = (left: number) => {
|
||||
this.props.panel.style.left = left + 'px';
|
||||
this.lastLeft = left;
|
||||
};
|
||||
|
||||
onDblClick = () => {
|
||||
const collapsed = this.isCollapsed();
|
||||
if (collapsed) {
|
||||
this.setWidth(
|
||||
this.widthBeforeLastDblClick || this.props.defaultWidth || 0
|
||||
);
|
||||
} else {
|
||||
this.widthBeforeLastDblClick = this.lastWidth;
|
||||
this.setWidth(this.minWidth);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
|
||||
this.props.resizeFinishCallback?.(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed()
|
||||
);
|
||||
};
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
if (this.props.widthEventCallback) {
|
||||
this.props.widthEventCallback();
|
||||
}
|
||||
|
||||
let x;
|
||||
if (event) {
|
||||
x = event.clientX;
|
||||
} else {
|
||||
/** Coming from resize event */
|
||||
x = 0;
|
||||
this.lastDownX = 0;
|
||||
}
|
||||
const deltaX = x - this.lastDownX;
|
||||
const newWidth = this.startWidth + deltaX;
|
||||
this.setWidth(newWidth, false);
|
||||
}
|
||||
|
||||
handleLeftEvent(event: MouseEvent) {
|
||||
const panelRect = this.props.panel.getBoundingClientRect();
|
||||
const x = event.clientX || panelRect.x;
|
||||
let deltaX = x - this.lastDownX;
|
||||
let newLeft = this.startLeft + deltaX;
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0;
|
||||
deltaX = -this.startLeft;
|
||||
}
|
||||
const parentRect = this.getParentRect();
|
||||
let newWidth = this.startWidth - deltaX;
|
||||
if (newWidth < this.minWidth) {
|
||||
newWidth = this.minWidth;
|
||||
}
|
||||
if (newWidth > parentRect.width) {
|
||||
newWidth = parentRect.width;
|
||||
}
|
||||
if (newLeft + newWidth > parentRect.width) {
|
||||
newLeft = parentRect.width - newWidth;
|
||||
}
|
||||
this.setLeft(newLeft);
|
||||
this.setWidth(newWidth, false);
|
||||
}
|
||||
|
||||
computeMaxWidth(): number {
|
||||
const parentRect = this.getParentRect();
|
||||
let width = parentRect.width - this.props.left;
|
||||
if (width < this.minWidth) {
|
||||
width = this.minWidth;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
const startWidth = this.isAtMaxWidth()
|
||||
? this.computeMaxWidth()
|
||||
: this.props.panel.scrollWidth;
|
||||
|
||||
this.startWidth = startWidth;
|
||||
this.lastWidth = startWidth;
|
||||
|
||||
this.handleWidthEvent();
|
||||
this.finishSettingWidth();
|
||||
};
|
||||
|
||||
onMouseDown = (event: MouseEvent) => {
|
||||
this.addInvisibleOverlay();
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.props.panel.scrollWidth;
|
||||
this.startLeft = this.props.panel.offsetLeft;
|
||||
this.setState({
|
||||
pressed: true,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseUp = () => {
|
||||
this.removeInvisibleOverlay();
|
||||
if (!this.state.pressed) {
|
||||
return;
|
||||
}
|
||||
this.setState({ pressed: false });
|
||||
const isMaxWidth = this.isAtMaxWidth();
|
||||
if (this.props.resizeFinishCallback) {
|
||||
this.props.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
isMaxWidth,
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
};
|
||||
|
||||
onMouseMove = (event: MouseEvent) => {
|
||||
if (!this.state.pressed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.props.side === PanelSide.Left) {
|
||||
this.handleLeftEvent(event);
|
||||
} else {
|
||||
this.handleWidthEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
|
||||
* document[onmouseup] is not triggered because the document is no longer the same over
|
||||
* the iframe. We add an invisible overlay while resizing so that the mouse context
|
||||
* remains in our main document.
|
||||
*/
|
||||
addInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
return;
|
||||
}
|
||||
const overlayElement = document.createElement('div');
|
||||
overlayElement.id = 'resizer-overlay';
|
||||
this.overlay = overlayElement;
|
||||
document.body.prepend(this.overlay);
|
||||
};
|
||||
|
||||
removeInvisibleOverlay = () => {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className={`panel-resizer ${panelResizerState.side} ${
|
||||
panelResizerState.hoverable ? 'hoverable' : ''
|
||||
} ${panelResizerState.alwaysVisible ? 'alwaysVisible' : ''} ${
|
||||
panelResizerState.pressed ? 'dragging' : ''
|
||||
} ${panelResizerState.collapsed ? 'collapsed' : ''}`}
|
||||
onMouseDown={panelResizerState.onMouseDown}
|
||||
onDblClick={panelResizerState.onDblClick}
|
||||
ref={panelResizerRef}
|
||||
className={`panel-resizer ${this.props.side} ${
|
||||
this.props.hoverable ? 'hoverable' : ''
|
||||
} ${this.props.alwaysVisible ? 'alwaysVisible' : ''} ${
|
||||
this.state.pressed ? 'dragging' : ''
|
||||
} ${this.state.collapsed ? 'collapsed' : ''}`}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onDblClick={this.onDblClick}
|
||||
ref={this.resizerElementRef}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
344
app/assets/javascripts/components/PasswordWizard.tsx
Normal file
344
app/assets/javascripts/components/PasswordWizard.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { createRef, JSX } from 'preact';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
|
||||
interface Props {
|
||||
application: WebApplication;
|
||||
}
|
||||
|
||||
type State = {
|
||||
continueTitle: string;
|
||||
formData: FormData;
|
||||
isContinuing?: boolean;
|
||||
lockContinue?: boolean;
|
||||
processing?: boolean;
|
||||
showSpinner?: boolean;
|
||||
step: Steps;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONTINUE_TITLE = 'Continue';
|
||||
|
||||
enum Steps {
|
||||
PasswordStep = 1,
|
||||
FinishStep = 2,
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
newPasswordConfirmation?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export class PasswordWizard extends PureComponent<Props, State> {
|
||||
private currentPasswordInput = createRef<HTMLInputElement>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
this.registerWindowUnloadStopper();
|
||||
this.state = {
|
||||
formData: {},
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
step: Steps.PasswordStep,
|
||||
title: 'Change Password',
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
super.componentDidMount();
|
||||
this.currentPasswordInput.current?.focus();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
super.componentWillUnmount();
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
|
||||
registerWindowUnloadStopper() {
|
||||
window.onbeforeunload = () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
resetContinueState() {
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
isContinuing: false,
|
||||
});
|
||||
}
|
||||
|
||||
nextStep = async () => {
|
||||
if (this.state.lockContinue || this.state.isContinuing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.step === Steps.FinishStep) {
|
||||
this.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isContinuing: true,
|
||||
showSpinner: true,
|
||||
continueTitle: 'Generating Keys...',
|
||||
});
|
||||
|
||||
const valid = await this.validateCurrentPassword();
|
||||
if (!valid) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await this.processPasswordChange();
|
||||
if (!success) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isContinuing: false,
|
||||
showSpinner: false,
|
||||
continueTitle: 'Finish',
|
||||
step: Steps.FinishStep,
|
||||
});
|
||||
};
|
||||
|
||||
async validateCurrentPassword() {
|
||||
const currentPassword = this.state.formData.currentPassword;
|
||||
const newPass = this.state.formData.newPassword;
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
this.application.alertService.alert(
|
||||
'Please enter your current password.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!newPass || newPass.length === 0) {
|
||||
this.application.alertService.alert('Please enter a new password.');
|
||||
return false;
|
||||
}
|
||||
if (newPass !== this.state.formData.newPasswordConfirmation) {
|
||||
this.application.alertService.alert(
|
||||
'Your new password does not match its confirmation.'
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.application.getUser()?.email) {
|
||||
this.application.alertService.alert(
|
||||
"We don't have your email stored. Please sign out then log back in to fix this issue."
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate current password */
|
||||
const success = await this.application.validateAccountPassword(
|
||||
this.state.formData.currentPassword!
|
||||
);
|
||||
if (!success) {
|
||||
this.application.alertService.alert(
|
||||
'The current password you entered is not correct. Please try again.'
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async processPasswordChange() {
|
||||
await this.application.downloadBackup();
|
||||
|
||||
this.setState({
|
||||
lockContinue: true,
|
||||
processing: true,
|
||||
});
|
||||
|
||||
await this.setFormDataState({
|
||||
status: 'Processing encryption keys…',
|
||||
});
|
||||
|
||||
const newPassword = this.state.formData.newPassword;
|
||||
const response = await this.application.changePassword(
|
||||
this.state.formData.currentPassword!,
|
||||
newPassword!
|
||||
);
|
||||
|
||||
const success = !response.error;
|
||||
this.setState({
|
||||
processing: false,
|
||||
lockContinue: false,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
this.setFormDataState({
|
||||
status: 'Unable to process your password. Please try again.',
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
status: 'Successfully changed password.',
|
||||
},
|
||||
});
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
dismiss = () => {
|
||||
if (this.state.lockContinue) {
|
||||
this.application.alertService.alert(
|
||||
'Cannot close window until pending tasks are complete.'
|
||||
);
|
||||
} else {
|
||||
this.dismissModal();
|
||||
}
|
||||
};
|
||||
|
||||
async setFormDataState(formData: Partial<FormData>) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
...formData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleCurrentPasswordInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
currentPassword: currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleNewPasswordInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPassword: currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleNewPasswordConfirmationInputChange = ({
|
||||
currentTarget,
|
||||
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||
this.setFormDataState({
|
||||
newPasswordConfirmation: currentTarget.value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="password-wizard" className="sk-modal small auto-height">
|
||||
<div className="sk-modal-background" />
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">
|
||||
{this.state.title}
|
||||
</div>
|
||||
<a onClick={this.dismiss} className="sk-a info close-button">
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
{this.state.step === Steps.PasswordStep && (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-panel-column stretch">
|
||||
<form className="sk-panel-form">
|
||||
<label
|
||||
htmlFor="password-wiz-current-password"
|
||||
className="block mb-1"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
ref={this.currentPasswordInput}
|
||||
id="password-wiz-current-password"
|
||||
value={this.state.formData.currentPassword}
|
||||
onChange={this.handleCurrentPasswordInputChange}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
|
||||
<div className="sk-panel-row" />
|
||||
|
||||
<label
|
||||
htmlFor="password-wiz-new-password"
|
||||
className="block mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="password-wiz-new-password"
|
||||
value={this.state.formData.newPassword}
|
||||
onChange={this.handleNewPasswordInputChange}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
<div className="sk-panel-row" />
|
||||
|
||||
<label
|
||||
htmlFor="password-wiz-confirm-new-password"
|
||||
className="block mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="password-wiz-confirm-new-password"
|
||||
value={
|
||||
this.state.formData.newPasswordConfirmation
|
||||
}
|
||||
onChange={
|
||||
this.handleNewPasswordConfirmationInputChange
|
||||
}
|
||||
type="password"
|
||||
className="sk-input contrast"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.step === Steps.FinishStep && (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-label sk-bold info">
|
||||
Your password has been successfully changed.
|
||||
</div>
|
||||
<p className="sk-p">
|
||||
Please ensure you are running the latest version of
|
||||
Standard Notes on all platforms to ensure maximum
|
||||
compatibility.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-panel-footer">
|
||||
<button
|
||||
onClick={this.nextStep}
|
||||
disabled={this.state.lockContinue}
|
||||
className="sn-button min-w-20 info"
|
||||
>
|
||||
{this.state.continueTitle}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
94
app/assets/javascripts/components/PermissionsModal.tsx
Normal file
94
app/assets/javascripts/components/PermissionsModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent } from '@standardnotes/snjs';
|
||||
import { Component } from 'preact';
|
||||
import { findDOMNode, unmountComponentAtNode } from 'preact/compat';
|
||||
|
||||
interface Props {
|
||||
application: WebApplication;
|
||||
callback: (approved: boolean) => void;
|
||||
component: SNComponent;
|
||||
permissionsString: string;
|
||||
}
|
||||
|
||||
export class PermissionsModal extends Component<Props> {
|
||||
getElement(): Element | null {
|
||||
return findDOMNode(this);
|
||||
}
|
||||
|
||||
dismiss = () => {
|
||||
const elem = this.getElement();
|
||||
if (!elem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = elem.parentElement;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
parent.remove();
|
||||
unmountComponentAtNode(parent);
|
||||
};
|
||||
|
||||
accept = () => {
|
||||
this.props.callback(true);
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
deny = () => {
|
||||
this.props.callback(false);
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sk-modal">
|
||||
<div onClick={this.deny} className="sk-modal-background" />
|
||||
<div id="permissions-modal" className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">Activate Component</div>
|
||||
<a onClick={this.deny} className="sk-a info close-button">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-h2">
|
||||
<strong>{this.props.component.name}</strong>
|
||||
{' would like to interact with your '}
|
||||
{this.props.permissionsString}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-row">
|
||||
<p className="sk-p">
|
||||
Components use an offline messaging system to communicate.
|
||||
Learn more at{' '}
|
||||
<a
|
||||
href="https://standardnotes.com/permissions"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="sk-a info"
|
||||
>
|
||||
https://standardnotes.com/permissions.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-footer">
|
||||
<button
|
||||
onClick={this.accept}
|
||||
className="sn-button info block w-full text-base py-3"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,22 @@ import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon } from './Icon';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
className?: string;
|
||||
onClickPreprocessing?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const PinNoteButton: FunctionComponent<Props> = observer(
|
||||
({ appState, className = '' }) => {
|
||||
({ appState, className = '', onClickPreprocessing }) => {
|
||||
const notes = Object.values(appState.notes.selectedNotes);
|
||||
const pinned = notes.some((note) => note.pinned);
|
||||
|
||||
const togglePinned = () => {
|
||||
const togglePinned = async () => {
|
||||
if (onClickPreprocessing) {
|
||||
await onClickPreprocessing();
|
||||
}
|
||||
if (!pinned) {
|
||||
appState.notes.setPinSelectedNotes(true);
|
||||
} else {
|
||||
@@ -34,5 +37,3 @@ export const PinNoteButton: FunctionComponent<Props> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const PinNoteButtonDirective = toDirective<Props>(PinNoteButton);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FeaturesState } from '@/ui_models/app_state/features_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { useCallback, useContext, useState } from 'preact/hooks';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import { createContext } from 'react';
|
||||
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const PremiumFeaturesModal: FunctionalComponent<Props> = ({
|
||||
<PremiumIllustration className="mb-2" />
|
||||
</div>
|
||||
<div className="text-lg text-center font-bold mb-1">
|
||||
Enable premium features
|
||||
Enable Premium Features
|
||||
</div>
|
||||
</AlertDialogLabel>
|
||||
<AlertDialogDescription className="text-sm text-center color-grey-1 px-4.5 mb-2">
|
||||
@@ -65,7 +65,7 @@ export const PremiumFeaturesModal: FunctionalComponent<Props> = ({
|
||||
className="w-full rounded no-border py-2 font-bold bg-info color-info-contrast hover:brightness-130 focus:brightness-130 cursor-pointer"
|
||||
ref={plansButtonRef}
|
||||
>
|
||||
See our plans
|
||||
See Plans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
@@ -7,7 +6,7 @@ type Props = {
|
||||
hasProtectionSources: boolean;
|
||||
};
|
||||
|
||||
function ProtectedNoteOverlay({
|
||||
export function ProtectedNoteOverlay({
|
||||
appState,
|
||||
onViewNote,
|
||||
hasProtectionSources,
|
||||
@@ -41,11 +40,3 @@ function ProtectedNoteOverlay({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProtectedNoteOverlayDirective = toDirective<Props>(
|
||||
ProtectedNoteOverlay,
|
||||
{
|
||||
onViewNote: '&',
|
||||
hasProtectionSources: '=',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { Icon } from '../Icon';
|
||||
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
|
||||
import { usePremiumModal } from '../Premium';
|
||||
import { Switch } from '../Switch';
|
||||
|
||||
type Props = {
|
||||
@@ -20,7 +20,7 @@ export const FocusModeSwitch: FunctionComponent<Props> = ({
|
||||
onClose,
|
||||
isEnabled,
|
||||
}) => {
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const premiumModal = usePremiumModal();
|
||||
const isEntitled =
|
||||
application.getFeatureStatus(FeatureIdentifier.FocusMode) ===
|
||||
FeatureStatus.Entitled;
|
||||
@@ -33,10 +33,10 @@ export const FocusModeSwitch: FunctionComponent<Props> = ({
|
||||
onToggle(!isEnabled);
|
||||
onClose();
|
||||
} else {
|
||||
setShowUpgradeModal(true);
|
||||
premiumModal.activate('Focused Writing');
|
||||
}
|
||||
},
|
||||
[isEntitled, isEnabled, onToggle, setShowUpgradeModal, onClose]
|
||||
[isEntitled, onToggle, isEnabled, onClose, premiumModal]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -57,11 +57,6 @@ export const FocusModeSwitch: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<PremiumFeaturesModal
|
||||
showModal={showUpgradeModal}
|
||||
featureName="Focus Mode"
|
||||
onClose={() => setShowUpgradeModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import {
|
||||
ComponentArea,
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
GetFeatures,
|
||||
SNComponent,
|
||||
SNTheme,
|
||||
} from '@standardnotes/snjs';
|
||||
@@ -17,7 +19,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { Icon } from '../Icon';
|
||||
import { Switch } from '../Switch';
|
||||
import { toDirective, useCloseOnBlur } from '../utils';
|
||||
import { useCloseOnBlur, useCloseOnClickOutside } from '../utils';
|
||||
import {
|
||||
quickSettingsKeyDownHandler,
|
||||
themesMenuKeyDownHandler,
|
||||
@@ -30,9 +32,16 @@ const focusModeAnimationDuration = 1255;
|
||||
const MENU_CLASSNAME =
|
||||
'sn-menu-border sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto';
|
||||
|
||||
export type ThemeItem = {
|
||||
name: string;
|
||||
identifier: FeatureIdentifier;
|
||||
component?: SNTheme;
|
||||
};
|
||||
|
||||
type MenuProps = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
onClickOutside: () => void;
|
||||
};
|
||||
|
||||
const toggleFocusMode = (enabled: boolean) => {
|
||||
@@ -62,15 +71,15 @@ export const sortThemes = (a: SNTheme, b: SNTheme) => {
|
||||
}
|
||||
};
|
||||
|
||||
const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
({ application, appState }) => {
|
||||
export const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
({ application, appState, onClickOutside }) => {
|
||||
const {
|
||||
closeQuickSettingsMenu,
|
||||
shouldAnimateCloseMenu,
|
||||
focusModeEnabled,
|
||||
setFocusModeEnabled,
|
||||
} = appState.quickSettingsMenu;
|
||||
const [themes, setThemes] = useState<SNTheme[]>([]);
|
||||
const [themes, setThemes] = useState<ThemeItem[]>([]);
|
||||
const [toggleableComponents, setToggleableComponents] = useState<
|
||||
SNComponent[]
|
||||
>([]);
|
||||
@@ -84,31 +93,72 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
const quickSettingsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const defaultThemeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const mainRef = useRef<HTMLDivElement>(null);
|
||||
useCloseOnClickOutside(mainRef, () => {
|
||||
onClickOutside();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
toggleFocusMode(focusModeEnabled);
|
||||
}, [focusModeEnabled]);
|
||||
|
||||
const reloadThemes = useCallback(() => {
|
||||
const themes = application.getDisplayableItems(
|
||||
ContentType.Theme
|
||||
) as SNTheme[];
|
||||
setThemes(themes.sort(sortThemes));
|
||||
const themes = (
|
||||
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
|
||||
)
|
||||
.sort(sortThemes)
|
||||
.map((item) => {
|
||||
return {
|
||||
name: item.name,
|
||||
identifier: item.identifier,
|
||||
component: item,
|
||||
};
|
||||
}) as ThemeItem[];
|
||||
|
||||
GetFeatures()
|
||||
.filter(
|
||||
(feature) =>
|
||||
feature.content_type === ContentType.Theme && !feature.layerable
|
||||
)
|
||||
.forEach((theme) => {
|
||||
if (
|
||||
themes.findIndex((item) => item.identifier === theme.identifier) ===
|
||||
-1
|
||||
) {
|
||||
themes.push({
|
||||
name: theme.name as string,
|
||||
identifier: theme.identifier,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setThemes(themes);
|
||||
|
||||
setDefaultThemeOn(
|
||||
!themes.find((theme) => theme.active && !theme.isLayerable())
|
||||
!themes
|
||||
.map((item) => item?.component)
|
||||
.find((theme) => theme?.active && !theme.isLayerable())
|
||||
);
|
||||
}, [application]);
|
||||
|
||||
const reloadToggleableComponents = useCallback(() => {
|
||||
const toggleableComponents = (
|
||||
application.getDisplayableItems(ContentType.Component) as SNComponent[]
|
||||
).filter((component) =>
|
||||
[ComponentArea.EditorStack, ComponentArea.TagsList].includes(
|
||||
component.area
|
||||
)
|
||||
).filter(
|
||||
(component) =>
|
||||
[ComponentArea.EditorStack, ComponentArea.TagsList].includes(
|
||||
component.area
|
||||
) && component.identifier !== FeatureIdentifier.FoldersComponent
|
||||
);
|
||||
setToggleableComponents(toggleableComponents);
|
||||
}, [application]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themes.length) {
|
||||
reloadThemes();
|
||||
}
|
||||
}, [reloadThemes, themes.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanupItemStream = application.streamItems(
|
||||
ContentType.Theme,
|
||||
@@ -145,10 +195,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
prefsButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const [closeOnBlur] = useCloseOnBlur(
|
||||
themesMenuRef as any,
|
||||
setThemesMenuOpen
|
||||
);
|
||||
const [closeOnBlur] = useCloseOnBlur(themesMenuRef, setThemesMenuOpen);
|
||||
|
||||
const toggleThemesMenu = () => {
|
||||
if (!themesMenuOpen && themesButtonRef.current) {
|
||||
@@ -216,14 +263,14 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
};
|
||||
|
||||
const toggleDefaultTheme = () => {
|
||||
const activeTheme = themes.find(
|
||||
(theme) => theme.active && !theme.isLayerable()
|
||||
);
|
||||
const activeTheme = themes
|
||||
.map((item) => item.component)
|
||||
.find((theme) => theme?.active && !theme.isLayerable());
|
||||
if (activeTheme) application.toggleTheme(activeTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div ref={mainRef} className="sn-component">
|
||||
<div
|
||||
className={`sn-quick-settings-menu absolute ${MENU_CLASSNAME} ${
|
||||
shouldAnimateCloseMenu
|
||||
@@ -236,56 +283,54 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
<div className="px-3 mt-1 mb-2 font-semibold color-text uppercase">
|
||||
Quick Settings
|
||||
</div>
|
||||
{themes && themes.length ? (
|
||||
<Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={handleBtnKeyDown}
|
||||
<Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={handleBtnKeyDown}
|
||||
onBlur={closeOnBlur}
|
||||
ref={themesButtonRef}
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="themes" className="color-neutral mr-2" />
|
||||
Themes
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onBlur={closeOnBlur}
|
||||
ref={themesMenuRef}
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
style={{
|
||||
...themesMenuPosition,
|
||||
}}
|
||||
className={`${MENU_CLASSNAME} fixed sn-dropdown--animated`}
|
||||
>
|
||||
<div className="px-3 my-1 font-semibold color-text uppercase">
|
||||
Themes
|
||||
</div>
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={toggleDefaultTheme}
|
||||
onBlur={closeOnBlur}
|
||||
ref={themesButtonRef}
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
|
||||
ref={defaultThemeButtonRef}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="themes" className="color-neutral mr-2" />
|
||||
Themes
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onBlur={closeOnBlur}
|
||||
ref={themesMenuRef}
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
style={{
|
||||
...themesMenuPosition,
|
||||
}}
|
||||
className={`${MENU_CLASSNAME} fixed sn-dropdown--animated`}
|
||||
>
|
||||
<div className="px-3 my-1 font-semibold color-text uppercase">
|
||||
Themes
|
||||
</div>
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={toggleDefaultTheme}
|
||||
<div
|
||||
className={`pseudo-radio-btn ${
|
||||
defaultThemeOn ? 'pseudo-radio-btn--checked' : ''
|
||||
} mr-2`}
|
||||
></div>
|
||||
Default
|
||||
</button>
|
||||
{themes.map((theme) => (
|
||||
<ThemesMenuButton
|
||||
item={theme}
|
||||
application={application}
|
||||
key={theme.component?.uuid ?? theme.identifier}
|
||||
onBlur={closeOnBlur}
|
||||
ref={defaultThemeButtonRef}
|
||||
>
|
||||
<div
|
||||
className={`pseudo-radio-btn ${
|
||||
defaultThemeOn ? 'pseudo-radio-btn--checked' : ''
|
||||
} mr-2`}
|
||||
></div>
|
||||
Default
|
||||
</button>
|
||||
{themes.map((theme) => (
|
||||
<ThemesMenuButton
|
||||
theme={theme}
|
||||
application={application}
|
||||
key={theme.uuid}
|
||||
onBlur={closeOnBlur}
|
||||
/>
|
||||
))}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
{toggleableComponents.map((component) => (
|
||||
<button
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
|
||||
@@ -320,6 +365,3 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const QuickSettingsMenuDirective =
|
||||
toDirective<MenuProps>(QuickSettingsMenu);
|
||||
|
||||
@@ -1,58 +1,94 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNTheme } from '@standardnotes/snjs';
|
||||
import { FeatureStatus } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { Icon } from '../Icon';
|
||||
import { usePremiumModal } from '../Premium';
|
||||
import { Switch } from '../Switch';
|
||||
import { ThemeItem } from './QuickSettingsMenu';
|
||||
|
||||
type Props = {
|
||||
theme: SNTheme;
|
||||
item: ThemeItem;
|
||||
application: WebApplication;
|
||||
onBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
};
|
||||
|
||||
export const ThemesMenuButton: FunctionComponent<Props> = ({
|
||||
application,
|
||||
theme,
|
||||
item,
|
||||
onBlur,
|
||||
}) => {
|
||||
const premiumModal = usePremiumModal();
|
||||
|
||||
const isThirdPartyTheme = useMemo(
|
||||
() => application.isThirdPartyFeature(item.identifier),
|
||||
[application, item.identifier]
|
||||
);
|
||||
const isEntitledToTheme = useMemo(
|
||||
() =>
|
||||
application.getFeatureStatus(item.identifier) === FeatureStatus.Entitled,
|
||||
[application, item.identifier]
|
||||
);
|
||||
const canActivateTheme = useMemo(
|
||||
() => isEntitledToTheme || isThirdPartyTheme,
|
||||
[isEntitledToTheme, isThirdPartyTheme]
|
||||
);
|
||||
|
||||
const toggleTheme: JSXInternal.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
if (theme.isLayerable() || !theme.active) {
|
||||
application.toggleTheme(theme);
|
||||
|
||||
if (item.component && canActivateTheme) {
|
||||
const themeIsLayerableOrNotActive =
|
||||
item.component.isLayerable() || !item.component.active;
|
||||
|
||||
if (themeIsLayerableOrNotActive) {
|
||||
application.toggleTheme(item.component);
|
||||
}
|
||||
} else {
|
||||
premiumModal.activate(`${item.name} theme`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${
|
||||
theme.isLayerable() ? `justify-start` : `justify-between`
|
||||
}`}
|
||||
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between`}
|
||||
onClick={toggleTheme}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{theme.isLayerable() ? (
|
||||
{item.component?.isLayerable() ? (
|
||||
<>
|
||||
<Switch className="px-0 mr-2" checked={theme.active} />
|
||||
{theme.package_info.name}
|
||||
<div className="flex items-center">
|
||||
<Switch className="px-0 mr-2" checked={item.component?.active} />
|
||||
{item.name}
|
||||
</div>
|
||||
{!canActivateTheme && <Icon type="premium-feature" />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`pseudo-radio-btn ${
|
||||
theme.active ? 'pseudo-radio-btn--checked' : ''
|
||||
item.component?.active ? 'pseudo-radio-btn--checked' : ''
|
||||
} mr-2`}
|
||||
></div>
|
||||
<span className={theme.active ? 'font-semibold' : undefined}>
|
||||
{theme.package_info.name}
|
||||
<span
|
||||
className={item.component?.active ? 'font-semibold' : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: theme.package_info?.dock_icon?.background_color,
|
||||
}}
|
||||
></div>
|
||||
{item.component && canActivateTheme ? (
|
||||
<div
|
||||
className="w-5 h-5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
item.component.package_info?.dock_icon?.background_color,
|
||||
}}
|
||||
></div>
|
||||
) : (
|
||||
<Icon type="premium-feature" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
181
app/assets/javascripts/components/RevisionPreviewModal.tsx
Normal file
181
app/assets/javascripts/components/RevisionPreviewModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ComponentViewer } from '@standardnotes/snjs/dist/@types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ContentType, PayloadSource, SNNote } from '@standardnotes/snjs';
|
||||
import { PayloadContent } from '@standardnotes/snjs';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { ComponentView } from './ComponentView';
|
||||
|
||||
interface Props {
|
||||
application: WebApplication;
|
||||
content: PayloadContent;
|
||||
title?: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
type State = {
|
||||
componentViewer?: ComponentViewer;
|
||||
};
|
||||
|
||||
export class RevisionPreviewModal extends PureComponent<Props, State> {
|
||||
private originalNote!: SNNote;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
}
|
||||
|
||||
async componentDidMount(): Promise<void> {
|
||||
super.componentDidMount();
|
||||
|
||||
const templateNote = (await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
this.props.content
|
||||
)) as SNNote;
|
||||
|
||||
this.originalNote = this.application.findItem(this.props.uuid) as SNNote;
|
||||
|
||||
const component = this.application.componentManager.editorForNote(
|
||||
this.originalNote
|
||||
);
|
||||
if (component) {
|
||||
const componentViewer =
|
||||
this.application.componentManager.createComponentViewer(component);
|
||||
componentViewer.setReadonly(true);
|
||||
componentViewer.lockReadonly = true;
|
||||
componentViewer.overrideContextItem = templateNote;
|
||||
this.setState({ componentViewer });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.state.componentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
this.state.componentViewer
|
||||
);
|
||||
}
|
||||
super.componentWillUnmount();
|
||||
}
|
||||
|
||||
restore = (asCopy: boolean) => {
|
||||
const run = async () => {
|
||||
if (asCopy) {
|
||||
await this.application.duplicateItem(this.originalNote, {
|
||||
...this.props.content,
|
||||
title: this.props.content.title
|
||||
? this.props.content.title + ' (copy)'
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
this.application.changeAndSaveItem(
|
||||
this.props.uuid,
|
||||
(mutator) => {
|
||||
mutator.unsafe_setCustomContent(this.props.content);
|
||||
},
|
||||
true,
|
||||
PayloadSource.RemoteActionRetrieved
|
||||
);
|
||||
}
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
if (!asCopy) {
|
||||
if (this.originalNote.locked) {
|
||||
this.application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
confirmButtonStyle: 'danger',
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
};
|
||||
|
||||
dismiss = ($event?: Event) => {
|
||||
$event?.stopPropagation();
|
||||
this.dismissModal();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="item-preview-modal" className="sk-modal medium">
|
||||
<div className="sk-modal-background" />
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-header">
|
||||
<div>
|
||||
<div className="sk-panel-header-title">Preview</div>
|
||||
{this.props.title && (
|
||||
<div className="sk-subtitle neutral mt-1">
|
||||
{this.props.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sk-horizontal-group">
|
||||
<a
|
||||
onClick={() => this.restore(false)}
|
||||
className="sk-a info close-button"
|
||||
>
|
||||
Restore
|
||||
</a>
|
||||
<a
|
||||
onClick={() => this.restore(true)}
|
||||
className="sk-a info close-button"
|
||||
>
|
||||
Restore as copy
|
||||
</a>
|
||||
<a
|
||||
onClick={this.dismiss}
|
||||
className="sk-a info close-button"
|
||||
>
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!this.state.componentViewer && (
|
||||
<div className="sk-panel-content selectable">
|
||||
<div className="sk-h2">{this.props.content.title}</div>
|
||||
<p
|
||||
style="white-space: pre-wrap; font-size: 16px;"
|
||||
className="normal sk-p"
|
||||
>
|
||||
{this.props.content.text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.componentViewer && (
|
||||
<>
|
||||
<div
|
||||
style="height: auto; flex-grow: 0"
|
||||
className="sk-panel-content sk-h2"
|
||||
>
|
||||
{this.props.content.title}
|
||||
</div>
|
||||
<div className="component-view">
|
||||
<ComponentView
|
||||
componentViewer={this.state.componentViewer}
|
||||
application={this.application}
|
||||
appState={this.appState}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon, IconType } from './Icon';
|
||||
import { Icon } from './Icon';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
|
||||
type ButtonType = 'normal' | 'primary';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import { toDirective, useCloseOnBlur } from './utils';
|
||||
import { useCloseOnBlur } from './utils';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
@@ -114,5 +114,3 @@ export const SearchOptions = observer(({ appState }: Props) => {
|
||||
</Disclosure>
|
||||
);
|
||||
});
|
||||
|
||||
export const SearchOptionsDirective = toDirective<Props>(SearchOptions);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogLabel,
|
||||
} from '@reach/alert-dialog';
|
||||
import { toDirective } from './utils';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
@@ -26,12 +25,12 @@ type Session = RemoteSession & {
|
||||
function useSessions(
|
||||
application: SNApplication
|
||||
): [
|
||||
Session[],
|
||||
() => void,
|
||||
boolean,
|
||||
(uuid: UuidString) => Promise<void>,
|
||||
string
|
||||
] {
|
||||
Session[],
|
||||
() => void,
|
||||
boolean,
|
||||
(uuid: UuidString) => Promise<void>,
|
||||
string
|
||||
] {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastRefreshDate, setLastRefreshDate] = useState(Date.now());
|
||||
const [refreshing, setRefreshing] = useState(true);
|
||||
@@ -93,19 +92,14 @@ function useSessions(
|
||||
return [sessions, refresh, refreshing, revokeSession, errorMessage];
|
||||
}
|
||||
|
||||
const SessionsModal: FunctionComponent<{
|
||||
const SessionsModalContent: FunctionComponent<{
|
||||
appState: AppState;
|
||||
application: SNApplication;
|
||||
}> = ({ appState, application }) => {
|
||||
const close = () => appState.closeSessionsModal();
|
||||
|
||||
const [
|
||||
sessions,
|
||||
refresh,
|
||||
refreshing,
|
||||
revokeSession,
|
||||
errorMessage,
|
||||
] = useSessions(application);
|
||||
const [sessions, refresh, refreshing, revokeSession, errorMessage] =
|
||||
useSessions(application);
|
||||
|
||||
const [confirmRevokingSessionUuid, setRevokingSessionUuid] = useState('');
|
||||
const closeRevokeSessionAlert = () => setRevokingSessionUuid('');
|
||||
@@ -240,15 +234,15 @@ const SessionsModal: FunctionComponent<{
|
||||
);
|
||||
};
|
||||
|
||||
export const Sessions: FunctionComponent<{
|
||||
export const SessionsModal: FunctionComponent<{
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
}> = observer(({ appState, application }) => {
|
||||
if (appState.isSessionsModalVisible) {
|
||||
return <SessionsModal application={application} appState={appState} />;
|
||||
return (
|
||||
<SessionsModalContent application={application} appState={appState} />
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const SessionsModalDirective = toDirective(Sessions);
|
||||
|
||||
@@ -29,7 +29,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className} ${isDisabled ? 'faded' : ''}`}
|
||||
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className} ${
|
||||
isDisabled ? 'faded' : ''
|
||||
}`}
|
||||
{...(props.role ? { role: props.role } : {})}
|
||||
>
|
||||
{props.children}
|
||||
@@ -51,8 +53,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`sn-switch-handle ${checked ? 'sn-switch-handle--right' : ''
|
||||
}`}
|
||||
className={`sn-switch-handle ${
|
||||
checked ? 'sn-switch-handle--right' : ''
|
||||
}`}
|
||||
/>
|
||||
</CustomCheckboxContainer>
|
||||
</label>
|
||||
|
||||
187
app/assets/javascripts/components/SyncResolutionMenu.tsx
Normal file
187
app/assets/javascripts/components/SyncResolutionMenu.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PureComponent } from './Abstract/PureComponent';
|
||||
import { Fragment } from 'preact';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export class SyncResolutionMenu extends PureComponent<Props> {
|
||||
private status: Partial<{
|
||||
backupFinished: boolean;
|
||||
resolving: boolean;
|
||||
attemptedResolution: boolean;
|
||||
success: boolean;
|
||||
fail: boolean;
|
||||
}> = {};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application);
|
||||
}
|
||||
|
||||
downloadBackup(encrypted: boolean) {
|
||||
this.props.application.getArchiveService().downloadBackup(encrypted);
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
skipBackup() {
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
async performSyncResolution() {
|
||||
this.status.resolving = true;
|
||||
await this.props.application.resolveOutOfSync();
|
||||
|
||||
this.status.resolving = false;
|
||||
this.status.attemptedResolution = true;
|
||||
if (this.props.application.isOutOfSync()) {
|
||||
this.status.fail = true;
|
||||
} else {
|
||||
this.status.success = true;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="sync-resolution-menu" className="sk-panel sk-panel-right">
|
||||
<div className="sk-panel-header">
|
||||
<div className="sk-panel-header-title">Out of Sync</div>
|
||||
<a onClick={this.close} className="sk-a info close-button">
|
||||
Close
|
||||
</a>
|
||||
</div>
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-row sk-p">
|
||||
We've detected that the data on the server may not match the
|
||||
data in the current application session.
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<strong className="sk-panel-row">
|
||||
Option 1 — Restart App:
|
||||
</strong>
|
||||
<div className="sk-p">
|
||||
Quit the application and re-open it. Sometimes, this may
|
||||
resolve the issue.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<strong className="sk-panel-row">
|
||||
Option 2 (recommended) — Sign Out:
|
||||
</strong>
|
||||
<div className="sk-p">
|
||||
Sign out of your account, then sign back in. This will
|
||||
ensure your data is consistent with the server.
|
||||
</div>
|
||||
Be sure to download a backup of your data before doing so.
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<strong className="sk-panel-row">
|
||||
Option 3 — Sync Resolution:
|
||||
</strong>
|
||||
<div className="sk-p">
|
||||
We can attempt to reconcile changes by downloading all data
|
||||
from the server. No existing data will be overwritten. If
|
||||
the local contents of an item differ from what the server
|
||||
has, a conflicted copy will be created.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!this.status.backupFinished && (
|
||||
<Fragment>
|
||||
<div className="sk-p sk-panel-row">
|
||||
Please download a backup before we attempt to perform a full
|
||||
account sync resolution.
|
||||
</div>
|
||||
<div className="sk-panel-row">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => this.downloadBackup(true)}
|
||||
className="sn-button small info"
|
||||
>
|
||||
Encrypted
|
||||
</button>
|
||||
<button
|
||||
onClick={() => this.downloadBackup(false)}
|
||||
className="sn-button small info"
|
||||
>
|
||||
Decrypted
|
||||
</button>
|
||||
<button
|
||||
onClick={this.skipBackup}
|
||||
className="sn-button small danger"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{this.status.backupFinished && (
|
||||
<div>
|
||||
{!this.status.resolving && !this.status.attemptedResolution && (
|
||||
<div className="sk-panel-row">
|
||||
<button
|
||||
onClick={this.performSyncResolution}
|
||||
className="sn-button small info"
|
||||
>
|
||||
Perform Sync Resolution
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{this.status.resolving && (
|
||||
<div className="sk-panel-row justify-left">
|
||||
<div className="sk-horizontal-group">
|
||||
<div className="sk-spinner small info" />
|
||||
<div className="sk-label">
|
||||
Attempting sync resolution...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{this.status.fail && (
|
||||
<div className="sk-panel-column">
|
||||
<div className="sk-panel-row sk-label danger">
|
||||
Sync Resolution Failed
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
We attempted to reconcile local content and server
|
||||
content, but were unable to do so. At this point, we
|
||||
recommend signing out of your account and signing back
|
||||
in. You may wish to download a data backup before doing
|
||||
so.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{this.status.success && (
|
||||
<div className="sk-panel-column">
|
||||
<div className="sk-panel-row sk-label success">
|
||||
Sync Resolution Success
|
||||
</div>
|
||||
<div className="sk-p sk-panel-row">
|
||||
Your local data is now in sync with the server. You may
|
||||
close this window.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import {
|
||||
FeaturesState,
|
||||
TAG_FOLDERS_FEATURE_NAME,
|
||||
} from '@/ui_models/app_state/features_state';
|
||||
import { FeaturesState } from '@/ui_models/app_state/features_state';
|
||||
import { TagsState } from '@/ui_models/app_state/tags_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useDrop } from 'react-dnd';
|
||||
@@ -14,51 +11,38 @@ type Props = {
|
||||
featuresState: FeaturesState;
|
||||
};
|
||||
|
||||
export const RootTagDropZone: React.FC<Props> = observer(
|
||||
({ tagsState, featuresState }) => {
|
||||
const premiumModal = usePremiumModal();
|
||||
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
|
||||
const hasFolders = featuresState.hasFolders;
|
||||
export const RootTagDropZone: React.FC<Props> = observer(({ tagsState }) => {
|
||||
const premiumModal = usePremiumModal();
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
|
||||
() => ({
|
||||
accept: ItemTypes.TAG,
|
||||
canDrop: () => {
|
||||
return true;
|
||||
},
|
||||
drop: (item) => {
|
||||
if (!hasFolders) {
|
||||
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
tagsState.assignParent(item.uuid, undefined);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
canDrop: !!monitor.canDrop(),
|
||||
}),
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
|
||||
() => ({
|
||||
accept: ItemTypes.TAG,
|
||||
canDrop: (item) => {
|
||||
return tagsState.hasParent(item.uuid);
|
||||
},
|
||||
drop: (item) => {
|
||||
tagsState.assignParent(item.uuid, undefined);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
canDrop: !!monitor.canDrop(),
|
||||
}),
|
||||
[tagsState, hasFolders, premiumModal]
|
||||
);
|
||||
}),
|
||||
[tagsState, premiumModal]
|
||||
);
|
||||
|
||||
if (!isNativeFoldersEnabled || !hasFolders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={`root-drop ${canDrop ? 'active' : ''} ${
|
||||
isOver ? 'is-drag-over' : ''
|
||||
}`}
|
||||
>
|
||||
<Icon className="color-neutral" type="link-off" />
|
||||
<p className="content">
|
||||
Move the tag here to <br />
|
||||
remove it from its folder.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={`root-drop ${canDrop ? 'active' : ''} ${
|
||||
isOver ? 'is-drag-over' : ''
|
||||
}`}
|
||||
>
|
||||
<Icon className="color-neutral" type="link-off" />
|
||||
<p className="content">
|
||||
Move the tag here to <br />
|
||||
remove it from its folder.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Icon, IconType } from '@/components/Icon';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { FeaturesState } from '@/ui_models/app_state/features_state';
|
||||
import { TagsState } from '@/ui_models/app_state/tags_state';
|
||||
import '@reach/tooltip/styles.css';
|
||||
import { SNSmartTag } from '@standardnotes/snjs';
|
||||
import { SNSmartTag, IconType } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
@@ -37,7 +37,6 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
|
||||
const level = 0;
|
||||
const isSelected = tagsState.selected === tag;
|
||||
const isEditing = tagsState.editingTag === tag;
|
||||
const isSmartTagsEnabled = features.enableNativeSmartTagsFeature;
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(tag.title || '');
|
||||
@@ -88,7 +87,7 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
|
||||
tagsState.remove(tag);
|
||||
}, [tagsState, tag]);
|
||||
|
||||
const isFaded = !isSmartTagsEnabled && !tag.isAllTag;
|
||||
const isFaded = !tag.isAllTag;
|
||||
const iconType = smartTagIconType(tag);
|
||||
|
||||
return (
|
||||
@@ -104,14 +103,12 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
|
||||
>
|
||||
{!tag.errorDecrypting ? (
|
||||
<div className="tag-info">
|
||||
{isSmartTagsEnabled && (
|
||||
<div className={`tag-icon mr-1`}>
|
||||
<Icon
|
||||
type={iconType}
|
||||
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`tag-icon mr-1`}>
|
||||
<Icon
|
||||
type={iconType}
|
||||
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className={`title ${isEditing ? 'editing' : ''}`}
|
||||
id={`react-tag-${tag.uuid}`}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const TagsList: FunctionComponent<Props> = observer(({ appState }) => {
|
||||
<DndProvider backend={backend}>
|
||||
{allTags.length === 0 ? (
|
||||
<div className="no-tags-placeholder">
|
||||
No tags. Create one using the add button above.
|
||||
No tags or folders. Create one using the add button above.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -37,12 +37,11 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
const hasChildren = childrenTags.length > 0;
|
||||
|
||||
const hasFolders = features.hasFolders;
|
||||
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
|
||||
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
|
||||
|
||||
const premiumModal = usePremiumModal();
|
||||
|
||||
const [showChildren, setShowChildren] = useState(hasChildren);
|
||||
const [showChildren, setShowChildren] = useState(tag.expanded);
|
||||
const [hadChildren, setHadChildren] = useState(hasChildren);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,9 +58,12 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
const toggleChildren = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowChildren((x) => !x);
|
||||
setShowChildren((x) => {
|
||||
tagsState.setExpanded(tag, !x);
|
||||
return !x;
|
||||
});
|
||||
},
|
||||
[setShowChildren]
|
||||
[setShowChildren, tag, tagsState]
|
||||
);
|
||||
|
||||
const selectCurrentTag = useCallback(() => {
|
||||
@@ -114,13 +116,13 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
type: ItemTypes.TAG,
|
||||
item: { uuid: tag.uuid },
|
||||
canDrag: () => {
|
||||
return isNativeFoldersEnabled;
|
||||
return true;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
}),
|
||||
[tag, hasFolders]
|
||||
[tag]
|
||||
);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
|
||||
@@ -160,7 +162,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
>
|
||||
{!tag.errorDecrypting ? (
|
||||
<div className="tag-info" title={title} ref={dropRef}>
|
||||
{hasFolders && isNativeFoldersEnabled && hasAtLeastOneFolder && (
|
||||
{hasAtLeastOneFolder && (
|
||||
<div
|
||||
className={`tag-fold ${showChildren ? 'opened' : 'closed'}`}
|
||||
onClick={hasChildren ? toggleChildren : undefined}
|
||||
@@ -173,12 +175,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`tag-icon ${
|
||||
isNativeFoldersEnabled ? 'draggable' : ''
|
||||
} mr-1`}
|
||||
ref={dragRef}
|
||||
>
|
||||
<div className={`tag-icon ${'draggable'} mr-1`} ref={dragRef}>
|
||||
<Icon
|
||||
type="hashtag"
|
||||
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { TagsList } from '@/components/Tags/TagsList';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { ApplicationEvent } from '@/__mocks__/@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { TagsSectionAddButton } from './TagsSectionAddButton';
|
||||
import { TagsSectionTitle } from './TagsSectionTitle';
|
||||
|
||||
@@ -11,11 +13,51 @@ type Props = {
|
||||
|
||||
export const TagsSection: FunctionComponent<Props> = observer(
|
||||
({ appState }) => {
|
||||
const [hasMigration, setHasMigration] = useState<boolean>(false);
|
||||
|
||||
const checkIfMigrationNeeded = useCallback(() => {
|
||||
setHasMigration(appState.application.hasTagsNeedingFoldersMigration());
|
||||
}, [appState.application]);
|
||||
|
||||
useEffect(() => {
|
||||
appState.application.addEventObserver(async (event) => {
|
||||
const events = [
|
||||
ApplicationEvent.CompletedInitialSync,
|
||||
ApplicationEvent.SignedIn,
|
||||
];
|
||||
if (events.includes(event)) {
|
||||
checkIfMigrationNeeded();
|
||||
}
|
||||
});
|
||||
}, [appState.application, checkIfMigrationNeeded]);
|
||||
|
||||
const runMigration = useCallback(async () => {
|
||||
if (
|
||||
await appState.application.alertService.confirm(
|
||||
'<i>Introducing native, built-in nested tags without requiring the legacy Folders extension.</i><br/></br> ' +
|
||||
" To get started, we'll need to migrate any tags containing a dot character to the new system.<br/></br> " +
|
||||
' This migration will convert any tags with dots appearing in their name into a natural' +
|
||||
' hierarchy that is compatible with the new nested tags feature.' +
|
||||
' Running this migration will remove any "." characters appearing in tag names.',
|
||||
'New: Folders to Nested Tags',
|
||||
'Run Migration'
|
||||
)
|
||||
) {
|
||||
appState.application.migrateTagsToFolders().then(() => {
|
||||
checkIfMigrationNeeded();
|
||||
});
|
||||
}
|
||||
}, [appState.application, checkIfMigrationNeeded]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<TagsSectionTitle features={appState.features} />
|
||||
<TagsSectionTitle
|
||||
features={appState.features}
|
||||
hasMigration={hasMigration}
|
||||
onClickMigration={runMigration}
|
||||
/>
|
||||
<TagsSectionAddButton
|
||||
tags={appState.tags}
|
||||
features={appState.features}
|
||||
|
||||
@@ -10,13 +10,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const TagsSectionAddButton: FunctionComponent<Props> = observer(
|
||||
({ tags, features }) => {
|
||||
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
|
||||
|
||||
if (!isNativeFoldersEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
({ tags }) => {
|
||||
return (
|
||||
<IconButton
|
||||
focusable={true}
|
||||
|
||||
@@ -11,33 +11,32 @@ import { useCallback } from 'preact/hooks';
|
||||
|
||||
type Props = {
|
||||
features: FeaturesState;
|
||||
hasMigration: boolean;
|
||||
onClickMigration: () => void;
|
||||
};
|
||||
|
||||
export const TagsSectionTitle: FunctionComponent<Props> = observer(
|
||||
({ features }) => {
|
||||
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
|
||||
const hasFolders = features.hasFolders;
|
||||
({ features, hasMigration, onClickMigration }) => {
|
||||
const entitledToFolders = features.hasFolders;
|
||||
const modal = usePremiumModal();
|
||||
|
||||
const showPremiumAlert = useCallback(() => {
|
||||
modal.activate(TAG_FOLDERS_FEATURE_NAME);
|
||||
}, [modal]);
|
||||
|
||||
if (!isNativeFoldersEnabled) {
|
||||
return (
|
||||
<>
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Tags</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFolders) {
|
||||
if (entitledToFolders) {
|
||||
return (
|
||||
<>
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Folders</span>
|
||||
{hasMigration && (
|
||||
<label
|
||||
className="ml-1 sk-bold color-info cursor-pointer"
|
||||
onClick={onClickMigration}
|
||||
>
|
||||
Migration Available
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
ComponentChild,
|
||||
ComponentChildren,
|
||||
FunctionComponent,
|
||||
VNode,
|
||||
} from 'preact';
|
||||
import { ComponentChildren, FunctionComponent, VNode } from 'preact';
|
||||
import { forwardRef, Ref } from 'preact/compat';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { Icon, IconType } from '../Icon';
|
||||
import { Icon } from '../Icon';
|
||||
import { Switch, SwitchProps } from '../Switch';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/views/constants';
|
||||
|
||||
export enum MenuItemType {
|
||||
IconButton,
|
||||
@@ -20,6 +17,7 @@ type MenuItemProps = {
|
||||
children: ComponentChildren;
|
||||
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>;
|
||||
onChange?: SwitchProps['onChange'];
|
||||
onBlur?: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
className?: string;
|
||||
checked?: boolean;
|
||||
icon?: IconType;
|
||||
@@ -33,6 +31,7 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
||||
children,
|
||||
onClick,
|
||||
onChange,
|
||||
onBlur,
|
||||
className = '',
|
||||
type,
|
||||
checked,
|
||||
@@ -44,22 +43,31 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
|
||||
) => {
|
||||
return type === MenuItemType.SwitchButton &&
|
||||
typeof onChange === 'function' ? (
|
||||
<Switch
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
|
||||
onClick={() => {
|
||||
onChange(!checked);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
tabIndex={
|
||||
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
|
||||
}
|
||||
role="menuitemcheckbox"
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1}
|
||||
aria-checked={checked}
|
||||
>
|
||||
{children}
|
||||
</Switch>
|
||||
<span className="flex flex-grow items-center">{children}</span>
|
||||
<Switch className="px-0" checked={checked} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={ref}
|
||||
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1}
|
||||
tabIndex={
|
||||
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
|
||||
}
|
||||
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
|
||||
onClick={onClick}
|
||||
onBlur={onBlur}
|
||||
{...(type === MenuItemType.RadioButton
|
||||
? { 'aria-checked': checked }
|
||||
: {})}
|
||||
@@ -90,22 +98,27 @@ type ListElementProps = {
|
||||
};
|
||||
|
||||
export const MenuItemListElement: FunctionComponent<ListElementProps> =
|
||||
forwardRef(({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => {
|
||||
const child = children as VNode<unknown>;
|
||||
forwardRef(
|
||||
(
|
||||
{ children, isFirstMenuItem }: ListElementProps,
|
||||
ref: Ref<HTMLLIElement>
|
||||
) => {
|
||||
const child = children as VNode<unknown>;
|
||||
|
||||
return (
|
||||
<li className="list-style-none" role="none" ref={ref}>
|
||||
{{
|
||||
...child,
|
||||
props: {
|
||||
...(child.props ? { ...child.props } : {}),
|
||||
...(child.type === MenuItem
|
||||
? {
|
||||
tabIndex: isFirstMenuItem ? 0 : -1,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<li className="list-style-none" role="none" ref={ref}>
|
||||
{{
|
||||
...child,
|
||||
props: {
|
||||
...(child.props ? { ...child.props } : {}),
|
||||
...(child.type === MenuItem
|
||||
? {
|
||||
tabIndex: isFirstMenuItem ? 0 : -1,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
|
||||
* monitored.
|
||||
*/
|
||||
export function useCloseOnBlur(
|
||||
container: { current?: HTMLDivElement },
|
||||
container: { current?: HTMLDivElement | null },
|
||||
setOpen: (open: boolean) => void
|
||||
): [
|
||||
(event: { relatedTarget: EventTarget | null }) => void,
|
||||
@@ -33,55 +33,27 @@ export function useCloseOnBlur(
|
||||
|
||||
export function useCloseOnClickOutside(
|
||||
container: { current: HTMLDivElement | null },
|
||||
setOpen: (open: boolean) => void
|
||||
callback: () => void
|
||||
): void {
|
||||
const closeOnClickOutside = useCallback(
|
||||
(event: { target: EventTarget | null }) => {
|
||||
if (!container.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
if (!container.current) {
|
||||
return;
|
||||
}
|
||||
const isDescendant = container.current.contains(event.target as Node);
|
||||
if (!isDescendant) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
[container, setOpen]
|
||||
[container, callback]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', closeOnClickOutside);
|
||||
document.addEventListener('click', closeOnClickOutside, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener('click', closeOnClickOutside);
|
||||
document.removeEventListener('click', closeOnClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [closeOnClickOutside]);
|
||||
}
|
||||
|
||||
export function toDirective<Props>(
|
||||
component: FunctionComponent<Props>,
|
||||
scope: Record<string, '=' | '&' | '@'> = {}
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
return function () {
|
||||
return {
|
||||
controller: [
|
||||
'$element',
|
||||
'$scope',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
($element: JQLite, $scope: any) => {
|
||||
if ($scope.class) {
|
||||
$element.addClass($scope.class);
|
||||
}
|
||||
return {
|
||||
$onChanges() {
|
||||
render(h(component, $scope), $element[0]);
|
||||
},
|
||||
$onDestroy() {
|
||||
unmountComponentAtNode($element[0]);
|
||||
},
|
||||
};
|
||||
},
|
||||
],
|
||||
scope: {
|
||||
application: '=',
|
||||
appState: '=',
|
||||
...scope,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function autofocus($timeout: ng.ITimeoutService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
shouldFocus: '='
|
||||
},
|
||||
link: function (
|
||||
$scope: ng.IScope,
|
||||
$element: JQLite
|
||||
) {
|
||||
$timeout(() => {
|
||||
if (($scope as any).shouldFocus) {
|
||||
$element[0].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function clickOutside($document: ng.IDocumentService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
link($scope: ng.IScope, $element: JQLite, attrs: any) {
|
||||
let didApplyClickOutside = false;
|
||||
|
||||
function onElementClick(event: JQueryEventObject) {
|
||||
didApplyClickOutside = false;
|
||||
if (attrs.isOpen) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick(event: JQueryEventObject) {
|
||||
/** Ignore click if on SKAlert */
|
||||
if (event.target.closest('.sk-modal')) {
|
||||
return;
|
||||
}
|
||||
if (!didApplyClickOutside) {
|
||||
$scope.$apply(attrs.clickOutside);
|
||||
didApplyClickOutside = true;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
attrs.clickOutside = undefined;
|
||||
$element.unbind('click', onElementClick);
|
||||
$document.unbind('click', onDocumentClick);
|
||||
});
|
||||
|
||||
$element.bind('click', onElementClick);
|
||||
$document.bind('click', onDocumentClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
/* @ngInject */
|
||||
export function delayHide($timeout: ng.ITimeoutService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
show: '=',
|
||||
delay: '@'
|
||||
},
|
||||
link: function (scope: ng.IScope, elem: JQLite) {
|
||||
const scopeAny = scope as any;
|
||||
const showSpinner = () => {
|
||||
if (scopeAny.hidePromise) {
|
||||
$timeout.cancel(scopeAny.hidePromise);
|
||||
scopeAny.hidePromise = null;
|
||||
}
|
||||
showElement(true);
|
||||
};
|
||||
|
||||
const hideSpinner = () => {
|
||||
scopeAny.hidePromise = $timeout(
|
||||
showElement.bind(this as any, false),
|
||||
getDelay()
|
||||
);
|
||||
};
|
||||
|
||||
const showElement = (show: boolean) => {
|
||||
show ? elem.css({ display: '' }) : elem.css({ display: 'none' });
|
||||
};
|
||||
|
||||
const getDelay = () => {
|
||||
const delay = parseInt(scopeAny.delay);
|
||||
return angular.isNumber(delay) ? delay : 200;
|
||||
};
|
||||
|
||||
showElement(false);
|
||||
// Whenever the scope variable updates we simply
|
||||
// show if it evaluates to 'true' and hide if 'false'
|
||||
scope.$watch('show', function (newVal) {
|
||||
newVal ? showSpinner() : hideSpinner();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function elemReady($parse: ng.IParseService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function($scope: ng.IScope, elem: JQLite, attrs: any) {
|
||||
elem.ready(function() {
|
||||
$scope.$apply(function() {
|
||||
const func = $parse(attrs.elemReady);
|
||||
func($scope);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function fileChange() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
handler: '&'
|
||||
},
|
||||
link: function (scope: ng.IScope, element: JQLite) {
|
||||
element.on('change', (event) => {
|
||||
scope.$apply(() => {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
(scope as any).handler({
|
||||
files: files
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export { autofocus } from './autofocus';
|
||||
export { clickOutside } from './click-outside';
|
||||
export { delayHide } from './delay-hide';
|
||||
export { elemReady } from './elemReady';
|
||||
export { fileChange } from './file-change';
|
||||
export { lowercase } from './lowercase';
|
||||
export { selectOnFocus } from './selectOnFocus';
|
||||
export { snEnter } from './snEnter';
|
||||
@@ -1,24 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function lowercase() {
|
||||
return {
|
||||
require: 'ngModel',
|
||||
link: function (
|
||||
scope: ng.IScope,
|
||||
_: JQLite,
|
||||
attrs: any,
|
||||
ctrl: any
|
||||
) {
|
||||
const lowercase = (inputValue: string) => {
|
||||
if (inputValue === undefined) inputValue = '';
|
||||
const lowercased = inputValue.toLowerCase();
|
||||
if (lowercased !== inputValue) {
|
||||
ctrl.$setViewValue(lowercased);
|
||||
ctrl.$render();
|
||||
}
|
||||
return lowercased;
|
||||
};
|
||||
ctrl.$parsers.push(lowercase);
|
||||
lowercase((scope as any)[attrs.ngModel]);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function selectOnFocus($window: ng.IWindowService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function (scope: ng.IScope, element: JQLite) {
|
||||
element.on('focus', () => {
|
||||
if (!$window.getSelection()!.toString()) {
|
||||
const input = element[0] as HTMLInputElement;
|
||||
/** Allow text to populate */
|
||||
setTimeout(() => {
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function snEnter() {
|
||||
return function (
|
||||
scope: ng.IScope,
|
||||
element: JQLite,
|
||||
attrs: any
|
||||
) {
|
||||
element.bind('keydown keypress', function (event) {
|
||||
if (event.which === 13) {
|
||||
scope.$apply(function () {
|
||||
scope.$eval(attrs.snEnter, { event: event });
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/actions-menu.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { SNItem, Action, SNActionsExtension, UuidString, CopyPayload } from '@standardnotes/snjs';
|
||||
import { ActionResponse } from '@standardnotes/snjs';
|
||||
import { ActionsExtensionMutator } from '@standardnotes/snjs';
|
||||
|
||||
type ActionsMenuScope = {
|
||||
application: WebApplication
|
||||
item: SNItem
|
||||
}
|
||||
|
||||
type ActionSubRow = {
|
||||
onClick: () => void
|
||||
label: string
|
||||
subtitle: string
|
||||
spinnerClass?: string
|
||||
}
|
||||
|
||||
type ExtensionState = {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
type ActionsMenuState = {
|
||||
extensions: SNActionsExtension[]
|
||||
extensionsState: Record<UuidString, ExtensionState>
|
||||
selectedActionId?: number
|
||||
menuItems: {
|
||||
uuid: UuidString,
|
||||
name: string,
|
||||
loading: boolean,
|
||||
error: boolean,
|
||||
hidden: boolean,
|
||||
actions: (Action & {
|
||||
subrows?: ActionSubRow[]
|
||||
})[]
|
||||
}[]
|
||||
}
|
||||
|
||||
class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements ActionsMenuScope {
|
||||
application!: WebApplication
|
||||
item!: SNItem
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.initProps({
|
||||
item: this.item
|
||||
});
|
||||
this.loadExtensions();
|
||||
this.autorun(() => {
|
||||
this.rebuildMenuState({
|
||||
hiddenExtensions: this.appState.actionsMenu.hiddenExtensions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState() {
|
||||
const extensions = this.application.actionsManager.getExtensions().sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
}).map((extension) => {
|
||||
return new SNActionsExtension(CopyPayload(extension.payload, {
|
||||
content: {
|
||||
...extension.payload.safeContent,
|
||||
actions: []
|
||||
}
|
||||
}));
|
||||
});
|
||||
const extensionsState: Record<UuidString, ExtensionState> = {};
|
||||
extensions.map((extension) => {
|
||||
extensionsState[extension.uuid] = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
extensions,
|
||||
extensionsState,
|
||||
hiddenExtensions: {},
|
||||
menuItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
rebuildMenuState({
|
||||
extensions = this.state.extensions,
|
||||
extensionsState = this.state.extensionsState,
|
||||
selectedActionId = this.state.selectedActionId,
|
||||
hiddenExtensions = this.appState.actionsMenu.hiddenExtensions,
|
||||
} = {}) {
|
||||
return this.setState({
|
||||
extensions,
|
||||
extensionsState,
|
||||
selectedActionId,
|
||||
menuItems: extensions.map(extension => {
|
||||
const state = extensionsState[extension.uuid];
|
||||
const hidden = hiddenExtensions[extension.uuid];
|
||||
return {
|
||||
uuid: extension.uuid,
|
||||
name: extension.name,
|
||||
loading: state?.loading ?? false,
|
||||
error: state?.error ?? false,
|
||||
hidden: hidden ?? false,
|
||||
deprecation: extension.deprecation!,
|
||||
actions: extension.actionsWithContextForItem(this.item).map(action => {
|
||||
if (action.id === selectedActionId) {
|
||||
return {
|
||||
...action,
|
||||
subrows: this.subRowsForAction(action, extension)
|
||||
};
|
||||
} else {
|
||||
return action;
|
||||
}
|
||||
})
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async loadExtensions() {
|
||||
await Promise.all(this.state.extensions.map(async (extension: SNActionsExtension) => {
|
||||
this.setLoadingExtension(extension.uuid, true);
|
||||
const updatedExtension = await this.application.actionsManager!.loadExtensionInContextOfItem(
|
||||
extension,
|
||||
this.item
|
||||
);
|
||||
if (updatedExtension) {
|
||||
await this.updateExtension(updatedExtension!);
|
||||
} else {
|
||||
this.setErrorExtension(extension.uuid, true);
|
||||
}
|
||||
this.setLoadingExtension(extension.uuid, false);
|
||||
}));
|
||||
}
|
||||
|
||||
async executeAction(action: Action, extensionUuid: UuidString) {
|
||||
if (action.verb === 'nested') {
|
||||
this.rebuildMenuState({
|
||||
selectedActionId: action.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
const extension = this.application.findItem(extensionUuid) as SNActionsExtension;
|
||||
await this.updateAction(action, extension, { running: true });
|
||||
const response = await this.application.actionsManager!.runAction(
|
||||
action,
|
||||
this.item,
|
||||
async () => {
|
||||
/** @todo */
|
||||
return '';
|
||||
}
|
||||
);
|
||||
if (response.error) {
|
||||
await this.updateAction(action, extension, { error: true });
|
||||
return;
|
||||
}
|
||||
await this.updateAction(action, extension, { running: false });
|
||||
this.handleActionResponse(action, response);
|
||||
await this.reloadExtension(extension);
|
||||
}
|
||||
|
||||
handleActionResponse(action: Action, result: ActionResponse) {
|
||||
switch (action.verb) {
|
||||
case 'render': {
|
||||
const item = result.item;
|
||||
this.application.presentRevisionPreviewModal(
|
||||
item.uuid,
|
||||
item.content
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private subRowsForAction(parentAction: Action, extension: Pick<SNActionsExtension, 'uuid'>): ActionSubRow[] | undefined {
|
||||
if (!parentAction.subactions) {
|
||||
return undefined;
|
||||
}
|
||||
return parentAction.subactions.map((subaction) => {
|
||||
return {
|
||||
id: subaction.id,
|
||||
onClick: () => {
|
||||
this.executeAction(subaction, extension.uuid);
|
||||
},
|
||||
label: subaction.label,
|
||||
subtitle: subaction.desc,
|
||||
spinnerClass: subaction.running ? 'info' : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async updateAction(
|
||||
action: Action,
|
||||
extension: SNActionsExtension,
|
||||
params: {
|
||||
running?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
) {
|
||||
const updatedExtension = await this.application.changeItem(extension.uuid, (mutator) => {
|
||||
const extensionMutator = mutator as ActionsExtensionMutator;
|
||||
extensionMutator.actions = extension!.actions.map((act) => {
|
||||
if (act && params && act.verb === action.verb && act.url === action.url) {
|
||||
return {
|
||||
...action,
|
||||
running: params?.running,
|
||||
error: params?.error,
|
||||
} as Action;
|
||||
}
|
||||
return act;
|
||||
});
|
||||
}) as SNActionsExtension;
|
||||
await this.updateExtension(updatedExtension);
|
||||
}
|
||||
|
||||
private async updateExtension(extension: SNActionsExtension) {
|
||||
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
|
||||
if (extension.uuid === ext.uuid) {
|
||||
return extension;
|
||||
}
|
||||
return ext;
|
||||
});
|
||||
await this.rebuildMenuState({
|
||||
extensions
|
||||
});
|
||||
}
|
||||
|
||||
private async reloadExtension(extension: SNActionsExtension) {
|
||||
const extensionInContext = await this.application.actionsManager!.loadExtensionInContextOfItem(
|
||||
extension,
|
||||
this.item
|
||||
);
|
||||
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
|
||||
if (extension.uuid === ext.uuid) {
|
||||
return extensionInContext!;
|
||||
}
|
||||
return ext;
|
||||
});
|
||||
this.rebuildMenuState({
|
||||
extensions
|
||||
});
|
||||
}
|
||||
|
||||
public toggleExtensionVisibility(extensionUuid: UuidString) {
|
||||
this.appState.actionsMenu.toggleExtensionVisibility(extensionUuid);
|
||||
}
|
||||
|
||||
private setLoadingExtension(extensionUuid: UuidString, value = false) {
|
||||
const { extensionsState } = this.state;
|
||||
extensionsState[extensionUuid].loading = value;
|
||||
this.rebuildMenuState({
|
||||
extensionsState
|
||||
});
|
||||
}
|
||||
|
||||
private setErrorExtension(extensionUuid: UuidString, value = false) {
|
||||
const { extensionsState } = this.state;
|
||||
extensionsState[extensionUuid].error = value;
|
||||
this.rebuildMenuState({
|
||||
extensionsState
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionsMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = ActionsMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent, SNItem, ComponentArea } from '@standardnotes/snjs';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import template from '%/directives/editor-menu.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
|
||||
interface EditorMenuScope {
|
||||
callback: (component: SNComponent) => void;
|
||||
selectedEditorUuid: string;
|
||||
currentItem: SNItem;
|
||||
application: WebApplication;
|
||||
}
|
||||
|
||||
class EditorMenuCtrl extends PureViewCtrl implements EditorMenuScope {
|
||||
callback!: () => (component: SNComponent) => void;
|
||||
selectedEditorUuid!: string;
|
||||
currentItem!: SNItem;
|
||||
application!: WebApplication;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.state = {
|
||||
isDesktop: isDesktopApplication(),
|
||||
};
|
||||
}
|
||||
|
||||
public isEditorSelected(editor: SNComponent) {
|
||||
if (!this.selectedEditorUuid) {
|
||||
return false;
|
||||
}
|
||||
return this.selectedEditorUuid === editor.uuid;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
const editors = this.application.componentManager
|
||||
.componentsForArea(ComponentArea.Editor)
|
||||
.sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
this.setState({
|
||||
editors: editors,
|
||||
});
|
||||
}
|
||||
|
||||
selectComponent(component: SNComponent) {
|
||||
if (component) {
|
||||
if (component.conflictOf) {
|
||||
this.application.changeAndSaveItem(component.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
this.$timeout(() => {
|
||||
this.callback()(component);
|
||||
});
|
||||
}
|
||||
|
||||
offlineAvailableForComponent(component: SNComponent) {
|
||||
return component.local_url && this.state.isDesktop;
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = EditorMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
callback: '&',
|
||||
selectedEditorUuid: '=',
|
||||
currentItem: '=',
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { WebDirective } from '../../types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import template from '%/directives/history-menu.pug';
|
||||
import { HistoryEntry, SNItem } from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@/views';
|
||||
import { RevisionListEntry, SingleRevision } from '@standardnotes/snjs';
|
||||
import { alertDialog, confirmDialog } from '@/services/alertService';
|
||||
|
||||
type HistoryState = {
|
||||
sessionHistory?: HistoryEntry[];
|
||||
remoteHistory?: RevisionListEntry[];
|
||||
fetchingRemoteHistory: boolean;
|
||||
autoOptimize: boolean;
|
||||
diskEnabled: boolean;
|
||||
};
|
||||
|
||||
class HistoryMenuCtrl extends PureViewCtrl<unknown, HistoryState> {
|
||||
application!: WebApplication;
|
||||
item!: SNItem;
|
||||
|
||||
/** @template */
|
||||
showSessionOptions = false;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
}
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
fetchingRemoteHistory: false,
|
||||
autoOptimize: this.application.historyManager.autoOptimize,
|
||||
diskEnabled: this.application.historyManager.isDiskEnabled(),
|
||||
sessionHistory: this.application.historyManager.sessionHistoryForItem(
|
||||
this.item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
reloadState() {
|
||||
this.setState({
|
||||
...this.getInitialState(),
|
||||
fetchingRemoteHistory: this.state.fetchingRemoteHistory,
|
||||
});
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.fetchRemoteHistory();
|
||||
}
|
||||
|
||||
async fetchRemoteHistory() {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
try {
|
||||
const remoteHistory = await this.application.historyManager.remoteHistoryForItem(
|
||||
this.item
|
||||
);
|
||||
this.setState({ remoteHistory });
|
||||
} finally {
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
}
|
||||
}
|
||||
|
||||
async openSessionRevision(revision: HistoryEntry & { previewTitle: () => string }) {
|
||||
this.application.presentRevisionPreviewModal(
|
||||
revision.payload.uuid,
|
||||
revision.payload.content,
|
||||
revision.previewTitle()
|
||||
);
|
||||
}
|
||||
|
||||
async openRemoteRevision(revision: RevisionListEntry) {
|
||||
this.setState({ fetchingRemoteHistory: true });
|
||||
const remoteRevision = await this.application.historyManager.fetchRemoteRevision(
|
||||
this.item.uuid,
|
||||
revision
|
||||
);
|
||||
this.setState({ fetchingRemoteHistory: false });
|
||||
if (!remoteRevision) {
|
||||
alertDialog({
|
||||
text:
|
||||
'The remote revision could not be loaded. Please try again later.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.application.presentRevisionPreviewModal(
|
||||
remoteRevision.payload.uuid,
|
||||
remoteRevision.payload.content,
|
||||
this.previewRemoteHistoryTitle(revision)
|
||||
);
|
||||
}
|
||||
|
||||
classForSessionRevision(revision: HistoryEntry) {
|
||||
const vector = revision.operationVector();
|
||||
if (vector === 0) {
|
||||
return 'default';
|
||||
} else if (vector === 1) {
|
||||
return 'success';
|
||||
} else if (vector === -1) {
|
||||
return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
async clearItemSessionHistory() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to delete the local session history for this note?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.application.historyManager.clearHistoryForItem(this.item);
|
||||
this.reloadState();
|
||||
}
|
||||
}
|
||||
|
||||
async clearAllSessionHistory() {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to delete the local session history for all notes?',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
await this.application.historyManager.clearAllHistory();
|
||||
this.reloadState();
|
||||
}
|
||||
}
|
||||
|
||||
/** @entries */
|
||||
get sessionHistoryEntries() {
|
||||
return this.state.sessionHistory;
|
||||
}
|
||||
|
||||
async toggleSessionHistoryDiskSaving() {
|
||||
if (!this.state.diskEnabled) {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text:
|
||||
'Are you sure you want to save history to disk? This will decrease general ' +
|
||||
'performance, especially as you type. You are advised to disable this feature ' +
|
||||
'if you experience any lagging.',
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
} else {
|
||||
this.application.historyManager.toggleDiskSaving();
|
||||
}
|
||||
this.reloadState();
|
||||
}
|
||||
|
||||
toggleSessionHistoryAutoOptimize() {
|
||||
this.application.historyManager.toggleAutoOptimize();
|
||||
this.reloadState();
|
||||
}
|
||||
|
||||
previewRemoteHistoryTitle(revision: RevisionListEntry) {
|
||||
return new Date(revision.created_at).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoryMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = HistoryMenuCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '=',
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export { ActionsMenu } from './actionsMenu';
|
||||
export { EditorMenu } from './editorMenu';
|
||||
export { InputModal } from './inputModal';
|
||||
export { MenuRow } from './menuRow';
|
||||
export { PanelResizer } from './panelResizer';
|
||||
export { PasswordWizard } from './passwordWizard';
|
||||
export { PermissionsModal } from './permissionsModal';
|
||||
export { RevisionPreviewModal } from './revisionPreviewModal';
|
||||
export { HistoryMenu } from './historyMenu';
|
||||
export { SyncResolutionMenu } from './syncResolutionMenu';
|
||||
@@ -1,53 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/input-modal.pug';
|
||||
|
||||
export interface InputModalScope extends Partial<ng.IScope> {
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
callback: (value: string) => void
|
||||
}
|
||||
|
||||
class InputModalCtrl implements InputModalScope {
|
||||
|
||||
$element: JQLite
|
||||
type!: string
|
||||
title!: string
|
||||
message!: string
|
||||
callback!: (value: string) => void
|
||||
formData = { input: '' }
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.callback(this.formData.input);
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export class InputModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = InputModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '=',
|
||||
title: '=',
|
||||
message: '=',
|
||||
callback: '&'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/menu-row.pug';
|
||||
|
||||
class MenuRowCtrl {
|
||||
|
||||
disabled!: boolean
|
||||
action!: () => void
|
||||
buttonAction!: () => void
|
||||
|
||||
onClick($event: Event) {
|
||||
if(this.disabled) {
|
||||
return;
|
||||
}
|
||||
$event.stopPropagation();
|
||||
this.action();
|
||||
}
|
||||
|
||||
clickAccessoryButton($event: Event) {
|
||||
if(this.disabled) {
|
||||
return;
|
||||
}
|
||||
$event.stopPropagation();
|
||||
this.buttonAction();
|
||||
}
|
||||
}
|
||||
|
||||
export class MenuRow extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.transclude = true;
|
||||
this.template = template;
|
||||
this.controller = MenuRowCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
action: '&',
|
||||
buttonAction: '&',
|
||||
buttonClass: '=',
|
||||
buttonText: '=',
|
||||
desc: '=',
|
||||
disabled: '=',
|
||||
circle: '=',
|
||||
circleAlign: '=',
|
||||
faded: '=',
|
||||
hasButton: '=',
|
||||
label: '=',
|
||||
spinnerClass: '=',
|
||||
stylekitClass: '=',
|
||||
subRows: '=',
|
||||
subtitle: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import { PanelPuppet, WebDirective } from './../../types';
|
||||
import angular from 'angular';
|
||||
import template from '%/directives/panel-resizer.pug';
|
||||
import { debounce } from '@/utils';
|
||||
|
||||
export enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
enum MouseEventType {
|
||||
Move = 'mousemove',
|
||||
Down = 'mousedown',
|
||||
Up = 'mouseup',
|
||||
}
|
||||
enum CssClass {
|
||||
Hoverable = 'hoverable',
|
||||
AlwaysVisible = 'always-visible',
|
||||
Dragging = 'dragging',
|
||||
NoSelection = 'no-selection',
|
||||
Collapsed = 'collapsed',
|
||||
AnimateOpacity = 'animate-opacity',
|
||||
}
|
||||
const WINDOW_EVENT_RESIZE = 'resize';
|
||||
|
||||
export type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => void;
|
||||
|
||||
interface PanelResizerScope {
|
||||
alwaysVisible: boolean;
|
||||
collapsable: boolean;
|
||||
control: PanelPuppet;
|
||||
defaultWidth: number;
|
||||
hoverable: boolean;
|
||||
index: number;
|
||||
minWidth: number;
|
||||
onResizeFinish: () => ResizeFinishCallback;
|
||||
onWidthEvent?: () => void;
|
||||
panelId: string;
|
||||
property: PanelSide;
|
||||
}
|
||||
|
||||
class PanelResizerCtrl implements PanelResizerScope {
|
||||
/** @scope */
|
||||
alwaysVisible!: boolean;
|
||||
collapsable!: boolean;
|
||||
control!: PanelPuppet;
|
||||
defaultWidth!: number;
|
||||
hoverable!: boolean;
|
||||
index!: number;
|
||||
minWidth!: number;
|
||||
onResizeFinish!: () => ResizeFinishCallback;
|
||||
onWidthEvent?: () => () => void;
|
||||
panelId!: string;
|
||||
property!: PanelSide;
|
||||
|
||||
$compile: ng.ICompileService;
|
||||
$element: JQLite;
|
||||
$timeout: ng.ITimeoutService;
|
||||
panel!: HTMLElement;
|
||||
resizerColumn!: HTMLElement;
|
||||
currentMinWidth = 0;
|
||||
pressed = false;
|
||||
startWidth = 0;
|
||||
lastDownX = 0;
|
||||
collapsed = false;
|
||||
lastWidth = 0;
|
||||
startLeft = 0;
|
||||
lastLeft = 0;
|
||||
appFrame?: DOMRect;
|
||||
widthBeforeLastDblClick = 0;
|
||||
overlay?: JQLite;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$compile: ng.ICompileService,
|
||||
$element: JQLite,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
this.$compile = $compile;
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
|
||||
/** To allow for registering events */
|
||||
this.handleResize = debounce(this.handleResize.bind(this), 250);
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.configureDefaults();
|
||||
this.reloadDefaultValues();
|
||||
this.configureControl();
|
||||
this.addDoubleClickHandler();
|
||||
this.resizerColumn.addEventListener(MouseEventType.Down, this.onMouseDown);
|
||||
document.addEventListener(MouseEventType.Move, this.onMouseMove);
|
||||
document.addEventListener(MouseEventType.Up, this.onMouseUp);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
(this.onResizeFinish as any) = undefined;
|
||||
(this.onWidthEvent as any) = undefined;
|
||||
(this.control as any) = undefined;
|
||||
window.removeEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
|
||||
document.removeEventListener(MouseEventType.Move, this.onMouseMove);
|
||||
document.removeEventListener(MouseEventType.Up, this.onMouseUp);
|
||||
this.resizerColumn.removeEventListener(
|
||||
MouseEventType.Down,
|
||||
this.onMouseDown
|
||||
);
|
||||
(this.handleResize as any) = undefined;
|
||||
(this.onMouseMove as any) = undefined;
|
||||
(this.onMouseUp as any) = undefined;
|
||||
(this.onMouseDown as any) = undefined;
|
||||
}
|
||||
|
||||
configureControl() {
|
||||
this.control.setWidth = (value) => {
|
||||
this.setWidth(value, true);
|
||||
};
|
||||
this.control.setLeft = (value) => {
|
||||
this.setLeft(value);
|
||||
};
|
||||
this.control.flash = () => {
|
||||
this.flash();
|
||||
};
|
||||
this.control.isCollapsed = () => {
|
||||
return this.isCollapsed();
|
||||
};
|
||||
this.control.ready = true;
|
||||
this.control.onReady!();
|
||||
}
|
||||
|
||||
configureDefaults() {
|
||||
this.panel = document.getElementById(this.panelId)!;
|
||||
if (!this.panel) {
|
||||
console.error('Panel not found for', this.panelId);
|
||||
return;
|
||||
}
|
||||
this.resizerColumn = this.$element[0];
|
||||
this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth + 2;
|
||||
this.pressed = false;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.lastDownX = 0;
|
||||
this.collapsed = false;
|
||||
this.lastWidth = this.startWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.lastLeft = this.startLeft;
|
||||
this.appFrame = undefined;
|
||||
this.widthBeforeLastDblClick = 0;
|
||||
if (this.property === PanelSide.Right) {
|
||||
this.configureRightPanel();
|
||||
}
|
||||
if (this.alwaysVisible) {
|
||||
this.resizerColumn.classList.add(CssClass.AlwaysVisible);
|
||||
}
|
||||
if (this.hoverable) {
|
||||
this.resizerColumn.classList.add(CssClass.Hoverable);
|
||||
}
|
||||
}
|
||||
|
||||
configureRightPanel() {
|
||||
window.addEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.reloadDefaultValues();
|
||||
this.handleWidthEvent();
|
||||
this.$timeout(() => {
|
||||
this.finishSettingWidth();
|
||||
});
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
const node = this.panel!.parentNode! as HTMLElement;
|
||||
return node.getBoundingClientRect();
|
||||
}
|
||||
|
||||
reloadDefaultValues() {
|
||||
this.startWidth = this.isAtMaxWidth()
|
||||
? this.getParentRect().width
|
||||
: this.panel.scrollWidth;
|
||||
this.lastWidth = this.startWidth;
|
||||
this.appFrame = document.getElementById('app')!.getBoundingClientRect();
|
||||
}
|
||||
|
||||
addDoubleClickHandler() {
|
||||
this.resizerColumn.ondblclick = () => {
|
||||
this.$timeout(() => {
|
||||
const preClickCollapseState = this.isCollapsed();
|
||||
if (preClickCollapseState) {
|
||||
this.setWidth(this.widthBeforeLastDblClick || this.defaultWidth);
|
||||
} else {
|
||||
this.widthBeforeLastDblClick = this.lastWidth;
|
||||
this.setWidth(this.currentMinWidth);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
const newCollapseState = !preClickCollapseState;
|
||||
this.onResizeFinish()(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
newCollapseState
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
onMouseDown(event: MouseEvent) {
|
||||
this.addInvisibleOverlay();
|
||||
this.pressed = true;
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.panel.classList.add(CssClass.NoSelection);
|
||||
if (this.hoverable) {
|
||||
this.resizerColumn.classList.add(CssClass.Dragging);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.removeInvisibleOverlay();
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
this.pressed = false;
|
||||
this.resizerColumn.classList.remove(CssClass.Dragging);
|
||||
this.panel.classList.remove(CssClass.NoSelection);
|
||||
const isMaxWidth = this.isAtMaxWidth();
|
||||
if (this.onResizeFinish) {
|
||||
this.onResizeFinish()(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
isMaxWidth,
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent) {
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.property && this.property === PanelSide.Left) {
|
||||
this.handleLeftEvent(event);
|
||||
} else {
|
||||
this.handleWidthEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
if (this.onWidthEvent && this.onWidthEvent()) {
|
||||
this.onWidthEvent()();
|
||||
}
|
||||
let x;
|
||||
if (event) {
|
||||
x = event!.clientX;
|
||||
} else {
|
||||
/** Coming from resize event */
|
||||
x = 0;
|
||||
this.lastDownX = 0;
|
||||
}
|
||||
const deltaX = x - this.lastDownX;
|
||||
const newWidth = this.startWidth + deltaX;
|
||||
this.setWidth(newWidth, false);
|
||||
}
|
||||
|
||||
handleLeftEvent(event: MouseEvent) {
|
||||
const panelRect = this.panel.getBoundingClientRect();
|
||||
const x = event.clientX || panelRect.x;
|
||||
let deltaX = x - this.lastDownX;
|
||||
let newLeft = this.startLeft + deltaX;
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0;
|
||||
deltaX = -this.startLeft;
|
||||
}
|
||||
const parentRect = this.getParentRect();
|
||||
let newWidth = this.startWidth - deltaX;
|
||||
if (newWidth < this.currentMinWidth) {
|
||||
newWidth = this.currentMinWidth;
|
||||
}
|
||||
if (newWidth > parentRect.width) {
|
||||
newWidth = parentRect.width;
|
||||
}
|
||||
if (newLeft + newWidth > parentRect.width) {
|
||||
newLeft = parentRect.width - newWidth;
|
||||
}
|
||||
this.setLeft(newLeft);
|
||||
this.setWidth(newWidth, false);
|
||||
}
|
||||
|
||||
isAtMaxWidth() {
|
||||
return (
|
||||
Math.round(this.lastWidth + this.lastLeft) ===
|
||||
Math.round(this.getParentRect().width)
|
||||
);
|
||||
}
|
||||
|
||||
isCollapsed() {
|
||||
return this.lastWidth <= this.currentMinWidth;
|
||||
}
|
||||
|
||||
setWidth(width: number, finish = false) {
|
||||
if (width < this.currentMinWidth) {
|
||||
width = this.currentMinWidth;
|
||||
}
|
||||
const parentRect = this.getParentRect();
|
||||
if (width > parentRect.width) {
|
||||
width = parentRect.width;
|
||||
}
|
||||
|
||||
const maxWidth =
|
||||
this.appFrame!.width - this.panel.getBoundingClientRect().x;
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
}
|
||||
if (Math.round(width + this.lastLeft) === Math.round(parentRect.width)) {
|
||||
this.panel.style.width = `calc(100% - ${this.lastLeft}px)`;
|
||||
} else {
|
||||
this.panel.style.width = width + 'px';
|
||||
}
|
||||
this.lastWidth = width;
|
||||
if (finish) {
|
||||
this.finishSettingWidth();
|
||||
}
|
||||
}
|
||||
|
||||
setLeft(left: number) {
|
||||
this.panel.style.left = left + 'px';
|
||||
this.lastLeft = left;
|
||||
}
|
||||
|
||||
finishSettingWidth() {
|
||||
if (!this.collapsable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.collapsed = this.isCollapsed();
|
||||
if (this.collapsed) {
|
||||
this.resizerColumn.classList.add(CssClass.Collapsed);
|
||||
} else {
|
||||
this.resizerColumn.classList.remove(CssClass.Collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
|
||||
* document[onmouseup] is not triggered because the document is no longer the same over
|
||||
* the iframe. We add an invisible overlay while resizing so that the mouse context
|
||||
* remains in our main document.
|
||||
*/
|
||||
addInvisibleOverlay() {
|
||||
if (this.overlay) {
|
||||
return;
|
||||
}
|
||||
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(
|
||||
this as any
|
||||
);
|
||||
angular.element(document.body).prepend(this.overlay);
|
||||
}
|
||||
|
||||
removeInvisibleOverlay() {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
flash() {
|
||||
const FLASH_DURATION = 3000;
|
||||
this.resizerColumn.classList.add(CssClass.AnimateOpacity);
|
||||
this.$timeout(() => {
|
||||
this.resizerColumn.classList.remove(CssClass.AnimateOpacity);
|
||||
}, FLASH_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelResizer extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PanelResizerCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
alwaysVisible: '=',
|
||||
collapsable: '=',
|
||||
control: '=',
|
||||
defaultWidth: '=',
|
||||
hoverable: '=',
|
||||
index: '=',
|
||||
minWidth: '=',
|
||||
onResizeFinish: '&',
|
||||
onWidthEvent: '&',
|
||||
panelId: '=',
|
||||
property: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
PasswordWizardScope,
|
||||
PasswordWizardType,
|
||||
WebDirective,
|
||||
} from './../../types';
|
||||
import template from '%/directives/password-wizard.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
|
||||
const DEFAULT_CONTINUE_TITLE = 'Continue';
|
||||
enum Steps {
|
||||
PasswordStep = 1,
|
||||
FinishStep = 2,
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
newPasswordConfirmation?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
lockContinue: boolean;
|
||||
formData: FormData;
|
||||
continueTitle: string;
|
||||
step: Steps;
|
||||
title: string;
|
||||
showSpinner: boolean;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type: PasswordWizardType;
|
||||
changePassword: boolean;
|
||||
securityUpdate: boolean;
|
||||
};
|
||||
|
||||
class PasswordWizardCtrl
|
||||
extends PureViewCtrl<Props, State>
|
||||
implements PasswordWizardScope
|
||||
{
|
||||
$element: JQLite;
|
||||
application!: WebApplication;
|
||||
type!: PasswordWizardType;
|
||||
isContinuing = false;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.registerWindowUnloadStopper();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.initProps({
|
||||
type: this.type,
|
||||
changePassword: this.type === PasswordWizardType.ChangePassword,
|
||||
securityUpdate: this.type === PasswordWizardType.AccountUpgrade,
|
||||
});
|
||||
this.setState({
|
||||
formData: {},
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
step: Steps.PasswordStep,
|
||||
title: this.props.changePassword ? 'Change Password' : 'Account Update',
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
super.$onDestroy();
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
|
||||
/** Confirms with user before closing tab */
|
||||
registerWindowUnloadStopper() {
|
||||
window.onbeforeunload = () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
resetContinueState() {
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
});
|
||||
this.isContinuing = false;
|
||||
}
|
||||
|
||||
async nextStep() {
|
||||
if (this.state.lockContinue || this.isContinuing) {
|
||||
return;
|
||||
}
|
||||
if (this.state.step === Steps.FinishStep) {
|
||||
this.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isContinuing = true;
|
||||
await this.setState({
|
||||
showSpinner: true,
|
||||
continueTitle: 'Generating Keys...',
|
||||
});
|
||||
const valid = await this.validateCurrentPassword();
|
||||
if (!valid) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
const success = await this.processPasswordChange();
|
||||
if (!success) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
this.isContinuing = false;
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: 'Finish',
|
||||
step: Steps.FinishStep,
|
||||
});
|
||||
}
|
||||
|
||||
async setFormDataState(formData: Partial<FormData>) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
...formData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async validateCurrentPassword() {
|
||||
const currentPassword = this.state.formData.currentPassword;
|
||||
const newPass = this.props.securityUpdate
|
||||
? currentPassword
|
||||
: this.state.formData.newPassword;
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
this.application.alertService!.alert(
|
||||
'Please enter your current password.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (this.props.changePassword) {
|
||||
if (!newPass || newPass.length === 0) {
|
||||
this.application.alertService!.alert('Please enter a new password.');
|
||||
return false;
|
||||
}
|
||||
if (newPass !== this.state.formData.newPasswordConfirmation) {
|
||||
this.application.alertService!.alert(
|
||||
'Your new password does not match its confirmation.'
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.application.getUser()?.email) {
|
||||
this.application.alertService!.alert(
|
||||
"We don't have your email stored. Please sign out then log back in to fix this issue."
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate current password */
|
||||
const success = await this.application.validateAccountPassword(
|
||||
this.state.formData.currentPassword!
|
||||
);
|
||||
if (!success) {
|
||||
this.application.alertService!.alert(
|
||||
'The current password you entered is not correct. Please try again.'
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async processPasswordChange() {
|
||||
await this.application.downloadBackup();
|
||||
await this.setState({
|
||||
lockContinue: true,
|
||||
processing: true,
|
||||
});
|
||||
await this.setFormDataState({
|
||||
status: 'Processing encryption keys…',
|
||||
});
|
||||
const newPassword = this.props.securityUpdate
|
||||
? this.state.formData.currentPassword
|
||||
: this.state.formData.newPassword;
|
||||
const response = await this.application.changePassword(
|
||||
this.state.formData.currentPassword!,
|
||||
newPassword!
|
||||
);
|
||||
const success = !response.error;
|
||||
await this.setState({
|
||||
processing: false,
|
||||
lockContinue: false,
|
||||
});
|
||||
if (!success) {
|
||||
this.setFormDataState({
|
||||
status: 'Unable to process your password. Please try again.',
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
status: this.props.changePassword
|
||||
? 'Successfully changed password.'
|
||||
: 'Successfully performed account update.',
|
||||
},
|
||||
});
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
if (this.state.lockContinue) {
|
||||
this.application.alertService!.alert(
|
||||
'Cannot close window until pending tasks are complete.'
|
||||
);
|
||||
} else {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordWizard extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PasswordWizardCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '=',
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/permissions-modal.pug';
|
||||
|
||||
class PermissionsModalCtrl {
|
||||
$element: JQLite;
|
||||
callback!: (success: boolean) => void;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
|
||||
accept() {
|
||||
this.callback(true);
|
||||
this.dismiss();
|
||||
}
|
||||
|
||||
deny() {
|
||||
this.callback(false);
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionsModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PermissionsModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
show: '=',
|
||||
component: '=',
|
||||
permissionsString: '=',
|
||||
callback: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { ComponentViewer } from '@standardnotes/snjs/dist/@types';
|
||||
import { PureViewCtrl } from './../../views/abstract/pure_view_ctrl';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import { ContentType, PayloadSource, SNNote } from '@standardnotes/snjs';
|
||||
import template from '%/directives/revision-preview-modal.pug';
|
||||
import { PayloadContent } from '@standardnotes/snjs';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
|
||||
|
||||
interface RevisionPreviewScope {
|
||||
uuid: string;
|
||||
content: PayloadContent;
|
||||
application: WebApplication;
|
||||
}
|
||||
|
||||
type State = {
|
||||
componentViewer?: ComponentViewer;
|
||||
};
|
||||
|
||||
class RevisionPreviewModalCtrl
|
||||
extends PureViewCtrl<unknown, State>
|
||||
implements RevisionPreviewScope
|
||||
{
|
||||
$element: JQLite;
|
||||
$timeout: ng.ITimeoutService;
|
||||
uuid!: string;
|
||||
content!: PayloadContent;
|
||||
title?: string;
|
||||
application!: WebApplication;
|
||||
note!: SNNote;
|
||||
private originalNote!: SNNote;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.configure();
|
||||
super.$onInit();
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
if (this.state.componentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
this.state.componentViewer
|
||||
);
|
||||
}
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
get componentManager() {
|
||||
return this.application.componentManager;
|
||||
}
|
||||
|
||||
async configure() {
|
||||
this.note = (await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
this.content
|
||||
)) as SNNote;
|
||||
this.originalNote = this.application.findItem(this.uuid) as SNNote;
|
||||
const component = this.componentManager.editorForNote(this.originalNote);
|
||||
if (component) {
|
||||
const componentViewer =
|
||||
this.application.componentManager.createComponentViewer(component);
|
||||
componentViewer.setReadonly(true);
|
||||
componentViewer.lockReadonly = true;
|
||||
componentViewer.overrideContextItem = this.note;
|
||||
this.setState({ componentViewer });
|
||||
}
|
||||
}
|
||||
|
||||
restore(asCopy: boolean) {
|
||||
const run = async () => {
|
||||
if (asCopy) {
|
||||
await this.application.duplicateItem(this.originalNote, {
|
||||
...this.content,
|
||||
title: this.content.title
|
||||
? this.content.title + ' (copy)'
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
this.application.changeAndSaveItem(
|
||||
this.uuid,
|
||||
(mutator) => {
|
||||
mutator.unsafe_setCustomContent(this.content);
|
||||
},
|
||||
true,
|
||||
PayloadSource.RemoteActionRetrieved
|
||||
);
|
||||
}
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
if (!asCopy) {
|
||||
if (this.originalNote.locked) {
|
||||
this.application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
confirmButtonStyle: 'danger',
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class RevisionPreviewModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = RevisionPreviewModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
uuid: '=',
|
||||
content: '=',
|
||||
title: '=',
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/sync-resolution-menu.pug';
|
||||
|
||||
class SyncResolutionMenuCtrl {
|
||||
|
||||
closeFunction!: () => void
|
||||
application!: WebApplication
|
||||
|
||||
$timeout: ng.ITimeoutService
|
||||
status: Partial<{
|
||||
backupFinished: boolean,
|
||||
resolving: boolean,
|
||||
attemptedResolution: boolean,
|
||||
success: boolean
|
||||
fail: boolean
|
||||
}> = {}
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
downloadBackup(encrypted: boolean) {
|
||||
this.application.getArchiveService().downloadBackup(encrypted);
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
skipBackup() {
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
async performSyncResolution() {
|
||||
this.status.resolving = true;
|
||||
await this.application.resolveOutOfSync();
|
||||
this.$timeout(() => {
|
||||
this.status.resolving = false;
|
||||
this.status.attemptedResolution = true;
|
||||
if (this.application.isOutOfSync()) {
|
||||
this.status.fail = true;
|
||||
} else {
|
||||
this.status.success = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$timeout(() => {
|
||||
this.closeFunction();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncResolutionMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = SyncResolutionMenuCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
closeFunction: '&',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
6
app/assets/javascripts/element_ids.ts
Normal file
6
app/assets/javascripts/element_ids.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const ElementIds = {
|
||||
NoteTextEditor: 'note-text-editor',
|
||||
NoteTitleEditor: 'note-title-editor',
|
||||
EditorContent: 'editor-content',
|
||||
EditorColumn: 'editor-column',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { trusted } from './trusted';
|
||||
@@ -1,6 +0,0 @@
|
||||
/* @ngInject */
|
||||
export function trusted($sce: ng.ISCEService) {
|
||||
return function(url: string) {
|
||||
return $sce.trustAsResourceUrl(url);
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import '@reach/dialog/styles.css';
|
||||
import '../stylesheets/index.css.scss';
|
||||
|
||||
// Vendor
|
||||
import 'angular';
|
||||
import '../../../vendor/assets/javascripts/zip/deflate';
|
||||
import '../../../vendor/assets/javascripts/zip/inflate';
|
||||
import '../../../vendor/assets/javascripts/zip/zip';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export enum RootScopeMessages {
|
||||
NewUpdateAvailable = 'new-update-available'
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IconType } from '@/components/Icon';
|
||||
import { action, makeAutoObservable, observable } from 'mobx';
|
||||
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
|
||||
import {
|
||||
@@ -6,14 +5,15 @@ import {
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
SNComponent,
|
||||
IconType,
|
||||
} from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
const PREFERENCE_IDS = [
|
||||
'general',
|
||||
'account',
|
||||
'appearance',
|
||||
'security',
|
||||
'appearance',
|
||||
'backups',
|
||||
'listed',
|
||||
'shortcuts',
|
||||
@@ -39,8 +39,8 @@ interface SelectableMenuItem extends PreferencesMenuItem {
|
||||
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
{ id: 'account', label: 'Account', icon: 'user' },
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'security', label: 'Security', icon: 'security' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore' },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
|
||||
@@ -52,8 +52,8 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
{ id: 'account', label: 'Account', icon: 'user' },
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'security', label: 'Security', icon: 'security' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore' },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
export const Title: FunctionComponent = ({ children }) => (
|
||||
<h2 className="text-base m-0 mb-1">{children}</h2>
|
||||
<>
|
||||
<h2 className="text-base m-0 mb-1">{children}</h2>
|
||||
<div className="min-h-2" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const Subtitle: FunctionComponent<{ className?: string }> = ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon, IconType } from '@/components/Icon';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { IconType } from '@standardnotes/snjs';
|
||||
|
||||
interface Props {
|
||||
iconType: IconType;
|
||||
@@ -15,7 +16,9 @@ export const MenuItem: FunctionComponent<Props> = ({
|
||||
onClick,
|
||||
}) => (
|
||||
<div
|
||||
className={`preferences-menu-item select-none ${selected ? 'selected' : ''}`}
|
||||
className={`preferences-menu-item select-none ${
|
||||
selected ? 'selected' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { toDirective } from '../components/utils';
|
||||
import {
|
||||
PreferencesViewWrapper,
|
||||
PreferencesViewWrapperProps,
|
||||
} from './PreferencesViewWrapper';
|
||||
|
||||
export const PreferencesDirective = toDirective<PreferencesViewWrapperProps>(
|
||||
PreferencesViewWrapper
|
||||
);
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Dropdown, DropdownItem } from '@/components/Dropdown';
|
||||
import { PremiumModalProvider, usePremiumModal } from '@/components/Premium';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import { sortThemes } from '@/components/QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Features } from '@standardnotes/features';
|
||||
import { GetFeatures } from '@standardnotes/features';
|
||||
import {
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
@@ -28,169 +28,175 @@ type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
const AppearancePane: FunctionComponent<Props> = observer(({ application }) => {
|
||||
const premiumModal = usePremiumModal();
|
||||
const isEntitledToMidnightTheme =
|
||||
application.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
|
||||
FeatureStatus.Entitled;
|
||||
export const Appearance: FunctionComponent<Props> = observer(
|
||||
({ application }) => {
|
||||
const premiumModal = usePremiumModal();
|
||||
const isEntitledToMidnightTheme =
|
||||
application.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
|
||||
FeatureStatus.Entitled;
|
||||
|
||||
const [themeItems, setThemeItems] = useState<DropdownItem[]>([]);
|
||||
const [autoLightTheme, setAutoLightTheme] = useState<string>(
|
||||
() =>
|
||||
application.getPreference(
|
||||
PrefKey.AutoLightThemeIdentifier,
|
||||
'Default'
|
||||
) as string
|
||||
);
|
||||
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
|
||||
() =>
|
||||
application.getPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default'
|
||||
) as string
|
||||
);
|
||||
const [useDeviceSettings, setUseDeviceSettings] = useState(
|
||||
() =>
|
||||
application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean
|
||||
);
|
||||
const [themeItems, setThemeItems] = useState<DropdownItem[]>([]);
|
||||
const [autoLightTheme, setAutoLightTheme] = useState<string>(
|
||||
() =>
|
||||
application.getPreference(
|
||||
PrefKey.AutoLightThemeIdentifier,
|
||||
'Default'
|
||||
) as string
|
||||
);
|
||||
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
|
||||
() =>
|
||||
application.getPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
isEntitledToMidnightTheme
|
||||
? FeatureIdentifier.MidnightTheme
|
||||
: 'Default'
|
||||
) as string
|
||||
);
|
||||
const [useDeviceSettings, setUseDeviceSettings] = useState(
|
||||
() =>
|
||||
application.getPreference(
|
||||
PrefKey.UseSystemColorScheme,
|
||||
false
|
||||
) as boolean
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const themesAsItems: DropdownItem[] = (
|
||||
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
|
||||
)
|
||||
.filter((theme) => !theme.isLayerable())
|
||||
.sort(sortThemes)
|
||||
.map((theme) => {
|
||||
return {
|
||||
label: theme.name,
|
||||
value: theme.identifier as string,
|
||||
};
|
||||
useEffect(() => {
|
||||
const themesAsItems: DropdownItem[] = (
|
||||
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
|
||||
)
|
||||
.filter((theme) => !theme.isLayerable())
|
||||
.sort(sortThemes)
|
||||
.map((theme) => {
|
||||
return {
|
||||
label: theme.name,
|
||||
value: theme.identifier as string,
|
||||
};
|
||||
});
|
||||
|
||||
GetFeatures()
|
||||
.filter(
|
||||
(feature) =>
|
||||
feature.content_type === ContentType.Theme && !feature.layerable
|
||||
)
|
||||
.forEach((theme) => {
|
||||
if (
|
||||
themesAsItems.findIndex(
|
||||
(item) => item.value === theme.identifier
|
||||
) === -1
|
||||
) {
|
||||
themesAsItems.push({
|
||||
label: theme.name as string,
|
||||
value: theme.identifier,
|
||||
icon: 'premium-feature',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
themesAsItems.unshift({
|
||||
label: 'Default',
|
||||
value: 'Default',
|
||||
});
|
||||
|
||||
Features.filter(
|
||||
(feature) =>
|
||||
feature.content_type === ContentType.Theme && !feature.layerable
|
||||
).forEach((theme) => {
|
||||
if (
|
||||
themesAsItems.findIndex((item) => item.value === theme.identifier) ===
|
||||
-1
|
||||
) {
|
||||
themesAsItems.push({
|
||||
label: theme.name as string,
|
||||
value: theme.identifier,
|
||||
icon: 'premium-feature',
|
||||
});
|
||||
setThemeItems(themesAsItems);
|
||||
}, [application]);
|
||||
|
||||
const toggleUseDeviceSettings = () => {
|
||||
application.setPreference(
|
||||
PrefKey.UseSystemColorScheme,
|
||||
!useDeviceSettings
|
||||
);
|
||||
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
|
||||
application.setPreference(
|
||||
PrefKey.AutoLightThemeIdentifier,
|
||||
autoLightTheme as FeatureIdentifier
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
|
||||
application.setPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
autoDarkTheme as FeatureIdentifier
|
||||
);
|
||||
}
|
||||
setUseDeviceSettings(!useDeviceSettings);
|
||||
};
|
||||
|
||||
themesAsItems.unshift({
|
||||
label: 'Default',
|
||||
value: 'Default',
|
||||
});
|
||||
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`);
|
||||
} else {
|
||||
application.setPreference(
|
||||
PrefKey.AutoLightThemeIdentifier,
|
||||
value as FeatureIdentifier
|
||||
);
|
||||
setAutoLightTheme(value);
|
||||
}
|
||||
};
|
||||
|
||||
setThemeItems(themesAsItems);
|
||||
}, [application]);
|
||||
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`);
|
||||
} else {
|
||||
application.setPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
value as FeatureIdentifier
|
||||
);
|
||||
setAutoDarkTheme(value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUseDeviceSettings = () => {
|
||||
application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings);
|
||||
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
|
||||
application.setPreference(
|
||||
PrefKey.AutoLightThemeIdentifier,
|
||||
autoLightTheme as FeatureIdentifier
|
||||
);
|
||||
}
|
||||
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
|
||||
application.setPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
autoDarkTheme as FeatureIdentifier
|
||||
);
|
||||
}
|
||||
setUseDeviceSettings(!useDeviceSettings);
|
||||
};
|
||||
|
||||
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`);
|
||||
} else {
|
||||
application.setPreference(
|
||||
PrefKey.AutoLightThemeIdentifier,
|
||||
value as FeatureIdentifier
|
||||
);
|
||||
setAutoLightTheme(value);
|
||||
}
|
||||
};
|
||||
|
||||
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
|
||||
if (item.icon === 'premium-feature') {
|
||||
premiumModal.activate(`${item.label} theme`);
|
||||
} else {
|
||||
application.setPreference(
|
||||
PrefKey.AutoDarkThemeIdentifier,
|
||||
value as FeatureIdentifier
|
||||
);
|
||||
setAutoDarkTheme(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Themes</Title>
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Use system color scheme</Subtitle>
|
||||
<Text>
|
||||
Automatically change active theme based on your system settings.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={toggleUseDeviceSettings}
|
||||
checked={useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Light Theme</Subtitle>
|
||||
<Text>Theme to be used for system light mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-light-theme-dropdown"
|
||||
label="Select the automatic light theme"
|
||||
items={themeItems}
|
||||
value={autoLightTheme}
|
||||
onChange={changeAutoLightTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Themes</Title>
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Use system color scheme</Subtitle>
|
||||
<Text>
|
||||
Automatically change active theme based on your system
|
||||
settings.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={toggleUseDeviceSettings}
|
||||
checked={useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Dark Theme</Subtitle>
|
||||
<Text>Theme to be used for system dark mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-dark-theme-dropdown"
|
||||
label="Select the automatic dark theme"
|
||||
items={themeItems}
|
||||
value={autoDarkTheme}
|
||||
onChange={changeAutoDarkTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
/>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Light Theme</Subtitle>
|
||||
<Text>Theme to be used for system light mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-light-theme-dropdown"
|
||||
label="Select the automatic light theme"
|
||||
items={themeItems}
|
||||
value={autoLightTheme}
|
||||
onChange={changeAutoLightTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<div>
|
||||
<Subtitle>Automatic Dark Theme</Subtitle>
|
||||
<Text>Theme to be used for system dark mode:</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="auto-dark-theme-dropdown"
|
||||
label="Select the automatic dark theme"
|
||||
items={themeItems}
|
||||
value={autoDarkTheme}
|
||||
onChange={changeAutoDarkTheme}
|
||||
disabled={!useDeviceSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
);
|
||||
});
|
||||
|
||||
export const Appearance: FunctionComponent<Props> = observer(
|
||||
({ application }) => (
|
||||
<PremiumModalProvider state={application.getAppState().features}>
|
||||
<AppearancePane application={application} />
|
||||
</PremiumModalProvider>
|
||||
)
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -74,6 +74,7 @@ export const Extensions: FunctionComponent<{
|
||||
|
||||
const confirmExtension = async () => {
|
||||
await application.insertItem(confirmableExtension as SNComponent);
|
||||
application.sync();
|
||||
setExtensions(loadExtensions(application));
|
||||
};
|
||||
|
||||
@@ -109,7 +110,6 @@ export const Extensions: FunctionComponent<{
|
||||
{!confirmableExtension && (
|
||||
<PreferencesSegment>
|
||||
<Title>Install Custom Extension</Title>
|
||||
<div className="min-h-2" />
|
||||
<DecoratedInput
|
||||
placeholder={'Enter Extension URL'}
|
||||
text={customUrl}
|
||||
|
||||
@@ -10,20 +10,20 @@ import { observer } from 'mobx-react-lite';
|
||||
interface GeneralProps {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
extensionsLatestVersions: ExtensionsLatestVersions,
|
||||
extensionsLatestVersions: ExtensionsLatestVersions;
|
||||
}
|
||||
|
||||
export const General: FunctionComponent<GeneralProps> = observer(
|
||||
({
|
||||
appState,
|
||||
application,
|
||||
extensionsLatestVersions
|
||||
}) => (
|
||||
({ appState, application, extensionsLatestVersions }) => (
|
||||
<PreferencesPane>
|
||||
<Tools application={application} />
|
||||
<Defaults application={application} />
|
||||
<ErrorReporting appState={appState} />
|
||||
<Advanced application={application} appState={appState} extensionsLatestVersions={extensionsLatestVersions} />
|
||||
<Advanced
|
||||
application={application}
|
||||
appState={appState}
|
||||
extensionsLatestVersions={extensionsLatestVersions}
|
||||
/>
|
||||
</PreferencesPane>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -5,72 +5,74 @@ import {
|
||||
Title,
|
||||
Subtitle,
|
||||
Text,
|
||||
LinkButton,
|
||||
} from '../components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import { SNItem } from '@standardnotes/snjs/dist/@types/models/core/item';
|
||||
import { ButtonType, ListedAccount } from '@standardnotes/snjs';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { BlogItem } from './listed/BlogItem';
|
||||
import { ListedAccountItem } from './listed/BlogItem';
|
||||
import { Button } from '@/components/Button';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
export const Listed = observer(({ application }: Props) => {
|
||||
const [items, setItems] = useState<SNComponent[]>([]);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [accounts, setAccounts] = useState<ListedAccount[]>([]);
|
||||
const [requestingAccount, setRequestingAccount] = useState<boolean>();
|
||||
|
||||
const reloadItems = useCallback(() => {
|
||||
const components = application
|
||||
.getItems(ContentType.ActionsExtension)
|
||||
.filter(
|
||||
(item) => (item as SNComponent).package_info?.name === 'Listed'
|
||||
) as SNComponent[];
|
||||
setItems(components);
|
||||
const reloadAccounts = useCallback(async () => {
|
||||
setAccounts(await application.getListedAccounts());
|
||||
}, [application]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadItems();
|
||||
}, [reloadItems]);
|
||||
reloadAccounts();
|
||||
}, [reloadAccounts]);
|
||||
|
||||
const disconnectListedBlog = (item: SNItem) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setIsDeleting(true);
|
||||
application
|
||||
.deleteItem(item)
|
||||
.then(() => {
|
||||
reloadItems();
|
||||
setIsDeleting(false);
|
||||
resolve(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
application.alertService.alert(err);
|
||||
setIsDeleting(false);
|
||||
console.error(err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
const registerNewAccount = useCallback(() => {
|
||||
setRequestingAccount(true);
|
||||
|
||||
const requestAccount = async () => {
|
||||
const account = await application.requestNewListedAccount();
|
||||
if (account) {
|
||||
const openSettings = await application.alertService.confirm(
|
||||
`Your new Listed blog has been successfully created!` +
|
||||
` You can publish a new post to your blog from Standard Notes via the` +
|
||||
` <i>Actions</i> menu in the editor pane. Open your blog settings to begin setting it up.`,
|
||||
undefined,
|
||||
'Open Settings',
|
||||
ButtonType.Info,
|
||||
'Later'
|
||||
);
|
||||
reloadAccounts();
|
||||
if (openSettings) {
|
||||
const info = await application.getListedAccountInfo(account);
|
||||
if (info) {
|
||||
application.deviceInterface.openUrl(info?.settings_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
setRequestingAccount(false);
|
||||
};
|
||||
|
||||
requestAccount();
|
||||
}, [application, reloadAccounts]);
|
||||
|
||||
return (
|
||||
<PreferencesPane>
|
||||
{items.length > 0 && (
|
||||
{accounts.length > 0 && (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>
|
||||
Your {items.length === 1 ? 'Blog' : 'Blogs'} on Listed
|
||||
Your {accounts.length === 1 ? 'Blog' : 'Blogs'} on Listed
|
||||
</Title>
|
||||
<div className="h-2 w-full" />
|
||||
{items.map((item, index, array) => {
|
||||
{accounts.map((item, index, array) => {
|
||||
return (
|
||||
<BlogItem
|
||||
item={item}
|
||||
<ListedAccountItem
|
||||
account={item}
|
||||
showSeparator={index !== array.length - 1}
|
||||
disabled={isDeleting}
|
||||
disconnect={disconnectListedBlog}
|
||||
key={item.uuid}
|
||||
key={item.authorId}
|
||||
application={application}
|
||||
/>
|
||||
);
|
||||
@@ -95,21 +97,19 @@ export const Listed = observer(({ application }: Props) => {
|
||||
</a>
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
{items.length === 0 ? (
|
||||
<PreferencesSegment>
|
||||
<Subtitle>How to get started?</Subtitle>
|
||||
<Text>
|
||||
First, you’ll need to sign up for Listed. Once you have your
|
||||
Listed account, follow the instructions to connect it with your
|
||||
Standard Notes account.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="min-w-20 mt-3"
|
||||
link="https://listed.to"
|
||||
label="Get started"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
) : null}
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Get Started</Subtitle>
|
||||
<Text>Create a free Listed author account to get started.</Text>
|
||||
<Button
|
||||
className="mt-3"
|
||||
type="normal"
|
||||
disabled={requestingAccount}
|
||||
label={
|
||||
requestingAccount ? 'Creating account...' : 'Create New Author'
|
||||
}
|
||||
onClick={registerNewAccount}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
);
|
||||
|
||||
@@ -1,68 +1,80 @@
|
||||
import { PreferencesGroup, PreferencesSegment, Text, Title } from '@/preferences/components';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '@/preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from '@node_modules/mobx-react-lite';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { dateToLocalizedString } from '@standardnotes/snjs';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
import { ChangeEmail } from '@/preferences/panes/account/changeEmail';
|
||||
import { PasswordWizardType } from '@/types';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { FunctionComponent, render } from 'preact';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { PasswordWizard } from '@/components/PasswordWizard';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
export const Credentials: FunctionComponent<Props> = observer(({ application, appState }: Props) => {
|
||||
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false);
|
||||
export const Credentials: FunctionComponent<Props> = observer(
|
||||
({ application }: Props) => {
|
||||
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const user = application.getUser();
|
||||
const user = application.getUser();
|
||||
|
||||
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date;
|
||||
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
|
||||
const passwordCreatedAtTimestamp =
|
||||
application.getUserPasswordCreationDate() as Date;
|
||||
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Credentials</Title>
|
||||
<div className={'text-input mt-2'}>
|
||||
Email
|
||||
</div>
|
||||
<Text>
|
||||
You're signed in as <span className='font-bold'>{user?.email}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className='min-w-20 mt-3'
|
||||
type='normal'
|
||||
label='Change email'
|
||||
onClick={() => {
|
||||
setIsChangeEmailDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
<HorizontalSeparator classes='mt-5 mb-3' />
|
||||
<div className={'text-input mt-2'}>
|
||||
Password
|
||||
</div>
|
||||
<Text>
|
||||
Current password was set on <span className='font-bold'>{passwordCreatedOn}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className='min-w-20 mt-3'
|
||||
type='normal'
|
||||
label='Change password'
|
||||
onClick={() => {
|
||||
application.presentPasswordWizard(PasswordWizardType.ChangePassword);
|
||||
}}
|
||||
/>
|
||||
{isChangeEmailDialogOpen && (
|
||||
<ChangeEmail
|
||||
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
|
||||
application={application}
|
||||
const presentPasswordWizard = useCallback(() => {
|
||||
render(
|
||||
<PasswordWizard application={application} />,
|
||||
document.body.appendChild(document.createElement('div'))
|
||||
);
|
||||
}, [application]);
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Credentials</Title>
|
||||
<Subtitle>Email</Subtitle>
|
||||
<Text>
|
||||
You're signed in as <span className="font-bold">{user?.email}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
type="normal"
|
||||
label="Change email"
|
||||
onClick={() => {
|
||||
setIsChangeEmailDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
});
|
||||
<HorizontalSeparator classes="mt-5 mb-3" />
|
||||
<Subtitle>Password</Subtitle>
|
||||
<Text>
|
||||
Current password was set on{' '}
|
||||
<span className="font-bold">{passwordCreatedOn}</span>
|
||||
</Text>
|
||||
<Button
|
||||
className="min-w-20 mt-3"
|
||||
type="normal"
|
||||
label="Change password"
|
||||
onClick={presentPasswordWizard}
|
||||
/>
|
||||
{isChangeEmailDialogOpen && (
|
||||
<ChangeEmail
|
||||
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
|
||||
application={application}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ const SignOutView: FunctionComponent<{
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Sign out</Title>
|
||||
<div className="min-h-2" />
|
||||
<Subtitle>Other devices</Subtitle>
|
||||
<Text>Want to sign out on all devices except this one?</Text>
|
||||
<div className="min-h-3" />
|
||||
@@ -74,7 +73,6 @@ const ClearSessionDataView: FunctionComponent<{
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Clear session data</Title>
|
||||
<div className="min-h-2" />
|
||||
<Text>This will delete all local items and preferences.</Text>
|
||||
<div className="min-h-3" />
|
||||
<Button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user