feat: Added command palette for quick actions and switching between items (#2933) [skip e2e]

* wip: command palette

* use code instead of key

* show recent items above commands

* refactor

* fix command

* add placeholder

* Tab/Shift-Tab to switch tabs

* Fix test

* Add menu item to general account menu

* if shortcut_id is available, use that as the id

* make toggle fn more stable

* small naming changes

* fix name

* Close open modals and popovers when opening command palette

* use stable ids + make sure selectedNotesCount only changes when the count actually changes

* display all commands, even ones in recents list
This commit is contained in:
Aman Harwara
2025-09-25 18:36:09 +05:30
committed by GitHub
parent cb92c10625
commit efba7c682d
61 changed files with 1381 additions and 522 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -128,6 +128,8 @@ export interface ItemManagerInterface extends AbstractService {
getDisplayableFiles(): FileItem[] getDisplayableFiles(): FileItem[]
setVaultDisplayOptions(options: VaultDisplayOptions): void setVaultDisplayOptions(options: VaultDisplayOptions): void
numberOfNotesWithConflicts(): number numberOfNotesWithConflicts(): number
/** Returns all notes, files, tags and views */
getInteractableItems(): DecryptedItemInterface[]
getDisplayableNotes(): SNNote[] getDisplayableNotes(): SNNote[]
getDisplayableNotesAndFiles(): (SNNote | FileItem)[] getDisplayableNotesAndFiles(): (SNNote | FileItem)[]
setPrimaryItemDisplayOptions(options: NotesAndFilesDisplayControllerOptions): void setPrimaryItemDisplayOptions(options: NotesAndFilesDisplayControllerOptions): void

View File

@@ -220,6 +220,17 @@ export class ItemManager extends Services.AbstractService implements Services.It
this.itemCounter.setVaultDisplayOptions(options) this.itemCounter.setVaultDisplayOptions(options)
} }
public getInteractableItems(): Models.DecryptedItemInterface[] {
return (this.systemSmartViews as Models.DecryptedItemInterface[]).concat(
this.collection.all([
ContentType.TYPES.Note,
ContentType.TYPES.File,
ContentType.TYPES.Tag,
ContentType.TYPES.SmartView,
]) as Models.DecryptedItemInterface[],
)
}
public getDisplayableNotes(): Models.SNNote[] { public getDisplayableNotes(): Models.SNNote[] {
assert(this.navigationDisplayController.contentTypes.length === 2) assert(this.navigationDisplayController.contentTypes.length === 2)

View File

@@ -41,3 +41,4 @@ export const OPEN_PREFERENCES_COMMAND = createKeyboardCommand('OPEN_PREFERENCES_
export const CHANGE_EDITOR_WIDTH_COMMAND = createKeyboardCommand('CHANGE_EDITOR_WIDTH_COMMAND') export const CHANGE_EDITOR_WIDTH_COMMAND = createKeyboardCommand('CHANGE_EDITOR_WIDTH_COMMAND')
export const TOGGLE_KEYBOARD_SHORTCUTS_MODAL = createKeyboardCommand('TOGGLE_KEYBOARD_SHORTCUTS_MODAL') export const TOGGLE_KEYBOARD_SHORTCUTS_MODAL = createKeyboardCommand('TOGGLE_KEYBOARD_SHORTCUTS_MODAL')
export const TOGGLE_COMMAND_PALETTE = createKeyboardCommand('TOGGLE_COMMAND_PALETTE')

View File

@@ -1,4 +1,4 @@
import { Environment, Platform } from '@standardnotes/snjs' import { Environment, Platform, UuidGenerator } from '@standardnotes/snjs'
import { eventMatchesKeyAndModifiers } from './eventMatchesKeyAndModifiers' import { eventMatchesKeyAndModifiers } from './eventMatchesKeyAndModifiers'
import { KeyboardCommand } from './KeyboardCommands' import { KeyboardCommand } from './KeyboardCommands'
import { KeyboardKeyEvent } from './KeyboardKeyEvent' import { KeyboardKeyEvent } from './KeyboardKeyEvent'
@@ -28,6 +28,20 @@ export class KeyboardService {
} }
} }
private isDisabled = false
/**
* When called, the service will stop triggering command handlers
* on keydown/keyup events. Useful when you need to handle events
* yourself while keeping the rest of behaviours inert.
* Make sure to call {@link enableEventHandling} once done.
*/
public disableEventHandling() {
this.isDisabled = true
}
public enableEventHandling() {
this.isDisabled = false
}
get isMac() { get isMac() {
return this.platform === Platform.MacDesktop || this.platform === Platform.MacWeb return this.platform === Platform.MacDesktop || this.platform === Platform.MacWeb
} }
@@ -116,6 +130,9 @@ export class KeyboardService {
} }
private handleKeyboardEvent(event: KeyboardEvent, keyEvent: KeyboardKeyEvent): void { private handleKeyboardEvent(event: KeyboardEvent, keyEvent: KeyboardKeyEvent): void {
if (this.isDisabled) {
return
}
for (const command of this.commandMap.keys()) { for (const command of this.commandMap.keys()) {
const shortcut = this.commandMap.get(command) const shortcut = this.commandMap.get(command)
if (!shortcut) { if (!shortcut) {
@@ -243,6 +260,7 @@ export class KeyboardService {
...shortcut, ...shortcut,
category: handler.category, category: handler.category,
description: handler.description, description: handler.description,
id: UuidGenerator.GenerateUuid(),
} }
} }
@@ -250,11 +268,12 @@ export class KeyboardService {
* Register help item for a keyboard shortcut that is handled outside of the KeyboardService, * Register help item for a keyboard shortcut that is handled outside of the KeyboardService,
* for example by a library like Lexical. * for example by a library like Lexical.
*/ */
registerExternalKeyboardShortcutHelpItem(item: KeyboardShortcutHelpItem): () => void { registerExternalKeyboardShortcutHelpItem(item: Omit<KeyboardShortcutHelpItem, 'id'>): () => void {
this.keyboardShortcutHelpItems.add(item) const itemWithId = { ...item, id: UuidGenerator.GenerateUuid() }
this.keyboardShortcutHelpItems.add(itemWithId)
return () => { return () => {
this.keyboardShortcutHelpItems.delete(item) this.keyboardShortcutHelpItems.delete(itemWithId)
} }
} }
@@ -262,7 +281,7 @@ export class KeyboardService {
* Register help item for a keyboard shortcut that is handled outside of the KeyboardService, * Register help item for a keyboard shortcut that is handled outside of the KeyboardService,
* for example by a library like Lexical. * for example by a library like Lexical.
*/ */
registerExternalKeyboardShortcutHelpItems(items: KeyboardShortcutHelpItem[]): () => void { registerExternalKeyboardShortcutHelpItems(items: Omit<KeyboardShortcutHelpItem, 'id'>[]): () => void {
const disposers = items.map((item) => this.registerExternalKeyboardShortcutHelpItem(item)) const disposers = items.map((item) => this.registerExternalKeyboardShortcutHelpItem(item))
return () => { return () => {

View File

@@ -21,8 +21,9 @@ export type PlatformedKeyboardShortcut = KeyboardShortcut & {
export type KeyboardShortcutCategory = 'General' | 'Notes list' | 'Current note' | 'Super notes' | 'Formatting' export type KeyboardShortcutCategory = 'General' | 'Notes list' | 'Current note' | 'Super notes' | 'Formatting'
export type KeyboardShortcutHelpItem = Omit<PlatformedKeyboardShortcut, 'command'> & { export interface KeyboardShortcutHelpItem extends Omit<PlatformedKeyboardShortcut, 'command'> {
command?: KeyboardCommand command?: KeyboardCommand
category: KeyboardShortcutCategory category: KeyboardShortcutCategory
description: string description: string
id: string
} }

View File

@@ -31,6 +31,7 @@ import {
CHANGE_EDITOR_WIDTH_COMMAND, CHANGE_EDITOR_WIDTH_COMMAND,
SUPER_TOGGLE_TOOLBAR, SUPER_TOGGLE_TOOLBAR,
TOGGLE_KEYBOARD_SHORTCUTS_MODAL, TOGGLE_KEYBOARD_SHORTCUTS_MODAL,
TOGGLE_COMMAND_PALETTE,
} from './KeyboardCommands' } from './KeyboardCommands'
import { KeyboardKey } from './KeyboardKey' import { KeyboardKey } from './KeyboardKey'
import { KeyboardModifier, getPrimaryModifier } from './KeyboardModifier' import { KeyboardModifier, getPrimaryModifier } from './KeyboardModifier'
@@ -108,7 +109,7 @@ export function getKeyboardShortcuts(platform: Platform, _environment: Environme
}, },
{ {
command: CHANGE_EDITOR_COMMAND, command: CHANGE_EDITOR_COMMAND,
key: '/', key: '?',
modifiers: [primaryModifier, KeyboardModifier.Shift], modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true, preventDefault: true,
}, },
@@ -200,5 +201,10 @@ export function getKeyboardShortcuts(platform: Platform, _environment: Environme
key: '/', key: '/',
modifiers: [primaryModifier], modifiers: [primaryModifier],
}, },
{
command: TOGGLE_COMMAND_PALETTE,
code: 'Semicolon',
modifiers: [primaryModifier, KeyboardModifier.Shift],
},
] ]
} }

View File

@@ -1,4 +1,4 @@
export function keyboardCharacterForKeyOrCode(keyOrCode: string) { export function keyboardCharacterForKeyOrCode(keyOrCode: string, shiftKey = false) {
if (keyOrCode.startsWith('Digit')) { if (keyOrCode.startsWith('Digit')) {
return keyOrCode.replace('Digit', '') return keyOrCode.replace('Digit', '')
} }
@@ -14,6 +14,8 @@ export function keyboardCharacterForKeyOrCode(keyOrCode: string) {
return '←' return '←'
case 'ArrowRight': case 'ArrowRight':
return '→' return '→'
case 'Semicolon':
return shiftKey ? ':' : ';'
default: default:
return keyOrCode return keyOrCode
} }

View File

@@ -107,7 +107,7 @@
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write" "app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
}, },
"dependencies": { "dependencies": {
"@ariakit/react": "^0.3.9", "@ariakit/react": "^0.4.18",
"@lexical/clipboard": "0.32.1", "@lexical/clipboard": "0.32.1",
"@lexical/headless": "0.32.1", "@lexical/headless": "0.32.1",
"@lexical/link": "0.32.1", "@lexical/link": "0.32.1",

View File

@@ -9,6 +9,7 @@ export const Web_TYPES = {
Importer: Symbol.for('Importer'), Importer: Symbol.for('Importer'),
ItemGroupController: Symbol.for('ItemGroupController'), ItemGroupController: Symbol.for('ItemGroupController'),
KeyboardService: Symbol.for('KeyboardService'), KeyboardService: Symbol.for('KeyboardService'),
CommandService: Symbol.for('CommandService'),
MobileWebReceiver: Symbol.for('MobileWebReceiver'), MobileWebReceiver: Symbol.for('MobileWebReceiver'),
MomentsService: Symbol.for('MomentsService'), MomentsService: Symbol.for('MomentsService'),
PersistenceService: Symbol.for('PersistenceService'), PersistenceService: Symbol.for('PersistenceService'),

View File

@@ -14,7 +14,6 @@ import {
ThemeManager, ThemeManager,
ToastService, ToastService,
VaultDisplayService, VaultDisplayService,
WebApplicationInterface,
} from '@standardnotes/ui-services' } from '@standardnotes/ui-services'
import { DependencyContainer } from '@standardnotes/utils' import { DependencyContainer } from '@standardnotes/utils'
import { Web_TYPES } from './Types' import { Web_TYPES } from './Types'
@@ -50,9 +49,11 @@ import { LoadPurchaseFlowUrl } from '../UseCase/LoadPurchaseFlowUrl'
import { GetPurchaseFlowUrl } from '../UseCase/GetPurchaseFlowUrl' import { GetPurchaseFlowUrl } from '../UseCase/GetPurchaseFlowUrl'
import { OpenSubscriptionDashboard } from '../UseCase/OpenSubscriptionDashboard' import { OpenSubscriptionDashboard } from '../UseCase/OpenSubscriptionDashboard'
import { HeadlessSuperConverter } from '@/Components/SuperEditor/Tools/HeadlessSuperConverter' import { HeadlessSuperConverter } from '@/Components/SuperEditor/Tools/HeadlessSuperConverter'
import { WebApplication } from '../WebApplication'
import { CommandService } from '../../Components/CommandPalette/CommandService'
export class WebDependencies extends DependencyContainer { export class WebDependencies extends DependencyContainer {
constructor(private application: WebApplicationInterface) { constructor(private application: WebApplication) {
super() super()
this.bind(Web_TYPES.SuperConverter, () => { this.bind(Web_TYPES.SuperConverter, () => {
@@ -124,6 +125,9 @@ export class WebDependencies extends DependencyContainer {
this.bind(Web_TYPES.KeyboardService, () => { this.bind(Web_TYPES.KeyboardService, () => {
return new KeyboardService(application.platform, application.environment) return new KeyboardService(application.platform, application.environment)
}) })
this.bind(Web_TYPES.CommandService, () => {
return new CommandService(this.get<KeyboardService>(Web_TYPES.KeyboardService), application.generateUuid)
})
this.bind(Web_TYPES.ArchiveManager, () => { this.bind(Web_TYPES.ArchiveManager, () => {
return new ArchiveManager(application) return new ArchiveManager(application)
@@ -199,6 +203,7 @@ export class WebDependencies extends DependencyContainer {
return new PaneController( return new PaneController(
application.preferences, application.preferences,
this.get<KeyboardService>(Web_TYPES.KeyboardService), this.get<KeyboardService>(Web_TYPES.KeyboardService),
application.commands,
this.get<IsTabletOrMobileScreen>(Web_TYPES.IsTabletOrMobileScreen), this.get<IsTabletOrMobileScreen>(Web_TYPES.IsTabletOrMobileScreen),
this.get<PanesForLayout>(Web_TYPES.PanesForLayout), this.get<PanesForLayout>(Web_TYPES.PanesForLayout),
application.events, application.events,
@@ -233,7 +238,7 @@ export class WebDependencies extends DependencyContainer {
return new NavigationController( return new NavigationController(
this.get<FeaturesController>(Web_TYPES.FeaturesController), this.get<FeaturesController>(Web_TYPES.FeaturesController),
this.get<VaultDisplayService>(Web_TYPES.VaultDisplayService), this.get<VaultDisplayService>(Web_TYPES.VaultDisplayService),
this.get<KeyboardService>(Web_TYPES.KeyboardService), this.get<CommandService>(Web_TYPES.CommandService),
this.get<PaneController>(Web_TYPES.PaneController), this.get<PaneController>(Web_TYPES.PaneController),
application.sync, application.sync,
application.mutator, application.mutator,
@@ -241,25 +246,16 @@ export class WebDependencies extends DependencyContainer {
application.preferences, application.preferences,
application.alerts, application.alerts,
application.changeAndSaveItem, application.changeAndSaveItem,
application.recents,
application.events, application.events,
) )
}) })
this.bind(Web_TYPES.NotesController, () => { this.bind(Web_TYPES.NotesController, () => {
return new NotesController( return new NotesController(
this.get<ItemListController>(Web_TYPES.ItemListController), application,
this.get<NavigationController>(Web_TYPES.NavigationController),
this.get<ItemGroupController>(Web_TYPES.ItemGroupController),
this.get<KeyboardService>(Web_TYPES.KeyboardService),
application.preferences,
application.items,
application.mutator,
application.sync,
application.protections,
application.alerts,
this.get<IsGlobalSpellcheckEnabled>(Web_TYPES.IsGlobalSpellcheckEnabled), this.get<IsGlobalSpellcheckEnabled>(Web_TYPES.IsGlobalSpellcheckEnabled),
this.get<GetItemTags>(Web_TYPES.GetItemTags), this.get<GetItemTags>(Web_TYPES.GetItemTags),
application.events,
) )
}) })
@@ -304,6 +300,7 @@ export class WebDependencies extends DependencyContainer {
application.options, application.options,
this.get<IsNativeMobileWeb>(Web_TYPES.IsNativeMobileWeb), this.get<IsNativeMobileWeb>(Web_TYPES.IsNativeMobileWeb),
application.changeAndSaveItem, application.changeAndSaveItem,
application.recents,
application.events, application.events,
) )
}) })
@@ -374,6 +371,7 @@ export class WebDependencies extends DependencyContainer {
application.platform, application.platform,
application.mobileDevice, application.mobileDevice,
this.get<IsNativeMobileWeb>(Web_TYPES.IsNativeMobileWeb), this.get<IsNativeMobileWeb>(Web_TYPES.IsNativeMobileWeb),
application.recents,
application.events, application.events,
) )
}) })
@@ -381,7 +379,7 @@ export class WebDependencies extends DependencyContainer {
this.bind(Web_TYPES.HistoryModalController, () => { this.bind(Web_TYPES.HistoryModalController, () => {
return new HistoryModalController( return new HistoryModalController(
this.get<NotesController>(Web_TYPES.NotesController), this.get<NotesController>(Web_TYPES.NotesController),
this.get<KeyboardService>(Web_TYPES.KeyboardService), this.get<CommandService>(Web_TYPES.CommandService),
application.events, application.events,
) )
}) })

View File

@@ -0,0 +1,33 @@
const MaxCommands = 5
const MaxItems = 10
export class RecentActionsState {
#commandUuids: string[] = []
#itemUuids: string[] = []
/**
* Recently used commands, most recent at the start
*/
get commandUuids() {
return this.#commandUuids
}
/**
* Recently opened items, most recent at the start
*/
get itemUuids() {
return this.#itemUuids
}
add(id: string, action_type: 'item' | 'command' = 'item') {
const action_array = action_type === 'item' ? this.#itemUuids : this.#commandUuids
const existing = action_array.findIndex((uuid) => uuid === id)
if (existing !== -1) {
action_array.splice(existing, 1)
}
const max = action_type === 'item' ? MaxItems : MaxCommands
if (action_array.length == max) {
action_array.pop()
}
action_array.unshift(id)
}
}

View File

@@ -2,6 +2,19 @@ import { Environment, namespacedKey, Platform, RawStorageKey, SNLog } from '@sta
import { WebApplication } from '@/Application/WebApplication' import { WebApplication } from '@/Application/WebApplication'
import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice' import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice'
jest.mock('@standardnotes/sncrypto-web', () => {
return {
SNWebCrypto: class {
initialize() {
return Promise.resolve()
}
generateUUID() {
return 'mock-uuid'
}
},
}
})
describe('web application', () => { describe('web application', () => {
let application: WebApplication let application: WebApplication

View File

@@ -80,6 +80,8 @@ import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { PersistenceService } from '@/Controllers/Abstract/PersistenceService' import { PersistenceService } from '@/Controllers/Abstract/PersistenceService'
import { removeFromArray } from '@standardnotes/utils' import { removeFromArray } from '@standardnotes/utils'
import { FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction' import { FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
import { RecentActionsState } from './Recents'
import { CommandService } from '../Components/CommandPalette/CommandService'
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
@@ -95,6 +97,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
public isSessionsModalVisible = false public isSessionsModalVisible = false
public devMode?: DevMode public devMode?: DevMode
public recents = new RecentActionsState()
constructor( constructor(
deviceInterface: WebOrDesktopDevice, deviceInterface: WebOrDesktopDevice,
@@ -597,6 +600,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return this.deps.get<KeyboardService>(Web_TYPES.KeyboardService) return this.deps.get<KeyboardService>(Web_TYPES.KeyboardService)
} }
get commands(): CommandService {
return this.deps.get<CommandService>(Web_TYPES.CommandService)
}
get featuresController(): FeaturesController { get featuresController(): FeaturesController {
return this.deps.get<FeaturesController>(Web_TYPES.FeaturesController) return this.deps.get<FeaturesController>(Web_TYPES.FeaturesController)
} }

View File

@@ -13,7 +13,7 @@ import Spinner from '@/Components/Spinner/Spinner'
import { MenuItemIconSize } from '@/Constants/TailwindClassNames' import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
import MenuSection from '../Menu/MenuSection' import MenuSection from '../Menu/MenuSection'
import { TOGGLE_KEYBOARD_SHORTCUTS_MODAL, isMobilePlatform } from '@standardnotes/ui-services' import { TOGGLE_COMMAND_PALETTE, TOGGLE_KEYBOARD_SHORTCUTS_MODAL, isMobilePlatform } from '@standardnotes/ui-services'
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator' import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
type Props = { type Props = {
@@ -95,6 +95,9 @@ const GeneralAccountMenu: FunctionComponent<Props> = ({ setMenuPane, closeMenu,
const keyboardShortcutsHelpShortcut = useMemo(() => { const keyboardShortcutsHelpShortcut = useMemo(() => {
return application.keyboardService.keyboardShortcutForCommand(TOGGLE_KEYBOARD_SHORTCUTS_MODAL) return application.keyboardService.keyboardShortcutForCommand(TOGGLE_KEYBOARD_SHORTCUTS_MODAL)
}, [application.keyboardService]) }, [application.keyboardService])
const commandPaletteShortcut = useMemo(() => {
return application.keyboardService.keyboardShortcutForCommand(TOGGLE_COMMAND_PALETTE)
}, [application.keyboardService])
return ( return (
<> <>
@@ -194,17 +197,30 @@ const GeneralAccountMenu: FunctionComponent<Props> = ({ setMenuPane, closeMenu,
<span className="text-neutral">v{application.version}</span> <span className="text-neutral">v{application.version}</span>
</MenuItem> </MenuItem>
{!isMobilePlatform(application.platform) && ( {!isMobilePlatform(application.platform) && (
<MenuItem <>
onClick={() => { <MenuItem
application.keyboardService.triggerCommand(TOGGLE_KEYBOARD_SHORTCUTS_MODAL) onClick={() => {
}} application.keyboardService.triggerCommand(TOGGLE_KEYBOARD_SHORTCUTS_MODAL)
> }}
<Icon type="keyboard" className={iconClassName} /> >
Keyboard shortcuts <Icon type="keyboard" className={iconClassName} />
{keyboardShortcutsHelpShortcut && ( Keyboard shortcuts
<KeyboardShortcutIndicator shortcut={keyboardShortcutsHelpShortcut} className="ml-auto" /> {keyboardShortcutsHelpShortcut && (
)} <KeyboardShortcutIndicator shortcut={keyboardShortcutsHelpShortcut} className="ml-auto" />
</MenuItem> )}
</MenuItem>
<MenuItem
onClick={() => {
application.keyboardService.triggerCommand(TOGGLE_COMMAND_PALETTE)
}}
>
<Icon type="info" className={iconClassName} />
Command palette
{commandPaletteShortcut && (
<KeyboardShortcutIndicator shortcut={commandPaletteShortcut} className="ml-auto" />
)}
</MenuItem>
</>
)} )}
</MenuSection> </MenuSection>
{user ? ( {user ? (

View File

@@ -23,7 +23,7 @@ import ResponsivePaneProvider from '../Panes/ResponsivePaneProvider'
import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler' import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler'
import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal' import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal'
import ApplicationProvider from '../ApplicationProvider' import ApplicationProvider from '../ApplicationProvider'
import CommandProvider from '../CommandProvider' import KeyboardServiceProvider from '../KeyboardServiceProvider'
import PanesSystemComponent from '../Panes/PanesSystemComponent' import PanesSystemComponent from '../Panes/PanesSystemComponent'
import DotOrgNotice from './DotOrgNotice' import DotOrgNotice from './DotOrgNotice'
import LinkingControllerProvider from '@/Controllers/LinkingControllerProvider' import LinkingControllerProvider from '@/Controllers/LinkingControllerProvider'
@@ -32,6 +32,8 @@ import IosKeyboardClose from '../IosKeyboardClose/IosKeyboardClose'
import EditorWidthSelectionModalWrapper from '../EditorWidthSelectionModal/EditorWidthSelectionModal' import EditorWidthSelectionModalWrapper from '../EditorWidthSelectionModal/EditorWidthSelectionModal'
import { ProtectionEvent } from '@standardnotes/services' import { ProtectionEvent } from '@standardnotes/services'
import KeyboardShortcutsModal from '../KeyboardShortcutsHelpModal/KeyboardShortcutsHelpModal' import KeyboardShortcutsModal from '../KeyboardShortcutsHelpModal/KeyboardShortcutsHelpModal'
import CommandPalette from '../CommandPalette/CommandPalette'
import SuperExportModal from '../NotesOptions/SuperExportModal'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -212,7 +214,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
if (route.type === RouteType.AppViewRoute && route.appViewRouteParam === 'extension') { if (route.type === RouteType.AppViewRoute && route.appViewRouteParam === 'extension') {
return ( return (
<ApplicationProvider application={application}> <ApplicationProvider application={application}>
<CommandProvider service={application.keyboardService}> <KeyboardServiceProvider service={application.keyboardService}>
<AndroidBackHandlerProvider application={application}> <AndroidBackHandlerProvider application={application}>
<ResponsivePaneProvider paneController={application.paneController}> <ResponsivePaneProvider paneController={application.paneController}>
<PremiumModalProvider application={application}> <PremiumModalProvider application={application}>
@@ -227,14 +229,14 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
</PremiumModalProvider> </PremiumModalProvider>
</ResponsivePaneProvider> </ResponsivePaneProvider>
</AndroidBackHandlerProvider> </AndroidBackHandlerProvider>
</CommandProvider> </KeyboardServiceProvider>
</ApplicationProvider> </ApplicationProvider>
) )
} }
return ( return (
<ApplicationProvider application={application}> <ApplicationProvider application={application}>
<CommandProvider service={application.keyboardService}> <KeyboardServiceProvider service={application.keyboardService}>
<AndroidBackHandlerProvider application={application}> <AndroidBackHandlerProvider application={application}>
<ResponsivePaneProvider paneController={application.paneController}> <ResponsivePaneProvider paneController={application.paneController}>
<PremiumModalProvider application={application}> <PremiumModalProvider application={application}>
@@ -269,6 +271,8 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
<ConfirmDeleteAccountContainer application={application} /> <ConfirmDeleteAccountContainer application={application} />
<ImportModal importModalController={application.importModalController} /> <ImportModal importModalController={application.importModalController} />
<KeyboardShortcutsModal keyboardService={application.keyboardService} /> <KeyboardShortcutsModal keyboardService={application.keyboardService} />
<SuperExportModal />
<CommandPalette />
</> </>
{application.routeService.isDotOrg && <DotOrgNotice />} {application.routeService.isDotOrg && <DotOrgNotice />}
{isIOS() && <IosKeyboardClose />} {isIOS() && <IosKeyboardClose />}
@@ -277,7 +281,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
</PremiumModalProvider> </PremiumModalProvider>
</ResponsivePaneProvider> </ResponsivePaneProvider>
</AndroidBackHandlerProvider> </AndroidBackHandlerProvider>
</CommandProvider> </KeyboardServiceProvider>
</ApplicationProvider> </ApplicationProvider>
) )
} }

View File

@@ -55,14 +55,15 @@ const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onCl
}, [isOpen, onClickPreprocessing, onClick]) }, [isOpen, onClickPreprocessing, onClick])
useEffect(() => { useEffect(() => {
return application.keyboardService.addCommandHandler({ return application.commands.addWithShortcut(
command: CHANGE_EDITOR_COMMAND, CHANGE_EDITOR_COMMAND,
category: 'Current note', 'Current note',
description: 'Change note type', 'Change note type',
onKeyDown: () => { () => {
void toggleMenu() void toggleMenu()
}, },
}) 'notes',
)
}, [application, toggleMenu]) }, [application, toggleMenu])
const shortcut = useMemo( const shortcut = useMemo(

View File

@@ -0,0 +1,425 @@
import { observer } from 'mobx-react-lite'
import { startTransition, useCallback, useEffect, useState } from 'react'
import { useKeyboardService } from '../KeyboardServiceProvider'
import { PlatformedKeyboardShortcut, TOGGLE_COMMAND_PALETTE } from '@standardnotes/ui-services'
import {
Combobox,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
ComboboxList,
ComboboxProvider,
Dialog,
Tab,
TabList,
TabPanel,
TabProvider,
useDialogStore,
useTabContext,
} from '@ariakit/react'
import {
classNames,
DecryptedItemInterface,
FileItem,
SmartView,
SNNote,
SNTag,
UuidGenerator,
} from '@standardnotes/snjs'
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import { getIconForItem } from '../../Utils/Items/Icons/getIconForItem'
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import type { CommandService } from './CommandService'
import { requestCloseAllOpenModalsAndPopovers } from '../../Utils/CloseOpenModalsAndPopovers'
type CommandPaletteItem = {
id: string
description: string
icon: JSX.Element
shortcut?: PlatformedKeyboardShortcut
resultRange?: [number, number]
} & ({ section: 'notes' | 'files' | 'tags'; itemUuid: string } | { section: 'commands' })
function ListItemDescription({ item }: { item: CommandPaletteItem }) {
const range = item.resultRange
if (!range) {
return item.description
}
return (
<>
{item.description.slice(0, range[0])}
<span className="rounded-sm bg-[color-mix(in_srgb,var(--sn-stylekit-accessory-tint-color-1),rgba(255,255,255,.1))] p-px">
{item.description.slice(range[0], range[1])}
</span>
{item.description.slice(range[1])}
</>
)
}
const Tabs = ['all', 'commands', 'notes', 'files', 'tags'] as const
type TabId = (typeof Tabs)[number]
function CommandPaletteListItem({
id,
item,
index,
handleClick,
selectedTab,
}: {
id: string
item: CommandPaletteItem
index: number
handleClick: (item: CommandPaletteItem) => void
selectedTab: TabId
}) {
if (selectedTab !== 'all' && selectedTab !== item.section) {
return null
}
return (
<ComboboxItem
id={id}
value={item.id}
hideOnClick={true}
focusOnHover={true}
blurOnHoverEnd={false}
className={classNames(
'flex scroll-m-2 items-center gap-2 whitespace-nowrap rounded-md px-2 py-2.5 text-[0.95rem] data-[active-item]:bg-info data-[active-item]:text-info-contrast [&>svg]:flex-shrink-0',
index === 0 && 'scroll-m-8',
)}
onClick={() => handleClick(item)}
>
{item.icon}
<div className="mr-auto overflow-hidden text-ellipsis whitespace-nowrap leading-none">
<ListItemDescription item={item} />
</div>
{item.shortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={item.shortcut} small={false} />}
</ComboboxItem>
)
}
function ComboboxInput() {
const tab = useTabContext()
return (
<Combobox
autoSelect="always"
className="h-10 w-full appearance-none bg-transparent px-1 text-base focus:shadow-none focus:outline-none"
placeholder="Search notes, files, commands, etc..."
onKeyDown={(event) => {
if (event.key !== 'Tab') {
return
}
const activeId = tab?.getState().selectedId
const options = { activeId }
const nextId = event.shiftKey ? tab?.previous(options) : tab?.next(options)
if (nextId) {
event.preventDefault()
tab?.select(nextId)
}
}}
/>
)
}
// Future TODO, nice to have: A way to expose items like the current note's options
// directly in the command palette rather than only having a way to open the menu
function CommandPalette() {
const application = useApplication()
const keyboardService = useKeyboardService()
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [recents, setRecents] = useState<CommandPaletteItem[]>([])
const [items, setItems] = useState<CommandPaletteItem[]>([])
// Storing counts as separate state to avoid iterating items multiple times
const [itemCountsPerTab, setItemCounts] = useState({
commands: 0,
notes: 0,
files: 0,
tags: 0,
})
const [selectedTab, setSelectedTab] = useState<TabId>('all')
const dialog = useDialogStore({
open: isOpen,
setOpen: setIsOpen,
})
useEffect(() => {
if (isOpen) {
keyboardService.disableEventHandling()
requestCloseAllOpenModalsAndPopovers()
} else {
keyboardService.enableEventHandling()
}
}, [keyboardService, isOpen])
useEffect(() => {
return keyboardService.addCommandHandler({
command: TOGGLE_COMMAND_PALETTE,
category: 'General',
description: 'Toggle command palette',
onKeyDown: (e) => {
e.preventDefault()
setIsOpen((open) => !open)
setQuery('')
},
})
}, [keyboardService])
const handleItemClick = useCallback(
(item: CommandPaletteItem) => {
if (item.section === 'commands') {
application.commands.triggerCommand(item.id)
application.recents.add(item.id, 'command')
} else {
const decryptedItem = application.items.findItem<DecryptedItemInterface>(item.itemUuid)
if (!decryptedItem) {
return
}
if (decryptedItem instanceof SNNote) {
void application.itemListController.selectItemUsingInstance(decryptedItem, true)
} else if (decryptedItem instanceof FileItem) {
void application.filesController.handleFileAction({
type: FileItemActionType.PreviewFile,
payload: { file: decryptedItem },
})
} else if (decryptedItem instanceof SNTag || decryptedItem instanceof SmartView) {
void application.navigationController.setSelectedTag(decryptedItem, 'all', {
userTriggered: true,
})
}
}
},
[
application.commands,
application.filesController,
application.itemListController,
application.items,
application.navigationController,
application.recents,
],
)
const createItemForInteractableItem = useCallback(
(item: DecryptedItemInterface): CommandPaletteItem => {
const icon = getIconForItem(item, application)
let section: 'notes' | 'files' | 'tags'
if (item instanceof SNNote) {
section = 'notes'
} else if (item instanceof FileItem) {
section = 'files'
} else if (item instanceof SNTag || item instanceof SmartView) {
section = 'tags'
} else {
throw new Error('Item is not a note, file or tag')
}
return {
section,
id: UuidGenerator.GenerateUuid(),
itemUuid: item.uuid,
description: item.title || '<no title>',
icon: <Icon type={icon[0]} className={item instanceof SNNote ? icon[1] : ''} />,
}
},
[application],
)
const createItemForCommand = useCallback(
(command: ReturnType<CommandService['getCommandDescriptions']>[0]): CommandPaletteItem => {
const shortcut = command.shortcut_id
? application.keyboardService.keyboardShortcutForCommand(command.shortcut_id)
: undefined
return {
id: command.id,
description: command.description,
section: 'commands',
icon: <Icon type={command.icon} />,
shortcut,
}
},
[application.keyboardService],
)
useEffect(
function updateCommandPaletteItems() {
if (!isOpen) {
setSelectedTab('all')
setItems([])
return
}
const recents: CommandPaletteItem[] = []
const items: CommandPaletteItem[] = []
const itemCounts: typeof itemCountsPerTab = {
commands: 0,
notes: 0,
files: 0,
tags: 0,
}
const searchQuery = query.toLowerCase()
const hasQuery = searchQuery.length > 0
if (hasQuery) {
const commands = application.commands.getCommandDescriptions()
for (let i = 0; i < commands.length; i++) {
const command = commands[i]
if (!command) {
continue
}
if (items.length >= 50) {
break
}
const index = command.description.toLowerCase().indexOf(searchQuery)
if (index === -1) {
continue
}
const item = createItemForCommand(command)
item.resultRange = [index, index + searchQuery.length]
items.push(item)
itemCounts[item.section]++
}
const interactableItems = application.items.getInteractableItems()
for (let i = 0; i < interactableItems.length; i++) {
if (items.length >= 50) {
break
}
const decryptedItem = interactableItems[i]
if (!decryptedItem || !decryptedItem.title) {
continue
}
const index = decryptedItem.title.toLowerCase().indexOf(searchQuery)
if (index === -1) {
continue
}
const item = createItemForInteractableItem(decryptedItem)
item.resultRange = [index, index + searchQuery.length]
items.push(item)
itemCounts[item.section]++
}
} else {
const recentCommands = application.recents.commandUuids
for (let i = 0; i < recentCommands.length; i++) {
const command = application.commands.getCommandDescription(recentCommands[i])
if (!command) {
continue
}
const item = createItemForCommand(command)
recents.push(item)
itemCounts[item.section]++
}
const recentItems = application.recents.itemUuids
for (let i = 0; i < recentItems.length; i++) {
const decryptedItem = application.items.findItem(recentItems[i])
if (!decryptedItem) {
continue
}
const item = createItemForInteractableItem(decryptedItem)
recents.push(item)
itemCounts[item.section]++
}
const commands = application.commands.getCommandDescriptions()
for (let i = 0; i < commands.length; i++) {
const command = commands[i]
if (!command) {
continue
}
const item = createItemForCommand(command)
items.push(item)
itemCounts[item.section]++
}
items.sort((a, b) => (a.description.toLowerCase() < b.description.toLowerCase() ? -1 : 1))
}
setItems(items)
setRecents(recents)
setItemCounts(itemCounts)
},
[application, createItemForCommand, createItemForInteractableItem, isOpen, query],
)
const hasNoItemsAtAll = items.length === 0
const hasNoItemsInSelectedTab = selectedTab !== 'all' && itemCountsPerTab[selectedTab] === 0
return (
<Dialog
store={dialog}
className="fixed inset-3 bottom-[10vh] top-[10vh] z-modal m-auto mt-0 flex h-fit max-h-[70vh] w-[min(45rem,90vw)] flex-col gap-3 overflow-auto rounded-xl border border-[--popover-border-color] bg-[--popover-background-color] px-3 py-3 shadow-main [backdrop-filter:var(--popover-backdrop-filter)]"
backdrop={<div className="bg-passive-5 opacity-50 transition-opacity duration-75 data-[enter]:opacity-85" />}
>
<ComboboxProvider
disclosure={dialog}
includesBaseElement={false}
resetValueOnHide={true}
setValue={(value) => {
startTransition(() => setQuery(value))
}}
>
<TabProvider selectedId={selectedTab} setSelectedId={(id) => setSelectedTab((id as TabId) || 'all')}>
<div className="flex rounded-lg border border-[--popover-border-color] bg-[--popover-background-color] px-2">
<ComboboxInput />
</div>
<TabList className="flex items-center gap-1">
{Tabs.map((id) => (
<Tab
key={id}
id={id}
className="rounded-full px-3 py-1 capitalize disabled:opacity-65 aria-selected:bg-info aria-selected:text-info-contrast data-[active-item]:ring-1 data-[active-item]:ring-info data-[active-item]:ring-offset-1 data-[active-item]:ring-offset-transparent"
disabled={hasNoItemsAtAll || (id !== 'all' && itemCountsPerTab[id] === 0)}
accessibleWhenDisabled={false}
>
{id}
</Tab>
))}
</TabList>
<TabPanel className="flex flex-col gap-1.5 overflow-y-auto" tabId={selectedTab}>
{query.length > 0 && (hasNoItemsAtAll || hasNoItemsInSelectedTab) && (
<div className="mx-auto px-2 text-sm font-semibold opacity-75">No items found</div>
)}
<ComboboxList className="focus:shadow-none focus:outline-none">
{recents.length > 0 && (
<ComboboxGroup>
<ComboboxGroupLabel className="px-2 font-semibold opacity-75">Recent</ComboboxGroupLabel>
{recents.map((item, index) => (
<CommandPaletteListItem
key={item.id}
id={
/* ariakit doesn't like multiple items with the same id in the same combobox list */
item.id + 'recent'
}
index={index}
item={item}
handleClick={handleItemClick}
selectedTab={selectedTab}
/>
))}
</ComboboxGroup>
)}
{!hasNoItemsAtAll && (
<ComboboxGroup>
{recents.length > 0 && (
<ComboboxGroupLabel className="mt-2 px-2 font-semibold opacity-75">All commands</ComboboxGroupLabel>
)}
{items.map((item, index) => (
<CommandPaletteListItem
key={item.id}
id={item.id}
index={index}
item={item}
handleClick={handleItemClick}
selectedTab={selectedTab}
/>
))}
</ComboboxGroup>
)}
</ComboboxList>
</TabPanel>
</TabProvider>
</ComboboxProvider>
</Dialog>
)
}
export default observer(CommandPalette)

View File

@@ -0,0 +1,66 @@
import { GenerateUuid, IconType } from '@standardnotes/snjs'
import { KeyboardCommand, KeyboardService, KeyboardShortcutCategory } from '@standardnotes/ui-services'
import mergeRegister from '../../Hooks/mergeRegister'
type CommandInfo = {
description: string
icon: IconType
shortcut_id?: KeyboardCommand
}
type CommandDescription = { id: string } & CommandInfo
export class CommandService {
#commandInfo = new Map<string, CommandInfo>()
#commandHandlers = new Map<string, () => void>()
constructor(
private keyboardService: KeyboardService,
private generateUuid: GenerateUuid,
) {}
public add(id: string, description: string, handler: () => void, icon?: IconType, shortcut_id?: KeyboardCommand) {
this.#commandInfo.set(id, { description, icon: icon ?? 'info', shortcut_id })
this.#commandHandlers.set(id, handler)
return () => {
this.#commandInfo.delete(id)
this.#commandHandlers.delete(id)
}
}
public addWithShortcut(
id: KeyboardCommand,
category: KeyboardShortcutCategory,
description: string,
handler: (event?: KeyboardEvent, data?: unknown) => void,
icon?: IconType,
) {
return mergeRegister(
this.add(id.description ?? this.generateUuid.execute().getValue(), description, handler, icon, id),
this.keyboardService.addCommandHandler({ command: id, category, description, onKeyDown: handler }),
)
}
public triggerCommand(id: string) {
const handler = this.#commandHandlers.get(id)
if (handler) {
handler()
}
}
public getCommandDescriptions() {
const descriptions: CommandDescription[] = []
for (const [id, { description, icon, shortcut_id }] of this.#commandInfo) {
descriptions.push({ id, description, icon, shortcut_id })
}
return descriptions
}
public getCommandDescription(id: string): CommandDescription | undefined {
const command = this.#commandInfo.get(id)
if (!command) {
return
}
return { id, ...command }
}
}

View File

@@ -175,15 +175,6 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
* probably better to be consistent. * probably better to be consistent.
*/ */
return application.keyboardService.addCommandHandlers([ return application.keyboardService.addCommandHandlers([
{
command: CREATE_NEW_NOTE_KEYBOARD_COMMAND,
category: 'General',
description: 'Create new note',
onKeyDown: (event) => {
event.preventDefault()
void addNewItem()
},
},
{ {
command: NEXT_LIST_ITEM_KEYBOARD_COMMAND, command: NEXT_LIST_ITEM_KEYBOARD_COMMAND,
category: 'Notes list', category: 'Notes list',
@@ -262,11 +253,28 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
) )
const addButtonLabel = useMemo(() => { const addButtonLabel = useMemo(() => {
return isFilesSmartView let shortcut = keyboardStringForShortcut(shortcutForCreate)
? 'Upload file' if (shortcut) {
: `Create a new note in the selected tag (${shortcutForCreate && keyboardStringForShortcut(shortcutForCreate)})` shortcut = '(' + shortcut + ')'
}
return isFilesSmartView ? `Upload file ${shortcut}` : `Create a new note in the selected tag ${shortcut}`
}, [isFilesSmartView, shortcutForCreate]) }, [isFilesSmartView, shortcutForCreate])
useEffect(
() =>
application.commands.addWithShortcut(
CREATE_NEW_NOTE_KEYBOARD_COMMAND,
'General',
isFilesSmartView ? 'Upload file' : 'Create new note',
(event) => {
event?.preventDefault()
void addNewItem()
},
isFilesSmartView ? 'upload' : 'add',
),
[addNewItem, application.commands, isFilesSmartView],
)
const dailyMode = selectedAsTag?.isDailyEntry const dailyMode = selectedAsTag?.isDailyEntry
const handleDailyListSelection = useCallback( const handleDailyListSelection = useCallback(

View File

@@ -103,6 +103,17 @@ const ContentListHeader = ({
setShowDisplayOptionsMenu((show) => !show) setShowDisplayOptionsMenu((show) => !show)
}, []) }, [])
useEffect(
() =>
application.commands.add(
'open-display-opts-menu',
'Open display options menu',
toggleDisplayOptionsMenu,
'sort-descending',
),
[application.commands, toggleDisplayOptionsMenu],
)
const OptionsMenu = useMemo(() => { const OptionsMenu = useMemo(() => {
return ( return (
<div className="flex"> <div className="flex">

View File

@@ -178,11 +178,11 @@ const EditorWidthSelectionModalWrapper = () => {
}, []) }, [])
useEffect(() => { useEffect(() => {
return application.keyboardService.addCommandHandler({ return application.commands.addWithShortcut(
command: CHANGE_EDITOR_WIDTH_COMMAND, CHANGE_EDITOR_WIDTH_COMMAND,
category: 'Current note', 'Current note',
description: 'Change editor width', 'Change editor width',
onKeyDown: (_, data) => { (_, data) => {
if (typeof data === 'boolean' && data) { if (typeof data === 'boolean' && data) {
setIsGlobal(data) setIsGlobal(data)
} else { } else {
@@ -190,7 +190,8 @@ const EditorWidthSelectionModalWrapper = () => {
} }
toggle() toggle()
}, },
}) 'line-width',
)
}, [application, toggle]) }, [application, toggle])
return ( return (

View File

@@ -1,11 +1,12 @@
import { classNames } from '@standardnotes/utils' import { classNames } from '@standardnotes/utils'
import { useRef } from 'react' import { useEffect, useRef } from 'react'
import AccountMenu, { AccountMenuProps } from '../AccountMenu/AccountMenu' import AccountMenu, { AccountMenuProps } from '../AccountMenu/AccountMenu'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import StyledTooltip from '../StyledTooltip/StyledTooltip' import StyledTooltip from '../StyledTooltip/StyledTooltip'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController' import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController'
import { useApplication } from '../ApplicationProvider'
type Props = AccountMenuProps & { type Props = AccountMenuProps & {
controller: AccountMenuController controller: AccountMenuController
@@ -15,9 +16,15 @@ type Props = AccountMenuProps & {
} }
const AccountMenuButton = ({ hasError, controller, mainApplicationGroup, onClickOutside, toggleMenu, user }: Props) => { const AccountMenuButton = ({ hasError, controller, mainApplicationGroup, onClickOutside, toggleMenu, user }: Props) => {
const application = useApplication()
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const { show: isOpen } = controller const { show: isOpen } = controller
useEffect(
() => application.commands.add('open-acc-menu', 'Open account menu', toggleMenu, 'account-circle'),
[application.commands, toggleMenu],
)
return ( return (
<> <>
<StyledTooltip label="Open account menu"> <StyledTooltip label="Open account menu">

View File

@@ -2,7 +2,7 @@ import { compareSemVersions, StatusServiceEvent } from '@standardnotes/snjs'
import { keyboardStringForShortcut, OPEN_PREFERENCES_COMMAND } from '@standardnotes/ui-services' import { keyboardStringForShortcut, OPEN_PREFERENCES_COMMAND } from '@standardnotes/ui-services'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
import { useCommandService } from '../CommandProvider' import { useKeyboardService } from '../KeyboardServiceProvider'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import StyledTooltip from '../StyledTooltip/StyledTooltip' import StyledTooltip from '../StyledTooltip/StyledTooltip'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
@@ -16,10 +16,10 @@ type Props = {
const PreferencesButton = ({ openPreferences }: Props) => { const PreferencesButton = ({ openPreferences }: Props) => {
const application = useApplication() const application = useApplication()
const commandService = useCommandService() const keyboardService = useKeyboardService()
const shortcut = useMemo( const shortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(OPEN_PREFERENCES_COMMAND)), () => keyboardStringForShortcut(keyboardService.keyboardShortcutForCommand(OPEN_PREFERENCES_COMMAND)),
[commandService], [keyboardService],
) )
const [changelogLastReadVersion, setChangelogLastReadVersion] = useState(() => const [changelogLastReadVersion, setChangelogLastReadVersion] = useState(() =>

View File

@@ -2,13 +2,14 @@ import { WebApplication } from '@/Application/WebApplication'
import { UIFeature, GetDarkThemeFeature } from '@standardnotes/snjs' import { UIFeature, GetDarkThemeFeature } from '@standardnotes/snjs'
import { TOGGLE_DARK_MODE_COMMAND } from '@standardnotes/ui-services' import { TOGGLE_DARK_MODE_COMMAND } from '@standardnotes/ui-services'
import { classNames } from '@standardnotes/utils' import { classNames } from '@standardnotes/utils'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useCommandService } from '../CommandProvider' import { useKeyboardService } from '../KeyboardServiceProvider'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import QuickSettingsMenu from '../QuickSettingsMenu/QuickSettingsMenu' import QuickSettingsMenu from '../QuickSettingsMenu/QuickSettingsMenu'
import StyledTooltip from '../StyledTooltip/StyledTooltip' import StyledTooltip from '../StyledTooltip/StyledTooltip'
import RoundIconButton from '../Button/RoundIconButton' import RoundIconButton from '../Button/RoundIconButton'
import mergeRegister from '../../Hooks/mergeRegister'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -17,30 +18,37 @@ type Props = {
const QuickSettingsButton = ({ application, isMobileNavigation = false }: Props) => { const QuickSettingsButton = ({ application, isMobileNavigation = false }: Props) => {
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const commandService = useCommandService() const keyboardService = useKeyboardService()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const toggleMenu = () => setIsOpen(!isOpen) const toggleMenu = useCallback(() => setIsOpen((isOpen) => !isOpen), [])
useEffect(() => { useEffect(() => {
if (isMobileNavigation) {
return
}
const darkThemeFeature = new UIFeature(GetDarkThemeFeature()) const darkThemeFeature = new UIFeature(GetDarkThemeFeature())
return commandService.addCommandHandler({ return mergeRegister(
command: TOGGLE_DARK_MODE_COMMAND, application.commands.addWithShortcut(TOGGLE_DARK_MODE_COMMAND, 'General', 'Toggle dark mode', () => {
category: 'General',
description: 'Toggle dark mode',
onKeyDown: () => {
void application.componentManager.toggleTheme(darkThemeFeature) void application.componentManager.toggleTheme(darkThemeFeature)
return true return true
}, }),
}) application.commands.add('open-quick-settings-menu', 'Open quick settings menu', toggleMenu, 'themes'),
}, [application, commandService]) )
}, [application, isMobileNavigation, keyboardService, toggleMenu])
return ( return (
<> <>
<StyledTooltip label="Open quick settings menu"> <StyledTooltip label="Open quick settings menu">
{isMobileNavigation ? ( {isMobileNavigation ? (
<RoundIconButton className="ml-2.5 bg-default" onClick={toggleMenu} label="Go to vaults menu" icon="themes" /> <RoundIconButton
className="ml-2.5 bg-default"
onClick={toggleMenu}
label="Go to quick settings menu"
icon="themes"
/>
) : ( ) : (
<button <button
onClick={toggleMenu} onClick={toggleMenu}

View File

@@ -3,13 +3,13 @@ import { ReactNode, createContext, useContext, memo } from 'react'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { KeyboardService } from '@standardnotes/ui-services' import { KeyboardService } from '@standardnotes/ui-services'
const CommandServiceContext = createContext<KeyboardService | undefined>(undefined) const KeyboardServiceContext = createContext<KeyboardService | undefined>(undefined)
export const useCommandService = () => { export const useKeyboardService = () => {
const value = useContext(CommandServiceContext) const value = useContext(KeyboardServiceContext)
if (!value) { if (!value) {
throw new Error('Component must be a child of <CommandServiceProvider />') throw new Error('Component must be a child of <KeyboardServiceProvider />')
} }
return value return value
@@ -25,12 +25,12 @@ type ProviderProps = {
const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>) const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>)
const CommandServiceProvider = ({ service, children }: ProviderProps) => { const KeyboardServiceProvider = ({ service, children }: ProviderProps) => {
return ( return (
<CommandServiceContext.Provider value={service}> <KeyboardServiceContext.Provider value={service}>
<MemoizedChildren children={children} /> <MemoizedChildren children={children} />
</CommandServiceContext.Provider> </KeyboardServiceContext.Provider>
) )
} }
export default observer(CommandServiceProvider) export default observer(KeyboardServiceProvider)

View File

@@ -4,6 +4,7 @@ import {
keyboardCharacterForModifier, keyboardCharacterForModifier,
isMobilePlatform, isMobilePlatform,
keyboardCharacterForKeyOrCode, keyboardCharacterForKeyOrCode,
KeyboardModifier,
} from '@standardnotes/ui-services' } from '@standardnotes/ui-services'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -20,7 +21,7 @@ export const KeyboardShortcutIndicator = ({ shortcut, small = true, dimmed = tru
const primaryKey = shortcut.key const primaryKey = shortcut.key
? keyboardCharacterForKeyOrCode(shortcut.key) ? keyboardCharacterForKeyOrCode(shortcut.key)
: shortcut.code : shortcut.code
? keyboardCharacterForKeyOrCode(shortcut.code) ? keyboardCharacterForKeyOrCode(shortcut.code, modifiers.includes(KeyboardModifier.Shift))
: undefined : undefined
const results: string[] = [] const results: string[] = []

View File

@@ -2,7 +2,7 @@ import { observer } from 'mobx-react-lite'
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput' import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
import { LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import LinkedItemBubble from './LinkedItemBubble' import LinkedItemBubble from './LinkedItemBubble'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider' import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import { classNames } from '@standardnotes/utils' import { classNames } from '@standardnotes/utils'
@@ -10,12 +10,13 @@ import { ContentType, DecryptedItemInterface } from '@standardnotes/snjs'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem' import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { ItemLink } from '@/Utils/Items/Search/ItemLink' import { ItemLink } from '@/Utils/Items/Search/ItemLink'
import { FOCUS_TAGS_INPUT_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services' import { FOCUS_TAGS_INPUT_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services'
import { useCommandService } from '../CommandProvider'
import { useItemLinks } from '@/Hooks/useItemLinks' import { useItemLinks } from '@/Hooks/useItemLinks'
import RoundIconButton from '../Button/RoundIconButton' import RoundIconButton from '../Button/RoundIconButton'
import VaultNameBadge from '../Vaults/VaultNameBadge' import VaultNameBadge from '../Vaults/VaultNameBadge'
import LastEditedByBadge from '../Vaults/LastEditedByBadge' import LastEditedByBadge from '../Vaults/LastEditedByBadge'
import { useItemVaultInfo } from '@/Hooks/useItemVaultInfo' import { useItemVaultInfo } from '@/Hooks/useItemVaultInfo'
import mergeRegister from '../../Hooks/mergeRegister'
import { useApplication } from '../ApplicationProvider'
type Props = { type Props = {
linkingController: LinkingController linkingController: LinkingController
@@ -39,7 +40,8 @@ const LinkedItemBubblesContainer = ({
}: Props) => { }: Props) => {
const { toggleAppPane } = useResponsiveAppPane() const { toggleAppPane } = useResponsiveAppPane()
const commandService = useCommandService() const application = useApplication()
const keyboardService = application.keyboardService
const { unlinkItems, activateItem } = linkingController const { unlinkItems, activateItem } = linkingController
const unlinkItem = useCallback( const unlinkItem = useCallback(
@@ -57,23 +59,28 @@ const LinkedItemBubblesContainer = ({
[filesLinkedToItem, notesLinkedToItem, tagsLinkedToItem], [filesLinkedToItem, notesLinkedToItem, tagsLinkedToItem],
) )
const linkInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
return commandService.addCommandHandler({ const focusInput = () => {
command: FOCUS_TAGS_INPUT_COMMAND, const input = linkInputRef.current
category: 'Current note', if (input) {
description: 'Link tags, notes, files', setTimeout(() => input.focus())
onKeyDown: () => { }
const input = document.getElementById(ElementIds.ItemLinkAutocompleteInput) }
if (input) { return mergeRegister(
input.focus() keyboardService.addCommandHandler({
} command: FOCUS_TAGS_INPUT_COMMAND,
}, category: 'Current note',
}) description: 'Link tags, notes, files',
}, [commandService]) onKeyDown: focusInput,
}),
application.commands.add('link-items-current', 'Link items to current note', focusInput, 'link'),
)
}, [application.commands, keyboardService])
const shortcut = useMemo( const shortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(FOCUS_TAGS_INPUT_COMMAND)), () => keyboardStringForShortcut(keyboardService.keyboardShortcutForCommand(FOCUS_TAGS_INPUT_COMMAND)),
[commandService], [keyboardService],
) )
const [focusedId, setFocusedId] = useState<string>() const [focusedId, setFocusedId] = useState<string>()
@@ -209,6 +216,7 @@ const LinkedItemBubblesContainer = ({
{isCollapsed && nonVisibleItems > 0 && <span className="flex-shrink-0">and {nonVisibleItems} more...</span>} {isCollapsed && nonVisibleItems > 0 && <span className="flex-shrink-0">and {nonVisibleItems} more...</span>}
{!readonly && ( {!readonly && (
<ItemLinkAutocompleteInput <ItemLinkAutocompleteInput
ref={linkInputRef}
focusedId={focusedId} focusedId={focusedId}
linkingController={linkingController} linkingController={linkingController}
focusPreviousItem={focusPreviousItem} focusPreviousItem={focusPreviousItem}

View File

@@ -1,9 +1,10 @@
import { LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { useRef, useCallback } from 'react' import { useRef, useCallback, useEffect } from 'react'
import RoundIconButton from '../Button/RoundIconButton' import RoundIconButton from '../Button/RoundIconButton'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import LinkedItemsPanel from './LinkedItemsPanel' import LinkedItemsPanel from './LinkedItemsPanel'
import { useApplication } from '../ApplicationProvider'
type Props = { type Props = {
linkingController: LinkingController linkingController: LinkingController
@@ -12,6 +13,7 @@ type Props = {
} }
const LinkedItemsButton = ({ linkingController, onClick, onClickPreprocessing }: Props) => { const LinkedItemsButton = ({ linkingController, onClick, onClickPreprocessing }: Props) => {
const application = useApplication()
const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
@@ -26,6 +28,8 @@ const LinkedItemsButton = ({ linkingController, onClick, onClickPreprocessing }:
} }
}, [isLinkingPanelOpen, onClick, onClickPreprocessing, setIsLinkingPanelOpen]) }, [isLinkingPanelOpen, onClick, onClickPreprocessing, setIsLinkingPanelOpen])
useEffect(() => application.commands.add('open-linked-items-panel', 'Open linked items panel', toggleMenu, 'link'))
if (!activeItem) { if (!activeItem) {
return null return null
} }

View File

@@ -44,7 +44,6 @@ const ModalOverlay = forwardRef(
close() close()
} }
}, },
animated: !isMobileScreen,
}) })
const portalId = useId() const portalId = useId()

View File

@@ -93,11 +93,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
onEditorComponentLoad?: () => void onEditorComponentLoad?: () => void
private removeTrashKeyObserver?: () => void #observers: (() => void)[] = []
private removeNoteStreamObserver?: () => void
private removeComponentManagerObserver?: () => void
private removeInnerNoteObserver?: () => void
private removeVaultUsersEventHandler?: () => void
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
private noteViewElementRef: RefObject<HTMLDivElement> private noteViewElementRef: RefObject<HTMLDivElement>
@@ -147,20 +143,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
super.deinit() super.deinit()
;(this.controller as unknown) = undefined ;(this.controller as unknown) = undefined
this.removeNoteStreamObserver?.() for (let i = 0; i < this.#observers.length; i++) {
;(this.removeNoteStreamObserver as unknown) = undefined const cleanup = this.#observers[i]
cleanup()
this.removeInnerNoteObserver?.() }
;(this.removeInnerNoteObserver as unknown) = undefined this.#observers.length = 0
;(this.#observers as unknown) = undefined
this.removeComponentManagerObserver?.()
;(this.removeComponentManagerObserver as unknown) = undefined
this.removeTrashKeyObserver?.()
this.removeTrashKeyObserver = undefined
this.removeVaultUsersEventHandler?.()
this.removeVaultUsersEventHandler = undefined
this.clearNoteProtectionInactivityTimer() this.clearNoteProtectionInactivityTimer()
;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined ;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined
@@ -213,23 +201,27 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
override componentDidMount(): void { override componentDidMount(): void {
super.componentDidMount() super.componentDidMount()
this.removeVaultUsersEventHandler = this.application.vaultUsers.addEventObserver((event, data) => { this.#observers.push(
if (event === VaultUserServiceEvent.InvalidatedUserCacheForVault) { this.application.vaultUsers.addEventObserver((event, data) => {
const vault = this.application.vaults.getItemVault(this.note) if (event === VaultUserServiceEvent.InvalidatedUserCacheForVault) {
if ((data as string) !== vault?.sharing?.sharedVaultUuid) { const vault = this.application.vaults.getItemVault(this.note)
return if ((data as string) !== vault?.sharing?.sharedVaultUuid) {
return
}
this.setState({
readonly: vault ? this.application.vaultUsers.isCurrentUserReadonlyVaultMember(vault) : undefined,
})
} }
this.setState({ }),
readonly: vault ? this.application.vaultUsers.isCurrentUserReadonlyVaultMember(vault) : undefined, )
})
}
})
this.registerKeyboardShortcuts() this.registerKeyboardShortcuts()
this.removeInnerNoteObserver = this.controller.addNoteInnerValueChangeObserver((note, source) => { this.#observers.push(
this.onNoteInnerChange(note, source) this.controller.addNoteInnerValueChangeObserver((note, source) => {
}) this.onNoteInnerChange(note, source)
}),
)
this.autorun(() => { this.autorun(() => {
const syncStatus = this.controller.syncStatus const syncStatus = this.controller.syncStatus
@@ -463,15 +455,17 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
} }
streamItems() { streamItems() {
this.removeNoteStreamObserver = this.application.items.streamItems<SNNote>(ContentType.TYPES.Note, async () => { this.#observers.push(
if (!this.note) { this.application.items.streamItems<SNNote>(ContentType.TYPES.Note, async () => {
return if (!this.note) {
} return
}
this.setState({ this.setState({
conflictedNotes: this.application.items.conflictsOf(this.note.uuid) as SNNote[], conflictedNotes: this.application.items.conflictsOf(this.note.uuid) as SNNote[],
}) })
}) }),
)
} }
private createComponentViewer(component: UIFeature<IframeComponentFeatureDescription>) { private createComponentViewer(component: UIFeature<IframeComponentFeatureDescription>) {
@@ -766,14 +760,18 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
} }
registerKeyboardShortcuts() { registerKeyboardShortcuts() {
this.removeTrashKeyObserver = this.application.keyboardService.addCommandHandler({ const moveNoteToTrash = () => {
command: DELETE_NOTE_KEYBOARD_COMMAND, this.deleteNote(false).catch(console.error)
notTags: ['INPUT', 'TEXTAREA'], }
notElementIds: [SuperEditorContentId],
onKeyDown: () => { this.#observers.push(
this.deleteNote(false).catch(console.error) this.application.keyboardService.addCommandHandler({
}, command: DELETE_NOTE_KEYBOARD_COMMAND,
}) notTags: ['INPUT', 'TEXTAREA'],
notElementIds: [SuperEditorContentId],
onKeyDown: moveNoteToTrash,
}),
)
} }
ensureNoteIsInsertedBeforeUIAction = async () => { ensureNoteIsInsertedBeforeUIAction = async () => {

View File

@@ -1,7 +1,7 @@
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { useState, useEffect, useMemo, useCallback } from 'react' import { useState, useEffect, useMemo, useCallback } from 'react'
import { NoteType, Platform, SNNote, pluralize } from '@standardnotes/snjs' import { NoteType, Platform } from '@standardnotes/snjs'
import { import {
CHANGE_EDITOR_WIDTH_COMMAND, CHANGE_EDITOR_WIDTH_COMMAND,
OPEN_NOTE_HISTORY_COMMAND, OPEN_NOTE_HISTORY_COMMAND,
@@ -12,7 +12,6 @@ import {
import ChangeEditorOption from './ChangeEditorOption' import ChangeEditorOption from './ChangeEditorOption'
import ListedActionsOption from './Listed/ListedActionsOption' import ListedActionsOption from './Listed/ListedActionsOption'
import AddTagOption from './AddTagOption' import AddTagOption from './AddTagOption'
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
import { NotesOptionsProps } from './NotesOptionsProps' import { NotesOptionsProps } from './NotesOptionsProps'
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider' import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
import { AppPaneId } from '../Panes/AppPaneMetadata' import { AppPaneId } from '../Panes/AppPaneMetadata'
@@ -27,13 +26,10 @@ import { iconClass } from './ClassNames'
import SuperNoteOptions from './SuperNoteOptions' import SuperNoteOptions from './SuperNoteOptions'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem' import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import MenuItem from '../Menu/MenuItem' import MenuItem from '../Menu/MenuItem'
import ModalOverlay from '../Modal/ModalOverlay'
import SuperExportModal from './SuperExportModal'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
import { MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption' import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
import MenuSection from '../Menu/MenuSection' import MenuSection from '../Menu/MenuSection'
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile' import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
const iconSize = MenuItemIconSize const iconSize = MenuItemIconSize
@@ -43,26 +39,13 @@ const iconClassSuccess = `text-success mr-2 ${iconSize}`
const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
const application = useApplication() const application = useApplication()
const notesController = application.notesController
const [altKeyDown, setAltKeyDown] = useState(false) const [altKeyDown, setAltKeyDown] = useState(false)
const { toggleAppPane } = useResponsiveAppPane() const { toggleAppPane } = useResponsiveAppPane()
const toggleOn = (condition: (note: SNNote) => boolean) => { const { trashed, notTrashed, pinned, unpinned, starred, archived, unarchived, locked, protect, hidePreviews } =
const notesMatchingAttribute = notes.filter(condition) notesController.getNotesInfo(notes)
const notesNotMatchingAttribute = notes.filter((note) => !condition(note))
return notesMatchingAttribute.length > notesNotMatchingAttribute.length
}
const hidePreviews = toggleOn((note) => note.hidePreview)
const locked = toggleOn((note) => note.locked)
const protect = toggleOn((note) => note.protected)
const archived = notes.some((note) => note.archived)
const unarchived = notes.some((note) => !note.archived)
const trashed = notes.some((note) => note.trashed)
const notTrashed = notes.some((note) => !note.trashed)
const pinned = notes.some((note) => note.pinned)
const unpinned = notes.some((note) => !note.pinned)
const starred = notes.some((note) => note.starred)
const editorForNote = useMemo( const editorForNote = useMemo(
() => (notes[0] ? application.componentManager.editorForNote(notes[0]) : undefined), () => (notes[0] ? application.componentManager.editorForNote(notes[0]) : undefined),
@@ -85,55 +68,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
} }
}, [application]) }, [application])
const [showExportSuperModal, setShowExportSuperModal] = useState(false)
const closeSuperExportModal = useCallback(() => {
setShowExportSuperModal(false)
}, [])
const downloadSelectedItems = useCallback(async () => {
if (notes.length === 0) {
return
}
const toast = addToast({
type: ToastType.Progress,
message: `Exporting ${notes.length} ${pluralize(notes.length, 'note', 'notes')}...`,
})
try {
const result = await createNoteExport(application, notes)
if (!result) {
return
}
const { blob, fileName } = result
void downloadOrShareBlobBasedOnPlatform({
archiveService: application.archiveService,
platform: application.platform,
mobileDevice: application.mobileDevice,
blob: blob,
filename: fileName,
isNativeMobileWeb: application.isNativeMobileWeb(),
})
dismissToast(toast)
} catch (error) {
console.error(error)
addToast({
type: ToastType.Error,
message: 'Could not export notes',
})
dismissToast(toast)
}
}, [application, notes])
const exportSelectedItems = useCallback(() => {
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
if (hasSuperNote) {
setShowExportSuperModal(true)
return
}
downloadSelectedItems().catch(console.error)
}, [downloadSelectedItems, notes])
const shareSelectedItems = useCallback(() => { const shareSelectedItems = useCallback(() => {
createNoteExport(application, notes) createNoteExport(application, notes)
.then((result) => { .then((result) => {
@@ -158,37 +92,14 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
closeMenu() closeMenu()
}, [closeMenu, toggleAppPane]) }, [closeMenu, toggleAppPane])
const duplicateSelectedItems = useCallback(async () => { const duplicateSelectedNotes = useCallback(async () => {
await Promise.all( await notesController.duplicateSelectedNotes()
notes.map((note) =>
application.mutator
.duplicateItem(note)
.then((duplicated) =>
addToast({
type: ToastType.Regular,
message: `Duplicated note "${duplicated.title}"`,
actions: [
{
label: 'Open',
handler: (toastId) => {
application.itemListController.selectUuids([duplicated.uuid], true).catch(console.error)
dismissToast(toastId)
},
},
],
autoClose: true,
}),
)
.catch(console.error),
),
)
void application.sync.sync()
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}, [application.mutator, application.itemListController, application.sync, closeMenuAndToggleNotesList, notes]) }, [closeMenuAndToggleNotesList, notesController])
const openRevisionHistoryModal = useCallback(() => { const openRevisionHistoryModal = useCallback(() => {
application.historyModalController.openModal(application.notesController.firstSelectedNote) application.historyModalController.openModal(notesController.firstSelectedNote)
}, [application.historyModalController, application.notesController.firstSelectedNote]) }, [application.historyModalController, notesController.firstSelectedNote])
const historyShortcut = useMemo( const historyShortcut = useMemo(
() => application.keyboardService.keyboardShortcutForCommand(OPEN_NOTE_HISTORY_COMMAND), () => application.keyboardService.keyboardShortcutForCommand(OPEN_NOTE_HISTORY_COMMAND),
@@ -259,7 +170,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<MenuSwitchButtonItem <MenuSwitchButtonItem
checked={locked} checked={locked}
onChange={(locked) => { onChange={(locked) => {
application.notesController.setLockSelectedNotes(locked) notesController.setLockSelectedNotes(locked)
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
> >
@@ -269,7 +180,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<MenuSwitchButtonItem <MenuSwitchButtonItem
checked={!hidePreviews} checked={!hidePreviews}
onChange={(hidePreviews) => { onChange={(hidePreviews) => {
application.notesController.setHideSelectedNotePreviews(!hidePreviews) notesController.setHideSelectedNotePreviews(!hidePreviews)
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
> >
@@ -279,7 +190,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<MenuSwitchButtonItem <MenuSwitchButtonItem
checked={protect} checked={protect}
onChange={(protect) => { onChange={(protect) => {
application.notesController.setProtectSelectedNotes(protect).catch(console.error) notesController.setProtectSelectedNotes(protect).catch(console.error)
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
> >
@@ -318,7 +229,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
)} )}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
application.notesController.setStarSelectedNotes(!starred) notesController.setStarSelectedNotes(!starred)
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
> >
@@ -330,7 +241,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
{unpinned && ( {unpinned && (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
application.notesController.setPinSelectedNotes(true) notesController.setPinSelectedNotes(true)
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
> >
@@ -342,7 +253,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
{pinned && ( {pinned && (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
application.notesController.setPinSelectedNotes(false) notesController.setPinSelectedNotes(false)
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
> >
@@ -351,7 +262,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />} {pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
</MenuItem> </MenuItem>
)} )}
<MenuItem onClick={exportSelectedItems}> <MenuItem onClick={notesController.exportSelectedNotes}>
<Icon type="download" className={iconClass} /> <Icon type="download" className={iconClass} />
Export Export
</MenuItem> </MenuItem>
@@ -361,14 +272,14 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
Share Share
</MenuItem> </MenuItem>
)} )}
<MenuItem onClick={duplicateSelectedItems} disabled={areSomeNotesInReadonlySharedVault}> <MenuItem onClick={duplicateSelectedNotes} disabled={areSomeNotesInReadonlySharedVault}>
<Icon type="copy" className={iconClass} /> <Icon type="copy" className={iconClass} />
Duplicate Duplicate
</MenuItem> </MenuItem>
{unarchived && ( {unarchived && (
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
await application.notesController.setArchiveSelectedNotes(true).catch(console.error) await notesController.setArchiveSelectedNotes(true).catch(console.error)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
@@ -380,7 +291,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
{archived && ( {archived && (
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
await application.notesController.setArchiveSelectedNotes(false).catch(console.error) await notesController.setArchiveSelectedNotes(false).catch(console.error)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
@@ -394,7 +305,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<MenuItem <MenuItem
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
onClick={async () => { onClick={async () => {
await application.notesController.deleteNotesPermanently() await notesController.deleteNotesPermanently()
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
> >
@@ -404,7 +315,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
) : ( ) : (
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
await application.notesController.setTrashSelectedNotes(true) await notesController.setTrashSelectedNotes(true)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
@@ -417,7 +328,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<> <>
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
await application.notesController.setTrashSelectedNotes(false) await notesController.setTrashSelectedNotes(false)
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
@@ -428,7 +339,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<MenuItem <MenuItem
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
onClick={async () => { onClick={async () => {
await application.notesController.deleteNotesPermanently() await notesController.deleteNotesPermanently()
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
> >
@@ -437,7 +348,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
await application.notesController.emptyTrash() await notesController.emptyTrash()
closeMenuAndToggleNotesList() closeMenuAndToggleNotesList()
}} }}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
@@ -446,7 +357,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<Icon type="trash-sweep" className="mr-2 text-danger" /> <Icon type="trash-sweep" className="mr-2 text-danger" />
<div className="flex-row"> <div className="flex-row">
<div className="text-danger">Empty Trash</div> <div className="text-danger">Empty Trash</div>
<div className="text-xs">{application.notesController.trashedNotesCount} notes in Trash</div> <div className="text-xs">{notesController.trashedNotesCount} notes in Trash</div>
</div> </div>
</div> </div>
</MenuItem> </MenuItem>
@@ -468,7 +379,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<MenuSection> <MenuSection>
<SpellcheckOptions <SpellcheckOptions
editorForNote={editorForNote} editorForNote={editorForNote}
notesController={application.notesController} notesController={notesController}
note={notes[0]} note={notes[0]}
disabled={areSomeNotesInReadonlySharedVault} disabled={areSomeNotesInReadonlySharedVault}
/> />
@@ -480,10 +391,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<NoteSizeWarning note={notes[0]} /> <NoteSizeWarning note={notes[0]} />
</> </>
)} )}
<ModalOverlay isOpen={showExportSuperModal} close={closeSuperExportModal} className="md:max-w-[25vw]">
<SuperExportModal notes={notes} exportNotes={downloadSelectedItems} close={closeSuperExportModal} />
</ModalOverlay>
</> </>
) )
} }

View File

@@ -1,4 +1,4 @@
import { PrefKey, PrefValue, SNNote } from '@standardnotes/snjs' import { PrefKey, PrefValue } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
import Modal from '../Modal/Modal' import Modal from '../Modal/Modal'
import usePreference from '@/Hooks/usePreference' import usePreference from '@/Hooks/usePreference'
@@ -6,15 +6,13 @@ import { useEffect } from 'react'
import Switch from '../Switch/Switch' import Switch from '../Switch/Switch'
import { noteHasEmbeddedFiles } from '@/Utils/NoteExportUtils' import { noteHasEmbeddedFiles } from '@/Utils/NoteExportUtils'
import Dropdown from '../Dropdown/Dropdown' import Dropdown from '../Dropdown/Dropdown'
import ModalOverlay from '../Modal/ModalOverlay'
import { observer } from 'mobx-react-lite'
type Props = { const ModalContent = observer(() => {
notes: SNNote[]
exportNotes: () => void
close: () => void
}
const SuperExportModal = ({ notes, exportNotes, close }: Props) => {
const application = useApplication() const application = useApplication()
const notesController = application.notesController
const notes = notesController.selectedNotes
const superNoteExportFormat = usePreference(PrefKey.SuperNoteExportFormat) const superNoteExportFormat = usePreference(PrefKey.SuperNoteExportFormat)
const superNoteExportEmbedBehavior = usePreference(PrefKey.SuperNoteExportEmbedBehavior) const superNoteExportEmbedBehavior = usePreference(PrefKey.SuperNoteExportEmbedBehavior)
@@ -53,8 +51,8 @@ const SuperExportModal = ({ notes, exportNotes, close }: Props) => {
label: 'Export', label: 'Export',
type: 'primary', type: 'primary',
onClick: () => { onClick: () => {
close() void notesController.downloadSelectedNotes()
exportNotes() notesController.closeSuperExportModal()
}, },
mobileSlot: 'right', mobileSlot: 'right',
}, },
@@ -157,6 +155,21 @@ const SuperExportModal = ({ notes, exportNotes, close }: Props) => {
)} )}
</Modal> </Modal>
) )
})
const SuperExportModal = () => {
const application = useApplication()
const notesController = application.notesController
return (
<ModalOverlay
isOpen={notesController.shouldShowSuperExportModal}
close={notesController.closeSuperExportModal}
className="md:max-w-[25vw]"
>
<ModalContent />
</ModalOverlay>
)
} }
export default SuperExportModal export default observer(SuperExportModal)

View File

@@ -5,30 +5,30 @@ import { iconClass } from './ClassNames'
import MenuSection from '../Menu/MenuSection' import MenuSection from '../Menu/MenuSection'
import { SUPER_SHOW_MARKDOWN_PREVIEW, SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services' import { SUPER_SHOW_MARKDOWN_PREVIEW, SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services'
import { useMemo, useCallback } from 'react' import { useMemo, useCallback } from 'react'
import { useCommandService } from '../CommandProvider' import { useKeyboardService } from '../KeyboardServiceProvider'
type Props = { type Props = {
closeMenu: () => void closeMenu: () => void
} }
const SuperNoteOptions = ({ closeMenu }: Props) => { const SuperNoteOptions = ({ closeMenu }: Props) => {
const commandService = useCommandService() const keyboardService = useKeyboardService()
const markdownShortcut = useMemo( const markdownShortcut = useMemo(
() => commandService.keyboardShortcutForCommand(SUPER_SHOW_MARKDOWN_PREVIEW), () => keyboardService.keyboardShortcutForCommand(SUPER_SHOW_MARKDOWN_PREVIEW),
[commandService], [keyboardService],
) )
const findShortcut = useMemo(() => commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH), [commandService]) const findShortcut = useMemo(() => keyboardService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH), [keyboardService])
const enableSuperMarkdownPreview = useCallback(() => { const enableSuperMarkdownPreview = useCallback(() => {
commandService.triggerCommand(SUPER_SHOW_MARKDOWN_PREVIEW) keyboardService.triggerCommand(SUPER_SHOW_MARKDOWN_PREVIEW)
}, [commandService]) }, [keyboardService])
const findInNote = useCallback(() => { const findInNote = useCallback(() => {
commandService.triggerCommand(SUPER_TOGGLE_SEARCH) keyboardService.triggerCommand(SUPER_TOGGLE_SEARCH)
closeMenu() closeMenu()
}, [closeMenu, commandService]) }, [closeMenu, keyboardService])
return ( return (
<MenuSection> <MenuSection>

View File

@@ -4,7 +4,7 @@ import Icon from '@/Components/Icon/Icon'
import { NotesController } from '@/Controllers/NotesController/NotesController' import { NotesController } from '@/Controllers/NotesController/NotesController'
import { classNames } from '@standardnotes/utils' import { classNames } from '@standardnotes/utils'
import { keyboardStringForShortcut, PIN_NOTE_COMMAND } from '@standardnotes/ui-services' import { keyboardStringForShortcut, PIN_NOTE_COMMAND } from '@standardnotes/ui-services'
import { useCommandService } from '../CommandProvider' import { useKeyboardService } from '../KeyboardServiceProvider'
import { VisuallyHidden } from '@ariakit/react' import { VisuallyHidden } from '@ariakit/react'
type Props = { type Props = {
@@ -24,11 +24,11 @@ const PinNoteButton: FunctionComponent<Props> = ({ className = '', notesControll
notesController.togglePinSelectedNotes() notesController.togglePinSelectedNotes()
}, [onClickPreprocessing, notesController]) }, [onClickPreprocessing, notesController])
const commandService = useCommandService() const keyboardService = useKeyboardService()
const shortcut = useMemo( const shortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(PIN_NOTE_COMMAND)), () => keyboardStringForShortcut(keyboardService.keyboardShortcutForCommand(PIN_NOTE_COMMAND)),
[commandService], [keyboardService],
) )
const label = pinned ? `Unpin note (${shortcut})` : `Pin note (${shortcut})` const label = pinned ? `Unpin note (${shortcut})` : `Pin note (${shortcut})`

View File

@@ -2,7 +2,6 @@ import { FunctionComponent, useEffect } from 'react'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import PreferencesView from './PreferencesView' import PreferencesView from './PreferencesView'
import { PreferencesViewWrapperProps } from './PreferencesViewWrapperProps' import { PreferencesViewWrapperProps } from './PreferencesViewWrapperProps'
import { useCommandService } from '../CommandProvider'
import { OPEN_PREFERENCES_COMMAND } from '@standardnotes/ui-services' import { OPEN_PREFERENCES_COMMAND } from '@standardnotes/ui-services'
import ModalOverlay from '../Modal/ModalOverlay' import ModalOverlay from '../Modal/ModalOverlay'
import { usePaneSwipeGesture } from '../Panes/usePaneGesture' import { usePaneSwipeGesture } from '../Panes/usePaneGesture'
@@ -10,16 +9,15 @@ import { performSafariAnimationFix } from '../Panes/PaneAnimator'
import { IosModalAnimationEasing } from '../Modal/useModalAnimation' import { IosModalAnimationEasing } from '../Modal/useModalAnimation'
const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperProps> = ({ application }) => { const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperProps> = ({ application }) => {
const commandService = useCommandService()
useEffect(() => { useEffect(() => {
return commandService.addCommandHandler({ return application.commands.addWithShortcut(
command: OPEN_PREFERENCES_COMMAND, OPEN_PREFERENCES_COMMAND,
category: 'General', 'General',
description: 'Open preferences', 'Open preferences',
onKeyDown: () => application.preferencesController.openPreferences(), () => application.preferencesController.openPreferences(),
}) 'tune',
}, [commandService, application]) )
}, [application.commands, application.preferencesController])
const [setElement] = usePaneSwipeGesture('right', async (element) => { const [setElement] = usePaneSwipeGesture('right', async (element) => {
const animation = element.animate( const animation = element.animate(

View File

@@ -2,23 +2,23 @@ import { TOGGLE_LIST_PANE_KEYBOARD_COMMAND, TOGGLE_NAVIGATION_PANE_KEYBOARD_COMM
import { useMemo } from 'react' import { useMemo } from 'react'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider' import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
import { useCommandService } from '../CommandProvider' import { useKeyboardService } from '../KeyboardServiceProvider'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem' import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
const PanelSettingsSection = () => { const PanelSettingsSection = () => {
const { isListPaneCollapsed, isNavigationPaneCollapsed, toggleListPane, toggleNavigationPane } = const { isListPaneCollapsed, isNavigationPaneCollapsed, toggleListPane, toggleNavigationPane } =
useResponsiveAppPane() useResponsiveAppPane()
const commandService = useCommandService() const keyboardService = useKeyboardService()
const navigationShortcut = useMemo( const navigationShortcut = useMemo(
() => commandService.keyboardShortcutForCommand(TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND), () => keyboardService.keyboardShortcutForCommand(TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND),
[commandService], [keyboardService],
) )
const listShortcut = useMemo( const listShortcut = useMemo(
() => commandService.keyboardShortcutForCommand(TOGGLE_LIST_PANE_KEYBOARD_COMMAND), () => keyboardService.keyboardShortcutForCommand(TOGGLE_LIST_PANE_KEYBOARD_COMMAND),
[commandService], [keyboardService],
) )
return ( return (

View File

@@ -7,7 +7,7 @@ import { isMobileScreen } from '@/Utils'
import { classNames } from '@standardnotes/utils' import { classNames } from '@standardnotes/utils'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem' import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import MenuRadioButtonItem from '../Menu/MenuRadioButtonItem' import MenuRadioButtonItem from '../Menu/MenuRadioButtonItem'
import { useCommandService } from '../CommandProvider' import { useKeyboardService } from '../KeyboardServiceProvider'
import { TOGGLE_DARK_MODE_COMMAND } from '@standardnotes/ui-services' import { TOGGLE_DARK_MODE_COMMAND } from '@standardnotes/ui-services'
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator' import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
@@ -18,7 +18,7 @@ type Props = {
const ThemesMenuButton: FunctionComponent<Props> = ({ uiFeature }) => { const ThemesMenuButton: FunctionComponent<Props> = ({ uiFeature }) => {
const application = useApplication() const application = useApplication()
const commandService = useCommandService() const keyboardService = useKeyboardService()
const premiumModal = usePremiumModal() const premiumModal = usePremiumModal()
const isThirdPartyTheme = useMemo( const isThirdPartyTheme = useMemo(
@@ -59,9 +59,9 @@ const ThemesMenuButton: FunctionComponent<Props> = ({ uiFeature }) => {
const darkThemeShortcut = useMemo(() => { const darkThemeShortcut = useMemo(() => {
if (uiFeature.featureIdentifier === NativeFeatureIdentifier.TYPES.DarkTheme) { if (uiFeature.featureIdentifier === NativeFeatureIdentifier.TYPES.DarkTheme) {
return commandService.keyboardShortcutForCommand(TOGGLE_DARK_MODE_COMMAND) return keyboardService.keyboardShortcutForCommand(TOGGLE_DARK_MODE_COMMAND)
} }
}, [commandService, uiFeature.featureIdentifier]) }, [keyboardService, uiFeature.featureIdentifier])
if (shouldHideButton) { if (shouldHideButton) {
return null return null

View File

@@ -20,6 +20,7 @@ const StyledTooltip = ({
side, side,
documentElement, documentElement,
closeOnClick = true, closeOnClick = true,
portal = true,
...props ...props
}: { }: {
children: ReactNode children: ReactNode
@@ -41,7 +42,6 @@ const StyledTooltip = ({
hideTimeout: 0, hideTimeout: 0,
skipTimeout: 0, skipTimeout: 0,
open: forceOpen, open: forceOpen,
animated: true,
type, type,
}) })
@@ -156,9 +156,11 @@ const StyledTooltip = ({
return return
} }
Object.assign(popoverElement.style, styles) for (const [key, value] of Object.entries(styles)) {
popoverElement.style.setProperty(key, value)
}
if (!props.portal) { if (!portal) {
const adjustedStyles = getAdjustedStylesForNonPortalPopover( const adjustedStyles = getAdjustedStylesForNonPortalPopover(
popoverElement, popoverElement,
styles, styles,

View File

@@ -15,7 +15,7 @@ import { useLifecycleAnimation } from '../../../../Hooks/useLifecycleAnimation'
import { classNames, debounce } from '@standardnotes/utils' import { classNames, debounce } from '@standardnotes/utils'
import DecoratedInput from '../../../Input/DecoratedInput' import DecoratedInput from '../../../Input/DecoratedInput'
import { searchInElement } from './searchInElement' import { searchInElement } from './searchInElement'
import { useCommandService } from '../../../CommandProvider' import { useKeyboardService } from '../../../KeyboardServiceProvider'
import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, CloseIcon } from '@standardnotes/icons' import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, CloseIcon } from '@standardnotes/icons'
import Button from '../../../Button/Button' import Button from '../../../Button/Button'
import { canUseCSSHiglights, SearchHighlightRenderer, SearchHighlightRendererMethods } from './SearchHighlightRenderer' import { canUseCSSHiglights, SearchHighlightRenderer, SearchHighlightRendererMethods } from './SearchHighlightRenderer'
@@ -272,18 +272,18 @@ export function SearchPlugin() {
} }
}, []) }, [])
const commandService = useCommandService() const keyboardService = useKeyboardService()
const searchToggleShortcut = useMemo( const searchToggleShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)), () => keyboardStringForShortcut(keyboardService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)),
[commandService], [keyboardService],
) )
const toggleReplaceShortcut = useMemo( const toggleReplaceShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)), () => keyboardStringForShortcut(keyboardService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)),
[commandService], [keyboardService],
) )
const caseSensitivityShortcut = useMemo( const caseSensitivityShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)), () => keyboardStringForShortcut(keyboardService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)),
[commandService], [keyboardService],
) )
if (!isMounted) { if (!isMounted) {
@@ -447,7 +447,6 @@ export function SearchPlugin() {
label="May lead to performance degradation, especially on large documents." label="May lead to performance degradation, especially on large documents."
className="!z-modal" className="!z-modal"
showOnMobile showOnMobile
portal={false}
> >
<button className="cursor-default"> <button className="cursor-default">
<Icon type="info" size="medium" /> <Icon type="info" size="medium" />

View File

@@ -135,7 +135,6 @@ const ToolbarButton = forwardRef(
showOnHover showOnHover
label={name} label={name}
side="top" side="top"
portal={false}
portalElement={isMobile ? parentElement : undefined} portalElement={isMobile ? parentElement : undefined}
documentElement={parentElement} documentElement={parentElement}
> >

View File

@@ -27,7 +27,6 @@ import {
ChangeContentCallbackPlugin, ChangeContentCallbackPlugin,
ChangeEditorFunction, ChangeEditorFunction,
} from './Plugins/ChangeContentCallback/ChangeContentCallback' } from './Plugins/ChangeContentCallback/ChangeContentCallback'
import { useCommandService } from '@/Components/CommandProvider'
import { SUPER_SHOW_MARKDOWN_PREVIEW, getPrimaryModifier } from '@standardnotes/ui-services' import { SUPER_SHOW_MARKDOWN_PREVIEW, getPrimaryModifier } from '@standardnotes/ui-services'
import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview' import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview'
import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMarkdownPlugin/GetMarkdownPlugin' import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMarkdownPlugin/GetMarkdownPlugin'
@@ -83,22 +82,23 @@ export const SuperEditor: FunctionComponent<Props> = ({
) )
}, [application.features]) }, [application.features])
const commandService = useCommandService() const keyboardService = application.keyboardService
useEffect(() => { useEffect(() => {
return commandService.addCommandHandler({ return application.commands.addWithShortcut(
command: SUPER_SHOW_MARKDOWN_PREVIEW, SUPER_SHOW_MARKDOWN_PREVIEW,
category: 'Super notes', 'Super notes',
description: 'Show markdown preview for current note', 'Show markdown preview for current note',
onKeyDown: () => setShowMarkdownPreview(true), () => setShowMarkdownPreview((s) => !s),
}) 'markdown',
}, [commandService]) )
}, [application.commands])
useEffect(() => { useEffect(() => {
const platform = application.platform const platform = application.platform
const primaryModifier = getPrimaryModifier(application.platform) const primaryModifier = getPrimaryModifier(application.platform)
return commandService.registerExternalKeyboardShortcutHelpItems([ return keyboardService.registerExternalKeyboardShortcutHelpItems([
{ {
key: 'b', key: 'b',
modifiers: [primaryModifier], modifiers: [primaryModifier],
@@ -128,7 +128,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
platform: platform, platform: platform,
}, },
]) ])
}, [application.platform, commandService]) }, [application.platform, keyboardService])
const closeMarkdownPreview = useCallback(() => { const closeMarkdownPreview = useCallback(() => {
setShowMarkdownPreview(false) setShowMarkdownPreview(false)

View File

@@ -41,7 +41,7 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEdit
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const level = 0 const level = 0
const isSelected = tagsState.selected === view const isSelected = tagsState.selected?.uuid === view.uuid
const isEditing = tagsState.editingTag === view const isEditing = tagsState.editingTag === view
useEffect(() => { useEffect(() => {

View File

@@ -4,7 +4,7 @@ import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useMemo } from 'react' import { FunctionComponent, useCallback, useEffect, useMemo } from 'react'
import IconButton from '../Button/IconButton' import IconButton from '../Button/IconButton'
import EditSmartViewModal from '../Preferences/Panes/General/SmartViews/EditSmartViewModal' import EditSmartViewModal from '../Preferences/Panes/General/SmartViews/EditSmartViewModal'
import { EditSmartViewModalController } from '../Preferences/Panes/General/SmartViews/EditSmartViewModalController' import { EditSmartViewModalController } from '../Preferences/Panes/General/SmartViews/EditSmartViewModalController'
@@ -33,6 +33,11 @@ const SmartViewsSection: FunctionComponent<Props> = ({ application, navigationCo
addSmartViewModalController.setIsAddingSmartView(true) addSmartViewModalController.setIsAddingSmartView(true)
}, [addSmartViewModalController, premiumModal, featuresController.hasSmartViews]) }, [addSmartViewModalController, premiumModal, featuresController.hasSmartViews])
useEffect(
() => application.commands.add('create-smart-view', 'Create a new smart view', createNewSmartView, 'add'),
[application.commands, createNewSmartView],
)
return ( return (
<section> <section>
<div className={'section-title-bar'}> <div className={'section-title-bar'}>

View File

@@ -27,9 +27,7 @@ const TagsSection: FunctionComponent = () => {
<div className={'section-title-bar'}> <div className={'section-title-bar'}>
<div className="section-title-bar-header"> <div className="section-title-bar-header">
<TagsSectionTitle features={application.featuresController} /> <TagsSectionTitle features={application.featuresController} />
{!application.navigationController.isSearching && ( {!application.navigationController.isSearching && <TagsSectionAddButton />}
<TagsSectionAddButton tags={application.navigationController} features={application.featuresController} />
)}
</div> </div>
</div> </div>
<TagsList type="all" /> <TagsList type="all" />

View File

@@ -1,22 +1,22 @@
import IconButton from '@/Components/Button/IconButton' import IconButton from '@/Components/Button/IconButton'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { CREATE_NEW_TAG_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services' import { CREATE_NEW_TAG_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useCommandService } from '../CommandProvider' import { useKeyboardService } from '../KeyboardServiceProvider'
import { useApplication } from '../ApplicationProvider'
type Props = { function TagsSectionAddButton() {
tags: NavigationController const application = useApplication()
features: FeaturesController const keyboardService = useKeyboardService()
}
const TagsSectionAddButton: FunctionComponent<Props> = ({ tags }) => { const addNewTag = useCallback(
const commandService = useCommandService() () => application.navigationController.createNewTemplate(),
[application.navigationController],
)
const shortcut = useMemo( const shortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(CREATE_NEW_TAG_COMMAND)), () => keyboardStringForShortcut(keyboardService.keyboardShortcutForCommand(CREATE_NEW_TAG_COMMAND)),
[commandService], [keyboardService],
) )
return ( return (
@@ -25,7 +25,7 @@ const TagsSectionAddButton: FunctionComponent<Props> = ({ tags }) => {
icon="add" icon="add"
title={`Create a new tag (${shortcut})`} title={`Create a new tag (${shortcut})`}
className="p-0 text-neutral" className="p-0 text-neutral"
onClick={() => tags.createNewTemplate()} onClick={addNewTag}
/> />
) )
} }

View File

@@ -41,6 +41,7 @@ import { AbstractViewController } from './Abstract/AbstractViewController'
import { NotesController } from './NotesController/NotesController' import { NotesController } from './NotesController/NotesController'
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform' import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
import { truncateString } from '@/Components/SuperEditor/Utils' import { truncateString } from '@/Components/SuperEditor/Utils'
import { RecentActionsState } from '../Application/Recents'
const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection] const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection]
const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionType.PreviewFile] const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionType.PreviewFile]
@@ -105,6 +106,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
private platform: Platform, private platform: Platform,
private mobileDevice: MobileDeviceInterface | undefined, private mobileDevice: MobileDeviceInterface | undefined,
private _isNativeMobileWeb: IsNativeMobileWeb, private _isNativeMobileWeb: IsNativeMobileWeb,
private recents: RecentActionsState,
eventBus: InternalEventBusInterface, eventBus: InternalEventBusInterface,
) { ) {
super(eventBus) super(eventBus)
@@ -278,6 +280,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
break break
case FileItemActionType.PreviewFile: case FileItemActionType.PreviewFile:
this.filePreviewModalController.activate(file, action.payload.otherFiles) this.filePreviewModalController.activate(file, action.payload.otherFiles)
this.recents.add(file.uuid)
break break
} }

View File

@@ -46,6 +46,7 @@ describe('item list controller', () => {
application.options, application.options,
application.isNativeMobileWebUseCase, application.isNativeMobileWebUseCase,
application.changeAndSaveItem, application.changeAndSaveItem,
application.recents,
eventBus, eventBus,
) )
}) })

View File

@@ -61,6 +61,7 @@ import { Persistable } from '../Abstract/Persistable'
import { PaneController } from '../PaneController/PaneController' import { PaneController } from '../PaneController/PaneController'
import { requestCloseAllOpenModalsAndPopovers } from '@/Utils/CloseOpenModalsAndPopovers' import { requestCloseAllOpenModalsAndPopovers } from '@/Utils/CloseOpenModalsAndPopovers'
import { PaneLayout } from '../PaneController/PaneLayout' import { PaneLayout } from '../PaneController/PaneLayout'
import { RecentActionsState } from '../../Application/Recents'
const MinNoteCellHeight = 51.0 const MinNoteCellHeight = 51.0
const DefaultListNumNotes = 20 const DefaultListNumNotes = 20
@@ -129,6 +130,7 @@ export class ItemListController
private options: FullyResolvedApplicationOptions, private options: FullyResolvedApplicationOptions,
private _isNativeMobileWeb: IsNativeMobileWeb, private _isNativeMobileWeb: IsNativeMobileWeb,
private _changeAndSaveItem: ChangeAndSaveItem, private _changeAndSaveItem: ChangeAndSaveItem,
private recents: RecentActionsState,
eventBus: InternalEventBusInterface, eventBus: InternalEventBusInterface,
) { ) {
super(eventBus) super(eventBus)
@@ -1120,9 +1122,7 @@ export class ItemListController
} }
replaceSelection = (item: ListableContentItem): void => { replaceSelection = (item: ListableContentItem): void => {
this.deselectAll() runInAction(() => this.setSelectedUuids(new Set([item.uuid])))
runInAction(() => this.setSelectedUuids(this.selectedUuids.add(item.uuid)))
this.lastSelectedItem = item this.lastSelectedItem = item
} }
@@ -1150,6 +1150,7 @@ export class ItemListController
} else if (item.content_type === ContentType.TYPES.File) { } else if (item.content_type === ContentType.TYPES.File) {
await this.openFile(item.uuid) await this.openFile(item.uuid)
} }
this.recents.add(item.uuid)
if (!this.paneController.isInMobileView || userTriggered) { if (!this.paneController.isInMobileView || userTriggered) {
void this.paneController.setPaneLayout(PaneLayout.Editing) void this.paneController.setPaneLayout(PaneLayout.Editing)
@@ -1165,21 +1166,13 @@ export class ItemListController
this.isMultipleSelectionMode = true this.isMultipleSelectionMode = true
} }
selectItem = async ( selectItemUsingInstance = async (
uuid: UuidString, item: ListableContentItem,
userTriggered?: boolean, userTriggered?: boolean,
): Promise<{ ): Promise<{ didSelect: boolean }> => {
didSelect: boolean const uuid = item.uuid
}> => {
const item = this.itemManager.findItem<ListableContentItem>(uuid)
if (!item) { log(LoggingDomain.Selection, 'Select item', uuid)
return {
didSelect: false,
}
}
log(LoggingDomain.Selection, 'Select item', item.uuid)
const hasShift = this.keyboardService.activeModifiers.has(KeyboardModifier.Shift) const hasShift = this.keyboardService.activeModifiers.has(KeyboardModifier.Shift)
const hasMoreThanOneSelected = this.selectedItemsCount > 1 const hasMoreThanOneSelected = this.selectedItemsCount > 1
@@ -1208,6 +1201,23 @@ export class ItemListController
} }
} }
selectItem = async (
uuid: UuidString,
userTriggered?: boolean,
): Promise<{
didSelect: boolean
}> => {
const item = this.itemManager.findItem<ListableContentItem>(uuid)
if (!item) {
return {
didSelect: false,
}
}
return this.selectItemUsingInstance(item, userTriggered)
}
selectItemWithScrollHandling = async ( selectItemWithScrollHandling = async (
item: { item: {
uuid: ListableContentItem['uuid'] uuid: ListableContentItem['uuid']

View File

@@ -1,7 +1,6 @@
import { import {
confirmDialog, confirmDialog,
CREATE_NEW_TAG_COMMAND, CREATE_NEW_TAG_COMMAND,
KeyboardService,
NavigationControllerPersistableValue, NavigationControllerPersistableValue,
VaultDisplayService, VaultDisplayService,
VaultDisplayServiceEvent, VaultDisplayServiceEvent,
@@ -43,6 +42,8 @@ import { TagListSectionType } from '@/Components/Tags/TagListSection'
import { PaneLayout } from '../PaneController/PaneLayout' import { PaneLayout } from '../PaneController/PaneLayout'
import { TagsCountsState } from './TagsCountsState' import { TagsCountsState } from './TagsCountsState'
import { PaneController } from '../PaneController/PaneController' import { PaneController } from '../PaneController/PaneController'
import { RecentActionsState } from '../../Application/Recents'
import { CommandService } from '../../Components/CommandPalette/CommandService'
export class NavigationController export class NavigationController
extends AbstractViewController extends AbstractViewController
@@ -73,7 +74,7 @@ export class NavigationController
constructor( constructor(
private featuresController: FeaturesController, private featuresController: FeaturesController,
private vaultDisplayService: VaultDisplayService, private vaultDisplayService: VaultDisplayService,
private keyboardService: KeyboardService, private commands: CommandService,
private paneController: PaneController, private paneController: PaneController,
private sync: SyncServiceInterface, private sync: SyncServiceInterface,
private mutator: MutatorClientInterface, private mutator: MutatorClientInterface,
@@ -81,6 +82,7 @@ export class NavigationController
private preferences: PreferenceServiceInterface, private preferences: PreferenceServiceInterface,
private alerts: AlertService, private alerts: AlertService,
private _changeAndSaveItem: ChangeAndSaveItem, private _changeAndSaveItem: ChangeAndSaveItem,
private recents: RecentActionsState,
eventBus: InternalEventBusInterface, eventBus: InternalEventBusInterface,
) { ) {
super(eventBus) super(eventBus)
@@ -197,14 +199,13 @@ export class NavigationController
) )
this.disposers.push( this.disposers.push(
this.keyboardService.addCommandHandler({ this.commands.addWithShortcut(
command: CREATE_NEW_TAG_COMMAND, CREATE_NEW_TAG_COMMAND,
category: 'General', 'General',
description: 'Create new tag', 'Create new tag',
onKeyDown: () => { () => this.createNewTemplate(),
this.createNewTemplate() 'add',
}, ),
}),
) )
this.setDisplayOptionsAndReloadTags = debounce(this.setDisplayOptionsAndReloadTags, 50) this.setDisplayOptionsAndReloadTags = debounce(this.setDisplayOptionsAndReloadTags, 50)
@@ -511,6 +512,10 @@ export class NavigationController
return return
} }
if (tag) {
this.recents.add(tag.uuid)
}
await this.eventBus.publishSync( await this.eventBus.publishSync(
{ {
type: CrossControllerEvent.TagChanged, type: CrossControllerEvent.TagChanged,

View File

@@ -1,8 +1,9 @@
import { InternalEventBusInterface, SNNote } from '@standardnotes/snjs' import { InternalEventBusInterface, SNNote } from '@standardnotes/snjs'
import { KeyboardService, OPEN_NOTE_HISTORY_COMMAND } from '@standardnotes/ui-services' import { OPEN_NOTE_HISTORY_COMMAND } from '@standardnotes/ui-services'
import { action, makeObservable, observable } from 'mobx' import { action, makeObservable, observable } from 'mobx'
import { AbstractViewController } from '../Abstract/AbstractViewController' import { AbstractViewController } from '../Abstract/AbstractViewController'
import { NotesControllerInterface } from '../NotesController/NotesControllerInterface' import { NotesControllerInterface } from '../NotesController/NotesControllerInterface'
import { CommandService } from '../../Components/CommandPalette/CommandService'
export class HistoryModalController extends AbstractViewController { export class HistoryModalController extends AbstractViewController {
note?: SNNote = undefined note?: SNNote = undefined
@@ -14,7 +15,7 @@ export class HistoryModalController extends AbstractViewController {
constructor( constructor(
notesController: NotesControllerInterface, notesController: NotesControllerInterface,
keyboardService: KeyboardService, commandService: CommandService,
eventBus: InternalEventBusInterface, eventBus: InternalEventBusInterface,
) { ) {
super(eventBus) super(eventBus)
@@ -25,14 +26,9 @@ export class HistoryModalController extends AbstractViewController {
}) })
this.disposers.push( this.disposers.push(
keyboardService.addCommandHandler({ commandService.addWithShortcut(OPEN_NOTE_HISTORY_COMMAND, 'Current note', 'Open note history', () => {
command: OPEN_NOTE_HISTORY_COMMAND, this.openModal(notesController.firstSelectedNote)
category: 'Current note', return true
description: 'Open note history',
onKeyDown: () => {
this.openModal(notesController.firstSelectedNote)
return true
},
}), }),
) )
} }

View File

@@ -3,7 +3,6 @@ import {
confirmDialog, confirmDialog,
GetItemTags, GetItemTags,
IsGlobalSpellcheckEnabled, IsGlobalSpellcheckEnabled,
KeyboardService,
PIN_NOTE_COMMAND, PIN_NOTE_COMMAND,
STAR_NOTE_COMMAND, STAR_NOTE_COMMAND,
} from '@standardnotes/ui-services' } from '@standardnotes/ui-services'
@@ -16,29 +15,25 @@ import {
PrefKey, PrefKey,
ApplicationEvent, ApplicationEvent,
EditorLineWidth, EditorLineWidth,
InternalEventBusInterface,
MutationType, MutationType,
PrefDefaults, PrefDefaults,
PreferenceServiceInterface,
InternalEventHandlerInterface, InternalEventHandlerInterface,
InternalEventInterface, InternalEventInterface,
ItemManagerInterface,
MutatorClientInterface,
SyncServiceInterface,
AlertService,
ProtectionsClientInterface,
LocalPrefKey, LocalPrefKey,
NoteContent, NoteContent,
noteTypeForEditorIdentifier, noteTypeForEditorIdentifier,
ContentReference, ContentReference,
pluralize,
NoteType,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { makeObservable, observable, action, computed, runInAction } from 'mobx' import { makeObservable, observable, action, computed, runInAction, reaction } from 'mobx'
import { AbstractViewController } from '../Abstract/AbstractViewController' import { AbstractViewController } from '../Abstract/AbstractViewController'
import { NavigationController } from '../Navigation/NavigationController'
import { NotesControllerInterface } from './NotesControllerInterface' import { NotesControllerInterface } from './NotesControllerInterface'
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
import { CrossControllerEvent } from '../CrossControllerEvent' import { CrossControllerEvent } from '../CrossControllerEvent'
import { ItemListController } from '../ItemList/ItemListController' import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
import { createNoteExport } from '../../Utils/NoteExportUtils'
import { WebApplication } from '../../Application/WebApplication'
import { downloadOrShareBlobBasedOnPlatform } from '../../Utils/DownloadOrShareBasedOnPlatform'
export class NotesController export class NotesController
extends AbstractViewController extends AbstractViewController
@@ -50,27 +45,21 @@ export class NotesController
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 } contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }
contextMenuMaxHeight: number | 'auto' = 'auto' contextMenuMaxHeight: number | 'auto' = 'auto'
showProtectedWarning = false showProtectedWarning = false
shouldShowSuperExportModal = false
commandRegisterDisposers: (() => void)[] = []
constructor( constructor(
private itemListController: ItemListController, private application: WebApplication,
private navigationController: NavigationController,
private itemControllerGroup: ItemGroupController,
private keyboardService: KeyboardService,
private preferences: PreferenceServiceInterface,
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private protections: ProtectionsClientInterface,
private alerts: AlertService,
private _isGlobalSpellcheckEnabled: IsGlobalSpellcheckEnabled, private _isGlobalSpellcheckEnabled: IsGlobalSpellcheckEnabled,
private _getItemTags: GetItemTags, private _getItemTags: GetItemTags,
eventBus: InternalEventBusInterface,
) { ) {
super(eventBus) super(application.events)
makeObservable(this, { makeObservable(this, {
contextMenuOpen: observable, contextMenuOpen: observable,
showProtectedWarning: observable, showProtectedWarning: observable,
shouldShowSuperExportModal: observable,
selectedNotes: computed, selectedNotes: computed,
firstSelectedNote: computed, firstSelectedNote: computed,
@@ -81,38 +70,121 @@ export class NotesController
setContextMenuClickLocation: action, setContextMenuClickLocation: action,
setShowProtectedWarning: action, setShowProtectedWarning: action,
unselectNotes: action, unselectNotes: action,
showSuperExportModal: action,
closeSuperExportModal: action,
}) })
this.shouldLinkToParentFolders = preferences.getValue( this.shouldLinkToParentFolders = application.preferences.getValue(
PrefKey.NoteAddToParentFolders, PrefKey.NoteAddToParentFolders,
PrefDefaults[PrefKey.NoteAddToParentFolders], PrefDefaults[PrefKey.NoteAddToParentFolders],
) )
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged) application.events.addEventHandler(this, ApplicationEvent.PreferencesChanged)
eventBus.addEventHandler(this, CrossControllerEvent.UnselectAllNotes) application.events.addEventHandler(this, CrossControllerEvent.UnselectAllNotes)
this.disposers.push( this.disposers.push(
this.keyboardService.addCommandHandler({ reaction(
() => this.selectedNotesCount,
(notes_count) => {
console.log('hello')
this.disposeCommandRegisters()
const descriptionSuffix = `${pluralize(notes_count, 'current', 'selected')} ${pluralize(
notes_count,
'note',
'note(s)',
)}`
this.commandRegisterDisposers.push(
application.commands.add(
'pin-current',
`Pin ${descriptionSuffix}`,
() => this.setPinSelectedNotes(true),
'unpin',
),
application.commands.add(
'unpin-current',
`Unpin ${descriptionSuffix}`,
() => this.setPinSelectedNotes(false),
'pin',
),
application.commands.add(
'star-current',
`Star ${descriptionSuffix}`,
() => this.setStarSelectedNotes(true),
'star',
),
application.commands.add(
'unstar-current',
`Unstar ${descriptionSuffix}`,
() => this.setStarSelectedNotes(false),
'star',
),
application.commands.add(
'archive-current',
`Archive ${descriptionSuffix}`,
() => this.setArchiveSelectedNotes(true),
'archive',
),
application.commands.add(
'unarchive-current',
`Unarchive ${descriptionSuffix}`,
() => this.setArchiveSelectedNotes(false),
'unarchive',
),
application.commands.add(
'restore-current',
`Restore ${descriptionSuffix}`,
() => this.setTrashSelectedNotes(false),
'restore',
),
application.commands.add(
'trash-current',
`Trash ${descriptionSuffix}`,
() => this.setTrashSelectedNotes(true),
'trash',
),
application.commands.add(
'delete-current',
`Delete ${descriptionSuffix} permanently`,
() => this.deleteNotesPermanently(),
'trash',
),
application.commands.add(
'export-current',
`Export ${descriptionSuffix}`,
this.exportSelectedNotes,
'download',
),
application.commands.add(
'duplicate-current',
`Duplicate ${descriptionSuffix}`,
this.duplicateSelectedNotes,
'copy',
),
)
},
),
)
this.disposers.push(
application.keyboardService.addCommandHandler({
command: PIN_NOTE_COMMAND, command: PIN_NOTE_COMMAND,
category: 'Current note', category: 'Current note',
description: 'Pin current note', description: 'Pin/unpin selected note(s)',
onKeyDown: () => { onKeyDown: this.togglePinSelectedNotes,
this.togglePinSelectedNotes()
},
}), }),
this.keyboardService.addCommandHandler({ application.keyboardService.addCommandHandler({
command: STAR_NOTE_COMMAND, command: STAR_NOTE_COMMAND,
category: 'Current note', category: 'Current note',
description: 'Star current note', description: 'Star/unstar selected note(s)',
onKeyDown: () => { onKeyDown: this.toggleStarSelectedNotes,
this.toggleStarSelectedNotes()
},
}), }),
) )
this.disposers.push( this.disposers.push(
this.itemControllerGroup.addActiveControllerChangeObserver(() => { application.itemControllerGroup.addActiveControllerChangeObserver(() => {
const controllers = this.itemControllerGroup.itemControllers const controllers = application.itemControllerGroup.itemControllers
const activeNoteUuids = controllers.map((controller) => controller.item.uuid) const activeNoteUuids = controllers.map((controller) => controller.item.uuid)
@@ -120,7 +192,7 @@ export class NotesController
for (const selectedId of selectedUuids) { for (const selectedId of selectedUuids) {
if (!activeNoteUuids.includes(selectedId)) { if (!activeNoteUuids.includes(selectedId)) {
this.itemListController.deselectItem({ uuid: selectedId }) application.itemListController.deselectItem({ uuid: selectedId })
} }
} }
}), }),
@@ -129,7 +201,7 @@ export class NotesController
async handleEvent(event: InternalEventInterface): Promise<void> { async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === ApplicationEvent.PreferencesChanged) { if (event.type === ApplicationEvent.PreferencesChanged) {
this.shouldLinkToParentFolders = this.preferences.getValue( this.shouldLinkToParentFolders = this.application.preferences.getValue(
PrefKey.NoteAddToParentFolders, PrefKey.NoteAddToParentFolders,
PrefDefaults[PrefKey.NoteAddToParentFolders], PrefDefaults[PrefKey.NoteAddToParentFolders],
) )
@@ -138,17 +210,23 @@ export class NotesController
} }
} }
private disposeCommandRegisters() {
if (this.commandRegisterDisposers.length > 0) {
for (const dispose of this.commandRegisterDisposers) {
dispose()
}
}
}
override deinit() { override deinit() {
super.deinit() super.deinit()
;(this.lastSelectedNote as unknown) = undefined ;(this.lastSelectedNote as unknown) = undefined
;(this.itemListController as unknown) = undefined
;(this.navigationController as unknown) = undefined
destroyAllObjectProperties(this) destroyAllObjectProperties(this)
} }
public get selectedNotes(): SNNote[] { public get selectedNotes(): SNNote[] {
return this.itemListController.getFilteredSelectedItems<SNNote>(ContentType.TYPES.Note) return this.application.itemListController.getFilteredSelectedItems<SNNote>(ContentType.TYPES.Note)
} }
get firstSelectedNote(): SNNote | undefined { get firstSelectedNote(): SNNote | undefined {
@@ -164,7 +242,7 @@ export class NotesController
} }
get trashedNotesCount(): number { get trashedNotesCount(): number {
return this.items.trashedItems.length return this.application.items.trashedItems.length
} }
setContextMenuOpen = (open: boolean) => { setContextMenuOpen = (open: boolean) => {
@@ -176,8 +254,8 @@ export class NotesController
} }
async changeSelectedNotes(mutate: (mutator: NoteMutator) => void): Promise<void> { async changeSelectedNotes(mutate: (mutator: NoteMutator) => void): Promise<void> {
await this.mutator.changeItems(this.getSelectedNotesList(), mutate, MutationType.NoUpdateUserTimestamps) await this.application.mutator.changeItems(this.getSelectedNotesList(), mutate, MutationType.NoUpdateUserTimestamps)
this.sync.sync().catch(console.error) this.application.sync.sync().catch(console.error)
} }
setHideSelectedNotePreviews(hide: boolean): void { setHideSelectedNotePreviews(hide: boolean): void {
@@ -217,7 +295,7 @@ export class NotesController
async deleteNotes(permanently: boolean): Promise<boolean> { async deleteNotes(permanently: boolean): Promise<boolean> {
if (this.getSelectedNotesList().some((note) => note.locked)) { if (this.getSelectedNotesList().some((note) => note.locked)) {
const text = StringUtils.deleteLockedNotesAttempt(this.selectedNotesCount) const text = StringUtils.deleteLockedNotesAttempt(this.selectedNotesCount)
this.alerts.alert(text).catch(console.error) this.application.alerts.alert(text).catch(console.error)
return false return false
} }
@@ -236,10 +314,10 @@ export class NotesController
confirmButtonStyle: 'danger', confirmButtonStyle: 'danger',
}) })
) { ) {
this.itemListController.selectNextItem() this.application.itemListController.selectNextItem()
if (permanently) { if (permanently) {
await this.mutator.deleteItems(this.getSelectedNotesList()) await this.application.mutator.deleteItems(this.getSelectedNotesList())
void this.sync.sync() void this.application.sync.sync()
} else { } else {
await this.changeSelectedNotes((mutator) => { await this.changeSelectedNotes((mutator) => {
mutator.trashed = true mutator.trashed = true
@@ -251,7 +329,7 @@ export class NotesController
return false return false
} }
togglePinSelectedNotes(): void { togglePinSelectedNotes = () => {
const notes = this.selectedNotes const notes = this.selectedNotes
const pinned = notes.some((note) => note.pinned) const pinned = notes.some((note) => note.pinned)
@@ -262,7 +340,7 @@ export class NotesController
} }
} }
toggleStarSelectedNotes(): void { toggleStarSelectedNotes = () => {
const notes = this.selectedNotes const notes = this.selectedNotes
const starred = notes.some((note) => note.starred) const starred = notes.some((note) => note.starred)
@@ -287,7 +365,9 @@ export class NotesController
async setArchiveSelectedNotes(archived: boolean): Promise<void> { async setArchiveSelectedNotes(archived: boolean): Promise<void> {
if (this.getSelectedNotesList().some((note) => note.locked)) { if (this.getSelectedNotesList().some((note) => note.locked)) {
this.alerts.alert(StringUtils.archiveLockedNotesAttempt(archived, this.selectedNotesCount)).catch(console.error) this.application.alerts
.alert(StringUtils.archiveLockedNotesAttempt(archived, this.selectedNotesCount))
.catch(console.error)
return return
} }
@@ -296,7 +376,7 @@ export class NotesController
}) })
runInAction(() => { runInAction(() => {
this.itemListController.deselectAll() this.application.itemListController.deselectAll()
this.contextMenuOpen = false this.contextMenuOpen = false
}) })
} }
@@ -315,18 +395,18 @@ export class NotesController
async setProtectSelectedNotes(protect: boolean): Promise<void> { async setProtectSelectedNotes(protect: boolean): Promise<void> {
const selectedNotes = this.getSelectedNotesList() const selectedNotes = this.getSelectedNotesList()
if (protect) { if (protect) {
await this.protections.protectNotes(selectedNotes) await this.application.protections.protectNotes(selectedNotes)
this.setShowProtectedWarning(true) this.setShowProtectedWarning(true)
} else { } else {
await this.protections.unprotectNotes(selectedNotes) await this.application.protections.unprotectNotes(selectedNotes)
this.setShowProtectedWarning(false) this.setShowProtectedWarning(false)
} }
void this.sync.sync() void this.application.sync.sync()
} }
unselectNotes(): void { unselectNotes(): void {
this.itemListController.deselectAll() this.application.itemListController.deselectAll()
} }
getSpellcheckStateForNote(note: SNNote) { getSpellcheckStateForNote(note: SNNote) {
@@ -334,52 +414,55 @@ export class NotesController
} }
async toggleGlobalSpellcheckForNote(note: SNNote) { async toggleGlobalSpellcheckForNote(note: SNNote) {
await this.mutator.changeItem<NoteMutator>( await this.application.mutator.changeItem<NoteMutator>(
note, note,
(mutator) => { (mutator) => {
mutator.toggleSpellcheck() mutator.toggleSpellcheck()
}, },
MutationType.NoUpdateUserTimestamps, MutationType.NoUpdateUserTimestamps,
) )
this.sync.sync().catch(console.error) this.application.sync.sync().catch(console.error)
} }
getEditorWidthForNote(note: SNNote) { getEditorWidthForNote(note: SNNote) {
return ( return (
note.editorWidth ?? note.editorWidth ??
this.preferences.getLocalValue(LocalPrefKey.EditorLineWidth, PrefDefaults[LocalPrefKey.EditorLineWidth]) this.application.preferences.getLocalValue(
LocalPrefKey.EditorLineWidth,
PrefDefaults[LocalPrefKey.EditorLineWidth],
)
) )
} }
async setNoteEditorWidth(note: SNNote, editorWidth: EditorLineWidth) { async setNoteEditorWidth(note: SNNote, editorWidth: EditorLineWidth) {
await this.mutator.changeItem<NoteMutator>( await this.application.mutator.changeItem<NoteMutator>(
note, note,
(mutator) => { (mutator) => {
mutator.editorWidth = editorWidth mutator.editorWidth = editorWidth
}, },
MutationType.NoUpdateUserTimestamps, MutationType.NoUpdateUserTimestamps,
) )
this.sync.sync().catch(console.error) this.application.sync.sync().catch(console.error)
} }
async addTagToSelectedNotes(tag: SNTag): Promise<void> { async addTagToSelectedNotes(tag: SNTag): Promise<void> {
const selectedNotes = this.getSelectedNotesList() const selectedNotes = this.getSelectedNotesList()
await Promise.all( await Promise.all(
selectedNotes.map(async (note) => { selectedNotes.map(async (note) => {
await this.mutator.addTagToNote(note, tag, this.shouldLinkToParentFolders) await this.application.mutator.addTagToNote(note, tag, this.shouldLinkToParentFolders)
}), }),
) )
this.sync.sync().catch(console.error) this.application.sync.sync().catch(console.error)
} }
async removeTagFromSelectedNotes(tag: SNTag): Promise<void> { async removeTagFromSelectedNotes(tag: SNTag): Promise<void> {
const selectedNotes = this.getSelectedNotesList() const selectedNotes = this.getSelectedNotesList()
await this.mutator.changeItem(tag, (mutator) => { await this.application.mutator.changeItem(tag, (mutator) => {
for (const note of selectedNotes) { for (const note of selectedNotes) {
mutator.removeItemAsRelationship(note) mutator.removeItemAsRelationship(note)
} }
}) })
this.sync.sync().catch(console.error) this.application.sync.sync().catch(console.error)
} }
isTagInSelectedNotes(tag: SNTag): boolean { isTagInSelectedNotes(tag: SNTag): boolean {
@@ -403,8 +486,8 @@ export class NotesController
confirmButtonStyle: 'danger', confirmButtonStyle: 'danger',
}) })
) { ) {
await this.mutator.emptyTrash() await this.application.mutator.emptyTrash()
this.sync.sync().catch(console.error) this.application.sync.sync().catch(console.error)
} }
} }
@@ -419,19 +502,174 @@ export class NotesController
references: ContentReference[] = [], references: ContentReference[] = [],
): Promise<SNNote> { ): Promise<SNNote> {
const noteType = noteTypeForEditorIdentifier(editorIdentifier) const noteType = noteTypeForEditorIdentifier(editorIdentifier)
const selectedTag = this.navigationController.selected const selectedTag = this.application.navigationController.selected
const templateNote = this.items.createTemplateItem<NoteContent, SNNote>(ContentType.TYPES.Note, { const templateNote = this.application.items.createTemplateItem<NoteContent, SNNote>(ContentType.TYPES.Note, {
title, title,
text, text,
references, references,
noteType, noteType,
editorIdentifier, editorIdentifier,
}) })
const note = await this.mutator.insertItem<SNNote>(templateNote) const note = await this.application.mutator.insertItem<SNNote>(templateNote)
if (selectedTag instanceof SNTag) { if (selectedTag instanceof SNTag) {
const shouldAddTagHierarchy = this.preferences.getValue(PrefKey.NoteAddToParentFolders, true) const shouldAddTagHierarchy = this.application.preferences.getValue(PrefKey.NoteAddToParentFolders, true)
await this.mutator.addTagToNote(templateNote, selectedTag, shouldAddTagHierarchy) await this.application.mutator.addTagToNote(templateNote, selectedTag, shouldAddTagHierarchy)
} }
return note return note
} }
showSuperExportModal = () => {
this.shouldShowSuperExportModal = true
}
closeSuperExportModal = () => {
this.shouldShowSuperExportModal = false
}
// gets attribute info about the given notes in a single loop
getNotesInfo = (notes: SNNote[]) => {
let pinned = false,
unpinned = false,
starred = false,
unstarred = false,
trashed = false,
notTrashed = false,
archived = false,
unarchived = false,
hiddenPreviews = 0,
unhiddenPreviews = 0,
locked = 0,
unlocked = 0,
protecteds = 0,
unprotected = 0
for (let i = 0; i < notes.length; i++) {
const note = notes[i]
if (!note) {
continue
}
if (note.pinned) {
pinned = true
} else {
unpinned = true
}
if (note.starred) {
starred = true
} else {
unstarred = true
}
if (note.trashed) {
trashed = true
} else {
notTrashed = true
}
if (note.archived) {
archived = true
} else {
unarchived = true
}
if (note.hidePreview) {
hiddenPreviews++
} else {
unhiddenPreviews++
}
if (note.locked) {
locked++
} else {
unlocked++
}
if (note.protected) {
protecteds++
} else {
unprotected++
}
}
return {
pinned,
unpinned,
starred,
unstarred,
trashed,
notTrashed,
archived,
unarchived,
hidePreviews: hiddenPreviews > unhiddenPreviews,
locked: locked > unlocked,
protect: protecteds > unprotected,
}
}
downloadSelectedNotes = async () => {
const notes = this.selectedNotes
if (notes.length === 0) {
return
}
const toast = addToast({
type: ToastType.Progress,
message: `Exporting ${notes.length} ${pluralize(notes.length, 'note', 'notes')}...`,
})
try {
const result = await createNoteExport(this.application, notes)
if (!result) {
return
}
const { blob, fileName } = result
void downloadOrShareBlobBasedOnPlatform({
archiveService: this.application.archiveService,
platform: this.application.platform,
mobileDevice: this.application.mobileDevice,
blob: blob,
filename: fileName,
isNativeMobileWeb: this.application.isNativeMobileWeb(),
})
dismissToast(toast)
} catch (error) {
console.error(error)
addToast({
type: ToastType.Error,
message: 'Could not export notes',
})
dismissToast(toast)
}
}
exportSelectedNotes = () => {
const notes = this.selectedNotes
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
if (hasSuperNote) {
this.showSuperExportModal()
return
}
this.downloadSelectedNotes().catch(console.error)
}
duplicateSelectedNotes = async () => {
const notes = this.selectedNotes
await Promise.all(
notes.map((note) =>
this.application.mutator
.duplicateItem(note)
.then((duplicated) =>
addToast({
type: ToastType.Regular,
message: `Duplicated note "${duplicated.title}"`,
actions: [
{
label: 'Open',
handler: (toastId) => {
this.application.itemListController.selectUuids([duplicated.uuid], true).catch(console.error)
dismissToast(toastId)
},
},
],
autoClose: true,
}),
)
.catch(console.error),
),
)
void this.application.sync.sync()
}
} }

View File

@@ -28,6 +28,7 @@ import { AbstractViewController } from '../Abstract/AbstractViewController'
import { log, LoggingDomain } from '@/Logging' import { log, LoggingDomain } from '@/Logging'
import { PaneLayout } from './PaneLayout' import { PaneLayout } from './PaneLayout'
import { IsTabletOrMobileScreen } from '@/Application/UseCase/IsTabletOrMobileScreen' import { IsTabletOrMobileScreen } from '@/Application/UseCase/IsTabletOrMobileScreen'
import { CommandService } from '../../Components/CommandPalette/CommandService'
const MinimumNavPanelWidth = PrefDefaults[PrefKey.TagsPanelWidth] const MinimumNavPanelWidth = PrefDefaults[PrefKey.TagsPanelWidth]
const MinimumNotesPanelWidth = PrefDefaults[PrefKey.NotesPanelWidth] const MinimumNotesPanelWidth = PrefDefaults[PrefKey.NotesPanelWidth]
@@ -56,7 +57,8 @@ export class PaneController extends AbstractViewController implements InternalEv
constructor( constructor(
private preferences: PreferenceServiceInterface, private preferences: PreferenceServiceInterface,
private keyboardService: KeyboardService, keyboardService: KeyboardService,
commands: CommandService,
private _isTabletOrMobileScreen: IsTabletOrMobileScreen, private _isTabletOrMobileScreen: IsTabletOrMobileScreen,
private _panesForLayout: PanesForLayout, private _panesForLayout: PanesForLayout,
eventBus: InternalEventBusInterface, eventBus: InternalEventBusInterface,
@@ -104,33 +106,17 @@ export class PaneController extends AbstractViewController implements InternalEv
eventBus.addEventHandler(this, ApplicationEvent.LocalPreferencesChanged) eventBus.addEventHandler(this, ApplicationEvent.LocalPreferencesChanged)
this.disposers.push( this.disposers.push(
keyboardService.addCommandHandler({ commands.addWithShortcut(TOGGLE_FOCUS_MODE_COMMAND, 'General', 'Toggle focus mode', (event) => {
command: TOGGLE_FOCUS_MODE_COMMAND, event?.preventDefault()
category: 'General', this.toggleFocusMode()
description: 'Toggle focus mode',
onKeyDown: (event) => {
event.preventDefault()
this.setFocusModeEnabled(!this.focusModeEnabled)
return true
},
}), }),
keyboardService.addCommandHandler({ commands.addWithShortcut(TOGGLE_LIST_PANE_KEYBOARD_COMMAND, 'General', 'Toggle notes panel', (event) => {
command: TOGGLE_LIST_PANE_KEYBOARD_COMMAND, event?.preventDefault()
category: 'General', this.toggleListPane()
description: 'Toggle notes panel',
onKeyDown: (event) => {
event.preventDefault()
this.toggleListPane()
},
}), }),
keyboardService.addCommandHandler({ commands.addWithShortcut(TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND, 'General', 'Toggle tags panel', (event) => {
command: TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND, event?.preventDefault()
category: 'General', this.toggleNavigationPane()
description: 'Toggle tags panel',
onKeyDown: (event) => {
event.preventDefault()
this.toggleNavigationPane()
},
}), }),
) )
} }
@@ -332,4 +318,8 @@ export class PaneController extends AbstractViewController implements InternalEv
}, FOCUS_MODE_ANIMATION_DURATION) }, FOCUS_MODE_ANIMATION_DURATION)
} }
} }
toggleFocusMode = () => {
this.setFocusModeEnabled(!this.focusModeEnabled)
}
} }

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
type Func = () => void
/**
* Returns a function that will execute all functions passed when called. It is generally used
* to register multiple lexical listeners and then tear them down with a single function call, such
* as React's useEffect hook.
* @example
* ```ts
* useEffect(() => {
* return mergeRegister(
* editor.registerCommand(...registerCommand1 logic),
* editor.registerCommand(...registerCommand2 logic),
* editor.registerCommand(...registerCommand3 logic)
* )
* }, [editor])
* ```
* In this case, useEffect is returning the function returned by mergeRegister as a cleanup
* function to be executed after either the useEffect runs again (due to one of its dependencies
* updating) or the component it resides in unmounts.
* Note the functions don't necessarily need to be in an array as all arguments
* are considered to be the func argument and spread from there.
* The order of cleanup is the reverse of the argument order. Generally it is
* expected that the first "acquire" will be "released" last (LIFO order),
* because a later step may have some dependency on an earlier one.
* @param func - An array of cleanup functions meant to be executed by the returned function.
* @returns the function which executes all the passed cleanup functions.
*/
export default function mergeRegister(...func: Array<Func>): () => void {
return () => {
for (let i = func.length - 1; i >= 0; i--) {
func[i]()
}
// Clean up the references and make future calls a no-op
func.length = 0
}
}

View File

@@ -1,4 +1,4 @@
import { IconType, FileItem, SNNote, SNTag, DecryptedItemInterface } from '@standardnotes/snjs' import { IconType, FileItem, SNNote, SNTag, DecryptedItemInterface, SmartView } from '@standardnotes/snjs'
import { getIconAndTintForNoteType } from './getIconAndTintForNoteType' import { getIconAndTintForNoteType } from './getIconAndTintForNoteType'
import { getIconForFileType } from './getIconForFileType' import { getIconForFileType } from './getIconForFileType'
import { WebApplicationInterface } from '@standardnotes/ui-services' import { WebApplicationInterface } from '@standardnotes/ui-services'
@@ -12,7 +12,7 @@ export function getIconForItem(item: DecryptedItemInterface, application: WebApp
} else if (item instanceof FileItem) { } else if (item instanceof FileItem) {
const icon = getIconForFileType(item.mimeType) const icon = getIconForFileType(item.mimeType)
return [icon, 'text-info'] return [icon, 'text-info']
} else if (item instanceof SNTag) { } else if (item instanceof SNTag || item instanceof SmartView) {
return [item.iconString as IconType, 'text-info'] return [item.iconString as IconType, 'text-info']
} }

View File

@@ -36,36 +36,36 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@ariakit/core@npm:0.3.8": "@ariakit/core@npm:0.4.15":
version: 0.3.8 version: 0.4.15
resolution: "@ariakit/core@npm:0.3.8" resolution: "@ariakit/core@npm:0.4.15"
checksum: e416596efe7783cba033efcf212119e228b3707006f294745d8beedb09388f4ceebe70d7e936362979d2ff98ec50a16ecc24c988b0825abd68d3a2367e96c1e0 checksum: add800c855c04f94a26e223ccb50f4c390182062848fb69717130ab25c33d755e4137ef5dd334194e4594c5852d64d52cd96b0d288ad9ade84bd24562cb97922
languageName: node languageName: node
linkType: hard linkType: hard
"@ariakit/react-core@npm:0.3.9": "@ariakit/react-core@npm:0.4.18":
version: 0.3.9 version: 0.4.18
resolution: "@ariakit/react-core@npm:0.3.9" resolution: "@ariakit/react-core@npm:0.4.18"
dependencies: dependencies:
"@ariakit/core": 0.3.8 "@ariakit/core": 0.4.15
"@floating-ui/dom": ^1.0.0 "@floating-ui/dom": ^1.0.0
use-sync-external-store: ^1.2.0 use-sync-external-store: ^1.2.0
peerDependencies: peerDependencies:
react: ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: ef44543aecf10d69ea63cb4c68d340f88a8f17a4e0b0dc25a508665a7591126a42731522f97a984268c1d0927738ad2002d702f6bfd40f272090174d56a5ffb4 checksum: 53a012f087c20ccd3d36e13af2fceb8928f280cf57c1c0e6e79088e81c53475f3ec8330be8ee465d3e270c089f80d3a98950bd103645190889f565f0184d2221
languageName: node languageName: node
linkType: hard linkType: hard
"@ariakit/react@npm:^0.3.9": "@ariakit/react@npm:^0.4.18":
version: 0.3.9 version: 0.4.18
resolution: "@ariakit/react@npm:0.3.9" resolution: "@ariakit/react@npm:0.4.18"
dependencies: dependencies:
"@ariakit/react-core": 0.3.9 "@ariakit/react-core": 0.4.18
peerDependencies: peerDependencies:
react: ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 901424a4e47d36c97a35cab016249b22bfe20519cc87ffa0420c14b3547751ca282cfaa9e770937af413c96776106f1f3d2c4b08434f15abca08abd2ab94d73e checksum: e8bc2df82f74dab00a0d950fb3236c78838af4f6c814ce459145c37457716133c924ccb302f29a4106ccbf04b9b0e2156e9490e641925a0465d4a6813f652491
languageName: node languageName: node
linkType: hard linkType: hard
@@ -8795,7 +8795,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@standardnotes/web@workspace:packages/web" resolution: "@standardnotes/web@workspace:packages/web"
dependencies: dependencies:
"@ariakit/react": ^0.3.9 "@ariakit/react": ^0.4.18
"@babel/core": "*" "@babel/core": "*"
"@babel/plugin-proposal-class-properties": ^7.18.6 "@babel/plugin-proposal-class-properties": ^7.18.6
"@babel/plugin-transform-react-jsx": ^7.19.0 "@babel/plugin-transform-react-jsx": ^7.19.0