Merge branch 'release/10.7.0'
@@ -24,3 +24,7 @@ NEW_RELIC_THREAD_PROFILER_ENABLED=false
|
||||
NEW_RELIC_LICENSE_KEY=
|
||||
NEW_RELIC_APP_NAME=Web
|
||||
NEW_RELIC_BROWSER_MONITORING_AUTO_INSTRUMENT=false
|
||||
|
||||
DEV_ACCOUNT_EMAIL=
|
||||
DEV_ACCOUNT_PASSWORD=
|
||||
DEV_ACCOUNT_SERVER=
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 525 B |
@@ -1,3 +1,4 @@
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 14C8 14.2652 8.10536 14.5196 8.29289 14.7071C8.48043 14.8946 8.73478 15 9 15C9.26522 15 9.51957 14.8946 9.70711 14.7071C9.89464 14.5196 10 14.2652 10 14C10 13.7348 9.89464 13.4804 9.70711 13.2929C9.51957 13.1054 9.26522 13 9 13C8.73478 13 8.48043 13.1054 8.29289 13.2929C8.10536 13.4804 8 13.7348 8 14ZM8 0V4H10V2.08C13.39 2.57 16 5.47 16 9C16 10.8565 15.2625 12.637 13.9497 13.9497C12.637 15.2625 10.8565 16 9 16C7.14348 16 5.36301 15.2625 4.05025 13.9497C2.7375 12.637 2 10.8565 2 9C2 7.32 2.59 5.78 3.58 4.58L9 10L10.41 8.59L3.61 1.79V1.81C1.42 3.45 0 6.05 0 9C0 11.3869 0.948211 13.6761 2.63604 15.364C4.32387 17.0518 6.61305 18 9 18C11.3869 18 13.6761 17.0518 15.364 15.364C17.0518 13.6761 18 11.3869 18 9C18 6.61305 17.0518 4.32387 15.364 2.63604C13.6761 0.948211 11.3869 0 9 0H8ZM15 9C15 8.73478 14.8946 8.48043 14.7071 8.29289C14.5196 8.10536 14.2652 8 14 8C13.7348 8 13.4804 8.10536 13.2929 8.29289C13.1054 8.48043 13 8.73478 13 9C13 9.26522 13.1054 9.51957 13.2929 9.70711C13.4804 9.89464 13.7348 10 14 10C14.2652 10 14.5196 9.89464 14.7071 9.70711C14.8946 9.51957 15 9.26522 15 9ZM3 9C3 9.26522 3.10536 9.51957 3.29289 9.70711C3.48043 9.89464 3.73478 10 4 10C4.26522 10 4.51957 9.89464 4.70711 9.70711C4.89464 9.51957 5 9.26522 5 9C5 8.73478 4.89464 8.48043 4.70711 8.29289C4.51957 8.10536 4.26522 8 4 8C3.73478 8 3.48043 8.10536 3.29289 8.29289C3.10536 8.48043 3 8.73478 3 9Z" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.16667 14.1667C9.16667 14.3877 9.25446 14.5996 9.41074 14.7559C9.56702 14.9122 9.77899 15 10 15C10.221 15 10.433 14.9122 10.5893 14.7559C10.7455 14.5996 10.8333 14.3877 10.8333 14.1667C10.8333 13.9457 10.7455 13.7337 10.5893 13.5774C10.433 13.4211 10.221 13.3333 10 13.3333C9.77899 13.3333 9.56702 13.4211 9.41074 13.5774C9.25446 13.7337 9.16667 13.9457 9.16667 14.1667ZM9.16667 2.5V5.83333H10.8333V4.23333C13.6583 4.64167 15.8333 7.05833 15.8333 10C15.8333 11.5471 15.2188 13.0308 14.1248 14.1248C13.0308 15.2188 11.5471 15.8333 10 15.8333C8.4529 15.8333 6.96917 15.2188 5.87521 14.1248C4.78125 13.0308 4.16667 11.5471 4.16667 10C4.16667 8.6 4.65833 7.31667 5.48333 6.31667L10 10.8333L11.175 9.65833L5.50833 3.99167V4.00833C3.68333 5.375 2.5 7.54167 2.5 10C2.5 11.9891 3.29018 13.8968 4.6967 15.3033C6.10322 16.7098 8.01088 17.5 10 17.5C11.9891 17.5 13.8968 16.7098 15.3033 15.3033C16.7098 13.8968 17.5 11.9891 17.5 10C17.5 8.01088 16.7098 6.10322 15.3033 4.6967C13.8968 3.29018 11.9891 2.5 10 2.5H9.16667ZM15 10C15 9.77899 14.9122 9.56702 14.7559 9.41074C14.5996 9.25446 14.3877 9.16667 14.1667 9.16667C13.9457 9.16667 13.7337 9.25446 13.5774 9.41074C13.4211 9.56702 13.3333 9.77899 13.3333 10C13.3333 10.221 13.4211 10.433 13.5774 10.5893C13.7337 10.7455 13.9457 10.8333 14.1667 10.8333C14.3877 10.8333 14.5996 10.7455 14.7559 10.5893C14.9122 10.433 15 10.221 15 10ZM5 10C5 10.221 5.0878 10.433 5.24408 10.5893C5.40036 10.7455 5.61232 10.8333 5.83333 10.8333C6.05435 10.8333 6.26631 10.7455 6.42259 10.5893C6.57887 10.433 6.66667 10.221 6.66667 10C6.66667 9.77899 6.57887 9.56702 6.42259 9.41074C6.26631 9.25446 6.05435 9.16667 5.83333 9.16667C5.61232 9.16667 5.40036 9.25446 5.24408 9.41074C5.0878 9.56702 5 9.77899 5 10Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -1,3 +1,4 @@
|
||||
<svg viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.89 0L13.85 0.4L10.11 18L8.15002 17.6L11.89 0ZM18.59 9L15 5.41V2.58L21.42 9L15 15.41V12.58L18.59 9ZM0.580017 9L7.00002 2.58V5.41L3.41002 9L7.00002 12.58V15.41L0.580017 9Z" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M10.7417 2.5L12.375 2.83333L9.25832 17.5L7.62498 17.1667L10.7417 2.5ZM16.325 10L13.3333 7.00833V4.65L18.6833 10L13.3333 15.3417V12.9833L16.325 10ZM1.31665 10L6.66665 4.65V7.00833L3.67498 10L6.66665 12.9833V15.3417L1.31665 10Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 324 B |
4
app/assets/icons/ic-lock-filled.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.99992 14.1666C10.4419 14.1666 10.8659 13.991 11.1784 13.6784C11.491 13.3659 11.6666 12.9419 11.6666 12.4999C11.6666 11.5749 10.9166 10.8333 9.99992 10.8333C9.55789 10.8333 9.13397 11.0088 8.82141 11.3214C8.50885 11.634 8.33325 12.0579 8.33325 12.4999C8.33325 12.9419 8.50885 13.3659 8.82141 13.6784C9.13397 13.991 9.55789 14.1666 9.99992 14.1666ZM14.9999 6.66659C15.4419 6.66659 15.8659 6.84218 16.1784 7.15474C16.491 7.4673 16.6666 7.89123 16.6666 8.33325V16.6666C16.6666 17.1086 16.491 17.5325 16.1784 17.8451C15.8659 18.1577 15.4419 18.3333 14.9999 18.3333H4.99992C4.55789 18.3333 4.13397 18.1577 3.82141 17.8451C3.50885 17.5325 3.33325 17.1086 3.33325 16.6666V8.33325C3.33325 7.40825 4.08325 6.66659 4.99992 6.66659H5.83325V4.99992C5.83325 3.89485 6.27224 2.83504 7.05364 2.05364C7.83504 1.27224 8.89485 0.833252 9.99992 0.833252C10.5471 0.833252 11.0889 0.941026 11.5944 1.15042C12.1 1.35982 12.5593 1.66673 12.9462 2.05364C13.3331 2.44055 13.64 2.89988 13.8494 3.4054C14.0588 3.91093 14.1666 4.45274 14.1666 4.99992V6.66659H14.9999ZM9.99992 2.49992C9.33688 2.49992 8.70099 2.76331 8.23215 3.23215C7.76331 3.70099 7.49992 4.33688 7.49992 4.99992V6.66659H12.4999V4.99992C12.4999 4.33688 12.2365 3.70099 11.7677 3.23215C11.2988 2.76331 10.663 2.49992 9.99992 2.49992Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,3 +1,4 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 0V2H14V14H11V16H16V0H11ZM0 0V16H5V14H2V2H5V0H0Z"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.4999 3.33325V4.99992H14.9999V14.9999H12.4999V16.6666H16.6666V3.33325H12.4999ZM3.33325 3.33325V16.6666H7.49992V14.9999H4.99992V4.99992H7.49992V3.33325H3.33325Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 144 B After Width: | Height: | Size: 261 B |
3
app/assets/icons/ic-notes.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.55556 3C3.69222 3 3 3.69222 3 4.55556V15.4444C3 16.3078 3.69222 17 4.55556 17H15.4444C16.3078 17 17 16.3078 17 15.4444V4.55556C17 3.69222 16.3078 3 15.4444 3H4.55556ZM4.55556 4.55556H15.4444V15.4444H4.55556V4.55556ZM6.11111 6.11111V7.66667H13.8889V6.11111H6.11111ZM6.11111 9.22222V10.7778H13.8889V9.22222H6.11111ZM6.11111 12.3333V13.8889H11.5556V12.3333H6.11111Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 458 B |
3
app/assets/icons/ic-pin-filled.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 10V4H13.75V2.5H6.25V4H7V10L4.5 11.5V13H9.4V17.5H10.6V13H15.5V11.5L13 10Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 171 B |
@@ -1,3 +1,4 @@
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.8 0.600098H16.2C16.6774 0.600098 17.1352 0.786413 17.4728 1.11806C17.8104 1.4497 18 1.8995 18 2.36852V15.6317C18 16.1007 17.8104 16.5505 17.4728 16.8821C17.1352 17.2138 16.6774 17.4001 16.2 17.4001H1.8C1.32261 17.4001 0.864773 17.2138 0.527208 16.8821C0.189642 16.5505 0 16.1007 0 15.6317V2.36852C0 1.8995 0.189642 1.4497 0.527208 1.11806C0.864773 0.786413 1.32261 0.600098 1.8 0.600098ZM1.8 4.13694V6.78957H5.4V4.13694H1.8ZM7.2 4.13694V6.78957H10.8V4.13694H7.2ZM16.2 6.78957V4.13694H12.6V6.78957H16.2ZM1.8 8.55799V11.2106H5.4V8.55799H1.8ZM1.8 15.6317H5.4V12.979H1.8V15.6317ZM7.2 8.55799V11.2106H10.8V8.55799H7.2ZM7.2 15.6317H10.8V12.979H7.2V15.6317ZM16.2 15.6317V12.979H12.6V15.6317H16.2ZM16.2 8.55799H12.6V11.2106H16.2V8.55799Z" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4 3H16C16.3978 3 16.7794 3.15526 17.0607 3.43163C17.342 3.708 17.5 4.08284 17.5 4.47368V15.5263C17.5 15.9172 17.342 16.292 17.0607 16.5684C16.7794 16.8447 16.3978 17 16 17H4C3.60218 17 3.22064 16.8447 2.93934 16.5684C2.65804 16.292 2.5 15.9172 2.5 15.5263V4.47368C2.5 4.08284 2.65804 3.708 2.93934 3.43163C3.22064 3.15526 3.60218 3 4 3ZM4 5.94737V8.15789H7V5.94737H4ZM8.5 5.94737V8.15789H11.5V5.94737H8.5ZM16 8.15789V5.94737H13V8.15789H16ZM4 9.63158V11.8421H7V9.63158H4ZM4 15.5263H7V13.3158H4V15.5263ZM8.5 9.63158V11.8421H11.5V9.63158H8.5ZM8.5 15.5263H11.5V13.3158H8.5V15.5263ZM16 15.5263V13.3158H13V15.5263H16ZM16 9.63158H13V11.8421H16V9.63158Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 745 B |
@@ -1,3 +1,4 @@
|
||||
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 16H2V2H12V0H2C0.89 0 0 0.89 0 2V16C0 16.5304 0.210714 17.0391 0.585786 17.4142C0.960859 17.7893 1.46957 18 2 18H16C16.5304 18 17.0391 17.7893 17.4142 17.4142C17.7893 17.0391 18 16.5304 18 16V8H16V16ZM4.91 7.08L3.5 8.5L8 13L18 3L16.59 1.58L8 10.17L4.91 7.08Z" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15.8333 15.8333H4.16667V4.16667H12.5V2.5H4.16667C3.24167 2.5 2.5 3.24167 2.5 4.16667V15.8333C2.5 16.2754 2.67559 16.6993 2.98816 17.0118C3.30072 17.3244 3.72464 17.5 4.16667 17.5H15.8333C16.2754 17.5 16.6993 17.3244 17.0118 17.0118C17.3244 16.6993 17.5 16.2754 17.5 15.8333V9.16667H15.8333V15.8333ZM6.59167 8.4L5.41667 9.58333L9.16667 13.3333L17.5 5L16.325 3.81667L9.16667 10.975L6.59167 8.4Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 492 B |
@@ -1,4 +1,3 @@
|
||||
<svg viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.4 0V2H0.599976V0H17.4ZM0.599976 12H8.99998V10H0.599976V12ZM0.599976 7H17.4V5H0.599976V7Z" />
|
||||
</svg>
|
||||
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 5V6.66667H3V5H17ZM3 15H10V13.3333H3V15ZM3 10.8333H17V9.16667H3V10.8333Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 190 B After Width: | Height: | Size: 170 B |
@@ -1,3 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 7.49984H7.5V12.4998H2.5V7.49984ZM2.5 4.1665H17.5V5.83317H2.5V4.1665ZM17.5 7.49984V9.1665H9.16667V7.49984H17.5ZM17.5 10.8332V12.4998H9.16667V10.8332H17.5ZM2.5 14.1665H14.1667V15.8332H2.5V14.1665Z" />
|
||||
</svg>
|
||||
<path
|
||||
d="M2.5 7.49984H7.5V12.4998H2.5V7.49984ZM2.5 4.1665H17.5V5.83317H2.5V4.1665ZM17.5 7.49984V9.1665H9.16667V7.49984H17.5ZM17.5 10.8332V12.4998H9.16667V10.8332H17.5ZM2.5 14.1665H14.1667V15.8332H2.5V14.1665Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 298 B |
4
app/assets/icons/ic-trash-filled.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7.49992 2.5V3.33333H3.33325V5H4.16659V15.8333C4.16659 16.2754 4.34218 16.6993 4.65474 17.0118C4.9673 17.3244 5.39122 17.5 5.83325 17.5H14.1666C14.6086 17.5 15.0325 17.3244 15.3451 17.0118C15.6577 16.6993 15.8332 16.2754 15.8332 15.8333V5H16.6666V3.33333H12.4999V2.5H7.49992ZM7.49992 6.66667H9.16658V14.1667H7.49992V6.66667ZM10.8333 6.66667H12.4999V14.1667H10.8333V6.66667Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@@ -17,78 +17,74 @@ declare global {
|
||||
// eslint-disable-next-line camelcase
|
||||
_websocket_url: string;
|
||||
startApplication?: StartApplication;
|
||||
|
||||
_devAccountEmail?: string;
|
||||
_devAccountPassword?: string;
|
||||
_devAccountServer?: string;
|
||||
}
|
||||
}
|
||||
|
||||
import { SNLog } from '@standardnotes/snjs';
|
||||
import angular from 'angular';
|
||||
import { configRoutes } from './routes';
|
||||
|
||||
import { ApplicationGroup } from './ui_models/application_group';
|
||||
import { AccountSwitcher } from './views/account_switcher/account_switcher';
|
||||
|
||||
import { ComponentViewDirective } from '@/components/ComponentView';
|
||||
import { NavigationDirective } from '@/components/Navigation';
|
||||
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
|
||||
import { IsWebPlatform, WebAppVersion } from '@/version';
|
||||
import {
|
||||
ApplicationGroupView,
|
||||
ApplicationView,
|
||||
ChallengeModal,
|
||||
FooterView,
|
||||
NoteGroupViewDirective,
|
||||
NoteViewDirective,
|
||||
TagsView,
|
||||
FooterView,
|
||||
ChallengeModal,
|
||||
} from '@/views';
|
||||
|
||||
import { SNLog } from '@standardnotes/snjs';
|
||||
import angular from 'angular';
|
||||
import { AccountMenuDirective } from './components/AccountMenu';
|
||||
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
|
||||
import { IconDirective } from './components/Icon';
|
||||
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
|
||||
import { NoAccountWarningDirective } from './components/NoAccountWarning';
|
||||
import { NotesContextMenuDirective } from './components/NotesContextMenu';
|
||||
import { NotesListOptionsDirective } from './components/NotesListOptionsMenu';
|
||||
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
|
||||
import { NotesViewDirective } from './components/NotesView';
|
||||
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
|
||||
import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
|
||||
import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu';
|
||||
import { SearchOptionsDirective } from './components/SearchOptions';
|
||||
import { SessionsModalDirective } from './components/SessionsModal';
|
||||
import {
|
||||
autofocus,
|
||||
clickOutside,
|
||||
delayHide,
|
||||
elemReady,
|
||||
fileChange,
|
||||
infiniteScroll,
|
||||
lowercase,
|
||||
selectOnFocus,
|
||||
snEnter,
|
||||
} from './directives/functional';
|
||||
|
||||
import {
|
||||
ActionsMenu,
|
||||
EditorMenu,
|
||||
HistoryMenu,
|
||||
InputModal,
|
||||
MenuRow,
|
||||
PanelResizer,
|
||||
PasswordWizard,
|
||||
PermissionsModal,
|
||||
RevisionPreviewModal,
|
||||
HistoryMenu,
|
||||
SyncResolutionMenu,
|
||||
} from './directives/views';
|
||||
|
||||
import { trusted } from './filters';
|
||||
import { isDev } from './utils';
|
||||
import { PreferencesDirective } from './preferences';
|
||||
import { PurchaseFlowDirective } from './purchaseFlow';
|
||||
import { configRoutes } from './routes';
|
||||
import { Bridge } from './services/bridge';
|
||||
import { BrowserBridge } from './services/browserBridge';
|
||||
import { startErrorReporting } from './services/errorReporting';
|
||||
import { StartApplication } from './startApplication';
|
||||
import { Bridge } from './services/bridge';
|
||||
import { SessionsModalDirective } from './components/SessionsModal';
|
||||
import { NoAccountWarningDirective } from './components/NoAccountWarning';
|
||||
import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
|
||||
import { SearchOptionsDirective } from './components/SearchOptions';
|
||||
import { AccountMenuDirective } from './components/AccountMenu';
|
||||
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
|
||||
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
|
||||
import { NotesContextMenuDirective } from './components/NotesContextMenu';
|
||||
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
|
||||
import { IconDirective } from './components/Icon';
|
||||
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
|
||||
import { PreferencesDirective } from './preferences';
|
||||
import { WebAppVersion, IsWebPlatform } from '@/version';
|
||||
import { NotesListOptionsDirective } from './components/NotesListOptionsMenu';
|
||||
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';
|
||||
import { ApplicationGroup } from './ui_models/application_group';
|
||||
import { isDev } from './utils';
|
||||
import { AccountSwitcher } from './views/account_switcher/account_switcher';
|
||||
|
||||
function reloadHiddenFirefoxTab(): boolean {
|
||||
/**
|
||||
@@ -143,7 +139,6 @@ const startApplication: StartApplication = async function startApplication(
|
||||
.directive('applicationView', () => new ApplicationView())
|
||||
.directive('noteGroupView', () => new NoteGroupViewDirective())
|
||||
.directive('noteView', () => new NoteViewDirective())
|
||||
.directive('tagsView', () => new TagsView())
|
||||
.directive('footerView', () => new FooterView());
|
||||
|
||||
// Directives - Functional
|
||||
@@ -154,7 +149,6 @@ const startApplication: StartApplication = async function startApplication(
|
||||
.directive('delayHide', delayHide)
|
||||
.directive('elemReady', elemReady)
|
||||
.directive('fileChange', fileChange)
|
||||
.directive('infiniteScroll', [infiniteScroll])
|
||||
.directive('lowercase', lowercase)
|
||||
.directive('selectOnFocus', ['$window', selectOnFocus])
|
||||
.directive('snEnter', snEnter);
|
||||
@@ -188,8 +182,7 @@ const startApplication: StartApplication = async function startApplication(
|
||||
.directive('notesListOptionsMenu', NotesListOptionsDirective)
|
||||
.directive('icon', IconDirective)
|
||||
.directive('noteTagsContainer', NoteTagsContainerDirective)
|
||||
.directive('tagsList', TagsListDirective)
|
||||
.directive('tagsSection', TagsSectionDirective)
|
||||
.directive('navigation', NavigationDirective)
|
||||
.directive('preferences', PreferencesDirective)
|
||||
.directive('purchaseFlow', PurchaseFlowDirective)
|
||||
.directive('notesView', NotesViewDirective)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { isDev } from '@/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
@@ -19,6 +20,12 @@ export const AdvancedOptions: FunctionComponent<Props> = observer(
|
||||
appState.accountMenu;
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
if (isDev && window._devAccountServer) {
|
||||
setEnableServerOption(true);
|
||||
setServer(window._devAccountServer);
|
||||
application.setCustomHost(window._devAccountServer);
|
||||
}
|
||||
|
||||
const handleServerOptionChange = (e: Event) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
setEnableServerOption(e.target.checked);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { isDev } from '@/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
@@ -32,6 +33,11 @@ export const SignInPane: FunctionComponent<Props> = observer(
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (isDev && window._devAccountEmail) {
|
||||
setEmail(window._devAccountEmail);
|
||||
setPassword(window._devAccountPassword as string);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef?.current) {
|
||||
emailInputRef.current?.focus();
|
||||
|
||||
@@ -21,6 +21,9 @@ export const AutocompleteTagResult = observer(
|
||||
|
||||
const tagResultRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const title = tagResult.title;
|
||||
const prefixTitle = appState.noteTags.getPrefixTitle(tagResult);
|
||||
|
||||
const onTagOptionClick = async (tag: SNTag) => {
|
||||
await appState.noteTags.addTagToActiveNote(tag);
|
||||
appState.noteTags.clearAutocompleteSearch();
|
||||
@@ -86,9 +89,10 @@ export const AutocompleteTagResult = observer(
|
||||
>
|
||||
<Icon type="hashtag" className="color-neutral mr-2 min-h-5 min-w-5" />
|
||||
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||
{prefixTitle && <span className="grey-2">{prefixTitle}</span>}
|
||||
{autocompleteSearchQuery === ''
|
||||
? tagResult.title
|
||||
: tagResult.title
|
||||
? title
|
||||
: title
|
||||
.split(new RegExp(`(${autocompleteSearchQuery})`, 'gi'))
|
||||
.map((substring, index) => (
|
||||
<span
|
||||
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { IconType, Icon } from './Icon';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export type DropdownItem = {
|
||||
icon?: IconType;
|
||||
iconClassName?: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
@@ -25,10 +26,7 @@ type DropdownProps = {
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type ListboxButtonProps = {
|
||||
icon?: IconType;
|
||||
value: string | null;
|
||||
label: string;
|
||||
type ListboxButtonProps = DropdownItem & {
|
||||
isExpanded: boolean;
|
||||
};
|
||||
|
||||
@@ -36,12 +34,13 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
|
||||
label,
|
||||
isExpanded,
|
||||
icon,
|
||||
iconClassName = '',
|
||||
}) => (
|
||||
<>
|
||||
<div className="sn-dropdown-button-label">
|
||||
{icon ? (
|
||||
<div className="flex mr-2">
|
||||
<Icon type={icon} className="sn-icon--small" />
|
||||
<Icon type={icon} className={`sn-icon--small ${iconClassName}`} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dropdown-selected-label">{label}</div>
|
||||
@@ -65,6 +64,10 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
const labelId = `${id}-label`;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
@@ -85,11 +88,13 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
|
||||
children={({ value, label, isExpanded }) => {
|
||||
const current = items.find((item) => item.value === value);
|
||||
const icon = current ? current?.icon : null;
|
||||
const iconClassName = current ? current?.iconClassName : null;
|
||||
return CustomDropdownButton({
|
||||
value,
|
||||
value: value ? value : label.toLowerCase(),
|
||||
label,
|
||||
isExpanded,
|
||||
...(icon ? { icon } : null),
|
||||
...(iconClassName ? { iconClassName } : null),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -104,7 +109,10 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex mr-3">
|
||||
<Icon type={item.icon} className="sn-icon--small" />
|
||||
<Icon
|
||||
type={item.icon}
|
||||
className={`sn-icon--small ${item.iconClassName ?? ''}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-input">{item.label}</div>
|
||||
|
||||
@@ -3,7 +3,9 @@ import PencilOffIcon from '../../icons/ic-pencil-off.svg';
|
||||
import PlainTextIcon from '../../icons/ic-text-paragraph.svg';
|
||||
import RichTextIcon from '../../icons/ic-text-rich.svg';
|
||||
import TrashIcon from '../../icons/ic-trash.svg';
|
||||
import TrashFilledIcon from '../../icons/ic-trash-filled.svg';
|
||||
import PinIcon from '../../icons/ic-pin.svg';
|
||||
import PinFilledIcon from '../../icons/ic-pin-filled.svg';
|
||||
import UnpinIcon from '../../icons/ic-pin-off.svg';
|
||||
import ArchiveIcon from '../../icons/ic-archive.svg';
|
||||
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
|
||||
@@ -21,6 +23,7 @@ import AuthenticatorIcon from '../../icons/ic-authenticator.svg';
|
||||
import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg';
|
||||
import TasksIcon from '../../icons/ic-tasks.svg';
|
||||
import MarkdownIcon from '../../icons/ic-markdown.svg';
|
||||
import NotesIcon from '../../icons/ic-notes.svg';
|
||||
import CodeIcon from '../../icons/ic-code.svg';
|
||||
|
||||
import AccessibilityIcon from '../../icons/ic-accessibility.svg';
|
||||
@@ -52,6 +55,7 @@ import ServerIcon from '../../icons/ic-server.svg';
|
||||
import EyeIcon from '../../icons/ic-eye.svg';
|
||||
import EyeOffIcon from '../../icons/ic-eye-off.svg';
|
||||
import LockIcon from '../../icons/ic-lock.svg';
|
||||
import LockFilledIcon from '../../icons/ic-lock-filled.svg';
|
||||
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
|
||||
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
|
||||
import WindowIcon from '../../icons/ic-window.svg';
|
||||
@@ -66,9 +70,11 @@ import { FunctionalComponent } from 'preact';
|
||||
const ICONS = {
|
||||
'menu-arrow-down-alt': MenuArrowDownAlt,
|
||||
'menu-arrow-right': MenuArrowRight,
|
||||
notes: NotesIcon,
|
||||
'arrows-sort-up': ArrowsSortUpIcon,
|
||||
'arrows-sort-down': ArrowsSortDownIcon,
|
||||
lock: LockIcon,
|
||||
'lock-filled': LockFilledIcon,
|
||||
eye: EyeIcon,
|
||||
'eye-off': EyeOffIcon,
|
||||
server: ServerIcon,
|
||||
@@ -89,7 +95,9 @@ const ICONS = {
|
||||
spreadsheets: SpreadsheetsIcon,
|
||||
tasks: TasksIcon,
|
||||
trash: TrashIcon,
|
||||
'trash-filled': TrashFilledIcon,
|
||||
pin: PinIcon,
|
||||
'pin-filled': PinFilledIcon,
|
||||
unpin: UnpinIcon,
|
||||
archive: ArchiveIcon,
|
||||
unarchive: UnarchiveIcon,
|
||||
@@ -130,11 +138,22 @@ export type IconType = keyof typeof ICONS;
|
||||
type Props = {
|
||||
type: IconType;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export const Icon: FunctionalComponent<Props> = ({ type, className = '' }) => {
|
||||
export const Icon: FunctionalComponent<Props> = ({
|
||||
type,
|
||||
className = '',
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const IconComponent = ICONS[type];
|
||||
return <IconComponent className={`sn-icon ${className}`} />;
|
||||
return (
|
||||
<IconComponent
|
||||
className={`sn-icon ${className}`}
|
||||
role="img"
|
||||
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconDirective = toDirective<Props>(Icon, {
|
||||
|
||||
123
app/assets/javascripts/components/Navigation.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ComponentView } from '@/components/ComponentView';
|
||||
import { PanelResizer } from '@/components/PanelResizer';
|
||||
import { SmartTagsSection } from '@/components/Tags/SmartTagsSection';
|
||||
import { TagsSection } from '@/components/Tags/TagsSection';
|
||||
import { toDirective } from '@/components/utils';
|
||||
import {
|
||||
PanelSide,
|
||||
ResizeFinishCallback,
|
||||
} from '@/directives/views/panelResizer';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { PrefKey } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useMemo, useState } from 'preact/hooks';
|
||||
import { PremiumModalProvider } from './Premium';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
const NAVIGATION_SELECTOR = 'navigation';
|
||||
|
||||
const useNavigationPanelRef = (): [HTMLDivElement | null, () => void] => {
|
||||
const [panelRef, setPanelRefInternal] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const setPanelRefPublic = useCallback(() => {
|
||||
const elem = document.querySelector(
|
||||
NAVIGATION_SELECTOR
|
||||
) as HTMLDivElement | null;
|
||||
setPanelRefInternal(elem);
|
||||
}, [setPanelRefInternal]);
|
||||
|
||||
return [panelRef, setPanelRefPublic];
|
||||
};
|
||||
|
||||
export const Navigation: FunctionComponent<Props> = observer(
|
||||
({ application }) => {
|
||||
const appState = useMemo(() => application.getAppState(), [application]);
|
||||
const componentViewer = appState.foldersComponentViewer;
|
||||
const enableNativeSmartTagsFeature =
|
||||
appState.features.enableNativeSmartTagsFeature;
|
||||
const [panelRef, setPanelRef] = useNavigationPanelRef();
|
||||
|
||||
const onCreateNewTag = useCallback(() => {
|
||||
appState.tags.createNewTemplate();
|
||||
}, [appState]);
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||
(_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed);
|
||||
},
|
||||
[appState]
|
||||
);
|
||||
|
||||
const panelWidthEventCallback = useCallback(() => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
}, [appState]);
|
||||
|
||||
return (
|
||||
<PremiumModalProvider state={appState.features}>
|
||||
<div
|
||||
id="navigation"
|
||||
className="sn-component section"
|
||||
data-aria-label="Navigation"
|
||||
ref={setPanelRef}
|
||||
>
|
||||
{componentViewer ? (
|
||||
<div className="component-view-container">
|
||||
<div className="component-view">
|
||||
<ComponentView
|
||||
componentViewer={componentViewer}
|
||||
application={application}
|
||||
appState={appState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div id="navigation-content" className="content">
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Views</span>
|
||||
</div>
|
||||
{!enableNativeSmartTagsFeature && (
|
||||
<div
|
||||
className="sk-button sk-secondary-contrast wide"
|
||||
onClick={onCreateNewTag}
|
||||
title="Create a new tag"
|
||||
>
|
||||
<div className="sk-label">
|
||||
<i className="icon ion-plus add-button" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrollable">
|
||||
<SmartTagsSection appState={appState} />
|
||||
<TagsSection appState={appState} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{panelRef && (
|
||||
<PanelResizer
|
||||
application={application}
|
||||
collapsable={true}
|
||||
defaultWidth={150}
|
||||
panel={panelRef}
|
||||
prefKey={PrefKey.TagsPanelWidth}
|
||||
side={PanelSide.Right}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PremiumModalProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const NavigationDirective = toDirective<Props>(Navigation);
|
||||
@@ -10,7 +10,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export const NoteTag = observer(({ appState, tag }: Props) => {
|
||||
const { autocompleteInputFocused, focusedTagUuid, tags } = appState.noteTags;
|
||||
const noteTags = appState.noteTags;
|
||||
|
||||
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags;
|
||||
|
||||
const [showDeleteButton, setShowDeleteButton] = useState(false);
|
||||
const [tagClicked, setTagClicked] = useState(false);
|
||||
@@ -18,6 +20,10 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
||||
|
||||
const tagRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const title = tag.title;
|
||||
const prefixTitle = noteTags.getPrefixTitle(tag);
|
||||
const longTitle = noteTags.getLongTitle(tag);
|
||||
|
||||
const deleteTag = () => {
|
||||
appState.noteTags.focusPreviousTag(tag);
|
||||
appState.noteTags.removeTagFromActiveNote(tag);
|
||||
@@ -32,7 +38,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
||||
const onTagClick = (event: MouseEvent) => {
|
||||
if (tagClicked && event.target !== deleteTagRef.current) {
|
||||
setTagClicked(false);
|
||||
appState.setSelectedTag(tag);
|
||||
appState.selectedTag = tag;
|
||||
} else {
|
||||
setTagClicked(true);
|
||||
}
|
||||
@@ -97,10 +103,12 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
tabIndex={getTabIndex()}
|
||||
title={longTitle}
|
||||
>
|
||||
<Icon type="hashtag" className="sn-icon--small color-info mr-1" />
|
||||
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px">
|
||||
{tag.title}
|
||||
{prefixTitle && <span className="color-grey-1">{prefixTitle}</span>}
|
||||
{title}
|
||||
</span>
|
||||
{showDeleteButton && (
|
||||
<button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { DisplayOptions } from '@/ui_models/app_state/notes_view_state';
|
||||
@@ -7,6 +8,7 @@ import { FunctionComponent } from 'preact';
|
||||
import { NotesListItem } from './NotesListItem';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
notes: SNNote[];
|
||||
selectedNotes: Record<string, SNNote>;
|
||||
@@ -18,23 +20,31 @@ const FOCUSABLE_BUT_NOT_TABBABLE = -1;
|
||||
const NOTES_LIST_SCROLL_THRESHOLD = 200;
|
||||
|
||||
export const NotesList: FunctionComponent<Props> = observer(
|
||||
({ appState, notes, selectedNotes, displayOptions, paginate }) => {
|
||||
({
|
||||
application,
|
||||
appState,
|
||||
notes,
|
||||
selectedNotes,
|
||||
displayOptions,
|
||||
paginate,
|
||||
}) => {
|
||||
const { selectPreviousNote, selectNextNote } = appState.notesView;
|
||||
const { hideTags, hideDate, hideNotePreview, sortBy } = displayOptions;
|
||||
const { hideTags, hideDate, hideNotePreview, hideEditorIcon, sortBy } =
|
||||
displayOptions;
|
||||
|
||||
const tagsStringForNote = (note: SNNote): string => {
|
||||
const tagsForNote = (note: SNNote): string[] => {
|
||||
if (hideTags) {
|
||||
return '';
|
||||
return [];
|
||||
}
|
||||
const selectedTag = appState.selectedTag;
|
||||
if (!selectedTag) {
|
||||
return '';
|
||||
return [];
|
||||
}
|
||||
const tags = appState.getNoteTags(note);
|
||||
if (!selectedTag.isSmartTag && tags.length === 1) {
|
||||
return '';
|
||||
return [];
|
||||
}
|
||||
return tags.map((tag) => `#${tag.title}`).join(' ');
|
||||
return tags.map((tag) => tag.title);
|
||||
};
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
@@ -46,11 +56,9 @@ export const NotesList: FunctionComponent<Props> = observer(
|
||||
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 onContextMenu = (note: SNNote, posX: number, posY: number) => {
|
||||
appState.notes.selectNote(note.uuid, true);
|
||||
openNoteContextMenu(posX, posY);
|
||||
};
|
||||
|
||||
const onScroll = (e: Event) => {
|
||||
@@ -84,13 +92,15 @@ export const NotesList: FunctionComponent<Props> = observer(
|
||||
>
|
||||
{notes.map((note) => (
|
||||
<NotesListItem
|
||||
application={application}
|
||||
key={note.uuid}
|
||||
note={note}
|
||||
tags={tagsStringForNote(note)}
|
||||
tags={tagsForNote(note)}
|
||||
selected={!!selectedNotes[note.uuid]}
|
||||
hideDate={hideDate}
|
||||
hidePreview={hideNotePreview}
|
||||
hideTags={hideTags}
|
||||
hideEditorIcon={hideEditorIcon}
|
||||
sortedBy={sortBy}
|
||||
onClick={() => {
|
||||
appState.notes.selectNote(note.uuid, true);
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { getIconAndTintForEditor } from '@/preferences/panes/general-segments';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
CollectionSort,
|
||||
sanitizeHtmlString,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
note: SNNote;
|
||||
tags: string;
|
||||
tags: string[];
|
||||
hideDate: boolean;
|
||||
hidePreview: boolean;
|
||||
hideTags: boolean;
|
||||
hideEditorIcon: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu: (e: MouseEvent) => void;
|
||||
selected: boolean;
|
||||
@@ -24,30 +29,6 @@ type NoteFlag = {
|
||||
|
||||
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',
|
||||
@@ -77,9 +58,11 @@ const flagsForNote = (note: SNNote) => {
|
||||
};
|
||||
|
||||
export const NotesListItem: FunctionComponent<Props> = ({
|
||||
application,
|
||||
hideDate,
|
||||
hidePreview,
|
||||
hideTags,
|
||||
hideEditorIcon,
|
||||
note,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
@@ -89,6 +72,9 @@ export const NotesListItem: FunctionComponent<Props> = ({
|
||||
}) => {
|
||||
const flags = flagsForNote(note);
|
||||
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
|
||||
const editorForNote = application.componentManager.editorForNote(note);
|
||||
const editorName = editorForNote?.name ?? 'Plain editor';
|
||||
const [icon, tint] = getIconAndTintForEditor(editorForNote?.identifier);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -97,52 +83,109 @@ export const NotesListItem: FunctionComponent<Props> = ({
|
||||
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>
|
||||
{!hideEditorIcon && (
|
||||
<div className="icon">
|
||||
<Icon
|
||||
ariaLabel={`Icon for ${editorName}`}
|
||||
type={icon}
|
||||
className={`color-accessory-tint-${tint}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`meta ${hideEditorIcon ? 'icon-hidden' : ''}`}>
|
||||
<div className="name">
|
||||
<div>{note.title}</div>
|
||||
<div className="flag-icons">
|
||||
{note.locked && (
|
||||
<span title="Editing Disabled">
|
||||
<Icon
|
||||
ariaLabel="Editing Disabled"
|
||||
type="pencil-off"
|
||||
className="sn-icon--small color-info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.trashed && (
|
||||
<span title="Trashed">
|
||||
<Icon
|
||||
ariaLabel="Trashed"
|
||||
type="trash-filled"
|
||||
className="sn-icon--small color-danger"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.archived && (
|
||||
<span title="Archived">
|
||||
<Icon
|
||||
ariaLabel="Archived"
|
||||
type="archive"
|
||||
className="sn-icon--mid color-accessory-tint-3"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{note.pinned && (
|
||||
<span title="Pinned">
|
||||
<Icon
|
||||
ariaLabel="Pinned"
|
||||
type="pin-filled"
|
||||
className="sn-icon--small color-info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!hidePreview && !note.hidePreview && !note.protected && (
|
||||
<div className="note-preview">
|
||||
{note.preview_html && (
|
||||
<div
|
||||
className="html-preview"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtmlString(note.preview_html),
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
{!note.preview_html && note.preview_plain && (
|
||||
<div className="plain-preview">{note.preview_plain}</div>
|
||||
)}
|
||||
{!note.preview_html && !note.preview_plain && note.text && (
|
||||
<div className="default-preview">{note.text}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hideDate || note.protected ? (
|
||||
<div className="bottom-info faded">
|
||||
{note.protected && <span>Protected {hideDate ? '' : ' • '}</span>}
|
||||
{!hideDate && showModifiedDate && (
|
||||
<span>Modified {note.updatedAtString || 'Now'}</span>
|
||||
)}
|
||||
{!hideDate && !showModifiedDate && (
|
||||
<span>{note.createdAtString || 'Now'}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{!hideTags && tags.length ? (
|
||||
<div className="tags-string">
|
||||
{tags.map((tag) => (
|
||||
<span className="tag color-foreground">
|
||||
<Icon
|
||||
type="hashtag"
|
||||
className="sn-icon--small color-grey-1 mr-1"
|
||||
/>
|
||||
<span>{tag}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{flags.length ? (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,9 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
const [hideProtected, setHideProtected] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideProtected, false)
|
||||
);
|
||||
const [hideEditorIcon, setHideEditorIcon] = useState(() =>
|
||||
application.getPreference(PrefKey.NotesHideEditorIcon, false)
|
||||
);
|
||||
|
||||
const toggleSortReverse = () => {
|
||||
application.setPreference(PrefKey.SortNotesReverse, !sortReverse);
|
||||
@@ -108,9 +111,14 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
application.setPreference(PrefKey.NotesHideProtected, !hideProtected);
|
||||
};
|
||||
|
||||
const toggleEditorIcon = () => {
|
||||
setHideEditorIcon(!hideEditorIcon);
|
||||
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon);
|
||||
};
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useCloseOnClickOutside(menuRef as any, (open: boolean) => {
|
||||
useCloseOnClickOutside(menuRef, (open: boolean) => {
|
||||
if (!open) {
|
||||
closeDisplayOptionsMenu();
|
||||
}
|
||||
@@ -201,6 +209,14 @@ flex flex-col py-2 bottom-0 left-2 absolute';
|
||||
>
|
||||
Show tags
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideEditorIcon}
|
||||
onChange={toggleEditorIcon}
|
||||
>
|
||||
Show editor icon
|
||||
</MenuItem>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">
|
||||
Other
|
||||
|
||||
@@ -124,9 +124,9 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = (
|
||||
_w,
|
||||
_l,
|
||||
_mw,
|
||||
_lastWidth,
|
||||
_lastLeft,
|
||||
_isMaxWidth,
|
||||
isCollapsed
|
||||
) => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
@@ -230,6 +230,7 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
<NotesList
|
||||
notes={renderedNotes}
|
||||
selectedNotes={selectedNotes}
|
||||
application={application}
|
||||
appState={appState}
|
||||
displayOptions={displayOptions}
|
||||
paginate={paginate}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FeaturesState } from '@/ui_models/app_state/features_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionalComponent } from 'preact';
|
||||
import { useCallback, useContext, useState } from 'preact/hooks';
|
||||
import { createContext } from 'react';
|
||||
@@ -21,29 +23,31 @@ export const usePremiumModal = (): PremiumModalContextData => {
|
||||
return value;
|
||||
};
|
||||
|
||||
export const PremiumModalProvider: FunctionalComponent = ({ children }) => {
|
||||
const [featureName, setFeatureName] = useState<null | string>(null);
|
||||
interface Props {
|
||||
state: FeaturesState;
|
||||
}
|
||||
|
||||
const activate = setFeatureName;
|
||||
export const PremiumModalProvider: FunctionalComponent<Props> = observer(
|
||||
({ state, children }) => {
|
||||
const featureName = state._premiumAlertFeatureName;
|
||||
const activate = state.showPremiumAlert;
|
||||
const close = state.closePremiumAlert;
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setFeatureName(null);
|
||||
}, [setFeatureName]);
|
||||
const showModal = !!featureName;
|
||||
|
||||
const showModal = !!featureName;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showModal && (
|
||||
<PremiumFeaturesModal
|
||||
showModal={!!featureName}
|
||||
featureName={featureName}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
<PremiumModalProvider_ value={{ activate }}>
|
||||
{children}
|
||||
</PremiumModalProvider_>
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{showModal && (
|
||||
<PremiumFeaturesModal
|
||||
showModal={!!featureName}
|
||||
featureName={featureName}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
<PremiumModalProvider_ value={{ activate }}>
|
||||
{children}
|
||||
</PremiumModalProvider_>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import {
|
||||
FeaturesState,
|
||||
TAG_FOLDERS_FEATURE_NAME,
|
||||
@@ -5,9 +7,7 @@ import {
|
||||
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';
|
||||
import { DropItem, DropProps, ItemTypes } from './dragndrop';
|
||||
|
||||
type Props = {
|
||||
tagsState: TagsState;
|
||||
@@ -18,7 +18,7 @@ export const RootTagDropZone: React.FC<Props> = observer(
|
||||
({ tagsState, featuresState }) => {
|
||||
const premiumModal = usePremiumModal();
|
||||
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
|
||||
const hasFolders = tagsState.hasFolders;
|
||||
const hasFolders = featuresState.hasFolders;
|
||||
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
|
||||
() => ({
|
||||
@@ -50,7 +50,7 @@ export const RootTagDropZone: React.FC<Props> = observer(
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={`root-drop ${canDrop ? 'active' : ''} ${
|
||||
isOver ? 'is-over' : ''
|
||||
isOver ? 'is-drag-over' : ''
|
||||
}`}
|
||||
>
|
||||
<Icon className="color-neutral" type="link-off" />
|
||||
29
app/assets/javascripts/components/Tags/SmartTagsList.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { SmartTagsListItem } from './SmartTagsListItem';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
export const SmartTagsList: FunctionComponent<Props> = observer(
|
||||
({ appState }) => {
|
||||
const allTags = appState.tags.smartTags;
|
||||
|
||||
return (
|
||||
<>
|
||||
{allTags.map((tag) => {
|
||||
return (
|
||||
<SmartTagsListItem
|
||||
key={tag.uuid}
|
||||
tag={tag}
|
||||
tagsState={appState.tags}
|
||||
features={appState.features}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
168
app/assets/javascripts/components/Tags/SmartTagsListItem.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Icon, IconType } from '@/components/Icon';
|
||||
import { FeaturesState } from '@/ui_models/app_state/features_state';
|
||||
import { TagsState } from '@/ui_models/app_state/tags_state';
|
||||
import '@reach/tooltip/styles.css';
|
||||
import { SNSmartTag } from '@standardnotes/snjs';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
type Props = {
|
||||
tag: SNSmartTag;
|
||||
tagsState: TagsState;
|
||||
features: FeaturesState;
|
||||
};
|
||||
|
||||
const PADDING_BASE_PX = 14;
|
||||
const PADDING_PER_LEVEL_PX = 21;
|
||||
|
||||
const smartTagIconType = (tag: SNSmartTag): IconType => {
|
||||
if (tag.isAllTag) {
|
||||
return 'notes';
|
||||
}
|
||||
if (tag.isArchiveTag) {
|
||||
return 'archive';
|
||||
}
|
||||
if (tag.isTrashTag) {
|
||||
return 'trash';
|
||||
}
|
||||
return 'hashtag';
|
||||
};
|
||||
|
||||
export const SmartTagsListItem: FunctionComponent<Props> = observer(
|
||||
({ tag, tagsState, features }) => {
|
||||
const [title, setTitle] = useState(tag.title || '');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const level = 0;
|
||||
const isSelected = tagsState.selected === tag;
|
||||
const isEditing = tagsState.editingTag === tag;
|
||||
const isSmartTagsEnabled = features.enableNativeSmartTagsFeature;
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(tag.title || '');
|
||||
}, [setTitle, tag]);
|
||||
|
||||
const selectCurrentTag = useCallback(() => {
|
||||
tagsState.selected = tag;
|
||||
}, [tagsState, tag]);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
tagsState.save(tag, title);
|
||||
setTitle(tag.title);
|
||||
}, [tagsState, tag, title, setTitle]);
|
||||
|
||||
const onInput = useCallback(
|
||||
(e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
setTitle(value);
|
||||
},
|
||||
[setTitle]
|
||||
);
|
||||
|
||||
const onKeyUp = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.code === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[inputRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [inputRef, isEditing]);
|
||||
|
||||
const onClickRename = useCallback(() => {
|
||||
tagsState.editingTag = tag;
|
||||
}, [tagsState, tag]);
|
||||
|
||||
const onClickSave = useCallback(() => {
|
||||
inputRef.current?.blur();
|
||||
}, [inputRef]);
|
||||
|
||||
const onClickDelete = useCallback(() => {
|
||||
tagsState.remove(tag);
|
||||
}, [tagsState, tag]);
|
||||
|
||||
const isFaded = !isSmartTagsEnabled && !tag.isAllTag;
|
||||
const iconType = smartTagIconType(tag);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`tag ${isSelected ? 'selected' : ''} ${
|
||||
isFaded ? 'faded' : ''
|
||||
}`}
|
||||
onClick={selectCurrentTag}
|
||||
style={{
|
||||
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
||||
}}
|
||||
>
|
||||
{!tag.errorDecrypting ? (
|
||||
<div className="tag-info">
|
||||
{isSmartTagsEnabled && (
|
||||
<div className={`tag-icon mr-1`}>
|
||||
<Icon
|
||||
type={iconType}
|
||||
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className={`title ${isEditing ? 'editing' : ''}`}
|
||||
id={`react-tag-${tag.uuid}`}
|
||||
onBlur={onBlur}
|
||||
onInput={onInput}
|
||||
value={title}
|
||||
onKeyUp={onKeyUp}
|
||||
spellCheck={false}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div className="count">
|
||||
{tag.isAllTag && tagsState.allNotesCount}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!tag.isSystemSmartTag && (
|
||||
<div className="meta">
|
||||
{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>
|
||||
)}
|
||||
{isEditing && (
|
||||
<a className="item" onClick={onClickSave}>
|
||||
Save
|
||||
</a>
|
||||
)}
|
||||
<a className="item" onClick={onClickDelete}>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
18
app/assets/javascripts/components/Tags/SmartTagsSection.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { SmartTagsList } from './SmartTagsList';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
export const SmartTagsSection: FunctionComponent<Props> = observer(
|
||||
({ appState }) => {
|
||||
return (
|
||||
<section>
|
||||
<SmartTagsList appState={appState} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
);
|
||||
48
app/assets/javascripts/components/Tags/TagsList.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { isMobile } from '@/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
export const TagsList: FunctionComponent<Props> = observer(({ appState }) => {
|
||||
const tagsState = appState.tags;
|
||||
const allTags = tagsState.allLocalRootTags;
|
||||
|
||||
const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
|
||||
|
||||
return (
|
||||
<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={tagsState}
|
||||
features={appState.features}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<RootTagDropZone
|
||||
tagsState={appState.tags}
|
||||
featuresState={appState.features}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DndProvider>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import {
|
||||
FeaturesState,
|
||||
TAG_FOLDERS_FEATURE_NAME,
|
||||
@@ -5,56 +7,39 @@ import {
|
||||
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 { computed } 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 };
|
||||
import { DropItem, DropProps, ItemTypes } from './dragndrop';
|
||||
|
||||
type Props = {
|
||||
tag: SNTag;
|
||||
tagsState: TagsState;
|
||||
selectTag: (tag: SNTag) => void;
|
||||
removeTag: (tag: SNTag) => void;
|
||||
saveTag: (tag: SNTag, newTitle: string) => void;
|
||||
appState: TagsListState;
|
||||
features: FeaturesState;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export type TagsListState = {
|
||||
readonly selectedTag: SNTag | undefined;
|
||||
tags: TagsState;
|
||||
editingTag: SNTag | undefined;
|
||||
features: FeaturesState;
|
||||
};
|
||||
const PADDING_BASE_PX = 14;
|
||||
const PADDING_PER_LEVEL_PX = 21;
|
||||
|
||||
export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => {
|
||||
({ tag, features, tagsState, level }) => {
|
||||
const [title, setTitle] = useState(tag.title || '');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isSelected = appState.selectedTag === tag;
|
||||
const isEditing = appState.editingTag === tag;
|
||||
const noteCounts = computed(() => appState.tags.getNotesCount(tag));
|
||||
const isSelected = tagsState.selected === tag;
|
||||
const isEditing = tagsState.editingTag === tag;
|
||||
const noteCounts = computed(() => tagsState.getNotesCount(tag));
|
||||
|
||||
const childrenTags = computed(() => tagsState.getChildren(tag)).get();
|
||||
const hasChildren = childrenTags.length > 0;
|
||||
|
||||
const hasFolders = tagsState.hasFolders;
|
||||
const isNativeFoldersEnabled = appState.features.enableNativeFoldersFeature;
|
||||
const hasFolders = features.hasFolders;
|
||||
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
|
||||
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
|
||||
|
||||
const premiumModal = usePremiumModal();
|
||||
|
||||
const [showChildren, setShowChildren] = useState(hasChildren);
|
||||
@@ -80,16 +65,13 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
);
|
||||
|
||||
const selectCurrentTag = useCallback(() => {
|
||||
if (isEditing || isSelected) {
|
||||
return;
|
||||
}
|
||||
selectTag(tag);
|
||||
}, [isSelected, isEditing, selectTag, tag]);
|
||||
tagsState.selected = tag;
|
||||
}, [tagsState, tag]);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
saveTag(tag, title);
|
||||
tagsState.save(tag, title);
|
||||
setTitle(tag.title);
|
||||
}, [tag, saveTag, title, setTitle]);
|
||||
}, [tagsState, tag, title, setTitle]);
|
||||
|
||||
const onInput = useCallback(
|
||||
(e: JSX.TargetedEvent<HTMLInputElement>) => {
|
||||
@@ -116,18 +98,16 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
}, [inputRef, isEditing]);
|
||||
|
||||
const onClickRename = useCallback(() => {
|
||||
runInAction(() => {
|
||||
appState.editingTag = tag;
|
||||
});
|
||||
}, [appState, tag]);
|
||||
tagsState.editingTag = tag;
|
||||
}, [tagsState, tag]);
|
||||
|
||||
const onClickSave = useCallback(() => {
|
||||
inputRef.current?.blur();
|
||||
}, [inputRef]);
|
||||
|
||||
const onClickDelete = useCallback(() => {
|
||||
removeTag(tag);
|
||||
}, [removeTag, tag]);
|
||||
tagsState.remove(tag);
|
||||
}, [tagsState, tag]);
|
||||
|
||||
const [, dragRef] = useDrag(
|
||||
() => ({
|
||||
@@ -174,7 +154,9 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
}`}
|
||||
onClick={selectCurrentTag}
|
||||
ref={dragRef}
|
||||
style={{ paddingLeft: `${level * 21 + 10}px` }}
|
||||
style={{
|
||||
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
||||
}}
|
||||
>
|
||||
{!tag.errorDecrypting ? (
|
||||
<div className="tag-info" title={title} ref={dropRef}>
|
||||
@@ -255,10 +237,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
||||
key={tag.uuid}
|
||||
tag={tag}
|
||||
tagsState={tagsState}
|
||||
selectTag={selectTag}
|
||||
saveTag={saveTag}
|
||||
removeTag={removeTag}
|
||||
appState={appState}
|
||||
features={features}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1,108 +1,29 @@
|
||||
import { TagsList } from '@/components/TagsList';
|
||||
import { toDirective } from '@/components/utils';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { TagsList } from '@/components/Tags/TagsList';
|
||||
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';
|
||||
import { TagsSectionAddButton } from './TagsSectionAddButton';
|
||||
import { TagsSectionTitle } from './TagsSectionTitle';
|
||||
|
||||
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 }) => {
|
||||
({ 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>
|
||||
<section>
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<TagsSectionTitle features={appState.features} />
|
||||
<TagsSectionAddButton
|
||||
tags={appState.tags}
|
||||
features={appState.features}
|
||||
/>
|
||||
</div>
|
||||
<TagsList application={application} appState={appState} />
|
||||
</section>
|
||||
</PremiumModalProvider>
|
||||
</div>
|
||||
<TagsList appState={appState} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const TagsSectionDirective = toDirective<Props>(TagsSection);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { FeaturesState } from '@/ui_models/app_state/features_state';
|
||||
import { TagsState } from '@/ui_models/app_state/tags_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
type Props = {
|
||||
tags: TagsState;
|
||||
features: FeaturesState;
|
||||
};
|
||||
|
||||
export const TagsSectionAddButton: FunctionComponent<Props> = observer(
|
||||
({ tags, features }) => {
|
||||
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
|
||||
|
||||
if (!isNativeFoldersEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
focusable={true}
|
||||
icon="add"
|
||||
title="Create a new tag"
|
||||
className="color-neutral"
|
||||
onClick={() => tags.createNewTemplate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
62
app/assets/javascripts/components/Tags/TagsSectionTitle.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
features: FeaturesState;
|
||||
};
|
||||
|
||||
export const TagsSectionTitle: FunctionComponent<Props> = 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
9
app/assets/javascripts/components/Tags/dragndrop.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum ItemTypes {
|
||||
TAG = 'TAG',
|
||||
}
|
||||
|
||||
export type DropItemTag = { uuid: string };
|
||||
|
||||
export type DropItem = DropItemTag;
|
||||
|
||||
export type DropProps = { isOver: boolean; canDrop: boolean };
|
||||
@@ -1,153 +0,0 @@
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const tagsWithOptionalTemplate = (
|
||||
template: SNTag | undefined,
|
||||
tags: SNTag[]
|
||||
): SNTag[] => {
|
||||
if (!template) {
|
||||
return tags;
|
||||
}
|
||||
return [template, ...tags];
|
||||
};
|
||||
|
||||
export const TagsList: FunctionComponent<Props> = observer(
|
||||
({ application, appState }) => {
|
||||
const templateTag = appState.templateTag;
|
||||
const rootTags = appState.tags.rootTags;
|
||||
|
||||
const allTags = tagsWithOptionalTemplate(templateTag, rootTags);
|
||||
|
||||
const selectTag = useCallback(
|
||||
(tag: SNTag) => {
|
||||
appState.setSelectedTag(tag);
|
||||
},
|
||||
[appState]
|
||||
);
|
||||
|
||||
const saveTag = useCallback(
|
||||
async (tag: SNTag, newTitle: string) => {
|
||||
const templateTag = appState.templateTag;
|
||||
|
||||
const hasEmptyTitle = newTitle.length === 0;
|
||||
const hasNotChangedTitle = newTitle === tag.title;
|
||||
const isTemplateChange = templateTag && tag.uuid === templateTag.uuid;
|
||||
const hasDuplicatedTitle = !!application.findTagByTitle(newTitle);
|
||||
|
||||
runInAction(() => {
|
||||
appState.templateTag = undefined;
|
||||
appState.editingTag = undefined;
|
||||
});
|
||||
|
||||
if (hasEmptyTitle || hasNotChangedTitle) {
|
||||
if (isTemplateChange) {
|
||||
appState.undoCreateNewTag();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasDuplicatedTitle) {
|
||||
if (isTemplateChange) {
|
||||
appState.undoCreateNewTag();
|
||||
}
|
||||
application.alertService?.alert(
|
||||
'A tag with this name already exists.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTemplateChange) {
|
||||
const insertedTag = await application.insertItem(templateTag);
|
||||
const changedTag = await application.changeItem<TagMutator>(
|
||||
insertedTag.uuid,
|
||||
(m) => {
|
||||
m.title = newTitle;
|
||||
}
|
||||
);
|
||||
|
||||
selectTag(changedTag as SNTag);
|
||||
await application.saveItem(insertedTag.uuid);
|
||||
} else {
|
||||
await application.changeAndSaveItem<TagMutator>(
|
||||
tag.uuid,
|
||||
(mutator) => {
|
||||
mutator.title = newTitle;
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[appState, application, selectTag]
|
||||
);
|
||||
|
||||
const removeTag = useCallback(
|
||||
async (tag: SNTag) => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: STRING_DELETE_TAG,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
appState.removeTag(tag);
|
||||
}
|
||||
},
|
||||
[appState]
|
||||
);
|
||||
|
||||
const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const TagsListDirective = toDirective<Props>(TagsList);
|
||||
@@ -32,7 +32,7 @@ export function useCloseOnBlur(
|
||||
}
|
||||
|
||||
export function useCloseOnClickOutside(
|
||||
container: { current: HTMLDivElement },
|
||||
container: { current: HTMLDivElement | null },
|
||||
setOpen: (open: boolean) => void
|
||||
): void {
|
||||
const closeOnClickOutside = useCallback(
|
||||
|
||||
@@ -3,7 +3,6 @@ export { clickOutside } from './click-outside';
|
||||
export { delayHide } from './delay-hide';
|
||||
export { elemReady } from './elemReady';
|
||||
export { fileChange } from './file-change';
|
||||
export { infiniteScroll } from './infiniteScroll';
|
||||
export { lowercase } from './lowercase';
|
||||
export { selectOnFocus } from './selectOnFocus';
|
||||
export { snEnter } from './snEnter';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { debounce } from '@/utils';
|
||||
/* @ngInject */
|
||||
export function infiniteScroll() {
|
||||
return {
|
||||
link: function (scope: ng.IScope, elem: JQLite, attrs: any) {
|
||||
const scopeAny = scope as any;
|
||||
const offset = parseInt(attrs.threshold) || 0;
|
||||
const element = elem[0];
|
||||
scopeAny.paginate = debounce(() => {
|
||||
scope.$apply(attrs.infiniteScroll);
|
||||
}, 10);
|
||||
scopeAny.onScroll = () => {
|
||||
if (
|
||||
scope.$eval(attrs.canLoad) &&
|
||||
element.scrollTop + element.offsetHeight >= element.scrollHeight - offset
|
||||
) {
|
||||
scopeAny.paginate();
|
||||
}
|
||||
};
|
||||
elem.on('scroll', scopeAny.onScroll);
|
||||
scope.$on('$destroy', () => {
|
||||
elem.off('scroll', scopeAny.onScroll);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { IconType } from '@/components/Icon';
|
||||
import { action, makeAutoObservable, observable } from 'mobx';
|
||||
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
|
||||
import { ContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import {
|
||||
ComponentArea,
|
||||
ContentType,
|
||||
FeatureIdentifier,
|
||||
SNComponent,
|
||||
} from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { FeatureIdentifier } from '@standardnotes/features';
|
||||
import { ComponentArea } from '@standardnotes/snjs';
|
||||
|
||||
const PREFERENCE_IDS = [
|
||||
'general',
|
||||
'account',
|
||||
'appearance',
|
||||
'security',
|
||||
'backups',
|
||||
'listed',
|
||||
'shortcuts',
|
||||
'accessibility',
|
||||
@@ -37,6 +41,7 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'security', label: 'Security', icon: 'security' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore' },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
|
||||
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
|
||||
@@ -48,6 +53,7 @@ const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
{ id: 'account', label: 'Account', icon: 'user' },
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'security', label: 'Security', icon: 'security' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore' },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
|
||||
];
|
||||
@@ -101,6 +107,7 @@ export class PreferencesMenu {
|
||||
FeatureIdentifier.TwoFactorAuthManager,
|
||||
'org.standardnotes.batch-manager',
|
||||
'org.standardnotes.extensions-manager',
|
||||
FeatureIdentifier.CloudLink,
|
||||
];
|
||||
this._extensionPanes = (
|
||||
this.application.getItems([
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MfaProps } from './panes/two-factor-auth/MfaProps';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { useEffect, useMemo } from 'preact/hooks';
|
||||
import { ExtensionPane } from './panes/ExtensionPane';
|
||||
import { Backups } from '@/preferences/panes/Backups';
|
||||
|
||||
interface PreferencesProps extends MfaProps {
|
||||
application: WebApplication;
|
||||
@@ -26,16 +27,54 @@ interface PreferencesProps extends MfaProps {
|
||||
|
||||
const PaneSelector: FunctionComponent<
|
||||
PreferencesProps & { menu: PreferencesMenu }
|
||||
> = observer(
|
||||
({
|
||||
menu,
|
||||
appState,
|
||||
application,
|
||||
mfaProvider,
|
||||
userProvider
|
||||
}) => {
|
||||
switch (menu.selectedPaneId) {
|
||||
case 'general':
|
||||
> = observer(({ menu, appState, application, mfaProvider, userProvider }) => {
|
||||
switch (menu.selectedPaneId) {
|
||||
case 'general':
|
||||
return (
|
||||
<General
|
||||
appState={appState}
|
||||
application={application}
|
||||
extensionsLatestVersions={menu.extensionsLatestVersions}
|
||||
/>
|
||||
);
|
||||
case 'account':
|
||||
return (
|
||||
<AccountPreferences application={application} appState={appState} />
|
||||
);
|
||||
case 'appearance':
|
||||
return null;
|
||||
case 'security':
|
||||
return (
|
||||
<Security
|
||||
mfaProvider={mfaProvider}
|
||||
userProvider={userProvider}
|
||||
appState={appState}
|
||||
application={application}
|
||||
/>
|
||||
);
|
||||
case 'backups':
|
||||
return <Backups application={application} appState={appState} />;
|
||||
case 'listed':
|
||||
return <Listed application={application} />;
|
||||
case 'shortcuts':
|
||||
return null;
|
||||
case 'accessibility':
|
||||
return null;
|
||||
case 'get-free-month':
|
||||
return null;
|
||||
case 'help-feedback':
|
||||
return <HelpAndFeedback />;
|
||||
default:
|
||||
if (menu.selectedExtension != undefined) {
|
||||
return (
|
||||
<ExtensionPane
|
||||
application={application}
|
||||
appState={appState}
|
||||
extension={menu.selectedExtension}
|
||||
preferencesMenu={menu}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<General
|
||||
appState={appState}
|
||||
@@ -43,55 +82,9 @@ const PaneSelector: FunctionComponent<
|
||||
extensionsLatestVersions={menu.extensionsLatestVersions}
|
||||
/>
|
||||
);
|
||||
case 'account':
|
||||
return (
|
||||
<AccountPreferences
|
||||
application={application}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
case 'appearance':
|
||||
return null;
|
||||
case 'security':
|
||||
return (
|
||||
<Security
|
||||
mfaProvider={mfaProvider}
|
||||
userProvider={userProvider}
|
||||
appState={appState}
|
||||
application={application}
|
||||
/>
|
||||
);
|
||||
case 'listed':
|
||||
return <Listed application={application} />;
|
||||
case 'shortcuts':
|
||||
return null;
|
||||
case 'accessibility':
|
||||
return null;
|
||||
case 'get-free-month':
|
||||
return null;
|
||||
case 'help-feedback':
|
||||
return <HelpAndFeedback />;
|
||||
default:
|
||||
if (menu.selectedExtension != undefined) {
|
||||
return (
|
||||
<ExtensionPane
|
||||
application={application}
|
||||
appState={appState}
|
||||
extension={menu.selectedExtension}
|
||||
preferencesMenu={menu}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<General
|
||||
appState={appState}
|
||||
application={application}
|
||||
extensionsLatestVersions={menu.extensionsLatestVersions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const PreferencesCanvas: FunctionComponent<
|
||||
PreferencesProps & { menu: PreferencesMenu }
|
||||
@@ -105,8 +98,13 @@ const PreferencesCanvas: FunctionComponent<
|
||||
export const PreferencesView: FunctionComponent<PreferencesProps> = observer(
|
||||
(props) => {
|
||||
const menu = useMemo(
|
||||
() => new PreferencesMenu(props.application, props.appState.enableUnfinishedFeatures),
|
||||
[props.appState.enableUnfinishedFeatures, props.application]);
|
||||
() =>
|
||||
new PreferencesMenu(
|
||||
props.application,
|
||||
props.appState.enableUnfinishedFeatures
|
||||
),
|
||||
[props.appState.enableUnfinishedFeatures, props.application]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
menu.selectPane(props.appState.preferences.currentPane);
|
||||
|
||||
23
app/assets/javascripts/preferences/panes/Backups.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { PreferencesPane } from '../components';
|
||||
import { CloudLink, DataBackups, EmailBackups } from './backups-segments';
|
||||
|
||||
interface Props {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
}
|
||||
|
||||
export const Backups: FunctionComponent<Props> = ({
|
||||
application,
|
||||
appState,
|
||||
}) => {
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<DataBackups application={application} appState={appState} />
|
||||
<EmailBackups application={application} />
|
||||
<CloudLink application={application} />
|
||||
</PreferencesPane>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { PreferencesPane } from '../components';
|
||||
import { Encryption, PasscodeLock, Protections, DataBackups } from './security-segments';
|
||||
import { Encryption, PasscodeLock, Protections } from './security-segments';
|
||||
import { TwoFactorAuthWrapper } from './two-factor-auth';
|
||||
import { MfaProps } from './two-factor-auth/MfaProps';
|
||||
|
||||
@@ -20,6 +20,5 @@ export const Security: FunctionComponent<SecurityProps> = (props) => (
|
||||
userProvider={props.userProvider}
|
||||
/>
|
||||
<PasscodeLock appState={props.appState} application={props.application} />
|
||||
<DataBackups application={props.application} appState={props.appState} />
|
||||
</PreferencesPane>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const OfflineSubscription: FunctionalComponent<IProps> = observer(
|
||||
({ application, appState }) => {
|
||||
({ application }) => {
|
||||
const [activationCode, setActivationCode] = useState('');
|
||||
const [isSuccessfullyActivated, setIsSuccessfullyActivated] =
|
||||
useState(false);
|
||||
@@ -33,7 +33,9 @@ export const OfflineSubscription: FunctionalComponent<IProps> = observer(
|
||||
}, [application]);
|
||||
|
||||
const shouldShowOfflineSubscription = () => {
|
||||
return !application.hasAccount() || application.isThirdPartyHostUsed();
|
||||
return !application.hasAccount() ||
|
||||
application.isThirdPartyHostUsed() ||
|
||||
hasUserPreviouslyStoredCode;
|
||||
};
|
||||
|
||||
const handleSubscriptionCodeSubmit = async (
|
||||
|
||||
@@ -5,32 +5,68 @@ import {
|
||||
STRING_INVALID_IMPORT_FILE,
|
||||
STRING_IMPORTING_ZIP_FILE,
|
||||
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
|
||||
StringImportError
|
||||
StringImportError,
|
||||
STRING_E2E_ENABLED,
|
||||
STRING_LOCAL_ENC_ENABLED,
|
||||
STRING_ENC_NOT_ENABLED,
|
||||
} from '@/strings';
|
||||
import { BackupFile } from '@standardnotes/snjs';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useCallback, useEffect, 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';
|
||||
import { PreferencesGroup, PreferencesSegment, Title, Text, Subtitle } from '../../components';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
Text,
|
||||
Subtitle,
|
||||
} from '../../components';
|
||||
import { Button } from '@/components/Button';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
export const DataBackups = observer(({
|
||||
application,
|
||||
appState
|
||||
}: Props) => {
|
||||
};
|
||||
|
||||
export const DataBackups = observer(({ application, appState }: Props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
|
||||
const {
|
||||
isBackupEncrypted,
|
||||
isEncryptionEnabled,
|
||||
setIsBackupEncrypted,
|
||||
setIsEncryptionEnabled,
|
||||
setEncryptionStatusString,
|
||||
} = appState.accountMenu;
|
||||
|
||||
const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu;
|
||||
const refreshEncryptionStatus = useCallback(() => {
|
||||
const hasUser = application.hasAccount();
|
||||
const hasPasscode = application.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,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshEncryptionStatus();
|
||||
}, [refreshEncryptionStatus]);
|
||||
|
||||
const downloadDataArchive = () => {
|
||||
application.getArchiveService().downloadBackup(isBackupEncrypted);
|
||||
@@ -74,12 +110,14 @@ export const DataBackups = observer(({
|
||||
statusText = StringImportError(result.errorCount);
|
||||
}
|
||||
void alertDialog({
|
||||
text: statusText
|
||||
text: statusText,
|
||||
});
|
||||
};
|
||||
|
||||
const importFileSelected = async (event: TargetedEvent<HTMLInputElement, Event>) => {
|
||||
const { files } = (event.target as HTMLInputElement);
|
||||
const importFileSelected = async (
|
||||
event: TargetedEvent<HTMLInputElement, Event>
|
||||
) => {
|
||||
const { files } = event.target as HTMLInputElement;
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
@@ -90,15 +128,14 @@ export const DataBackups = observer(({
|
||||
return;
|
||||
}
|
||||
|
||||
const version = data.version || data.keyParams?.version || data.auth_params?.version;
|
||||
const version =
|
||||
data.version || data.keyParams?.version || data.auth_params?.version;
|
||||
if (!version) {
|
||||
await performImport(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
application.protocolService.supportedVersions().includes(version)
|
||||
) {
|
||||
if (application.protocolService.supportedVersions().includes(version)) {
|
||||
await performImport(data);
|
||||
} else {
|
||||
setIsImportDataLoading(false);
|
||||
@@ -107,7 +144,9 @@ export const DataBackups = observer(({
|
||||
};
|
||||
|
||||
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import
|
||||
const handleImportFile = (event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent) => {
|
||||
const handleImportFile = (
|
||||
event: TargetedEvent<HTMLSpanElement, Event> | KeyboardEvent
|
||||
) => {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
const { code } = event;
|
||||
|
||||
@@ -161,26 +200,33 @@ export const DataBackups = observer(({
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Button type="normal" onClick={downloadDataArchive} label="Download backup" className="mt-2" />
|
||||
|
||||
<Button
|
||||
type="normal"
|
||||
onClick={downloadDataArchive}
|
||||
label="Download backup"
|
||||
className="mt-2"
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
|
||||
<Subtitle>Import a previously saved backup file</Subtitle>
|
||||
|
||||
<div class="flex flex-row items-center mt-3" >
|
||||
<Button type="normal" label="Import Backup" onClick={handleImportFile} />
|
||||
<div class="flex flex-row items-center mt-3">
|
||||
<Button
|
||||
type="normal"
|
||||
label="Import Backup"
|
||||
onClick={handleImportFile}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={importFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
{isImportDataLoading && <div className="sk-spinner normal info ml-4" />}
|
||||
{isImportDataLoading && (
|
||||
<div className="sk-spinner normal info ml-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</PreferencesSegment>
|
||||
|
||||
</PreferencesGroup>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,188 @@
|
||||
import { convertStringifiedBooleanToBoolean, isDesktopApplication } from '@/utils';
|
||||
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Subtitle,
|
||||
Text,
|
||||
Title,
|
||||
} from '../../components';
|
||||
import { EmailBackupFrequency, SettingName } from '@standardnotes/settings';
|
||||
import { Dropdown, DropdownItem } from '@/components/Dropdown';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { FeatureIdentifier } from '@standardnotes/features';
|
||||
import { FeatureStatus } from '@standardnotes/snjs';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
export const EmailBackups = observer(({ application }: Props) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [emailFrequency, setEmailFrequency] = useState<EmailBackupFrequency>(
|
||||
EmailBackupFrequency.Disabled
|
||||
);
|
||||
const [emailFrequencyOptions, setEmailFrequencyOptions] = useState<
|
||||
DropdownItem[]
|
||||
>([]);
|
||||
const [isFailedBackupEmailMuted, setIsFailedBackupEmailMuted] =
|
||||
useState(true);
|
||||
const [isEntitledForEmailBackups, setIsEntitledForEmailBackups] =
|
||||
useState(false);
|
||||
|
||||
const loadEmailFrequencySetting = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const userSettings = await application.listSettings();
|
||||
setEmailFrequency(
|
||||
(userSettings.EMAIL_BACKUP_FREQUENCY ||
|
||||
EmailBackupFrequency.Disabled) as EmailBackupFrequency
|
||||
);
|
||||
setIsFailedBackupEmailMuted(
|
||||
convertStringifiedBooleanToBoolean(
|
||||
userSettings[SettingName.MuteFailedBackupsEmails] as string
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [application]);
|
||||
|
||||
useEffect(() => {
|
||||
const emailBackupsFeatureStatus = application.getFeatureStatus(
|
||||
FeatureIdentifier.DailyEmailBackup
|
||||
);
|
||||
setIsEntitledForEmailBackups(
|
||||
emailBackupsFeatureStatus === FeatureStatus.Entitled
|
||||
);
|
||||
|
||||
const frequencyOptions = [];
|
||||
for (const frequency in EmailBackupFrequency) {
|
||||
const frequencyValue =
|
||||
EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency];
|
||||
frequencyOptions.push({
|
||||
value: frequencyValue,
|
||||
label: application.getEmailBackupFrequencyOptionLabel(frequencyValue),
|
||||
});
|
||||
}
|
||||
setEmailFrequencyOptions(frequencyOptions);
|
||||
|
||||
loadEmailFrequencySetting();
|
||||
}, [application, loadEmailFrequencySetting]);
|
||||
|
||||
const updateSetting = async (
|
||||
settingName: SettingName,
|
||||
payload: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await application.updateSetting(settingName, payload);
|
||||
return true;
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateEmailFrequency = async (frequency: EmailBackupFrequency) => {
|
||||
const previousFrequency = emailFrequency;
|
||||
setEmailFrequency(frequency);
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.EmailBackupFrequency,
|
||||
frequency
|
||||
);
|
||||
if (!updateResult) {
|
||||
setEmailFrequency(previousFrequency);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMuteFailedBackupEmails = async () => {
|
||||
const previousValue = isFailedBackupEmailMuted;
|
||||
setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted);
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.MuteFailedBackupsEmails,
|
||||
`${!isFailedBackupEmailMuted}`
|
||||
);
|
||||
if (!updateResult) {
|
||||
setIsFailedBackupEmailMuted(previousValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Email Backups</Title>
|
||||
{!isEntitledForEmailBackups && (
|
||||
<>
|
||||
<Text>
|
||||
A <span className={'font-bold'}>Plus</span> or{' '}
|
||||
<span className={'font-bold'}>Pro</span> subscription plan is
|
||||
required to enable Email Backups.{' '}
|
||||
<a target="_blank" href="https://standardnotes.com/features">
|
||||
Learn more
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
<HorizontalSeparator classes="mt-3 mb-3" />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
isEntitledForEmailBackups
|
||||
? ''
|
||||
: 'faded cursor-default pointer-events-none'
|
||||
}
|
||||
>
|
||||
{!isDesktopApplication() && (
|
||||
<Text className="mb-3">
|
||||
Daily encrypted email backups of your entire data set delivered
|
||||
directly to your inbox.
|
||||
</Text>
|
||||
)}
|
||||
<Subtitle>Email frequency</Subtitle>
|
||||
<Text>How often to receive backups.</Text>
|
||||
<div className="mt-2">
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Dropdown
|
||||
id="def-editor-dropdown"
|
||||
label="Select email frequency"
|
||||
items={emailFrequencyOptions}
|
||||
defaultValue={emailFrequency}
|
||||
onChange={(item) => {
|
||||
updateEmailFrequency(item as EmailBackupFrequency);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HorizontalSeparator classes="mt-5 mb-4" />
|
||||
<Subtitle>Email preferences</Subtitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Text>
|
||||
Receive a notification email if an email backup fails.
|
||||
</Text>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Switch
|
||||
onChange={toggleMuteFailedBackupEmails}
|
||||
checked={!isFailedBackupEmailMuted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { ButtonType, SettingName } from '@standardnotes/snjs';
|
||||
import {
|
||||
CloudProvider,
|
||||
DropboxBackupFrequency,
|
||||
GoogleDriveBackupFrequency,
|
||||
OneDriveBackupFrequency
|
||||
} from '@standardnotes/settings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { Button } from '@/components/Button';
|
||||
import { isDev, openInNewTab } from '@/utils';
|
||||
import { Subtitle } from '@/preferences/components';
|
||||
import { KeyboardKey } from '@Services/ioService';
|
||||
import { FunctionComponent } from 'preact';
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
providerName: CloudProvider;
|
||||
};
|
||||
|
||||
export const CloudBackupProvider: FunctionComponent<Props> = ({
|
||||
application,
|
||||
providerName
|
||||
}) => {
|
||||
const [authBegan, setAuthBegan] = useState(false);
|
||||
const [successfullyInstalled, setSuccessfullyInstalled] = useState(false);
|
||||
const [backupFrequency, setBackupFrequency] = useState<string | null>(null);
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
|
||||
const disable = async (event: Event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
const shouldDisable = await application.alertService
|
||||
.confirm(
|
||||
'Are you sure you want to disable this integration?',
|
||||
'Disable?',
|
||||
'Disable',
|
||||
ButtonType.Danger,
|
||||
'Cancel'
|
||||
);
|
||||
if (shouldDisable) {
|
||||
await application.deleteSetting(backupFrequencySettingName);
|
||||
await application.deleteSetting(backupTokenSettingName);
|
||||
|
||||
setBackupFrequency(null);
|
||||
}
|
||||
} catch (error) {
|
||||
application.alertService.alert(error as string);
|
||||
}
|
||||
};
|
||||
|
||||
const installIntegration = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const authUrl = application.getCloudProviderIntegrationUrl(providerName, isDev);
|
||||
openInNewTab(authUrl);
|
||||
setAuthBegan(true);
|
||||
};
|
||||
|
||||
const performBackupNow = async () => {
|
||||
// A backup is performed anytime the setting is updated with the integration token, so just update it here
|
||||
try {
|
||||
await application.updateSetting(backupFrequencySettingName, backupFrequency as string);
|
||||
application.alertService.alert(
|
||||
'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.'
|
||||
);
|
||||
} catch (err) {
|
||||
application.alertService.alert(
|
||||
'There was an error while trying to trigger a backup for this provider. Please try again.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const backupSettingsData = {
|
||||
[CloudProvider.Dropbox]: {
|
||||
backupTokenSettingName: SettingName.DropboxBackupToken,
|
||||
backupFrequencySettingName: SettingName.DropboxBackupFrequency,
|
||||
defaultBackupFrequency: DropboxBackupFrequency.Daily
|
||||
},
|
||||
[CloudProvider.Google]: {
|
||||
backupTokenSettingName: SettingName.GoogleDriveBackupToken,
|
||||
backupFrequencySettingName: SettingName.GoogleDriveBackupFrequency,
|
||||
defaultBackupFrequency: GoogleDriveBackupFrequency.Daily
|
||||
},
|
||||
[CloudProvider.OneDrive]: {
|
||||
backupTokenSettingName: SettingName.OneDriveBackupToken,
|
||||
backupFrequencySettingName: SettingName.OneDriveBackupFrequency,
|
||||
defaultBackupFrequency: OneDriveBackupFrequency.Daily
|
||||
}
|
||||
};
|
||||
const { backupTokenSettingName, backupFrequencySettingName, defaultBackupFrequency } = backupSettingsData[providerName];
|
||||
|
||||
const getCloudProviderIntegrationTokenFromUrl = (url: URL) => {
|
||||
const urlSearchParams = new URLSearchParams(url.search);
|
||||
let integrationTokenKeyInUrl = '';
|
||||
|
||||
switch (providerName) {
|
||||
case CloudProvider.Dropbox:
|
||||
integrationTokenKeyInUrl = 'dbt';
|
||||
break;
|
||||
case CloudProvider.Google:
|
||||
integrationTokenKeyInUrl = 'key';
|
||||
break;
|
||||
case CloudProvider.OneDrive:
|
||||
integrationTokenKeyInUrl = 'key';
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid Cloud Provider name');
|
||||
}
|
||||
return urlSearchParams.get(integrationTokenKeyInUrl);
|
||||
};
|
||||
|
||||
const handleKeyPress = async (event: KeyboardEvent) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
try {
|
||||
const decryptedCode = atob(confirmation);
|
||||
const urlFromDecryptedCode = new URL(decryptedCode);
|
||||
const cloudProviderToken =
|
||||
getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode);
|
||||
|
||||
if (!cloudProviderToken) {
|
||||
throw new Error();
|
||||
}
|
||||
await application.updateSetting(backupTokenSettingName, cloudProviderToken);
|
||||
await application.updateSetting(backupFrequencySettingName, defaultBackupFrequency);
|
||||
|
||||
setBackupFrequency(defaultBackupFrequency);
|
||||
|
||||
setAuthBegan(false);
|
||||
setSuccessfullyInstalled(true);
|
||||
setConfirmation('');
|
||||
|
||||
await application.alertService.alert(
|
||||
`${providerName} has been successfully installed. Your first backup has also been queued and should be reflected in your external cloud's folder within the next few minutes.`
|
||||
);
|
||||
} catch (e) {
|
||||
await application.alertService.alert('Invalid code. Please try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
setConfirmation((event.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
const getIntegrationStatus = useCallback(async () => {
|
||||
const frequency = await application.getSetting(backupFrequencySettingName);
|
||||
setBackupFrequency(frequency);
|
||||
}, [application, backupFrequencySettingName]);
|
||||
|
||||
useEffect(() => {
|
||||
getIntegrationStatus();
|
||||
}, [getIntegrationStatus]);
|
||||
|
||||
const isExpanded = authBegan || successfullyInstalled;
|
||||
const shouldShowEnableButton = !backupFrequency && !authBegan;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mr-1 ${isExpanded ? 'expanded' : ' '} ${
|
||||
shouldShowEnableButton || backupFrequency
|
||||
? 'flex justify-between items-center'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<Subtitle>{providerName}</Subtitle>
|
||||
|
||||
{successfullyInstalled && (
|
||||
<p>{providerName} has been successfully enabled.</p>
|
||||
)}
|
||||
</div>
|
||||
{authBegan && (
|
||||
<div>
|
||||
<p className='sk-panel-row'>
|
||||
Complete authentication from the newly opened window. Upon
|
||||
completion, a confirmation code will be displayed. Enter this code
|
||||
below:
|
||||
</p>
|
||||
<div className={`mt-1`}>
|
||||
<input
|
||||
className='sk-input sk-base center-text'
|
||||
placeholder='Enter confirmation code'
|
||||
value={confirmation}
|
||||
onKeyPress={handleKeyPress}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowEnableButton && (
|
||||
<div>
|
||||
<Button
|
||||
type='normal'
|
||||
label='Enable'
|
||||
className={'px-1 text-xs min-w-40'}
|
||||
onClick={installIntegration}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupFrequency && (
|
||||
<div className={'flex flex-col items-end'}>
|
||||
<Button
|
||||
className='min-w-40 mb-2'
|
||||
type='normal'
|
||||
label='Perform Backup'
|
||||
onClick={performBackupNow}
|
||||
/>
|
||||
<Button
|
||||
className='min-w-40'
|
||||
type='normal'
|
||||
label='Disable'
|
||||
onClick={disable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { CloudBackupProvider } from './CloudBackupProvider';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment, Subtitle,
|
||||
Text,
|
||||
Title
|
||||
} from '@/preferences/components';
|
||||
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
|
||||
import { FeatureIdentifier } from '@standardnotes/features';
|
||||
import { FeatureStatus } from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { CloudProvider, EmailBackupFrequency, SettingName } from '@standardnotes/settings';
|
||||
import { Switch } from '@/components/Switch';
|
||||
import { convertStringifiedBooleanToBoolean } from '@/utils';
|
||||
import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
|
||||
|
||||
const providerData = [{
|
||||
name: CloudProvider.Dropbox
|
||||
}, {
|
||||
name: CloudProvider.Google
|
||||
}, {
|
||||
name: CloudProvider.OneDrive
|
||||
}
|
||||
];
|
||||
|
||||
type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
export const CloudLink: FunctionComponent<Props> = ({ application }) => {
|
||||
const [isEntitledForCloudBackups, setIsEntitledForCloudBackups] = useState(false);
|
||||
const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const userSettings = await application.listSettings();
|
||||
setIsFailedCloudBackupEmailMuted(
|
||||
convertStringifiedBooleanToBoolean(
|
||||
userSettings[SettingName.MuteFailedCloudBackupsEmails] as string
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [application]);
|
||||
|
||||
useEffect(() => {
|
||||
const cloudBackupsFeatureStatus = application.getFeatureStatus(
|
||||
FeatureIdentifier.CloudLink
|
||||
);
|
||||
setIsEntitledForCloudBackups(
|
||||
cloudBackupsFeatureStatus === FeatureStatus.Entitled
|
||||
);
|
||||
loadIsFailedCloudBackupEmailMutedSetting();
|
||||
}, [application, loadIsFailedCloudBackupEmailMutedSetting]);
|
||||
|
||||
const updateSetting = async (
|
||||
settingName: SettingName,
|
||||
payload: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await application.updateSetting(settingName, payload);
|
||||
return true;
|
||||
} catch (e) {
|
||||
application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMuteFailedCloudBackupEmails = async () => {
|
||||
const previousValue = isFailedCloudBackupEmailMuted;
|
||||
setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted);
|
||||
|
||||
const updateResult = await updateSetting(
|
||||
SettingName.MuteFailedCloudBackupsEmails,
|
||||
`${!isFailedCloudBackupEmailMuted}`
|
||||
);
|
||||
if (!updateResult) {
|
||||
setIsFailedCloudBackupEmailMuted(previousValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Cloud Backups</Title>
|
||||
{!isEntitledForCloudBackups && (
|
||||
<>
|
||||
<Text>
|
||||
A <span className={'font-bold'}>Plus</span> or{' '}
|
||||
<span className={'font-bold'}>Pro</span> subscription plan is
|
||||
required to enable Cloud Backups.{' '}
|
||||
<a target='_blank' href='https://standardnotes.com/features'>
|
||||
Learn more
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
<HorizontalSeparator classes='mt-3 mb-3' />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
isEntitledForCloudBackups
|
||||
? ''
|
||||
: 'faded cursor-default pointer-events-none'
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
Configure the integrations below to enable automatic daily backups
|
||||
of your encrypted data set to your third-party cloud provider.
|
||||
</Text>
|
||||
<div>
|
||||
<HorizontalSeparator classes={'mt-3 mb-3'} />
|
||||
<div>
|
||||
{providerData.map(({ name }) => (
|
||||
<>
|
||||
<CloudBackupProvider application={application} providerName={name} />
|
||||
<HorizontalSeparator classes={'mt-3 mb-3'} />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Subtitle>Email preferences</Subtitle>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className="flex flex-col">
|
||||
<Text>Receive a notification email if a cloud backup fails.</Text>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className={'sk-spinner info small'} />
|
||||
) : (
|
||||
<Switch
|
||||
onChange={toggleMuteFailedCloudBackupEmails}
|
||||
checked={!isFailedCloudBackupEmailMuted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './DataBackups';
|
||||
export * from './EmailBackups';
|
||||
export * from './cloud-backups';
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FunctionComponent } from "preact";
|
||||
import { SNComponent } from "@standardnotes/snjs";
|
||||
import { ComponentArea } from "@standardnotes/features";
|
||||
import { PreferencesSegment, SubtitleLight, Title } from "@/preferences/components";
|
||||
import { Switch } from "@/components/Switch";
|
||||
import { WebApplication } from "@/ui_models/application";
|
||||
@@ -27,28 +26,10 @@ export interface ExtensionItemProps {
|
||||
}
|
||||
|
||||
export const ExtensionItem: FunctionComponent<ExtensionItemProps> =
|
||||
({ application, extension, first, uninstall, toggleActivate, latestVersion }) => {
|
||||
const [autoupdateDisabled, setAutoupdateDisabled] = useState(extension.autoupdateDisabled ?? false);
|
||||
({ application, extension, first, uninstall}) => {
|
||||
const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false);
|
||||
const [extensionName, setExtensionName] = useState(extension.name);
|
||||
|
||||
const toggleAutoupdate = () => {
|
||||
const newAutoupdateValue = !autoupdateDisabled;
|
||||
setAutoupdateDisabled(newAutoupdateValue);
|
||||
application
|
||||
.changeAndSaveItem(extension.uuid, (m: any) => {
|
||||
if (m.content == undefined) m.content = {};
|
||||
m.content.autoupdateDisabled = newAutoupdateValue;
|
||||
})
|
||||
.then((item) => {
|
||||
const component = (item as SNComponent);
|
||||
setAutoupdateDisabled(component.autoupdateDisabled);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOffllineOnly = () => {
|
||||
const newOfflineOnly = !offlineOnly;
|
||||
setOfflineOnly(newOfflineOnly);
|
||||
@@ -80,6 +61,7 @@ export const ExtensionItem: FunctionComponent<ExtensionItemProps> =
|
||||
};
|
||||
|
||||
const localInstallable = extension.package_info.download_url;
|
||||
const isThirParty = application.isThirdPartyFeature(extension.identifier);
|
||||
|
||||
return (
|
||||
<PreferencesSegment classes={'mb-5'}>
|
||||
@@ -91,7 +73,7 @@ export const ExtensionItem: FunctionComponent<ExtensionItemProps> =
|
||||
<RenameExtension extensionName={extensionName} changeName={changeExtensionName} />
|
||||
<div className="min-h-2" />
|
||||
|
||||
{localInstallable && <UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />}
|
||||
{isThirParty && localInstallable && <UseHosted offlineOnly={offlineOnly} toggleOfllineOnly={toggleOffllineOnly} />}
|
||||
|
||||
<>
|
||||
<div className="min-h-2" />
|
||||
|
||||
@@ -21,32 +21,33 @@ type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
type EditorOption = {
|
||||
icon?: IconType;
|
||||
label: string;
|
||||
type EditorOption = DropdownItem & {
|
||||
value: FeatureIdentifier | 'plain-editor';
|
||||
};
|
||||
|
||||
const getEditorIconType = (identifier: string): IconType | null => {
|
||||
export const getIconAndTintForEditor = (
|
||||
identifier: FeatureIdentifier | undefined
|
||||
): [IconType, number] => {
|
||||
switch (identifier) {
|
||||
case FeatureIdentifier.BoldEditor:
|
||||
case FeatureIdentifier.PlusEditor:
|
||||
return 'rich-text';
|
||||
return ['rich-text', 1];
|
||||
case FeatureIdentifier.MarkdownBasicEditor:
|
||||
case FeatureIdentifier.MarkdownMathEditor:
|
||||
case FeatureIdentifier.MarkdownMinimistEditor:
|
||||
case FeatureIdentifier.MarkdownProEditor:
|
||||
return 'markdown';
|
||||
return ['markdown', 2];
|
||||
case FeatureIdentifier.TokenVaultEditor:
|
||||
return 'authenticator';
|
||||
return ['authenticator', 6];
|
||||
case FeatureIdentifier.SheetsEditor:
|
||||
return 'spreadsheets';
|
||||
return ['spreadsheets', 5];
|
||||
case FeatureIdentifier.TaskEditor:
|
||||
return 'tasks';
|
||||
return ['tasks', 3];
|
||||
case FeatureIdentifier.CodeEditor:
|
||||
return 'code';
|
||||
return ['code', 4];
|
||||
default:
|
||||
return ['plain-text', 1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const makeEditorDefault = (
|
||||
@@ -91,17 +92,19 @@ export const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
.componentsForArea(ComponentArea.Editor)
|
||||
.map((editor): EditorOption => {
|
||||
const identifier = editor.package_info.identifier;
|
||||
const iconType = getEditorIconType(identifier);
|
||||
const [iconType, tint] = getIconAndTintForEditor(identifier);
|
||||
|
||||
return {
|
||||
label: editor.name,
|
||||
value: identifier,
|
||||
...(iconType ? { icon: iconType } : null),
|
||||
...(tint ? { iconClassName: `color-accessory-tint-${tint}` } : null),
|
||||
};
|
||||
})
|
||||
.concat([
|
||||
{
|
||||
icon: 'plain-text',
|
||||
iconClassName: `color-accessory-tint-1`,
|
||||
label: 'Plain Editor',
|
||||
value: 'plain-editor',
|
||||
},
|
||||
|
||||
@@ -72,8 +72,8 @@ export const Tools: FunctionalComponent<Props> = observer(
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Spellcheck</Subtitle>
|
||||
<Text>
|
||||
May degrade performance, especially with long notes. Available
|
||||
in the Plain Text editor and most specialty editors.
|
||||
May degrade performance, especially with long notes. This option only controls
|
||||
spellcheck in the Plain Editor.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch onChange={toggleSpellcheck} checked={spellcheck} />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './Encryption';
|
||||
export * from './PasscodeLock';
|
||||
export * from './Protections';
|
||||
export * from './DataBackups';
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ContentType,
|
||||
UuidString,
|
||||
FeatureStatus,
|
||||
PayloadSource,
|
||||
} from '@standardnotes/snjs';
|
||||
|
||||
const CACHED_THEMES_KEY = 'cachedThemes';
|
||||
@@ -22,6 +23,11 @@ export class ThemeManager extends ApplicationService {
|
||||
super.onAppEvent(event);
|
||||
if (event === ApplicationEvent.SignedOut) {
|
||||
this.deactivateAllThemes();
|
||||
this.activeThemes = [];
|
||||
this.application?.removeValue(
|
||||
CACHED_THEMES_KEY,
|
||||
StorageValueModes.Nonwrapped
|
||||
);
|
||||
} else if (event === ApplicationEvent.StorageReady) {
|
||||
await this.activateCachedThemes();
|
||||
} else if (event === ApplicationEvent.FeaturesUpdated) {
|
||||
@@ -34,7 +40,7 @@ export class ThemeManager extends ApplicationService {
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.clearAppThemeState();
|
||||
this.deactivateAllThemes();
|
||||
this.activeThemes.length = 0;
|
||||
this.unregisterDesktop();
|
||||
this.unregisterStream();
|
||||
@@ -43,7 +49,8 @@ export class ThemeManager extends ApplicationService {
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
reloadThemeStatus(): void {
|
||||
private reloadThemeStatus(): void {
|
||||
let hasChange = false;
|
||||
for (const themeUuid of this.activeThemes) {
|
||||
const theme = this.application.findItem(themeUuid) as SNTheme;
|
||||
if (
|
||||
@@ -52,8 +59,13 @@ export class ThemeManager extends ApplicationService {
|
||||
FeatureStatus.Entitled
|
||||
) {
|
||||
this.deactivateTheme(themeUuid);
|
||||
hasChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChange) {
|
||||
this.cacheThemeState();
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
@@ -64,9 +76,8 @@ export class ThemeManager extends ApplicationService {
|
||||
|
||||
private async activateCachedThemes() {
|
||||
const cachedThemes = await this.getCachedThemes();
|
||||
const writeToCache = false;
|
||||
for (const theme of cachedThemes) {
|
||||
this.activateTheme(theme, writeToCache);
|
||||
this.activateTheme(theme);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,16 +89,15 @@ export class ThemeManager extends ApplicationService {
|
||||
this.deactivateTheme(component.uuid);
|
||||
setTimeout(() => {
|
||||
this.activateTheme(component as SNTheme);
|
||||
this.cacheThemeState();
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
|
||||
this.unregisterStream = this.application.streamItems(
|
||||
ContentType.Theme,
|
||||
() => {
|
||||
const themes = this.application.getDisplayableItems(
|
||||
ContentType.Theme
|
||||
) as SNTheme[];
|
||||
(items, source) => {
|
||||
const themes = items as SNTheme[];
|
||||
for (const theme of themes) {
|
||||
if (theme.active) {
|
||||
this.activateTheme(theme);
|
||||
@@ -95,23 +105,21 @@ export class ThemeManager extends ApplicationService {
|
||||
this.deactivateTheme(theme.uuid);
|
||||
}
|
||||
}
|
||||
if (source !== PayloadSource.LocalRetrieved) {
|
||||
this.cacheThemeState();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private clearAppThemeState() {
|
||||
for (const uuid of this.activeThemes) {
|
||||
this.deactivateTheme(uuid, false);
|
||||
private deactivateAllThemes() {
|
||||
const activeThemes = this.activeThemes.slice();
|
||||
for (const uuid of activeThemes) {
|
||||
this.deactivateTheme(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private deactivateAllThemes() {
|
||||
this.clearAppThemeState();
|
||||
this.activeThemes = [];
|
||||
this.decacheThemes();
|
||||
}
|
||||
|
||||
private activateTheme(theme: SNTheme, writeToCache = true) {
|
||||
private activateTheme(theme: SNTheme) {
|
||||
if (this.activeThemes.find((uuid) => uuid === theme.uuid)) {
|
||||
return;
|
||||
}
|
||||
@@ -128,24 +136,19 @@ export class ThemeManager extends ApplicationService {
|
||||
link.media = 'screen,print';
|
||||
link.id = theme.uuid;
|
||||
document.getElementsByTagName('head')[0].appendChild(link);
|
||||
if (writeToCache) {
|
||||
this.cacheThemes();
|
||||
}
|
||||
}
|
||||
|
||||
private deactivateTheme(uuid: string, recache = true) {
|
||||
private deactivateTheme(uuid: string) {
|
||||
const element = document.getElementById(uuid) as HTMLLinkElement;
|
||||
if (element) {
|
||||
element.disabled = true;
|
||||
element.parentNode!.removeChild(element);
|
||||
element.parentNode?.removeChild(element);
|
||||
}
|
||||
|
||||
removeFromArray(this.activeThemes, uuid);
|
||||
if (recache) {
|
||||
this.cacheThemes();
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheThemes() {
|
||||
private async cacheThemeState() {
|
||||
const themes = this.application.getAll(this.activeThemes) as SNTheme[];
|
||||
const mapped = await Promise.all(
|
||||
themes.map(async (theme) => {
|
||||
@@ -165,15 +168,6 @@ export class ThemeManager extends ApplicationService {
|
||||
);
|
||||
}
|
||||
|
||||
private async decacheThemes() {
|
||||
if (this.application) {
|
||||
return this.application.removeValue(
|
||||
CACHED_THEMES_KEY,
|
||||
StorageValueModes.Nonwrapped
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCachedThemes() {
|
||||
const cachedThemes = (await this.application.getValue(
|
||||
CACHED_THEMES_KEY,
|
||||
|
||||
@@ -22,14 +22,9 @@ export const STRING_NEW_UPDATE_READY =
|
||||
export const STRING_DELETE_TAG =
|
||||
'Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.';
|
||||
|
||||
export const STRING_MISSING_SYSTEM_TAG = 'We are missing a System Tag.';
|
||||
|
||||
/** @editor */
|
||||
export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN =
|
||||
'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.';
|
||||
export const STRING_DELETED_NOTE =
|
||||
'The note you are attempting to edit has been deleted, and is awaiting sync. Changes you make will be disregarded.';
|
||||
export const STRING_INVALID_NOTE =
|
||||
"The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note.";
|
||||
export const STRING_ELLIPSES = '...';
|
||||
export const STRING_GENERIC_SAVE_ERROR =
|
||||
'There was an error saving your note. Please try again.';
|
||||
export const STRING_DELETE_PLACEHOLDER_ATTEMPT =
|
||||
@@ -116,6 +111,9 @@ export const STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON = 'Upgrade';
|
||||
export const STRING_REMOVE_OFFLINE_KEY_CONFIRMATION =
|
||||
'This will delete the previously saved offline key.';
|
||||
|
||||
export const STRING_FAILED_TO_UPDATE_USER_SETTING =
|
||||
'There was an error while trying to update your settings. Please try again.';
|
||||
|
||||
export const Strings = {
|
||||
protectingNoteWithoutProtectionSources:
|
||||
'Access to this note will not be restricted until you set up a passcode or account.',
|
||||
|
||||
@@ -2,24 +2,30 @@ import { Bridge } from '@/services/bridge';
|
||||
import { storage, StorageKey } from '@/services/localStorage';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AccountMenuState } from '@/ui_models/app_state/account_menu_state';
|
||||
import { NoteViewController } from '@/views/note_view/note_view_controller';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ComponentArea,
|
||||
ContentType,
|
||||
DeinitSource,
|
||||
isPayloadSourceInternalChange,
|
||||
PayloadSource,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
SNSmartTag,
|
||||
ComponentViewer,
|
||||
SNTag,
|
||||
NoteViewController,
|
||||
} from '@standardnotes/snjs';
|
||||
import pull from 'lodash/pull';
|
||||
import {
|
||||
action,
|
||||
computed,
|
||||
IReactionDisposer,
|
||||
makeObservable,
|
||||
observable,
|
||||
runInAction,
|
||||
reaction,
|
||||
} from 'mobx';
|
||||
import { ActionsMenuState } from './actions_menu_state';
|
||||
import { FeaturesState } from './features_state';
|
||||
@@ -72,11 +78,6 @@ export class AppState {
|
||||
onVisibilityChange: any;
|
||||
showBetaWarning: boolean;
|
||||
|
||||
selectedTag: SNTag | undefined;
|
||||
previouslySelectedTag: SNTag | undefined;
|
||||
editingTag: SNTag | undefined;
|
||||
_templateTag: SNTag | undefined;
|
||||
|
||||
private multiEditorSupport = false;
|
||||
|
||||
readonly quickSettingsMenu = new QuickSettingsState();
|
||||
@@ -92,10 +93,16 @@ export class AppState {
|
||||
readonly features: FeaturesState;
|
||||
readonly tags: TagsState;
|
||||
readonly notesView: NotesViewState;
|
||||
|
||||
public foldersComponentViewer?: ComponentViewer;
|
||||
|
||||
isSessionsModalVisible = false;
|
||||
|
||||
private appEventObserverRemovers: (() => void)[] = [];
|
||||
|
||||
private readonly tagChangedDisposer: IReactionDisposer;
|
||||
private readonly foldersComponentViewerDisposer: () => void;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
@@ -160,30 +167,27 @@ export class AppState {
|
||||
this.showBetaWarning = false;
|
||||
}
|
||||
|
||||
this.selectedTag = undefined;
|
||||
this.previouslySelectedTag = undefined;
|
||||
this.editingTag = undefined;
|
||||
this._templateTag = undefined;
|
||||
this.foldersComponentViewer = undefined;
|
||||
|
||||
makeObservable(this, {
|
||||
selectedTag: computed,
|
||||
|
||||
showBetaWarning: observable,
|
||||
isSessionsModalVisible: observable,
|
||||
preferences: observable,
|
||||
|
||||
selectedTag: observable,
|
||||
previouslySelectedTag: observable,
|
||||
_templateTag: observable,
|
||||
templateTag: computed,
|
||||
createNewTag: action,
|
||||
editingTag: observable,
|
||||
setSelectedTag: action,
|
||||
removeTag: action,
|
||||
|
||||
enableBetaWarning: action,
|
||||
disableBetaWarning: action,
|
||||
openSessionsModal: action,
|
||||
closeSessionsModal: action,
|
||||
|
||||
foldersComponentViewer: observable.ref,
|
||||
setFoldersComponent: action,
|
||||
});
|
||||
|
||||
this.tagChangedDisposer = this.tagChangedNotifier();
|
||||
this.foldersComponentViewerDisposer =
|
||||
this.subscribeToFoldersComponentChanges();
|
||||
}
|
||||
|
||||
deinit(source: DeinitSource): void {
|
||||
@@ -206,6 +210,8 @@ export class AppState {
|
||||
}
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.onVisibilityChange = undefined;
|
||||
this.tagChangedDisposer();
|
||||
this.foldersComponentViewerDisposer();
|
||||
}
|
||||
|
||||
openSessionsModal(): void {
|
||||
@@ -234,16 +240,16 @@ export class AppState {
|
||||
if (!this.multiEditorSupport) {
|
||||
this.closeActiveNoteController();
|
||||
}
|
||||
const activeTagUuid = this.selectedTag
|
||||
? this.selectedTag.isSmartTag
|
||||
? undefined
|
||||
: this.selectedTag.uuid
|
||||
: undefined;
|
||||
|
||||
const selectedTag = this.selectedTag;
|
||||
|
||||
const activeRegularTagUuid =
|
||||
selectedTag && !selectedTag.isSmartTag ? selectedTag.uuid : undefined;
|
||||
|
||||
await this.application.noteControllerGroup.createNoteView(
|
||||
undefined,
|
||||
title,
|
||||
activeTagUuid
|
||||
activeRegularTagUuid
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,10 +281,88 @@ export class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
private tagChangedNotifier(): IReactionDisposer {
|
||||
return reaction(
|
||||
() => this.tags.selectedUuid,
|
||||
() => {
|
||||
const tag = this.tags.selected;
|
||||
const previousTag = this.tags.previouslySelected;
|
||||
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.application.isTemplateItem(tag)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyEvent(AppStateEvent.TagChanged, {
|
||||
tag,
|
||||
previousTag,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async setFoldersComponent(component?: SNComponent) {
|
||||
const foldersComponentViewer = this.foldersComponentViewer;
|
||||
|
||||
if (foldersComponentViewer) {
|
||||
this.application.componentManager.destroyComponentViewer(
|
||||
foldersComponentViewer
|
||||
);
|
||||
this.foldersComponentViewer = undefined;
|
||||
}
|
||||
|
||||
if (component) {
|
||||
this.foldersComponentViewer =
|
||||
this.application.componentManager.createComponentViewer(
|
||||
component,
|
||||
undefined,
|
||||
this.tags.onFoldersComponentMessage.bind(this.tags)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToFoldersComponentChanges() {
|
||||
return this.application.streamItems(
|
||||
[ContentType.Component],
|
||||
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) {
|
||||
const componentViewer = this.application.componentManager
|
||||
.componentsForArea(ComponentArea.TagsList)
|
||||
.find((component) => component.active);
|
||||
|
||||
this.setFoldersComponent(componentViewer);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public get selectedTag(): SNTag | SNSmartTag | undefined {
|
||||
return this.tags.selected;
|
||||
}
|
||||
|
||||
public set selectedTag(tag: SNTag | SNSmartTag | undefined) {
|
||||
this.tags.selected = tag;
|
||||
}
|
||||
|
||||
streamNotesAndTags() {
|
||||
this.application.streamItems(
|
||||
[ContentType.Note, ContentType.Tag],
|
||||
async (items, source) => {
|
||||
const selectedTag = this.tags.selected;
|
||||
|
||||
/** Close any note controllers for deleted/trashed/archived notes */
|
||||
if (source === PayloadSource.PreSyncSave) {
|
||||
const notes = items.filter(
|
||||
@@ -293,13 +377,13 @@ export class AppState {
|
||||
this.closeNoteController(noteController);
|
||||
} else if (
|
||||
note.trashed &&
|
||||
!this.selectedTag?.isTrashTag &&
|
||||
!selectedTag?.isTrashTag &&
|
||||
!this.searchOptions.includeTrashed
|
||||
) {
|
||||
this.closeNoteController(noteController);
|
||||
} else if (
|
||||
note.archived &&
|
||||
!this.selectedTag?.isArchiveTag &&
|
||||
!selectedTag?.isArchiveTag &&
|
||||
!this.searchOptions.includeArchived &&
|
||||
!this.application.getPreference(PrefKey.NotesShowArchived, false)
|
||||
) {
|
||||
@@ -307,17 +391,6 @@ export class AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.selectedTag) {
|
||||
const matchingTag = items.find(
|
||||
(candidate) =>
|
||||
this.selectedTag && candidate.uuid === this.selectedTag.uuid
|
||||
);
|
||||
if (matchingTag) {
|
||||
runInAction(() => {
|
||||
this.selectedTag = matchingTag as SNTag;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -385,74 +458,6 @@ export class AppState {
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedTag(tag: SNTag) {
|
||||
if (tag.conflictOf) {
|
||||
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (this.selectedTag === tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previouslySelectedTag = this.selectedTag;
|
||||
this.selectedTag = tag;
|
||||
|
||||
if (this.templateTag?.uuid === tag.uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyEvent(AppStateEvent.TagChanged, {
|
||||
tag: tag,
|
||||
previousTag: this.previouslySelectedTag,
|
||||
});
|
||||
}
|
||||
|
||||
public getSelectedTag() {
|
||||
return this.selectedTag;
|
||||
}
|
||||
|
||||
public get templateTag(): SNTag | undefined {
|
||||
return this._templateTag;
|
||||
}
|
||||
|
||||
public set templateTag(tag: SNTag | undefined) {
|
||||
const previous = this._templateTag;
|
||||
this._templateTag = tag;
|
||||
|
||||
if (tag) {
|
||||
this.setSelectedTag(tag);
|
||||
this.editingTag = tag;
|
||||
} else if (previous) {
|
||||
this.selectedTag =
|
||||
previous === this.selectedTag ? undefined : this.selectedTag;
|
||||
this.editingTag =
|
||||
previous === this.editingTag ? undefined : this.editingTag;
|
||||
}
|
||||
}
|
||||
|
||||
public removeTag(tag: SNTag) {
|
||||
this.application.deleteItem(tag);
|
||||
this.setSelectedTag(this.tags.smartTags[0]);
|
||||
}
|
||||
|
||||
public async createNewTag() {
|
||||
if (this.templateTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTag = (await this.application.createTemplateItem(
|
||||
ContentType.Tag
|
||||
)) as SNTag;
|
||||
this.templateTag = newTag;
|
||||
}
|
||||
|
||||
public async undoCreateNewTag() {
|
||||
const previousTag = this.previouslySelectedTag || this.tags.smartTags[0];
|
||||
this.setSelectedTag(previousTag);
|
||||
}
|
||||
|
||||
/** Returns the tags that are referncing this note */
|
||||
public getNoteTags(note: SNNote) {
|
||||
return this.application.referencingForItem(note).filter((ref) => {
|
||||
|
||||
@@ -3,13 +3,22 @@ import {
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
} from '@standardnotes/snjs';
|
||||
import { computed, makeObservable, observable, runInAction } from 'mobx';
|
||||
import {
|
||||
action,
|
||||
computed,
|
||||
makeObservable,
|
||||
observable,
|
||||
runInAction,
|
||||
when,
|
||||
} 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.';
|
||||
|
||||
export const SMART_TAGS_FEATURE_NAME = 'Smart Tags';
|
||||
|
||||
/**
|
||||
* Holds state for premium/non premium features for the current user features,
|
||||
* and eventually for in-development features (feature flags).
|
||||
@@ -19,23 +28,37 @@ export class FeaturesState {
|
||||
window?._enable_unfinished_features;
|
||||
|
||||
_hasFolders = false;
|
||||
_hasSmartTags = false;
|
||||
_premiumAlertFeatureName: string | undefined;
|
||||
|
||||
private unsub: () => void;
|
||||
|
||||
constructor(private application: WebApplication) {
|
||||
this._hasFolders = this.hasNativeFolders();
|
||||
this._hasSmartTags = this.hasNativeSmartTags();
|
||||
this._premiumAlertFeatureName = undefined;
|
||||
|
||||
makeObservable(this, {
|
||||
_hasFolders: observable,
|
||||
_hasSmartTags: observable,
|
||||
hasFolders: computed,
|
||||
enableNativeFoldersFeature: computed,
|
||||
enableNativeSmartTagsFeature: computed,
|
||||
_premiumAlertFeatureName: observable,
|
||||
showPremiumAlert: action,
|
||||
closePremiumAlert: action,
|
||||
});
|
||||
|
||||
this.showPremiumAlert = this.showPremiumAlert.bind(this);
|
||||
this.closePremiumAlert = this.closePremiumAlert.bind(this);
|
||||
|
||||
this.unsub = this.application.addEventObserver(async (eventName) => {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.FeaturesUpdated:
|
||||
case ApplicationEvent.Launched:
|
||||
runInAction(() => {
|
||||
this._hasFolders = this.hasNativeFolders();
|
||||
this._hasSmartTags = this.hasNativeSmartTags();
|
||||
});
|
||||
break;
|
||||
default:
|
||||
@@ -52,25 +75,25 @@ export class FeaturesState {
|
||||
return this.enableUnfinishedFeatures;
|
||||
}
|
||||
|
||||
public get enableNativeSmartTagsFeature(): boolean {
|
||||
return this.enableUnfinishedFeatures;
|
||||
}
|
||||
|
||||
public get hasFolders(): boolean {
|
||||
return this._hasFolders;
|
||||
}
|
||||
|
||||
public set hasFolders(hasFolders: boolean) {
|
||||
if (!hasFolders) {
|
||||
this._hasFolders = false;
|
||||
return;
|
||||
}
|
||||
public get hasSmartTags(): boolean {
|
||||
return this._hasSmartTags;
|
||||
}
|
||||
|
||||
if (!this.hasNativeFolders()) {
|
||||
this.application.alertService?.alert(
|
||||
`${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.`
|
||||
);
|
||||
this._hasFolders = false;
|
||||
return;
|
||||
}
|
||||
public async showPremiumAlert(featureName: string): Promise<void> {
|
||||
this._premiumAlertFeatureName = featureName;
|
||||
return when(() => this._premiumAlertFeatureName === undefined);
|
||||
}
|
||||
|
||||
this._hasFolders = hasFolders;
|
||||
public async closePremiumAlert(): Promise<void> {
|
||||
this._premiumAlertFeatureName = undefined;
|
||||
}
|
||||
|
||||
private hasNativeFolders(): boolean {
|
||||
@@ -84,4 +107,16 @@ export class FeaturesState {
|
||||
|
||||
return status === FeatureStatus.Entitled;
|
||||
}
|
||||
|
||||
private hasNativeSmartTags(): boolean {
|
||||
if (!this.enableNativeSmartTagsFeature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = this.application.getFeatureStatus(
|
||||
FeatureIdentifier.SmartFilters
|
||||
);
|
||||
|
||||
return status === FeatureStatus.Entitled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SNNote, ContentType, SNTag, UuidString } from '@standardnotes/snjs';
|
||||
import { ContentType, SNNote, SNTag, UuidString } from '@standardnotes/snjs';
|
||||
import { action, computed, makeObservable, observable } from 'mobx';
|
||||
import { WebApplication } from '../application';
|
||||
import { AppState } from './app_state';
|
||||
@@ -194,4 +194,41 @@ export class NoteTagsState {
|
||||
this.reloadTags();
|
||||
}
|
||||
}
|
||||
|
||||
getSortedTagsForNote(note: SNNote): SNTag[] {
|
||||
const tags = this.application.getSortedTagsForNote(note);
|
||||
|
||||
const sortFunction = (tagA: SNTag, tagB: SNTag): number => {
|
||||
const a = this.getLongTitle(tagA);
|
||||
const b = this.getLongTitle(tagB);
|
||||
|
||||
if (a < b) {
|
||||
return -1;
|
||||
}
|
||||
if (b > a) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return tags.sort(sortFunction);
|
||||
}
|
||||
|
||||
getPrefixTitle(tag: SNTag): string | undefined {
|
||||
const hierarchy = this.application.getTagParentChain(tag);
|
||||
|
||||
if (hierarchy.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prefixTitle = hierarchy.map((tag) => tag.title).join('/');
|
||||
return `${prefixTitle}/`;
|
||||
}
|
||||
|
||||
getLongTitle(tag: SNTag): string {
|
||||
const hierarchy = this.application.getTagParentChain(tag);
|
||||
const tags = [...hierarchy, tag];
|
||||
const longTitle = tags.map((tag) => tag.title).join('/');
|
||||
return longTitle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ContentType,
|
||||
SNTag,
|
||||
ChallengeReason,
|
||||
NoteViewController,
|
||||
} from '@standardnotes/snjs';
|
||||
import {
|
||||
makeObservable,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
runInAction,
|
||||
} from 'mobx';
|
||||
import { WebApplication } from '../application';
|
||||
import { NoteViewController } from '@/views/note_view/note_view_controller';
|
||||
import { AppState } from './app_state';
|
||||
|
||||
export class NotesState {
|
||||
@@ -115,12 +115,12 @@ export class NotesState {
|
||||
async selectNote(uuid: UuidString, userTriggered?: boolean): Promise<void> {
|
||||
const note = this.application.findItem(uuid) as SNNote;
|
||||
|
||||
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta);
|
||||
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl);
|
||||
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift);
|
||||
|
||||
if (note) {
|
||||
if (
|
||||
userTriggered &&
|
||||
(this.io.activeModifiers.has(KeyboardModifier.Meta) ||
|
||||
this.io.activeModifiers.has(KeyboardModifier.Ctrl))
|
||||
) {
|
||||
if (userTriggered && (hasMeta || hasCtrl)) {
|
||||
if (this.selectedNotes[uuid]) {
|
||||
delete this.selectedNotes[uuid];
|
||||
} else if (await this.application.authorizeNoteAccess(note)) {
|
||||
@@ -129,10 +129,7 @@ export class NotesState {
|
||||
this.lastSelectedNote = note;
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
userTriggered &&
|
||||
this.io.activeModifiers.has(KeyboardModifier.Shift)
|
||||
) {
|
||||
} else if (userTriggered && hasShift) {
|
||||
await this.selectNotesRange(note);
|
||||
} else {
|
||||
const shouldSelectNote =
|
||||
@@ -328,6 +325,7 @@ export class NotesState {
|
||||
if (permanently) {
|
||||
for (const note of Object.values(this.selectedNotes)) {
|
||||
await this.application.deleteItem(note);
|
||||
delete this.selectedNotes[note.uuid];
|
||||
}
|
||||
} else {
|
||||
await this.changeSelectedNotes((mutator) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ export type DisplayOptions = {
|
||||
hideTags: boolean;
|
||||
hideNotePreview: boolean;
|
||||
hideDate: boolean;
|
||||
hideEditorIcon: boolean;
|
||||
};
|
||||
|
||||
export class NotesViewState {
|
||||
@@ -58,6 +59,7 @@ export class NotesViewState {
|
||||
hideTags: true,
|
||||
hideDate: false,
|
||||
hideNotePreview: false,
|
||||
hideEditorIcon: false,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -301,6 +303,10 @@ export class NotesViewState {
|
||||
PrefKey.NotesHideTags,
|
||||
true
|
||||
);
|
||||
freshDisplayOptions.hideEditorIcon = this.application.getPreference(
|
||||
PrefKey.NotesHideEditorIcon,
|
||||
false
|
||||
);
|
||||
const displayOptionsChanged =
|
||||
freshDisplayOptions.sortBy !== this.displayOptions.sortBy ||
|
||||
freshDisplayOptions.sortReverse !== this.displayOptions.sortReverse ||
|
||||
@@ -308,6 +314,8 @@ export class NotesViewState {
|
||||
freshDisplayOptions.showArchived !== this.displayOptions.showArchived ||
|
||||
freshDisplayOptions.showTrashed !== this.displayOptions.showTrashed ||
|
||||
freshDisplayOptions.hideProtected !== this.displayOptions.hideProtected ||
|
||||
freshDisplayOptions.hideEditorIcon !==
|
||||
this.displayOptions.hideEditorIcon ||
|
||||
freshDisplayOptions.hideTags !== this.displayOptions.hideTags;
|
||||
this.displayOptions = freshDisplayOptions;
|
||||
if (displayOptionsChanged) {
|
||||
@@ -495,7 +503,9 @@ export class NotesViewState {
|
||||
this.reloadNotesDisplayOptions();
|
||||
this.reloadNotes();
|
||||
|
||||
if (this.notes.length > 0) {
|
||||
const hasSomeNotes = this.notes.length > 0;
|
||||
|
||||
if (hasSomeNotes) {
|
||||
this.selectFirstNote();
|
||||
} else if (dbLoaded) {
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
import { STRING_DELETE_TAG, STRING_MISSING_SYSTEM_TAG } from '@/strings';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ComponentAction,
|
||||
ContentType,
|
||||
MessageData,
|
||||
SNApplication,
|
||||
SNSmartTag,
|
||||
SNTag,
|
||||
TagMutator,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs';
|
||||
import {
|
||||
@@ -13,11 +20,60 @@ import {
|
||||
runInAction,
|
||||
} from 'mobx';
|
||||
import { WebApplication } from '../application';
|
||||
import { FeaturesState } from './features_state';
|
||||
import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './features_state';
|
||||
|
||||
type AnyTag = SNTag | SNSmartTag;
|
||||
|
||||
const rootTags = (application: SNApplication): SNTag[] => {
|
||||
const hasNoParent = (tag: SNTag) => !application.getTagParent(tag);
|
||||
|
||||
const allTags = application.getDisplayableItems(ContentType.Tag) as SNTag[];
|
||||
const rootTags = allTags.filter(hasNoParent);
|
||||
|
||||
return rootTags;
|
||||
};
|
||||
|
||||
const tagSiblings = (application: SNApplication, tag: SNTag): SNTag[] => {
|
||||
const withoutCurrentTag = (tags: SNTag[]) =>
|
||||
tags.filter((other) => other.uuid !== tag.uuid);
|
||||
|
||||
const isTemplateTag = application.isTemplateItem(tag);
|
||||
const parentTag = !isTemplateTag && application.getTagParent(tag);
|
||||
|
||||
if (parentTag) {
|
||||
const siblingsAndTag = application.getTagChildren(parentTag);
|
||||
return withoutCurrentTag(siblingsAndTag);
|
||||
}
|
||||
|
||||
return withoutCurrentTag(rootTags(application));
|
||||
};
|
||||
|
||||
const isValidFutureSiblings = (
|
||||
application: SNApplication,
|
||||
futureSiblings: SNTag[],
|
||||
tag: SNTag
|
||||
): boolean => {
|
||||
const siblingWithSameName = futureSiblings.find(
|
||||
(otherTag) => otherTag.title === tag.title
|
||||
);
|
||||
|
||||
if (siblingWithSameName) {
|
||||
application.alertService?.alert(
|
||||
`A tag with the name ${tag.title} already exists at this destination. Please rename this tag before moving and try again.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export class TagsState {
|
||||
tags: SNTag[] = [];
|
||||
smartTags: SNSmartTag[] = [];
|
||||
allNotesCount_ = 0;
|
||||
selected_: AnyTag | undefined;
|
||||
previouslySelected_: AnyTag | undefined;
|
||||
editing_: SNTag | undefined;
|
||||
|
||||
private readonly tagsCountsState: TagsCountsState;
|
||||
|
||||
constructor(
|
||||
@@ -27,22 +83,43 @@ export class TagsState {
|
||||
) {
|
||||
this.tagsCountsState = new TagsCountsState(this.application);
|
||||
|
||||
this.selected_ = undefined;
|
||||
this.previouslySelected_ = undefined;
|
||||
this.editing_ = undefined;
|
||||
|
||||
this.smartTags = this.application.getSmartTags();
|
||||
this.selected_ = this.smartTags[0];
|
||||
|
||||
makeObservable(this, {
|
||||
tags: observable.ref,
|
||||
smartTags: observable.ref,
|
||||
hasFolders: computed,
|
||||
hasAtLeastOneFolder: computed,
|
||||
allNotesCount_: observable,
|
||||
allNotesCount: computed,
|
||||
|
||||
selected_: observable.ref,
|
||||
previouslySelected_: observable.ref,
|
||||
previouslySelected: computed,
|
||||
editing_: observable.ref,
|
||||
selected: computed,
|
||||
selectedUuid: computed,
|
||||
editingTag: computed,
|
||||
|
||||
assignParent: action,
|
||||
|
||||
rootTags: computed,
|
||||
tagsCount: computed,
|
||||
|
||||
createNewTemplate: action,
|
||||
undoCreateNewTag: action,
|
||||
save: action,
|
||||
remove: action,
|
||||
});
|
||||
|
||||
appEventListeners.push(
|
||||
this.application.streamItems(
|
||||
[ContentType.Tag, ContentType.SmartTag],
|
||||
() => {
|
||||
(items) => {
|
||||
runInAction(() => {
|
||||
this.tags = this.application.getDisplayableItems(
|
||||
ContentType.Tag
|
||||
@@ -50,10 +127,46 @@ export class TagsState {
|
||||
this.smartTags = this.application.getSmartTags();
|
||||
|
||||
this.tagsCountsState.update(this.tags);
|
||||
this.allNotesCount_ = this.countAllNotes();
|
||||
|
||||
const selectedTag = this.selected_;
|
||||
if (selectedTag) {
|
||||
const matchingTag = items.find(
|
||||
(candidate) => candidate.uuid === selectedTag.uuid
|
||||
);
|
||||
if (matchingTag) {
|
||||
if (matchingTag.deleted) {
|
||||
this.selected_ = this.smartTags[0];
|
||||
} else {
|
||||
this.selected_ = matchingTag as AnyTag;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.selected_ = this.smartTags[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
appEventListeners.push(
|
||||
this.application.addEventObserver(async (eventName) => {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.CompletedIncrementalSync:
|
||||
runInAction(() => {
|
||||
this.allNotesCount_ = this.countAllNotes();
|
||||
});
|
||||
break;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public get allLocalRootTags(): SNTag[] {
|
||||
if (this.editing_ && this.application.isTemplateItem(this.editing_)) {
|
||||
return [this.editing_, ...this.rootTags];
|
||||
}
|
||||
return this.rootTags;
|
||||
}
|
||||
|
||||
public getNotesCount(tag: SNTag): number {
|
||||
@@ -61,7 +174,7 @@ export class TagsState {
|
||||
}
|
||||
|
||||
getChildren(tag: SNTag): SNTag[] {
|
||||
if (!this.hasFolders) {
|
||||
if (!this.features.hasFolders) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -69,7 +182,10 @@ export class TagsState {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children = this.application.getTagChildren(tag);
|
||||
const children = this.application
|
||||
.getTagChildren(tag)
|
||||
.filter((tag) => !tag.isSmartTag);
|
||||
|
||||
const childrenUuids = children.map((childTag) => childTag.uuid);
|
||||
const childrenTags = this.tags.filter((tag) =>
|
||||
childrenUuids.includes(tag.uuid)
|
||||
@@ -87,12 +203,27 @@ export class TagsState {
|
||||
): Promise<void> {
|
||||
const tag = this.application.findItem(tagUuid) as SNTag;
|
||||
|
||||
const currentParent = this.application.getTagParent(tag);
|
||||
const currentParentUuid = currentParent?.parentId;
|
||||
|
||||
if (currentParentUuid === parentUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent =
|
||||
parentUuid && (this.application.findItem(parentUuid) as SNTag);
|
||||
|
||||
if (!parent) {
|
||||
const futureSiblings = rootTags(this.application);
|
||||
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
|
||||
return;
|
||||
}
|
||||
await this.application.unsetTagParent(tag);
|
||||
} else {
|
||||
const futureSiblings = this.application.getTagChildren(parent);
|
||||
if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
|
||||
return;
|
||||
}
|
||||
await this.application.setTagParent(parent, tag);
|
||||
}
|
||||
|
||||
@@ -100,7 +231,7 @@ export class TagsState {
|
||||
}
|
||||
|
||||
get rootTags(): SNTag[] {
|
||||
if (!this.hasFolders) {
|
||||
if (!this.features.hasFolders) {
|
||||
return this.tags;
|
||||
}
|
||||
|
||||
@@ -111,12 +242,196 @@ export class TagsState {
|
||||
return this.tags.length;
|
||||
}
|
||||
|
||||
public get hasFolders(): boolean {
|
||||
return this.features.hasFolders;
|
||||
public get allNotesCount(): number {
|
||||
return this.allNotesCount_;
|
||||
}
|
||||
|
||||
public set hasFolders(hasFolders: boolean) {
|
||||
this.features.hasFolders = hasFolders;
|
||||
public get previouslySelected(): AnyTag | undefined {
|
||||
return this.previouslySelected_;
|
||||
}
|
||||
|
||||
public get selected(): AnyTag | undefined {
|
||||
return this.selected_;
|
||||
}
|
||||
|
||||
public set selected(tag: AnyTag | undefined) {
|
||||
if (tag && tag.conflictOf) {
|
||||
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const selectionHasNotChanged = this.selected_?.uuid === tag?.uuid;
|
||||
|
||||
if (selectionHasNotChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previouslySelected_ = this.selected_;
|
||||
this.selected_ = tag;
|
||||
}
|
||||
|
||||
public get selectedUuid(): UuidString | undefined {
|
||||
return this.selected_?.uuid;
|
||||
}
|
||||
|
||||
public get editingTag(): SNTag | undefined {
|
||||
return this.editing_;
|
||||
}
|
||||
|
||||
public set editingTag(editingTag: SNTag | undefined) {
|
||||
this.editing_ = editingTag;
|
||||
this.selected = editingTag;
|
||||
}
|
||||
|
||||
public async createNewTemplate() {
|
||||
const isAlreadyEditingATemplate =
|
||||
this.editing_ && this.application.isTemplateItem(this.editing_);
|
||||
|
||||
if (isAlreadyEditingATemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTag = (await this.application.createTemplateItem(
|
||||
ContentType.Tag
|
||||
)) as SNTag;
|
||||
|
||||
runInAction(() => {
|
||||
this.editing_ = newTag;
|
||||
});
|
||||
}
|
||||
|
||||
public undoCreateNewTag() {
|
||||
this.editing_ = undefined;
|
||||
const previousTag = this.previouslySelected_ || this.smartTags[0];
|
||||
this.selected = previousTag;
|
||||
}
|
||||
|
||||
public async remove(tag: SNTag) {
|
||||
if (
|
||||
await confirmDialog({
|
||||
text: STRING_DELETE_TAG,
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.application.deleteItem(tag);
|
||||
this.selected = this.smartTags[0];
|
||||
}
|
||||
}
|
||||
|
||||
public async save(tag: SNTag, newTitle: string) {
|
||||
const hasEmptyTitle = newTitle.length === 0;
|
||||
const hasNotChangedTitle = newTitle === tag.title;
|
||||
const isTemplateChange = this.application.isTemplateItem(tag);
|
||||
|
||||
const siblings = tagSiblings(this.application, tag);
|
||||
const hasDuplicatedTitle = siblings.some(
|
||||
(other) => other.title.toLowerCase() === newTitle.toLowerCase()
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.editing_ = undefined;
|
||||
});
|
||||
|
||||
if (hasEmptyTitle || hasNotChangedTitle) {
|
||||
if (isTemplateChange) {
|
||||
this.undoCreateNewTag();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasDuplicatedTitle) {
|
||||
if (isTemplateChange) {
|
||||
this.undoCreateNewTag();
|
||||
}
|
||||
this.application.alertService?.alert(
|
||||
'A tag with this name already exists.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTemplateChange) {
|
||||
if (this.features.enableNativeSmartTagsFeature) {
|
||||
const isSmartTagTitle = this.application.isSmartTagTitle(newTitle);
|
||||
|
||||
if (isSmartTagTitle) {
|
||||
if (!this.features.hasSmartTags) {
|
||||
await this.features.showPremiumAlert(SMART_TAGS_FEATURE_NAME);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const insertedTag = await this.application.createTagOrSmartTag(
|
||||
newTitle
|
||||
);
|
||||
runInAction(() => {
|
||||
this.selected = insertedTag as SNTag;
|
||||
});
|
||||
} else {
|
||||
// Legacy code, remove me after we enableNativeSmartTagsFeature for everyone.
|
||||
// See https://app.asana.com/0/0/1201612665552831/f
|
||||
const insertedTag = await this.application.insertItem(tag);
|
||||
const changedTag = await this.application.changeItem<TagMutator>(
|
||||
insertedTag.uuid,
|
||||
(m) => {
|
||||
m.title = newTitle;
|
||||
}
|
||||
);
|
||||
this.selected = changedTag as SNTag;
|
||||
await this.application.saveItem(insertedTag.uuid);
|
||||
}
|
||||
} else {
|
||||
await this.application.changeAndSaveItem<TagMutator>(
|
||||
tag.uuid,
|
||||
(mutator) => {
|
||||
mutator.title = newTitle;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private countAllNotes(): number {
|
||||
const allTag = this.application.getSmartTags().find((tag) => tag.isAllTag);
|
||||
|
||||
if (!allTag) {
|
||||
console.error(STRING_MISSING_SYSTEM_TAG);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const notes = this.application
|
||||
.notesMatchingSmartTag(allTag)
|
||||
.filter((note) => {
|
||||
return !note.archived && !note.trashed;
|
||||
});
|
||||
|
||||
return notes.length;
|
||||
}
|
||||
|
||||
public onFoldersComponentMessage(
|
||||
action: ComponentAction,
|
||||
data: MessageData
|
||||
): void {
|
||||
if (action === ComponentAction.SelectItem) {
|
||||
const item = data.item;
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
item.content_type === ContentType.Tag ||
|
||||
item.content_type === ContentType.SmartTag
|
||||
) {
|
||||
const matchingTag = this.application.findItem(item.uuid);
|
||||
|
||||
if (matchingTag) {
|
||||
this.selected = matchingTag as AnyTag;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (action === ComponentAction.ClearSelection) {
|
||||
this.selected = this.smartTags[0];
|
||||
}
|
||||
}
|
||||
|
||||
public get hasAtLeastOneFolder(): boolean {
|
||||
|
||||
@@ -10,13 +10,13 @@ import { StatusManager } from '@/services/statusManager';
|
||||
import { ThemeManager } from '@/services/themeManager';
|
||||
import { PasswordWizardScope, PasswordWizardType } from '@/types';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { NoteGroupController } from '@/views/note_group_view/note_group_controller';
|
||||
import { WebDeviceInterface } from '@/web_device_interface';
|
||||
import {
|
||||
DeinitSource,
|
||||
PermissionDialog,
|
||||
Platform,
|
||||
SNApplication,
|
||||
NoteGroupController,
|
||||
} from '@standardnotes/snjs';
|
||||
import angular from 'angular';
|
||||
import { AccountSwitcherScope, PermissionsModalScope } from './../types';
|
||||
|
||||
@@ -56,6 +56,12 @@ export class PanelResizerState {
|
||||
side,
|
||||
widthEventCallback,
|
||||
}: PanelResizerProps) {
|
||||
const currentKnownPref =
|
||||
(application.getPreference(prefKey) as number) ?? defaultWidth ?? 0;
|
||||
|
||||
this.panel = panel;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.alwaysVisible = alwaysVisible ?? false;
|
||||
this.application = application;
|
||||
this.collapsable = collapsable ?? false;
|
||||
@@ -66,16 +72,15 @@ export class PanelResizerState {
|
||||
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;
|
||||
|
||||
this.setWidth(currentKnownPref, true);
|
||||
|
||||
application.addEventObserver(async () => {
|
||||
const changedWidth = application.getPreference(prefKey) as number;
|
||||
if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true);
|
||||
|
||||
@@ -155,3 +155,12 @@ export function getDesktopVersion() {
|
||||
export const isEmailValid = (email: string): boolean => {
|
||||
return EMAIL_REGEX.test(email);
|
||||
};
|
||||
|
||||
export const openInNewTab = (url: string) => {
|
||||
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (newWindow) newWindow.opener = null;
|
||||
};
|
||||
|
||||
export const convertStringifiedBooleanToBoolean = (value: string) => {
|
||||
return value !== 'false';
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
ng-class='self.state.appClass',
|
||||
ng-if='!self.state.needsUnlock && self.state.launched'
|
||||
)
|
||||
tags-view(application='self.application')
|
||||
navigation(application='self.application', appState='self.appState')
|
||||
notes-view(
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Challenge,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants';
|
||||
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
|
||||
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { alertDialog } from '@/services/alertService';
|
||||
@@ -24,7 +24,7 @@ class ApplicationViewCtrl extends PureViewCtrl<
|
||||
> {
|
||||
public platformString: string;
|
||||
private notesCollapsed = false;
|
||||
private tagsCollapsed = false;
|
||||
private navigationCollapsed = false;
|
||||
|
||||
/**
|
||||
* To prevent stale state reads (setState is async),
|
||||
@@ -136,15 +136,15 @@ class ApplicationViewCtrl extends PureViewCtrl<
|
||||
if (panel === PANEL_NAME_NOTES) {
|
||||
this.notesCollapsed = collapsed;
|
||||
}
|
||||
if (panel === PANEL_NAME_TAGS) {
|
||||
this.tagsCollapsed = collapsed;
|
||||
if (panel === PANEL_NAME_NAVIGATION) {
|
||||
this.navigationCollapsed = collapsed;
|
||||
}
|
||||
let appClass = '';
|
||||
if (this.notesCollapsed) {
|
||||
appClass += 'collapsed-notes';
|
||||
}
|
||||
if (this.tagsCollapsed) {
|
||||
appClass += ' collapsed-tags';
|
||||
if (this.navigationCollapsed) {
|
||||
appClass += ' collapsed-navigation';
|
||||
}
|
||||
this.setState({ appClass });
|
||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PANEL_NAME_NOTES = 'notes';
|
||||
export const PANEL_NAME_TAGS = 'tags';
|
||||
export const PANEL_NAME_NAVIGATION = 'navigation';
|
||||
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])?)$/;
|
||||
|
||||
@@ -4,5 +4,4 @@ export { ApplicationView } from './application/application_view';
|
||||
export { NoteGroupViewDirective } from './note_group_view/note_group_view';
|
||||
export { NoteViewDirective } from './note_view/note_view';
|
||||
export { FooterView } from './footer/footer_view';
|
||||
export { TagsView } from './tags/tags_view';
|
||||
export { ChallengeModal } from './challenge_modal/challenge_modal';
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { removeFromArray, UuidString } from '@standardnotes/snjs';
|
||||
import { NoteViewController } from '@/views/note_view/note_view_controller';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
type NoteControllerGroupChangeCallback = () => void;
|
||||
|
||||
export class NoteGroupController {
|
||||
public noteControllers: NoteViewController[] = [];
|
||||
private application: WebApplication;
|
||||
changeObservers: NoteControllerGroupChangeCallback[] = [];
|
||||
|
||||
constructor(application: WebApplication) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
(this.application as unknown) = undefined;
|
||||
for (const controller of this.noteControllers) {
|
||||
this.deleteNoteView(controller);
|
||||
}
|
||||
}
|
||||
|
||||
async createNoteView(
|
||||
noteUuid?: string,
|
||||
noteTitle?: string,
|
||||
noteTag?: UuidString
|
||||
) {
|
||||
const controller = new NoteViewController(
|
||||
this.application,
|
||||
noteUuid,
|
||||
noteTitle,
|
||||
noteTag
|
||||
);
|
||||
await controller.initialize();
|
||||
this.noteControllers.push(controller);
|
||||
this.notifyObservers();
|
||||
}
|
||||
|
||||
deleteNoteView(controller: NoteViewController) {
|
||||
controller.deinit();
|
||||
removeFromArray(this.noteControllers, controller);
|
||||
}
|
||||
|
||||
closeNoteView(controller: NoteViewController) {
|
||||
this.deleteNoteView(controller);
|
||||
this.notifyObservers();
|
||||
}
|
||||
|
||||
closeActiveNoteView() {
|
||||
const activeController = this.activeNoteViewController;
|
||||
if (activeController) {
|
||||
this.deleteNoteView(activeController);
|
||||
}
|
||||
}
|
||||
|
||||
closeAllNoteViews() {
|
||||
for (const controller of this.noteControllers) {
|
||||
this.deleteNoteView(controller);
|
||||
}
|
||||
}
|
||||
|
||||
get activeNoteViewController() {
|
||||
return this.noteControllers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies observer when the active controller has changed.
|
||||
*/
|
||||
public addChangeObserver(callback: NoteControllerGroupChangeCallback) {
|
||||
this.changeObservers.push(callback);
|
||||
if (this.activeNoteViewController) {
|
||||
callback();
|
||||
}
|
||||
return () => {
|
||||
removeFromArray(this.changeObservers, callback);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyObservers() {
|
||||
for (const observer of this.changeObservers) {
|
||||
observer();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WebDirective } from './../../types';
|
||||
import template from './note-group-view.pug';
|
||||
import { NoteViewController } from '@/views/note_view/note_view_controller';
|
||||
import { PureViewCtrl } from '../abstract/pure_view_ctrl';
|
||||
import { NoteViewController } from '@standardnotes/snjs';
|
||||
|
||||
class NoteGroupView extends PureViewCtrl<
|
||||
unknown,
|
||||
@@ -20,9 +20,11 @@ class NoteGroupView extends PureViewCtrl<
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.application.noteControllerGroup.addChangeObserver(() => {
|
||||
this.controllers = this.application.noteControllerGroup.noteControllers;
|
||||
});
|
||||
this.application.noteControllerGroup.addActiveControllerChangeObserver(
|
||||
() => {
|
||||
this.controllers = this.application.noteControllerGroup.noteControllers;
|
||||
}
|
||||
);
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { STRING_SAVING_WHILE_DOCUMENT_HIDDEN } from './../../strings';
|
||||
import { NoteViewController } from '@/views/note_view/note_view_controller';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PanelPuppet, WebDirective } from '@/types';
|
||||
import angular from 'angular';
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
TransactionalMutation,
|
||||
ItemMutator,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
NoteViewController,
|
||||
} from '@standardnotes/snjs';
|
||||
import { debounce, isDesktopApplication } from '@/utils';
|
||||
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
|
||||
@@ -27,9 +26,6 @@ import template from './note-view.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { EventSource } from '@/ui_models/app_state';
|
||||
import {
|
||||
STRING_DELETED_NOTE,
|
||||
STRING_INVALID_NOTE,
|
||||
STRING_ELLIPSES,
|
||||
STRING_DELETE_PLACEHOLDER_ATTEMPT,
|
||||
STRING_DELETE_LOCKED_ATTEMPT,
|
||||
STRING_EDIT_LOCKED_ATTEMPT,
|
||||
@@ -37,10 +33,7 @@ import {
|
||||
} from '@/strings';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
|
||||
const NOTE_PREVIEW_CHAR_LIMIT = 80;
|
||||
const MINIMUM_STATUS_DURATION = 400;
|
||||
const SAVE_TIMEOUT_DEBOUNCE = 350;
|
||||
const SAVE_TIMEOUT_NO_DEBOUNCE = 100;
|
||||
const EDITOR_DEBOUNCE = 100;
|
||||
|
||||
const ElementIds = {
|
||||
@@ -97,7 +90,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
|
||||
private leftPanelPuppet?: PanelPuppet;
|
||||
private rightPanelPuppet?: PanelPuppet;
|
||||
private saveTimeout?: ng.IPromise<void>;
|
||||
private statusTimeout?: ng.IPromise<void>;
|
||||
private lastEditorFocusEventSource?: EventSource;
|
||||
public editorValues: EditorValues = { title: '', text: '' };
|
||||
@@ -108,6 +100,7 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
private removeTabObserver?: () => void;
|
||||
private removeComponentStreamObserver?: () => void;
|
||||
private removeComponentManagerObserver?: () => void;
|
||||
private removeInnerNoteObserver?: () => void;
|
||||
|
||||
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -139,6 +132,8 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
deinit() {
|
||||
this.removeComponentStreamObserver?.();
|
||||
(this.removeComponentStreamObserver as unknown) = undefined;
|
||||
this.removeInnerNoteObserver?.();
|
||||
(this.removeInnerNoteObserver as unknown) = undefined;
|
||||
this.removeComponentManagerObserver?.();
|
||||
(this.removeComponentManagerObserver as unknown) = undefined;
|
||||
this.removeTrashKeyObserver?.();
|
||||
@@ -149,7 +144,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
this.leftPanelPuppet = undefined;
|
||||
this.rightPanelPuppet = undefined;
|
||||
this.onEditorComponentLoad = undefined;
|
||||
this.saveTimeout = undefined;
|
||||
this.statusTimeout = undefined;
|
||||
(this.onPanelResizeFinish as unknown) = undefined;
|
||||
(this.editorMenuOnSelect as unknown) = undefined;
|
||||
@@ -167,9 +161,10 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.registerKeyboardShortcuts();
|
||||
this.controller.setOnNoteInnerValueChange((note, source) => {
|
||||
this.onNoteInnerChange(note, source);
|
||||
});
|
||||
this.removeInnerNoteObserver =
|
||||
this.controller.addNoteInnerValueChangeObserver((note, source) => {
|
||||
this.onNoteInnerChange(note, source);
|
||||
});
|
||||
this.autorun(() => {
|
||||
this.setState({
|
||||
showProtectedWarning: this.appState.notes.showProtectedWarning,
|
||||
@@ -479,13 +474,16 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
const transactions: TransactionalMutation[] = [];
|
||||
|
||||
this.setMenuState('showEditorMenu', false);
|
||||
|
||||
if (this.appState.getActiveNoteController()?.isTemplateNote) {
|
||||
await this.appState.getActiveNoteController().insertTemplatedNote();
|
||||
}
|
||||
|
||||
if (this.note.locked) {
|
||||
this.application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!component) {
|
||||
if (!this.note.prefersPlainEditor) {
|
||||
transactions.push({
|
||||
@@ -542,83 +540,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bypassDebouncer Calling save will debounce by default. You can pass true to save
|
||||
* immediately.
|
||||
* @param isUserModified This field determines if the item will be saved as a user
|
||||
* modification, thus updating the user modified date displayed in the UI
|
||||
* @param dontUpdatePreviews Whether this change should update the note's plain and HTML
|
||||
* preview.
|
||||
* @param customMutate A custom mutator function.
|
||||
* @param closeAfterSync Whether this editor should be closed after the sync starts.
|
||||
* This allows us to make a destructive change, wait for sync to be triggered, then
|
||||
* close the editor (if we closed the editor before sync began, we'd get an exception,
|
||||
* since the debouncer will be triggered on a non-existent editor)
|
||||
*/
|
||||
async save(
|
||||
note: SNNote,
|
||||
editorValues: EditorValues,
|
||||
bypassDebouncer = false,
|
||||
isUserModified = false,
|
||||
dontUpdatePreviews = false,
|
||||
customMutate?: (mutator: NoteMutator) => void,
|
||||
closeAfterSync = false
|
||||
) {
|
||||
const title = editorValues.title;
|
||||
const text = editorValues.text;
|
||||
const isTemplate = this.controller.isTemplateNote;
|
||||
if (document.hidden) {
|
||||
this.application.alertService.alert(STRING_SAVING_WHILE_DOCUMENT_HIDDEN);
|
||||
return;
|
||||
}
|
||||
if (note.deleted) {
|
||||
this.application.alertService.alert(STRING_DELETED_NOTE);
|
||||
return;
|
||||
}
|
||||
if (isTemplate) {
|
||||
await this.controller.insertTemplatedNote();
|
||||
}
|
||||
if (!this.application.findItem(note.uuid)) {
|
||||
this.application.alertService.alert(STRING_INVALID_NOTE);
|
||||
return;
|
||||
}
|
||||
await this.application.changeItem(
|
||||
note.uuid,
|
||||
(mutator) => {
|
||||
const noteMutator = mutator as NoteMutator;
|
||||
if (customMutate) {
|
||||
customMutate(noteMutator);
|
||||
}
|
||||
noteMutator.title = title;
|
||||
noteMutator.text = text;
|
||||
if (!dontUpdatePreviews) {
|
||||
const noteText = text || '';
|
||||
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;
|
||||
}
|
||||
},
|
||||
isUserModified
|
||||
);
|
||||
if (this.saveTimeout) {
|
||||
this.$timeout.cancel(this.saveTimeout);
|
||||
}
|
||||
const noDebounce = bypassDebouncer || this.application.noAccount();
|
||||
const syncDebouceMs = noDebounce
|
||||
? SAVE_TIMEOUT_NO_DEBOUNCE
|
||||
: SAVE_TIMEOUT_DEBOUNCE;
|
||||
this.saveTimeout = this.$timeout(() => {
|
||||
this.application.sync();
|
||||
if (closeAfterSync) {
|
||||
this.appState.closeNoteController(this.controller);
|
||||
}
|
||||
}, syncDebouceMs);
|
||||
}
|
||||
|
||||
showSavingStatus() {
|
||||
this.setStatus({ message: 'Saving…' }, false);
|
||||
}
|
||||
@@ -672,7 +593,10 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
}
|
||||
|
||||
contentChanged() {
|
||||
this.save(this.note, copyEditorValues(this.editorValues), false, true);
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
isUserModified: true,
|
||||
});
|
||||
}
|
||||
|
||||
onTitleEnter($event: Event) {
|
||||
@@ -682,13 +606,11 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
}
|
||||
|
||||
onTitleChange() {
|
||||
this.save(
|
||||
this.note,
|
||||
copyEditorValues(this.editorValues),
|
||||
false,
|
||||
true,
|
||||
true
|
||||
);
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
isUserModified: true,
|
||||
dontUpdatePreviews: true,
|
||||
});
|
||||
}
|
||||
|
||||
focusEditor() {
|
||||
@@ -740,16 +662,14 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
if (permanently) {
|
||||
this.performNoteDeletion(this.note);
|
||||
} else {
|
||||
this.save(
|
||||
this.note,
|
||||
copyEditorValues(this.editorValues),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
(mutator) => {
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
bypassDebouncer: true,
|
||||
dontUpdatePreviews: true,
|
||||
customMutate: (mutator) => {
|
||||
mutator.trashed = true;
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1018,7 +938,11 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
|
||||
editor.selectionStart = editor.selectionEnd = start + 4;
|
||||
}
|
||||
this.editorValues.text = editor.value;
|
||||
this.save(this.note, copyEditorValues(this.editorValues), true);
|
||||
|
||||
this.controller.save({
|
||||
editorValues: copyEditorValues(this.editorValues),
|
||||
bypassDebouncer: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
SNNote,
|
||||
ContentType,
|
||||
PayloadSource,
|
||||
UuidString,
|
||||
SNTag,
|
||||
} from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
|
||||
export class NoteViewController {
|
||||
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,
|
||||
private defaultTitle: string | undefined,
|
||||
private defaultTag: UuidString | undefined
|
||||
) {
|
||||
this.application = application;
|
||||
if (noteUuid) {
|
||||
this.note = application.findItem(noteUuid) as SNNote;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.note) {
|
||||
const note = (await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
{
|
||||
text: '',
|
||||
title: this.defaultTitle,
|
||||
references: [],
|
||||
}
|
||||
)) as SNNote;
|
||||
if (this.defaultTag) {
|
||||
const tag = this.application.findItem(this.defaultTag) as SNTag;
|
||||
await this.application.addTagHierarchyToNote(note, tag);
|
||||
}
|
||||
this.isTemplateNote = true;
|
||||
this.note = note;
|
||||
this.onNoteValueChange?.(this.note, this.note.payload.source);
|
||||
}
|
||||
this.streamItems();
|
||||
}
|
||||
|
||||
private streamItems() {
|
||||
this.removeStreamObserver = this.application.streamItems(
|
||||
ContentType.Note,
|
||||
(items, source) => {
|
||||
this.handleNoteStream(items as SNNote[], source);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.removeStreamObserver?.();
|
||||
(this.removeStreamObserver as unknown) = undefined;
|
||||
(this.application as unknown) = undefined;
|
||||
this.onNoteValueChange = undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}) as SNNote;
|
||||
if (matchingNote) {
|
||||
this.isTemplateNote = false;
|
||||
this.note = matchingNote;
|
||||
this.onNoteValueChange?.(matchingNote, source);
|
||||
}
|
||||
}
|
||||
|
||||
insertTemplatedNote() {
|
||||
this.isTemplateNote = false;
|
||||
return this.application.insertItem(this.note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register to be notified when the controller's note's inner values change
|
||||
* (and thus a new object reference is created)
|
||||
*/
|
||||
public setOnNoteInnerValueChange(
|
||||
callback: (note: SNNote, source: PayloadSource) => void
|
||||
) {
|
||||
this.onNoteValueChange = callback;
|
||||
if (this.note) {
|
||||
this.onNoteValueChange(this.note, this.note.payload.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
#tags-column.sn-component.section.tags(aria-label='Tags')
|
||||
.component-view-container(ng-if='self.state.componentViewer')
|
||||
component-view.component-view(
|
||||
component-viewer='self.state.componentViewer',
|
||||
application='self.application'
|
||||
app-state='self.appState'
|
||||
)
|
||||
#tags-content.content(ng-if='!(self.state.componentViewer)')
|
||||
.tags-title-section.section-title-bar
|
||||
.section-title-bar-header
|
||||
.sk-h3.title
|
||||
span.sk-bold Views
|
||||
.sk-button.sk-secondary-contrast.wide(
|
||||
ng-click='self.clickedAddNewTag()',
|
||||
title='Create a new tag'
|
||||
)
|
||||
.sk-label
|
||||
i.icon.ion-plus.add-button
|
||||
.scrollable
|
||||
.infinite-scroll
|
||||
.tag(
|
||||
ng-class="{'selected' : self.state.selectedTag == tag, 'faded' : !tag.isAllTag}",
|
||||
ng-click='self.selectTag(tag)',
|
||||
ng-repeat='tag in self.state.smartTags track by tag.uuid'
|
||||
)
|
||||
.tag-info
|
||||
.title(ng-if="!tag.errorDecrypting") {{tag.title}}
|
||||
.count(ng-show='tag.isAllTag') {{self.state.noteCounts[tag.uuid]}}
|
||||
.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-section(
|
||||
application='self.application',
|
||||
app-state='self.appState'
|
||||
)
|
||||
panel-resizer(
|
||||
collapsable='true',
|
||||
control='self.panelPuppet',
|
||||
default-width='150',
|
||||
hoverable='true',
|
||||
on-resize-finish='self.onPanelResize',
|
||||
panel-id="'tags-column'"
|
||||
)
|
||||
@@ -1,298 +0,0 @@
|
||||
import { PanelPuppet, WebDirective } from '@/types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppStateEvent } from '@/ui_models/app_state';
|
||||
import { PANEL_NAME_TAGS } from '@/views/constants';
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ComponentAction,
|
||||
ComponentArea,
|
||||
ComponentViewer,
|
||||
ContentType,
|
||||
isPayloadSourceInternalChange,
|
||||
MessageData,
|
||||
PayloadSource,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNSmartTag,
|
||||
SNTag,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import template from './tags-view.pug';
|
||||
|
||||
type NoteCounts = Partial<Record<string, number>>;
|
||||
|
||||
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?: () => void;
|
||||
/** The original name of the edtingTag before it began editing */
|
||||
formData: { tagTitle?: string } = {};
|
||||
titles: Partial<Record<UuidString, string>> = {};
|
||||
private removeTagsObserver!: () => void;
|
||||
private removeFoldersObserver!: () => void;
|
||||
|
||||
/* @ngInject */
|
||||
constructor($timeout: ng.ITimeoutService) {
|
||||
super($timeout);
|
||||
this.panelPuppet = {
|
||||
onReady: () => this.loadPreferences(),
|
||||
};
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.removeTagsObserver?.();
|
||||
(this.removeTagsObserver as unknown) = undefined;
|
||||
(this.removeFoldersObserver as unknown) = undefined;
|
||||
this.unregisterComponent?.();
|
||||
this.unregisterComponent = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
getInitialState(): TagState {
|
||||
return {
|
||||
smartTags: [],
|
||||
noteCounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
getState(): TagState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.loadPreferences();
|
||||
this.streamForFoldersComponent();
|
||||
|
||||
const smartTags = this.application.getSmartTags();
|
||||
this.setState({ smartTags });
|
||||
this.selectTag(smartTags[0]);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppIncrementalSync() {
|
||||
super.onAppIncrementalSync();
|
||||
this.reloadNoteCounts();
|
||||
}
|
||||
|
||||
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 (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],
|
||||
async (items) => {
|
||||
const tags = items as Array<SNTag | SNSmartTag>;
|
||||
|
||||
await this.setState({
|
||||
smartTags: this.application.getSmartTags(),
|
||||
});
|
||||
|
||||
for (const tag of tags) {
|
||||
this.titles[tag.uuid] = tag.title;
|
||||
}
|
||||
|
||||
this.reloadNoteCounts();
|
||||
const selectedTag = this.state.selectedTag;
|
||||
|
||||
if (selectedTag) {
|
||||
/** If the selected tag has been deleted, revert to All view. */
|
||||
const matchingTag = tags.find((tag) => {
|
||||
return tag.uuid === selectedTag.uuid;
|
||||
});
|
||||
|
||||
if (matchingTag) {
|
||||
if (matchingTag.deleted) {
|
||||
this.selectTag(this.getState().smartTags[0]);
|
||||
} else {
|
||||
this.setState({
|
||||
selectedTag: matchingTag,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
onAppStateEvent(eventName: AppStateEvent) {
|
||||
if (eventName === AppStateEvent.TagChanged) {
|
||||
this.setState({
|
||||
selectedTag: this.application.getAppState().getSelectedTag(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName);
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.LocalDataIncrementalLoad:
|
||||
this.reloadNoteCounts();
|
||||
break;
|
||||
case ApplicationEvent.PreferencesChanged:
|
||||
this.loadPreferences();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
reloadNoteCounts() {
|
||||
const smartTags = this.state.smartTags;
|
||||
const noteCounts: NoteCounts = {};
|
||||
|
||||
for (const tag of smartTags) {
|
||||
/** Other smart tags do not contain counts */
|
||||
if (tag.isAllTag) {
|
||||
const notes = this.application
|
||||
.notesMatchingSmartTag(tag as SNSmartTag)
|
||||
.filter((note) => {
|
||||
return !note.archived && !note.trashed;
|
||||
});
|
||||
noteCounts[tag.uuid] = notes.length;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
noteCounts: noteCounts,
|
||||
});
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
if (!this.panelPuppet.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = this.application.getPreference(PrefKey.TagsPanelWidth);
|
||||
if (width) {
|
||||
this.panelPuppet.setWidth!(width);
|
||||
if (this.panelPuppet.isCollapsed!()) {
|
||||
this.application
|
||||
.getAppState()
|
||||
.panelDidResize(PANEL_NAME_TAGS, this.panelPuppet.isCollapsed!());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPanelResize = (
|
||||
newWidth: number,
|
||||
_lastLeft: number,
|
||||
_isAtMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => {
|
||||
this.application
|
||||
.setPreference(PrefKey.TagsPanelWidth, newWidth)
|
||||
.then(() => this.application.sync());
|
||||
this.application.getAppState().panelDidResize(PANEL_NAME_TAGS, isCollapsed);
|
||||
};
|
||||
|
||||
async selectTag(tag: SNTag) {
|
||||
if (tag.conflictOf) {
|
||||
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
this.application.getAppState().setSelectedTag(tag);
|
||||
}
|
||||
|
||||
async clickedAddNewTag() {
|
||||
if (this.appState.templateTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appState.createNewTag();
|
||||
}
|
||||
}
|
||||
|
||||
export class TagsView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.scope = {
|
||||
application: '=',
|
||||
};
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = TagsViewCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section.tags,
|
||||
navigation,
|
||||
notes-view {
|
||||
will-change: opacity;
|
||||
animation: fade-out 1.25s forwards;
|
||||
@@ -45,7 +45,7 @@
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.section.tags:hover {
|
||||
navigation:hover {
|
||||
flex: initial;
|
||||
width: 0px !important;
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
.disable-focus-mode {
|
||||
.section.tags,
|
||||
navigation,
|
||||
notes-view {
|
||||
transition: width 1.25s;
|
||||
will-change: opacity;
|
||||
|
||||
@@ -203,11 +203,6 @@ $footer-height: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.scrollable {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
> .content {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
@@ -250,3 +245,21 @@ $footer-height: 2rem;
|
||||
.z-index-purchase-flow {
|
||||
z-index: $z-index-purchase-flow;
|
||||
}
|
||||
|
||||
textarea {
|
||||
&.non-interactive {
|
||||
user-select: text !important;
|
||||
resize: none;
|
||||
background-color: transparent;
|
||||
border-color: var(--sn-stylekit-border-color);
|
||||
font-family: monospace;
|
||||
outline: 0;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-o-user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
.tags {
|
||||
width: 180px;
|
||||
@import './scrollbar';
|
||||
|
||||
#navigation .scrollable {
|
||||
@include minimal_scrollbar();
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#navigation {
|
||||
width: 100%;
|
||||
flex-grow: 0;
|
||||
|
||||
user-select: none;
|
||||
@@ -8,42 +15,21 @@
|
||||
-webkit-user-select: none;
|
||||
|
||||
&,
|
||||
#tags-content {
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
#navigation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
}
|
||||
|
||||
.tags-title-section {
|
||||
.section-title-bar {
|
||||
color: var(--sn-stylekit-secondary-foreground-color);
|
||||
padding-top: 15px;
|
||||
padding-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.infinite-scroll {
|
||||
overflow-x: hidden;
|
||||
height: inherit;
|
||||
|
||||
// Autohide scrollbar on Windows.
|
||||
@at-root {
|
||||
.windows-web &,
|
||||
.windows-desktop & {
|
||||
overflow-y: hidden;
|
||||
&:hover {
|
||||
overflow-y: auto;
|
||||
overflow-y: overlay; // overlay is not supported on ff, so keep previous statement up
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-tags-placeholder {
|
||||
padding: 0px 12px;
|
||||
font-size: 12px;
|
||||
@@ -76,12 +62,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
.tag,
|
||||
.root-drop {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
|
||||
min-height: 30px;
|
||||
padding: 5px 12px;
|
||||
padding: 5px 14px;
|
||||
cursor: pointer;
|
||||
transition: height 0.1s ease-in-out;
|
||||
position: relative;
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './scrollbar';
|
||||
|
||||
notes-view {
|
||||
width: 350px;
|
||||
}
|
||||
@@ -101,64 +103,106 @@ notes-view {
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.infinite-scroll {
|
||||
overflow-x: hidden;
|
||||
@include minimal_scrollbar();
|
||||
height: inherit;
|
||||
|
||||
// Autohide scrollbar on Windows.
|
||||
@at-root {
|
||||
.windows-web &,
|
||||
.windows-desktop & {
|
||||
overflow-y: hidden;
|
||||
&:hover {
|
||||
overflow-y: auto;
|
||||
overflow-y: overlay; // overlay is not supported on ff, so keep previous statement up
|
||||
}
|
||||
}
|
||||
}
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid var(--sn-stylekit-border-color);
|
||||
cursor: pointer;
|
||||
|
||||
> .name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
&:hover {
|
||||
background-color: var(--sn-stylekit-grey-5);
|
||||
}
|
||||
|
||||
> .bottom-info {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
.icon {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
padding-right: 0.75rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
padding-left: 0;
|
||||
border-bottom: 1px solid var(--sn-stylekit-border-color);
|
||||
|
||||
&.icon-hidden {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.flag-icons {
|
||||
&,
|
||||
& > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& > * + * {
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-info {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-string {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.345rem;
|
||||
font-size: 0.725rem;
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.375rem 0.25rem 0.325rem;
|
||||
background-color: var(--sn-stylekit-grey-4-opacity-variant);
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
font-size: var(--sn-stylekit-font-size-h3);
|
||||
margin-top: 2px;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
& > * {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.default-preview,
|
||||
.plain-preview {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1; /* number of lines to show */
|
||||
$line-height: 18px;
|
||||
line-height: $line-height; /* fallback */
|
||||
max-height: calc(#{$line-height} * 1); /* fallback */
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.html-preview {
|
||||
@@ -175,8 +219,7 @@ notes-view {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-top: -4px;
|
||||
margin-top: 0.125rem;
|
||||
|
||||
.flag {
|
||||
padding: 4px;
|
||||
@@ -238,13 +281,8 @@ notes-view {
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--sn-stylekit-info-color);
|
||||
color: var(--sn-stylekit-info-contrast-color);
|
||||
|
||||
.note-flags .flag {
|
||||
background-color: var(--sn-stylekit-info-contrast-color);
|
||||
color: var(--sn-stylekit-info-color);
|
||||
}
|
||||
background-color: var(--sn-stylekit-grey-5);
|
||||
border-left: 2px solid var(--sn-stylekit-info-color);
|
||||
|
||||
progress {
|
||||
background-color: var(--sn-stylekit-secondary-foreground-color);
|
||||
@@ -255,7 +293,7 @@ notes-view {
|
||||
}
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
background-color: var(--sn-stylekit-info-color);
|
||||
}
|
||||
|
||||
&::-moz-progress-bar {
|
||||
|
||||
9
app/assets/stylesheets/_scrollbar.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@mixin minimal_scrollbar() {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
|
||||
&:hover {
|
||||
overflow-y: auto;
|
||||
overflow-y: overlay;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@
|
||||
@extend .h-3\.5;
|
||||
@extend .w-3\.5;
|
||||
}
|
||||
|
||||
&.sn-icon--mid {
|
||||
@extend .w-4;
|
||||
@extend .h-4;
|
||||
}
|
||||
}
|
||||
|
||||
.sn-dropdown {
|
||||
@@ -777,6 +782,7 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sn-stylekit-contrast-background-color) !important;
|
||||
@extend .color-info;
|
||||
@extend .border-info;
|
||||
}
|
||||
@@ -807,3 +813,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dimmed {
|
||||
opacity: .5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,10 @@ $screen-md-max: ($screen-lg-min - 1) !default;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fill-current {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@import 'main';
|
||||
@import 'ui';
|
||||
@import 'footer';
|
||||
@import 'tags';
|
||||
@import 'navigation';
|
||||
@import 'notes';
|
||||
@import 'editor';
|
||||
@import 'menus';
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
data-purchase-url="<%= env.PURCHASE_URL %>"
|
||||
data-plans-url="<%= env.PLANS_URL %>"
|
||||
data-dashboard-url="<%= env.DASHBOARD_URL %>"
|
||||
>
|
||||
data-dev-account-email="<%= env.DEV_ACCOUNT_EMAIL %>"
|
||||
data-dev-account-password="<%= env.DEV_ACCOUNT_PASSWORD %>"
|
||||
data-dev-account-server="<%= env.DEV_ACCOUNT_SERVER %>"
|
||||
>
|
||||
<script>
|
||||
window._default_sync_server = document.body.dataset.defaultSyncServer || "https://api.standardnotes.com";
|
||||
window._bugsnag_api_key = document.body.dataset.bugsnagApiKey;
|
||||
@@ -45,6 +48,9 @@
|
||||
window._purchase_url = document.body.dataset.purchaseUrl;
|
||||
window._plans_url = document.body.dataset.plansUrl;
|
||||
window._dashboard_url = document.body.dataset.dashboardUrl;
|
||||
window._devAccountEmail = document.body.dataset.devAccountEmail;
|
||||
window._devAccountPassword = document.body.dataset.devAccountPassword;
|
||||
window._devAccountServer = document.body.dataset.devAccountServer;
|
||||
</script>
|
||||
<application-group-view />
|
||||
</body>
|
||||
|
||||
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "standard-notes-web",
|
||||
"version": "3.9.13",
|
||||
"version": "3.9.14",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@reach/disclosure": "^0.16.2",
|
||||
"@reach/visually-hidden": "^0.16.0",
|
||||
"@standardnotes/components": "^1.2.4",
|
||||
"@standardnotes/components": "^1.2.5",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@types/angular": "^1.8.3",
|
||||
"@types/jest": "^27.0.3",
|
||||
@@ -68,7 +68,7 @@
|
||||
"pug-loader": "^2.4.0",
|
||||
"sass-loader": "^12.2.0",
|
||||
"serve-static": "^1.14.1",
|
||||
"sn-stylekit": "5.2.20",
|
||||
"sn-stylekit": "5.2.21",
|
||||
"svg-jest": "^1.0.1",
|
||||
"ts-jest": "^27.0.7",
|
||||
"ts-loader": "^9.2.6",
|
||||
@@ -87,9 +87,10 @@
|
||||
"@reach/dialog": "^0.16.2",
|
||||
"@reach/listbox": "^0.16.2",
|
||||
"@reach/tooltip": "^0.16.2",
|
||||
"@standardnotes/features": "1.20.5",
|
||||
"@standardnotes/features": "1.20.6",
|
||||
"@standardnotes/settings": "^1.9.0",
|
||||
"@standardnotes/sncrypto-web": "1.5.3",
|
||||
"@standardnotes/snjs": "2.31.25",
|
||||
"@standardnotes/snjs": "2.35.3",
|
||||
"mobx": "^6.3.5",
|
||||
"mobx-react-lite": "^3.2.2",
|
||||
"preact": "^10.5.15",
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
"binary": "1928aa349a04471afd273725cc4befe711eeda91aca70aee00c7ad356241c252"
|
||||
},
|
||||
"org.standardnotes.theme-dynamic": {
|
||||
"version": "1.0.1",
|
||||
"base64": "ead03c37f6cb3b1858793db4433331143f916aebb3a13ab07637c27dcf310034",
|
||||
"binary": "fb93157b0249f577e7a5e58f8bb562e68435f683cc66f7adace12b935fa3eee1"
|
||||
"version": "1.0.2",
|
||||
"base64": "7a5075a265e67ae54cb7c49bad6aa8b6f84f8fb4883ca859327e8e794cbfff0c",
|
||||
"binary": "01a1356c879aa1ef38e856ee0028e6afbff1fa40544e24e3297af863d6cac669"
|
||||
},
|
||||
"org.standardnotes.code-editor": {
|
||||
"version": "1.3.8",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
.section.tags {
|
||||
.section.tags,
|
||||
navigation {
|
||||
flex: none !important;
|
||||
width: 120px !important;
|
||||
transition: width 0.25s;
|
||||
}
|
||||
|
||||
.section.tags:hover {
|
||||
.section.tags:hover,
|
||||
navigation:hover {
|
||||
flex: initial;
|
||||
width: 180px !important;
|
||||
transition: width 0.25s;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 3,
|
||||
"mappings": "AAAA,aAAc;EACZ,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB,mBAAoB;EAClB,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;UACW;EACT,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;gBACiB;EACf,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW",
|
||||
"mappings": "AAAA;UACW;EACT,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;gBACiB;EACf,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;UACW;EACT,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;gBACiB;EACf,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW",
|
||||
"sources": ["../src/main.scss"],
|
||||
"names": [],
|
||||
"file": "dist.css"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sn-theme-dynamic",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"main": "dist/dist.css",
|
||||
"devDependencies": {
|
||||
"grunt": "^1.0.1",
|
||||
|
||||
52
yarn.lock
@@ -2616,10 +2616,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.2.1.tgz#9db212db86ccbf08b347da02549b3dbe4bedbb02"
|
||||
integrity sha512-HilBxS50CBlC6TJvy1mrnhGVDzOH63M/Jf+hyMxQ0Vt1nYzpd0iyxVEUrgMh7ZiyO1b9CLnCDED99Jy9rnZWVQ==
|
||||
|
||||
"@standardnotes/components@^1.2.4":
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/components/-/components-1.2.4.tgz#29cab333551e91d8c88c920a7d5e3bd863d55f17"
|
||||
integrity sha512-Ous0tCCnzIH4IHN/3y0C03XYlpTfxP+mfsPNJN/kPhC0Z+91OV+z6Y/qK1zxe6khsegUEO6k01qyPu5EJLhorg==
|
||||
"@standardnotes/components@^1.2.5":
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/components/-/components-1.2.5.tgz#3e1e959bc40e5e0d34a0a44ac68fda4b29807f8e"
|
||||
integrity sha512-/E87R6wpMK0nLeS6ugCyjHht3M5hqZUH4lWA179jE9N4A6WXYvlqcFYO4t2RiG6yErRPZOHfpUFFsL1agXVuwA==
|
||||
|
||||
"@standardnotes/domain-events@2.5.1":
|
||||
version "2.5.1"
|
||||
@@ -2628,18 +2628,26 @@
|
||||
dependencies:
|
||||
"@standardnotes/auth" "^3.8.1"
|
||||
|
||||
"@standardnotes/features@1.20.5", "@standardnotes/features@^1.20.5":
|
||||
version "1.20.5"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.5.tgz#443e3ae84d13f0aaa35708c5c237dac8041cb50d"
|
||||
integrity sha512-4QQeWLk2frEF9UYOfnuQoulkUJ3PooVLasPUA+zva+KIokBiyPmVPsi3HAYXlHqowu+lDhKU2pUklLhm1ePvJw==
|
||||
"@standardnotes/features@1.20.6":
|
||||
version "1.20.6"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.6.tgz#94d397892dd12f76a10c89c70092933627a9c457"
|
||||
integrity sha512-/w8+/8J8UNJ+DAsOud8XbWkeUBN6eb+5+Ic4NgkXYkx/wv6sTDk9XVc+mOhxOkYlb2iy85JDmoLAwu+GW/3Gtg==
|
||||
dependencies:
|
||||
"@standardnotes/auth" "3.8.3"
|
||||
"@standardnotes/common" "1.2.1"
|
||||
|
||||
"@standardnotes/settings@^1.8.1":
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.8.1.tgz#a448f2b48a994dab2a84dc93255cd2f9ea0df6af"
|
||||
integrity sha512-hQFg4xYkvI7WWRCxYjbyiNW7EjaUlmASGXsd/AoYlHGrlYhTnOEajBEh3sSMMV0b7UUps0wGZcGjQMpq5fabuw==
|
||||
"@standardnotes/features@^1.20.7":
|
||||
version "1.20.7"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.7.tgz#d666569492e942eaecc05e40a79d50d33df4fbe9"
|
||||
integrity sha512-eaZu/+PvHYXWaq6r3ET87t52lZqFknZVUEjspAL34Fdr+5cDma5ZRoylx6hPCVDO9VpHd6fjGWlS+5kZ+qJ+bA==
|
||||
dependencies:
|
||||
"@standardnotes/auth" "3.8.3"
|
||||
"@standardnotes/common" "1.2.1"
|
||||
|
||||
"@standardnotes/settings@^1.9.0":
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.9.0.tgz#0f01da5f6782363e4d77ee584b40f8614c555626"
|
||||
integrity sha512-y+Mh7NuXtekEDr4PAvzg9KcRaCdd+0zlTXWO2D5MG28lLv/uhZmSsyWxZCVZqW3Rx6vz3c9IJdi7SoXN51gzSQ==
|
||||
|
||||
"@standardnotes/sncrypto-common@1.5.2", "@standardnotes/sncrypto-common@^1.5.2":
|
||||
version "1.5.2"
|
||||
@@ -2655,16 +2663,16 @@
|
||||
buffer "^6.0.3"
|
||||
libsodium-wrappers "^0.7.9"
|
||||
|
||||
"@standardnotes/snjs@2.31.25":
|
||||
version "2.31.25"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.31.25.tgz#715dbecc0c71cc22a81d93b3aabfe7b436a480ac"
|
||||
integrity sha512-DF0ZcIHfxIpaFepCIXNCVipwjgoy60FrSy5th0kNj5TCOYHryQ9bOiaWXQKHrQUi/8sKYJ+/W1pwRjz6+MpZMw==
|
||||
"@standardnotes/snjs@2.35.3":
|
||||
version "2.35.3"
|
||||
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.35.3.tgz#e8604329930317000fbec239534a3020d7e7aefb"
|
||||
integrity sha512-Cooby9VKS92Zo5xWKQbtCDPTzr2ugsuHzp054NaQTwhGJO/WpgAX1VVoW9MsN4dQxOi6Kf2sIRBUZU4TL+caTQ==
|
||||
dependencies:
|
||||
"@standardnotes/auth" "3.8.1"
|
||||
"@standardnotes/common" "1.2.1"
|
||||
"@standardnotes/domain-events" "2.5.1"
|
||||
"@standardnotes/features" "^1.20.5"
|
||||
"@standardnotes/settings" "^1.8.1"
|
||||
"@standardnotes/features" "^1.20.7"
|
||||
"@standardnotes/settings" "^1.9.0"
|
||||
"@standardnotes/sncrypto-common" "1.5.2"
|
||||
|
||||
"@svgr/babel-plugin-add-jsx-attribute@^5.4.0":
|
||||
@@ -9264,10 +9272,10 @@ slice-ansi@^5.0.0:
|
||||
ansi-styles "^6.0.0"
|
||||
is-fullwidth-code-point "^4.0.0"
|
||||
|
||||
sn-stylekit@5.2.20:
|
||||
version "5.2.20"
|
||||
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.20.tgz#c18f40ff3aaf4c59af89152439a8efbdde35f2dd"
|
||||
integrity sha512-JymHBiZOzQPfCqHYgnVPSA2PwJqiKR268qqQoEMqI85MMAWSG3WYzuKEbd0LgfIQAKLElCxJjeZkrhejyRg+2A==
|
||||
sn-stylekit@5.2.21:
|
||||
version "5.2.21"
|
||||
resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.21.tgz#5aec6c329949bda64a1e3c563ee594b141295d27"
|
||||
integrity sha512-rjlgo42A/kx+M4iY7HYRpnQyp4dLb2HQpEMHz+CYumOzTf/lsRy0Up5HI1haNK4/JMmpq36Eb/7BMDmvLpdXnQ==
|
||||
dependencies:
|
||||
"@reach/listbox" "^0.15.0"
|
||||
"@reach/menu-button" "^0.15.1"
|
||||
|
||||