diff --git a/app/assets/icons/ic-themes.svg b/app/assets/icons/ic-themes.svg index 33abb061a1f..62ac3133b3a 100644 --- a/app/assets/icons/ic-themes.svg +++ b/app/assets/icons/ic-themes.svg @@ -1,3 +1,3 @@ - + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 971f793ebf1..e94e181c903 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -64,6 +64,7 @@ import { IconDirective } from './components/Icon'; import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; import { PreferencesDirective } from './preferences'; import { AppVersion, IsWebPlatform } from '@/version'; +import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu'; function reloadHiddenFirefoxTab(): boolean { /** @@ -154,6 +155,7 @@ const startApplication: StartApplication = async function startApplication( .directive('syncResolutionMenu', () => new SyncResolutionMenu()) .directive('sessionsModal', SessionsModalDirective) .directive('accountMenu', AccountMenuDirective) + .directive('quickSettingsMenu', QuickSettingsMenuDirective) .directive('noAccountWarning', NoAccountWarningDirective) .directive('protectedNotePanel', NoProtectionsdNoteWarningDirective) .directive('searchOptions', SearchOptionsDirective) diff --git a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx index 82865ede93b..1a75878df60 100644 --- a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx +++ b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx @@ -39,7 +39,7 @@ export const AdvancedOptions: FunctionComponent = observer( return ( <> ) : null} - + ); } ); diff --git a/app/assets/javascripts/components/AccountMenu/index.tsx b/app/assets/javascripts/components/AccountMenu/index.tsx index 77da5608cb7..54de2a9ab06 100644 --- a/app/assets/javascripts/components/AccountMenu/index.tsx +++ b/app/assets/javascripts/components/AccountMenu/index.tsx @@ -9,6 +9,7 @@ import { SignInPane } from './SignIn'; import { CreateAccount } from './CreateAccount'; import { ConfirmSignoutContainer } from '../ConfirmSignoutModal'; import { ConfirmPassword } from './ConfirmPassword'; +import { JSXInternal } from 'preact/src/jsx'; export enum AccountMenuPane { GeneralMenu, @@ -87,14 +88,31 @@ const AccountMenu: FunctionComponent = observer( closeAccountMenu, } = appState.accountMenu; + const handleKeyDown: JSXInternal.KeyboardEventHandler = ( + event + ) => { + switch (event.key) { + case 'Escape': + if (currentPane === AccountMenuPane.GeneralMenu) { + closeAccountMenu(); + } else if (currentPane === AccountMenuPane.ConfirmPassword) { + setCurrentPane(AccountMenuPane.Register); + } else { + setCurrentPane(AccountMenuPane.GeneralMenu); + } + break; + } + }; + return (
void; +}; + +type MenuProps = { + appState: AppState; + application: WebApplication; +}; + +const ThemeButton: FunctionComponent = ({ + application, + theme, + onBlur, +}) => { + const toggleTheme = () => { + if (theme.isLayerable() || !theme.active) { + application.toggleComponent(theme); + } + }; + + return ( + + ); +}; + +const QuickSettingsMenu: FunctionComponent = observer( + ({ application, appState }) => { + const { closeQuickSettingsMenu, shouldAnimateCloseMenu } = + appState.quickSettingsMenu; + const [themes, setThemes] = useState([]); + const [themesMenuOpen, setThemesMenuOpen] = useState(false); + const [themesMenuPosition, setThemesMenuPosition] = useState({}); + const [defaultThemeOn, setDefaultThemeOn] = useState(false); + + const themesMenuRef = useRef(); + const themesButtonRef = useRef(); + const prefsButtonRef = useRef(); + const quickSettingsMenuRef = useRef(); + const defaultThemeButtonRef = useRef(); + + const reloadThemes = useCallback(() => { + application.streamItems(ContentType.Theme, () => { + const themes = application.getDisplayableItems( + ContentType.Theme + ) as SNTheme[]; + setThemes( + themes.sort((a, b) => { + const aIsLayerable = a.isLayerable(); + const bIsLayerable = b.isLayerable(); + + if (aIsLayerable && !bIsLayerable) { + return 1; + } else if (!aIsLayerable && bIsLayerable) { + return -1; + } else { + return a.package_info.name.toLowerCase() < + b.package_info.name.toLowerCase() + ? -1 + : 1; + } + }) + ); + setDefaultThemeOn( + !themes.find((theme) => theme.active && !theme.isLayerable()) + ); + }); + }, [application]); + + useEffect(() => { + reloadThemes(); + }, [reloadThemes]); + + useEffect(() => { + if (themesMenuOpen) { + defaultThemeButtonRef.current.focus(); + } + }, [themesMenuOpen]); + + useEffect(() => { + prefsButtonRef.current.focus(); + }, []); + + const [closeOnBlur] = useCloseOnBlur(themesMenuRef, setThemesMenuOpen); + + const toggleThemesMenu = () => { + if (!themesMenuOpen) { + const themesButtonRect = + themesButtonRef.current.getBoundingClientRect(); + setThemesMenuPosition({ + left: themesButtonRect.right, + bottom: + document.documentElement.clientHeight - themesButtonRect.bottom, + }); + setThemesMenuOpen(true); + } else { + setThemesMenuOpen(false); + } + }; + + const openPreferences = () => { + closeQuickSettingsMenu(); + appState.preferences.openPreferences(); + }; + + const handleBtnKeyDown: React.KeyboardEventHandler = ( + event + ) => { + switch (event.key) { + case 'Escape': + setThemesMenuOpen(false); + themesButtonRef.current.focus(); + break; + case 'ArrowRight': + if (!themesMenuOpen) { + toggleThemesMenu(); + } + } + }; + + const handleQuickSettingsKeyDown: JSXInternal.KeyboardEventHandler = + (event) => { + const items: NodeListOf = + quickSettingsMenuRef.current.querySelectorAll(':scope > button'); + const currentFocusedIndex = Array.from(items).findIndex( + (btn) => btn === document.activeElement + ); + + if (!themesMenuOpen) { + switch (event.key) { + case 'Escape': + closeQuickSettingsMenu(); + break; + case 'ArrowDown': + if (items[currentFocusedIndex + 1]) { + items[currentFocusedIndex + 1].focus(); + } else { + items[0].focus(); + } + break; + case 'ArrowUp': + if (items[currentFocusedIndex - 1]) { + items[currentFocusedIndex - 1].focus(); + } else { + items[items.length - 1].focus(); + } + break; + } + } + }; + + const handlePanelKeyDown: React.KeyboardEventHandler = ( + event + ) => { + const themes = themesMenuRef.current.querySelectorAll('button'); + const currentFocusedIndex = Array.from(themes).findIndex( + (themeBtn) => themeBtn === document.activeElement + ); + + switch (event.key) { + case 'Escape': + case 'ArrowLeft': + event.stopPropagation(); + setThemesMenuOpen(false); + themesButtonRef.current.focus(); + break; + case 'ArrowDown': + if (themes[currentFocusedIndex + 1]) { + themes[currentFocusedIndex + 1].focus(); + } else { + themes[0].focus(); + } + break; + case 'ArrowUp': + if (themes[currentFocusedIndex - 1]) { + themes[currentFocusedIndex - 1].focus(); + } else { + themes[themes.length - 1].focus(); + } + break; + } + }; + + const toggleDefaultTheme = () => { + const activeTheme = themes.find( + (theme) => theme.active && !theme.isLayerable() + ); + if (activeTheme) application.toggleComponent(activeTheme); + }; + + return ( +
+
+
+ Quick Settings +
+ + +
+ + Themes +
+ +
+ +
+ Themes +
+ + {themes.map((theme) => ( + + ))} +
+
+
+ +
+
+ ); + } +); + +export const QuickSettingsMenuDirective = + toDirective(QuickSettingsMenu); diff --git a/app/assets/javascripts/ui_models/app_state/account_menu_state.ts b/app/assets/javascripts/ui_models/app_state/account_menu_state.ts index 7a8dbafddf1..8fe287989a3 100644 --- a/app/assets/javascripts/ui_models/app_state/account_menu_state.ts +++ b/app/assets/javascripts/ui_models/app_state/account_menu_state.ts @@ -52,6 +52,7 @@ export class AccountMenuState { shouldAnimateCloseMenu: observable, setShow: action, + setShouldAnimateClose: action, toggleShow: action, setSigningOut: action, setIsEncryptionEnabled: action, @@ -95,11 +96,15 @@ export class AccountMenuState { this.show = show; }; + setShouldAnimateClose = (shouldAnimateCloseMenu: boolean): void => { + this.shouldAnimateCloseMenu = shouldAnimateCloseMenu; + }; + closeAccountMenu = (): void => { - this.shouldAnimateCloseMenu = true; + this.setShouldAnimateClose(true); setTimeout(() => { this.setShow(false); - this.shouldAnimateCloseMenu = false; + this.setShouldAnimateClose(false); this.setCurrentPane(AccountMenuPane.GeneralMenu); }, 150); }; @@ -137,7 +142,11 @@ export class AccountMenuState { }; toggleShow = (): void => { - this.show = !this.show; + if (this.show) { + this.closeAccountMenu(); + } else { + this.setShow(true); + } }; setOtherSessionsSignOut = (otherSessionsSignOut: boolean): void => { diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 272c3fb3ab7..ad3b87ae3f1 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -23,6 +23,7 @@ import { NotesState } from './notes_state'; import { TagsState } from './tags_state'; import { AccountMenuState } from '@/ui_models/app_state/account_menu_state'; import { PreferencesState } from './preferences_state'; +import { QuickSettingsState } from './quick_settings_state'; export enum AppStateEvent { TagChanged, @@ -62,6 +63,7 @@ export class AppState { onVisibilityChange: any; selectedTag?: SNTag; showBetaWarning: boolean; + readonly quickSettingsMenu = new QuickSettingsState(); readonly accountMenu: AccountMenuState; readonly actionsMenu = new ActionsMenuState(); readonly preferences = new PreferencesState(); @@ -105,7 +107,7 @@ export class AppState { ); this.accountMenu = new AccountMenuState( application, - this.appEventObserverRemovers, + this.appEventObserverRemovers ); this.searchOptions = new SearchOptionsState( application, diff --git a/app/assets/javascripts/ui_models/app_state/quick_settings_state.ts b/app/assets/javascripts/ui_models/app_state/quick_settings_state.ts new file mode 100644 index 00000000000..f3571cb00c0 --- /dev/null +++ b/app/assets/javascripts/ui_models/app_state/quick_settings_state.ts @@ -0,0 +1,42 @@ +import { action, makeObservable, observable } from 'mobx'; + +export class QuickSettingsState { + open = false; + shouldAnimateCloseMenu = false; + + constructor() { + makeObservable(this, { + open: observable, + shouldAnimateCloseMenu: observable, + + setOpen: action, + setShouldAnimateCloseMenu: action, + toggle: action, + closeQuickSettingsMenu: action, + }); + } + + setOpen = (open: boolean): void => { + this.open = open; + }; + + setShouldAnimateCloseMenu = (shouldAnimate: boolean): void => { + this.shouldAnimateCloseMenu = shouldAnimate; + }; + + toggle = (): void => { + if (this.open) { + this.closeQuickSettingsMenu(); + } else { + this.setOpen(true); + } + }; + + closeQuickSettingsMenu = (): void => { + this.setShouldAnimateCloseMenu(true); + setTimeout(() => { + this.setOpen(false); + this.setShouldAnimateCloseMenu(false); + }, 150); + }; +} diff --git a/app/assets/javascripts/views/footer/footer-view.pug b/app/assets/javascripts/views/footer/footer-view.pug index 5b0f57b9d4a..e24019f3754 100644 --- a/app/assets/javascripts/views/footer/footer-view.pug +++ b/app/assets/javascripts/views/footer/footer-view.pug @@ -23,14 +23,22 @@ ng-if='ctrl.showAccountMenu', ) .sk-app-bar-item.ml-0-important( - ng-click='ctrl.clickPreferences()' + click-outside='ctrl.clickOutsideQuickSettingsMenu()', + is-open='ctrl.showQuickSettingsMenu', + ng-click='ctrl.quickSettingsPressed()' ) .w-8.h-full.flex.items-center.justify-center.cursor-pointer .h-5 icon( type="tune" class-name="rounded hover:color-info" + ng-class="{'color-info': ctrl.showQuickSettingsMenu}" ) + quick-settings-menu( + ng-click='$event.stopPropagation()', + app-state='ctrl.appState' + application='ctrl.application' + ng-if='ctrl.showQuickSettingsMenu',) .sk-app-bar-item a.no-decoration.sk-label.title( href='https://standardnotes.com/help', diff --git a/app/assets/javascripts/views/footer/footer_view.ts b/app/assets/javascripts/views/footer/footer_view.ts index 65ff3042710..c0064484cb7 100644 --- a/app/assets/javascripts/views/footer/footer_view.ts +++ b/app/assets/javascripts/views/footer/footer_view.ts @@ -64,6 +64,7 @@ class FooterViewCtrl extends PureViewCtrl< public user?: any; private offline = true; public showAccountMenu = false; + public showQuickSettingsMenu = false; private didCheckForOffline = false; private queueExtReload = false; private reloadInProgress = false; @@ -115,6 +116,7 @@ class FooterViewCtrl extends PureViewCtrl< this.autorun(() => { const showBetaWarning = this.appState.showBetaWarning; this.showAccountMenu = this.appState.accountMenu.show; + this.showQuickSettingsMenu = this.appState.quickSettingsMenu.open; this.setState({ showBetaWarning: showBetaWarning, showDataUpgrade: !showBetaWarning, @@ -449,10 +451,21 @@ class FooterViewCtrl extends PureViewCtrl< } accountMenuPressed() { + this.appState.quickSettingsMenu.closeQuickSettingsMenu(); this.appState.accountMenu.toggleShow(); this.closeAllRooms(); } + quickSettingsPressed() { + this.appState.accountMenu.closeAccountMenu(); + if (this.themesWithIcons.length > 0) { + this.appState.quickSettingsMenu.toggle(); + } else { + this.appState.preferences.openPreferences(); + } + this.closeAllRooms(); + } + toggleSyncResolutionMenu() { this.showSyncResolution = !this.showSyncResolution; } @@ -476,22 +489,7 @@ class FooterViewCtrl extends PureViewCtrl< } reloadDockShortcuts() { - const shortcuts = []; - for (const theme of this.themesWithIcons) { - if (!theme.package_info) { - continue; - } - const name = theme.package_info.name; - const icon = theme.package_info.dock_icon; - if (!icon) { - continue; - } - shortcuts.push({ - name: name, - component: theme, - icon: icon, - } as DockShortcut); - } + const shortcuts: DockShortcut[] = []; this.setState({ dockShortcuts: shortcuts.sort((a, b) => { /** Circles first, then images */ @@ -556,8 +554,8 @@ class FooterViewCtrl extends PureViewCtrl< this.appState.accountMenu.closeAccountMenu(); } - clickPreferences() { - this.appState.preferences.openPreferences(); + clickOutsideQuickSettingsMenu() { + this.appState.quickSettingsMenu.closeQuickSettingsMenu(); } } diff --git a/app/assets/stylesheets/_menus.scss b/app/assets/stylesheets/_menus.scss index d65ac574169..b79039dbd24 100644 --- a/app/assets/stylesheets/_menus.scss +++ b/app/assets/stylesheets/_menus.scss @@ -17,13 +17,20 @@ max-height: calc(85vh - 90px); } -.sn-account-menu { +.sn-account-menu, +.sn-quick-settings-menu { z-index: $z-index-footer-bar-item-panel; @extend .bottom-100; @extend .left-0; @extend .cursor-auto; } +.sn-menu-border { + @extend .border-1; + @extend .border-solid; + @extend .border-gray-300; +} + .sn-account-menu-headline { @extend .sk-h2; @extend .sk-bold; diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 8f9f2cf7359..4638703f0ef 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -384,3 +384,41 @@ .cursor-auto { cursor: auto; } + +.top-1\/2 { + top: 50%; +} + +.left-1\/2 { + left: 50%; +} + +.-translate-1\/2 { + transform: translate(-50%, -50%); +} + +.pseudo-radio-btn { + @extend .w-4; + @extend .h-4; + @extend .border-2; + @extend .border-solid; + @extend .border-info; + @extend .rounded-full; + @extend .relative; +} + +.pseudo-radio-btn--checked::after { + content: ''; + @extend .bg-info; + @extend .absolute; + @extend .top-1\/2; + @extend .left-1\/2; + @extend .-translate-1\/2; + @extend .w-2; + @extend .h-2; + @extend .rounded-full; +} + +.focus\:bg-info-backdrop:focus { + background-color: var(--sn-stylekit-info-backdrop-color); +}