diff --git a/app/assets/icons/ic-menu-close.svg b/app/assets/icons/ic-menu-close.svg new file mode 100644 index 000000000..74aedeeb1 --- /dev/null +++ b/app/assets/icons/ic-menu-close.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/assets/icons/ic-premium-feature.svg b/app/assets/icons/ic-premium-feature.svg new file mode 100644 index 000000000..7b02bc1ff --- /dev/null +++ b/app/assets/icons/ic-premium-feature.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index a9b5dd44f..875a9a948 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -78,7 +78,7 @@ import { PreferencesDirective } from './preferences'; import { AppVersion, IsWebPlatform } from '@/version'; import { NotesListOptionsDirective } from './components/NotesListOptionsMenu'; import { PurchaseFlowDirective } from './purchaseFlow'; -import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu'; +import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu'; import { ComponentViewDirective } from '@/components/ComponentView'; import { TagsListDirective } from '@/components/TagsList'; diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index f21c0137f..d83e33ab6 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -1,3 +1,4 @@ +import PremiumFeatureIcon from '../../icons/ic-premium-feature.svg'; import PencilOffIcon from '../../icons/ic-pencil-off.svg'; import PlainTextIcon from '../../icons/ic-text-paragraph.svg'; import RichTextIcon from '../../icons/ic-text-rich.svg'; @@ -15,6 +16,7 @@ import TrashSweepIcon from '../../icons/ic-trash-sweep.svg'; import MoreIcon from '../../icons/ic-more.svg'; import TuneIcon from '../../icons/ic-tune.svg'; import MenuArrowDownIcon from '../../icons/ic-menu-arrow-down.svg'; +import MenuCloseIcon from '../../icons/ic-menu-close.svg'; import AuthenticatorIcon from '../../icons/ic-authenticator.svg'; import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg'; import TasksIcon from '../../icons/ic-tasks.svg'; @@ -107,7 +109,9 @@ const ICONS = { 'check-bold': CheckBoldIcon, 'account-circle': AccountCircleIcon, 'menu-arrow-down': MenuArrowDownIcon, - window: WindowIcon + 'menu-close': MenuCloseIcon, + window: WindowIcon, + 'premium-feature': PremiumFeatureIcon }; export type IconType = keyof typeof ICONS; diff --git a/app/assets/javascripts/components/PremiumFeaturesModal.tsx b/app/assets/javascripts/components/PremiumFeaturesModal.tsx new file mode 100644 index 000000000..aa631584a --- /dev/null +++ b/app/assets/javascripts/components/PremiumFeaturesModal.tsx @@ -0,0 +1,75 @@ +import { + AlertDialog, + AlertDialogDescription, + AlertDialogLabel, +} from '@reach/alert-dialog'; +import { FunctionalComponent } from 'preact'; +import { Icon } from './Icon'; +import PremiumIllustration from '../../svg/il-premium.svg'; +import { useRef } from 'preact/hooks'; + +type Props = { + featureName: string; + onClose: () => void; + showModal: boolean; +}; + +export const PremiumFeaturesModal: FunctionalComponent = ({ + featureName, + onClose, + showModal, +}) => { + const plansButtonRef = useRef(null); + + const onClickPlans = () => { + if (window._plans_url) { + window.location.assign(window._plans_url); + } + }; + + return showModal ? ( + +
+
+ +
+ +
+
+ +
+
+ Enable premium features +
+
+ + In order to use {featureName}{' '} + and other premium features, please purchase a subscription or + upgrade your current plan. + +
+ +
+
+
+
+ ) : null; +}; diff --git a/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx b/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx new file mode 100644 index 000000000..718e2b951 --- /dev/null +++ b/app/assets/javascripts/components/QuickSettingsMenu/FocusModeSwitch.tsx @@ -0,0 +1,66 @@ +import { WebApplication } from '@/ui_models/application'; +import { FeatureIdentifier } from '@standardnotes/features'; +import { FeatureStatus } from '@standardnotes/snjs'; +import { FunctionComponent } from 'preact'; +import { useState } from 'preact/hooks'; +import { JSXInternal } from 'preact/src/jsx'; +import { Icon } from '../Icon'; +import { PremiumFeaturesModal } from '../PremiumFeaturesModal'; +import { Switch } from '../Switch'; + +type Props = { + application: WebApplication; + closeQuickSettingsMenu: () => void; + focusModeEnabled: boolean; + setFocusModeEnabled: (enabled: boolean) => void; +}; + +export const FocusModeSwitch: FunctionComponent = ({ + application, + closeQuickSettingsMenu, + focusModeEnabled, + setFocusModeEnabled, +}) => { + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const isEntitledToFocusMode = + application.getFeatureStatus(FeatureIdentifier.FocusMode) === + FeatureStatus.Entitled; + + const toggleFocusMode = ( + e: JSXInternal.TargetedMouseEvent + ) => { + e.preventDefault(); + if (isEntitledToFocusMode) { + setFocusModeEnabled(!focusModeEnabled); + closeQuickSettingsMenu(); + } else { + setShowUpgradeModal(true); + } + }; + + return ( + <> + + setShowUpgradeModal(false)} + /> + + ); +}; diff --git a/app/assets/javascripts/components/QuickSettingsMenu.tsx b/app/assets/javascripts/components/QuickSettingsMenu/QuickSettingsMenu.tsx similarity index 67% rename from app/assets/javascripts/components/QuickSettingsMenu.tsx rename to app/assets/javascripts/components/QuickSettingsMenu/QuickSettingsMenu.tsx index 7279b7c37..a6ef74504 100644 --- a/app/assets/javascripts/components/QuickSettingsMenu.tsx +++ b/app/assets/javascripts/components/QuickSettingsMenu/QuickSettingsMenu.tsx @@ -15,81 +15,46 @@ import { observer } from 'mobx-react-lite'; import { FunctionComponent } from 'preact'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { JSXInternal } from 'preact/src/jsx'; -import { Icon } from './Icon'; -import { Switch } from './Switch'; -import { toDirective, useCloseOnBlur } from './utils'; +import { Icon } from '../Icon'; +import { Switch } from '../Switch'; +import { toDirective, useCloseOnBlur } from '../utils'; +import { + quickSettingsKeyDownHandler, + themesMenuKeyDownHandler, +} from './eventHandlers'; +import { FocusModeSwitch } from './FocusModeSwitch'; +import { ThemesMenuButton } from './ThemesMenuButton'; const MENU_CLASSNAME = 'sn-menu-border sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto'; -type ThemeButtonProps = { - theme: SNTheme; - application: WebApplication; - onBlur: (event: { relatedTarget: EventTarget | null }) => void; -}; - type MenuProps = { appState: AppState; application: WebApplication; }; -const ThemeButton: FunctionComponent = ({ - application, - theme, - onBlur, -}) => { - const toggleTheme = (e: any) => { - e.preventDefault(); - if (theme.isLayerable() || !theme.active) { - application.toggleComponent(theme); +const toggleFocusMode = (enabled: boolean) => { + if (enabled) { + document.body.classList.add('focus-mode'); + } else { + if (document.body.classList.contains('focus-mode')) { + document.body.classList.add('disable-focus-mode'); + document.body.classList.remove('focus-mode'); + setTimeout(() => { + document.body.classList.remove('disable-focus-mode'); + }, 315); } - }; - - return ( - - ); + } }; const QuickSettingsMenu: FunctionComponent = observer( ({ application, appState }) => { - const { closeQuickSettingsMenu, shouldAnimateCloseMenu } = - appState.quickSettingsMenu; + const { + closeQuickSettingsMenu, + shouldAnimateCloseMenu, + focusModeEnabled, + setFocusModeEnabled, + } = appState.quickSettingsMenu; const [themes, setThemes] = useState([]); const [toggleableComponents, setToggleableComponents] = useState< SNComponent[] @@ -104,6 +69,10 @@ const QuickSettingsMenu: FunctionComponent = observer( const quickSettingsMenuRef = useRef(null); const defaultThemeButtonRef = useRef(null); + useEffect(() => { + toggleFocusMode(focusModeEnabled); + }, [focusModeEnabled]); + const reloadThemes = useCallback(() => { application.streamItems(ContentType.Theme, () => { const themes = application.getDisplayableItems( @@ -157,12 +126,12 @@ const QuickSettingsMenu: FunctionComponent = observer( useEffect(() => { if (themesMenuOpen) { - defaultThemeButtonRef.current!.focus(); + defaultThemeButtonRef.current?.focus(); } }, [themesMenuOpen]); useEffect(() => { - prefsButtonRef.current!.focus(); + prefsButtonRef.current?.focus(); }, []); const [closeOnBlur] = useCloseOnBlur( @@ -171,9 +140,9 @@ const QuickSettingsMenu: FunctionComponent = observer( ); const toggleThemesMenu = () => { - if (!themesMenuOpen) { + if (!themesMenuOpen && themesButtonRef.current) { const themesButtonRect = - themesButtonRef.current!.getBoundingClientRect(); + themesButtonRef.current.getBoundingClientRect(); setThemesMenuPosition({ left: themesButtonRect.right, bottom: @@ -200,7 +169,7 @@ const QuickSettingsMenu: FunctionComponent = observer( switch (event.key) { case 'Escape': setThemesMenuOpen(false); - themesButtonRef.current!.focus(); + themesButtonRef.current?.focus(); break; case 'ArrowRight': if (!themesMenuOpen) { @@ -211,65 +180,23 @@ const QuickSettingsMenu: FunctionComponent = observer( const handleQuickSettingsKeyDown: JSXInternal.KeyboardEventHandler = (event) => { - const items: NodeListOf = - quickSettingsMenuRef.current!.querySelectorAll(':scope > button'); - const currentFocusedIndex = Array.from(items).findIndex( - (btn) => btn === document.activeElement + quickSettingsKeyDownHandler( + closeQuickSettingsMenu, + event, + quickSettingsMenuRef, + themesMenuOpen ); - - 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 + themesMenuKeyDownHandler( + event, + themesMenuRef, + setThemesMenuOpen, + themesButtonRef ); - - 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 = () => { @@ -332,7 +259,7 @@ const QuickSettingsMenu: FunctionComponent = observer( Default {themes.map((theme) => ( - = observer( ))} - {toggleableComponents.map((component) => ( = observer( ))} - +
+ ); +}; diff --git a/app/assets/javascripts/components/QuickSettingsMenu/eventHandlers.ts b/app/assets/javascripts/components/QuickSettingsMenu/eventHandlers.ts new file mode 100644 index 000000000..33c985210 --- /dev/null +++ b/app/assets/javascripts/components/QuickSettingsMenu/eventHandlers.ts @@ -0,0 +1,77 @@ +import { RefObject } from 'preact'; +import { StateUpdater } from 'preact/hooks'; +import { JSXInternal } from 'preact/src/jsx'; + +export const quickSettingsKeyDownHandler = ( + closeQuickSettingsMenu: () => void, + event: JSXInternal.TargetedKeyboardEvent, + quickSettingsMenuRef: RefObject, + themesMenuOpen: boolean +) => { + if (quickSettingsMenuRef?.current) { + 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; + } + } + } +}; + +export const themesMenuKeyDownHandler = ( + event: React.KeyboardEvent, + themesMenuRef: RefObject, + setThemesMenuOpen: StateUpdater, + themesButtonRef: RefObject +) => { + if (themesMenuRef?.current) { + 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; + } + } +}; diff --git a/app/assets/javascripts/components/Switch.tsx b/app/assets/javascripts/components/Switch.tsx index 06b5a2881..9850921ee 100644 --- a/app/assets/javascripts/components/Switch.tsx +++ b/app/assets/javascripts/components/Switch.tsx @@ -10,7 +10,8 @@ import '@reach/checkbox/styles.css'; export type SwitchProps = HTMLProps & { checked?: boolean; - onChange: (checked: boolean) => void; + // Optional in case it is wrapped in a button (e.g. a menu item) + onChange?: (checked: boolean) => void; className?: string; children?: ComponentChildren; role?: string; @@ -32,7 +33,7 @@ export const Switch: FunctionalComponent = ( checked={checked} onChange={(event) => { setChecked(event.target.checked); - props.onChange(event.target.checked); + props.onChange?.(event.target.checked); }} className={`sn-switch ${checked ? 'bg-info' : 'bg-neutral'}`} > 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 index f3571cb00..733ccc74a 100644 --- a/app/assets/javascripts/ui_models/app_state/quick_settings_state.ts +++ b/app/assets/javascripts/ui_models/app_state/quick_settings_state.ts @@ -3,14 +3,17 @@ import { action, makeObservable, observable } from 'mobx'; export class QuickSettingsState { open = false; shouldAnimateCloseMenu = false; + focusModeEnabled = false; constructor() { makeObservable(this, { open: observable, shouldAnimateCloseMenu: observable, + focusModeEnabled: observable, setOpen: action, setShouldAnimateCloseMenu: action, + setFocusModeEnabled: action, toggle: action, closeQuickSettingsMenu: action, }); @@ -24,6 +27,10 @@ export class QuickSettingsState { this.shouldAnimateCloseMenu = shouldAnimate; }; + setFocusModeEnabled = (enabled: boolean): void => { + this.focusModeEnabled = enabled; + }; + toggle = (): void => { if (this.open) { this.closeQuickSettingsMenu(); diff --git a/app/assets/stylesheets/_focused.scss b/app/assets/stylesheets/_focused.scss new file mode 100644 index 000000000..eaeafa118 --- /dev/null +++ b/app/assets/stylesheets/_focused.scss @@ -0,0 +1,88 @@ +.section.tags, +.section.notes { + transition: width 1.25s; +} + +.focus-mode { + .mac-desktop #editor-column { + // To offset colored circles in Mac + padding-top: 35px; + } + + .mac-desktop #editor-column:before { + content: ''; + display: block; + position: absolute; + top: 0; + width: 100%; + height: 38px; + -webkit-app-region: drag; + } + + #editor-title-bar { + display: none; + } + + #editor-menu-bar { + display: none; + } + + #editor-pane-component-stack { + display: none; + } + + #footer-bar { + opacity: 0.08; + transition: opacity 0.25s; + } + + #footer-bar:hover { + opacity: 1; + } + + .section.tags, + .section.notes { + will-change: opacity; + animation: fade-out 1.25s forwards; + transition-delay: 0s; + width: 0px !important; + flex: none !important; + } + + .section.tags:hover { + flex: initial; + width: 0px !important; + } + + .section.notes:hover { + flex: initial; + width: 0px !important; + } +} + +.disable-focus-mode { + .section.tags, + .section.notes { + will-change: opacity; + animation: fade-in 1.25s forwards; + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes fade-out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + border: none !important; + } +} diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index b6ec33c01..a705d5a1e 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -289,6 +289,10 @@ max-width: 18rem; } +.max-w-89 { + max-width: 22.25rem; +} + .mb-4 { margin-bottom: 1rem; } @@ -300,10 +304,6 @@ margin-bottom: 2rem; } -.max-w-89 { - max-width: 22.25rem; -} - .w-26 { width: 6.5rem; } @@ -461,6 +461,11 @@ padding-right: 0; } +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + .sn-component .px-4\.5, .sn-component .sk-panel .px-4\.5 { padding-left: 1.375rem; diff --git a/app/assets/stylesheets/index.css.scss b/app/assets/stylesheets/index.css.scss index ff9cbf500..330d1cb19 100644 --- a/app/assets/stylesheets/index.css.scss +++ b/app/assets/stylesheets/index.css.scss @@ -13,4 +13,5 @@ @import 'reach-sub'; @import 'sessions-modal'; @import 'preferences'; +@import 'focused'; @import 'sn'; diff --git a/app/assets/svg/il-premium.svg b/app/assets/svg/il-premium.svg new file mode 100644 index 000000000..c4c91ff1b --- /dev/null +++ b/app/assets/svg/il-premium.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index edfaae450..edaf490f7 100644 --- a/package.json +++ b/package.json @@ -78,9 +78,9 @@ "@reach/checkbox": "^0.16.0", "@reach/dialog": "^0.16.2", "@reach/listbox": "^0.16.2", - "@standardnotes/features": "1.9.0", + "@standardnotes/features": "1.10.1", "@standardnotes/sncrypto-web": "1.5.3", - "@standardnotes/snjs": "2.18.2", + "@standardnotes/snjs": "2.18.3", "mobx": "^6.3.5", "mobx-react-lite": "^3.2.2", "preact": "^10.5.15", diff --git a/yarn.lock b/yarn.lock index 070e73909..14d82273e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2587,10 +2587,10 @@ dependencies: "@standardnotes/auth" "^3.8.1" -"@standardnotes/features@1.9.0", "@standardnotes/features@^1.8.3": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.9.0.tgz#6b563dbc177592a6c0741abc7794b0cd6f1989e0" - integrity sha512-fuRfLrnKEq43ti7FpYZhZu9SLk6j8ruuLcDygoS8gDKB3LU3yuAbKfumqnt0NDF/hXg5/y5FhxxjYQX2GxykoA== +"@standardnotes/features@1.10.1", "@standardnotes/features@^1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.10.1.tgz#5b5a59d2d69751ca040a270d04a08662b1591bfc" + integrity sha512-fN7WyR8jeaAsDWkm4a6SSz3JZeaNfL+CkmWAYqxRI5XoXZlWy+kBVaYWaGe7vI4T6ncwdhxRTjE7mirG4PEQ6g== dependencies: "@standardnotes/auth" "3.8.3" "@standardnotes/common" "^1.2.1" @@ -2614,15 +2614,15 @@ buffer "^6.0.3" libsodium-wrappers "^0.7.9" -"@standardnotes/snjs@2.18.2": - version "2.18.2" - resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.18.2.tgz#42957cf50b18e2db3f10dacb2c3dfa95c16719e7" - integrity sha512-KcYRwxJJmA+b9E0xXJGDsdCC3ptNaDLiGDpB8Lv4lUpjH6Z1VC43oxhFDXVxLLrQxTpQULvhlkW8Mc38Kxtzxg== +"@standardnotes/snjs@2.18.3": + version "2.18.3" + resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.18.3.tgz#5dd685c6e0df4c07c98b087aa672411e9becde4c" + integrity sha512-Q8X1UUSUw4NkKcZPHFYZ/WH9weOXW7LelOE1kCBx/O1g284dJWvHhqQh8qTgjHQnrcYR8Xh3AfoR0bh1Ms+iyA== dependencies: "@standardnotes/auth" "^3.8.1" "@standardnotes/common" "^1.2.1" "@standardnotes/domain-events" "^2.5.1" - "@standardnotes/features" "^1.8.3" + "@standardnotes/features" "^1.10.1" "@standardnotes/settings" "^1.2.1" "@standardnotes/sncrypto-common" "^1.5.2"