Merge branch 'release/10.4.0'
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
"parserOptions": {
|
||||
"project": "./app/assets/javascripts/tsconfig.json"
|
||||
},
|
||||
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js"],
|
||||
"ignorePatterns": [".eslintrc.js", "webpack.*.js", "webpack-defaults.js", "jest.config.js", "__mocks__"],
|
||||
"rules": {
|
||||
"standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals
|
||||
"no-throw-literal": 0,
|
||||
@@ -19,8 +19,8 @@
|
||||
"semi": 1,
|
||||
"camelcase": "warn",
|
||||
"sort-imports": "off",
|
||||
"react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
|
||||
"react-hooks/exhaustive-deps": "error", // Checks effect dependencies
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"eol-last": "error",
|
||||
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
|
||||
"no-trailing-spaces": "error"
|
||||
|
||||
73
.github/workflows/beta.yml
vendored
Normal file
73
.github/workflows/beta.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: Beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ beta/* ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
tsc:
|
||||
name: Check types & lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: yarn install --pure-lockfile
|
||||
- name: Typescript
|
||||
run: yarn tsc
|
||||
- name: ESLint
|
||||
run: yarn lint --quiet
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tsc
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Copy robots.txt
|
||||
run: cp public/robots.txt.development public/robots.txt
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@master
|
||||
with:
|
||||
name: standardnotes/web
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
tags: "beta,${{ github.sha }}"
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v1
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-1
|
||||
- name: Download task definition
|
||||
run: |
|
||||
aws ecs describe-task-definition --task-definition app-beta-dev --query taskDefinition > task-definition.json
|
||||
|
||||
- name: Fill in the new image ID in the Amazon ECS task definition
|
||||
id: task-def
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@v1
|
||||
with:
|
||||
task-definition: task-definition.json
|
||||
container-name: app-beta-dev
|
||||
image: "standardnotes/web:${{ github.sha }}"
|
||||
|
||||
- name: Deploy Amazon ECS task definition
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
|
||||
with:
|
||||
task-definition: ${{ steps.task-def.outputs.task-definition }}
|
||||
service: app-beta-dev
|
||||
cluster: dev
|
||||
wait-for-service-stability: true
|
||||
|
||||
notify_discord:
|
||||
needs: deploy
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Run Discord Webhook
|
||||
uses: johnnyhuy/actions-discord-git-webhook@main
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
10
.github/workflows/dev.yml
vendored
10
.github/workflows/dev.yml
vendored
@@ -73,15 +73,13 @@ jobs:
|
||||
cluster: dev
|
||||
wait-for-service-stability: true
|
||||
|
||||
notify_slack:
|
||||
notify_discord:
|
||||
needs: deploy
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Notify slack
|
||||
uses: pullreminders/slack-action@master
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
- name: Run Discord Webhook
|
||||
uses: johnnyhuy/actions-discord-git-webhook@main
|
||||
with:
|
||||
args: '{ \"channel\": \"${{ secrets.SLACK_NOTIFICATION_CHANNEL }}\", \"blocks\": [{\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Successfully deployed <https://app-dev.standardnotes.com|[DEV] Web App>\"}}, {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Changes: <https://github.com/standardnotes/web/commit/${{ github.sha }}|${{ github.sha }}>\"}, \"accessory\": {\"type\": \"image\", \"image_url\": \"https://website-dev.standardnotes.com/assets/icon.png\", \"alt_text\": \"Standard Notes\"}}, { \"type\": \"section\", \"fields\": [{\"type\": \"mrkdwn\", \"text\": \"<https://github.com/standardnotes/web/actions/runs/${{ github.run_id }}|Build details>\"}]}]}'
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
|
||||
10
.github/workflows/prod.yml
vendored
10
.github/workflows/prod.yml
vendored
@@ -73,15 +73,13 @@ jobs:
|
||||
cluster: prod
|
||||
wait-for-service-stability: true
|
||||
|
||||
notify_slack:
|
||||
notify_discord:
|
||||
needs: deploy
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Notify slack
|
||||
uses: pullreminders/slack-action@master
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
- name: Run Discord Webhook
|
||||
uses: johnnyhuy/actions-discord-git-webhook@main
|
||||
with:
|
||||
args: '{ \"channel\": \"${{ secrets.SLACK_NOTIFICATION_CHANNEL }}\", \"blocks\": [{\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Successfully deployed <https://app-prod.standardnotes.com|[PROD] Web App>\"}}, {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Changes: <https://github.com/standardnotes/web/commit/${{ github.sha }}|${{ github.sha }}>\"}, \"accessory\": {\"type\": \"image\", \"image_url\": \"https://website-dev.standardnotes.com/assets/icon.png\", \"alt_text\": \"Standard Notes\"}}, { \"type\": \"section\", \"fields\": [{\"type\": \"mrkdwn\", \"text\": \"<https://github.com/standardnotes/web/actions/runs/${{ github.run_id }}|Build details>\"}]}]}'
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -50,3 +50,5 @@ yarn-error.log
|
||||
package-lock.json
|
||||
|
||||
codeqldb
|
||||
|
||||
coverage
|
||||
|
||||
@@ -101,7 +101,7 @@ GEM
|
||||
net-ssh (>= 2.6.5, < 6.0.0)
|
||||
net-ssh (5.2.0)
|
||||
newrelic_rpm (7.0.0)
|
||||
nio4r (2.5.2)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.11.1)
|
||||
mini_portile2 (~> 2.5.0)
|
||||
racc (~> 1.4)
|
||||
@@ -111,7 +111,7 @@ GEM
|
||||
racc (~> 1.4)
|
||||
non-stupid-digest-assets (1.0.9)
|
||||
sprockets (>= 2.0)
|
||||
puma (4.3.5)
|
||||
puma (4.3.9)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.5.2)
|
||||
rack (2.2.3)
|
||||
|
||||
3
app/assets/icons/ic-add.svg
Normal file
3
app/assets/icons/ic-add.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5999 8.00002C13.5999 8.44185 13.2417 8.80002 12.7999 8.80002H8.7999V12.8C8.7999 13.2419 8.44173 13.6 7.9999 13.6C7.55807 13.6 7.1999 13.2419 7.1999 12.8V8.80002H3.1999C2.75807 8.80002 2.3999 8.44185 2.3999 8.00002C2.3999 7.5582 2.75807 7.20002 3.1999 7.20002H7.1999V3.20002C7.1999 2.7582 7.55807 2.40002 7.9999 2.40002C8.44173 2.40002 8.7999 2.7582 8.7999 3.20002V7.20002H12.7999C13.2417 7.20002 13.5999 7.5582 13.5999 8.00002Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 548 B |
3
app/assets/icons/ic-folder.svg
Normal file
3
app/assets/icons/ic-folder.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6668 14.9999H3.33342V6.66658H16.6668V14.9999ZM16.6668 4.99992H10.0001L8.33342 3.33325H3.33342C2.40841 3.33325 1.66675 4.07492 1.66675 4.99992V14.9999C1.66675 15.4419 1.84234 15.8659 2.1549 16.1784C2.46746 16.491 2.89139 16.6666 3.33342 16.6666H16.6668C17.1088 16.6666 17.5327 16.491 17.8453 16.1784C18.1578 15.8659 18.3334 15.4419 18.3334 14.9999V6.66658C18.3334 5.74159 17.5834 4.99992 16.6668 4.99992Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 500 B |
3
app/assets/icons/ic-link-off.svg
Normal file
3
app/assets/icons/ic-link-off.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 5.83333H10.8334V7.41667H14.1667C15.5917 7.41667 16.7501 8.575 16.7501 10C16.7501 11.1917 15.9334 12.1917 14.8251 12.5L16.0417 13.7C17.4001 13.0083 18.3334 11.625 18.3334 10C18.3334 8.89493 17.8944 7.83512 17.113 7.05372C16.3316 6.27232 15.2718 5.83333 14.1667 5.83333ZM13.3334 9.16667H11.5084L13.1751 10.8333H13.3334V9.16667ZM1.66675 3.55833L4.25841 6.15C2.74175 6.76667 1.66675 8.25833 1.66675 10C1.66675 11.1051 2.10573 12.1649 2.88714 12.9463C3.66854 13.7277 4.72835 14.1667 5.83342 14.1667H9.16675V12.5833H5.83342C4.40841 12.5833 3.25008 11.425 3.25008 10C3.25008 8.675 4.25841 7.58333 5.55008 7.44167L7.27508 9.16667H6.66675V10.8333H8.94175L10.8334 12.725V14.1667H12.2751L15.6167 17.5L16.6667 16.45L2.72508 2.5L1.66675 3.55833Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 834 B |
3
app/assets/icons/ic-list-bulleted.svg
Normal file
3
app/assets/icons/ic-list-bulleted.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.83325 4.16667H17.4999V5.83333H5.83325V4.16667ZM5.83325 10.8333V9.16667H17.4999V10.8333H5.83325ZM3.33325 3.75C3.66477 3.75 3.98272 3.8817 4.21714 4.11612C4.45156 4.35054 4.58325 4.66848 4.58325 5C4.58325 5.33152 4.45156 5.64946 4.21714 5.88388C3.98272 6.1183 3.66477 6.25 3.33325 6.25C3.00173 6.25 2.68379 6.1183 2.44937 5.88388C2.21495 5.64946 2.08325 5.33152 2.08325 5C2.08325 4.66848 2.21495 4.35054 2.44937 4.11612C2.68379 3.8817 3.00173 3.75 3.33325 3.75ZM3.33325 8.75C3.66477 8.75 3.98272 8.8817 4.21714 9.11612C4.45156 9.35054 4.58325 9.66848 4.58325 10C4.58325 10.3315 4.45156 10.6495 4.21714 10.8839C3.98272 11.1183 3.66477 11.25 3.33325 11.25C3.00173 11.25 2.68379 11.1183 2.44937 10.8839C2.21495 10.6495 2.08325 10.3315 2.08325 10C2.08325 9.66848 2.21495 9.35054 2.44937 9.11612C2.68379 8.8817 3.00173 8.75 3.33325 8.75ZM5.83325 15.8333V14.1667H17.4999V15.8333H5.83325ZM3.33325 13.75C3.66477 13.75 3.98272 13.8817 4.21714 14.1161C4.45156 14.3505 4.58325 14.6685 4.58325 15C4.58325 15.3315 4.45156 15.6495 4.21714 15.8839C3.98272 16.1183 3.66477 16.25 3.33325 16.25C3.00173 16.25 2.68379 16.1183 2.44937 15.8839C2.21495 15.6495 2.08325 15.3315 2.08325 15C2.08325 14.6685 2.21495 14.3505 2.44937 14.1161C2.68379 13.8817 3.00173 13.75 3.33325 13.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
app/assets/icons/ic-menu-arrow-down-alt.svg
Normal file
3
app/assets/icons/ic-menu-arrow-down-alt.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.83325 8.33337L9.99992 12.5L14.1666 8.33337H5.83325Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 147 B |
3
app/assets/icons/ic-menu-arrow-right.svg
Normal file
3
app/assets/icons/ic-menu-arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.91675 14.5834L12.0834 10.4167L7.91675 6.25004L7.91675 14.5834Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 158 B |
11
app/assets/javascripts/__mocks__/@standardnotes/snjs.js
Normal file
11
app/assets/javascripts/__mocks__/@standardnotes/snjs.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const {
|
||||
ApplicationEvent,
|
||||
ProtectionSessionDurations,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
} = require('@standardnotes/snjs');
|
||||
|
||||
module.exports = {
|
||||
ApplicationEvent: ApplicationEvent,
|
||||
ProtectionSessionDurations: ProtectionSessionDurations,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
};
|
||||
@@ -10,6 +10,13 @@ declare global {
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +33,6 @@ import {
|
||||
EditorGroupView,
|
||||
EditorView,
|
||||
TagsView,
|
||||
NotesView,
|
||||
FooterView,
|
||||
ChallengeModal,
|
||||
} from '@/views';
|
||||
@@ -45,7 +51,6 @@ import {
|
||||
|
||||
import {
|
||||
ActionsMenu,
|
||||
ComponentModal,
|
||||
EditorMenu,
|
||||
InputModal,
|
||||
MenuRow,
|
||||
@@ -65,7 +70,7 @@ import { StartApplication } from './startApplication';
|
||||
import { Bridge } from './services/bridge';
|
||||
import { SessionsModalDirective } from './components/SessionsModal';
|
||||
import { NoAccountWarningDirective } from './components/NoAccountWarning';
|
||||
import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNoteWarning';
|
||||
import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
|
||||
import { SearchOptionsDirective } from './components/SearchOptions';
|
||||
import { AccountMenuDirective } from './components/AccountMenu';
|
||||
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
|
||||
@@ -81,7 +86,9 @@ import { PurchaseFlowDirective } from './purchaseFlow';
|
||||
import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { ComponentViewDirective } from '@/components/ComponentView';
|
||||
import { TagsListDirective } from '@/components/TagsList';
|
||||
import { NotesViewDirective } from './components/NotesView';
|
||||
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
|
||||
import { TagsSectionDirective } from './components/Tags/TagsSection';
|
||||
|
||||
function reloadHiddenFirefoxTab(): boolean {
|
||||
/**
|
||||
@@ -117,7 +124,7 @@ const startApplication: StartApplication = async function startApplication(
|
||||
SNLog.onLog = console.log;
|
||||
startErrorReporting();
|
||||
|
||||
angular.module('app', ['ngSanitize']);
|
||||
angular.module('app', []);
|
||||
|
||||
// Config
|
||||
angular
|
||||
@@ -137,7 +144,6 @@ const startApplication: StartApplication = async function startApplication(
|
||||
.directive('editorGroupView', () => new EditorGroupView())
|
||||
.directive('editorView', () => new EditorView())
|
||||
.directive('tagsView', () => new TagsView())
|
||||
.directive('notesView', () => new NotesView())
|
||||
.directive('footerView', () => new FooterView());
|
||||
|
||||
// Directives - Functional
|
||||
@@ -159,7 +165,6 @@ const startApplication: StartApplication = async function startApplication(
|
||||
.directive('accountSwitcher', () => new AccountSwitcher())
|
||||
.directive('actionsMenu', () => new ActionsMenu())
|
||||
.directive('challengeModal', () => new ChallengeModal())
|
||||
.directive('componentModal', () => new ComponentModal())
|
||||
.directive('componentView', ComponentViewDirective)
|
||||
.directive('editorMenu', () => new EditorMenu())
|
||||
.directive('inputModal', () => new InputModal())
|
||||
@@ -174,7 +179,7 @@ const startApplication: StartApplication = async function startApplication(
|
||||
.directive('accountMenu', AccountMenuDirective)
|
||||
.directive('quickSettingsMenu', QuickSettingsMenuDirective)
|
||||
.directive('noAccountWarning', NoAccountWarningDirective)
|
||||
.directive('protectedNotePanel', NoProtectionsdNoteWarningDirective)
|
||||
.directive('protectedNotePanel', ProtectedNoteOverlayDirective)
|
||||
.directive('searchOptions', SearchOptionsDirective)
|
||||
.directive('confirmSignout', ConfirmSignoutDirective)
|
||||
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
|
||||
@@ -183,9 +188,11 @@ const startApplication: StartApplication = async function startApplication(
|
||||
.directive('notesListOptionsMenu', NotesListOptionsDirective)
|
||||
.directive('icon', IconDirective)
|
||||
.directive('noteTagsContainer', NoteTagsContainerDirective)
|
||||
.directive('tags', TagsListDirective)
|
||||
.directive('tagsList', TagsListDirective)
|
||||
.directive('tagsSection', TagsSectionDirective)
|
||||
.directive('preferences', PreferencesDirective)
|
||||
.directive('purchaseFlow', PurchaseFlowDirective)
|
||||
.directive('notesView', NotesViewDirective)
|
||||
.directive('pinNoteButton', PinNoteButtonDirective);
|
||||
|
||||
// Filters
|
||||
@@ -216,11 +223,11 @@ const startApplication: StartApplication = async function startApplication(
|
||||
|
||||
if (IsWebPlatform) {
|
||||
startApplication(
|
||||
(window as any)._default_sync_server as string,
|
||||
window._default_sync_server,
|
||||
new BrowserBridge(AppVersion),
|
||||
(window as any)._enable_unfinished_features as boolean,
|
||||
(window as any)._websocket_url as string
|
||||
window._enable_unfinished_features,
|
||||
window._websocket_url
|
||||
);
|
||||
} else {
|
||||
(window as any).startApplication = startApplication;
|
||||
window.startApplication = startApplication;
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { alertDialog } from '@Services/alertService';
|
||||
import {
|
||||
STRING_IMPORT_SUCCESS,
|
||||
STRING_INVALID_IMPORT_FILE,
|
||||
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
|
||||
StringImportError
|
||||
} from '@/strings';
|
||||
import { BackupFile } from '@standardnotes/snjs';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import TargetedEvent = JSXInternal.TargetedEvent;
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const DataBackup = observer(({
|
||||
application,
|
||||
appState
|
||||
}: Props) => {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
|
||||
|
||||
const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu;
|
||||
|
||||
const downloadDataArchive = () => {
|
||||
application.getArchiveService().downloadBackup(isBackupEncrypted);
|
||||
};
|
||||
|
||||
const readFile = async (file: File): Promise<any> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target!.result as string);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_INVALID_IMPORT_FILE);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
|
||||
const performImport = async (data: BackupFile) => {
|
||||
setIsImportDataLoading(true);
|
||||
|
||||
const result = await application.importData(data);
|
||||
|
||||
setIsImportDataLoading(false);
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
let statusText = STRING_IMPORT_SUCCESS;
|
||||
if ('error' in result) {
|
||||
statusText = result.error;
|
||||
} else if (result.errorCount) {
|
||||
statusText = StringImportError(result.errorCount);
|
||||
}
|
||||
void alertDialog({
|
||||
text: statusText
|
||||
});
|
||||
};
|
||||
|
||||
const importFileSelected = async (event: TargetedEvent<HTMLInputElement, Event>) => {
|
||||
const { files } = (event.target as HTMLInputElement);
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
const data = await readFile(file);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const version = data.version || data.keyParams?.version || data.auth_params?.version;
|
||||
if (!version) {
|
||||
await performImport(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
application.protocolService.supportedVersions().includes(version)
|
||||
) {
|
||||
await performImport(data);
|
||||
} else {
|
||||
setIsImportDataLoading(false);
|
||||
void alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION });
|
||||
}
|
||||
};
|
||||
|
||||
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import
|
||||
const handleImportFile = (event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent) => {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
const { code } = event;
|
||||
|
||||
// Process only when "Enter" or "Space" keys are pressed
|
||||
if (code !== 'Enter' && code !== 'Space') {
|
||||
return;
|
||||
}
|
||||
// Don't proceed the event's default action
|
||||
// (like scrolling in case the "space" key is pressed)
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
(fileInputRef.current as HTMLInputElement).click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isImportDataLoading ? (
|
||||
<div className="sk-spinner small info" />
|
||||
) : (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-section-title">Data Backups</div>
|
||||
<div className="sk-p">Download a backup of all your data.</div>
|
||||
{isEncryptionEnabled && (
|
||||
<form className="sk-panel-form sk-panel-row">
|
||||
<div className="sk-input-group">
|
||||
<label className="sk-horizontal-group tight">
|
||||
<input
|
||||
type="radio"
|
||||
onChange={() => setIsBackupEncrypted(true)}
|
||||
checked={isBackupEncrypted}
|
||||
/>
|
||||
<p className="sk-p">Encrypted</p>
|
||||
</label>
|
||||
<label className="sk-horizontal-group tight">
|
||||
<input
|
||||
type="radio"
|
||||
onChange={() => setIsBackupEncrypted(false)}
|
||||
checked={!isBackupEncrypted}
|
||||
/>
|
||||
<p className="sk-p">Decrypted</p>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<div className="sk-panel-row" />
|
||||
<div className="flex">
|
||||
<button className="sn-button small info" onClick={downloadDataArchive}>Download Backup</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sn-button small flex items-center info ml-2"
|
||||
tabIndex={0}
|
||||
onClick={handleImportFile}
|
||||
onKeyDown={handleImportFile}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={importFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
Import Backup
|
||||
</button>
|
||||
</div>
|
||||
{isDesktopApplication() && (
|
||||
<p className="mt-5">
|
||||
Backups are automatically created on desktop and can be managed
|
||||
via the "Backups" top-level menu.
|
||||
</p>
|
||||
)}
|
||||
<div className="sk-panel-row" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default DataBackup;
|
||||
@@ -1,33 +0,0 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const Encryption = observer(({ appState }: Props) => {
|
||||
const { isEncryptionEnabled, encryptionStatusString, notesAndTagsCount } = appState.accountMenu;
|
||||
|
||||
const getEncryptionStatusForNotes = () => {
|
||||
const length = notesAndTagsCount;
|
||||
return `${length}/${length} notes and tags encrypted`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-section-title">
|
||||
Encryption
|
||||
</div>
|
||||
{isEncryptionEnabled && (
|
||||
<div className="sk-panel-section-subtitle info">
|
||||
{getEncryptionStatusForNotes()}
|
||||
</div>
|
||||
)}
|
||||
<p className="sk-p">
|
||||
{encryptionStatusString}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Encryption;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { storage, StorageKey } from '@Services/localStorage';
|
||||
import { disableErrorReporting, enableErrorReporting, errorReportingId } from '@Services/errorReporting';
|
||||
import { alertDialog } from '@Services/alertService';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const ErrorReporting = observer(({ appState }: Props) => {
|
||||
const [isErrorReportingEnabled] = useState(() => storage.get(StorageKey.DisableErrorReporting) === false);
|
||||
const [errorReportingIdValue] = useState(() => errorReportingId());
|
||||
|
||||
const toggleErrorReportingEnabled = () => {
|
||||
if (isErrorReportingEnabled) {
|
||||
disableErrorReporting();
|
||||
} else {
|
||||
enableErrorReporting();
|
||||
}
|
||||
if (!appState.sync.inProgress) {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const openErrorReportingDialog = () => {
|
||||
alertDialog({
|
||||
title: 'Data sent during automatic error reporting',
|
||||
text: `
|
||||
We use <a target="_blank" rel="noreferrer" href="https://www.bugsnag.com/">Bugsnag</a>
|
||||
to automatically report errors that occur while the app is running. See
|
||||
<a target="_blank" rel="noreferrer" href="https://docs.bugsnag.com/platforms/javascript/#sending-diagnostic-data">
|
||||
this article, paragraph 'Browser' under 'Sending diagnostic data',
|
||||
</a>
|
||||
to see what data is included in error reports.
|
||||
<br><br>
|
||||
Error reports never include IP addresses and are fully
|
||||
anonymized. We use error reports to be alerted when something in our
|
||||
code is causing unexpected errors and crashes in your application
|
||||
experience.
|
||||
`
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-section-title">Error Reporting</div>
|
||||
<div className="sk-panel-section-subtitle info">
|
||||
Automatic error reporting is {isErrorReportingEnabled ? 'enabled' : 'disabled'}
|
||||
</div>
|
||||
<p className="sk-p">
|
||||
Help us improve Standard Notes by automatically submitting
|
||||
anonymized error reports.
|
||||
</p>
|
||||
{errorReportingIdValue && (
|
||||
<>
|
||||
<p className="sk-p selectable">
|
||||
Your random identifier is <span className="font-bold">{errorReportingIdValue}</span>
|
||||
</p>
|
||||
<p className="sk-p">
|
||||
Disabling error reporting will remove that identifier from your
|
||||
local storage, and a new identifier will be created should you
|
||||
decide to enable error reporting again in the future.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="sk-panel-row">
|
||||
<button className="sn-button small info" onClick={toggleErrorReportingEnabled}>
|
||||
{isErrorReportingEnabled ? 'Disable' : 'Enable'} Error Reporting
|
||||
</button>
|
||||
</div>
|
||||
<div className="sk-panel-row">
|
||||
<a className="sk-a" onClick={openErrorReportingDialog}>What data is being sent?</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ErrorReporting;
|
||||
@@ -1,272 +0,0 @@
|
||||
import {
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, STRING_E2E_ENABLED, STRING_ENC_NOT_ENABLED, STRING_LOCAL_ENC_ENABLED,
|
||||
STRING_NON_MATCHING_PASSCODES,
|
||||
StringUtils,
|
||||
Strings
|
||||
} from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { preventRefreshing } from '@/utils';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import TargetedEvent = JSXInternal.TargetedEvent;
|
||||
import { alertDialog } from '@Services/alertService';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { ApplicationEvent } from '@standardnotes/snjs';
|
||||
import TargetedMouseEvent = JSXInternal.TargetedMouseEvent;
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const PasscodeLock = observer(({
|
||||
application,
|
||||
appState,
|
||||
}: Props) => {
|
||||
const keyStorageInfo = StringUtils.keyStorageInfo(application);
|
||||
const passcodeAutoLockOptions = application.getAutolockService().getAutoLockIntervalOptions();
|
||||
|
||||
const { setIsEncryptionEnabled, setIsBackupEncrypted, setEncryptionStatusString } = appState.accountMenu;
|
||||
|
||||
const passcodeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [passcode, setPasscode] = useState<string | undefined>(undefined);
|
||||
const [passcodeConfirmation, setPasscodeConfirmation] = useState<string | undefined>(undefined);
|
||||
const [selectedAutoLockInterval, setSelectedAutoLockInterval] = useState<unknown>(null);
|
||||
const [isPasscodeFocused, setIsPasscodeFocused] = useState(false);
|
||||
const [showPasscodeForm, setShowPasscodeForm] = useState(false);
|
||||
const [canAddPasscode, setCanAddPasscode] = useState(!application.isEphemeralSession());
|
||||
const [hasPasscode, setHasPasscode] = useState(application.hasPasscode());
|
||||
|
||||
const handleAddPassCode = () => {
|
||||
setShowPasscodeForm(true);
|
||||
setIsPasscodeFocused(true);
|
||||
};
|
||||
|
||||
const changePasscodePressed = () => {
|
||||
handleAddPassCode();
|
||||
};
|
||||
|
||||
const reloadAutoLockInterval = useCallback(async () => {
|
||||
const interval = await application.getAutolockService().getAutoLockInterval();
|
||||
setSelectedAutoLockInterval(interval);
|
||||
}, [application]);
|
||||
|
||||
const refreshEncryptionStatus = useCallback(() => {
|
||||
const hasUser = application.hasAccount();
|
||||
const hasPasscode = application.hasPasscode();
|
||||
|
||||
setHasPasscode(hasPasscode);
|
||||
|
||||
const encryptionEnabled = hasUser || hasPasscode;
|
||||
|
||||
const encryptionStatusString = hasUser
|
||||
? STRING_E2E_ENABLED
|
||||
: hasPasscode
|
||||
? STRING_LOCAL_ENC_ENABLED
|
||||
: STRING_ENC_NOT_ENABLED;
|
||||
|
||||
setEncryptionStatusString(encryptionStatusString);
|
||||
setIsEncryptionEnabled(encryptionEnabled);
|
||||
setIsBackupEncrypted(encryptionEnabled);
|
||||
}, [application, setEncryptionStatusString, setIsBackupEncrypted, setIsEncryptionEnabled]);
|
||||
|
||||
const selectAutoLockInterval = async (interval: number) => {
|
||||
if (!(await application.authorizeAutolockIntervalChange())) {
|
||||
return;
|
||||
}
|
||||
await application.getAutolockService().setAutoLockInterval(interval);
|
||||
reloadAutoLockInterval();
|
||||
};
|
||||
|
||||
const removePasscodePressed = async () => {
|
||||
await preventRefreshing(
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
|
||||
async () => {
|
||||
if (await application.removePasscode()) {
|
||||
await application
|
||||
.getAutolockService()
|
||||
.deleteAutolockPreference();
|
||||
await reloadAutoLockInterval();
|
||||
refreshEncryptionStatus();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handlePasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
setPasscode(value);
|
||||
};
|
||||
|
||||
const handleConfirmPasscodeChange = (event: TargetedEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
setPasscodeConfirmation(value);
|
||||
};
|
||||
|
||||
const submitPasscodeForm = async (event: TargetedEvent<HTMLFormElement> | TargetedMouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!passcode || passcode.length === 0) {
|
||||
await alertDialog({
|
||||
text: Strings.enterPasscode,
|
||||
});
|
||||
}
|
||||
|
||||
if (passcode !== passcodeConfirmation) {
|
||||
await alertDialog({
|
||||
text: STRING_NON_MATCHING_PASSCODES
|
||||
});
|
||||
setIsPasscodeFocused(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await preventRefreshing(
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
|
||||
async () => {
|
||||
const successful = application.hasPasscode()
|
||||
? await application.changePasscode(passcode as string)
|
||||
: await application.addPasscode(passcode as string);
|
||||
|
||||
if (!successful) {
|
||||
setIsPasscodeFocused(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
setPasscode(undefined);
|
||||
setPasscodeConfirmation(undefined);
|
||||
setShowPasscodeForm(false);
|
||||
|
||||
refreshEncryptionStatus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshEncryptionStatus();
|
||||
}, [refreshEncryptionStatus]);
|
||||
|
||||
// `reloadAutoLockInterval` gets interval asynchronously, therefore we call `useEffect` to set initial
|
||||
// value of `selectedAutoLockInterval`
|
||||
useEffect(() => {
|
||||
reloadAutoLockInterval();
|
||||
}, [reloadAutoLockInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPasscodeFocused) {
|
||||
passcodeInputRef.current!.focus();
|
||||
setIsPasscodeFocused(false);
|
||||
}
|
||||
}, [isPasscodeFocused]);
|
||||
|
||||
// Add the required event observers
|
||||
useEffect(() => {
|
||||
const removeKeyStatusChangedObserver = application.addEventObserver(
|
||||
async () => {
|
||||
setCanAddPasscode(!application.isEphemeralSession());
|
||||
setHasPasscode(application.hasPasscode());
|
||||
setShowPasscodeForm(false);
|
||||
},
|
||||
ApplicationEvent.KeyStatusChanged
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeKeyStatusChangedObserver();
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
return (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-section-title">Passcode Lock</div>
|
||||
{!hasPasscode && (
|
||||
<div>
|
||||
{canAddPasscode && (
|
||||
<>
|
||||
{!showPasscodeForm && (
|
||||
<div className="sk-panel-row">
|
||||
<button className="sn-button small info" onClick={handleAddPassCode}>
|
||||
Add Passcode
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="sk-p">
|
||||
Add a passcode to lock the application and
|
||||
encrypt on-device key storage.
|
||||
</p>
|
||||
{keyStorageInfo && (
|
||||
<p>{keyStorageInfo}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!canAddPasscode && (
|
||||
<p className="sk-p">
|
||||
Adding a passcode is not supported in temporary sessions. Please sign
|
||||
out, then sign back in with the "Stay signed in" option checked.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showPasscodeForm && (
|
||||
<form className="sk-panel-form" onSubmit={submitPasscodeForm}>
|
||||
<div className="sk-panel-row" />
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
type="password"
|
||||
ref={passcodeInputRef}
|
||||
value={passcode}
|
||||
onChange={handlePasscodeChange}
|
||||
placeholder="Passcode"
|
||||
/>
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
type="password"
|
||||
value={passcodeConfirmation}
|
||||
onChange={handleConfirmPasscodeChange}
|
||||
placeholder="Confirm Passcode"
|
||||
/>
|
||||
<button className="sn-button small info mt-2" onClick={submitPasscodeForm}>
|
||||
Set Passcode
|
||||
</button>
|
||||
<button className="sn-button small outlined ml-2" onClick={() => setShowPasscodeForm(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{hasPasscode && !showPasscodeForm && (
|
||||
<>
|
||||
<div className="sk-panel-section-subtitle info">Passcode lock is enabled</div>
|
||||
<div className="sk-notification contrast">
|
||||
<div className="sk-notification-title">Options</div>
|
||||
<div className="sk-notification-text">
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-horizontal-group">
|
||||
<div className="sk-h4 sk-bold">Autolock</div>
|
||||
{passcodeAutoLockOptions.map(option => {
|
||||
return (
|
||||
<a
|
||||
className={`sk-a info ${option.value === selectedAutoLockInterval ? 'boxed' : ''}`}
|
||||
onClick={() => selectAutoLockInterval(option.value)}>
|
||||
{option.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-p">The autolock timer begins when the window or tab loses focus.</div>
|
||||
<div className="sk-panel-row" />
|
||||
<a className="sk-a info sk-panel-row condensed" onClick={changePasscodePressed}>
|
||||
Change Passcode
|
||||
</a>
|
||||
<a className="sk-a danger sk-panel-row condensed" onClick={removePasscodePressed}>
|
||||
Remove Passcode
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PasscodeLock;
|
||||
@@ -1,100 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { ApplicationEvent } from '@standardnotes/snjs';
|
||||
import { isSameDay } from '@/utils';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
const Protections: FunctionalComponent<Props> = ({ application }) => {
|
||||
const enableProtections = () => {
|
||||
application.clearProtectionSession();
|
||||
};
|
||||
|
||||
const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources());
|
||||
|
||||
const getProtectionsDisabledUntil = useCallback((): string | null => {
|
||||
const protectionExpiry = application.getProtectionSessionExpiryDate();
|
||||
const now = new Date();
|
||||
if (protectionExpiry > now) {
|
||||
let f: Intl.DateTimeFormat;
|
||||
if (isSameDay(protectionExpiry, now)) {
|
||||
f = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
} else {
|
||||
f = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
return f.format(protectionExpiry);
|
||||
}
|
||||
return null;
|
||||
}, [application]);
|
||||
|
||||
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil());
|
||||
|
||||
useEffect(() => {
|
||||
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver(
|
||||
async () => {
|
||||
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
|
||||
},
|
||||
ApplicationEvent.ProtectionSessionExpiryDateChanged
|
||||
);
|
||||
|
||||
const removeKeyStatusChangedObserver = application.addEventObserver(
|
||||
async () => {
|
||||
setHasProtections(application.hasProtectionSources());
|
||||
},
|
||||
ApplicationEvent.KeyStatusChanged
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeProtectionSessionExpiryDateChangedObserver();
|
||||
removeKeyStatusChangedObserver();
|
||||
};
|
||||
}, [application, getProtectionsDisabledUntil]);
|
||||
|
||||
if (!hasProtections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sk-panel-section">
|
||||
<div className="sk-panel-section-title">Protections</div>
|
||||
{protectionsDisabledUntil && (
|
||||
<div className="sk-panel-section-subtitle info">
|
||||
Protections are disabled until {protectionsDisabledUntil}
|
||||
</div>
|
||||
)}
|
||||
{!protectionsDisabledUntil && (
|
||||
<div className="sk-panel-section-subtitle info">
|
||||
Protections are enabled
|
||||
</div>
|
||||
)}
|
||||
<p className="sk-p">
|
||||
Actions like viewing protected notes, exporting decrypted backups,
|
||||
or revoking an active session, require additional authentication
|
||||
like entering your account password or application passcode.
|
||||
</p>
|
||||
{protectionsDisabledUntil && (
|
||||
<div className="sk-panel-row">
|
||||
<button className="sn-button small info" onClick={enableProtections}>
|
||||
Enable protections
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Protections;
|
||||
@@ -5,11 +5,14 @@ interface IProps {
|
||||
expiredDate: string;
|
||||
componentName: string;
|
||||
featureStatus: FeatureStatus;
|
||||
reloadStatus: () => void;
|
||||
manageSubscription: () => void;
|
||||
}
|
||||
|
||||
const statusString = (featureStatus: FeatureStatus, expiredDate: string, componentName: string) => {
|
||||
const statusString = (
|
||||
featureStatus: FeatureStatus,
|
||||
expiredDate: string,
|
||||
componentName: string
|
||||
) => {
|
||||
switch (featureStatus) {
|
||||
case FeatureStatus.InCurrentPlanButExpired:
|
||||
return `Your subscription expired on ${expiredDate}`;
|
||||
@@ -25,9 +28,8 @@ const statusString = (featureStatus: FeatureStatus, expiredDate: string, compone
|
||||
export const IsExpired: FunctionalComponent<IProps> = ({
|
||||
expiredDate,
|
||||
featureStatus,
|
||||
reloadStatus,
|
||||
componentName,
|
||||
manageSubscription
|
||||
manageSubscription,
|
||||
}) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
@@ -50,11 +52,13 @@ export const IsExpired: FunctionalComponent<IProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={() => manageSubscription()}>
|
||||
<button className={'sn-button small success'}>Manage Subscription</button>
|
||||
</div>
|
||||
<div className={'sk-app-bar-item'} onClick={() => reloadStatus()}>
|
||||
<button className={'sn-button small info'}>Reload</button>
|
||||
<div
|
||||
className={'sk-app-bar-item'}
|
||||
onClick={() => manageSubscription()}
|
||||
>
|
||||
<button className={'sn-button small success'}>
|
||||
Manage Subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,16 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const IssueOnLoading: FunctionalComponent<IProps> = ({
|
||||
componentName,
|
||||
reloadIframe
|
||||
}) => {
|
||||
componentName,
|
||||
reloadIframe,
|
||||
}) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
<div className={'left'}>
|
||||
<div className={'sk-app-bar-item'}>
|
||||
<div className={'sk-label.warning'}>
|
||||
There was an issue loading {componentName}
|
||||
There was an issue loading {componentName}.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
|
||||
interface IProps {
|
||||
isReloading: boolean;
|
||||
reloadStatus: () => void;
|
||||
}
|
||||
|
||||
export const OfflineRestricted: FunctionalComponent<IProps> = ({
|
||||
isReloading,
|
||||
reloadStatus
|
||||
}) => {
|
||||
export const OfflineRestricted: FunctionalComponent = () => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-panel static'}>
|
||||
@@ -16,40 +8,29 @@ export const OfflineRestricted: FunctionalComponent<IProps> = ({
|
||||
<div className={'sk-panel-section stretch'}>
|
||||
<div className={'sk-panel-column'} />
|
||||
<div className={'sk-h1 sk-bold'}>
|
||||
You have restricted this extension to be used offline only.
|
||||
You have restricted this component to not use a hosted version.
|
||||
</div>
|
||||
<div className={'sk-subtitle'}>
|
||||
Offline extensions are not available in the Web app.
|
||||
Locally-installed components are not available in the web
|
||||
application.
|
||||
</div>
|
||||
<div className={'sk-panel-row'} />
|
||||
<div className={'sk-panel-row'}>
|
||||
<div className={'sk-panel-column'}>
|
||||
<div className={'sk-p'}>
|
||||
You can either:
|
||||
To continue, choose from the following options:
|
||||
</div>
|
||||
<ul>
|
||||
<li className={'sk-p'}>
|
||||
<span className={'font-bold'}>
|
||||
Enable the Hosted option for this extension by opening the 'Extensions' menu and{' '}
|
||||
toggling 'Use hosted when local is unavailable' under this extension's options.{' '}
|
||||
Then press Reload below.
|
||||
</span>
|
||||
</li>
|
||||
<li className={'sk-p'}>
|
||||
<span className={'font-bold'}>Use the Desktop application.</span>
|
||||
Enable the Hosted option for this component by opening the
|
||||
Preferences {'>'} General {'>'} Advanced Settings menu and{' '}
|
||||
toggling 'Use hosted when local is unavailable' under this
|
||||
component's options. Then press Reload.
|
||||
</li>
|
||||
<li className={'sk-p'}>Use the desktop application.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'sk-panel-row'}>
|
||||
{isReloading ?
|
||||
<div className={'sk-spinner info small'} />
|
||||
:
|
||||
<button className={'sn-button small info'} onClick={() => reloadStatus()}>
|
||||
Reload
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ComponentAction, FeatureStatus, LiveItem, SNComponent, dateToLocalizedString } from '@standardnotes/snjs';
|
||||
import {
|
||||
ComponentAction,
|
||||
FeatureStatus,
|
||||
SNComponent,
|
||||
dateToLocalizedString,
|
||||
ComponentViewer,
|
||||
ComponentViewerEvent,
|
||||
ComponentViewerError,
|
||||
} from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { toDirective } from '@/components/utils';
|
||||
@@ -11,15 +19,14 @@ import { IsDeprecated } from '@/components/ComponentView/IsDeprecated';
|
||||
import { IsExpired } from '@/components/ComponentView/IsExpired';
|
||||
import { IssueOnLoading } from '@/components/ComponentView/IssueOnLoading';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { ComponentArea } from '@node_modules/@standardnotes/features';
|
||||
import { openSubscriptionDashboard } from '@/hooks/manageSubscription';
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
componentUuid: string;
|
||||
componentViewer: ComponentViewer;
|
||||
requestReload?: (viewer: ComponentViewer) => void;
|
||||
onLoad?: (component: SNComponent) => void;
|
||||
templateComponent?: SNComponent;
|
||||
manualDealloc?: boolean;
|
||||
}
|
||||
|
||||
@@ -29,339 +36,237 @@ interface IProps {
|
||||
*/
|
||||
const MaxLoadThreshold = 4000;
|
||||
const VisibilityChangeKey = 'visibilitychange';
|
||||
const avoidFlickerTimeout = 7;
|
||||
const MSToWaitAfterIframeLoadToAvoidFlicker = 35;
|
||||
|
||||
export const ComponentView: FunctionalComponent<IProps> = observer(
|
||||
({
|
||||
application,
|
||||
onLoad,
|
||||
componentUuid,
|
||||
templateComponent
|
||||
}) => {
|
||||
const liveComponentRef = useRef<LiveItem<SNComponent> | null>(null);
|
||||
({ application, onLoad, componentViewer, requestReload }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const excessiveLoadingTimeout = useRef<
|
||||
ReturnType<typeof setTimeout> | undefined
|
||||
>(undefined);
|
||||
|
||||
const [isIssueOnLoading, setIsIssueOnLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [featureStatus, setFeatureStatus] = useState<FeatureStatus | undefined>(FeatureStatus.Entitled);
|
||||
const [hasIssueLoading, setHasIssueLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(
|
||||
componentViewer.getFeatureStatus()
|
||||
);
|
||||
const [isComponentValid, setIsComponentValid] = useState(true);
|
||||
const [error, setError] = useState<'offline-restricted' | 'url-missing' | undefined>(undefined);
|
||||
const [isDeprecated, setIsDeprecated] = useState(false);
|
||||
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined);
|
||||
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false);
|
||||
const [error, setError] = useState<ComponentViewerError | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [deprecationMessage, setDeprecationMessage] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] =
|
||||
useState(false);
|
||||
const [didAttemptReload, setDidAttemptReload] = useState(false);
|
||||
const [component, setComponent] = useState<SNComponent | undefined>(undefined);
|
||||
|
||||
const getComponent = useCallback((): SNComponent => {
|
||||
return (templateComponent || liveComponentRef.current?.item) as SNComponent;
|
||||
}, [templateComponent]);
|
||||
|
||||
const reloadIframe = () => {
|
||||
setTimeout(() => {
|
||||
setIsReloading(true);
|
||||
setTimeout(() => {
|
||||
setIsReloading(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
const component = componentViewer.component;
|
||||
|
||||
const manageSubscription = useCallback(() => {
|
||||
openSubscriptionDashboard(application);
|
||||
}, [application]);
|
||||
|
||||
const reloadStatus = useCallback(() => {
|
||||
if (!component) {
|
||||
return;
|
||||
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) {
|
||||
componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled);
|
||||
}
|
||||
setIsComponentValid(componentViewer.shouldRender());
|
||||
|
||||
const offlineRestricted = component.offlineOnly && !isDesktopApplication();
|
||||
const hasUrlError = function () {
|
||||
if (isDesktopApplication()) {
|
||||
return !component.local_url && !component.hasValidHostedUrl();
|
||||
} else {
|
||||
return !component.hasValidHostedUrl();
|
||||
}
|
||||
}();
|
||||
|
||||
setFeatureStatus(application.getFeatureStatus(component.identifier));
|
||||
|
||||
const readonlyState = application.componentManager.getReadonlyStateForComponent(component);
|
||||
|
||||
if (!readonlyState.lockReadonly) {
|
||||
application.componentManager.setReadonlyStateForComponent(component, featureStatus !== FeatureStatus.Entitled);
|
||||
}
|
||||
setIsComponentValid(!offlineRestricted && !hasUrlError);
|
||||
|
||||
if (!isComponentValid) {
|
||||
if (isLoading && !isComponentValid) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (offlineRestricted) {
|
||||
setError('offline-restricted');
|
||||
} else if (hasUrlError) {
|
||||
setError('url-missing');
|
||||
} else {
|
||||
setError(undefined);
|
||||
}
|
||||
setIsDeprecated(component.isDeprecated);
|
||||
setDeprecationMessage(component.package_info.deprecation_message);
|
||||
}, [application, component, isComponentValid, featureStatus]);
|
||||
setError(componentViewer.getError());
|
||||
setDeprecationMessage(component.deprecationMessage);
|
||||
}, [
|
||||
componentViewer,
|
||||
component.deprecationMessage,
|
||||
featureStatus,
|
||||
isComponentValid,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadValidityStatus();
|
||||
}, [reloadValidityStatus]);
|
||||
|
||||
const dismissDeprecationMessage = () => {
|
||||
setTimeout(() => {
|
||||
setIsDeprecationMessageDismissed(true);
|
||||
});
|
||||
setIsDeprecationMessageDismissed(true);
|
||||
};
|
||||
|
||||
const onVisibilityChange = useCallback(() => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
if (isIssueOnLoading) {
|
||||
reloadIframe();
|
||||
if (hasIssueLoading) {
|
||||
requestReload?.(componentViewer);
|
||||
}
|
||||
}, [isIssueOnLoading]);
|
||||
}, [hasIssueLoading, componentViewer, requestReload]);
|
||||
|
||||
const handleIframeLoadTimeout = useCallback(async () => {
|
||||
if (isLoading) {
|
||||
setIsLoading(false);
|
||||
setIsIssueOnLoading(true);
|
||||
const handleIframeTakingTooLongToLoad = useCallback(async () => {
|
||||
setIsLoading(false);
|
||||
setHasIssueLoading(true);
|
||||
|
||||
if (!didAttemptReload) {
|
||||
setDidAttemptReload(true);
|
||||
reloadIframe();
|
||||
} else {
|
||||
document.addEventListener(
|
||||
VisibilityChangeKey,
|
||||
onVisibilityChange
|
||||
);
|
||||
}
|
||||
if (!didAttemptReload) {
|
||||
setDidAttemptReload(true);
|
||||
requestReload?.(componentViewer);
|
||||
} else {
|
||||
document.addEventListener(VisibilityChangeKey, onVisibilityChange);
|
||||
}
|
||||
}, [didAttemptReload, isLoading, onVisibilityChange]);
|
||||
|
||||
const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => {
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
let desktopError = false;
|
||||
if (isDesktopApplication()) {
|
||||
try {
|
||||
/** Accessing iframe.contentWindow.origin only allowed in desktop app. */
|
||||
if (!iframe.contentWindow!.origin || iframe.contentWindow!.origin === 'null') {
|
||||
desktopError = true;
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
loadTimeout && clearTimeout(loadTimeout);
|
||||
await application.componentManager.registerComponentWindow(
|
||||
component,
|
||||
iframe.contentWindow!
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setIsIssueOnLoading(desktopError ? true : false);
|
||||
onLoad?.(component!);
|
||||
}, avoidFlickerTimeout);
|
||||
}, [application.componentManager, component, loadTimeout, onLoad]);
|
||||
|
||||
const loadComponent = useCallback(() => {
|
||||
if (!component) {
|
||||
throw Error('Component view is missing component');
|
||||
}
|
||||
|
||||
if (!component.active && !component.isEditor() && component.area !== ComponentArea.Modal) {
|
||||
/** Editors don't need to be active to be displayed */
|
||||
throw Error('Component view component must be active');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
if (loadTimeout) {
|
||||
clearTimeout(loadTimeout);
|
||||
}
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
handleIframeLoadTimeout();
|
||||
}, MaxLoadThreshold);
|
||||
|
||||
setLoadTimeout(timeoutHandler);
|
||||
}, [component, handleIframeLoadTimeout, loadTimeout]);
|
||||
}, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadStatus();
|
||||
|
||||
if (!iframeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
iframeRef.current.onload = () => {
|
||||
if (!component) {
|
||||
return;
|
||||
const iframe = iframeRef.current as HTMLIFrameElement;
|
||||
iframe.onload = () => {
|
||||
const contentWindow = iframe.contentWindow as Window;
|
||||
|
||||
let hasDesktopError = false;
|
||||
const canAccessWindowOrigin = isDesktopApplication();
|
||||
if (canAccessWindowOrigin) {
|
||||
try {
|
||||
if (!contentWindow.origin || contentWindow.origin === 'null') {
|
||||
hasDesktopError = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const iframe = application.componentManager.iframeForComponent(
|
||||
component.uuid
|
||||
);
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
excessiveLoadingTimeout.current &&
|
||||
clearTimeout(excessiveLoadingTimeout.current);
|
||||
|
||||
componentViewer.setWindow(contentWindow);
|
||||
|
||||
setTimeout(() => {
|
||||
loadComponent();
|
||||
reloadStatus();
|
||||
handleIframeLoad(iframe);
|
||||
});
|
||||
setIsLoading(false);
|
||||
setHasIssueLoading(hasDesktopError);
|
||||
onLoad?.(component);
|
||||
}, MSToWaitAfterIframeLoadToAvoidFlicker);
|
||||
};
|
||||
}, [application.componentManager, component, handleIframeLoad, loadComponent, reloadStatus]);
|
||||
|
||||
const getUrl = () => {
|
||||
const url = component ? application.componentManager.urlForComponent(component) : '';
|
||||
return url as string;
|
||||
};
|
||||
}, [onLoad, component, componentViewer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (componentUuid) {
|
||||
liveComponentRef.current = new LiveItem(componentUuid, application);
|
||||
} else {
|
||||
application.componentManager.addTemporaryTemplateComponent(templateComponent as SNComponent);
|
||||
}
|
||||
const removeFeaturesChangedObserver = componentViewer.addEventObserver(
|
||||
(event) => {
|
||||
if (event === ComponentViewerEvent.FeatureStatusUpdated) {
|
||||
setFeatureStatus(componentViewer.getFeatureStatus());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (application.componentManager) {
|
||||
/** Component manager Can be destroyed already via locking */
|
||||
if (component) {
|
||||
application.componentManager.onComponentIframeDestroyed(component.uuid);
|
||||
}
|
||||
if (templateComponent) {
|
||||
application.componentManager.removeTemporaryTemplateComponent(templateComponent);
|
||||
}
|
||||
}
|
||||
|
||||
if (liveComponentRef.current) {
|
||||
liveComponentRef.current.deinit();
|
||||
}
|
||||
|
||||
document.removeEventListener(
|
||||
VisibilityChangeKey,
|
||||
onVisibilityChange
|
||||
);
|
||||
removeFeaturesChangedObserver();
|
||||
};
|
||||
}, [application, component, componentUuid, onVisibilityChange, templateComponent]);
|
||||
}, [componentViewer]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set/update `component` based on `componentUuid` prop.
|
||||
// It's a hint that the props were changed and we should rerender this component (and particularly, the iframe).
|
||||
if (!component || component.uuid && componentUuid && component.uuid !== componentUuid) {
|
||||
const latestComponentValue = getComponent();
|
||||
setComponent(latestComponentValue);
|
||||
}
|
||||
}, [component, componentUuid, getComponent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unregisterComponentHandler = application.componentManager.registerHandler({
|
||||
identifier: 'component-view-' + Math.random(),
|
||||
areas: [component.area],
|
||||
actionHandler: (component, action, data) => {
|
||||
const removeActionObserver = componentViewer.addActionObserver(
|
||||
(action, data) => {
|
||||
switch (action) {
|
||||
case (ComponentAction.SetSize):
|
||||
application.componentManager.handleSetSizeEvent(component, data);
|
||||
break;
|
||||
case (ComponentAction.KeyDown):
|
||||
case ComponentAction.KeyDown:
|
||||
application.io.handleComponentKeyDown(data.keyboardModifier);
|
||||
break;
|
||||
case (ComponentAction.KeyUp):
|
||||
case ComponentAction.KeyUp:
|
||||
application.io.handleComponentKeyUp(data.keyboardModifier);
|
||||
break;
|
||||
case (ComponentAction.Click):
|
||||
case ComponentAction.Click:
|
||||
application.getAppState().notes.setContextMenuOpen(false);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
);
|
||||
return () => {
|
||||
unregisterComponentHandler();
|
||||
removeActionObserver();
|
||||
};
|
||||
}, [application, component]);
|
||||
}, [componentViewer, application]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregisterDesktopObserver = application.getDesktopService()
|
||||
const unregisterDesktopObserver = application
|
||||
.getDesktopService()
|
||||
.registerUpdateObserver((component: SNComponent) => {
|
||||
if (component.uuid === component.uuid && component.active) {
|
||||
reloadIframe();
|
||||
requestReload?.(componentViewer);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregisterDesktopObserver();
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
if (!component) {
|
||||
return null;
|
||||
}
|
||||
}, [application, requestReload, componentViewer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isIssueOnLoading && (
|
||||
{hasIssueLoading && (
|
||||
<IssueOnLoading
|
||||
componentName={component.name}
|
||||
reloadIframe={reloadIframe}
|
||||
reloadIframe={() => {
|
||||
reloadValidityStatus(), requestReload?.(componentViewer);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{featureStatus !== FeatureStatus.Entitled && (
|
||||
<IsExpired
|
||||
expiredDate={dateToLocalizedString(component.valid_until)}
|
||||
reloadStatus={reloadStatus}
|
||||
featureStatus={featureStatus!}
|
||||
featureStatus={featureStatus}
|
||||
componentName={component.name}
|
||||
manageSubscription={manageSubscription}
|
||||
/>
|
||||
)}
|
||||
{isDeprecated && !isDeprecationMessageDismissed && (
|
||||
{deprecationMessage && !isDeprecationMessageDismissed && (
|
||||
<IsDeprecated
|
||||
deprecationMessage={deprecationMessage}
|
||||
dismissDeprecationMessage={dismissDeprecationMessage}
|
||||
/>
|
||||
)}
|
||||
{error == 'offline-restricted' && (
|
||||
<OfflineRestricted isReloading={isReloading} reloadStatus={reloadStatus} />
|
||||
{error === ComponentViewerError.OfflineRestricted && (
|
||||
<OfflineRestricted />
|
||||
)}
|
||||
{error == 'url-missing' && (
|
||||
{error === ComponentViewerError.MissingUrl && (
|
||||
<UrlMissing componentName={component.name} />
|
||||
)}
|
||||
{component.uuid && !isReloading && isComponentValid && (
|
||||
{component.uuid && isComponentValid && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-component-id={component.uuid}
|
||||
data-component-viewer-id={componentViewer.identifier}
|
||||
frameBorder={0}
|
||||
data-attr-id={`component-iframe-${component.uuid}`}
|
||||
src={getUrl()}
|
||||
sandbox='allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads'
|
||||
src={application.componentManager.urlForComponent(component) || ''}
|
||||
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
|
||||
>
|
||||
Loading
|
||||
</iframe>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className={'loading-overlay'} />
|
||||
)}
|
||||
{isLoading && <div className={'loading-overlay'} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const ComponentViewDirective = toDirective<IProps>(ComponentView, {
|
||||
onLoad: '=',
|
||||
componentUuid: '=',
|
||||
templateComponent: '=',
|
||||
manualDealloc: '='
|
||||
componentViewer: '=',
|
||||
requestReload: '=',
|
||||
manualDealloc: '=',
|
||||
});
|
||||
|
||||
@@ -24,8 +24,10 @@ import MarkdownIcon from '../../icons/ic-markdown.svg';
|
||||
import CodeIcon from '../../icons/ic-code.svg';
|
||||
|
||||
import AccessibilityIcon from '../../icons/ic-accessibility.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';
|
||||
@@ -53,11 +55,17 @@ import LockIcon from '../../icons/ic-lock.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 LinkOffIcon from '../../icons/ic-link-off.svg';
|
||||
|
||||
import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg';
|
||||
import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg';
|
||||
|
||||
import { toDirective } from './utils';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
|
||||
const ICONS = {
|
||||
'menu-arrow-down-alt': MenuArrowDownAlt,
|
||||
'menu-arrow-right': MenuArrowRight,
|
||||
'arrows-sort-up': ArrowsSortUpIcon,
|
||||
'arrows-sort-down': ArrowsSortDownIcon,
|
||||
lock: LockIcon,
|
||||
@@ -94,8 +102,11 @@ const ICONS = {
|
||||
more: MoreIcon,
|
||||
tune: TuneIcon,
|
||||
accessibility: AccessibilityIcon,
|
||||
add: AddIcon,
|
||||
help: HelpIcon,
|
||||
keyboard: KeyboardIcon,
|
||||
'list-bulleted': ListBulleted,
|
||||
'link-off': LinkOffIcon,
|
||||
listed: ListedIcon,
|
||||
security: SecurityIcon,
|
||||
settings: SettingsIcon,
|
||||
@@ -111,7 +122,7 @@ const ICONS = {
|
||||
'menu-arrow-down': MenuArrowDownIcon,
|
||||
'menu-close': MenuCloseIcon,
|
||||
window: WindowIcon,
|
||||
'premium-feature': PremiumFeatureIcon
|
||||
'premium-feature': PremiumFeatureIcon,
|
||||
};
|
||||
|
||||
export type IconType = keyof typeof ICONS;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
|
||||
|
||||
type Props = { appState: AppState };
|
||||
|
||||
const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
export const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
const canShow = appState.noAccountWarning.show;
|
||||
if (!canShow) {
|
||||
return null;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
type Props = { appState: AppState; onViewNote: () => void };
|
||||
|
||||
function NoProtectionsNoteWarning({ appState, onViewNote }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center max-w-md">
|
||||
<h1 className="text-2xl m-0 w-full">This note is protected</h1>
|
||||
<p className="text-lg mt-2 w-full">
|
||||
Add a passcode or create an account to require authentication to view
|
||||
this note.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
className="sn-button small info"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setShow(true);
|
||||
}}
|
||||
>
|
||||
Open account menu
|
||||
</button>
|
||||
<button className="sn-button small outlined" onClick={onViewNote}>
|
||||
View note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const NoProtectionsdNoteWarningDirective = toDirective<Props>(
|
||||
NoProtectionsNoteWarning,
|
||||
{
|
||||
onViewNote: '&',
|
||||
}
|
||||
);
|
||||
107
app/assets/javascripts/components/NotesList.tsx
Normal file
107
app/assets/javascripts/components/NotesList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { DisplayOptions } from '@/ui_models/app_state/notes_view_state';
|
||||
import { SNNote } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { NotesListItem } from './NotesListItem';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
notes: SNNote[];
|
||||
selectedNotes: Record<string, SNNote>;
|
||||
displayOptions: DisplayOptions;
|
||||
paginate: () => void;
|
||||
};
|
||||
|
||||
const FOCUSABLE_BUT_NOT_TABBABLE = -1;
|
||||
const NOTES_LIST_SCROLL_THRESHOLD = 200;
|
||||
|
||||
export const NotesList: FunctionComponent<Props> = observer(
|
||||
({ appState, notes, selectedNotes, displayOptions, paginate }) => {
|
||||
const { selectPreviousNote, selectNextNote } = appState.notesView;
|
||||
const { hideTags, hideDate, hideNotePreview, sortBy } = displayOptions;
|
||||
|
||||
const tagsStringForNote = (note: SNNote): string => {
|
||||
if (hideTags) {
|
||||
return '';
|
||||
}
|
||||
const selectedTag = appState.selectedTag;
|
||||
if (!selectedTag) {
|
||||
return '';
|
||||
}
|
||||
const tags = appState.getNoteTags(note);
|
||||
if (!selectedTag.isSmartTag && tags.length === 1) {
|
||||
return '';
|
||||
}
|
||||
return tags.map((tag) => `#${tag.title}`).join(' ');
|
||||
};
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
appState.notes.setContextMenuClickLocation({
|
||||
x: posX,
|
||||
y: posY,
|
||||
});
|
||||
appState.notes.reloadContextMenuLayout();
|
||||
appState.notes.setContextMenuOpen(true);
|
||||
};
|
||||
|
||||
const onContextMenu = async (note: SNNote, posX: number, posY: number) => {
|
||||
await appState.notes.selectNote(note.uuid, true);
|
||||
if (selectedNotes[note.uuid]) {
|
||||
openNoteContextMenu(posX, posY);
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = (e: Event) => {
|
||||
const offset = NOTES_LIST_SCROLL_THRESHOLD;
|
||||
const element = e.target as HTMLElement;
|
||||
if (
|
||||
element.scrollTop + element.offsetHeight >=
|
||||
element.scrollHeight - offset
|
||||
) {
|
||||
paginate();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Up) {
|
||||
e.preventDefault();
|
||||
selectPreviousNote();
|
||||
} else if (e.key === KeyboardKey.Down) {
|
||||
e.preventDefault();
|
||||
selectNextNote();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="infinite-scroll"
|
||||
id="notes-scrollable"
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
{notes.map((note) => (
|
||||
<NotesListItem
|
||||
key={note.uuid}
|
||||
note={note}
|
||||
tags={tagsStringForNote(note)}
|
||||
selected={!!selectedNotes[note.uuid]}
|
||||
hideDate={hideDate}
|
||||
hidePreview={hideNotePreview}
|
||||
hideTags={hideTags}
|
||||
sortedBy={sortBy}
|
||||
onClick={() => {
|
||||
appState.notes.selectNote(note.uuid, true);
|
||||
}}
|
||||
onContextMenu={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(note, e.clientX, e.clientY);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
148
app/assets/javascripts/components/NotesListItem.tsx
Normal file
148
app/assets/javascripts/components/NotesListItem.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
CollectionSort,
|
||||
sanitizeHtmlString,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
type Props = {
|
||||
note: SNNote;
|
||||
tags: string;
|
||||
hideDate: boolean;
|
||||
hidePreview: boolean;
|
||||
hideTags: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu: (e: MouseEvent) => void;
|
||||
selected: boolean;
|
||||
sortedBy?: CollectionSort;
|
||||
};
|
||||
|
||||
type NoteFlag = {
|
||||
text: string;
|
||||
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger';
|
||||
};
|
||||
|
||||
const flagsForNote = (note: SNNote) => {
|
||||
const flags = [] as NoteFlag[];
|
||||
if (note.pinned) {
|
||||
flags.push({
|
||||
text: 'Pinned',
|
||||
class: 'info',
|
||||
});
|
||||
}
|
||||
if (note.archived) {
|
||||
flags.push({
|
||||
text: 'Archived',
|
||||
class: 'warning',
|
||||
});
|
||||
}
|
||||
if (note.locked) {
|
||||
flags.push({
|
||||
text: 'Editing Disabled',
|
||||
class: 'neutral',
|
||||
});
|
||||
}
|
||||
if (note.trashed) {
|
||||
flags.push({
|
||||
text: 'Deleted',
|
||||
class: 'danger',
|
||||
});
|
||||
}
|
||||
if (note.conflictOf) {
|
||||
flags.push({
|
||||
text: 'Conflicted Copy',
|
||||
class: 'danger',
|
||||
});
|
||||
}
|
||||
if (note.errorDecrypting) {
|
||||
if (note.waitingForKey) {
|
||||
flags.push({
|
||||
text: 'Waiting For Keys',
|
||||
class: 'info',
|
||||
});
|
||||
} else {
|
||||
flags.push({
|
||||
text: 'Missing Keys',
|
||||
class: 'danger',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (note.deleted) {
|
||||
flags.push({
|
||||
text: 'Deletion Pending Sync',
|
||||
class: 'danger',
|
||||
});
|
||||
}
|
||||
return flags;
|
||||
};
|
||||
|
||||
export const NotesListItem: FunctionComponent<Props> = ({
|
||||
hideDate,
|
||||
hidePreview,
|
||||
hideTags,
|
||||
note,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
selected,
|
||||
sortedBy,
|
||||
tags,
|
||||
}) => {
|
||||
const flags = flagsForNote(note);
|
||||
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`note ${selected ? 'selected' : ''}`}
|
||||
id={`note-${note.uuid}`}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{flags && flags.length > 0 ? (
|
||||
<div className="note-flags flex flex-wrap">
|
||||
{flags.map((flag) => (
|
||||
<div className={`flag ${flag.class}`}>
|
||||
<div className="label">{flag.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="name">{note.title}</div>
|
||||
{!hidePreview && !note.hidePreview && !note.protected ? (
|
||||
<div className="note-preview">
|
||||
{note.preview_html ? (
|
||||
<div
|
||||
className="html-preview"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtmlString(note.preview_html),
|
||||
}}
|
||||
></div>
|
||||
) : null}
|
||||
{!note.preview_html && note.preview_plain ? (
|
||||
<div className="plain-preview">{note.preview_plain}</div>
|
||||
) : null}
|
||||
{!note.preview_html && !note.preview_plain ? (
|
||||
<div className="default-preview">{note.text}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!hideDate || note.protected ? (
|
||||
<div className="bottom-info faded">
|
||||
{note.protected ? (
|
||||
<span>Protected {hideDate ? '' : ' • '}</span>
|
||||
) : null}
|
||||
{!hideDate && showModifiedDate ? (
|
||||
<span>Modified {note.updatedAtString || 'Now'}</span>
|
||||
) : null}
|
||||
{!hideDate && !showModifiedDate ? (
|
||||
<span>{note.createdAtString || 'Now'}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!hideTags && (
|
||||
<div className="tags-string">
|
||||
<div className="faded">{tags}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,11 +10,11 @@ import { toDirective, useCloseOnClickOutside } from './utils';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
setShowMenuFalse: () => void;
|
||||
closeDisplayOptionsMenu: () => void;
|
||||
};
|
||||
|
||||
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
|
||||
({ setShowMenuFalse, application }) => {
|
||||
({ closeDisplayOptionsMenu, 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 \
|
||||
@@ -112,13 +112,13 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
|
||||
useCloseOnClickOutside(menuRef as any, (open: boolean) => {
|
||||
if (!open) {
|
||||
setShowMenuFalse();
|
||||
closeDisplayOptionsMenu();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={menuClassName}>
|
||||
<Menu a11yLabel="Sort by" closeMenu={setShowMenuFalse}>
|
||||
<Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}>
|
||||
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">
|
||||
Sort by
|
||||
</div>
|
||||
@@ -246,7 +246,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
export const NotesListOptionsDirective = toDirective<Props>(
|
||||
NotesListOptionsMenu,
|
||||
{
|
||||
setShowMenuFalse: '=',
|
||||
closeDisplayOptionsMenu: '=',
|
||||
state: '&',
|
||||
}
|
||||
);
|
||||
|
||||
256
app/assets/javascripts/components/NotesView.tsx
Normal file
256
app/assets/javascripts/components/NotesView.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
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';
|
||||
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 { NoAccountWarning } from './NoAccountWarning';
|
||||
import { NotesList } from './NotesList';
|
||||
import { NotesListOptionsMenu } from './NotesListOptionsMenu';
|
||||
import { PanelResizer } from './PanelResizer';
|
||||
import { SearchOptions } from './SearchOptions';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const NotesView: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
const notesViewPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
completedFullSync,
|
||||
createNewNote,
|
||||
displayOptions,
|
||||
noteFilterText,
|
||||
optionsSubtitle,
|
||||
panelTitle,
|
||||
renderedNotes,
|
||||
selectedNotes,
|
||||
setNoteFilterText,
|
||||
showDisplayOptionsMenu,
|
||||
toggleDisplayOptionsMenu,
|
||||
searchBarElement,
|
||||
selectNextNote,
|
||||
selectPreviousNote,
|
||||
onFilterEnter,
|
||||
handleFilterTextChanged,
|
||||
onSearchInputBlur,
|
||||
clearFilterText,
|
||||
paginate,
|
||||
} = appState.notesView;
|
||||
|
||||
useEffect(() => {
|
||||
handleFilterTextChanged();
|
||||
}, [noteFilterText, handleFilterTextChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
||||
* use Control modifier as well. These rules don't apply to desktop, but
|
||||
* probably better to be consistent.
|
||||
*/
|
||||
const newNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: 'n',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
|
||||
onKeyDown: (event) => {
|
||||
event.preventDefault();
|
||||
createNewNote();
|
||||
},
|
||||
});
|
||||
|
||||
const nextNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Down,
|
||||
elements: [
|
||||
document.body,
|
||||
...(searchBarElement ? [searchBarElement] : []),
|
||||
],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement === document.activeElement) {
|
||||
searchBarElement?.blur();
|
||||
}
|
||||
selectNextNote();
|
||||
},
|
||||
});
|
||||
|
||||
const previousNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Up,
|
||||
element: document.body,
|
||||
onKeyDown: () => {
|
||||
selectPreviousNote();
|
||||
},
|
||||
});
|
||||
|
||||
const searchKeyObserver = application.io.addKeyObserver({
|
||||
key: 'f',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement) {
|
||||
searchBarElement.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
newNoteKeyObserver();
|
||||
nextNoteKeyObserver();
|
||||
previousNoteKeyObserver();
|
||||
searchKeyObserver();
|
||||
};
|
||||
}, [
|
||||
application.io,
|
||||
createNewNote,
|
||||
searchBarElement,
|
||||
selectNextNote,
|
||||
selectPreviousNote,
|
||||
]);
|
||||
|
||||
const onNoteFilterTextChange = (e: Event) => {
|
||||
setNoteFilterText((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === KeyboardKey.Enter) {
|
||||
onFilterEnter();
|
||||
}
|
||||
};
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = (
|
||||
_w,
|
||||
_l,
|
||||
_mw,
|
||||
isCollapsed
|
||||
) => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed);
|
||||
};
|
||||
|
||||
const panelWidthEventCallback = () => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="notes-column"
|
||||
className="sn-component section notes"
|
||||
aria-label="Notes"
|
||||
ref={notesViewPanelRef}
|
||||
>
|
||||
<div className="content">
|
||||
<div id="notes-title-bar" className="section-title-bar">
|
||||
<div className="p-4">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h2 font-semibold title">{panelTitle}</div>
|
||||
<button
|
||||
className="sk-button contrast wide"
|
||||
title="Create a new note in the selected tag"
|
||||
aria-label="Create new note"
|
||||
onClick={() => createNewNote()}
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i className="ion-plus add-button" aria-hidden></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="filter-section" role="search">
|
||||
<input
|
||||
type="text"
|
||||
id="search-bar"
|
||||
className="filter-bar"
|
||||
placeholder="Search"
|
||||
title="Searches notes in the currently selected tag"
|
||||
value={noteFilterText}
|
||||
onChange={onNoteFilterTextChange}
|
||||
onKeyUp={onNoteFilterKeyUp}
|
||||
onBlur={() => onSearchInputBlur()}
|
||||
/>
|
||||
{noteFilterText ? (
|
||||
<button
|
||||
onClick={clearFilterText}
|
||||
aria-role="button"
|
||||
id="search-clear-button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : null}
|
||||
<div className="ml-2">
|
||||
<SearchOptions
|
||||
application={application}
|
||||
appState={appState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NoAccountWarning appState={appState} />
|
||||
</div>
|
||||
<div id="notes-menu-bar" className="sn-component">
|
||||
<div className="sk-app-bar no-edges">
|
||||
<div className="left">
|
||||
<div
|
||||
className={`sk-app-bar-item ${
|
||||
showDisplayOptionsMenu ? 'selected' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
toggleDisplayOptionsMenu(!showDisplayOptionsMenu)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{showDisplayOptionsMenu && (
|
||||
<NotesListOptionsMenu
|
||||
application={application}
|
||||
closeDisplayOptionsMenu={() =>
|
||||
toggleDisplayOptionsMenu(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{completedFullSync && !renderedNotes.length ? (
|
||||
<p className="empty-notes-list faded">No notes.</p>
|
||||
) : null}
|
||||
{!completedFullSync && !renderedNotes.length ? (
|
||||
<p className="empty-notes-list faded">Loading notes...</p>
|
||||
) : null}
|
||||
{renderedNotes.length ? (
|
||||
<NotesList
|
||||
notes={renderedNotes}
|
||||
selectedNotes={selectedNotes}
|
||||
appState={appState}
|
||||
displayOptions={displayOptions}
|
||||
paginate={paginate}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{notesViewPanelRef.current && (
|
||||
<PanelResizer
|
||||
application={application}
|
||||
collapsable={true}
|
||||
defaultWidth={300}
|
||||
panel={document.querySelector('notes-view') as HTMLDivElement}
|
||||
prefKey={PrefKey.NotesPanelWidth}
|
||||
side={PanelSide.Right}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NotesViewDirective = toDirective<Props>(NotesView);
|
||||
60
app/assets/javascripts/components/PanelResizer.tsx
Normal file
60
app/assets/javascripts/components/PanelResizer.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
const panelResizerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (panelResizerRef.current) {
|
||||
panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2);
|
||||
}
|
||||
}, [panelResizerState]);
|
||||
|
||||
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}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
app/assets/javascripts/components/Premium/index.ts
Normal file
1
app/assets/javascripts/components/Premium/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { usePremiumModal, PremiumModalProvider } from './usePremiumModal';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { useCallback, useContext, useState } from 'preact/hooks';
|
||||
import { createContext } from 'react';
|
||||
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
|
||||
|
||||
type PremiumModalContextData = {
|
||||
activate: (featureName: string) => void;
|
||||
};
|
||||
|
||||
const PremiumModalContext = createContext<PremiumModalContextData | null>(null);
|
||||
|
||||
const PremiumModalProvider_ = PremiumModalContext.Provider;
|
||||
|
||||
export const usePremiumModal = (): PremiumModalContextData => {
|
||||
const value = useContext(PremiumModalContext);
|
||||
|
||||
if (!value) {
|
||||
throw new Error('invalid PremiumModal context');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const PremiumModalProvider: FunctionalComponent = ({ children }) => {
|
||||
const [featureName, setFeatureName] = useState<null | string>(null);
|
||||
|
||||
const activate = setFeatureName;
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setFeatureName(null);
|
||||
}, [setFeatureName]);
|
||||
|
||||
const showModal = !!featureName;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showModal && (
|
||||
<PremiumFeaturesModal
|
||||
showModal={!!featureName}
|
||||
featureName={featureName}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
<PremiumModalProvider_ value={{ activate }}>
|
||||
{children}
|
||||
</PremiumModalProvider_>
|
||||
</>
|
||||
);
|
||||
};
|
||||
51
app/assets/javascripts/components/ProtectedNoteOverlay.tsx
Normal file
51
app/assets/javascripts/components/ProtectedNoteOverlay.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
onViewNote: () => void;
|
||||
hasProtectionSources: boolean;
|
||||
};
|
||||
|
||||
function ProtectedNoteOverlay({
|
||||
appState,
|
||||
onViewNote,
|
||||
hasProtectionSources,
|
||||
}: Props) {
|
||||
const instructionText = hasProtectionSources
|
||||
? 'Authenticate to view this note.'
|
||||
: 'Add a passcode or create an account to require authentication to view this note.';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center max-w-md">
|
||||
<h1 className="text-2xl m-0 w-full">This note is protected</h1>
|
||||
<p className="text-lg mt-2 w-full">{instructionText}</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
{!hasProtectionSources && (
|
||||
<button
|
||||
className="sn-button small info"
|
||||
onClick={() => {
|
||||
appState.accountMenu.setShow(true);
|
||||
}}
|
||||
>
|
||||
Open account menu
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="sn-button small outlined normal-focus-brightness"
|
||||
onClick={onViewNote}
|
||||
>
|
||||
{hasProtectionSources ? 'Authenticate' : 'View Note'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProtectedNoteOverlayDirective = toDirective<Props>(
|
||||
ProtectedNoteOverlay,
|
||||
{
|
||||
onViewNote: '&',
|
||||
hasProtectionSources: '=',
|
||||
}
|
||||
);
|
||||
@@ -1,8 +1,7 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FeatureIdentifier } from '@standardnotes/features';
|
||||
import { FeatureStatus } from '@standardnotes/snjs';
|
||||
import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
import { JSXInternal } from 'preact/src/jsx';
|
||||
import { Icon } from '../Icon';
|
||||
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';
|
||||
@@ -10,46 +9,48 @@ import { Switch } from '../Switch';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
closeQuickSettingsMenu: () => void;
|
||||
focusModeEnabled: boolean;
|
||||
setFocusModeEnabled: (enabled: boolean) => void;
|
||||
onToggle: (value: boolean) => void;
|
||||
onClose: () => void;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export const FocusModeSwitch: FunctionComponent<Props> = ({
|
||||
application,
|
||||
closeQuickSettingsMenu,
|
||||
focusModeEnabled,
|
||||
setFocusModeEnabled,
|
||||
onToggle,
|
||||
onClose,
|
||||
isEnabled,
|
||||
}) => {
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const isEntitledToFocusMode =
|
||||
const isEntitled =
|
||||
application.getFeatureStatus(FeatureIdentifier.FocusMode) ===
|
||||
FeatureStatus.Entitled;
|
||||
|
||||
const toggleFocusMode = (
|
||||
e: JSXInternal.TargetedMouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if (isEntitledToFocusMode) {
|
||||
setFocusModeEnabled(!focusModeEnabled);
|
||||
closeQuickSettingsMenu();
|
||||
} else {
|
||||
setShowUpgradeModal(true);
|
||||
}
|
||||
};
|
||||
const toggle = useCallback(
|
||||
(e: JSXInternal.TargetedMouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isEntitled) {
|
||||
onToggle(!isEnabled);
|
||||
onClose();
|
||||
} else {
|
||||
setShowUpgradeModal(true);
|
||||
}
|
||||
},
|
||||
[isEntitled, isEnabled, onToggle, setShowUpgradeModal, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
|
||||
onClick={toggleFocusMode}
|
||||
onClick={toggle}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="menu-close" className="color-neutral mr-2" />
|
||||
Focused Writing
|
||||
</div>
|
||||
{isEntitledToFocusMode ? (
|
||||
<Switch className="px-0" checked={focusModeEnabled} />
|
||||
{isEntitled ? (
|
||||
<Switch className="px-0" checked={isEnabled} />
|
||||
) : (
|
||||
<div title="Premium feature">
|
||||
<Icon type="premium-feature" />
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import {
|
||||
ContentType,
|
||||
SNTheme,
|
||||
ComponentArea,
|
||||
ContentType,
|
||||
SNComponent,
|
||||
SNTheme,
|
||||
} from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
@@ -174,7 +174,11 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
};
|
||||
|
||||
const toggleComponent = (component: SNComponent) => {
|
||||
application.toggleComponent(component);
|
||||
if (component.isTheme()) {
|
||||
application.toggleTheme(component);
|
||||
} else {
|
||||
application.toggleComponent(component);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBtnKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (
|
||||
@@ -218,7 +222,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
const activeTheme = themes.find(
|
||||
(theme) => theme.active && !theme.isLayerable()
|
||||
);
|
||||
if (activeTheme) application.toggleComponent(activeTheme);
|
||||
if (activeTheme) application.toggleTheme(activeTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -301,9 +305,9 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
|
||||
))}
|
||||
<FocusModeSwitch
|
||||
application={application}
|
||||
closeQuickSettingsMenu={closeQuickSettingsMenu}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
setFocusModeEnabled={setFocusModeEnabled}
|
||||
onToggle={setFocusModeEnabled}
|
||||
onClose={closeQuickSettingsMenu}
|
||||
isEnabled={focusModeEnabled}
|
||||
/>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<button
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ThemesMenuButton: FunctionComponent<Props> = ({
|
||||
const toggleTheme: JSXInternal.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
if (theme.isLayerable() || !theme.active) {
|
||||
application.toggleComponent(theme);
|
||||
application.toggleTheme(theme);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
64
app/assets/javascripts/components/RootTagDropZone.tsx
Normal file
64
app/assets/javascripts/components/RootTagDropZone.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
FeaturesState,
|
||||
TAG_FOLDERS_FEATURE_NAME,
|
||||
} 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';
|
||||
import { Icon } from './Icon';
|
||||
import { usePremiumModal } from './Premium';
|
||||
import { DropItem, DropProps, ItemTypes } from './TagsListItem';
|
||||
|
||||
type Props = {
|
||||
tagsState: TagsState;
|
||||
featuresState: FeaturesState;
|
||||
};
|
||||
|
||||
export const RootTagDropZone: React.FC<Props> = observer(
|
||||
({ tagsState, featuresState }) => {
|
||||
const premiumModal = usePremiumModal();
|
||||
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
|
||||
const hasFolders = tagsState.hasFolders;
|
||||
|
||||
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(),
|
||||
}),
|
||||
}),
|
||||
[tagsState, hasFolders, premiumModal]
|
||||
);
|
||||
|
||||
if (!isNativeFoldersEnabled || !hasFolders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={`root-drop ${canDrop ? 'active' : ''} ${
|
||||
isOver ? 'is-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,7 +1,7 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { Icon } from './Icon';
|
||||
import { toDirective, useCloseOnBlur } from './utils';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import {
|
||||
@@ -11,21 +11,17 @@ import {
|
||||
} from '@reach/disclosure';
|
||||
import { Switch } from './Switch';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
const SearchOptions = observer(({ appState }: Props) => {
|
||||
export const SearchOptions = observer(({ appState }: Props) => {
|
||||
const { searchOptions } = appState;
|
||||
|
||||
const {
|
||||
includeProtectedContents,
|
||||
includeArchived,
|
||||
includeTrashed,
|
||||
} = searchOptions;
|
||||
const { includeProtectedContents, includeArchived, includeTrashed } =
|
||||
searchOptions;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({
|
||||
@@ -35,7 +31,10 @@ const SearchOptions = observer(({ appState }: Props) => {
|
||||
const [maxWidth, setMaxWidth] = useState<number | 'auto'>('auto');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef as any, setOpen);
|
||||
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(
|
||||
panelRef as any,
|
||||
setOpen
|
||||
);
|
||||
|
||||
async function toggleIncludeProtectedContents() {
|
||||
setLockCloseOnBlur(true);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ComponentChildren, FunctionalComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { HTMLProps } from 'react';
|
||||
import {
|
||||
CustomCheckboxContainer,
|
||||
CustomCheckboxInput,
|
||||
CustomCheckboxInputProps,
|
||||
} from '@reach/checkbox';
|
||||
import '@reach/checkbox/styles.css';
|
||||
import { ComponentChildren, FunctionalComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { HTMLProps } from 'react';
|
||||
|
||||
export type SwitchProps = HTMLProps<HTMLInputElement> & {
|
||||
checked?: boolean;
|
||||
@@ -23,6 +23,10 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
const [checkedState, setChecked] = useState(props.checked || false);
|
||||
const checked = props.checked ?? checkedState;
|
||||
const className = props.className ?? '';
|
||||
|
||||
const isDisabled = !!props.disabled;
|
||||
const isActive = checked && !isDisabled;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className}`}
|
||||
@@ -35,7 +39,8 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
setChecked(event.target.checked);
|
||||
props.onChange?.(event.target.checked);
|
||||
}}
|
||||
className={`sn-switch ${checked ? 'bg-info' : 'bg-neutral'}`}
|
||||
className={`sn-switch ${isActive ? 'bg-info' : 'bg-neutral'}`}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<CustomCheckboxInput
|
||||
{...({
|
||||
|
||||
108
app/assets/javascripts/components/Tags/TagsSection.tsx
Normal file
108
app/assets/javascripts/components/Tags/TagsSection.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { TagsList } from '@/components/TagsList';
|
||||
import { toDirective } from '@/components/utils';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import {
|
||||
FeaturesState,
|
||||
TAG_FOLDERS_FEATURE_NAME,
|
||||
TAG_FOLDERS_FEATURE_TOOLTIP,
|
||||
} from '@/ui_models/app_state/features_state';
|
||||
import { Tooltip } from '@reach/tooltip';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { IconButton } from '../IconButton';
|
||||
import { PremiumModalProvider, usePremiumModal } from '../Premium';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const TagAddButton: FunctionComponent<{
|
||||
appState: AppState;
|
||||
features: FeaturesState;
|
||||
}> = observer(({ appState, features }) => {
|
||||
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
|
||||
|
||||
if (!isNativeFoldersEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon="add"
|
||||
title="Create a new tag"
|
||||
focusable={true}
|
||||
onClick={() => appState.createNewTag()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TagTitle: FunctionComponent<{
|
||||
features: FeaturesState;
|
||||
}> = observer(({ features }) => {
|
||||
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
|
||||
const hasFolders = 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) {
|
||||
return (
|
||||
<>
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Folders</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Tags</span>
|
||||
<Tooltip label={TAG_FOLDERS_FEATURE_TOOLTIP}>
|
||||
<label
|
||||
className="ml-1 sk-bold color-grey-2 cursor-pointer"
|
||||
onClick={showPremiumAlert}
|
||||
>
|
||||
Folders
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const TagsSection: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
return (
|
||||
<PremiumModalProvider>
|
||||
<section>
|
||||
<div className="tags-title-section section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<TagTitle features={appState.features} />
|
||||
<TagAddButton appState={appState} features={appState.features} />
|
||||
</div>
|
||||
</div>
|
||||
<TagsList application={application} appState={appState} />
|
||||
</section>
|
||||
</PremiumModalProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const TagsSectionDirective = toDirective<Props>(TagsSection);
|
||||
@@ -1,12 +1,18 @@
|
||||
import { PremiumModalProvider } from '@/components/Premium';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_DELETE_TAG } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { isMobile } from '@/utils';
|
||||
import { SNTag, TagMutator } from '@standardnotes/snjs';
|
||||
import { runInAction } from 'mobx';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { TouchBackend } from 'react-dnd-touch-backend';
|
||||
import { RootTagDropZone } from './RootTagDropZone';
|
||||
import { TagsListItem } from './TagsListItem';
|
||||
import { toDirective } from './utils';
|
||||
|
||||
@@ -28,8 +34,9 @@ const tagsWithOptionalTemplate = (
|
||||
export const TagsList: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
const templateTag = appState.templateTag;
|
||||
const tags = appState.tags.tags;
|
||||
const allTags = tagsWithOptionalTemplate(templateTag, tags);
|
||||
const rootTags = appState.tags.rootTags;
|
||||
|
||||
const allTags = tagsWithOptionalTemplate(templateTag, rootTags);
|
||||
|
||||
const selectTag = useCallback(
|
||||
(tag: SNTag) => {
|
||||
@@ -106,29 +113,39 @@ export const TagsList: FunctionComponent<Props> = observer(
|
||||
[appState]
|
||||
);
|
||||
|
||||
const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
|
||||
|
||||
return (
|
||||
<>
|
||||
{allTags.length === 0 ? (
|
||||
<div className="no-tags-placeholder">
|
||||
No tags. Create one using the add button above.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allTags.map((tag) => {
|
||||
return (
|
||||
<TagsListItem
|
||||
key={tag.uuid}
|
||||
tag={tag}
|
||||
selectTag={selectTag}
|
||||
saveTag={saveTag}
|
||||
removeTag={removeTag}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<PremiumModalProvider>
|
||||
<DndProvider backend={backend}>
|
||||
{allTags.length === 0 ? (
|
||||
<div className="no-tags-placeholder">
|
||||
No tags. Create one using the add button above.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allTags.map((tag) => {
|
||||
return (
|
||||
<TagsListItem
|
||||
level={0}
|
||||
key={tag.uuid}
|
||||
tag={tag}
|
||||
tagsState={appState.tags}
|
||||
selectTag={selectTag}
|
||||
saveTag={saveTag}
|
||||
removeTag={removeTag}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<RootTagDropZone
|
||||
tagsState={appState.tags}
|
||||
featuresState={appState.features}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DndProvider>
|
||||
</PremiumModalProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
import {
|
||||
FeaturesState,
|
||||
TAG_FOLDERS_FEATURE_NAME,
|
||||
} from '@/ui_models/app_state/features_state';
|
||||
import { TagsState } from '@/ui_models/app_state/tags_state';
|
||||
import '@reach/tooltip/styles.css';
|
||||
import { SNTag } from '@standardnotes/snjs';
|
||||
import { computed, runInAction } from 'mobx';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent, JSX } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { Icon } from './Icon';
|
||||
import { usePremiumModal } from './Premium';
|
||||
|
||||
export enum ItemTypes {
|
||||
TAG = 'TAG',
|
||||
}
|
||||
|
||||
export type DropItemTag = { uuid: string };
|
||||
|
||||
export type DropItem = DropItemTag;
|
||||
|
||||
export type DropProps = { isOver: boolean; canDrop: boolean };
|
||||
|
||||
type Props = {
|
||||
tag: SNTag;
|
||||
tagsState: TagsState;
|
||||
selectTag: (tag: SNTag) => void;
|
||||
removeTag: (tag: SNTag) => void;
|
||||
saveTag: (tag: SNTag, newTitle: string) => void;
|
||||
appState: TagsListState;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export type TagsListState = {
|
||||
readonly selectedTag: SNTag | undefined;
|
||||
tags: TagsState;
|
||||
editingTag: SNTag | undefined;
|
||||
features: FeaturesState;
|
||||
};
|
||||
|
||||
export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
({ tag, selectTag, saveTag, removeTag, appState }) => {
|
||||
({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => {
|
||||
const [title, setTitle] = useState(tag.title || '');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -28,10 +49,36 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
const isEditing = appState.editingTag === tag;
|
||||
const noteCounts = computed(() => appState.tags.getNotesCount(tag));
|
||||
|
||||
const childrenTags = computed(() => tagsState.getChildren(tag)).get();
|
||||
const hasChildren = childrenTags.length > 0;
|
||||
|
||||
const hasFolders = tagsState.hasFolders;
|
||||
const isNativeFoldersEnabled = appState.features.enableNativeFoldersFeature;
|
||||
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
|
||||
const premiumModal = usePremiumModal();
|
||||
|
||||
const [showChildren, setShowChildren] = useState(hasChildren);
|
||||
const [hadChildren, setHadChildren] = useState(hasChildren);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hadChildren && hasChildren) {
|
||||
setShowChildren(true);
|
||||
}
|
||||
setHadChildren(hasChildren);
|
||||
}, [hadChildren, hasChildren]);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(tag.title || '');
|
||||
}, [setTitle, tag]);
|
||||
|
||||
const toggleChildren = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowChildren((x) => !x);
|
||||
},
|
||||
[setShowChildren]
|
||||
);
|
||||
|
||||
const selectCurrentTag = useCallback(() => {
|
||||
if (isEditing || isSelected) {
|
||||
return;
|
||||
@@ -41,7 +88,8 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
saveTag(tag, title);
|
||||
}, [tag, saveTag, title]);
|
||||
setTitle(tag.title);
|
||||
}, [tag, saveTag, title, setTitle]);
|
||||
|
||||
const onInput = useCallback(
|
||||
(e: JSX.TargetedEvent<HTMLInputElement>) => {
|
||||
@@ -81,56 +129,142 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
removeTag(tag);
|
||||
}, [removeTag, tag]);
|
||||
|
||||
const [, dragRef] = useDrag(
|
||||
() => ({
|
||||
type: ItemTypes.TAG,
|
||||
item: { uuid: tag.uuid },
|
||||
canDrag: () => {
|
||||
return isNativeFoldersEnabled;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
}),
|
||||
[tag, hasFolders]
|
||||
);
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
|
||||
() => ({
|
||||
accept: ItemTypes.TAG,
|
||||
canDrop: (item) => {
|
||||
return tagsState.isValidTagParent(tag.uuid, item.uuid);
|
||||
},
|
||||
drop: (item) => {
|
||||
if (!hasFolders) {
|
||||
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME);
|
||||
return;
|
||||
}
|
||||
tagsState.assignParent(item.uuid, tag.uuid);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
canDrop: !!monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[tag, tagsState, hasFolders, premiumModal]
|
||||
);
|
||||
|
||||
const readyToDrop = isOver && canDrop;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tag ${isSelected ? 'selected' : ''}`}
|
||||
onClick={selectCurrentTag}
|
||||
>
|
||||
{!tag.errorDecrypting ? (
|
||||
<div className="tag-info">
|
||||
<div className="tag-icon">#</div>
|
||||
<input
|
||||
className={`title ${isEditing ? 'editing' : ''}`}
|
||||
id={`react-tag-${tag.uuid}`}
|
||||
onBlur={onBlur}
|
||||
onInput={onInput}
|
||||
value={title}
|
||||
onKeyUp={onKeyUp}
|
||||
spellCheck={false}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div className="count">{noteCounts.get()}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{tag.conflictOf && (
|
||||
<div className="danger small-text font-bold">
|
||||
Conflicted Copy {tag.conflictOf}
|
||||
</div>
|
||||
)}
|
||||
{tag.errorDecrypting && !tag.waitingForKey && (
|
||||
<div className="danger small-text font-bold">Missing Keys</div>
|
||||
)}
|
||||
{tag.errorDecrypting && tag.waitingForKey && (
|
||||
<div className="info small-text font-bold">Waiting For Keys</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="menu">
|
||||
{!isEditing && (
|
||||
<a className="item" onClick={onClickRename}>
|
||||
Rename
|
||||
</a>
|
||||
<>
|
||||
<div
|
||||
className={`tag ${isSelected ? 'selected' : ''} ${
|
||||
readyToDrop ? 'is-drag-over' : ''
|
||||
}`}
|
||||
onClick={selectCurrentTag}
|
||||
ref={dragRef}
|
||||
style={{ paddingLeft: `${level * 21 + 10}px` }}
|
||||
>
|
||||
{!tag.errorDecrypting ? (
|
||||
<div className="tag-info" title={title} ref={dropRef}>
|
||||
{hasFolders && isNativeFoldersEnabled && hasAtLeastOneFolder && (
|
||||
<div
|
||||
className={`tag-fold ${showChildren ? 'opened' : 'closed'}`}
|
||||
onClick={hasChildren ? toggleChildren : undefined}
|
||||
>
|
||||
<Icon
|
||||
className={`color-neutral ${!hasChildren ? 'hidden' : ''}`}
|
||||
type={
|
||||
showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`tag-icon ${
|
||||
isNativeFoldersEnabled ? 'draggable' : ''
|
||||
} mr-1`}
|
||||
ref={dragRef}
|
||||
>
|
||||
<Icon
|
||||
type="hashtag"
|
||||
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className={`title ${isEditing ? 'editing' : ''}`}
|
||||
id={`react-tag-${tag.uuid}`}
|
||||
onBlur={onBlur}
|
||||
onInput={onInput}
|
||||
value={title}
|
||||
onKeyUp={onKeyUp}
|
||||
spellCheck={false}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div className="count">{noteCounts.get()}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
|
||||
{tag.conflictOf && (
|
||||
<div className="danger small-text font-bold">
|
||||
Conflicted Copy {tag.conflictOf}
|
||||
</div>
|
||||
)}
|
||||
{isEditing && (
|
||||
<a className="item" onClick={onClickSave}>
|
||||
Save
|
||||
</a>
|
||||
{tag.errorDecrypting && !tag.waitingForKey && (
|
||||
<div className="danger small-text font-bold">Missing Keys</div>
|
||||
)}
|
||||
{tag.errorDecrypting && tag.waitingForKey && (
|
||||
<div className="info small-text font-bold">Waiting For Keys</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="menu">
|
||||
{!isEditing && (
|
||||
<a className="item" onClick={onClickRename}>
|
||||
Rename
|
||||
</a>
|
||||
)}
|
||||
{isEditing && (
|
||||
<a className="item" onClick={onClickSave}>
|
||||
Save
|
||||
</a>
|
||||
)}
|
||||
<a className="item" onClick={onClickDelete}>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<a className="item" onClick={onClickDelete}>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{showChildren && (
|
||||
<>
|
||||
{childrenTags.map((tag) => {
|
||||
return (
|
||||
<TagsListItem
|
||||
level={level + 1}
|
||||
key={tag.uuid}
|
||||
tag={tag}
|
||||
tagsState={tagsState}
|
||||
selectTag={selectTag}
|
||||
saveTag={saveTag}
|
||||
removeTag={removeTag}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FunctionComponent, h, render } from 'preact';
|
||||
import { unmountComponentAtNode } from 'preact/compat';
|
||||
import { StateUpdater, useCallback, useState } from 'preact/hooks';
|
||||
import { useEffect } from 'react';
|
||||
import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
|
||||
|
||||
/**
|
||||
* @returns a callback that will close a dropdown if none of its children has
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent, LiveItem } from '@standardnotes/snjs';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/component-modal.pug';
|
||||
|
||||
export type ComponentModalScope = {
|
||||
componentUuid: string
|
||||
onDismiss: () => void
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export class ComponentModalCtrl implements ComponentModalScope {
|
||||
$element: JQLite
|
||||
componentUuid!: string
|
||||
onDismiss!: () => void
|
||||
application!: WebApplication
|
||||
liveComponent!: LiveItem<SNComponent>
|
||||
component!: SNComponent
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.liveComponent = new LiveItem(
|
||||
this.componentUuid,
|
||||
this.application,
|
||||
(component) => {
|
||||
this.component = component;
|
||||
}
|
||||
);
|
||||
this.application.componentGroup.activateComponent(this.component);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.application.componentGroup.deactivateComponent(this.component);
|
||||
this.liveComponent.deinit();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.onDismiss && this.onDismiss();
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = ComponentModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
componentUuid: '=',
|
||||
onDismiss: '&',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export { ActionsMenu } from './actionsMenu';
|
||||
export { ComponentModal } from './componentModal';
|
||||
export { EditorMenu } from './editorMenu';
|
||||
export { InputModal } from './inputModal';
|
||||
export { MenuRow } from './menuRow';
|
||||
|
||||
@@ -3,14 +3,14 @@ import angular from 'angular';
|
||||
import template from '%/directives/panel-resizer.pug';
|
||||
import { debounce } from '@/utils';
|
||||
|
||||
enum PanelSide {
|
||||
export enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left'
|
||||
Left = 'left',
|
||||
}
|
||||
enum MouseEventType {
|
||||
Move = 'mousemove',
|
||||
Down = 'mousedown',
|
||||
Up = 'mouseup'
|
||||
Up = 'mouseup',
|
||||
}
|
||||
enum CssClass {
|
||||
Hoverable = 'hoverable',
|
||||
@@ -22,64 +22,63 @@ enum CssClass {
|
||||
}
|
||||
const WINDOW_EVENT_RESIZE = 'resize';
|
||||
|
||||
type ResizeFinishCallback = (
|
||||
export type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => void
|
||||
) => 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
|
||||
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
|
||||
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
|
||||
$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,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
this.$compile = $compile;
|
||||
this.$element = $element;
|
||||
@@ -109,7 +108,10 @@ class PanelResizerCtrl implements PanelResizerScope {
|
||||
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.resizerColumn.removeEventListener(
|
||||
MouseEventType.Down,
|
||||
this.onMouseDown
|
||||
);
|
||||
(this.handleResize as any) = undefined;
|
||||
(this.onMouseMove as any) = undefined;
|
||||
(this.onMouseUp as any) = undefined;
|
||||
@@ -140,7 +142,7 @@ class PanelResizerCtrl implements PanelResizerScope {
|
||||
return;
|
||||
}
|
||||
this.resizerColumn = this.$element[0];
|
||||
this.currentMinWidth = this.minWidth || (this.resizerColumn.offsetWidth + 2);
|
||||
this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth + 2;
|
||||
this.pressed = false;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.lastDownX = 0;
|
||||
@@ -313,7 +315,8 @@ class PanelResizerCtrl implements PanelResizerScope {
|
||||
width = parentRect.width;
|
||||
}
|
||||
|
||||
const maxWidth = this.appFrame!.width - this.panel.getBoundingClientRect().x;
|
||||
const maxWidth =
|
||||
this.appFrame!.width - this.panel.getBoundingClientRect().x;
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
}
|
||||
@@ -356,7 +359,9 @@ class PanelResizerCtrl implements PanelResizerScope {
|
||||
if (this.overlay) {
|
||||
return;
|
||||
}
|
||||
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(this as any);
|
||||
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(
|
||||
this as any
|
||||
);
|
||||
angular.element(document.body).prepend(this.overlay);
|
||||
}
|
||||
|
||||
@@ -395,7 +400,7 @@ export class PanelResizer extends WebDirective {
|
||||
onResizeFinish: '&',
|
||||
onWidthEvent: '&',
|
||||
panelId: '=',
|
||||
property: '='
|
||||
property: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { WebDirective } from './../../types';
|
||||
import template from '%/directives/permissions-modal.pug';
|
||||
|
||||
class PermissionsModalCtrl {
|
||||
|
||||
$element: JQLite
|
||||
callback!: (success: boolean) => void
|
||||
$element: JQLite;
|
||||
callback!: (success: boolean) => void;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
@@ -41,7 +40,7 @@ export class PermissionsModal extends WebDirective {
|
||||
show: '=',
|
||||
component: '=',
|
||||
permissionsString: '=',
|
||||
callback: '='
|
||||
callback: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
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,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
ComponentArea
|
||||
} from '@standardnotes/snjs';
|
||||
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
|
||||
uuid: string;
|
||||
content: PayloadContent;
|
||||
application: WebApplication;
|
||||
}
|
||||
|
||||
class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewScope {
|
||||
type State = {
|
||||
componentViewer?: ComponentViewer;
|
||||
};
|
||||
|
||||
$element: JQLite
|
||||
$timeout: ng.ITimeoutService
|
||||
uuid!: string
|
||||
content!: PayloadContent
|
||||
title?: string
|
||||
application!: WebApplication
|
||||
unregisterComponent?: any
|
||||
note!: SNNote
|
||||
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
|
||||
) {
|
||||
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
@@ -43,53 +40,36 @@ class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewSc
|
||||
|
||||
$onInit() {
|
||||
this.configure();
|
||||
super.$onInit();
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
if (this.unregisterComponent) {
|
||||
this.unregisterComponent();
|
||||
this.unregisterComponent = undefined;
|
||||
if (this.state.componentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
this.state.componentViewer
|
||||
);
|
||||
}
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
get componentManager() {
|
||||
return this.application.componentManager!;
|
||||
return this.application.componentManager;
|
||||
}
|
||||
|
||||
async configure() {
|
||||
this.note = await this.application.createTemplateItem(
|
||||
this.note = (await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
this.content
|
||||
) as SNNote;
|
||||
)) as SNNote;
|
||||
this.originalNote = this.application.findItem(this.uuid) as SNNote;
|
||||
const editorForNote = this.componentManager.editorForNote(this.originalNote);
|
||||
if (editorForNote) {
|
||||
/**
|
||||
* Create temporary copy, as a lot of componentManager is uuid based, so might
|
||||
* interfere with active editor. Be sure to copy only the content, as the top level
|
||||
* editor object has non-copyable properties like .window, which cannot be transfered
|
||||
*/
|
||||
const editorCopy = await this.application.createTemplateItem(
|
||||
ContentType.Component,
|
||||
editorForNote.safeContent
|
||||
) as SNComponent;
|
||||
this.componentManager.setReadonlyStateForComponent(editorCopy, true, true);
|
||||
this.unregisterComponent = this.componentManager.registerHandler({
|
||||
identifier: editorCopy.uuid,
|
||||
areas: [ComponentArea.Editor],
|
||||
contextRequestHandler: (componentUuid) => {
|
||||
if (componentUuid === this.state.editor?.uuid) {
|
||||
return this.note;
|
||||
}
|
||||
},
|
||||
componentForSessionKeyHandler: (key) => {
|
||||
if (key === this.componentManager.sessionKeyForComponent(this.state.editor!)) {
|
||||
return this.state.editor;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({editor: editorCopy});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +78,19 @@ class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewSc
|
||||
if (asCopy) {
|
||||
await this.application.duplicateItem(this.originalNote, {
|
||||
...this.content,
|
||||
title: this.content.title ? this.content.title + ' (copy)' : undefined
|
||||
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.application.changeAndSaveItem(
|
||||
this.uuid,
|
||||
(mutator) => {
|
||||
mutator.unsafe_setCustomContent(this.content);
|
||||
},
|
||||
true,
|
||||
PayloadSource.RemoteActionRetrieved
|
||||
);
|
||||
}
|
||||
this.dismiss();
|
||||
};
|
||||
@@ -115,7 +102,7 @@ class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewSc
|
||||
}
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
confirmButtonStyle: "danger"
|
||||
confirmButtonStyle: 'danger',
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
run();
|
||||
@@ -146,7 +133,7 @@ export class RevisionPreviewModal extends WebDirective {
|
||||
uuid: '=',
|
||||
content: '=',
|
||||
title: '=',
|
||||
application: '='
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import '../stylesheets/index.css.scss';
|
||||
|
||||
// Vendor
|
||||
import 'angular';
|
||||
import '../../../vendor/assets/javascripts/angular-sanitize';
|
||||
import '../../../vendor/assets/javascripts/zip/deflate';
|
||||
import '../../../vendor/assets/javascripts/zip/inflate';
|
||||
import '../../../vendor/assets/javascripts/zip/zip';
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const pathsToModuleNameMapper = require('ts-jest/utils').pathsToModuleNameMapper;
|
||||
const pathsToModuleNameMapper =
|
||||
require('ts-jest/utils').pathsToModuleNameMapper;
|
||||
const tsConfig = require('./tsconfig.json');
|
||||
|
||||
const pathsFromTsconfig = tsConfig.compilerOptions.paths;
|
||||
|
||||
module.exports = {
|
||||
restoreMocks: true,
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
moduleNameMapper: {
|
||||
...pathsToModuleNameMapper(pathsFromTsconfig, {
|
||||
prefix: '<rootDir>',
|
||||
@@ -14,7 +17,6 @@ module.exports = {
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
},
|
||||
globals: {
|
||||
window: {},
|
||||
__VERSION__: '1.0.0',
|
||||
__DESKTOP__: false,
|
||||
__WEB__: true,
|
||||
|
||||
115
app/assets/javascripts/preferences/panes/CloudLink.tsx
Normal file
115
app/assets/javascripts/preferences/panes/CloudLink.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { FunctionComponent } from 'preact';
|
||||
import {
|
||||
Title,
|
||||
Subtitle,
|
||||
Text,
|
||||
LinkButton,
|
||||
PreferencesGroup,
|
||||
PreferencesPane,
|
||||
PreferencesSegment,
|
||||
} from '../components';
|
||||
|
||||
export const CloudLink: FunctionComponent = () => (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Frequently asked questions</Title>
|
||||
<div className="h-2 w-full" />
|
||||
<Subtitle>Who can read my private notes?</Subtitle>
|
||||
<Text>
|
||||
Quite simply: no one but you. Not us, not your ISP, not a hacker, and
|
||||
not a government agency. As long as you keep your password safe, and
|
||||
your password is reasonably strong, then you are the only person in
|
||||
the world with the ability to decrypt your notes. For more on how we
|
||||
handle your privacy and security, check out our easy to read{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/privacy">
|
||||
Privacy Manifesto.
|
||||
</a>
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can I collaborate with others on a note?</Subtitle>
|
||||
<Text>
|
||||
Because of our encrypted architecture, Standard Notes does not
|
||||
currently provide a real-time collaboration solution. Multiple users
|
||||
can share the same account however, but editing at the same time may
|
||||
result in sync conflicts, which may result in the duplication of
|
||||
notes.
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can I use Standard Notes totally offline?</Subtitle>
|
||||
<Text>
|
||||
Standard Notes can be used totally offline without an account, and
|
||||
without an internet connection. You can find{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://standardnotes.com/help/59/can-i-use-standard-notes-totally-offline"
|
||||
>
|
||||
more details here.
|
||||
</a>
|
||||
</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle>Can’t find your question here?</Subtitle>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
label="Open FAQ"
|
||||
link="https://standardnotes.com/help"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Community forum</Title>
|
||||
<Text>
|
||||
If you have an issue, found a bug or want to suggest a feature, you
|
||||
can browse or post to the forum. It’s recommended for non-account
|
||||
related issues. Please read our{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/longevity/">
|
||||
Longevity statement
|
||||
</a>{' '}
|
||||
before advocating for a feature request.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
label="Go to the forum"
|
||||
link="https://forum.standardnotes.org/"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Community groups</Title>
|
||||
<Text>
|
||||
Want to meet other passionate note-takers and privacy enthusiasts?
|
||||
Want to share your feedback with us? Join the Standard Notes community
|
||||
groups for discussions on security, themes, editors and more.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
link="https://standardnotes.com/slack"
|
||||
label="Join our Slack"
|
||||
/>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
link="https://standardnotes.com/discord"
|
||||
label="Join our Discord"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Account related issue?</Title>
|
||||
<Text>
|
||||
Send an email to help@standardnotes.com and we’ll sort it out.
|
||||
</Text>
|
||||
<LinkButton
|
||||
className="mt-3"
|
||||
link="mailto: help@standardnotes.com"
|
||||
label="Email us"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
);
|
||||
@@ -1,12 +1,13 @@
|
||||
import { PreferencesGroup, PreferencesSegment } from "@/preferences/components";
|
||||
import { WebApplication } from "@/ui_models/application";
|
||||
import { SNComponent } from "@standardnotes/snjs/dist/@types";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { FunctionComponent } from "preact";
|
||||
import { ExtensionItem } from "./extensions-segments";
|
||||
import { PreferencesGroup, PreferencesSegment } from '@/preferences/components';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ComponentViewer, SNComponent } from '@standardnotes/snjs/dist/@types';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { ExtensionItem } from './extensions-segments';
|
||||
import { ComponentView } from '@/components/ComponentView';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { PreferencesMenu } from '@/preferences/PreferencesMenu';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
interface IProps {
|
||||
application: WebApplication;
|
||||
@@ -17,7 +18,17 @@ interface IProps {
|
||||
|
||||
export const ExtensionPane: FunctionComponent<IProps> = observer(
|
||||
({ extension, application, appState, preferencesMenu }) => {
|
||||
const latestVersion = preferencesMenu.extensionsLatestVersions.getVersion(extension);
|
||||
const [componentViewer] = useState<ComponentViewer>(
|
||||
application.componentManager.createComponentViewer(extension)
|
||||
);
|
||||
const latestVersion =
|
||||
preferencesMenu.extensionsLatestVersions.getVersion(extension);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
application.componentManager.destroyComponentViewer(componentViewer);
|
||||
};
|
||||
}, [application, componentViewer]);
|
||||
|
||||
return (
|
||||
<div className="preferences-extension-pane color-foreground flex-grow flex flex-row overflow-y-auto min-h-0">
|
||||
@@ -28,15 +39,18 @@ export const ExtensionPane: FunctionComponent<IProps> = observer(
|
||||
application={application}
|
||||
extension={extension}
|
||||
first={false}
|
||||
uninstall={() => application.deleteItem(extension).then(() => preferencesMenu.loadExtensionsPanes())}
|
||||
toggleActivate={() => application.toggleComponent(extension).then(() => preferencesMenu.loadExtensionsPanes())}
|
||||
uninstall={() =>
|
||||
application
|
||||
.deleteItem(extension)
|
||||
.then(() => preferencesMenu.loadExtensionsPanes())
|
||||
}
|
||||
latestVersion={latestVersion}
|
||||
/>
|
||||
<PreferencesSegment>
|
||||
<ComponentView
|
||||
application={application}
|
||||
appState={appState}
|
||||
componentUuid={extension.uuid}
|
||||
componentViewer={componentViewer}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
@@ -44,4 +58,5 @@ export const ExtensionPane: FunctionComponent<IProps> = observer(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -77,11 +77,6 @@ export const Extensions: FunctionComponent<{
|
||||
setExtensions(loadExtensions(application));
|
||||
};
|
||||
|
||||
const toggleActivateExtension = (extension: SNComponent) => {
|
||||
application.toggleComponent(extension);
|
||||
setExtensions(loadExtensions(application));
|
||||
};
|
||||
|
||||
const visibleExtensions = extensions.filter((extension) => {
|
||||
return (
|
||||
extension.package_info != undefined &&
|
||||
@@ -105,7 +100,6 @@ export const Extensions: FunctionComponent<{
|
||||
latestVersion={extensionsLatestVersions.getVersion(extension)}
|
||||
first={i === 0}
|
||||
uninstall={uninstallExtension}
|
||||
toggleActivate={toggleActivateExtension}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/Button';
|
||||
import { SyncQueueStrategy, dateToLocalizedString } from '@standardnotes/snjs';
|
||||
import { STRING_GENERIC_SYNC_ERROR } from '@/strings';
|
||||
import { useState } from '@node_modules/preact/hooks';
|
||||
import { observer } from '@node_modules/mobx-react-lite';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useCallback, useState } from 'preact/hooks';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { ApplicationEvent } from '@standardnotes/snjs';
|
||||
import { isSameDay } from '@/utils';
|
||||
import { PreferencesGroup, PreferencesSegment, Title, Text } from '@/preferences/components';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
Text,
|
||||
} from '@/preferences/components';
|
||||
import { Button } from '@/components/Button';
|
||||
|
||||
type Props = {
|
||||
@@ -16,7 +21,9 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
|
||||
application.clearProtectionSession();
|
||||
};
|
||||
|
||||
const [hasProtections, setHasProtections] = useState(() => application.hasProtectionSources());
|
||||
const [hasProtections, setHasProtections] = useState(() =>
|
||||
application.hasProtectionSources()
|
||||
);
|
||||
|
||||
const getProtectionsDisabledUntil = useCallback((): string | null => {
|
||||
const protectionExpiry = application.getProtectionSessionExpiryDate();
|
||||
@@ -26,7 +33,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
|
||||
if (isSameDay(protectionExpiry, now)) {
|
||||
f = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
minute: 'numeric',
|
||||
});
|
||||
} else {
|
||||
f = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -34,7 +41,7 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
minute: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,14 +50,23 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
|
||||
return null;
|
||||
}, [application]);
|
||||
|
||||
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(getProtectionsDisabledUntil());
|
||||
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = useState(
|
||||
getProtectionsDisabledUntil()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const removeProtectionSessionExpiryDateChangedObserver = application.addEventObserver(
|
||||
const removeUnprotectedSessionBeginObserver = application.addEventObserver(
|
||||
async () => {
|
||||
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
|
||||
},
|
||||
ApplicationEvent.ProtectionSessionExpiryDateChanged
|
||||
ApplicationEvent.UnprotectedSessionBegan
|
||||
);
|
||||
|
||||
const removeUnprotectedSessionEndObserver = application.addEventObserver(
|
||||
async () => {
|
||||
setProtectionsDisabledUntil(getProtectionsDisabledUntil());
|
||||
},
|
||||
ApplicationEvent.UnprotectedSessionExpired
|
||||
);
|
||||
|
||||
const removeKeyStatusChangedObserver = application.addEventObserver(
|
||||
@@ -61,7 +77,8 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeProtectionSessionExpiryDateChangedObserver();
|
||||
removeUnprotectedSessionBeginObserver();
|
||||
removeUnprotectedSessionEndObserver();
|
||||
removeKeyStatusChangedObserver();
|
||||
};
|
||||
}, [application, getProtectionsDisabledUntil]);
|
||||
@@ -74,19 +91,28 @@ export const Protections: FunctionalComponent<Props> = ({ application }) => {
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Protections</Title>
|
||||
{protectionsDisabledUntil
|
||||
? <Text className="info">Protections are disabled until {protectionsDisabledUntil}.</Text>
|
||||
: <Text className="info">Protections are enabled.</Text>
|
||||
}
|
||||
{protectionsDisabledUntil ? (
|
||||
<Text className="info">
|
||||
Unprotected access expires at {protectionsDisabledUntil}.
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="info">Protections are enabled.</Text>
|
||||
)}
|
||||
<Text className="mt-2">
|
||||
Actions like viewing protected notes, exporting decrypted backups,
|
||||
or revoking an active session, require additional authentication
|
||||
like entering your account password or application passcode.
|
||||
Actions like viewing or searching protected notes, exporting decrypted
|
||||
backups, or revoking an active session require additional
|
||||
authentication such as entering your account password or application
|
||||
passcode.
|
||||
</Text>
|
||||
{protectionsDisabledUntil &&
|
||||
<Button className="mt-3" type="primary" label="Enable Protections" onClick={enableProtections} />
|
||||
}
|
||||
{protectionsDisabledUntil && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
type="primary"
|
||||
label="End Unprotected Access"
|
||||
onClick={enableProtections}
|
||||
/>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup >
|
||||
</PreferencesGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useState, useRef, useEffect, MouseEventHandler } from 'react';
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
|
||||
const DisclosureIconButton: FunctionComponent<{
|
||||
className?: string;
|
||||
@@ -16,8 +17,9 @@ const DisclosureIconButton: FunctionComponent<{
|
||||
<DisclosureButton
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${className ?? ''
|
||||
}`}
|
||||
className={`no-border cursor-pointer bg-transparent hover:brightness-130 p-0 ${
|
||||
className ?? ''
|
||||
}`}
|
||||
>
|
||||
<Icon type={icon} />
|
||||
</DisclosureButton>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PurchaseFlowView: FunctionComponent<PurchaseFlowViewProps> =
|
||||
const { currentPane } = appState.purchaseFlow;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center overflow-hidden h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-2">
|
||||
<div className="flex items-center justify-center overflow-hidden h-full w-full absolute top-left-0 z-index-purchase-flow bg-grey-super-light">
|
||||
<div className="relative fit-content">
|
||||
<div className="relative p-12 xs:px-8 mb-4 bg-default border-1 border-solid border-main rounded xs:rounded-0">
|
||||
<SNLogoFull className="mb-5" />
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
import {
|
||||
SNComponent,
|
||||
PurePayload,
|
||||
ComponentMutator,
|
||||
AppDataField,
|
||||
EncryptionIntent,
|
||||
ApplicationService,
|
||||
ApplicationEvent,
|
||||
removeFromArray,
|
||||
BackupFile,
|
||||
DesktopManagerInterface,
|
||||
} from '@standardnotes/snjs';
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
// An interface used by the Desktop app to interact with SN
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { Bridge } from './bridge';
|
||||
|
||||
type UpdateObserverCallback = (component: SNComponent) => void;
|
||||
type ComponentActivationCallback = (payload: PurePayload) => void;
|
||||
type ComponentActivationObserver = {
|
||||
id: string;
|
||||
callback: ComponentActivationCallback;
|
||||
};
|
||||
|
||||
export class DesktopManager extends ApplicationService {
|
||||
/**
|
||||
* An interface used by the Desktop application to interact with SN
|
||||
*/
|
||||
export class DesktopManager
|
||||
extends ApplicationService
|
||||
implements DesktopManagerInterface
|
||||
{
|
||||
$rootScope: ng.IRootScopeService;
|
||||
$timeout: ng.ITimeoutService;
|
||||
componentActivationObservers: ComponentActivationObserver[] = [];
|
||||
updateObservers: {
|
||||
callback: UpdateObserverCallback;
|
||||
callback: (component: SNComponent) => void;
|
||||
}[] = [];
|
||||
|
||||
isDesktop = isDesktopApplication();
|
||||
|
||||
dataLoaded = false;
|
||||
lastSearchedText?: string;
|
||||
private removeComponentObserver?: () => void;
|
||||
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
@@ -52,10 +46,7 @@ export class DesktopManager extends ApplicationService {
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.componentActivationObservers.length = 0;
|
||||
this.updateObservers.length = 0;
|
||||
this.removeComponentObserver?.();
|
||||
this.removeComponentObserver = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
@@ -73,9 +64,9 @@ export class DesktopManager extends ApplicationService {
|
||||
this.bridge.onMajorDataChange();
|
||||
}
|
||||
|
||||
getExtServerHost() {
|
||||
getExtServerHost(): string {
|
||||
console.assert(!!this.bridge.extensionsServerHost, 'extServerHost is null');
|
||||
return this.bridge.extensionsServerHost;
|
||||
return this.bridge.extensionsServerHost!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +74,7 @@ export class DesktopManager extends ApplicationService {
|
||||
* Keys are not passed into ItemParams, so the result is not encrypted
|
||||
*/
|
||||
convertComponentForTransmission(component: SNComponent) {
|
||||
return this.application!.protocolService!.payloadByEncryptingPayload(
|
||||
return this.application.protocolService!.payloadByEncryptingPayload(
|
||||
component.payloadRepresentation(),
|
||||
EncryptionIntent.FileDecrypted
|
||||
);
|
||||
@@ -107,7 +98,7 @@ export class DesktopManager extends ApplicationService {
|
||||
});
|
||||
}
|
||||
|
||||
registerUpdateObserver(callback: UpdateObserverCallback) {
|
||||
registerUpdateObserver(callback: (component: SNComponent) => void) {
|
||||
const observer = {
|
||||
callback: callback,
|
||||
};
|
||||
@@ -143,11 +134,11 @@ export class DesktopManager extends ApplicationService {
|
||||
componentData: any,
|
||||
error: any
|
||||
) {
|
||||
const component = this.application!.findItem(componentData.uuid);
|
||||
const component = this.application.findItem(componentData.uuid);
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
const updatedComponent = await this.application!.changeAndSaveItem(
|
||||
const updatedComponent = await this.application.changeAndSaveItem(
|
||||
component.uuid,
|
||||
(m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
@@ -168,34 +159,8 @@ export class DesktopManager extends ApplicationService {
|
||||
});
|
||||
}
|
||||
|
||||
desktop_registerComponentActivationObserver(
|
||||
callback: ComponentActivationCallback
|
||||
) {
|
||||
const observer = { id: `${Math.random}`, callback: callback };
|
||||
this.componentActivationObservers.push(observer);
|
||||
return observer;
|
||||
}
|
||||
|
||||
desktop_deregisterComponentActivationObserver(
|
||||
observer: ComponentActivationObserver
|
||||
) {
|
||||
removeFromArray(this.componentActivationObservers, observer);
|
||||
}
|
||||
|
||||
/* Notify observers that a component has been registered/activated */
|
||||
async notifyComponentActivation(component: SNComponent) {
|
||||
const serializedComponent = await this.convertComponentForTransmission(
|
||||
component
|
||||
);
|
||||
this.$timeout(() => {
|
||||
for (const observer of this.componentActivationObservers) {
|
||||
observer.callback(serializedComponent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async desktop_requestBackupFile() {
|
||||
const data = await this.application!.createBackupFile(
|
||||
const data = await this.application.createBackupFile(
|
||||
this.application.hasProtectionSources()
|
||||
? EncryptionIntent.FileEncrypted
|
||||
: EncryptionIntent.FileDecrypted
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum KeyboardKey {
|
||||
Backspace = 'Backspace',
|
||||
Up = 'ArrowUp',
|
||||
Down = 'ArrowDown',
|
||||
Enter = 'Enter',
|
||||
}
|
||||
|
||||
export enum KeyboardModifier {
|
||||
@@ -51,7 +52,9 @@ export class IOService {
|
||||
(this.handleWindowBlur as unknown) = undefined;
|
||||
}
|
||||
|
||||
private addActiveModifier = (modifier: KeyboardModifier | undefined): void => {
|
||||
private addActiveModifier = (
|
||||
modifier: KeyboardModifier | undefined
|
||||
): void => {
|
||||
if (!modifier) {
|
||||
return;
|
||||
}
|
||||
@@ -73,14 +76,16 @@ export class IOService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private removeActiveModifier = (modifier: KeyboardModifier | undefined): void => {
|
||||
private removeActiveModifier = (
|
||||
modifier: KeyboardModifier | undefined
|
||||
): void => {
|
||||
if (!modifier) {
|
||||
return;
|
||||
}
|
||||
this.activeModifiers.delete(modifier);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (event: KeyboardEvent): void => {
|
||||
for (const modifier of this.modifiersForEvent(event)) {
|
||||
@@ -91,7 +96,7 @@ export class IOService {
|
||||
|
||||
handleComponentKeyDown = (modifier: KeyboardModifier | undefined): void => {
|
||||
this.addActiveModifier(modifier);
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyUp = (event: KeyboardEvent): void => {
|
||||
for (const modifier of this.modifiersForEvent(event)) {
|
||||
@@ -102,7 +107,7 @@ export class IOService {
|
||||
|
||||
handleComponentKeyUp = (modifier: KeyboardModifier | undefined): void => {
|
||||
this.removeActiveModifier(modifier);
|
||||
}
|
||||
};
|
||||
|
||||
handleWindowBlur = (): void => {
|
||||
for (const modifier of this.activeModifiers) {
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
removeFromArray,
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
UuidString,
|
||||
FeatureStatus,
|
||||
} from '@standardnotes/snjs';
|
||||
|
||||
const CACHED_THEMES_KEY = 'cachedThemes';
|
||||
|
||||
export class ThemeManager extends ApplicationService {
|
||||
private activeThemes: string[] = [];
|
||||
private activeThemes: UuidString[] = [];
|
||||
private unregisterDesktop!: () => void;
|
||||
private unregisterStream!: () => void;
|
||||
|
||||
@@ -22,6 +24,8 @@ export class ThemeManager extends ApplicationService {
|
||||
this.deactivateAllThemes();
|
||||
} else if (event === ApplicationEvent.StorageReady) {
|
||||
await this.activateCachedThemes();
|
||||
} else if (event === ApplicationEvent.FeaturesUpdated) {
|
||||
this.reloadThemeStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +38,24 @@ export class ThemeManager extends ApplicationService {
|
||||
this.activeThemes.length = 0;
|
||||
this.unregisterDesktop();
|
||||
this.unregisterStream();
|
||||
(this.unregisterDesktop as any) = undefined;
|
||||
(this.unregisterStream as any) = undefined;
|
||||
(this.unregisterDesktop as unknown) = undefined;
|
||||
(this.unregisterStream as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
reloadThemeStatus(): void {
|
||||
for (const themeUuid of this.activeThemes) {
|
||||
const theme = this.application.findItem(themeUuid) as SNTheme;
|
||||
if (
|
||||
!theme ||
|
||||
this.application.getFeatureStatus(theme.identifier) !==
|
||||
FeatureStatus.Entitled
|
||||
) {
|
||||
this.deactivateTheme(themeUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
@@ -99,7 +116,11 @@ export class ThemeManager extends ApplicationService {
|
||||
return;
|
||||
}
|
||||
this.activeThemes.push(theme.uuid);
|
||||
const url = this.application!.componentManager!.urlForComponent(theme)!;
|
||||
const url = this.application.componentManager.urlForComponent(theme);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.href = url;
|
||||
link.type = 'text/css';
|
||||
@@ -125,19 +146,19 @@ export class ThemeManager extends ApplicationService {
|
||||
}
|
||||
|
||||
private async cacheThemes() {
|
||||
const themes = this.application!.getAll(this.activeThemes) as SNTheme[];
|
||||
const themes = this.application.getAll(this.activeThemes) as SNTheme[];
|
||||
const mapped = await Promise.all(
|
||||
themes.map(async (theme) => {
|
||||
const payload = theme.payloadRepresentation();
|
||||
const processedPayload =
|
||||
await this.application!.protocolService!.payloadByEncryptingPayload(
|
||||
await this.application.protocolService.payloadByEncryptingPayload(
|
||||
payload,
|
||||
EncryptionIntent.LocalStorageDecrypted
|
||||
);
|
||||
return processedPayload;
|
||||
})
|
||||
);
|
||||
return this.application!.setValue(
|
||||
return this.application.setValue(
|
||||
CACHED_THEMES_KEY,
|
||||
mapped,
|
||||
StorageValueModes.Nonwrapped
|
||||
@@ -154,15 +175,15 @@ export class ThemeManager extends ApplicationService {
|
||||
}
|
||||
|
||||
private async getCachedThemes() {
|
||||
const cachedThemes = (await this.application!.getValue(
|
||||
const cachedThemes = (await this.application.getValue(
|
||||
CACHED_THEMES_KEY,
|
||||
StorageValueModes.Nonwrapped
|
||||
)) as SNTheme[];
|
||||
if (cachedThemes) {
|
||||
const themes = [];
|
||||
for (const cachedTheme of cachedThemes) {
|
||||
const payload = this.application!.createPayloadFromObject(cachedTheme);
|
||||
const theme = this.application!.createItemFromPayload(
|
||||
const payload = this.application.createPayloadFromObject(cachedTheme);
|
||||
const theme = this.application.createItemFromPayload(
|
||||
payload
|
||||
) as SNTheme;
|
||||
themes.push(theme);
|
||||
|
||||
67
app/assets/javascripts/typings/hoist-non-react-statics.d.ts
vendored
Normal file
67
app/assets/javascripts/typings/hoist-non-react-statics.d.ts
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Type definitions for hoist-non-react-statics 3.3
|
||||
// Project: https://github.com/mridgway/hoist-non-react-statics#readme
|
||||
// Definitions by: JounQin <https://github.com/JounQin>, James Reggio <https://github.com/jamesreggio>
|
||||
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||
// TypeScript Version: 2.8
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/hoist-non-react-statics
|
||||
|
||||
declare module 'hoist-non-react-statics' {
|
||||
interface REACT_STATICS {
|
||||
childContextTypes: true;
|
||||
contextType: true;
|
||||
contextTypes: true;
|
||||
defaultProps: true;
|
||||
displayName: true;
|
||||
getDefaultProps: true;
|
||||
getDerivedStateFromError: true;
|
||||
getDerivedStateFromProps: true;
|
||||
mixins: true;
|
||||
propTypes: true;
|
||||
type: true;
|
||||
}
|
||||
|
||||
interface KNOWN_STATICS {
|
||||
name: true;
|
||||
length: true;
|
||||
prototype: true;
|
||||
caller: true;
|
||||
callee: true;
|
||||
arguments: true;
|
||||
arity: true;
|
||||
}
|
||||
|
||||
interface MEMO_STATICS {
|
||||
$$typeof: true;
|
||||
compare: true;
|
||||
defaultProps: true;
|
||||
displayName: true;
|
||||
propTypes: true;
|
||||
type: true;
|
||||
}
|
||||
|
||||
interface FORWARD_REF_STATICS {
|
||||
$$typeof: true;
|
||||
render: true;
|
||||
defaultProps: true;
|
||||
displayName: true;
|
||||
propTypes: true;
|
||||
}
|
||||
|
||||
export type NonReactStatics<
|
||||
S extends React.ComponentType<any>,
|
||||
C extends {
|
||||
[key: string]: true;
|
||||
} = {}
|
||||
> = {
|
||||
[key in Exclude<
|
||||
keyof S,
|
||||
S extends React.MemoExoticComponent<any>
|
||||
? keyof MEMO_STATICS | keyof C
|
||||
: S extends React.ForwardRefExoticComponent<any>
|
||||
? keyof FORWARD_REF_STATICS | keyof C
|
||||
: keyof REACT_STATICS | keyof KNOWN_STATICS | keyof C
|
||||
>]: S[key];
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
runInAction,
|
||||
} from 'mobx';
|
||||
import { ActionsMenuState } from './actions_menu_state';
|
||||
import { FeaturesState } from './features_state';
|
||||
import { NotesState } from './notes_state';
|
||||
import { NotesViewState } from './notes_view_state';
|
||||
import { NoteTagsState } from './note_tags_state';
|
||||
import { NoAccountWarningState } from './no_account_warning_state';
|
||||
import { PreferencesState } from './preferences_state';
|
||||
@@ -56,8 +58,8 @@ export enum EventSource {
|
||||
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>;
|
||||
|
||||
export class AppState {
|
||||
readonly enableUnfinishedFeatures: boolean = (window as any)
|
||||
?._enable_unfinished_features;
|
||||
readonly enableUnfinishedFeatures: boolean =
|
||||
window?._enable_unfinished_features;
|
||||
|
||||
$rootScope: ng.IRootScopeService;
|
||||
$timeout: ng.ITimeoutService;
|
||||
@@ -75,6 +77,8 @@ export class AppState {
|
||||
editingTag: SNTag | undefined;
|
||||
_templateTag: SNTag | undefined;
|
||||
|
||||
private multiEditorSupport = false;
|
||||
|
||||
readonly quickSettingsMenu = new QuickSettingsState();
|
||||
readonly accountMenu: AccountMenuState;
|
||||
readonly actionsMenu = new ActionsMenuState();
|
||||
@@ -85,7 +89,9 @@ export class AppState {
|
||||
readonly sync = new SyncState();
|
||||
readonly searchOptions: SearchOptionsState;
|
||||
readonly notes: NotesState;
|
||||
readonly features: FeaturesState;
|
||||
readonly tags: TagsState;
|
||||
readonly notesView: NotesViewState;
|
||||
isSessionsModalVisible = false;
|
||||
|
||||
private appEventObserverRemovers: (() => void)[] = [];
|
||||
@@ -113,7 +119,12 @@ export class AppState {
|
||||
this,
|
||||
this.appEventObserverRemovers
|
||||
);
|
||||
this.tags = new TagsState(application, this.appEventObserverRemovers);
|
||||
this.features = new FeaturesState(application);
|
||||
this.tags = new TagsState(
|
||||
application,
|
||||
this.appEventObserverRemovers,
|
||||
this.features
|
||||
);
|
||||
this.noAccountWarning = new NoAccountWarningState(
|
||||
application,
|
||||
this.appEventObserverRemovers
|
||||
@@ -127,6 +138,11 @@ export class AppState {
|
||||
this.appEventObserverRemovers
|
||||
);
|
||||
this.purchaseFlow = new PurchaseFlowState(application);
|
||||
this.notesView = new NotesViewState(
|
||||
application,
|
||||
this,
|
||||
this.appEventObserverRemovers
|
||||
);
|
||||
this.addAppEventObserver();
|
||||
this.streamNotesAndTags();
|
||||
this.onVisibilityChange = () => {
|
||||
@@ -180,6 +196,7 @@ export class AppState {
|
||||
this.unsubApp = undefined;
|
||||
this.observers.length = 0;
|
||||
this.appEventObserverRemovers.forEach((remover) => remover());
|
||||
this.features.deinit();
|
||||
this.appEventObserverRemovers.length = 0;
|
||||
if (this.rootScopeCleanup1) {
|
||||
this.rootScopeCleanup1();
|
||||
@@ -209,27 +226,21 @@ export class AppState {
|
||||
storage.set(StorageKey.ShowBetaWarning, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new editor if one doesn't exist. If one does, we'll replace the
|
||||
* editor's note with an empty one.
|
||||
*/
|
||||
async createEditor(title?: string) {
|
||||
const activeEditor = this.getActiveEditor();
|
||||
if (!this.multiEditorSupport) {
|
||||
this.closeActiveEditor();
|
||||
}
|
||||
const activeTagUuid = this.selectedTag
|
||||
? this.selectedTag.isSmartTag
|
||||
? undefined
|
||||
: this.selectedTag.uuid
|
||||
: undefined;
|
||||
|
||||
if (!activeEditor) {
|
||||
this.application.editorGroup.createEditor(
|
||||
undefined,
|
||||
title,
|
||||
activeTagUuid
|
||||
);
|
||||
} else {
|
||||
await activeEditor.reset(title, activeTagUuid);
|
||||
}
|
||||
await this.application.editorGroup.createEditor(
|
||||
undefined,
|
||||
title,
|
||||
activeTagUuid
|
||||
);
|
||||
}
|
||||
|
||||
getActiveEditor() {
|
||||
@@ -423,6 +434,10 @@ export class AppState {
|
||||
}
|
||||
|
||||
public async createNewTag() {
|
||||
if (this.templateTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTag = (await this.application.createTemplateItem(
|
||||
ContentType.Tag
|
||||
)) as SNTag;
|
||||
|
||||
87
app/assets/javascripts/ui_models/app_state/features_state.ts
Normal file
87
app/assets/javascripts/ui_models/app_state/features_state.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
ApplicationEvent,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
} from '@standardnotes/snjs';
|
||||
import { computed, makeObservable, observable, runInAction } from 'mobx';
|
||||
import { WebApplication } from '../application';
|
||||
|
||||
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders';
|
||||
export const TAG_FOLDERS_FEATURE_TOOLTIP =
|
||||
'A Plus or Pro plan is required to enable Tag folders.';
|
||||
|
||||
/**
|
||||
* Holds state for premium/non premium features for the current user features,
|
||||
* and eventually for in-development features (feature flags).
|
||||
*/
|
||||
export class FeaturesState {
|
||||
readonly enableUnfinishedFeatures: boolean =
|
||||
window?._enable_unfinished_features;
|
||||
|
||||
_hasFolders = false;
|
||||
private unsub: () => void;
|
||||
|
||||
constructor(private application: WebApplication) {
|
||||
this._hasFolders = this.hasNativeFolders();
|
||||
|
||||
makeObservable(this, {
|
||||
_hasFolders: observable,
|
||||
hasFolders: computed,
|
||||
enableNativeFoldersFeature: computed,
|
||||
});
|
||||
|
||||
this.unsub = this.application.addEventObserver(async (eventName) => {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.FeaturesUpdated:
|
||||
case ApplicationEvent.Launched:
|
||||
runInAction(() => {
|
||||
this._hasFolders = this.hasNativeFolders();
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
this.unsub();
|
||||
}
|
||||
|
||||
public get enableNativeFoldersFeature(): boolean {
|
||||
return this.enableUnfinishedFeatures;
|
||||
}
|
||||
|
||||
public get hasFolders(): boolean {
|
||||
return this._hasFolders;
|
||||
}
|
||||
|
||||
public set hasFolders(hasFolders: boolean) {
|
||||
if (!hasFolders) {
|
||||
this._hasFolders = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.hasNativeFolders()) {
|
||||
this.application.alertService?.alert(
|
||||
`${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.`
|
||||
);
|
||||
this._hasFolders = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasFolders = hasFolders;
|
||||
}
|
||||
|
||||
private hasNativeFolders(): boolean {
|
||||
if (!this.enableNativeFoldersFeature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = this.application.getFeatureStatus(
|
||||
FeatureIdentifier.TagNesting
|
||||
);
|
||||
|
||||
return status === FeatureStatus.Entitled;
|
||||
}
|
||||
}
|
||||
@@ -176,16 +176,9 @@ export class NoteTagsState {
|
||||
|
||||
async addTagToActiveNote(tag: SNTag): Promise<void> {
|
||||
const { activeNote } = this;
|
||||
|
||||
if (activeNote) {
|
||||
const parentChainTags = this.application.getTagParentChain(tag);
|
||||
const tagsToAdd = [...parentChainTags, tag];
|
||||
await Promise.all(
|
||||
tagsToAdd.map(async (tag) => {
|
||||
await this.application.changeItem(tag.uuid, (mutator) => {
|
||||
mutator.addItemAsRelationship(activeNote);
|
||||
});
|
||||
})
|
||||
);
|
||||
await this.application.addTagHierarchyToNote(activeNote, tag);
|
||||
this.application.sync();
|
||||
this.reloadTags();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export class NotesState {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
contextMenuClickLocation: { x: number, y: number } = { x: 0, y: 0 };
|
||||
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 };
|
||||
contextMenuMaxHeight: number | 'auto' = 'auto';
|
||||
showProtectedWarning = false;
|
||||
|
||||
@@ -167,12 +167,12 @@ export class NotesState {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeEditor) {
|
||||
this.application.editorGroup.createEditor(noteUuid);
|
||||
} else {
|
||||
this.activeEditor.setNote(note);
|
||||
if (this.activeEditor) {
|
||||
this.application.editorGroup.closeActiveEditor();
|
||||
}
|
||||
|
||||
await this.application.editorGroup.createEditor(noteUuid);
|
||||
|
||||
this.appState.noteTags.reloadTags();
|
||||
await this.onActiveEditorChanged();
|
||||
|
||||
@@ -185,7 +185,7 @@ export class NotesState {
|
||||
this.contextMenuOpen = open;
|
||||
}
|
||||
|
||||
setContextMenuClickLocation(location: { x: number, y: number }): void {
|
||||
setContextMenuClickLocation(location: { x: number; y: number }): void {
|
||||
this.contextMenuClickLocation = location;
|
||||
}
|
||||
|
||||
@@ -212,7 +212,8 @@ export class NotesState {
|
||||
// Open up-bottom is default behavior
|
||||
let openUpBottom = true;
|
||||
|
||||
const bottomSpace = clientHeight - footerHeight - this.contextMenuClickLocation.y;
|
||||
const bottomSpace =
|
||||
clientHeight - footerHeight - this.contextMenuClickLocation.y;
|
||||
const upSpace = this.contextMenuClickLocation.y;
|
||||
|
||||
// If not enough space to open up-bottom
|
||||
@@ -220,26 +221,18 @@ export class NotesState {
|
||||
// If there's enough space, open bottom-up
|
||||
if (upSpace > maxContextMenuHeight) {
|
||||
openUpBottom = false;
|
||||
this.setContextMenuMaxHeight(
|
||||
'auto'
|
||||
);
|
||||
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
|
||||
this.setContextMenuMaxHeight('auto');
|
||||
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
|
||||
} else {
|
||||
if (upSpace > bottomSpace) {
|
||||
this.setContextMenuMaxHeight(
|
||||
upSpace - 2
|
||||
);
|
||||
this.setContextMenuMaxHeight(upSpace - 2);
|
||||
openUpBottom = false;
|
||||
} else {
|
||||
this.setContextMenuMaxHeight(
|
||||
bottomSpace - 2
|
||||
);
|
||||
this.setContextMenuMaxHeight(bottomSpace - 2);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.setContextMenuMaxHeight(
|
||||
'auto'
|
||||
);
|
||||
this.setContextMenuMaxHeight('auto');
|
||||
}
|
||||
|
||||
if (openUpBottom) {
|
||||
@@ -375,9 +368,7 @@ export class NotesState {
|
||||
const selectedNotes = Object.values(this.selectedNotes);
|
||||
if (protect) {
|
||||
await this.application.protectNotes(selectedNotes);
|
||||
if (!this.application.hasProtectionSources()) {
|
||||
this.setShowProtectedWarning(true);
|
||||
}
|
||||
this.setShowProtectedWarning(true);
|
||||
} else {
|
||||
await this.application.unprotectNotes(selectedNotes);
|
||||
this.setShowProtectedWarning(false);
|
||||
|
||||
541
app/assets/javascripts/ui_models/app_state/notes_view_state.ts
Normal file
541
app/assets/javascripts/ui_models/app_state/notes_view_state.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import {
|
||||
ApplicationEvent,
|
||||
CollectionSort,
|
||||
ContentType,
|
||||
findInArray,
|
||||
NotesDisplayCriteria,
|
||||
PrefKey,
|
||||
SNNote,
|
||||
SNTag,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs';
|
||||
import {
|
||||
action,
|
||||
autorun,
|
||||
computed,
|
||||
makeObservable,
|
||||
observable,
|
||||
reaction,
|
||||
} from 'mobx';
|
||||
import { AppState, AppStateEvent } from '.';
|
||||
import { WebApplication } from '../application';
|
||||
|
||||
const MIN_NOTE_CELL_HEIGHT = 51.0;
|
||||
const DEFAULT_LIST_NUM_NOTES = 20;
|
||||
const ELEMENT_ID_SEARCH_BAR = 'search-bar';
|
||||
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable';
|
||||
|
||||
export type DisplayOptions = {
|
||||
sortBy: CollectionSort;
|
||||
sortReverse: boolean;
|
||||
hidePinned: boolean;
|
||||
showArchived: boolean;
|
||||
showTrashed: boolean;
|
||||
hideProtected: boolean;
|
||||
hideTags: boolean;
|
||||
hideNotePreview: boolean;
|
||||
hideDate: boolean;
|
||||
};
|
||||
|
||||
export class NotesViewState {
|
||||
completedFullSync = false;
|
||||
noteFilterText = '';
|
||||
notes: SNNote[] = [];
|
||||
notesToDisplay = 0;
|
||||
pageSize = 0;
|
||||
panelTitle = 'All Notes';
|
||||
renderedNotes: SNNote[] = [];
|
||||
searchSubmitted = false;
|
||||
selectedNotes: Record<UuidString, SNNote> = {};
|
||||
showDisplayOptionsMenu = false;
|
||||
displayOptions = {
|
||||
sortBy: CollectionSort.CreatedAt,
|
||||
sortReverse: false,
|
||||
hidePinned: false,
|
||||
showArchived: false,
|
||||
showTrashed: false,
|
||||
hideProtected: false,
|
||||
hideTags: true,
|
||||
hideDate: false,
|
||||
hideNotePreview: false,
|
||||
};
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
private appState: AppState,
|
||||
appObservers: (() => void)[]
|
||||
) {
|
||||
this.resetPagination();
|
||||
|
||||
appObservers.push(
|
||||
application.streamItems(ContentType.Note, () => {
|
||||
this.reloadNotes();
|
||||
const activeNote = this.appState.notes.activeEditor?.note;
|
||||
if (this.application.getAppState().notes.selectedNotesCount < 2) {
|
||||
if (activeNote) {
|
||||
const discarded = activeNote.deleted || activeNote.trashed;
|
||||
if (
|
||||
discarded &&
|
||||
!this.appState?.selectedTag?.isTrashTag &&
|
||||
!this.appState?.searchOptions.includeTrashed
|
||||
) {
|
||||
this.selectNextOrCreateNew();
|
||||
} else if (!this.selectedNotes[activeNote.uuid]) {
|
||||
this.selectNote(activeNote);
|
||||
}
|
||||
} else {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
}
|
||||
}),
|
||||
application.streamItems([ContentType.Tag], async (items) => {
|
||||
const tags = items as SNTag[];
|
||||
/** A tag could have changed its relationships, so we need to reload the filter */
|
||||
this.reloadNotesDisplayOptions();
|
||||
this.reloadNotes();
|
||||
if (findInArray(tags, 'uuid', this.appState.selectedTag?.uuid)) {
|
||||
/** Tag title could have changed */
|
||||
this.reloadPanelTitle();
|
||||
}
|
||||
}),
|
||||
application.addEventObserver(async () => {
|
||||
this.reloadPreferences();
|
||||
}, ApplicationEvent.PreferencesChanged),
|
||||
application.addEventObserver(async () => {
|
||||
this.appState.closeAllEditors();
|
||||
this.selectFirstNote();
|
||||
this.setCompletedFullSync(false);
|
||||
}, ApplicationEvent.SignedIn),
|
||||
application.addEventObserver(async () => {
|
||||
this.reloadNotes();
|
||||
if (
|
||||
this.notes.length === 0 &&
|
||||
this.appState.selectedTag?.isAllTag &&
|
||||
this.noteFilterText === ''
|
||||
) {
|
||||
this.createPlaceholderNote();
|
||||
}
|
||||
this.setCompletedFullSync(true);
|
||||
}, ApplicationEvent.CompletedFullSync),
|
||||
autorun(() => {
|
||||
if (appState.notes.selectedNotes) {
|
||||
this.syncSelectedNotes();
|
||||
}
|
||||
}),
|
||||
reaction(
|
||||
() => [
|
||||
appState.searchOptions.includeProtectedContents,
|
||||
appState.searchOptions.includeArchived,
|
||||
appState.searchOptions.includeTrashed,
|
||||
],
|
||||
() => {
|
||||
this.reloadNotesDisplayOptions();
|
||||
this.reloadNotes();
|
||||
}
|
||||
),
|
||||
appState.addObserver(async (eventName) => {
|
||||
if (eventName === AppStateEvent.TagChanged) {
|
||||
this.handleTagChange();
|
||||
} else if (eventName === AppStateEvent.ActiveEditorChanged) {
|
||||
this.handleEditorChange();
|
||||
} else if (eventName === AppStateEvent.EditorFocused) {
|
||||
this.toggleDisplayOptionsMenu(false);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
makeObservable(this, {
|
||||
completedFullSync: observable,
|
||||
displayOptions: observable.struct,
|
||||
noteFilterText: observable,
|
||||
notes: observable,
|
||||
notesToDisplay: observable,
|
||||
panelTitle: observable,
|
||||
renderedNotes: observable,
|
||||
selectedNotes: observable,
|
||||
showDisplayOptionsMenu: observable,
|
||||
|
||||
reloadNotes: action,
|
||||
reloadPanelTitle: action,
|
||||
reloadPreferences: action,
|
||||
resetPagination: action,
|
||||
setCompletedFullSync: action,
|
||||
setNoteFilterText: action,
|
||||
syncSelectedNotes: action,
|
||||
toggleDisplayOptionsMenu: action,
|
||||
onFilterEnter: action,
|
||||
handleFilterTextChanged: action,
|
||||
|
||||
optionsSubtitle: computed,
|
||||
});
|
||||
|
||||
window.onresize = () => {
|
||||
this.resetPagination(true);
|
||||
};
|
||||
}
|
||||
|
||||
setCompletedFullSync = (completed: boolean) => {
|
||||
this.completedFullSync = completed;
|
||||
};
|
||||
|
||||
toggleDisplayOptionsMenu = (enabled: boolean) => {
|
||||
this.showDisplayOptionsMenu = enabled;
|
||||
};
|
||||
|
||||
get searchBarElement() {
|
||||
return document.getElementById(ELEMENT_ID_SEARCH_BAR);
|
||||
}
|
||||
|
||||
get isFiltering(): boolean {
|
||||
return !!this.noteFilterText && this.noteFilterText.length > 0;
|
||||
}
|
||||
|
||||
get activeEditorNote() {
|
||||
return this.appState.notes.activeEditor?.note;
|
||||
}
|
||||
|
||||
reloadPanelTitle = () => {
|
||||
let title = this.panelTitle;
|
||||
if (this.isFiltering) {
|
||||
const resultCount = this.notes.length;
|
||||
title = `${resultCount} search results`;
|
||||
} else if (this.appState.selectedTag) {
|
||||
title = `${this.appState.selectedTag.title}`;
|
||||
}
|
||||
this.panelTitle = title;
|
||||
};
|
||||
|
||||
reloadNotes = () => {
|
||||
const tag = this.appState.selectedTag;
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
const notes = this.application.getDisplayableItems(
|
||||
ContentType.Note
|
||||
) as SNNote[];
|
||||
const renderedNotes = notes.slice(0, this.notesToDisplay);
|
||||
|
||||
this.notes = notes;
|
||||
this.renderedNotes = renderedNotes;
|
||||
this.reloadPanelTitle();
|
||||
};
|
||||
|
||||
reloadNotesDisplayOptions = () => {
|
||||
const tag = this.appState.selectedTag;
|
||||
|
||||
const searchText = this.noteFilterText.toLowerCase();
|
||||
const isSearching = searchText.length;
|
||||
let includeArchived: boolean;
|
||||
let includeTrashed: boolean;
|
||||
|
||||
if (isSearching) {
|
||||
includeArchived = this.appState.searchOptions.includeArchived;
|
||||
includeTrashed = this.appState.searchOptions.includeTrashed;
|
||||
} else {
|
||||
includeArchived = this.displayOptions.showArchived ?? false;
|
||||
includeTrashed = this.displayOptions.showTrashed ?? false;
|
||||
}
|
||||
|
||||
const criteria = NotesDisplayCriteria.Create({
|
||||
sortProperty: this.displayOptions.sortBy as CollectionSort,
|
||||
sortDirection: this.displayOptions.sortReverse ? 'asc' : 'dsc',
|
||||
tags: tag ? [tag] : [],
|
||||
includeArchived,
|
||||
includeTrashed,
|
||||
includePinned: !this.displayOptions.hidePinned,
|
||||
includeProtected: !this.displayOptions.hideProtected,
|
||||
searchQuery: {
|
||||
query: searchText,
|
||||
includeProtectedNoteText:
|
||||
this.appState.searchOptions.includeProtectedContents,
|
||||
},
|
||||
});
|
||||
this.application.setNotesDisplayCriteria(criteria);
|
||||
};
|
||||
|
||||
reloadPreferences = () => {
|
||||
const freshDisplayOptions = {} as DisplayOptions;
|
||||
const currentSortBy = this.displayOptions.sortBy;
|
||||
let sortBy = this.application.getPreference(
|
||||
PrefKey.SortNotesBy,
|
||||
CollectionSort.CreatedAt
|
||||
);
|
||||
if (
|
||||
sortBy === CollectionSort.UpdatedAt ||
|
||||
(sortBy as string) === 'client_updated_at'
|
||||
) {
|
||||
/** Use UserUpdatedAt instead */
|
||||
sortBy = CollectionSort.UpdatedAt;
|
||||
}
|
||||
freshDisplayOptions.sortBy = sortBy;
|
||||
freshDisplayOptions.sortReverse = this.application.getPreference(
|
||||
PrefKey.SortNotesReverse,
|
||||
false
|
||||
);
|
||||
freshDisplayOptions.showArchived = this.application.getPreference(
|
||||
PrefKey.NotesShowArchived,
|
||||
false
|
||||
);
|
||||
freshDisplayOptions.showTrashed = this.application.getPreference(
|
||||
PrefKey.NotesShowTrashed,
|
||||
false
|
||||
) as boolean;
|
||||
freshDisplayOptions.hidePinned = this.application.getPreference(
|
||||
PrefKey.NotesHidePinned,
|
||||
false
|
||||
);
|
||||
freshDisplayOptions.hideProtected = this.application.getPreference(
|
||||
PrefKey.NotesHideProtected,
|
||||
false
|
||||
);
|
||||
freshDisplayOptions.hideNotePreview = this.application.getPreference(
|
||||
PrefKey.NotesHideNotePreview,
|
||||
false
|
||||
);
|
||||
freshDisplayOptions.hideDate = this.application.getPreference(
|
||||
PrefKey.NotesHideDate,
|
||||
false
|
||||
);
|
||||
freshDisplayOptions.hideTags = this.application.getPreference(
|
||||
PrefKey.NotesHideTags,
|
||||
true
|
||||
);
|
||||
const displayOptionsChanged =
|
||||
freshDisplayOptions.sortBy !== this.displayOptions.sortBy ||
|
||||
freshDisplayOptions.sortReverse !== this.displayOptions.sortReverse ||
|
||||
freshDisplayOptions.hidePinned !== this.displayOptions.hidePinned ||
|
||||
freshDisplayOptions.showArchived !== this.displayOptions.showArchived ||
|
||||
freshDisplayOptions.showTrashed !== this.displayOptions.showTrashed ||
|
||||
freshDisplayOptions.hideProtected !== this.displayOptions.hideProtected ||
|
||||
freshDisplayOptions.hideTags !== this.displayOptions.hideTags;
|
||||
this.displayOptions = freshDisplayOptions;
|
||||
if (displayOptionsChanged) {
|
||||
this.reloadNotesDisplayOptions();
|
||||
}
|
||||
this.reloadNotes();
|
||||
if (freshDisplayOptions.sortBy !== currentSortBy) {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
};
|
||||
|
||||
createNewNote = async (focusNewNote = true) => {
|
||||
this.appState.notes.unselectNotes();
|
||||
let title = `Note ${this.notes.length + 1}`;
|
||||
if (this.isFiltering) {
|
||||
title = this.noteFilterText;
|
||||
}
|
||||
await this.appState.createEditor(title);
|
||||
this.reloadNotes();
|
||||
this.appState.noteTags.reloadTags();
|
||||
const noteTitleEditorElement = document.getElementById('note-title-editor');
|
||||
if (focusNewNote) {
|
||||
noteTitleEditorElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
createPlaceholderNote = () => {
|
||||
const selectedTag = this.appState.selectedTag;
|
||||
if (selectedTag && selectedTag.isSmartTag && !selectedTag.isAllTag) {
|
||||
return;
|
||||
}
|
||||
return this.createNewNote(false);
|
||||
};
|
||||
|
||||
get optionsSubtitle(): string {
|
||||
let base = '';
|
||||
if (this.displayOptions.sortBy === CollectionSort.CreatedAt) {
|
||||
base += ' Date Added';
|
||||
} else if (this.displayOptions.sortBy === CollectionSort.UpdatedAt) {
|
||||
base += ' Date Modified';
|
||||
} else if (this.displayOptions.sortBy === CollectionSort.Title) {
|
||||
base += ' Title';
|
||||
}
|
||||
if (this.displayOptions.showArchived) {
|
||||
base += ' | + Archived';
|
||||
}
|
||||
if (this.displayOptions.showTrashed) {
|
||||
base += ' | + Trashed';
|
||||
}
|
||||
if (this.displayOptions.hidePinned) {
|
||||
base += ' | – Pinned';
|
||||
}
|
||||
if (this.displayOptions.hideProtected) {
|
||||
base += ' | – Protected';
|
||||
}
|
||||
if (this.displayOptions.sortReverse) {
|
||||
base += ' | Reversed';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
paginate = () => {
|
||||
this.notesToDisplay += this.pageSize;
|
||||
this.reloadNotes();
|
||||
if (this.searchSubmitted) {
|
||||
this.application.getDesktopService().searchText(this.noteFilterText);
|
||||
}
|
||||
};
|
||||
|
||||
resetPagination = (keepCurrentIfLarger = false) => {
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT);
|
||||
if (this.pageSize === 0) {
|
||||
this.pageSize = DEFAULT_LIST_NUM_NOTES;
|
||||
}
|
||||
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
|
||||
return;
|
||||
}
|
||||
this.notesToDisplay = this.pageSize;
|
||||
};
|
||||
|
||||
getFirstNonProtectedNote = () => {
|
||||
return this.notes.find((note) => !note.protected);
|
||||
};
|
||||
|
||||
get notesListScrollContainer() {
|
||||
return document.getElementById(ELEMENT_ID_SCROLL_CONTAINER);
|
||||
}
|
||||
|
||||
selectNote = async (
|
||||
note: SNNote,
|
||||
userTriggered?: boolean,
|
||||
scrollIntoView = true
|
||||
): Promise<void> => {
|
||||
await this.appState.notes.selectNote(note.uuid, userTriggered);
|
||||
if (scrollIntoView) {
|
||||
const noteElement = document.getElementById(`note-${note.uuid}`);
|
||||
noteElement?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
selectFirstNote = () => {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note, false, false);
|
||||
this.resetScrollPosition();
|
||||
}
|
||||
};
|
||||
|
||||
selectNextNote = () => {
|
||||
const displayableNotes = this.notes;
|
||||
const currentIndex = displayableNotes.findIndex((candidate) => {
|
||||
return candidate.uuid === this.activeEditorNote?.uuid;
|
||||
});
|
||||
if (currentIndex + 1 < displayableNotes.length) {
|
||||
const nextNote = displayableNotes[currentIndex + 1];
|
||||
this.selectNote(nextNote);
|
||||
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`);
|
||||
nextNoteElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
selectNextOrCreateNew = () => {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note, false, false);
|
||||
} else {
|
||||
this.appState.closeActiveEditor();
|
||||
}
|
||||
};
|
||||
|
||||
selectPreviousNote = () => {
|
||||
const displayableNotes = this.notes;
|
||||
if (this.activeEditorNote) {
|
||||
const currentIndex = displayableNotes.indexOf(this.activeEditorNote);
|
||||
if (currentIndex - 1 >= 0) {
|
||||
const previousNote = displayableNotes[currentIndex - 1];
|
||||
this.selectNote(previousNote);
|
||||
const previousNoteElement = document.getElementById(
|
||||
`note-${previousNote.uuid}`
|
||||
);
|
||||
previousNoteElement?.focus();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setNoteFilterText = (text: string) => {
|
||||
this.noteFilterText = text;
|
||||
};
|
||||
|
||||
syncSelectedNotes = () => {
|
||||
this.selectedNotes = this.appState.notes.selectedNotes;
|
||||
};
|
||||
|
||||
handleEditorChange = async () => {
|
||||
const activeNote = this.appState.getActiveEditor()?.note;
|
||||
if (activeNote && activeNote.conflictOf) {
|
||||
this.application.changeAndSaveItem(activeNote.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
if (this.isFiltering) {
|
||||
this.application.getDesktopService().searchText(this.noteFilterText);
|
||||
}
|
||||
};
|
||||
|
||||
resetScrollPosition = () => {
|
||||
if (this.notesListScrollContainer) {
|
||||
this.notesListScrollContainer.scrollTop = 0;
|
||||
this.notesListScrollContainer.scrollLeft = 0;
|
||||
}
|
||||
};
|
||||
|
||||
handleTagChange = () => {
|
||||
this.resetScrollPosition();
|
||||
this.toggleDisplayOptionsMenu(false);
|
||||
this.setNoteFilterText('');
|
||||
this.application.getDesktopService().searchText();
|
||||
this.resetPagination();
|
||||
|
||||
/* Capture db load state before beginning reloadNotes,
|
||||
since this status may change during reload */
|
||||
const dbLoaded = this.application.isDatabaseLoaded();
|
||||
this.reloadNotesDisplayOptions();
|
||||
this.reloadNotes();
|
||||
|
||||
if (this.notes.length > 0) {
|
||||
this.selectFirstNote();
|
||||
} else if (dbLoaded) {
|
||||
if (
|
||||
this.activeEditorNote &&
|
||||
!this.notes.includes(this.activeEditorNote)
|
||||
) {
|
||||
this.appState.closeActiveEditor();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onFilterEnter = () => {
|
||||
/**
|
||||
* For Desktop, performing a search right away causes
|
||||
* input to lose focus. We wait until user explicity hits
|
||||
* enter before highlighting desktop search results.
|
||||
*/
|
||||
this.searchSubmitted = true;
|
||||
this.application.getDesktopService().searchText(this.noteFilterText);
|
||||
};
|
||||
|
||||
handleFilterTextChanged = () => {
|
||||
if (this.searchSubmitted) {
|
||||
this.searchSubmitted = false;
|
||||
}
|
||||
this.reloadNotesDisplayOptions();
|
||||
this.reloadNotes();
|
||||
};
|
||||
|
||||
onSearchInputBlur = () => {
|
||||
this.appState.searchOptions.refreshIncludeProtectedContents();
|
||||
};
|
||||
|
||||
clearFilterText = () => {
|
||||
this.setNoteFilterText('');
|
||||
this.onFilterEnter();
|
||||
this.handleFilterTextChanged();
|
||||
this.resetPagination();
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApplicationEvent } from "@standardnotes/snjs";
|
||||
import { makeObservable, observable, action, runInAction } from "mobx";
|
||||
import { WebApplication } from "../application";
|
||||
import { ApplicationEvent } from '@standardnotes/snjs';
|
||||
import { makeObservable, observable, action, runInAction } from 'mobx';
|
||||
import { WebApplication } from '../application';
|
||||
|
||||
export class SearchOptionsState {
|
||||
includeProtectedContents = false;
|
||||
@@ -25,7 +25,10 @@ export class SearchOptionsState {
|
||||
appObservers.push(
|
||||
this.application.addEventObserver(async () => {
|
||||
this.refreshIncludeProtectedContents();
|
||||
}, ApplicationEvent.ProtectionSessionExpiryDateChanged)
|
||||
}, ApplicationEvent.UnprotectedSessionBegan),
|
||||
this.application.addEventObserver(async () => {
|
||||
this.refreshIncludeProtectedContents();
|
||||
}, ApplicationEvent.UnprotectedSessionExpired)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,21 +41,17 @@ export class SearchOptionsState {
|
||||
};
|
||||
|
||||
refreshIncludeProtectedContents = (): void => {
|
||||
if (
|
||||
this.includeProtectedContents &&
|
||||
this.application.areProtectionsEnabled()
|
||||
) {
|
||||
this.includeProtectedContents = false;
|
||||
}
|
||||
this.includeProtectedContents =
|
||||
this.application.hasUnprotectedAccessSession();
|
||||
};
|
||||
|
||||
toggleIncludeProtectedContents = async (): Promise<void> => {
|
||||
if (this.includeProtectedContents) {
|
||||
this.includeProtectedContents = false;
|
||||
} else {
|
||||
const authorized = await this.application.authorizeSearchingProtectedNotesText();
|
||||
await this.application.authorizeSearchingProtectedNotesText();
|
||||
runInAction(() => {
|
||||
this.includeProtectedContents = authorized;
|
||||
this.refreshIncludeProtectedContents();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ContentType, SNSmartTag, SNTag } from '@standardnotes/snjs';
|
||||
import {
|
||||
ContentType,
|
||||
SNSmartTag,
|
||||
SNTag,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs';
|
||||
import {
|
||||
action,
|
||||
computed,
|
||||
@@ -8,6 +13,7 @@ import {
|
||||
runInAction,
|
||||
} from 'mobx';
|
||||
import { WebApplication } from '../application';
|
||||
import { FeaturesState } from './features_state';
|
||||
|
||||
export class TagsState {
|
||||
tags: SNTag[] = [];
|
||||
@@ -16,14 +22,20 @@ export class TagsState {
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
appEventListeners: (() => void)[]
|
||||
appEventListeners: (() => void)[],
|
||||
private features: FeaturesState
|
||||
) {
|
||||
this.tagsCountsState = new TagsCountsState(this.application);
|
||||
|
||||
makeObservable(this, {
|
||||
tags: observable,
|
||||
smartTags: observable,
|
||||
tags: observable.ref,
|
||||
smartTags: observable.ref,
|
||||
hasFolders: computed,
|
||||
hasAtLeastOneFolder: computed,
|
||||
|
||||
assignParent: action,
|
||||
|
||||
rootTags: computed,
|
||||
tagsCount: computed,
|
||||
});
|
||||
|
||||
@@ -48,9 +60,68 @@ export class TagsState {
|
||||
return this.tagsCountsState.counts[tag.uuid] || 0;
|
||||
}
|
||||
|
||||
getChildren(tag: SNTag): SNTag[] {
|
||||
if (!this.hasFolders) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.application.isTemplateItem(tag)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children = this.application.getTagChildren(tag);
|
||||
const childrenUuids = children.map((childTag) => childTag.uuid);
|
||||
const childrenTags = this.tags.filter((tag) =>
|
||||
childrenUuids.includes(tag.uuid)
|
||||
);
|
||||
return childrenTags;
|
||||
}
|
||||
|
||||
isValidTagParent(parentUuid: UuidString, tagUuid: UuidString): boolean {
|
||||
return this.application.isValidTagParent(parentUuid, tagUuid);
|
||||
}
|
||||
|
||||
public async assignParent(
|
||||
tagUuid: string,
|
||||
parentUuid: string | undefined
|
||||
): Promise<void> {
|
||||
const tag = this.application.findItem(tagUuid) as SNTag;
|
||||
|
||||
const parent =
|
||||
parentUuid && (this.application.findItem(parentUuid) as SNTag);
|
||||
|
||||
if (!parent) {
|
||||
await this.application.unsetTagParent(tag);
|
||||
} else {
|
||||
await this.application.setTagParent(parent, tag);
|
||||
}
|
||||
|
||||
await this.application.sync();
|
||||
}
|
||||
|
||||
get rootTags(): SNTag[] {
|
||||
if (!this.hasFolders) {
|
||||
return this.tags;
|
||||
}
|
||||
|
||||
return this.tags.filter((tag) => !this.application.getTagParent(tag));
|
||||
}
|
||||
|
||||
get tagsCount(): number {
|
||||
return this.tags.length;
|
||||
}
|
||||
|
||||
public get hasFolders(): boolean {
|
||||
return this.features.hasFolders;
|
||||
}
|
||||
|
||||
public set hasFolders(hasFolders: boolean) {
|
||||
this.features.hasFolders = hasFolders;
|
||||
}
|
||||
|
||||
public get hasAtLeastOneFolder(): boolean {
|
||||
return this.tags.some((tag) => !!this.application.getTagParent(tag));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,12 +18,9 @@ import {
|
||||
PermissionDialog,
|
||||
Platform,
|
||||
SNApplication,
|
||||
SNComponent,
|
||||
} from '@standardnotes/snjs';
|
||||
import angular from 'angular';
|
||||
import { ComponentModalScope } from './../directives/views/componentModal';
|
||||
import { AccountSwitcherScope, PermissionsModalScope } from './../types';
|
||||
import { ComponentGroup } from './component_group';
|
||||
|
||||
type WebServices = {
|
||||
appState: AppState;
|
||||
@@ -40,7 +37,6 @@ export class WebApplication extends SNApplication {
|
||||
private webServices!: WebServices;
|
||||
private currentAuthenticationElement?: angular.IRootElementService;
|
||||
public editorGroup: EditorGroup;
|
||||
public componentGroup: ComponentGroup;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
@@ -71,8 +67,6 @@ export class WebApplication extends SNApplication {
|
||||
this.scope = scope;
|
||||
deviceInterface.setApplication(this);
|
||||
this.editorGroup = new EditorGroup(this);
|
||||
this.componentGroup = new ComponentGroup(this);
|
||||
this.openModalComponent = this.openModalComponent.bind(this);
|
||||
this.presentPermissionsDialog = this.presentPermissionsDialog.bind(this);
|
||||
}
|
||||
|
||||
@@ -85,14 +79,12 @@ export class WebApplication extends SNApplication {
|
||||
(service as any).application = undefined;
|
||||
}
|
||||
this.webServices = {} as WebServices;
|
||||
(this.$compile as any) = undefined;
|
||||
(this.$compile as unknown) = undefined;
|
||||
this.editorGroup.deinit();
|
||||
this.componentGroup.deinit();
|
||||
(this.scope! as any).application = undefined;
|
||||
(this.scope as any).application = undefined;
|
||||
this.scope!.$destroy();
|
||||
this.scope = undefined;
|
||||
(this.openModalComponent as any) = undefined;
|
||||
(this.presentPermissionsDialog as any) = undefined;
|
||||
(this.presentPermissionsDialog as unknown) = undefined;
|
||||
/** Allow our Angular directives to be destroyed and any pending digest cycles
|
||||
* to complete before destroying the global application instance and all its services */
|
||||
setTimeout(() => {
|
||||
@@ -105,8 +97,7 @@ export class WebApplication extends SNApplication {
|
||||
|
||||
onStart(): void {
|
||||
super.onStart();
|
||||
this.componentManager!.openModalComponent = this.openModalComponent;
|
||||
this.componentManager!.presentPermissionsDialog =
|
||||
this.componentManager.presentPermissionsDialog =
|
||||
this.presentPermissionsDialog;
|
||||
}
|
||||
|
||||
@@ -210,24 +201,6 @@ export class WebApplication extends SNApplication {
|
||||
this.applicationElement.append(el);
|
||||
}
|
||||
|
||||
async openModalComponent(component: SNComponent): Promise<void> {
|
||||
switch (component.package_info?.identifier) {
|
||||
case 'org.standardnotes.cloudlink':
|
||||
if (!(await this.authorizeCloudLinkAccess())) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const scope = this.scope!.$new(true) as Partial<ComponentModalScope>;
|
||||
scope.componentUuid = component.uuid;
|
||||
scope.application = this;
|
||||
const el = this.$compile!(
|
||||
"<component-modal application='application' component-uuid='componentUuid' " +
|
||||
"class='sk-modal'></component-modal>"
|
||||
)(scope as any);
|
||||
this.applicationElement.append(el);
|
||||
}
|
||||
|
||||
presentPermissionsDialog(dialog: PermissionDialog) {
|
||||
const scope = this.scope!.$new(true) as PermissionsModalScope;
|
||||
scope.permissionsString = dialog.permissionsString;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { SNComponent, ComponentArea, removeFromArray, addIfUnique , UuidString } from '@standardnotes/snjs';
|
||||
import { WebApplication } from './application';
|
||||
|
||||
/** Areas that only allow a single component to be active */
|
||||
const SingleComponentAreas = [
|
||||
ComponentArea.Editor,
|
||||
ComponentArea.NoteTags,
|
||||
ComponentArea.TagsList
|
||||
];
|
||||
|
||||
export class ComponentGroup {
|
||||
|
||||
private application: WebApplication
|
||||
changeObservers: any[] = []
|
||||
activeComponents: UuidString[] = []
|
||||
|
||||
constructor(application: WebApplication) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
get componentManager() {
|
||||
return this.application.componentManager!;
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
(this.application as any) = undefined;
|
||||
}
|
||||
|
||||
async activateComponent(component: SNComponent) {
|
||||
if (this.activeComponents.includes(component.uuid)) {
|
||||
return;
|
||||
}
|
||||
if (SingleComponentAreas.includes(component.area)) {
|
||||
const currentActive = this.activeComponentForArea(component.area);
|
||||
if (currentActive) {
|
||||
await this.deactivateComponent(currentActive, false);
|
||||
}
|
||||
}
|
||||
addIfUnique(this.activeComponents, component.uuid);
|
||||
await this.componentManager.activateComponent(component.uuid);
|
||||
this.notifyObservers();
|
||||
}
|
||||
|
||||
async deactivateComponent(component: SNComponent, notify = true) {
|
||||
if (!this.activeComponents.includes(component.uuid)) {
|
||||
return;
|
||||
}
|
||||
removeFromArray(this.activeComponents, component.uuid);
|
||||
/** If this function is called as part of global application deinit (locking),
|
||||
* componentManager can be destroyed. In this case, it's harmless to not take any
|
||||
* action since the componentManager will be destroyed, and the component will
|
||||
* essentially be deregistered. */
|
||||
if(this.componentManager) {
|
||||
await this.componentManager.deactivateComponent(component.uuid);
|
||||
if(notify) {
|
||||
this.notifyObservers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deactivateComponentForArea(area: ComponentArea) {
|
||||
const component = this.activeComponentForArea(area);
|
||||
if (component) {
|
||||
return this.deactivateComponent(component);
|
||||
}
|
||||
}
|
||||
|
||||
activeComponentForArea(area: ComponentArea) {
|
||||
return this.activeComponentsForArea(area)[0];
|
||||
}
|
||||
|
||||
activeComponentsForArea(area: ComponentArea) {
|
||||
return this.allActiveComponents().filter((c) => c.area === area);
|
||||
}
|
||||
|
||||
allComponentsForArea(area: ComponentArea) {
|
||||
return this.componentManager.componentsForArea(area);
|
||||
}
|
||||
|
||||
private allActiveComponents() {
|
||||
return this.application.getAll(this.activeComponents) as SNComponent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies observer when the active editor has changed.
|
||||
*/
|
||||
public addChangeObserver(callback: () => void) {
|
||||
this.changeObservers.push(callback);
|
||||
callback();
|
||||
return () => {
|
||||
removeFromArray(this.changeObservers, callback);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyObservers() {
|
||||
for (const observer of this.changeObservers) {
|
||||
observer();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,38 @@
|
||||
import { SNNote, ContentType, PayloadSource, UuidString, TagMutator } from '@standardnotes/snjs';
|
||||
import {
|
||||
SNNote,
|
||||
ContentType,
|
||||
PayloadSource,
|
||||
UuidString,
|
||||
SNTag,
|
||||
} from '@standardnotes/snjs';
|
||||
import { WebApplication } from './application';
|
||||
|
||||
export class Editor {
|
||||
|
||||
public note!: SNNote
|
||||
private application: WebApplication
|
||||
private _onNoteChange?: () => void
|
||||
private _onNoteValueChange?: (note: SNNote, source?: PayloadSource) => void
|
||||
private removeStreamObserver?: () => void
|
||||
public isTemplateNote = false
|
||||
public note!: SNNote;
|
||||
private application: WebApplication;
|
||||
private onNoteValueChange?: (note: SNNote, source: PayloadSource) => void;
|
||||
private removeStreamObserver?: () => void;
|
||||
public isTemplateNote = false;
|
||||
|
||||
constructor(
|
||||
application: WebApplication,
|
||||
noteUuid: string | undefined,
|
||||
noteTitle: string | undefined,
|
||||
noteTag: UuidString | undefined
|
||||
private defaultTitle: string | undefined,
|
||||
private defaultTag: UuidString | undefined
|
||||
) {
|
||||
this.application = application;
|
||||
if (noteUuid) {
|
||||
this.note = application.findItem(noteUuid) as SNNote;
|
||||
this.streamItems();
|
||||
} else {
|
||||
this.reset(noteTitle, noteTag)
|
||||
.then(() => this.streamItems())
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.note) {
|
||||
await this.createTemplateNote(this.defaultTitle, this.defaultTag);
|
||||
}
|
||||
this.streamItems();
|
||||
}
|
||||
|
||||
private streamItems() {
|
||||
this.removeStreamObserver = this.application.streamItems(
|
||||
ContentType.Note,
|
||||
@@ -38,14 +44,12 @@ export class Editor {
|
||||
|
||||
deinit() {
|
||||
this.removeStreamObserver?.();
|
||||
(this.removeStreamObserver as any) = undefined;
|
||||
this._onNoteChange = undefined;
|
||||
(this.application as any) = undefined;
|
||||
this._onNoteChange = undefined;
|
||||
this._onNoteValueChange = undefined;
|
||||
(this.removeStreamObserver as unknown) = undefined;
|
||||
(this.application as unknown) = undefined;
|
||||
this.onNoteValueChange = undefined;
|
||||
}
|
||||
|
||||
private handleNoteStream(notes: SNNote[], source?: PayloadSource) {
|
||||
private handleNoteStream(notes: SNNote[], source: PayloadSource) {
|
||||
/** Update our note object reference whenever it changes */
|
||||
const matchingNote = notes.find((item) => {
|
||||
return item.uuid === this.note.uuid;
|
||||
@@ -53,7 +57,7 @@ export class Editor {
|
||||
if (matchingNote) {
|
||||
this.isTemplateNote = false;
|
||||
this.note = matchingNote;
|
||||
this._onNoteValueChange && this._onNoteValueChange!(matchingNote, source);
|
||||
this.onNoteValueChange?.(matchingNote, source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,58 +70,31 @@ export class Editor {
|
||||
* Reverts the editor to a blank state, removing any existing note from view,
|
||||
* and creating a placeholder note.
|
||||
*/
|
||||
async reset(
|
||||
noteTitle = '',
|
||||
noteTag?: UuidString,
|
||||
) {
|
||||
const note = await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
{
|
||||
text: '',
|
||||
title: noteTitle,
|
||||
references: []
|
||||
}
|
||||
) as SNNote;
|
||||
async createTemplateNote(defaultTitle?: string, noteTag?: UuidString) {
|
||||
const note = (await this.application.createTemplateItem(ContentType.Note, {
|
||||
text: '',
|
||||
title: defaultTitle,
|
||||
references: [],
|
||||
})) as SNNote;
|
||||
if (noteTag) {
|
||||
await this.application.changeItem<TagMutator>(noteTag, (m) => {
|
||||
m.addItemAsRelationship(note);
|
||||
});
|
||||
const tag = this.application.findItem(noteTag) as SNTag;
|
||||
await this.application.addTagHierarchyToNote(note, tag);
|
||||
}
|
||||
if (!this.isTemplateNote || this.note.title !== note.title) {
|
||||
this.setNote(note as SNNote, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register to be notified when the editor's note changes.
|
||||
*/
|
||||
public onNoteChange(callback: () => void) {
|
||||
this._onNoteChange = callback;
|
||||
if (this.note) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
public clearNoteChangeListener() {
|
||||
this._onNoteChange = undefined;
|
||||
this.isTemplateNote = true;
|
||||
this.note = note;
|
||||
this.onNoteValueChange?.(this.note, this.note.payload.source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register to be notified when the editor's note's values change
|
||||
* (and thus a new object reference is created)
|
||||
*/
|
||||
public onNoteValueChange(callback: (note: SNNote, source?: PayloadSource) => void) {
|
||||
this._onNoteValueChange = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the editor contents by setting its note.
|
||||
*/
|
||||
public setNote(note: SNNote, isTemplate = false) {
|
||||
this.note = note;
|
||||
this.isTemplateNote = isTemplate;
|
||||
if (this._onNoteChange) {
|
||||
this._onNoteChange();
|
||||
public setOnNoteValueChange(
|
||||
callback: (note: SNNote, source: PayloadSource) => void
|
||||
) {
|
||||
this.onNoteValueChange = callback;
|
||||
if (this.note) {
|
||||
this.onNoteValueChange(this.note, this.note.payload.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,31 +2,31 @@ import { removeFromArray, UuidString } from '@standardnotes/snjs';
|
||||
import { Editor } from './editor';
|
||||
import { WebApplication } from './application';
|
||||
|
||||
type EditorGroupChangeCallback = () => void
|
||||
type EditorGroupChangeCallback = () => void;
|
||||
|
||||
export class EditorGroup {
|
||||
|
||||
public editors: Editor[] = []
|
||||
private application: WebApplication
|
||||
changeObservers: EditorGroupChangeCallback[] = []
|
||||
public editors: Editor[] = [];
|
||||
private application: WebApplication;
|
||||
changeObservers: EditorGroupChangeCallback[] = [];
|
||||
|
||||
constructor(application: WebApplication) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
(this.application as any) = undefined;
|
||||
(this.application as unknown) = undefined;
|
||||
for (const editor of this.editors) {
|
||||
this.deleteEditor(editor);
|
||||
}
|
||||
}
|
||||
|
||||
createEditor(
|
||||
async createEditor(
|
||||
noteUuid?: string,
|
||||
noteTitle?: string,
|
||||
noteTag?: UuidString
|
||||
) {
|
||||
const editor = new Editor(this.application, noteUuid, noteTitle, noteTag);
|
||||
await editor.initialize();
|
||||
this.editors.push(editor);
|
||||
this.notifyObservers();
|
||||
}
|
||||
@@ -43,13 +43,13 @@ export class EditorGroup {
|
||||
|
||||
closeActiveEditor() {
|
||||
const activeEditor = this.activeEditor;
|
||||
if(activeEditor) {
|
||||
if (activeEditor) {
|
||||
this.deleteEditor(activeEditor);
|
||||
}
|
||||
}
|
||||
|
||||
closeAllEditors() {
|
||||
for(const editor of this.editors) {
|
||||
for (const editor of this.editors) {
|
||||
this.deleteEditor(editor);
|
||||
}
|
||||
}
|
||||
|
||||
317
app/assets/javascripts/ui_models/panel_resizer.ts
Normal file
317
app/assets/javascripts/ui_models/panel_resizer.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
} from '@/directives/views/panelResizer';
|
||||
import { debounce } from '@/utils';
|
||||
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs';
|
||||
import { action, computed, makeObservable, observable } from 'mobx';
|
||||
import { WebApplication } from './application';
|
||||
|
||||
export type PanelResizerProps = {
|
||||
alwaysVisible?: boolean;
|
||||
application: WebApplication;
|
||||
collapsable: boolean;
|
||||
defaultWidth?: number;
|
||||
hoverable?: boolean;
|
||||
minWidth?: number;
|
||||
panel: HTMLDivElement;
|
||||
prefKey: PrefKey;
|
||||
resizeFinishCallback?: ResizeFinishCallback;
|
||||
side: PanelSide;
|
||||
widthEventCallback?: () => void;
|
||||
};
|
||||
|
||||
export class PanelResizerState {
|
||||
private application: WebApplication;
|
||||
alwaysVisible: boolean;
|
||||
collapsable: boolean;
|
||||
collapsed = false;
|
||||
currentMinWidth = 0;
|
||||
defaultWidth: number;
|
||||
hoverable: boolean;
|
||||
lastDownX = 0;
|
||||
lastLeft = 0;
|
||||
lastWidth = 0;
|
||||
panel: HTMLDivElement;
|
||||
pressed = false;
|
||||
prefKey: PrefKey;
|
||||
resizeFinishCallback?: ResizeFinishCallback;
|
||||
side: PanelSide;
|
||||
startLeft = 0;
|
||||
startWidth = 0;
|
||||
widthBeforeLastDblClick = 0;
|
||||
widthEventCallback?: () => void;
|
||||
overlay?: HTMLDivElement;
|
||||
|
||||
constructor({
|
||||
alwaysVisible,
|
||||
application,
|
||||
defaultWidth,
|
||||
hoverable,
|
||||
collapsable,
|
||||
minWidth,
|
||||
panel,
|
||||
prefKey,
|
||||
resizeFinishCallback,
|
||||
side,
|
||||
widthEventCallback,
|
||||
}: PanelResizerProps) {
|
||||
this.alwaysVisible = alwaysVisible ?? false;
|
||||
this.application = application;
|
||||
this.collapsable = collapsable ?? false;
|
||||
this.collapsed = false;
|
||||
this.currentMinWidth = minWidth ?? 0;
|
||||
this.defaultWidth = defaultWidth ?? 0;
|
||||
this.hoverable = hoverable ?? true;
|
||||
this.lastDownX = 0;
|
||||
this.lastLeft = this.startLeft;
|
||||
this.lastWidth = this.startWidth;
|
||||
this.panel = panel;
|
||||
this.prefKey = prefKey;
|
||||
this.pressed = false;
|
||||
this.side = side;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.widthBeforeLastDblClick = 0;
|
||||
this.widthEventCallback = widthEventCallback;
|
||||
this.resizeFinishCallback = resizeFinishCallback;
|
||||
|
||||
application.addEventObserver(async () => {
|
||||
const changedWidth = application.getPreference(prefKey) as number;
|
||||
if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true);
|
||||
}, ApplicationEvent.PreferencesChanged);
|
||||
|
||||
makeObservable(this, {
|
||||
pressed: observable,
|
||||
collapsed: observable,
|
||||
|
||||
onMouseUp: action,
|
||||
onMouseDown: action,
|
||||
onDblClick: action,
|
||||
handleWidthEvent: action,
|
||||
handleLeftEvent: action,
|
||||
setWidth: action,
|
||||
setMinWidth: action,
|
||||
reloadDefaultValues: action,
|
||||
|
||||
appFrame: computed,
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp.bind(this));
|
||||
document.addEventListener('mousemove', this.onMouseMove.bind(this));
|
||||
if (this.side === PanelSide.Right) {
|
||||
window.addEventListener(
|
||||
'resize',
|
||||
debounce(this.handleResize.bind(this), 250)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get appFrame() {
|
||||
return document.getElementById('app')?.getBoundingClientRect() as DOMRect;
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
return (this.panel.parentNode as HTMLElement).getBoundingClientRect();
|
||||
}
|
||||
|
||||
isAtMaxWidth = () => {
|
||||
return (
|
||||
Math.round(this.lastWidth + this.lastLeft) ===
|
||||
Math.round(this.getParentRect().width)
|
||||
);
|
||||
};
|
||||
|
||||
isCollapsed() {
|
||||
return this.lastWidth <= this.currentMinWidth;
|
||||
}
|
||||
|
||||
reloadDefaultValues = () => {
|
||||
this.startWidth = this.isAtMaxWidth()
|
||||
? this.getParentRect().width
|
||||
: this.panel.scrollWidth;
|
||||
this.lastWidth = this.startWidth;
|
||||
};
|
||||
|
||||
finishSettingWidth = () => {
|
||||
if (!this.collapsable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.collapsed = this.isCollapsed();
|
||||
};
|
||||
|
||||
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();
|
||||
if (this.resizeFinishCallback) {
|
||||
this.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.application.setPreference(this.prefKey, this.lastWidth);
|
||||
};
|
||||
|
||||
setLeft = (left: number) => {
|
||||
this.panel.style.left = left + 'px';
|
||||
this.lastLeft = left;
|
||||
};
|
||||
|
||||
onDblClick = () => {
|
||||
const collapsed = this.isCollapsed();
|
||||
if (collapsed) {
|
||||
this.setWidth(this.widthBeforeLastDblClick || this.defaultWidth);
|
||||
} else {
|
||||
this.widthBeforeLastDblClick = this.lastWidth;
|
||||
this.setWidth(this.currentMinWidth);
|
||||
}
|
||||
this.application.setPreference(this.prefKey, this.lastWidth);
|
||||
this.finishSettingWidth();
|
||||
if (this.resizeFinishCallback) {
|
||||
this.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
this.isAtMaxWidth(),
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
if (this.widthEventCallback) {
|
||||
this.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.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);
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
this.reloadDefaultValues();
|
||||
this.handleWidthEvent();
|
||||
this.finishSettingWidth();
|
||||
};
|
||||
|
||||
onMouseDown = (event: MouseEvent) => {
|
||||
this.addInvisibleOverlay();
|
||||
this.pressed = true;
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
};
|
||||
|
||||
onMouseUp = () => {
|
||||
this.removeInvisibleOverlay();
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
this.pressed = false;
|
||||
const isMaxWidth = this.isAtMaxWidth();
|
||||
if (this.resizeFinishCallback) {
|
||||
this.resizeFinishCallback(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
isMaxWidth,
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
};
|
||||
|
||||
onMouseMove(event: MouseEvent) {
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.side === PanelSide.Left) {
|
||||
this.handleLeftEvent(event);
|
||||
} else {
|
||||
this.handleWidthEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
setMinWidth = (minWidth?: number) => {
|
||||
this.currentMinWidth = minWidth ?? this.currentMinWidth;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Platform, platformFromString } from '@standardnotes/snjs';
|
||||
import { IsDesktopPlatform, IsWebPlatform } from '@/version';
|
||||
import { EMAIL_REGEX } from '@Views/constants';
|
||||
export { isMobile } from './isMobile';
|
||||
|
||||
declare const process: {
|
||||
env: {
|
||||
28
app/assets/javascripts/utils/isMobile.ts
Normal file
28
app/assets/javascripts/utils/isMobile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* source: https://github.com/juliangruber/is-mobile
|
||||
*
|
||||
* (MIT)
|
||||
* Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
const mobileRE =
|
||||
/(android|bb\d+|meego).+mobile|armv7l|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i;
|
||||
|
||||
const tabletRE = /android|ipad|playbook|silk/i;
|
||||
|
||||
export type Opts = {
|
||||
tablet?: boolean;
|
||||
};
|
||||
|
||||
export const isMobile = (opts: Opts = {}) => {
|
||||
const ua = navigator.userAgent || navigator.vendor;
|
||||
|
||||
if (typeof ua !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mobileRE.test(ua) || (!!opts.tablet && tabletRE.test(ua));
|
||||
};
|
||||
@@ -3,29 +3,26 @@ import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx';
|
||||
|
||||
export type CtrlState = Partial<Record<string, any>>
|
||||
export type CtrlProps = Partial<Record<string, any>>
|
||||
export type CtrlState = Partial<Record<string, any>>;
|
||||
export type CtrlProps = Partial<Record<string, any>>;
|
||||
|
||||
export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
|
||||
$timeout: ng.ITimeoutService
|
||||
$timeout: ng.ITimeoutService;
|
||||
/** Passed through templates */
|
||||
application!: WebApplication
|
||||
state: S = {} as any
|
||||
private unsubApp: any
|
||||
private unsubState: any
|
||||
private stateTimeout?: ng.IPromise<void>
|
||||
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
|
||||
protected templateReady = false;
|
||||
private reactionDisposers: IReactionDisposer[] = [];
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
public props: P = {} as any
|
||||
) {
|
||||
constructor($timeout: ng.ITimeoutService, public props: P = {} as any) {
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
@@ -91,8 +88,7 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
|
||||
|
||||
/** @override */
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
afterStateChange(): void {
|
||||
}
|
||||
afterStateChange(): void {}
|
||||
|
||||
/** @returns a promise that resolves after the UI has been updated. */
|
||||
flushUI(): angular.IPromise<void> {
|
||||
@@ -129,25 +125,27 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
|
||||
if (this.application!.isLaunched()) {
|
||||
this.onAppLaunch();
|
||||
}
|
||||
this.unsubApp = this.application!.addEventObserver(async (eventName) => {
|
||||
this.onAppEvent(eventName);
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
await this.onAppStart();
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
await this.onAppLaunch();
|
||||
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
|
||||
this.onAppIncrementalSync();
|
||||
} else if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
this.onAppFullSync();
|
||||
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.onAppKeyChange();
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.onLocalDataLoaded();
|
||||
this.unsubApp = this.application!.addEventObserver(
|
||||
async (eventName, data: any) => {
|
||||
this.onAppEvent(eventName, data);
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
await this.onAppStart();
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
await this.onAppLaunch();
|
||||
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
|
||||
this.onAppIncrementalSync();
|
||||
} else if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
this.onAppFullSync();
|
||||
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.onAppKeyChange();
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.onLocalDataLoaded();
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
onAppEvent(eventName: ApplicationEvent, data?: any) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
@@ -175,5 +173,4 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
|
||||
onAppFullSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
)
|
||||
#app.app(
|
||||
ng-class='self.state.appClass',
|
||||
ng-if='!self.state.needsUnlock && self.state.ready'
|
||||
ng-if='!self.state.needsUnlock && self.state.launched'
|
||||
)
|
||||
tags-view(application='self.application')
|
||||
notes-view(application='self.application')
|
||||
notes-view(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
editor-group-view.flex-grow(application='self.application')
|
||||
|
||||
footer-view(
|
||||
ng-if='!self.state.needsUnlock && self.state.ready'
|
||||
ng-if='!self.state.needsUnlock && self.state.launched'
|
||||
application='self.application'
|
||||
)
|
||||
|
||||
|
||||
@@ -3,25 +3,29 @@ import { WebDirective } from '@/types';
|
||||
import { getPlatformString } from '@/utils';
|
||||
import template from './application-view.pug';
|
||||
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
|
||||
import { ApplicationEvent, Challenge, removeFromArray } from '@standardnotes/snjs';
|
||||
import {
|
||||
PANEL_NAME_NOTES,
|
||||
PANEL_NAME_TAGS
|
||||
} from '@/views/constants';
|
||||
import {
|
||||
STRING_DEFAULT_FILE_ERROR
|
||||
} from '@/strings';
|
||||
ApplicationEvent,
|
||||
Challenge,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants';
|
||||
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { alertDialog } from '@/services/alertService';
|
||||
|
||||
class ApplicationViewCtrl extends PureViewCtrl<unknown, {
|
||||
ready?: boolean,
|
||||
needsUnlock?: boolean,
|
||||
appClass: string,
|
||||
}> {
|
||||
public platformString: string
|
||||
private notesCollapsed = false
|
||||
private tagsCollapsed = false
|
||||
class ApplicationViewCtrl extends PureViewCtrl<
|
||||
unknown,
|
||||
{
|
||||
started?: boolean;
|
||||
launched?: boolean;
|
||||
needsUnlock?: boolean;
|
||||
appClass: string;
|
||||
}
|
||||
> {
|
||||
public platformString: string;
|
||||
private notesCollapsed = false;
|
||||
private tagsCollapsed = false;
|
||||
|
||||
/**
|
||||
* To prevent stale state reads (setState is async),
|
||||
* challenges is a mutable array
|
||||
@@ -76,7 +80,7 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
|
||||
this.$timeout(() => {
|
||||
this.challenges.push(challenge);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
await this.application.launch();
|
||||
}
|
||||
@@ -90,14 +94,17 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
this.setState({
|
||||
ready: true,
|
||||
needsUnlock: this.application.hasPasscode()
|
||||
started: true,
|
||||
needsUnlock: this.application.hasPasscode(),
|
||||
});
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.setState({ needsUnlock: false });
|
||||
this.setState({
|
||||
launched: true,
|
||||
needsUnlock: false,
|
||||
});
|
||||
this.handleDemoSignInFromParams();
|
||||
}
|
||||
|
||||
@@ -108,14 +115,17 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName);
|
||||
if (eventName === ApplicationEvent.LocalDatabaseReadError) {
|
||||
alertDialog({
|
||||
text: 'Unable to load local database. Please restart the app and try again.'
|
||||
});
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
|
||||
alertDialog({
|
||||
text: 'Unable to write to local database. Please restart the app and try again.'
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +139,13 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
|
||||
if (panel === PANEL_NAME_TAGS) {
|
||||
this.tagsCollapsed = collapsed;
|
||||
}
|
||||
let appClass = "";
|
||||
if (this.notesCollapsed) { appClass += "collapsed-notes"; }
|
||||
if (this.tagsCollapsed) { appClass += " collapsed-tags"; }
|
||||
let appClass = '';
|
||||
if (this.notesCollapsed) {
|
||||
appClass += 'collapsed-notes';
|
||||
}
|
||||
if (this.tagsCollapsed) {
|
||||
appClass += ' collapsed-tags';
|
||||
}
|
||||
this.setState({ appClass });
|
||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
||||
if (!(await this.application.isLocked())) {
|
||||
@@ -160,7 +174,7 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
|
||||
if (event.dataTransfer?.files.length) {
|
||||
event.preventDefault();
|
||||
void alertDialog({
|
||||
text: STRING_DEFAULT_FILE_ERROR
|
||||
text: STRING_DEFAULT_FILE_ERROR,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -168,15 +182,12 @@ class ApplicationViewCtrl extends PureViewCtrl<unknown, {
|
||||
async handleDemoSignInFromParams() {
|
||||
if (
|
||||
this.$location.search().demo === 'true' &&
|
||||
!this.application.hasAccount()
|
||||
!this.application.hasAccount()
|
||||
) {
|
||||
await this.application.setCustomHost(
|
||||
'https://syncing-server-demo.standardnotes.com'
|
||||
);
|
||||
this.application.signIn(
|
||||
'demo@standardnotes.org',
|
||||
'password',
|
||||
);
|
||||
this.application.signIn('demo@standardnotes.org', 'password');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +201,7 @@ export class ApplicationView extends WebDirective {
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
application: '=',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ class ChallengeModalCtrl extends PureViewCtrl<unknown, ChallengeModalState> {
|
||||
|
||||
$onDestroy() {
|
||||
render(<></>, this.$element[0]);
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
private render() {
|
||||
@@ -352,8 +353,8 @@ function ChallengePrompts({
|
||||
{/** 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">
|
||||
<div className="sk-p sk-bold">Remember For</div>
|
||||
<div className="sk-horizontal-group mt-3">
|
||||
<div className="sk-p sk-bold">Allow protected access for</div>
|
||||
{ProtectionSessionDurations.map((option) => (
|
||||
<a
|
||||
className={
|
||||
@@ -374,10 +375,13 @@ function ChallengePrompts({
|
||||
</div>
|
||||
) : (
|
||||
<div key={prompt.id} className="sk-panel-row">
|
||||
<form className="w-full" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
ctrl.submit();
|
||||
}}>
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
ctrl.submit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="sk-input contrast"
|
||||
value={ctrl.state.values[prompt.id]!.value as string | number}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PANEL_NAME_NOTES = 'notes';
|
||||
export const PANEL_NAME_TAGS = 'tags';
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const EMAIL_REGEX = /^([a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
|
||||
export const PANEL_NAME_TAGS = 'tags';
|
||||
export const EMAIL_REGEX =
|
||||
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
protected-note-panel.h-full.flex.justify-center.items-center(
|
||||
ng-if='self.state.showProtectedWarning'
|
||||
app-state='self.appState'
|
||||
has-protection-sources='self.application.hasProtectionSources()'
|
||||
on-view-note='self.dismissProtectedWarning()'
|
||||
)
|
||||
.flex-grow.flex.flex-col(
|
||||
@@ -33,10 +34,8 @@
|
||||
)
|
||||
.title.overflow-auto
|
||||
input#note-title-editor.input(
|
||||
ng-blur='self.onTitleBlur()',
|
||||
ng-change='self.onTitleChange()',
|
||||
ng-disabled='self.noteLocked',
|
||||
ng-focus='self.onTitleFocus()',
|
||||
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
|
||||
ng-model='self.editorValues.title',
|
||||
select-on-focus='true',
|
||||
@@ -75,7 +74,7 @@
|
||||
callback='self.editorMenuOnSelect',
|
||||
current-item='self.note',
|
||||
ng-if='self.state.showEditorMenu',
|
||||
selected-editor-uuid='self.state.editorComponent && self.state.editorComponent.uuid',
|
||||
selected-editor-uuid='self.state.editorComponentViewer && self.state.editorComponentViewer.component.uuid',
|
||||
application='self.application'
|
||||
)
|
||||
.sk-app-bar-item(
|
||||
@@ -113,9 +112,10 @@
|
||||
property="'left'"
|
||||
)
|
||||
component-view.component-view(
|
||||
component-uuid='self.state.editorComponent.uuid',
|
||||
ng-if='self.state.editorComponent && !self.state.editorUnloading',
|
||||
component-viewer='self.state.editorComponentViewer',
|
||||
ng-if='self.state.editorComponentViewer',
|
||||
on-load='self.onEditorLoad',
|
||||
request-reload='self.editorComponentViewerRequestsReload'
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
@@ -125,7 +125,7 @@
|
||||
ng-change='self.contentChanged()',
|
||||
ng-click='self.clickedTextArea()',
|
||||
ng-focus='self.onContentFocus()',
|
||||
ng-if='!self.state.editorComponent && !self.state.textareaUnloading',
|
||||
ng-if='self.state.editorStateDidLoad && !self.state.editorComponentViewer && !self.state.textareaUnloading',
|
||||
ng-model='self.editorValues.text',
|
||||
ng-model-options='{ debounce: self.state.editorDebounce}',
|
||||
ng-readonly='self.noteLocked',
|
||||
@@ -155,24 +155,23 @@
|
||||
| There was an error decrypting this item. Ensure you are running the
|
||||
| latest version of this app, then sign out and sign back in to try again.
|
||||
#editor-pane-component-stack(ng-if='!self.note.errorDecrypting' ng-show='self.note')
|
||||
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.stackComponents.length')
|
||||
#component-stack-menu-bar.sk-app-bar.no-edges(ng-if='self.state.availableStackComponents.length')
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
ng-repeat='component in self.state.stackComponents track by component.uuid'
|
||||
ng-click='self.toggleStackComponentForCurrentItem(component)',
|
||||
ng-repeat='component in self.state.availableStackComponents track by component.uuid'
|
||||
ng-click='self.toggleStackComponent(component)',
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-circle.small(
|
||||
ng-class="{'info' : !self.stackComponentHidden(component) && component.active, 'neutral' : self.stackComponentHidden(component) || !component.active}"
|
||||
ng-class="{'info' : self.stackComponentExpanded(component) && component.active, 'neutral' : !self.stackComponentExpanded(component)}"
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-label {{component.name}}
|
||||
.sn-component
|
||||
component-view.component-view.component-stack-item(
|
||||
ng-repeat='component in self.state.stackComponents track by component.uuid',
|
||||
component-uuid='component.uuid',
|
||||
ng-repeat='viewer in self.state.stackComponentViewers track by viewer.componentUuid',
|
||||
component-viewer='viewer',
|
||||
manual-dealloc='true',
|
||||
ng-show='!self.stackComponentHidden(component)',
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
|
||||
196
app/assets/javascripts/views/editor/editor_view.test.ts
Normal file
196
app/assets/javascripts/views/editor/editor_view.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { EditorViewCtrl } from '@Views/editor/editor_view';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
} from '@standardnotes/snjs/';
|
||||
|
||||
describe('editor-view', () => {
|
||||
let ctrl: EditorViewCtrl;
|
||||
let setShowProtectedWarningSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
|
||||
ctrl = new EditorViewCtrl($timeout);
|
||||
|
||||
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
|
||||
|
||||
Object.defineProperties(ctrl, {
|
||||
application: {
|
||||
value: {
|
||||
getAppState: () => {
|
||||
return {
|
||||
notes: {
|
||||
setShowProtectedWarning: jest.fn(),
|
||||
},
|
||||
};
|
||||
},
|
||||
hasProtectionSources: () => true,
|
||||
authorizeNoteAccess: jest.fn(),
|
||||
},
|
||||
},
|
||||
removeComponentsObserver: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
removeTrashKeyObserver: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
unregisterComponent: {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
},
|
||||
editor: {
|
||||
value: {
|
||||
clearNoteChangeListener: jest.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctrl.deinit();
|
||||
});
|
||||
|
||||
describe('note is protected', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(ctrl, 'note', {
|
||||
value: {
|
||||
protected: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
|
||||
jest
|
||||
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
|
||||
.mockImplementation(
|
||||
() =>
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction +
|
||||
5
|
||||
);
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
|
||||
const secondsElapsedSinceLastEdit =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
3;
|
||||
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
const secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastEdit;
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1 * 1000);
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
|
||||
const secondsElapsedSinceLastModification = 3;
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(
|
||||
Date.now() - secondsElapsedSinceLastModification * 1000
|
||||
),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
let secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastModification;
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
|
||||
|
||||
// A new modification has just happened
|
||||
Object.defineProperty(ctrl.note, 'userModifiedDate', {
|
||||
value: new Date(),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
secondsAfterWhichTheNoteShouldHide =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction;
|
||||
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1 * 1000);
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note is unprotected', () => {
|
||||
it('should not call any hiding logic', async () => {
|
||||
Object.defineProperty(ctrl, 'note', {
|
||||
value: {
|
||||
protected: false,
|
||||
},
|
||||
});
|
||||
const hideProtectedNoteIfInactiveSpy = jest.spyOn(
|
||||
ctrl,
|
||||
'hideProtectedNoteIfInactive'
|
||||
);
|
||||
|
||||
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
|
||||
|
||||
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissProtectedWarning', () => {
|
||||
describe('the note has protection sources', () => {
|
||||
it('should reveal note contents if the authorization has been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(true));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not reveal note contents if the authorization has not been passed', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'authorizeNoteAccess')
|
||||
.mockImplementation(async () => Promise.resolve(false));
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
|
||||
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the note does not have protection sources', () => {
|
||||
it('should reveal note contents', async () => {
|
||||
jest
|
||||
.spyOn(ctrl.application, 'hasProtectionSources')
|
||||
.mockImplementation(() => false);
|
||||
|
||||
await ctrl.dismissProtectedWarning();
|
||||
|
||||
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
SNComponent,
|
||||
SNNote,
|
||||
NoteMutator,
|
||||
Uuids,
|
||||
ComponentArea,
|
||||
PrefKey,
|
||||
ComponentMutator,
|
||||
PayloadSource,
|
||||
ComponentViewer,
|
||||
ComponentManagerEvent,
|
||||
TransactionalMutation,
|
||||
ItemMutator,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
} from '@standardnotes/snjs';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
|
||||
@@ -51,8 +55,10 @@ type NoteStatus = {
|
||||
};
|
||||
|
||||
type EditorState = {
|
||||
stackComponents: SNComponent[];
|
||||
editorComponent?: SNComponent;
|
||||
availableStackComponents: SNComponent[];
|
||||
stackComponentViewers: ComponentViewer[];
|
||||
editorComponentViewer?: ComponentViewer;
|
||||
editorStateDidLoad: boolean;
|
||||
saveError?: any;
|
||||
noteStatus?: NoteStatus;
|
||||
marginResizersEnabled?: boolean;
|
||||
@@ -63,11 +69,6 @@ type EditorState = {
|
||||
showEditorMenu: boolean;
|
||||
showHistoryMenu: boolean;
|
||||
spellcheck: boolean;
|
||||
/**
|
||||
* Setting to false then true will allow the current editor component-view to be destroyed
|
||||
* then re-initialized. Used when changing between component editors.
|
||||
*/
|
||||
editorUnloading: boolean;
|
||||
/** Setting to true then false will allow the main content textarea to be destroyed
|
||||
* then re-initialized. Used when reloading spellcheck status. */
|
||||
textareaUnloading: boolean;
|
||||
@@ -89,14 +90,13 @@ function sortAlphabetically(array: SNComponent[]): SNComponent[] {
|
||||
);
|
||||
}
|
||||
|
||||
class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
export class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
/** Passed through template */
|
||||
readonly application!: WebApplication;
|
||||
readonly editor!: Editor;
|
||||
|
||||
private leftPanelPuppet?: PanelPuppet;
|
||||
private rightPanelPuppet?: PanelPuppet;
|
||||
private unregisterComponent: any;
|
||||
private saveTimeout?: ng.IPromise<void>;
|
||||
private statusTimeout?: ng.IPromise<void>;
|
||||
private lastEditorFocusEventSource?: EventSource;
|
||||
@@ -104,10 +104,12 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
onEditorLoad?: () => void;
|
||||
|
||||
private scrollPosition = 0;
|
||||
private removeTrashKeyObserver?: any;
|
||||
private removeTabObserver?: any;
|
||||
private removeTrashKeyObserver?: () => void;
|
||||
private removeTabObserver?: () => void;
|
||||
private removeComponentStreamObserver?: () => void;
|
||||
private removeComponentManagerObserver?: () => void;
|
||||
|
||||
private removeComponentsObserver!: () => void;
|
||||
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
@@ -123,28 +125,30 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
this.onPanelResizeFinish = this.onPanelResizeFinish.bind(this);
|
||||
this.setScrollPosition = this.setScrollPosition.bind(this);
|
||||
this.resetScrollPosition = this.resetScrollPosition.bind(this);
|
||||
this.editorComponentViewerRequestsReload =
|
||||
this.editorComponentViewerRequestsReload.bind(this);
|
||||
this.onEditorLoad = () => {
|
||||
this.application!.getDesktopService().redoSearch();
|
||||
this.application.getDesktopService().redoSearch();
|
||||
};
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.editor.clearNoteChangeListener();
|
||||
this.removeComponentsObserver();
|
||||
(this.removeComponentsObserver as any) = undefined;
|
||||
this.removeTrashKeyObserver();
|
||||
this.removeComponentStreamObserver?.();
|
||||
(this.removeComponentStreamObserver as unknown) = undefined;
|
||||
this.removeComponentManagerObserver?.();
|
||||
(this.removeComponentManagerObserver as unknown) = undefined;
|
||||
this.removeTrashKeyObserver?.();
|
||||
this.removeTrashKeyObserver = undefined;
|
||||
this.removeTabObserver && this.removeTabObserver();
|
||||
this.clearNoteProtectionInactivityTimer();
|
||||
this.removeTabObserver?.();
|
||||
this.removeTabObserver = undefined;
|
||||
this.leftPanelPuppet = undefined;
|
||||
this.rightPanelPuppet = undefined;
|
||||
this.onEditorLoad = undefined;
|
||||
this.unregisterComponent();
|
||||
this.unregisterComponent = undefined;
|
||||
this.saveTimeout = undefined;
|
||||
this.statusTimeout = undefined;
|
||||
(this.onPanelResizeFinish as any) = undefined;
|
||||
(this.editorMenuOnSelect as any) = undefined;
|
||||
(this.onPanelResizeFinish as unknown) = undefined;
|
||||
(this.editorMenuOnSelect as unknown) = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
@@ -159,54 +163,82 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.registerKeyboardShortcuts();
|
||||
this.editor.onNoteChange(() => {
|
||||
this.handleEditorNoteChange();
|
||||
});
|
||||
this.editor.onNoteValueChange((note, source) => {
|
||||
if (isPayloadSourceRetrieved(source!)) {
|
||||
this.editorValues.title = note.title;
|
||||
this.editorValues.text = note.text;
|
||||
}
|
||||
if (!this.editorValues.title) {
|
||||
this.editorValues.title = note.title;
|
||||
}
|
||||
if (!this.editorValues.text) {
|
||||
this.editorValues.text = note.text;
|
||||
}
|
||||
|
||||
const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadSource.Constructor && note.dirty;
|
||||
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.lastSyncBegan || note.dirty) {
|
||||
if (note.lastSyncEnd) {
|
||||
if (
|
||||
note.dirty ||
|
||||
note.lastSyncBegan!.getTime() > note.lastSyncEnd!.getTime()
|
||||
) {
|
||||
this.showSavingStatus();
|
||||
} else if (
|
||||
note.lastSyncEnd!.getTime() > note.lastSyncBegan!.getTime()
|
||||
) {
|
||||
this.showAllChangesSavedStatus();
|
||||
}
|
||||
} else {
|
||||
this.showSavingStatus();
|
||||
}
|
||||
}
|
||||
this.editor.setOnNoteValueChange((note, source) => {
|
||||
this.onNoteChanges(note, source);
|
||||
});
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showProtectedWarning: this.appState.notes.showProtectedWarning,
|
||||
});
|
||||
});
|
||||
this.reloadEditorComponent();
|
||||
this.reloadStackComponents();
|
||||
|
||||
const showProtectedWarning =
|
||||
this.note.protected && !this.application.hasProtectionSources();
|
||||
this.setShowProtectedOverlay(showProtectedWarning);
|
||||
|
||||
this.reloadPreferences();
|
||||
|
||||
if (this.note.dirty) {
|
||||
this.showSavingStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private onNoteChanges(note: SNNote, source: PayloadSource): void {
|
||||
if (note.uuid !== this.note.uuid) {
|
||||
throw Error('Editor received changes for non-current note');
|
||||
}
|
||||
if (isPayloadSourceRetrieved(source)) {
|
||||
this.editorValues.title = note.title;
|
||||
this.editorValues.text = note.text;
|
||||
}
|
||||
if (!this.editorValues.title) {
|
||||
this.editorValues.title = note.title;
|
||||
}
|
||||
if (!this.editorValues.text) {
|
||||
this.editorValues.text = note.text;
|
||||
}
|
||||
|
||||
const isTemplateNoteInsertedToBeInteractableWithEditor =
|
||||
source === PayloadSource.Constructor && note.dirty;
|
||||
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.lastSyncBegan || note.dirty) {
|
||||
if (note.lastSyncEnd) {
|
||||
if (
|
||||
note.dirty ||
|
||||
note.lastSyncBegan!.getTime() > note.lastSyncEnd!.getTime()
|
||||
) {
|
||||
this.showSavingStatus();
|
||||
} else if (
|
||||
note.lastSyncEnd!.getTime() > note.lastSyncBegan!.getTime()
|
||||
) {
|
||||
this.showAllChangesSavedStatus();
|
||||
}
|
||||
} else {
|
||||
this.showSavingStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$onDestroy(): void {
|
||||
if (this.state.editorComponentViewer) {
|
||||
this.application.componentManager?.destroyComponentViewer(
|
||||
this.state.editorComponentViewer
|
||||
);
|
||||
}
|
||||
super.$onDestroy();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState() {
|
||||
return {
|
||||
stackComponents: [],
|
||||
availableStackComponents: [],
|
||||
stackComponentViewers: [],
|
||||
editorStateDidLoad: false,
|
||||
editorDebounce: EDITOR_DEBOUNCE,
|
||||
isDesktop: isDesktopApplication(),
|
||||
spellcheck: true,
|
||||
@@ -215,7 +247,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
showEditorMenu: false,
|
||||
showHistoryMenu: false,
|
||||
noteStatus: undefined,
|
||||
editorUnloading: false,
|
||||
textareaUnloading: false,
|
||||
showProtectedWarning: false,
|
||||
} as EditorState;
|
||||
@@ -224,11 +255,11 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
async onAppLaunch() {
|
||||
await super.onAppLaunch();
|
||||
this.streamItems();
|
||||
this.registerComponentHandler();
|
||||
this.registerComponentManagerEventObserver();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppEvent(eventName: ApplicationEvent) {
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.PreferencesChanged:
|
||||
this.reloadPreferences();
|
||||
@@ -261,33 +292,60 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
desc: 'Changes not saved',
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.UnprotectedSessionBegan: {
|
||||
this.setShowProtectedOverlay(false);
|
||||
break;
|
||||
}
|
||||
case ApplicationEvent.UnprotectedSessionExpired: {
|
||||
if (this.note.protected) {
|
||||
this.hideProtectedNoteIfInactive();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleEditorNoteChange() {
|
||||
this.cancelPendingSetStatus();
|
||||
const note = this.editor.note;
|
||||
const showProtectedWarning =
|
||||
note.protected && !this.application.hasProtectionSources();
|
||||
this.setShowProtectedWarning(showProtectedWarning);
|
||||
await this.setState({
|
||||
showActionsMenu: false,
|
||||
showEditorMenu: false,
|
||||
showHistoryMenu: false,
|
||||
noteStatus: undefined,
|
||||
});
|
||||
this.editorValues.title = note.title;
|
||||
this.editorValues.text = note.text;
|
||||
this.reloadEditor();
|
||||
this.reloadPreferences();
|
||||
this.reloadStackComponents();
|
||||
if (note.dirty) {
|
||||
this.showSavingStatus();
|
||||
getSecondsElapsedSinceLastEdit(): number {
|
||||
return (Date.now() - this.note.userModifiedDate.getTime()) / 1000;
|
||||
}
|
||||
|
||||
hideProtectedNoteIfInactive(): void {
|
||||
const secondsElapsedSinceLastEdit = this.getSecondsElapsedSinceLastEdit();
|
||||
if (
|
||||
secondsElapsedSinceLastEdit >=
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction
|
||||
) {
|
||||
this.setShowProtectedOverlay(true);
|
||||
} else {
|
||||
const secondsUntilTheNextCheck =
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
|
||||
secondsElapsedSinceLastEdit;
|
||||
this.startNoteProtectionInactivityTimer(secondsUntilTheNextCheck);
|
||||
}
|
||||
}
|
||||
|
||||
startNoteProtectionInactivityTimer(timerDurationInSeconds: number): void {
|
||||
this.clearNoteProtectionInactivityTimer();
|
||||
this.protectionTimeoutId = setTimeout(() => {
|
||||
this.hideProtectedNoteIfInactive();
|
||||
}, timerDurationInSeconds * 1000);
|
||||
}
|
||||
|
||||
clearNoteProtectionInactivityTimer(): void {
|
||||
if (this.protectionTimeoutId) {
|
||||
clearTimeout(this.protectionTimeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async dismissProtectedWarning() {
|
||||
this.setShowProtectedWarning(false);
|
||||
let showNoteContents = true;
|
||||
if (this.application.hasProtectionSources()) {
|
||||
showNoteContents = await this.application.authorizeNoteAccess(this.note);
|
||||
}
|
||||
if (!showNoteContents) {
|
||||
return;
|
||||
}
|
||||
this.setShowProtectedOverlay(false);
|
||||
this.focusTitle();
|
||||
}
|
||||
|
||||
@@ -307,20 +365,45 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
}
|
||||
|
||||
streamItems() {
|
||||
this.removeComponentsObserver = this.application.streamItems(
|
||||
this.removeComponentStreamObserver = this.application.streamItems(
|
||||
ContentType.Component,
|
||||
async (_items, source) => {
|
||||
if (isPayloadSourceInternalChange(source!)) {
|
||||
if (
|
||||
isPayloadSourceInternalChange(source) ||
|
||||
source === PayloadSource.InitialObserverRegistrationPush
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!this.note) return;
|
||||
this.reloadStackComponents();
|
||||
this.reloadEditor();
|
||||
await this.reloadStackComponents();
|
||||
await this.reloadEditorComponent();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async reloadEditor() {
|
||||
private createComponentViewer(component: SNComponent) {
|
||||
const viewer = this.application.componentManager.createComponentViewer(
|
||||
component,
|
||||
this.note.uuid
|
||||
);
|
||||
return viewer;
|
||||
}
|
||||
|
||||
public async editorComponentViewerRequestsReload(
|
||||
viewer: ComponentViewer
|
||||
): Promise<void> {
|
||||
const component = viewer.component;
|
||||
this.application.componentManager.destroyComponentViewer(viewer);
|
||||
await this.setState({
|
||||
editorComponentViewer: undefined,
|
||||
});
|
||||
await this.setState({
|
||||
editorComponentViewer: this.createComponentViewer(component),
|
||||
editorStateDidLoad: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async reloadEditorComponent() {
|
||||
const newEditor = this.application.componentManager.editorForNote(
|
||||
this.note
|
||||
);
|
||||
@@ -328,22 +411,29 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
if (newEditor && this.editor.isTemplateNote) {
|
||||
await this.editor.insertTemplatedNote();
|
||||
}
|
||||
const currentEditor = this.state.editorComponent;
|
||||
if (currentEditor?.uuid !== newEditor?.uuid) {
|
||||
const currentComponentViewer = this.state.editorComponentViewer;
|
||||
|
||||
if (currentComponentViewer?.componentUuid !== newEditor?.uuid) {
|
||||
if (currentComponentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
currentComponentViewer
|
||||
);
|
||||
}
|
||||
await this.setState({
|
||||
/** Unload current component view so that we create a new one */
|
||||
editorUnloading: true,
|
||||
});
|
||||
await this.setState({
|
||||
/** Reload component view */
|
||||
editorComponent: newEditor,
|
||||
editorUnloading: false,
|
||||
editorComponentViewer: undefined,
|
||||
});
|
||||
if (newEditor) {
|
||||
await this.setState({
|
||||
editorComponentViewer: this.createComponentViewer(newEditor),
|
||||
editorStateDidLoad: true,
|
||||
});
|
||||
}
|
||||
this.reloadFont();
|
||||
} else {
|
||||
await this.setState({
|
||||
editorStateDidLoad: true,
|
||||
});
|
||||
}
|
||||
this.application.componentManager.contextItemDidChangeInArea(
|
||||
ComponentArea.Editor
|
||||
);
|
||||
}
|
||||
|
||||
setMenuState(menu: string, state: boolean) {
|
||||
@@ -370,6 +460,8 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
}
|
||||
|
||||
async editorMenuOnSelect(component?: SNComponent) {
|
||||
const transactions: TransactionalMutation[] = [];
|
||||
|
||||
this.setMenuState('showEditorMenu', false);
|
||||
if (this.appState.getActiveEditor()?.isTemplateNote) {
|
||||
await this.appState.getActiveEditor().insertTemplatedNote();
|
||||
@@ -380,43 +472,56 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
}
|
||||
if (!component) {
|
||||
if (!this.note.prefersPlainEditor) {
|
||||
await this.application.changeItem(this.note.uuid, (mutator) => {
|
||||
const noteMutator = mutator as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = true;
|
||||
transactions.push({
|
||||
itemUuid: this.note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = true;
|
||||
},
|
||||
});
|
||||
this.reloadEditor();
|
||||
}
|
||||
if (
|
||||
this.state.editorComponent?.isExplicitlyEnabledForItem(this.note.uuid)
|
||||
this.state.editorComponentViewer?.component.isExplicitlyEnabledForItem(
|
||||
this.note.uuid
|
||||
)
|
||||
) {
|
||||
await this.disassociateComponentWithCurrentNote(
|
||||
this.state.editorComponent
|
||||
transactions.push(
|
||||
this.transactionForDisassociateComponentWithCurrentNote(
|
||||
this.state.editorComponentViewer.component
|
||||
)
|
||||
);
|
||||
}
|
||||
this.reloadFont();
|
||||
} else if (component.area === ComponentArea.Editor) {
|
||||
const currentEditor = this.state.editorComponent;
|
||||
if (currentEditor && component !== currentEditor) {
|
||||
await this.disassociateComponentWithCurrentNote(currentEditor);
|
||||
const currentEditor = this.state.editorComponentViewer?.component;
|
||||
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
||||
transactions.push(
|
||||
this.transactionForDisassociateComponentWithCurrentNote(currentEditor)
|
||||
);
|
||||
}
|
||||
const prefersPlain = this.note.prefersPlainEditor;
|
||||
if (prefersPlain) {
|
||||
await this.application.changeItem(this.note.uuid, (mutator) => {
|
||||
const noteMutator = mutator as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = false;
|
||||
transactions.push({
|
||||
itemUuid: this.note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.associateComponentWithCurrentNote(component);
|
||||
} else if (component.area === ComponentArea.EditorStack) {
|
||||
await this.toggleStackComponentForCurrentItem(component);
|
||||
transactions.push(
|
||||
this.transactionForAssociateComponentWithCurrentNote(component)
|
||||
);
|
||||
}
|
||||
|
||||
await this.application.runTransactionalMutations(transactions);
|
||||
/** Dirtying can happen above */
|
||||
this.application.sync();
|
||||
}
|
||||
|
||||
hasAvailableExtensions() {
|
||||
return (
|
||||
this.application.actionsManager!.extensionsInContextOfItem(this.note)
|
||||
this.application.actionsManager.extensionsInContextOfItem(this.note)
|
||||
.length > 0
|
||||
);
|
||||
}
|
||||
@@ -475,7 +580,9 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
const truncate = noteText.length > NOTE_PREVIEW_CHAR_LIMIT;
|
||||
const substring = noteText.substring(0, NOTE_PREVIEW_CHAR_LIMIT);
|
||||
const previewPlain = substring + (truncate ? STRING_ELLIPSES : '');
|
||||
// eslint-disable-next-line camelcase
|
||||
noteMutator.preview_plain = previewPlain;
|
||||
// eslint-disable-next-line camelcase
|
||||
noteMutator.preview_html = undefined;
|
||||
}
|
||||
},
|
||||
@@ -584,12 +691,6 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
this.closeAllMenus();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onTitleFocus() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onTitleBlur() {}
|
||||
|
||||
onContentFocus() {
|
||||
this.application
|
||||
.getAppState()
|
||||
@@ -597,17 +698,17 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
this.lastEditorFocusEventSource = undefined;
|
||||
}
|
||||
|
||||
setShowProtectedWarning(show: boolean) {
|
||||
setShowProtectedOverlay(show: boolean) {
|
||||
this.appState.notes.setShowProtectedWarning(show);
|
||||
}
|
||||
|
||||
async deleteNote(permanently: boolean) {
|
||||
if (this.editor.isTemplateNote) {
|
||||
this.application.alertService!.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT);
|
||||
this.application.alertService.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
if (this.note.locked) {
|
||||
this.application.alertService!.alert(STRING_DELETE_LOCKED_ATTEMPT);
|
||||
this.application.alertService.alert(STRING_DELETE_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
const title = this.note.safeTitle().length
|
||||
@@ -723,25 +824,15 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
|
||||
/** @components */
|
||||
|
||||
registerComponentHandler() {
|
||||
this.unregisterComponent =
|
||||
this.application.componentManager.registerHandler({
|
||||
identifier: 'editor',
|
||||
areas: [ComponentArea.EditorStack, ComponentArea.Editor],
|
||||
contextRequestHandler: (componentUuid) => {
|
||||
const currentEditor = this.state.editorComponent;
|
||||
if (
|
||||
componentUuid === currentEditor?.uuid ||
|
||||
Uuids(this.state.stackComponents).includes(componentUuid)
|
||||
) {
|
||||
return this.note;
|
||||
}
|
||||
},
|
||||
focusHandler: (component, focused) => {
|
||||
if (component.isEditor() && focused) {
|
||||
registerComponentManagerEventObserver() {
|
||||
this.removeComponentManagerObserver =
|
||||
this.application.componentManager.addEventObserver((eventName, data) => {
|
||||
if (eventName === ComponentManagerEvent.ViewerDidFocus) {
|
||||
const viewer = data?.componentViewer;
|
||||
if (viewer?.component.isEditor) {
|
||||
this.closeAllMenus();
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -751,58 +842,98 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
||||
.componentsForArea(ComponentArea.EditorStack)
|
||||
.filter((component) => component.active)
|
||||
);
|
||||
if (this.note) {
|
||||
for (const component of stackComponents) {
|
||||
if (component.active) {
|
||||
this.application.componentManager.setComponentHidden(
|
||||
component,
|
||||
!component.isExplicitlyEnabledForItem(this.note.uuid)
|
||||
);
|
||||
}
|
||||
const enabledComponents = stackComponents.filter((component) => {
|
||||
return component.isExplicitlyEnabledForItem(this.note.uuid);
|
||||
});
|
||||
|
||||
const needsNewViewer = enabledComponents.filter((component) => {
|
||||
const hasExistingViewer = this.state.stackComponentViewers.find(
|
||||
(viewer) => viewer.componentUuid === component.uuid
|
||||
);
|
||||
return !hasExistingViewer;
|
||||
});
|
||||
|
||||
const needsDestroyViewer = this.state.stackComponentViewers.filter(
|
||||
(viewer) => {
|
||||
const viewerComponentExistsInEnabledComponents = enabledComponents.find(
|
||||
(component) => {
|
||||
return component.uuid === viewer.componentUuid;
|
||||
}
|
||||
);
|
||||
return !viewerComponentExistsInEnabledComponents;
|
||||
}
|
||||
);
|
||||
|
||||
const newViewers: ComponentViewer[] = [];
|
||||
for (const component of needsNewViewer) {
|
||||
newViewers.push(
|
||||
this.application.componentManager.createComponentViewer(
|
||||
component,
|
||||
this.note.uuid
|
||||
)
|
||||
);
|
||||
}
|
||||
await this.setState({ stackComponents });
|
||||
this.application.componentManager.contextItemDidChangeInArea(
|
||||
ComponentArea.EditorStack
|
||||
|
||||
for (const viewer of needsDestroyViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(viewer);
|
||||
}
|
||||
await this.setState({
|
||||
availableStackComponents: stackComponents,
|
||||
stackComponentViewers: newViewers,
|
||||
});
|
||||
}
|
||||
|
||||
stackComponentExpanded(component: SNComponent): boolean {
|
||||
return !!this.state.stackComponentViewers.find(
|
||||
(viewer) => viewer.componentUuid === component.uuid
|
||||
);
|
||||
}
|
||||
|
||||
stackComponentHidden(component: SNComponent) {
|
||||
return this.application.componentManager?.isComponentHidden(component);
|
||||
}
|
||||
|
||||
async toggleStackComponentForCurrentItem(component: SNComponent) {
|
||||
const hidden =
|
||||
this.application.componentManager.isComponentHidden(component);
|
||||
if (hidden || !component.active) {
|
||||
this.application.componentManager.setComponentHidden(component, false);
|
||||
async toggleStackComponent(component: SNComponent) {
|
||||
if (!component.isExplicitlyEnabledForItem(this.note.uuid)) {
|
||||
await this.associateComponentWithCurrentNote(component);
|
||||
this.application.componentManager.contextItemDidChangeInArea(
|
||||
ComponentArea.EditorStack
|
||||
);
|
||||
} else {
|
||||
this.application.componentManager.setComponentHidden(component, true);
|
||||
await this.disassociateComponentWithCurrentNote(component);
|
||||
}
|
||||
this.application.sync();
|
||||
}
|
||||
|
||||
async disassociateComponentWithCurrentNote(component: SNComponent) {
|
||||
return this.application.runTransactionalMutation(
|
||||
this.transactionForDisassociateComponentWithCurrentNote(component)
|
||||
);
|
||||
}
|
||||
|
||||
transactionForDisassociateComponentWithCurrentNote(component: SNComponent) {
|
||||
const note = this.note;
|
||||
return this.application.changeItem(component.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.removeAssociatedItemId(note.uuid);
|
||||
mutator.disassociateWithItem(note.uuid);
|
||||
});
|
||||
const transaction: TransactionalMutation = {
|
||||
itemUuid: component.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.removeAssociatedItemId(note.uuid);
|
||||
mutator.disassociateWithItem(note.uuid);
|
||||
},
|
||||
};
|
||||
return transaction;
|
||||
}
|
||||
|
||||
async associateComponentWithCurrentNote(component: SNComponent) {
|
||||
return this.application.runTransactionalMutation(
|
||||
this.transactionForAssociateComponentWithCurrentNote(component)
|
||||
);
|
||||
}
|
||||
|
||||
transactionForAssociateComponentWithCurrentNote(component: SNComponent) {
|
||||
const note = this.note;
|
||||
return this.application.changeItem(component.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.removeDisassociatedItemId(note.uuid);
|
||||
mutator.associateWithItem(note.uuid);
|
||||
});
|
||||
const transaction: TransactionalMutation = {
|
||||
itemUuid: component.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.removeDisassociatedItemId(note.uuid);
|
||||
mutator.associateWithItem(note.uuid);
|
||||
},
|
||||
};
|
||||
return transaction;
|
||||
}
|
||||
|
||||
registerKeyboardShortcuts() {
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
ng-if='ctrl.state.hasAccountSwitcher'
|
||||
ng-click='ctrl.openAccountSwitcher()',
|
||||
)
|
||||
#account-switcher-icon(ng-class='{"alone": !ctrl.state.hasPasscode}')
|
||||
svg.info.ionicon
|
||||
#account-switcher-icon.flex.items-center(ng-class='{"alone": !ctrl.state.hasPasscode}')
|
||||
svg.info.ionicon.w-5.h-5
|
||||
use(href="#layers-sharp")
|
||||
.sk-app-bar-item.border(ng-if='ctrl.state.hasPasscode')
|
||||
#lock-item.sk-app-bar-item(
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
SNTheme,
|
||||
ComponentArea,
|
||||
CollectionSort,
|
||||
} from '@standardnotes/snjs';
|
||||
import template from './footer-view.pug';
|
||||
@@ -43,7 +42,6 @@ class FooterViewCtrl extends PureViewCtrl<
|
||||
> {
|
||||
private $rootScope: ng.IRootScopeService;
|
||||
private showSyncResolution = false;
|
||||
private unregisterComponent: any;
|
||||
private rootScopeListener2: any;
|
||||
public arbitraryStatusMessage?: string;
|
||||
public user?: any;
|
||||
@@ -73,8 +71,6 @@ class FooterViewCtrl extends PureViewCtrl<
|
||||
deinit() {
|
||||
for (const remove of this.observerRemovers) remove();
|
||||
this.observerRemovers.length = 0;
|
||||
this.unregisterComponent();
|
||||
this.unregisterComponent = undefined;
|
||||
this.rootScopeListener2();
|
||||
this.rootScopeListener2 = undefined;
|
||||
(this.closeAccountMenu as unknown) = undefined;
|
||||
@@ -146,7 +142,6 @@ class FooterViewCtrl extends PureViewCtrl<
|
||||
this.updateOfflineStatus();
|
||||
this.findErrors();
|
||||
this.streamItems();
|
||||
this.registerComponentHandler();
|
||||
}
|
||||
|
||||
reloadUser() {
|
||||
@@ -273,25 +268,6 @@ class FooterViewCtrl extends PureViewCtrl<
|
||||
);
|
||||
}
|
||||
|
||||
registerComponentHandler() {
|
||||
this.unregisterComponent =
|
||||
this.application.componentManager.registerHandler({
|
||||
identifier: 'room-bar',
|
||||
areas: [ComponentArea.Modal],
|
||||
focusHandler: (component, focused) => {
|
||||
if (component.isEditor() && focused) {
|
||||
if (
|
||||
component.package_info?.identifier ===
|
||||
'org.standardnotes.standard-sheets'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.getStatusManager();
|
||||
const syncStatus = this.application.getSyncStatus();
|
||||
|
||||
@@ -4,6 +4,5 @@ export { ApplicationView } from './application/application_view';
|
||||
export { EditorGroupView } from './editor_group/editor_group_view';
|
||||
export { EditorView } from './editor/editor_view';
|
||||
export { FooterView } from './footer/footer_view';
|
||||
export { NotesView } from './notes/notes_view';
|
||||
export { TagsView } from './tags/tags_view';
|
||||
export { ChallengeModal } from './challenge_modal/challenge_modal';
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
#notes-column.sn-component.section.notes(aria-label='Notes')
|
||||
.content
|
||||
#notes-title-bar.section-title-bar
|
||||
.p-4
|
||||
.section-title-bar-header
|
||||
.sk-h2.font-semibold.title {{self.state.panelTitle}}
|
||||
.sk-button.contrast.wide(
|
||||
ng-click='self.createNewNote()',
|
||||
title='Create a new note in the selected tag'
|
||||
aria-label="Create new note"
|
||||
)
|
||||
.sk-label
|
||||
i.icon.ion-plus.add-button
|
||||
.filter-section(role='search')
|
||||
input#search-bar.filter-bar(
|
||||
type="text"
|
||||
ng-ref='self.searchBarInput'
|
||||
ng-focus='self.onSearchInputFocus()'
|
||||
ng-blur='self.onSearchInputBlur()',
|
||||
ng-change='self.filterTextChanged()',
|
||||
ng-keyup='$event.keyCode == 13 && self.onFilterEnter();',
|
||||
ng-model='self.state.noteFilter.text',
|
||||
placeholder='Search',
|
||||
select-on-focus='true',
|
||||
title='Searches notes in the currently selected tag'
|
||||
)
|
||||
#search-clear-button(
|
||||
ng-click='self.clearFilterText();',
|
||||
ng-show='self.state.noteFilter.text'
|
||||
aria-role="button"
|
||||
) ✕
|
||||
search-options(
|
||||
class="ml-2"
|
||||
app-state='self.appState'
|
||||
)
|
||||
no-account-warning(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
#notes-menu-bar.sn-component
|
||||
.sk-app-bar.no-edges
|
||||
.left
|
||||
.sk-app-bar-item(
|
||||
ng-class="{'selected' : self.state.mutable.showMenu}",
|
||||
ng-click='self.state.mutable.showMenu = !self.state.mutable.showMenu'
|
||||
)
|
||||
.sk-app-bar-item-column
|
||||
.sk-label
|
||||
| Options
|
||||
.sk-app-bar-item-column
|
||||
.sk-sublabel {{self.optionsSubtitle()}}
|
||||
notes-list-options-menu(
|
||||
ng-if='self.state.mutable.showMenu'
|
||||
app-state='self.appState'
|
||||
application='self.application'
|
||||
set-show-menu-false='self.setShowMenuFalse'
|
||||
)
|
||||
p.empty-notes-list.faded(
|
||||
ng-if="self.state.completedFullSync && !self.state.renderedNotes.length"
|
||||
) No notes.
|
||||
p.empty-notes-list.faded(
|
||||
ng-if="!self.state.completedFullSync && !self.state.renderedNotes.length"
|
||||
) Loading notes…
|
||||
.scrollable(ng-if="self.state.renderedNotes.length")
|
||||
#notes-scrollable.infinite-scroll(
|
||||
can-load='true',
|
||||
infinite-scroll='self.paginate()',
|
||||
threshold='200'
|
||||
)
|
||||
.note(
|
||||
ng-attr-id='note-{{note.uuid}}'
|
||||
ng-repeat='note in self.state.renderedNotes track by note.uuid'
|
||||
ng-class="{'selected' : self.isNoteSelected(note.uuid) }"
|
||||
ng-click='self.selectNote(note, true)'
|
||||
)
|
||||
.note-flags.flex.flex-wrap(ng-show='self.noteFlags[note.uuid].length > 0')
|
||||
.flag(ng-class='flag.class', ng-repeat='flag in self.noteFlags[note.uuid]')
|
||||
.label {{flag.text}}
|
||||
.name(ng-show='note.title')
|
||||
| {{note.title}}
|
||||
.note-preview(
|
||||
ng-if=`
|
||||
!self.state.hideNotePreview &&
|
||||
!note.hidePreview &&
|
||||
!note.protected`
|
||||
)
|
||||
.html-preview(
|
||||
ng-bind-html='note.preview_html',
|
||||
ng-show='note.preview_html'
|
||||
)
|
||||
.plain-preview(
|
||||
ng-show='!note.preview_html && note.preview_plain'
|
||||
) {{note.preview_plain}}
|
||||
.default-preview(
|
||||
ng-show='!note.preview_html && !note.preview_plain'
|
||||
) {{note.text}}
|
||||
.bottom-info.faded(ng-show='!self.state.hideDate || note.protected')
|
||||
span(ng-if="note.protected")
|
||||
| Protected{{self.state.hideDate ? '' : ' • '}}
|
||||
span(ng-show="!self.state.hideDate && self.state.sortBy == 'userModifiedDate'")
|
||||
| Modified {{note.updatedAtString || 'Now'}}
|
||||
span(ng-show="!self.state.hideDate && self.state.sortBy != 'userModifiedDate'")
|
||||
| {{note.createdAtString || 'Now'}}
|
||||
.tags-string(ng-if='!self.state.hideTags && self.state.renderedNotesTags[$index]')
|
||||
.faded {{self.state.renderedNotesTags[$index]}}
|
||||
|
||||
panel-resizer(
|
||||
collapsable="true"
|
||||
control="self.panelPuppet"
|
||||
default-width="300"
|
||||
hoverable="true"
|
||||
on-resize-finish="self.onPanelResize"
|
||||
on-width-event="self.onPanelWidthEvent"
|
||||
panel-id="'notes-column'"
|
||||
)
|
||||
@@ -1,955 +0,0 @@
|
||||
import { PanelPuppet, WebDirective } from './../../types';
|
||||
import template from './notes-view.pug';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
removeFromArray,
|
||||
SNNote,
|
||||
SNTag,
|
||||
PrefKey,
|
||||
findInArray,
|
||||
CollectionSort,
|
||||
UuidString,
|
||||
NotesDisplayCriteria
|
||||
} from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { AppStateEvent } from '@/ui_models/app_state';
|
||||
import { KeyboardKey, KeyboardModifier } from '@/services/ioService';
|
||||
import {
|
||||
PANEL_NAME_NOTES
|
||||
} from '@/views/constants';
|
||||
|
||||
type NotesCtrlState = {
|
||||
panelTitle: string
|
||||
notes: SNNote[]
|
||||
renderedNotes: SNNote[]
|
||||
renderedNotesTags: string[],
|
||||
selectedNotes: Record<UuidString, SNNote>,
|
||||
sortBy?: string
|
||||
sortReverse?: boolean
|
||||
showArchived?: boolean
|
||||
hidePinned?: boolean
|
||||
hideNotePreview?: boolean
|
||||
hideDate?: boolean
|
||||
hideTags: boolean
|
||||
noteFilter: {
|
||||
text: string;
|
||||
}
|
||||
searchOptions: {
|
||||
includeProtectedContents: boolean;
|
||||
includeArchived: boolean;
|
||||
includeTrashed: boolean;
|
||||
}
|
||||
mutable: { showMenu: boolean }
|
||||
completedFullSync: boolean
|
||||
[PrefKey.TagsPanelWidth]?: number
|
||||
[PrefKey.NotesPanelWidth]?: number
|
||||
[PrefKey.EditorWidth]?: number
|
||||
[PrefKey.EditorLeft]?: number
|
||||
[PrefKey.EditorMonospaceEnabled]?: boolean
|
||||
[PrefKey.EditorSpellcheck]?: boolean
|
||||
[PrefKey.EditorResizersEnabled]?: boolean
|
||||
[PrefKey.NotesShowTrashed]?: boolean
|
||||
[PrefKey.NotesHideProtected]?: boolean
|
||||
}
|
||||
|
||||
type NoteFlag = {
|
||||
text: string
|
||||
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger'
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the height of a note cell with nothing but the title,
|
||||
* which *is* a display option
|
||||
*/
|
||||
const MIN_NOTE_CELL_HEIGHT = 51.0;
|
||||
const DEFAULT_LIST_NUM_NOTES = 20;
|
||||
const ELEMENT_ID_SEARCH_BAR = 'search-bar';
|
||||
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable';
|
||||
|
||||
class NotesViewCtrl extends PureViewCtrl<unknown, NotesCtrlState> {
|
||||
|
||||
private panelPuppet?: PanelPuppet
|
||||
private reloadNotesPromise?: any
|
||||
private notesToDisplay = 0
|
||||
private pageSize = 0
|
||||
private searchSubmitted = false
|
||||
private newNoteKeyObserver: any
|
||||
private nextNoteKeyObserver: any
|
||||
private previousNoteKeyObserver: any
|
||||
private searchKeyObserver: any
|
||||
private noteFlags: Partial<Record<UuidString, NoteFlag[]>> = {}
|
||||
private removeObservers: Array<() => void> = [];
|
||||
private rightClickListeners: Map<UuidString, (e: MouseEvent) => void> = new Map();
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService,) {
|
||||
super($timeout);
|
||||
this.resetPagination();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.panelPuppet = {
|
||||
onReady: () => this.reloadPanelWidth()
|
||||
};
|
||||
this.onWindowResize = this.onWindowResize.bind(this);
|
||||
this.onPanelResize = this.onPanelResize.bind(this);
|
||||
this.onPanelWidthEvent = this.onPanelWidthEvent.bind(this);
|
||||
this.setShowMenuFalse = this.setShowMenuFalse.bind(this);
|
||||
window.addEventListener('resize', this.onWindowResize, true);
|
||||
this.registerKeyboardShortcuts();
|
||||
this.autorun(async () => {
|
||||
const {
|
||||
includeProtectedContents,
|
||||
includeArchived,
|
||||
includeTrashed,
|
||||
} = this.appState.searchOptions;
|
||||
await this.setState({
|
||||
searchOptions: {
|
||||
includeProtectedContents,
|
||||
includeArchived,
|
||||
includeTrashed,
|
||||
}
|
||||
});
|
||||
if (this.state.noteFilter.text) {
|
||||
this.reloadNotesDisplayOptions();
|
||||
this.reloadNotes();
|
||||
}
|
||||
});
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
selectedNotes: this.appState.notes.selectedNotes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
this.resetPagination(true);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
for (const remove of this.removeObservers) remove();
|
||||
this.removeObservers.length = 0;
|
||||
this.removeRightClickListeners();
|
||||
this.panelPuppet!.onReady = undefined;
|
||||
this.panelPuppet = undefined;
|
||||
window.removeEventListener('resize', this.onWindowResize, true);
|
||||
(this.onWindowResize as any) = undefined;
|
||||
(this.onPanelResize as any) = undefined;
|
||||
(this.onPanelWidthEvent as any) = undefined;
|
||||
this.newNoteKeyObserver();
|
||||
this.nextNoteKeyObserver();
|
||||
this.previousNoteKeyObserver();
|
||||
this.searchKeyObserver();
|
||||
this.newNoteKeyObserver = undefined;
|
||||
this.nextNoteKeyObserver = undefined;
|
||||
this.previousNoteKeyObserver = undefined;
|
||||
this.searchKeyObserver = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
async setNotesState(state: Partial<NotesCtrlState>) {
|
||||
return this.setState(state);
|
||||
}
|
||||
|
||||
getInitialState(): NotesCtrlState {
|
||||
return {
|
||||
notes: [],
|
||||
renderedNotes: [],
|
||||
renderedNotesTags: [],
|
||||
selectedNotes: {},
|
||||
mutable: { showMenu: false },
|
||||
noteFilter: {
|
||||
text: '',
|
||||
},
|
||||
searchOptions: {
|
||||
includeArchived: false,
|
||||
includeProtectedContents: false,
|
||||
includeTrashed: false,
|
||||
},
|
||||
panelTitle: '',
|
||||
completedFullSync: false,
|
||||
hideTags: true
|
||||
};
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.streamNotesAndTags();
|
||||
this.reloadPreferences();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent, data?: any) {
|
||||
if (eventName === AppStateEvent.TagChanged) {
|
||||
this.handleTagChange(this.selectedTag!);
|
||||
} else if (eventName === AppStateEvent.ActiveEditorChanged) {
|
||||
this.handleEditorChange();
|
||||
} else if (eventName === AppStateEvent.EditorFocused) {
|
||||
this.setShowMenuFalse();
|
||||
}
|
||||
}
|
||||
|
||||
private get activeEditorNote() {
|
||||
return this.appState.notes.activeEditor?.note;
|
||||
}
|
||||
|
||||
/** @template */
|
||||
public isNoteSelected(uuid: UuidString) {
|
||||
return !!this.state.selectedNotes[uuid];
|
||||
}
|
||||
|
||||
public get editorNotes() {
|
||||
return this.appState.getEditors().map((editor) => editor.note);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.PreferencesChanged:
|
||||
this.reloadPreferences();
|
||||
break;
|
||||
case ApplicationEvent.SignedIn:
|
||||
this.appState.closeAllEditors();
|
||||
this.selectFirstNote();
|
||||
this.setState({
|
||||
completedFullSync: false,
|
||||
});
|
||||
break;
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
this.getMostValidNotes().then((notes) => {
|
||||
if (notes.length === 0 && this.selectedTag?.isAllTag && this.state.noteFilter.text === '') {
|
||||
this.createPlaceholderNote();
|
||||
}
|
||||
});
|
||||
this.setState({
|
||||
completedFullSync: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the current state notes without awaiting any potential reloads
|
||||
* that may be in progress. This is the sync alternative to `async getMostValidNotes`
|
||||
*/
|
||||
private getPossiblyStaleNotes() {
|
||||
return this.state.notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the current state notes after waiting for any pending reloads.
|
||||
* This returns the most up to date notes, but is the asyncronous counterpart
|
||||
* to `getPossiblyStaleNotes`
|
||||
*/
|
||||
private async getMostValidNotes() {
|
||||
await this.reloadNotesPromise;
|
||||
return this.getPossiblyStaleNotes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered programatically to create a new placeholder note
|
||||
* when conditions allow for it. This is as opposed to creating a new note
|
||||
* as part of user interaction (pressing the + button).
|
||||
*/
|
||||
private async createPlaceholderNote() {
|
||||
const selectedTag = this.selectedTag!;
|
||||
if (selectedTag.isSmartTag && !selectedTag.isAllTag) {
|
||||
return;
|
||||
}
|
||||
return this.createNewNote(false);
|
||||
}
|
||||
|
||||
streamNotesAndTags() {
|
||||
this.removeObservers.push(this.application.streamItems(
|
||||
[ContentType.Note],
|
||||
async (items) => {
|
||||
const notes = items as SNNote[];
|
||||
/** Note has changed values, reset its flags */
|
||||
for (const note of notes) {
|
||||
if (note.deleted) {
|
||||
continue;
|
||||
}
|
||||
this.loadFlagsForNote(note);
|
||||
}
|
||||
/** If a note changes, it will be queried against the existing filter;
|
||||
* we dont need to reload display options */
|
||||
await this.reloadNotes();
|
||||
const activeNote = this.activeEditorNote;
|
||||
if (this.application.getAppState().notes.selectedNotesCount < 2) {
|
||||
if (activeNote) {
|
||||
const discarded = activeNote.deleted || activeNote.trashed;
|
||||
if (
|
||||
discarded &&
|
||||
!this.appState?.selectedTag?.isTrashTag &&
|
||||
!this.appState?.searchOptions.includeTrashed
|
||||
) {
|
||||
this.selectNextOrCreateNew();
|
||||
} else if (!this.state.selectedNotes[activeNote.uuid]) {
|
||||
this.selectNote(activeNote);
|
||||
}
|
||||
} else {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
this.removeObservers.push(this.application.streamItems(
|
||||
[ContentType.Tag],
|
||||
async (items) => {
|
||||
const tags = items as SNTag[];
|
||||
/** A tag could have changed its relationships, so we need to reload the filter */
|
||||
this.reloadNotesDisplayOptions();
|
||||
await this.reloadNotes();
|
||||
if (findInArray(tags, 'uuid', this.appState.selectedTag?.uuid)) {
|
||||
/** Tag title could have changed */
|
||||
this.reloadPanelTitle();
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
private async openNotesContextMenu(e: MouseEvent, note: SNNote) {
|
||||
e.preventDefault();
|
||||
if (!this.state.selectedNotes[note.uuid]) {
|
||||
await this.selectNote(note, true);
|
||||
}
|
||||
if (this.state.selectedNotes[note.uuid]) {
|
||||
this.appState.notes.setContextMenuClickLocation({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
this.appState.notes.reloadContextMenuLayout();
|
||||
this.appState.notes.setContextMenuOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
private removeRightClickListeners() {
|
||||
for (const [noteUuid, listener] of this.rightClickListeners.entries()) {
|
||||
document
|
||||
.getElementById(`note-${noteUuid}`)
|
||||
?.removeEventListener('contextmenu', listener);
|
||||
}
|
||||
this.rightClickListeners.clear();
|
||||
}
|
||||
|
||||
private addRightClickListeners() {
|
||||
for (const [noteUuid, listener] of this.rightClickListeners.entries()) {
|
||||
if (!this.state.renderedNotes.find(note => note.uuid === noteUuid)) {
|
||||
document
|
||||
.getElementById(`note-${noteUuid}`)
|
||||
?.removeEventListener('contextmenu', listener);
|
||||
this.rightClickListeners.delete(noteUuid);
|
||||
}
|
||||
}
|
||||
for (const note of this.state.renderedNotes) {
|
||||
if (!this.rightClickListeners.has(note.uuid)) {
|
||||
const listener = async (e: MouseEvent): Promise<void> => {
|
||||
return await this.openNotesContextMenu(e, note);
|
||||
};
|
||||
document
|
||||
.getElementById(`note-${note.uuid}`)
|
||||
?.addEventListener('contextmenu', listener);
|
||||
this.rightClickListeners.set(note.uuid, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selectNote(note: SNNote, userTriggered?: boolean): Promise<void> {
|
||||
await this.appState.notes.selectNote(note.uuid, userTriggered);
|
||||
}
|
||||
|
||||
async createNewNote(focusNewNote = true) {
|
||||
this.appState.notes.unselectNotes();
|
||||
let title = `Note ${this.state.notes.length + 1}`;
|
||||
if (this.isFiltering()) {
|
||||
title = this.state.noteFilter.text;
|
||||
}
|
||||
await this.appState.createEditor(title);
|
||||
await this.flushUI();
|
||||
await this.reloadNotes();
|
||||
await this.appState.noteTags.reloadTags();
|
||||
const noteTitleEditorElement = document.getElementById('note-title-editor');
|
||||
if (focusNewNote) {
|
||||
noteTitleEditorElement?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async handleTagChange(tag: SNTag) {
|
||||
this.resetScrollPosition();
|
||||
this.setShowMenuFalse();
|
||||
await this.setNoteFilterText('');
|
||||
this.application.getDesktopService().searchText();
|
||||
this.resetPagination();
|
||||
|
||||
/* Capture db load state before beginning reloadNotes,
|
||||
since this status may change during reload */
|
||||
const dbLoaded = this.application.isDatabaseLoaded();
|
||||
this.reloadNotesDisplayOptions();
|
||||
await this.reloadNotes();
|
||||
|
||||
if (this.state.notes.length > 0) {
|
||||
this.selectFirstNote();
|
||||
} else if (dbLoaded) {
|
||||
if (
|
||||
this.activeEditorNote &&
|
||||
!this.state.notes.includes(this.activeEditorNote!)
|
||||
) {
|
||||
this.appState.closeActiveEditor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetScrollPosition() {
|
||||
const scrollable = document.getElementById(ELEMENT_ID_SCROLL_CONTAINER);
|
||||
if (scrollable) {
|
||||
scrollable.scrollTop = 0;
|
||||
scrollable.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async removeNoteFromList(note: SNNote) {
|
||||
const notes = this.state.notes;
|
||||
removeFromArray(notes, note);
|
||||
await this.setNotesState({
|
||||
notes: notes,
|
||||
renderedNotes: notes.slice(0, this.notesToDisplay)
|
||||
});
|
||||
}
|
||||
|
||||
private async reloadNotes() {
|
||||
this.reloadNotesPromise = this.performReloadNotes();
|
||||
return this.reloadNotesPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that reloading display options destroys the current index and rebuilds it,
|
||||
* so call sparingly. The runtime complexity of destroying and building
|
||||
* an index is roughly O(n^2).
|
||||
*/
|
||||
private reloadNotesDisplayOptions() {
|
||||
const tag = this.appState.selectedTag;
|
||||
|
||||
const searchText = this.state.noteFilter.text.toLowerCase();
|
||||
const isSearching = searchText.length;
|
||||
let includeArchived: boolean;
|
||||
let includeTrashed: boolean;
|
||||
|
||||
if (isSearching) {
|
||||
includeArchived = this.state.searchOptions.includeArchived;
|
||||
includeTrashed = this.state.searchOptions.includeTrashed;
|
||||
} else {
|
||||
includeArchived = this.state.showArchived ?? false;
|
||||
includeTrashed = this.state.showTrashed ?? false;
|
||||
}
|
||||
|
||||
const criteria = NotesDisplayCriteria.Create({
|
||||
sortProperty: this.state.sortBy as CollectionSort,
|
||||
sortDirection: this.state.sortReverse ? 'asc' : 'dsc',
|
||||
tags: tag ? [tag] : [],
|
||||
includeArchived,
|
||||
includeTrashed,
|
||||
includePinned: !this.state.hidePinned,
|
||||
includeProtected: !this.state.hideProtected,
|
||||
searchQuery: {
|
||||
query: searchText,
|
||||
includeProtectedNoteText: this.state.searchOptions.includeProtectedContents
|
||||
}
|
||||
});
|
||||
this.application.setNotesDisplayCriteria(criteria);
|
||||
}
|
||||
|
||||
private get selectedTag() {
|
||||
return this.application.getAppState().getSelectedTag();
|
||||
}
|
||||
|
||||
private async performReloadNotes() {
|
||||
const tag = this.appState.selectedTag!;
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
const notes = this.application.getDisplayableItems(
|
||||
ContentType.Note
|
||||
) as SNNote[];
|
||||
const renderedNotes = notes.slice(0, this.notesToDisplay);
|
||||
const renderedNotesTags = this.notesTagsList(renderedNotes);
|
||||
|
||||
await this.setNotesState({
|
||||
notes,
|
||||
renderedNotesTags,
|
||||
renderedNotes,
|
||||
});
|
||||
this.reloadPanelTitle();
|
||||
this.addRightClickListeners();
|
||||
}
|
||||
|
||||
private notesTagsList(notes: SNNote[]): string[] {
|
||||
if (this.state.hideTags) {
|
||||
return [];
|
||||
} else {
|
||||
const selectedTag = this.appState.selectedTag;
|
||||
if (!selectedTag) {
|
||||
return [];
|
||||
} else if (selectedTag?.isSmartTag) {
|
||||
return notes.map((note) =>
|
||||
this.appState
|
||||
.getNoteTags(note)
|
||||
.map((tag) => '#' + tag.title)
|
||||
.join(' ')
|
||||
);
|
||||
} else {
|
||||
/**
|
||||
* Displaying a normal tag, hide the note's tag when there's only one
|
||||
*/
|
||||
return notes.map((note) => {
|
||||
const tags = this.appState.getNoteTags(note);
|
||||
if (tags.length === 1) return '';
|
||||
return tags.map((tag) => '#' + tag.title).join(' ');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setShowMenuFalse() {
|
||||
this.setNotesState({
|
||||
mutable: {
|
||||
...this.state.mutable,
|
||||
showMenu: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleEditorChange() {
|
||||
const activeNote = this.appState.getActiveEditor()?.note;
|
||||
if (activeNote && activeNote.conflictOf) {
|
||||
this.application.changeAndSaveItem(activeNote.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
if (this.isFiltering()) {
|
||||
this.application.getDesktopService().searchText(this.state.noteFilter.text);
|
||||
}
|
||||
}
|
||||
|
||||
async reloadPreferences() {
|
||||
const viewOptions = {} as NotesCtrlState;
|
||||
const prevSortValue = this.state.sortBy;
|
||||
let sortBy = this.application.getPreference(
|
||||
PrefKey.SortNotesBy,
|
||||
CollectionSort.CreatedAt
|
||||
);
|
||||
if (
|
||||
sortBy === CollectionSort.UpdatedAt ||
|
||||
(sortBy as string) === "client_updated_at"
|
||||
) {
|
||||
/** Use UserUpdatedAt instead */
|
||||
sortBy = CollectionSort.UpdatedAt;
|
||||
}
|
||||
viewOptions.sortBy = sortBy;
|
||||
viewOptions.sortReverse = this.application.getPreference(
|
||||
PrefKey.SortNotesReverse,
|
||||
false
|
||||
);
|
||||
viewOptions.showArchived = this.application.getPreference(
|
||||
PrefKey.NotesShowArchived,
|
||||
false
|
||||
);
|
||||
viewOptions.showTrashed = this.application.getPreference(
|
||||
PrefKey.NotesShowTrashed,
|
||||
false
|
||||
) as boolean;
|
||||
viewOptions.hidePinned = this.application.getPreference(
|
||||
PrefKey.NotesHidePinned,
|
||||
false
|
||||
);
|
||||
viewOptions.hideProtected = this.application.getPreference(
|
||||
PrefKey.NotesHideProtected,
|
||||
false
|
||||
);
|
||||
viewOptions.hideNotePreview = this.application.getPreference(
|
||||
PrefKey.NotesHideNotePreview,
|
||||
false
|
||||
);
|
||||
viewOptions.hideDate = this.application.getPreference(
|
||||
PrefKey.NotesHideDate,
|
||||
false
|
||||
);
|
||||
viewOptions.hideTags = this.application.getPreference(
|
||||
PrefKey.NotesHideTags,
|
||||
true,
|
||||
);
|
||||
const state = this.state;
|
||||
const displayOptionsChanged = (
|
||||
viewOptions.sortBy !== state.sortBy ||
|
||||
viewOptions.sortReverse !== state.sortReverse ||
|
||||
viewOptions.hidePinned !== state.hidePinned ||
|
||||
viewOptions.showArchived !== state.showArchived ||
|
||||
viewOptions.showTrashed !== state.showTrashed ||
|
||||
viewOptions.hideProtected !== state.hideProtected ||
|
||||
viewOptions.hideTags !== state.hideTags
|
||||
);
|
||||
await this.setNotesState({
|
||||
...viewOptions
|
||||
});
|
||||
this.reloadPanelWidth();
|
||||
if (displayOptionsChanged) {
|
||||
this.reloadNotesDisplayOptions();
|
||||
}
|
||||
await this.reloadNotes();
|
||||
if (prevSortValue && prevSortValue !== sortBy) {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
}
|
||||
|
||||
reloadPanelWidth() {
|
||||
const width = this.application.getPreference(
|
||||
PrefKey.NotesPanelWidth
|
||||
);
|
||||
if (width && this.panelPuppet!.ready) {
|
||||
this.panelPuppet!.setWidth!(width);
|
||||
if (this.panelPuppet!.isCollapsed!()) {
|
||||
this.application.getAppState().panelDidResize(
|
||||
PANEL_NAME_NOTES,
|
||||
this.panelPuppet!.isCollapsed!()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPanelResize(
|
||||
newWidth: number,
|
||||
newLeft: number,
|
||||
__: boolean,
|
||||
isCollapsed: boolean
|
||||
) {
|
||||
this.appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
this.application.setPreference(
|
||||
PrefKey.NotesPanelWidth,
|
||||
newWidth
|
||||
);
|
||||
this.application.getAppState().panelDidResize(
|
||||
PANEL_NAME_NOTES,
|
||||
isCollapsed
|
||||
);
|
||||
}
|
||||
|
||||
onPanelWidthEvent(): void {
|
||||
this.appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
}
|
||||
|
||||
paginate() {
|
||||
this.notesToDisplay += this.pageSize;
|
||||
this.reloadNotes();
|
||||
if (this.searchSubmitted) {
|
||||
this.application.getDesktopService().searchText(this.state.noteFilter.text);
|
||||
}
|
||||
}
|
||||
|
||||
resetPagination(keepCurrentIfLarger = false) {
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT);
|
||||
if (this.pageSize === 0) {
|
||||
this.pageSize = DEFAULT_LIST_NUM_NOTES;
|
||||
}
|
||||
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
|
||||
return;
|
||||
}
|
||||
this.notesToDisplay = this.pageSize;
|
||||
}
|
||||
|
||||
reloadPanelTitle() {
|
||||
let title;
|
||||
if (this.isFiltering()) {
|
||||
const resultCount = this.state.notes.length;
|
||||
title = `${resultCount} search results`;
|
||||
} else if (this.appState.selectedTag) {
|
||||
title = `${this.appState.selectedTag.title}`;
|
||||
}
|
||||
this.setNotesState({
|
||||
panelTitle: title
|
||||
});
|
||||
}
|
||||
|
||||
optionsSubtitle() {
|
||||
let base = "";
|
||||
if (this.state.sortBy === CollectionSort.CreatedAt) {
|
||||
base += " Date Added";
|
||||
} else if (this.state.sortBy === CollectionSort.UpdatedAt) {
|
||||
base += " Date Modified";
|
||||
} else if (this.state.sortBy === CollectionSort.Title) {
|
||||
base += " Title";
|
||||
}
|
||||
if (this.state.showArchived) {
|
||||
base += " | + Archived";
|
||||
}
|
||||
if (this.state.showTrashed) {
|
||||
base += " | + Trashed";
|
||||
}
|
||||
if (this.state.hidePinned) {
|
||||
base += " | – Pinned";
|
||||
}
|
||||
if (this.state.hideProtected) {
|
||||
base += " | – Protected";
|
||||
}
|
||||
if (this.state.sortReverse) {
|
||||
base += " | Reversed";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
loadFlagsForNote(note: SNNote) {
|
||||
const flags = [] as NoteFlag[];
|
||||
if (note.pinned) {
|
||||
flags.push({
|
||||
text: "Pinned",
|
||||
class: 'info'
|
||||
});
|
||||
}
|
||||
if (note.archived) {
|
||||
flags.push({
|
||||
text: "Archived",
|
||||
class: 'warning'
|
||||
});
|
||||
}
|
||||
if (note.locked) {
|
||||
flags.push({
|
||||
text: "Editing Disabled",
|
||||
class: 'neutral'
|
||||
});
|
||||
}
|
||||
if (note.trashed) {
|
||||
flags.push({
|
||||
text: "Deleted",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
if (note.conflictOf) {
|
||||
flags.push({
|
||||
text: "Conflicted Copy",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
if (note.errorDecrypting) {
|
||||
if (note.waitingForKey) {
|
||||
flags.push({
|
||||
text: "Waiting For Keys",
|
||||
class: 'info'
|
||||
});
|
||||
} else {
|
||||
flags.push({
|
||||
text: "Missing Keys",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
}
|
||||
if (note.deleted) {
|
||||
flags.push({
|
||||
text: "Deletion Pending Sync",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
this.noteFlags[note.uuid] = flags;
|
||||
}
|
||||
|
||||
getFirstNonProtectedNote() {
|
||||
return this.state.notes.find(note => !note.protected);
|
||||
}
|
||||
|
||||
selectFirstNote() {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note);
|
||||
}
|
||||
}
|
||||
|
||||
selectNextNote() {
|
||||
const displayableNotes = this.state.notes;
|
||||
const currentIndex = displayableNotes.findIndex((candidate) => {
|
||||
return candidate.uuid === this.activeEditorNote?.uuid;
|
||||
});
|
||||
if (currentIndex + 1 < displayableNotes.length) {
|
||||
const nextNote = displayableNotes[currentIndex + 1];
|
||||
this.selectNote(nextNote);
|
||||
const nextNoteElement = document.getElementById(`note-${nextNote.uuid}`);
|
||||
nextNoteElement?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
selectNextOrCreateNew() {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note);
|
||||
} else {
|
||||
this.appState.closeActiveEditor();
|
||||
}
|
||||
}
|
||||
|
||||
selectPreviousNote() {
|
||||
const displayableNotes = this.state.notes;
|
||||
const currentIndex = displayableNotes.indexOf(this.activeEditorNote!);
|
||||
if (currentIndex - 1 >= 0) {
|
||||
const previousNote = displayableNotes[currentIndex - 1];
|
||||
this.selectNote(previousNote);
|
||||
const previousNoteElement = document.getElementById(`note-${previousNote.uuid}`);
|
||||
previousNoteElement?.focus();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isFiltering() {
|
||||
return this.state.noteFilter.text &&
|
||||
this.state.noteFilter.text.length > 0;
|
||||
}
|
||||
|
||||
async setNoteFilterText(text: string) {
|
||||
await this.setNotesState({
|
||||
noteFilter: {
|
||||
...this.state.noteFilter,
|
||||
text: text
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clearFilterText() {
|
||||
await this.setNoteFilterText('');
|
||||
this.onFilterEnter();
|
||||
this.filterTextChanged();
|
||||
this.resetPagination();
|
||||
}
|
||||
|
||||
async filterTextChanged() {
|
||||
if (this.searchSubmitted) {
|
||||
this.searchSubmitted = false;
|
||||
}
|
||||
this.reloadNotesDisplayOptions();
|
||||
await this.reloadNotes();
|
||||
}
|
||||
|
||||
async onSearchInputBlur() {
|
||||
this.appState.searchOptions.refreshIncludeProtectedContents();
|
||||
}
|
||||
|
||||
onFilterEnter() {
|
||||
/**
|
||||
* For Desktop, performing a search right away causes
|
||||
* input to lose focus. We wait until user explicity hits
|
||||
* enter before highlighting desktop search results.
|
||||
*/
|
||||
this.searchSubmitted = true;
|
||||
this.application.getDesktopService().searchText(this.state.noteFilter.text);
|
||||
}
|
||||
|
||||
selectedMenuItem() {
|
||||
this.setShowMenuFalse();
|
||||
}
|
||||
|
||||
togglePrefKey(key: PrefKey) {
|
||||
this.application.setPreference(
|
||||
key,
|
||||
!this.state[key]
|
||||
);
|
||||
}
|
||||
|
||||
selectedSortByCreated() {
|
||||
this.setSortBy(CollectionSort.CreatedAt);
|
||||
}
|
||||
|
||||
selectedSortByUpdated() {
|
||||
this.setSortBy(CollectionSort.UpdatedAt);
|
||||
}
|
||||
|
||||
selectedSortByTitle() {
|
||||
this.setSortBy(CollectionSort.Title);
|
||||
}
|
||||
|
||||
toggleReverseSort() {
|
||||
this.selectedMenuItem();
|
||||
this.application.setPreference(
|
||||
PrefKey.SortNotesReverse,
|
||||
!this.state.sortReverse
|
||||
);
|
||||
}
|
||||
|
||||
setSortBy(type: CollectionSort) {
|
||||
this.application.setPreference(
|
||||
PrefKey.SortNotesBy,
|
||||
type
|
||||
);
|
||||
}
|
||||
|
||||
getSearchBar() {
|
||||
return document.getElementById(ELEMENT_ID_SEARCH_BAR)!;
|
||||
}
|
||||
|
||||
registerKeyboardShortcuts() {
|
||||
/**
|
||||
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
||||
* use Control modifier as well. These rules don't apply to desktop, but
|
||||
* probably better to be consistent.
|
||||
*/
|
||||
this.newNoteKeyObserver = this.application.io.addKeyObserver({
|
||||
key: 'n',
|
||||
modifiers: [
|
||||
KeyboardModifier.Meta,
|
||||
KeyboardModifier.Ctrl
|
||||
],
|
||||
onKeyDown: (event) => {
|
||||
event.preventDefault();
|
||||
this.createNewNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.nextNoteKeyObserver = this.application.io.addKeyObserver({
|
||||
key: KeyboardKey.Down,
|
||||
elements: [
|
||||
document.body,
|
||||
this.getSearchBar()
|
||||
],
|
||||
onKeyDown: () => {
|
||||
const searchBar = this.getSearchBar();
|
||||
if (searchBar === document.activeElement) {
|
||||
searchBar.blur();
|
||||
}
|
||||
this.selectNextNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.previousNoteKeyObserver = this.application.io.addKeyObserver({
|
||||
key: KeyboardKey.Up,
|
||||
element: document.body,
|
||||
onKeyDown: () => {
|
||||
this.selectPreviousNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.searchKeyObserver = this.application.io.addKeyObserver({
|
||||
key: "f",
|
||||
modifiers: [
|
||||
KeyboardModifier.Meta,
|
||||
KeyboardModifier.Shift
|
||||
],
|
||||
onKeyDown: () => {
|
||||
const searchBar = this.getSearchBar();
|
||||
if (searchBar) { searchBar.focus(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotesView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = NotesViewCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
#tags-column.sn-component.section.tags(aria-label='Tags')
|
||||
.component-view-container(ng-if='self.component')
|
||||
.component-view-container(ng-if='self.state.componentViewer')
|
||||
component-view.component-view(
|
||||
component-uuid='self.component.uuid',
|
||||
component-viewer='self.state.componentViewer',
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
#tags-content.content(ng-if='!(self.component)')
|
||||
#tags-content.content(ng-if='!(self.state.componentViewer)')
|
||||
.tags-title-section.section-title-bar
|
||||
.section-title-bar-header
|
||||
.sk-h3.title
|
||||
@@ -29,11 +29,7 @@
|
||||
.danger.small-text.font-bold(ng-show='tag.conflictOf') Conflicted Copy
|
||||
.danger.small-text.font-bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys
|
||||
.info.small-text.font-bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys
|
||||
.tags-title-section.section-title-bar
|
||||
.section-title-bar-header
|
||||
.sk-h3.title
|
||||
span.sk-bold Tags
|
||||
tags(
|
||||
tags-section(
|
||||
application='self.application',
|
||||
app-state='self.appState'
|
||||
)
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
ApplicationEvent,
|
||||
ComponentAction,
|
||||
ComponentArea,
|
||||
ComponentViewer,
|
||||
ContentType,
|
||||
isPayloadSourceInternalChange,
|
||||
MessageData,
|
||||
PayloadSource,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNSmartTag,
|
||||
@@ -22,14 +26,14 @@ type TagState = {
|
||||
smartTags: SNSmartTag[];
|
||||
noteCounts: NoteCounts;
|
||||
selectedTag?: SNTag;
|
||||
componentViewer?: ComponentViewer;
|
||||
};
|
||||
|
||||
class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
|
||||
/** Passed through template */
|
||||
readonly application!: WebApplication;
|
||||
private readonly panelPuppet: PanelPuppet;
|
||||
private unregisterComponent?: any;
|
||||
component?: SNComponent;
|
||||
private unregisterComponent?: () => void;
|
||||
/** The original name of the edtingTag before it began editing */
|
||||
formData: { tagTitle?: string } = {};
|
||||
titles: Partial<Record<UuidString, string>> = {};
|
||||
@@ -46,9 +50,9 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
|
||||
|
||||
deinit() {
|
||||
this.removeTagsObserver?.();
|
||||
(this.removeTagsObserver as any) = undefined;
|
||||
(this.removeFoldersObserver as any) = undefined;
|
||||
this.unregisterComponent();
|
||||
(this.removeTagsObserver as unknown) = undefined;
|
||||
(this.removeFoldersObserver as unknown) = undefined;
|
||||
this.unregisterComponent?.();
|
||||
this.unregisterComponent = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
@@ -64,15 +68,10 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
super.onAppStart();
|
||||
this.registerComponentHandler();
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.loadPreferences();
|
||||
this.beginStreamingItems();
|
||||
this.streamForFoldersComponent();
|
||||
|
||||
const smartTags = this.application.getSmartTags();
|
||||
this.setState({ smartTags });
|
||||
@@ -85,13 +84,78 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
|
||||
this.reloadNoteCounts();
|
||||
}
|
||||
|
||||
beginStreamingItems() {
|
||||
async setFoldersComponent(component?: SNComponent) {
|
||||
if (this.state.componentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
this.state.componentViewer
|
||||
);
|
||||
await this.setState({ componentViewer: undefined });
|
||||
}
|
||||
if (component) {
|
||||
await this.setState({
|
||||
componentViewer:
|
||||
this.application.componentManager.createComponentViewer(
|
||||
component,
|
||||
undefined,
|
||||
this.handleFoldersComponentMessage.bind(this)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleFoldersComponentMessage(
|
||||
action: ComponentAction,
|
||||
data: MessageData
|
||||
): void {
|
||||
if (action === ComponentAction.SelectItem) {
|
||||
const item = data.item;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.content_type === ContentType.Tag) {
|
||||
const matchingTag = this.application.findItem(item.uuid);
|
||||
|
||||
if (matchingTag) {
|
||||
this.selectTag(matchingTag as SNTag);
|
||||
}
|
||||
} else if (item.content_type === ContentType.SmartTag) {
|
||||
const matchingTag = this.getState().smartTags.find(
|
||||
(t) => t.uuid === item.uuid
|
||||
);
|
||||
|
||||
if (matchingTag) {
|
||||
this.selectTag(matchingTag);
|
||||
}
|
||||
}
|
||||
} else if (action === ComponentAction.ClearSelection) {
|
||||
this.selectTag(this.getState().smartTags[0]);
|
||||
}
|
||||
}
|
||||
|
||||
streamForFoldersComponent() {
|
||||
this.removeFoldersObserver = this.application.streamItems(
|
||||
[ContentType.Component],
|
||||
async () => {
|
||||
this.component = this.application.componentManager
|
||||
.componentsForArea(ComponentArea.TagsList).find((component) => component.active);
|
||||
});
|
||||
async (items, source) => {
|
||||
if (
|
||||
isPayloadSourceInternalChange(source) ||
|
||||
source === PayloadSource.InitialObserverRegistrationPush
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const components = items as SNComponent[];
|
||||
const hasFoldersChange = !!components.find(
|
||||
(component) => component.area === ComponentArea.TagsList
|
||||
);
|
||||
if (hasFoldersChange) {
|
||||
this.setFoldersComponent(
|
||||
this.application.componentManager
|
||||
.componentsForArea(ComponentArea.TagsList)
|
||||
.find((component) => component.active)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.removeTagsObserver = this.application.streamItems(
|
||||
[ContentType.Tag, ContentType.SmartTag],
|
||||
@@ -200,41 +264,6 @@ class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
|
||||
this.application.getAppState().panelDidResize(PANEL_NAME_TAGS, isCollapsed);
|
||||
};
|
||||
|
||||
registerComponentHandler() {
|
||||
this.unregisterComponent =
|
||||
this.application.componentManager.registerHandler({
|
||||
identifier: 'tags',
|
||||
areas: [ComponentArea.TagsList],
|
||||
actionHandler: (_, action, data) => {
|
||||
if (action === ComponentAction.SelectItem) {
|
||||
const item = data.item;
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.content_type === ContentType.Tag) {
|
||||
const matchingTag = this.application.findItem(item.uuid);
|
||||
|
||||
if (matchingTag) {
|
||||
this.selectTag(matchingTag as SNTag);
|
||||
}
|
||||
} else if (item.content_type === ContentType.SmartTag) {
|
||||
const matchingTag = this.getState().smartTags.find(
|
||||
(t) => t.uuid === item.uuid
|
||||
);
|
||||
|
||||
if (matchingTag) {
|
||||
this.selectTag(matchingTag);
|
||||
}
|
||||
}
|
||||
} else if (action === ComponentAction.ClearSelection) {
|
||||
this.selectTag(this.getState().smartTags[0]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async selectTag(tag: SNTag) {
|
||||
if (tag.conflictOf) {
|
||||
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
|
||||
.section.tags,
|
||||
.section.notes {
|
||||
notes-view {
|
||||
will-change: opacity;
|
||||
animation: fade-out 1.25s forwards;
|
||||
transition: width 1.25s;
|
||||
@@ -50,7 +50,7 @@
|
||||
width: 0px !important;
|
||||
}
|
||||
|
||||
.section.notes:hover {
|
||||
notes-view:hover {
|
||||
flex: initial;
|
||||
width: 0px !important;
|
||||
}
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
.disable-focus-mode {
|
||||
.section.tags,
|
||||
.section.notes {
|
||||
notes-view {
|
||||
transition: width 1.25s;
|
||||
will-change: opacity;
|
||||
animation: fade-in 1.25s forwards;
|
||||
|
||||
@@ -136,7 +136,7 @@ $footer-height: 2rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
panel-resizer {
|
||||
panel-resizer, .panel-resizer {
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: $z-index-panel-resizer;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
notes-view {
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
#notes-column,
|
||||
.notes {
|
||||
width: 100%;
|
||||
|
||||
border-left: 1px solid var(--sn-stylekit-border-color);
|
||||
border-right: 1px solid var(--sn-stylekit-border-color);
|
||||
|
||||
font-size: var(--sn-stylekit-font-size-h2);
|
||||
|
||||
width: 350px;
|
||||
flex-grow: 0;
|
||||
user-select: none;
|
||||
|
||||
@@ -71,6 +76,8 @@
|
||||
}
|
||||
|
||||
#search-clear-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* Components and utilities that are good candidates for extraction to StyleKit. */
|
||||
|
||||
:root {
|
||||
--sn-stylekit-grey-2: #f8f9fc;
|
||||
}
|
||||
|
||||
.bg-grey-2 {
|
||||
background-color: var(--sn-stylekit-grey-2);
|
||||
.bg-grey-super-light {
|
||||
background-color: var(--sn-stylekit-grey-super-light);
|
||||
}
|
||||
|
||||
.h-90vh {
|
||||
@@ -788,6 +787,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sn-button {
|
||||
&.normal-focus-brightness {
|
||||
&:hover,
|
||||
&:focus {
|
||||
filter: brightness(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $screen-md-min) {
|
||||
.sn-component {
|
||||
.md\:hidden {
|
||||
|
||||
@@ -51,7 +51,35 @@
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.root-drop {
|
||||
width: '100%';
|
||||
padding: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.sn-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
|
||||
&.is-over {
|
||||
background-color: var(--sn-stylekit-white);
|
||||
color: var(--sn-stylekit-secondary-contrast-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
|
||||
min-height: 30px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
@@ -65,17 +93,42 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.sn-icon {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
|
||||
&.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
> .tag-fold {
|
||||
width: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> .tag-icon {
|
||||
width: 10px;
|
||||
opacity: 0.2;
|
||||
font-size: var(--sn-stylekit-font-size-h2);
|
||||
font-weight: bold;
|
||||
margin-right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
&.draggable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&.propose-folders {
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
@extend .focus\:outline-none;
|
||||
@extend .focus\:shadow-none;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
|
||||
width: 80%;
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
@@ -84,6 +137,7 @@
|
||||
cursor: pointer;
|
||||
text-overflow: ellipsis;
|
||||
width: 75%;
|
||||
flex-grow: 1;
|
||||
|
||||
// Required for Safari to avoid highlighting when dragging panel resizers
|
||||
// Make sure to undo if it's selected (for editing)
|
||||
@@ -118,21 +172,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
> .menu {
|
||||
font-size: 11px;
|
||||
.meta {
|
||||
padding-left: 3px;
|
||||
|
||||
> .item {
|
||||
margin-right: 2px;
|
||||
&.with-folders {
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
clear: both;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
> .menu {
|
||||
font-size: 11px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
> .item {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
clear: both;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +207,8 @@
|
||||
}
|
||||
|
||||
&:hover:not(.selected),
|
||||
&.selected {
|
||||
&.selected,
|
||||
&.is-drag-over {
|
||||
background-color: var(--sn-stylekit-secondary-contrast-background-color);
|
||||
color: var(--sn-stylekit-secondary-contrast-foreground-color);
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
.sk-modal-background(ng-click="ctrl.dismiss()")
|
||||
.sk-modal-content(
|
||||
ng-attr-id="component-content-outer-{{ctrl.component.uuid}}"
|
||||
)
|
||||
.sn-component
|
||||
.sk-panel(
|
||||
ng-attr-id="component-content-inner-{{ctrl.component.uuid}}"
|
||||
)
|
||||
.sk-panel-header
|
||||
.sk-panel-header-title
|
||||
| {{ctrl.component.name}}
|
||||
a.sk-a.info.close-button(ng-click="ctrl.dismiss()") Close
|
||||
component-view.component-view(
|
||||
ng-if='ctrl.component.active'
|
||||
component-uuid="ctrl.component.uuid",
|
||||
application='ctrl.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
.sn-component
|
||||
.sk-panel
|
||||
.sk-panel-header
|
||||
.sk-panel-header-title Activate Extension
|
||||
.sk-panel-header-title Activate Component
|
||||
a.sk-a.info.close-button(ng-click='ctrl.deny()') Cancel
|
||||
.sk-panel-content
|
||||
.sk-panel-section
|
||||
@@ -14,8 +14,8 @@
|
||||
| {{ctrl.permissionsString}}
|
||||
.sk-panel-row
|
||||
p.sk-p
|
||||
| Extensions use an offline messaging system to communicate. Learn more at
|
||||
|
|
||||
| Components use an offline messaging system to communicate. Learn more at
|
||||
|
|
||||
a.sk-a.info(
|
||||
href='https://standardnotes.com/permissions',
|
||||
rel='noopener',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
.sk-panel-header-title Preview
|
||||
.sk-subtitle.neutral.mt-1(
|
||||
ng-if="ctrl.title"
|
||||
) {{ctrl.title}}
|
||||
) {{ctrl.title}}
|
||||
.sk-horizontal-group
|
||||
a.sk-a.info.close-button(
|
||||
ng-click="ctrl.restore(false)"
|
||||
@@ -20,18 +20,18 @@
|
||||
a.sk-a.info.close-button(
|
||||
ng-click="ctrl.dismiss(); $event.stopPropagation()"
|
||||
) Close
|
||||
.sk-panel-content.selectable(ng-if="!ctrl.state.editor")
|
||||
.sk-panel-content.selectable(ng-if="!ctrl.state.componentViewer")
|
||||
.sk-h2 {{ctrl.content.title}}
|
||||
p.normal.sk-p(
|
||||
style="white-space: pre-wrap; font-size: 16px;"
|
||||
) {{ctrl.content.text}}
|
||||
.sk-panel-content.sk-h2(
|
||||
ng-if="ctrl.state.editor"
|
||||
ng-if="ctrl.state.componentViewer"
|
||||
style="height: auto; flex-grow: 0"
|
||||
) {{ctrl.content.title}}
|
||||
component-view.component-view(
|
||||
ng-if="ctrl.state.editor",
|
||||
template-component="ctrl.state.editor",
|
||||
ng-if="ctrl.state.componentViewer",
|
||||
component-viewer="ctrl.state.componentViewer",
|
||||
application='ctrl.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
|
||||
13
package.json
13
package.json
@@ -18,6 +18,7 @@
|
||||
"lint": "eslint --fix app/assets/javascripts",
|
||||
"tsc": "tsc --project app/assets/javascripts/tsconfig.json",
|
||||
"test": "jest --config app/assets/javascripts/jest.config.js",
|
||||
"test:coverage": "yarn test --coverage",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49,10 +50,10 @@
|
||||
"eslint-plugin-react-hooks": "^4.2.1-beta-149b420f6-20211119",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.4.0",
|
||||
"husky": "^7.0.4",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^27.3.1",
|
||||
"jest-transform-pug": "^0.1.0",
|
||||
"husky": "^7.0.4",
|
||||
"lint-staged": ">=10",
|
||||
"lodash": "^4.17.21",
|
||||
"mini-css-extract-plugin": "^2.4.3",
|
||||
@@ -65,7 +66,7 @@
|
||||
"pug-loader": "^2.4.0",
|
||||
"sass-loader": "^12.2.0",
|
||||
"serve-static": "^1.14.1",
|
||||
"sn-stylekit": "5.2.17",
|
||||
"sn-stylekit": "5.2.20",
|
||||
"svg-jest": "^1.0.1",
|
||||
"ts-jest": "^27.0.7",
|
||||
"ts-loader": "^9.2.6",
|
||||
@@ -84,12 +85,16 @@
|
||||
"@reach/dialog": "^0.16.2",
|
||||
"@reach/listbox": "^0.16.2",
|
||||
"@standardnotes/features": "1.10.2",
|
||||
"@reach/tooltip": "^0.16.2",
|
||||
"@standardnotes/sncrypto-web": "1.5.3",
|
||||
"@standardnotes/snjs": "2.20.5",
|
||||
"@standardnotes/snjs": "2.29.0",
|
||||
"mobx": "^6.3.5",
|
||||
"mobx-react-lite": "^3.2.2",
|
||||
"preact": "^10.5.15",
|
||||
"qrcode.react": "^1.0.1"
|
||||
"qrcode.react": "^1.0.1",
|
||||
"react-dnd": "^14.0.4",
|
||||
"react-dnd-html5-backend": "^14.0.2",
|
||||
"react-dnd-touch-backend": "^14.1.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx}": "eslint --cache --fix",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user