浏览代码

Enhance/shortcuts (#9803)

* refactor(shortcuts): simplify to build handler category map

* fix(shortcuts): redundant re-mount for the pdf shortcuts

* refactor(shortcuts): simplify names

* refactor(shortcuts): simplify user keynames

* fix(shortcuts): persist inited state for dev mode

* refactor(shortcuts): simplify handlers installation

* refactor(shortcuts): optimize shortcuts mixin

* fix: incorrect function ref

* refactor(shortcuts): shortcuts mixin

* fix(shortcuts): incorrect initialization for the pdf shortcut handler

* refactor(shortcuts): optimize binding keys map

* refactor(shortcuts): optimize shortcuts conflicts detection

* refactor(shortcuts): optimize binding ids map

* refactor(shortcuts): WIP the new keymap page

* refactor(shortcuts): WIP the new keymap related components

* feat(shortcuts): WIP fuzzy search for the shortcuts

* refactor(shortcuts): WIP the new keymap related components

* feat(shortcuts): WIP the new shorcuts record component

* feat(shortcuts): WIP the new shorcuts record component

* feat(shortcuts): WIP check shortcut conflicts component

* feat(shortcuts): WIP the new shorcuts record component

* refactor(shortcuts): WIP persist user shortcuts

* fix(shortcuts): detection for the conflicts

* feat(shortcuts): WIP detection for the conflicts

* feat(shortcuts): WIP persist user shortcuts

* refactor(shortcuts): add unit tests

* enhance(ux): search pane for the shortcuts

* feat(shortcuts): remove the existent shortcut item

* feat(shortcuts): fold/unfold categories

* feat(shortcuts): add shortcuts filters

* enhance(shortcuts): resove binding map description

* enhance(shortcuts): reactive category shortcuts

* enhance(shortcuts): register api for plugins

* feat(shortcuts): add keyboard shortcuts filters

* feat(shortcuts): impl keyboard shortcuts filters

* enhance(shortcuts): leader keys for the shortcut conflicts detection

* enhance(tests): leader keys conflicts for the shortucts

* enhance(shortcuts): parse conflicts from current binding list

* enhance(ui): polish the component of the restore shortcut action

* enhance(shortcuts): get conflicts with specific handler id

* enhance(shortcuts): polish the confilts component

* enhance(shortcuts): polish keymap conflicts component

* enhance(shortcuts): ux for handling shorcuts conflicts

* enhance(ui): polish notifications cp

* fix(shortcuts): remove reduplicate shortcuts for category

* enhance(shortcuts): polish ux for handling shorcuts conflicts

* chore(plugin): build libs core

* enhance(plugin): support shortcut command lifecycle hooks

* enhance(plugin): support shortcut command lifecycle hooks

* chore(plugin): build libs core

* enhance(shortcuts): support shortcuts saved to global config

* enhance(shortcuts): support shortcuts be saved to global config

* feat(shortcuts): support keymap manager to global settings

* enhance(shortcuts): shortcut to open keymap settings

* fix(units): tests

* fix: lints

* enhance(shortcuts): unlisten all shortcuts

* fix: lints

* fix: lints

* fix(units): tests

* fix(units): tests

* fix(units): tests

* enhance(shortcuts): unlisten/listen all shortcuts

* enhance(shortcuts): polish conflicts component

* fix(ui): modal size

* fix(ui): modal panel container

* enhance(shortcuts): i18n

* enhance(ui): layout of the shortcuts recorder component

* fix(lint): i18n

* enhance(ui): keyboard icon for the keymap settings tab

* fix(shortcuts): incorrect filters for the collaspsed shortcuts

* enhance(ui): polish details for the keymap settings

* enhance(ui): polish details for the keymap settings

* fix(shortcuts): get shortcut description error when the associated handler-id not exist

* fix(ui): the shortcut disabled label overlaps with section headers.

* refactor(shortcuts): names

* enhance(ui): filter icons
Charlie 2 年之前
父节点
当前提交
6d6da2046c
共有 44 个文件被更改,包括 2384 次插入1346 次删除
  1. 10 5
      libs/src/LSPlugin.caller.ts
  2. 39 30
      libs/src/LSPlugin.core.ts
  3. 55 32
      libs/src/LSPlugin.ts
  4. 113 99
      libs/src/LSPlugin.user.ts
  5. 42 37
      libs/src/helpers.ts
  6. 2 1
      libs/src/modules/LSPlugin.Experiments.ts
  7. 34 36
      libs/src/modules/LSPlugin.Request.ts
  8. 48 51
      libs/src/modules/LSPlugin.Search.ts
  9. 1 1
      libs/src/modules/LSPlugin.Storage.ts
  10. 3 1
      libs/src/postmate/index.ts
  11. 0 0
      resources/js/lsplugin.core.js
  12. 2 2
      src/main/frontend/components/command_palette.cljs
  13. 2 2
      src/main/frontend/components/plugins.css
  14. 1 1
      src/main/frontend/components/plugins_settings.cljs
  15. 32 5
      src/main/frontend/components/settings.cljs
  16. 40 52
      src/main/frontend/components/settings.css
  17. 4 15
      src/main/frontend/components/shortcut.cljs
  18. 165 0
      src/main/frontend/components/shortcut.css
  19. 476 0
      src/main/frontend/components/shortcut2.cljs
  20. 1 1
      src/main/frontend/components/theme.cljs
  21. 1 1
      src/main/frontend/components/whiteboard.cljs
  22. 9 4
      src/main/frontend/extensions/pdf/core.cljs
  23. 1 1
      src/main/frontend/extensions/srs.cljs
  24. 3 2
      src/main/frontend/handler/command_palette.cljs
  25. 3 0
      src/main/frontend/handler/config.cljs
  26. 4 7
      src/main/frontend/handler/events.cljs
  27. 18 1
      src/main/frontend/handler/global_config.cljs
  28. 5 3
      src/main/frontend/handler/notification.cljs
  29. 12 3
      src/main/frontend/handler/plugin.cljs
  30. 26 24
      src/main/frontend/mixins.cljs
  31. 673 696
      src/main/frontend/modules/shortcut/config.cljs
  32. 158 74
      src/main/frontend/modules/shortcut/core.cljs
  33. 173 73
      src/main/frontend/modules/shortcut/data_helper.cljs
  34. 58 0
      src/main/frontend/modules/shortcut/utils.cljs
  35. 0 5
      src/main/frontend/routes.cljs
  36. 30 12
      src/main/frontend/state.cljs
  37. 7 6
      src/main/frontend/ui.cljs
  38. 1 1
      src/main/frontend/ui/date_picker.cljs
  39. 0 13
      src/main/frontend/util.cljc
  40. 4 4
      src/main/logseq/api.cljs
  41. 17 1
      src/resources/dicts/en.edn
  42. 18 0
      src/resources/dicts/zh-cn.edn
  43. 49 0
      src/test/frontend/modules/shortcut/core_test.cljs
  44. 44 44
      src/test/frontend/util_test.cljs

+ 10 - 5
libs/src/LSPlugin.caller.ts

@@ -243,18 +243,23 @@ class LSPluginCaller extends EventEmitter {
         let { width, height, left, top, vw, vh } = mainLayoutInfo
         let { width, height, left, top, vw, vh } = mainLayoutInfo
 
 
         left = Math.max(left, 0)
         left = Math.max(left, 0)
-        left = (typeof vw === 'number') ?
-          `${Math.min(left * 100 / vw, 99)}%` : `${left}px`
+        left =
+          typeof vw === 'number'
+            ? `${Math.min((left * 100) / vw, 99)}%`
+            : `${left}px`
 
 
         // 45 is height of headbar
         // 45 is height of headbar
         top = Math.max(top, 45)
         top = Math.max(top, 45)
-        top = (typeof vh === 'number') ?
-          `${Math.min(top * 100 / vh, 99)}%` : `${top}px`
+        top =
+          typeof vh === 'number'
+            ? `${Math.min((top * 100) / vh, 99)}%`
+            : `${top}px`
 
 
         Object.assign(cnt.style, {
         Object.assign(cnt.style, {
           width: width + 'px',
           width: width + 'px',
           height: height + 'px',
           height: height + 'px',
-          left, top
+          left,
+          top,
         })
         })
       }
       }
     } catch (e) {
     } catch (e) {

+ 39 - 30
libs/src/LSPlugin.core.ts

@@ -395,11 +395,9 @@ class ExistedImportedPluginPackageError extends Error {
 /**
 /**
  * Host plugin for local
  * Host plugin for local
  */
  */
-class PluginLocal extends EventEmitter<'loaded'
-  | 'unloaded'
-  | 'beforeunload'
-  | 'error'
-  | string> {
+class PluginLocal extends EventEmitter<
+  'loaded' | 'unloaded' | 'beforeunload' | 'error' | string
+> {
   private _sdk: Partial<PluginLocalSDKMetadata> = {}
   private _sdk: Partial<PluginLocalSDKMetadata> = {}
   private _disposes: Array<() => Promise<any>> = []
   private _disposes: Array<() => Promise<any>> = []
   private _id: PluginLocalIdentity
   private _id: PluginLocalIdentity
@@ -534,7 +532,7 @@ class PluginLocal extends EventEmitter<'loaded'
     const localRoot = (this._localRoot = safetyPathNormalize(url))
     const localRoot = (this._localRoot = safetyPathNormalize(url))
     const logseq: Partial<LSPluginPkgConfig> = pkg.logseq || {}
     const logseq: Partial<LSPluginPkgConfig> = pkg.logseq || {}
 
 
-      // Pick legal attrs
+    // Pick legal attrs
     ;[
     ;[
       'name',
       'name',
       'author',
       'author',
@@ -594,7 +592,9 @@ class PluginLocal extends EventEmitter<'loaded'
     // Validate id
     // Validate id
     const { registeredPlugins, isRegistering } = this._ctx
     const { registeredPlugins, isRegistering } = this._ctx
     if (isRegistering && registeredPlugins.has(this.id)) {
     if (isRegistering && registeredPlugins.has(this.id)) {
-      throw new ExistedImportedPluginPackageError('Registered plugin package Error')
+      throw new ExistedImportedPluginPackageError(
+        'Registered plugin package Error'
+      )
     }
     }
 
 
     return async () => {
     return async () => {
@@ -642,10 +642,10 @@ class PluginLocal extends EventEmitter<'loaded'
     <meta charset="UTF-8">
     <meta charset="UTF-8">
     <title>logseq plugin entry</title>
     <title>logseq plugin entry</title>
     ${
     ${
-        IS_DEV
-          ? `<script src="${sdkPathRoot}/lsplugin.user.js?v=${tag}"></script>`
-          : `<script src="https://cdn.jsdelivr.net/npm/@logseq/libs/dist/lsplugin.user.min.js?v=${tag}"></script>`
-      }
+      IS_DEV
+        ? `<script src="${sdkPathRoot}/lsplugin.user.js?v=${tag}"></script>`
+        : `<script src="https://cdn.jsdelivr.net/npm/@logseq/libs/dist/lsplugin.user.min.js?v=${tag}"></script>`
+    }
     
     
   </head>
   </head>
   <body>
   <body>
@@ -1103,7 +1103,8 @@ class LSPluginCore
     | 'beforereload'
     | 'beforereload'
     | 'reloaded'
     | 'reloaded'
   >
   >
-  implements ILSPluginThemeManager {
+  implements ILSPluginThemeManager
+{
   private _isRegistering = false
   private _isRegistering = false
   private _readyIndicator?: DeferredActor
   private _readyIndicator?: DeferredActor
   private readonly _hostMountedActor: DeferredActor = deferred()
   private readonly _hostMountedActor: DeferredActor = deferred()
@@ -1117,8 +1118,10 @@ class LSPluginCore
     externals: [],
     externals: [],
   }
   }
   private readonly _registeredThemes = new Map<PluginLocalIdentity, Theme[]>()
   private readonly _registeredThemes = new Map<PluginLocalIdentity, Theme[]>()
-  private readonly _registeredPlugins = new Map<PluginLocalIdentity,
-    PluginLocal>()
+  private readonly _registeredPlugins = new Map<
+    PluginLocalIdentity,
+    PluginLocal
+  >()
   private _currentTheme: {
   private _currentTheme: {
     pid: PluginLocalIdentity
     pid: PluginLocalIdentity
     opt: Theme | LegacyTheme
     opt: Theme | LegacyTheme
@@ -1194,14 +1197,18 @@ class LSPluginCore
       return
       return
     }
     }
 
 
-    const perfTable = new Map<string,
-      { o: PluginLocal; s: number; e: number }>()
+    const perfTable = new Map<
+      string,
+      { o: PluginLocal; s: number; e: number }
+    >()
     const debugPerfInfo = () => {
     const debugPerfInfo = () => {
       const data: any = Array.from(perfTable.values()).reduce((ac, it) => {
       const data: any = Array.from(perfTable.values()).reduce((ac, it) => {
         const { id, options, status, disabled } = it.o
         const { id, options, status, disabled } = it.o
 
 
-        if (disabled !== true &&
-          (options.entry || (!options.name && !options.entry))) {
+        if (
+          disabled !== true &&
+          (options.entry || (!options.name && !options.entry))
+        ) {
           ac[id] = {
           ac[id] = {
             name: options.name,
             name: options.name,
             entry: options.entry,
             entry: options.entry,
@@ -1234,17 +1241,19 @@ class LSPluginCore
       // valid externals
       // valid externals
       if (externals?.size) {
       if (externals?.size) {
         try {
         try {
-          const validatedExternals: Record<string, boolean> = await invokeHostExportedApi(
-            'validate_external_plugins', [...externals]
-          )
+          const validatedExternals: Record<string, boolean> =
+            await invokeHostExportedApi('validate_external_plugins', [
+              ...externals,
+            ])
 
 
-          externals = new Set([...Object.entries(validatedExternals)].reduce(
-            (a, [k, v]) => {
+          externals = new Set(
+            [...Object.entries(validatedExternals)].reduce((a, [k, v]) => {
               if (v) {
               if (v) {
                 a.push(k)
                 a.push(k)
               }
               }
               return a
               return a
-            }, []))
+            }, [])
+          )
         } catch (e) {
         } catch (e) {
           console.error('[validatedExternals Error]', e)
           console.error('[validatedExternals Error]', e)
         }
         }
@@ -1557,12 +1566,12 @@ class LSPluginCore
       await this.saveUserPreferences(
       await this.saveUserPreferences(
         theme.mode
         theme.mode
           ? {
           ? {
-            themes: {
-              ...this._userPreferences.themes,
-              mode: theme.mode,
-              [theme.mode]: theme,
-            },
-          }
+              themes: {
+                ...this._userPreferences.themes,
+                mode: theme.mode,
+                [theme.mode]: theme,
+              },
+            }
           : { theme: theme }
           : { theme: theme }
       )
       )
     }
     }

+ 55 - 32
libs/src/LSPlugin.ts

@@ -6,7 +6,8 @@ import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
 import { IAsyncStorage, LSPluginFileStorage } from './modules/LSPlugin.Storage'
 import { IAsyncStorage, LSPluginFileStorage } from './modules/LSPlugin.Storage'
 import { LSPluginRequest } from './modules/LSPlugin.Request'
 import { LSPluginRequest } from './modules/LSPlugin.Request'
 
 
-export type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
+export type WithOptional<T, K extends keyof T> = Omit<T, K> &
+  Partial<Pick<T, K>>
 
 
 export type PluginLocalIdentity = string
 export type PluginLocalIdentity = string
 
 
@@ -297,7 +298,12 @@ export type ExternalCommandType =
 export type UserProxyTags = 'app' | 'editor' | 'db' | 'git' | 'ui' | 'assets'
 export type UserProxyTags = 'app' | 'editor' | 'db' | 'git' | 'ui' | 'assets'
 
 
 export type SearchIndiceInitStatus = boolean
 export type SearchIndiceInitStatus = boolean
-export type SearchBlockItem = { id: EntityID, uuid: BlockIdentity, content: string, page: EntityID }
+export type SearchBlockItem = {
+  id: EntityID
+  uuid: BlockIdentity
+  content: string
+  page: EntityID
+}
 export type SearchPageItem = string
 export type SearchPageItem = string
 export type SearchFileItem = string
 export type SearchFileItem = string
 
 
@@ -309,21 +315,23 @@ export interface IPluginSearchServiceHooks {
     graph: string,
     graph: string,
     key: string,
     key: string,
     opts: Partial<{ limit: number }>
     opts: Partial<{ limit: number }>
-  ) =>
-    Promise<{
-      graph: string,
-      key: string,
-      blocks?: Array<Partial<SearchBlockItem>>,
-      pages?: Array<SearchPageItem>,
-      files?: Array<SearchFileItem>
-    }>
+  ) => Promise<{
+    graph: string
+    key: string
+    blocks?: Array<Partial<SearchBlockItem>>
+    pages?: Array<SearchPageItem>
+    files?: Array<SearchFileItem>
+  }>
 
 
   onIndiceInit: (graph: string) => Promise<SearchIndiceInitStatus>
   onIndiceInit: (graph: string) => Promise<SearchIndiceInitStatus>
   onIndiceReset: (graph: string) => Promise<void>
   onIndiceReset: (graph: string) => Promise<void>
-  onBlocksChanged: (graph: string, changes: {
-    added: Array<SearchBlockItem>,
-    removed: Array<EntityID>
-  }) => Promise<void>
+  onBlocksChanged: (
+    graph: string,
+    changes: {
+      added: Array<SearchBlockItem>
+      removed: Array<EntityID>
+    }
+  ) => Promise<void>
   onGraphRemoved: (graph: string, opts?: {}) => Promise<any>
   onGraphRemoved: (graph: string, opts?: {}) => Promise<any>
 }
 }
 
 
@@ -372,8 +380,14 @@ export interface IAppProxy {
    * @param action
    * @param action
    */
    */
   registerCommandShortcut: (
   registerCommandShortcut: (
-    keybinding: SimpleCommandKeybinding,
-    action: SimpleCommandCallback
+    keybinding: SimpleCommandKeybinding | string,
+    action: SimpleCommandCallback,
+    opts?: Partial<{
+      key: string
+      label: string
+      desc: string
+      extras: Record<string, any>
+    }>
   ) => void
   ) => void
 
 
   /**
   /**
@@ -392,10 +406,7 @@ export interface IAppProxy {
    * @param type `xx-plugin-id.commands.xx-key`, `xx-plugin-id.models.xx-key`
    * @param type `xx-plugin-id.commands.xx-key`, `xx-plugin-id.models.xx-key`
    * @param args
    * @param args
    */
    */
-  invokeExternalPlugin: (
-    type: string,
-    ...args: Array<any>
-  ) => Promise<unknown>
+  invokeExternalPlugin: (type: string, ...args: Array<any>) => Promise<unknown>
 
 
   /**
   /**
    * @added 0.0.13
    * @added 0.0.13
@@ -452,7 +463,11 @@ export interface IAppProxy {
   // templates
   // templates
   getTemplate: (name: string) => Promise<BlockEntity | null>
   getTemplate: (name: string) => Promise<BlockEntity | null>
   existTemplate: (name: string) => Promise<Boolean>
   existTemplate: (name: string) => Promise<Boolean>
-  createTemplate: (target: BlockUUID, name: string, opts?: { overwrite: boolean }) => Promise<any>
+  createTemplate: (
+    target: BlockUUID,
+    name: string,
+    opts?: { overwrite: boolean }
+  ) => Promise<any>
   removeTemplate: (name: string) => Promise<any>
   removeTemplate: (name: string) => Promise<any>
   insertTemplate: (target: BlockUUID, name: string) => Promise<any>
   insertTemplate: (target: BlockUUID, name: string) => Promise<any>
 
 
@@ -495,15 +510,21 @@ export interface IAppProxy {
   onCurrentGraphChanged: IUserHook
   onCurrentGraphChanged: IUserHook
   onGraphAfterIndexed: IUserHook<{ repo: string }>
   onGraphAfterIndexed: IUserHook<{ repo: string }>
   onThemeModeChanged: IUserHook<{ mode: 'dark' | 'light' }>
   onThemeModeChanged: IUserHook<{ mode: 'dark' | 'light' }>
-  onThemeChanged: IUserHook<Partial<{ name: string, mode: string, pid: string, url: string }>>
+  onThemeChanged: IUserHook<
+    Partial<{ name: string; mode: string; pid: string; url: string }>>
   onTodayJournalCreated: IUserHook<{ title: string }>
   onTodayJournalCreated: IUserHook<{ title: string }>
+  onBeforeCommandInvoked: (condition: ExternalCommandType | string, callback: (e: IHookEvent) => void) => IUserOffHook
+  onAfterCommandInvoked: (condition: ExternalCommandType | string, callback: (e: IHookEvent) => void) => IUserOffHook
 
 
   /**
   /**
    * provide ui slot to specific block with UUID
    * provide ui slot to specific block with UUID
    *
    *
    * @added 0.0.13
    * @added 0.0.13
    */
    */
-  onBlockRendererSlotted: IUserConditionSlotHook<BlockUUID, Omit<BlockEntity, 'children' | 'page'>>
+  onBlockRendererSlotted: IUserConditionSlotHook<
+    BlockUUID,
+    Omit<BlockEntity, 'children' | 'page'>
+  >
 
 
   /**
   /**
    * provide ui slot to block `renderer` macro for `{{renderer arg1, arg2}}`
    * provide ui slot to block `renderer` macro for `{{renderer arg1, arg2}}`
@@ -690,7 +711,7 @@ export interface IEditorProxy extends Record<string, any> {
   insertBatchBlock: (
   insertBatchBlock: (
     srcBlock: BlockIdentity,
     srcBlock: BlockIdentity,
     batch: IBatchBlock | Array<IBatchBlock>,
     batch: IBatchBlock | Array<IBatchBlock>,
-    opts?: Partial<{ before: boolean; sibling: boolean, keepUUID: boolean }>
+    opts?: Partial<{ before: boolean; sibling: boolean; keepUUID: boolean }>
   ) => Promise<Array<BlockEntity> | null>
   ) => Promise<Array<BlockEntity> | null>
 
 
   updateBlock: (
   updateBlock: (
@@ -896,14 +917,16 @@ export interface IAssetsProxy {
    * @added 0.0.2
    * @added 0.0.2
    * @param exts
    * @param exts
    */
    */
-  listFilesOfCurrentGraph(exts?: string | string[]): Promise<Array<{
-    path: string
-    size: number
-    accessTime: number
-    modifiedTime: number
-    changeTime: number
-    birthTime: number
-  }>>
+  listFilesOfCurrentGraph(exts?: string | string[]): Promise<
+    Array<{
+      path: string
+      size: number
+      accessTime: number
+      modifiedTime: number
+      changeTime: number
+      birthTime: number
+    }>
+  >
 
 
   /**
   /**
    * @example https://github.com/logseq/logseq/pull/6488
    * @example https://github.com/logseq/logseq/pull/6488

+ 113 - 99
libs/src/LSPlugin.user.ts

@@ -35,7 +35,8 @@ import {
   BlockEntity,
   BlockEntity,
   IDatom,
   IDatom,
   IAssetsProxy,
   IAssetsProxy,
-  AppInfo, IPluginSearchServiceHooks,
+  AppInfo,
+  IPluginSearchServiceHooks,
 } from './LSPlugin'
 } from './LSPlugin'
 import Debug from 'debug'
 import Debug from 'debug'
 import * as CSS from 'csstype'
 import * as CSS from 'csstype'
@@ -52,8 +53,7 @@ declare global {
   }
   }
 }
 }
 
 
-type callableMethods =
-  keyof typeof callableAPIs | string // host exported SDK apis & host platform related apis
+type callableMethods = keyof typeof callableAPIs | string // host exported SDK apis & host platform related apis
 
 
 const PROXY_CONTINUE = Symbol.for('proxy-continue')
 const PROXY_CONTINUE = Symbol.for('proxy-continue')
 const debug = Debug('LSPlugin:user')
 const debug = Debug('LSPlugin:user')
@@ -64,7 +64,7 @@ const logger = new PluginLogger('', { console: true })
  * @param opts
  * @param opts
  * @param action
  * @param action
  */
  */
-function registerSimpleCommand(
+function registerSimpleCommand (
   this: LSPluginUser,
   this: LSPluginUser,
   type: string,
   type: string,
   opts: {
   opts: {
@@ -91,13 +91,16 @@ function registerSimpleCommand(
     args: [
     args: [
       this.baseInfo.id,
       this.baseInfo.id,
       // [cmd, action]
       // [cmd, action]
-      [{ key, label, type, desc, keybinding, extras }, ['editor/hook', eventKey]],
+      [
+        { key, label, type, desc, keybinding, extras },
+        ['editor/hook', eventKey],
+      ],
       palette,
       palette,
     ],
     ],
   })
   })
 }
 }
 
 
-function shouldValidUUID(uuid: string) {
+function shouldValidUUID (uuid: string) {
   if (!isValidUUID(uuid)) {
   if (!isValidUUID(uuid)) {
     logger.error(`#${uuid} is not a valid UUID string.`)
     logger.error(`#${uuid} is not a valid UUID string.`)
     return false
     return false
@@ -106,7 +109,7 @@ function shouldValidUUID(uuid: string) {
   return true
   return true
 }
 }
 
 
-function checkEffect(p: LSPluginUser) {
+function checkEffect (p: LSPluginUser) {
   return p && (p.baseInfo?.effect || !p.baseInfo?.iir)
   return p && (p.baseInfo?.effect || !p.baseInfo?.iir)
 }
 }
 
 
@@ -114,10 +117,7 @@ let _appBaseInfo: AppInfo = null
 let _searchServices: Map<string, LSPluginSearchService> = new Map()
 let _searchServices: Map<string, LSPluginSearchService> = new Map()
 
 
 const app: Partial<IAppProxy> = {
 const app: Partial<IAppProxy> = {
-  async getInfo(
-    this: LSPluginUser,
-    key
-  ) {
+  async getInfo (this: LSPluginUser, key) {
     if (!_appBaseInfo) {
     if (!_appBaseInfo) {
       _appBaseInfo = await this._execCallableAPIAsync('get-app-info')
       _appBaseInfo = await this._execCallableAPIAsync('get-app-info')
     }
     }
@@ -126,7 +126,7 @@ const app: Partial<IAppProxy> = {
 
 
   registerCommand: registerSimpleCommand,
   registerCommand: registerSimpleCommand,
 
 
-  registerSearchService<T extends IPluginSearchServiceHooks>(
+  registerSearchService<T extends IPluginSearchServiceHooks> (
     this: LSPluginUser,
     this: LSPluginUser,
     s: T
     s: T
   ) {
   ) {
@@ -137,7 +137,7 @@ const app: Partial<IAppProxy> = {
     _searchServices.set(s.name, new LSPluginSearchService(this, s))
     _searchServices.set(s.name, new LSPluginSearchService(this, s))
   },
   },
 
 
-  registerCommandPalette(
+  registerCommandPalette (
     opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
     opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
     action: SimpleCommandCallback
     action: SimpleCommandCallback
   ) {
   ) {
@@ -152,10 +152,23 @@ const app: Partial<IAppProxy> = {
     )
     )
   },
   },
 
 
-  registerCommandShortcut(
-    keybinding: SimpleCommandKeybinding,
-    action: SimpleCommandCallback
+  registerCommandShortcut (
+    keybinding: SimpleCommandKeybinding | string,
+    action: SimpleCommandCallback,
+    opts: Partial<{
+      key: string
+      label: string
+      desc: string
+      extras: Record<string, any>
+    }> = {}
   ) {
   ) {
+    if (typeof keybinding == 'string') {
+      keybinding = {
+        mode: 'global',
+        binding: keybinding,
+      }
+    }
+
     const { binding } = keybinding
     const { binding } = keybinding
     const group = '$shortcut$'
     const group = '$shortcut$'
     const key = group + safeSnakeCase(binding)
     const key = group + safeSnakeCase(binding)
@@ -163,12 +176,12 @@ const app: Partial<IAppProxy> = {
     return registerSimpleCommand.call(
     return registerSimpleCommand.call(
       this,
       this,
       group,
       group,
-      { key, palette: false, keybinding },
+      { ...opts, key, palette: false, keybinding },
       action
       action
     )
     )
   },
   },
 
 
-  registerUIItem(
+  registerUIItem (
     type: 'toolbar' | 'pagebar',
     type: 'toolbar' | 'pagebar',
     opts: { key: string; template: string }
     opts: { key: string; template: string }
   ) {
   ) {
@@ -181,7 +194,7 @@ const app: Partial<IAppProxy> = {
     })
     })
   },
   },
 
 
-  registerPageMenuItem(
+  registerPageMenuItem (
     this: LSPluginUser,
     this: LSPluginUser,
     tag: string,
     tag: string,
     action: (e: IHookEvent & { page: string }) => void
     action: (e: IHookEvent & { page: string }) => void
@@ -205,9 +218,7 @@ const app: Partial<IAppProxy> = {
     )
     )
   },
   },
 
 
-  onBlockRendererSlotted(
-    uuid,
-    callback: (payload: any) => void) {
+  onBlockRendererSlotted (uuid, callback: (payload: any) => void) {
     if (!shouldValidUUID(uuid)) return
     if (!shouldValidUUID(uuid)) return
 
 
     const pid = this.baseInfo.id
     const pid = this.baseInfo.id
@@ -222,11 +233,7 @@ const app: Partial<IAppProxy> = {
     }
     }
   },
   },
 
 
-  invokeExternalPlugin(
-    this: LSPluginUser,
-    type: string,
-    ...args: Array<any>
-  ) {
+  invokeExternalPlugin (this: LSPluginUser, type: string, ...args: Array<any>) {
     type = type?.trim()
     type = type?.trim()
     if (!type) return
     if (!type) return
     let [pid, group] = type.split('.')
     let [pid, group] = type.split('.')
@@ -240,11 +247,14 @@ const app: Partial<IAppProxy> = {
     }
     }
     return this._execCallableAPIAsync(
     return this._execCallableAPIAsync(
       'invoke_external_plugin_cmd',
       'invoke_external_plugin_cmd',
-      pid, group.toLowerCase(), key, args
+      pid,
+      group.toLowerCase(),
+      key,
+      args
     )
     )
   },
   },
 
 
-  setFullScreen(flag) {
+  setFullScreen (flag) {
     const sf = (...args) => this._callWin('setFullScreen', ...args)
     const sf = (...args) => this._callWin('setFullScreen', ...args)
 
 
     if (flag === 'toggle') {
     if (flag === 'toggle') {
@@ -254,17 +264,17 @@ const app: Partial<IAppProxy> = {
     } else {
     } else {
       flag ? sf(true) : sf()
       flag ? sf(true) : sf()
     }
     }
-  }
+  },
 }
 }
 
 
 let registeredCmdUid = 0
 let registeredCmdUid = 0
 
 
 const editor: Partial<IEditorProxy> = {
 const editor: Partial<IEditorProxy> = {
-  newBlockUUID(this: LSPluginUser): Promise<string> {
+  newBlockUUID (this: LSPluginUser): Promise<string> {
     return this._execCallableAPIAsync('new_block_uuid')
     return this._execCallableAPIAsync('new_block_uuid')
   },
   },
 
 
-  registerSlashCommand(
+  registerSlashCommand (
     this: LSPluginUser,
     this: LSPluginUser,
     tag: string,
     tag: string,
     actions: BlockCommandCallback | Array<SlashCommandAction>
     actions: BlockCommandCallback | Array<SlashCommandAction>
@@ -312,7 +322,7 @@ const editor: Partial<IEditorProxy> = {
     })
     })
   },
   },
 
 
-  registerBlockContextMenuItem(
+  registerBlockContextMenuItem (
     this: LSPluginUser,
     this: LSPluginUser,
     label: string,
     label: string,
     action: BlockCommandCallback
     action: BlockCommandCallback
@@ -335,11 +345,12 @@ const editor: Partial<IEditorProxy> = {
     )
     )
   },
   },
 
 
-  registerHighlightContextMenuItem(
+  registerHighlightContextMenuItem (
     this: LSPluginUser,
     this: LSPluginUser,
     label: string,
     label: string,
     action: SimpleCommandCallback,
     action: SimpleCommandCallback,
-    opts?: { clearSelection: boolean }) {
+    opts?: { clearSelection: boolean }
+  ) {
     if (typeof action !== 'function') {
     if (typeof action !== 'function') {
       return false
       return false
     }
     }
@@ -353,13 +364,13 @@ const editor: Partial<IEditorProxy> = {
       {
       {
         key,
         key,
         label,
         label,
-        extras: opts
+        extras: opts,
       },
       },
       action
       action
     )
     )
   },
   },
 
 
-  scrollToBlockInPage(
+  scrollToBlockInPage (
     this: LSPluginUser,
     this: LSPluginUser,
     pageName: BlockPageName,
     pageName: BlockPageName,
     blockId: BlockIdentity,
     blockId: BlockIdentity,
@@ -371,11 +382,11 @@ const editor: Partial<IEditorProxy> = {
     } else {
     } else {
       this.App.pushState('page', { name: pageName }, { anchor })
       this.App.pushState('page', { name: pageName }, { anchor })
     }
     }
-  }
+  },
 }
 }
 
 
 const db: Partial<IDBProxy> = {
 const db: Partial<IDBProxy> = {
-  onBlockChanged(
+  onBlockChanged (
     this: LSPluginUser,
     this: LSPluginUser,
     uuid: BlockUUID,
     uuid: BlockUUID,
     callback: (
     callback: (
@@ -405,7 +416,7 @@ const db: Partial<IDBProxy> = {
     }
     }
   },
   },
 
 
-  datascriptQuery<T = any>(
+  datascriptQuery<T = any> (
     this: LSPluginUser,
     this: LSPluginUser,
     query: string,
     query: string,
     ...inputs: Array<any>
     ...inputs: Array<any>
@@ -413,16 +424,13 @@ const db: Partial<IDBProxy> = {
     // force remove proxy ns flag `db`
     // force remove proxy ns flag `db`
     inputs.pop()
     inputs.pop()
 
 
-    if (inputs?.some(it => (typeof it === 'function'))) {
+    if (inputs?.some((it) => typeof it === 'function')) {
       const host = this.Experiments.ensureHostScope()
       const host = this.Experiments.ensureHostScope()
       return host.logseq.api.datascript_query(query, ...inputs)
       return host.logseq.api.datascript_query(query, ...inputs)
     }
     }
 
 
-    return this._execCallableAPIAsync(
-      `datascript_query`,
-      ...[query, ...inputs]
-    )
-  }
+    return this._execCallableAPIAsync(`datascript_query`, ...[query, ...inputs])
+  },
 }
 }
 
 
 const git: Partial<IGitProxy> = {}
 const git: Partial<IGitProxy> = {}
@@ -430,13 +438,9 @@ const git: Partial<IGitProxy> = {}
 const ui: Partial<IUIProxy> = {}
 const ui: Partial<IUIProxy> = {}
 
 
 const assets: Partial<IAssetsProxy> = {
 const assets: Partial<IAssetsProxy> = {
-  makeSandboxStorage(
-    this: LSPluginUser
-  ): IAsyncStorage {
-    return new LSPluginFileStorage(
-      this, { assets: true }
-    )
-  }
+  makeSandboxStorage (this: LSPluginUser): IAsyncStorage {
+    return new LSPluginFileStorage(this, { assets: true })
+  },
 }
 }
 
 
 type uiState = {
 type uiState = {
@@ -483,7 +487,7 @@ export class LSPluginUser
    * @param _baseInfo
    * @param _baseInfo
    * @param _caller
    * @param _caller
    */
    */
-  constructor(
+  constructor (
     private _baseInfo: LSPluginBaseInfo,
     private _baseInfo: LSPluginBaseInfo,
     private _caller: LSPluginCaller
     private _caller: LSPluginCaller
   ) {
   ) {
@@ -516,7 +520,7 @@ export class LSPluginUser
   }
   }
 
 
   // Life related
   // Life related
-  async ready(model?: any, callback?: any) {
+  async ready (model?: any, callback?: any) {
     if (this._connected) return
     if (this._connected) return
 
 
     try {
     try {
@@ -562,39 +566,39 @@ export class LSPluginUser
     }
     }
   }
   }
 
 
-  ensureConnected() {
+  ensureConnected () {
     if (!this._connected) {
     if (!this._connected) {
       throw new Error('not connected')
       throw new Error('not connected')
     }
     }
   }
   }
 
 
-  beforeunload(callback: (e: any) => Promise<void>): void {
+  beforeunload (callback: (e: any) => Promise<void>): void {
     if (typeof callback !== 'function') return
     if (typeof callback !== 'function') return
     this._beforeunloadCallback = callback
     this._beforeunloadCallback = callback
   }
   }
 
 
-  provideModel(model: Record<string, any>) {
+  provideModel (model: Record<string, any>) {
     this.caller._extendUserModel(model)
     this.caller._extendUserModel(model)
     return this
     return this
   }
   }
 
 
-  provideTheme(theme: Theme) {
+  provideTheme (theme: Theme) {
     this.caller.call('provider:theme', theme)
     this.caller.call('provider:theme', theme)
     return this
     return this
   }
   }
 
 
-  provideStyle(style: StyleString) {
+  provideStyle (style: StyleString) {
     this.caller.call('provider:style', style)
     this.caller.call('provider:style', style)
     return this
     return this
   }
   }
 
 
-  provideUI(ui: UIOptions) {
+  provideUI (ui: UIOptions) {
     this.caller.call('provider:ui', ui)
     this.caller.call('provider:ui', ui)
     return this
     return this
   }
   }
 
 
   // Settings related
   // Settings related
-  useSettingsSchema(schema: Array<SettingSchemaDesc>) {
+  useSettingsSchema (schema: Array<SettingSchemaDesc>) {
     if (this.connected) {
     if (this.connected) {
       this.caller.call('settings:schema', {
       this.caller.call('settings:schema', {
         schema,
         schema,
@@ -606,35 +610,35 @@ export class LSPluginUser
     return this
     return this
   }
   }
 
 
-  updateSettings(attrs: Record<string, any>) {
+  updateSettings (attrs: Record<string, any>) {
     this.caller.call('settings:update', attrs)
     this.caller.call('settings:update', attrs)
     // TODO: update associated baseInfo settings
     // TODO: update associated baseInfo settings
   }
   }
 
 
-  onSettingsChanged<T = any>(cb: (a: T, b: T) => void): IUserOffHook {
+  onSettingsChanged<T = any> (cb: (a: T, b: T) => void): IUserOffHook {
     const type = 'settings:changed'
     const type = 'settings:changed'
     this.on(type, cb)
     this.on(type, cb)
     return () => this.off(type, cb)
     return () => this.off(type, cb)
   }
   }
 
 
-  showSettingsUI() {
+  showSettingsUI () {
     this.caller.call('settings:visible:changed', { visible: true })
     this.caller.call('settings:visible:changed', { visible: true })
   }
   }
 
 
-  hideSettingsUI() {
+  hideSettingsUI () {
     this.caller.call('settings:visible:changed', { visible: false })
     this.caller.call('settings:visible:changed', { visible: false })
   }
   }
 
 
   // UI related
   // UI related
-  setMainUIAttrs(attrs: Partial<UIContainerAttrs>): void {
+  setMainUIAttrs (attrs: Partial<UIContainerAttrs>): void {
     this.caller.call('main-ui:attrs', attrs)
     this.caller.call('main-ui:attrs', attrs)
   }
   }
 
 
-  setMainUIInlineStyle(style: CSS.Properties): void {
+  setMainUIInlineStyle (style: CSS.Properties): void {
     this.caller.call('main-ui:style', style)
     this.caller.call('main-ui:style', style)
   }
   }
 
 
-  hideMainUI(opts?: { restoreEditingCursor: boolean }): void {
+  hideMainUI (opts?: { restoreEditingCursor: boolean }): void {
     const payload = {
     const payload = {
       key: KEY_MAIN_UI,
       key: KEY_MAIN_UI,
       visible: false,
       visible: false,
@@ -645,7 +649,7 @@ export class LSPluginUser
     this._ui.set(payload.key, payload)
     this._ui.set(payload.key, payload)
   }
   }
 
 
-  showMainUI(opts?: { autoFocus: boolean }): void {
+  showMainUI (opts?: { autoFocus: boolean }): void {
     const payload = {
     const payload = {
       key: KEY_MAIN_UI,
       key: KEY_MAIN_UI,
       visible: true,
       visible: true,
@@ -656,7 +660,7 @@ export class LSPluginUser
     this._ui.set(payload.key, payload)
     this._ui.set(payload.key, payload)
   }
   }
 
 
-  toggleMainUI(): void {
+  toggleMainUI (): void {
     const payload = { key: KEY_MAIN_UI, toggle: true }
     const payload = { key: KEY_MAIN_UI, toggle: true }
     const state = this._ui.get(payload.key)
     const state = this._ui.get(payload.key)
     if (state && state.visible) {
     if (state && state.visible) {
@@ -667,40 +671,40 @@ export class LSPluginUser
   }
   }
 
 
   // Getters
   // Getters
-  get version(): string {
+  get version (): string {
     return this._version
     return this._version
   }
   }
 
 
-  get isMainUIVisible(): boolean {
+  get isMainUIVisible (): boolean {
     const state = this._ui.get(KEY_MAIN_UI)
     const state = this._ui.get(KEY_MAIN_UI)
     return Boolean(state && state.visible)
     return Boolean(state && state.visible)
   }
   }
 
 
-  get connected(): boolean {
+  get connected (): boolean {
     return this._connected
     return this._connected
   }
   }
 
 
-  get baseInfo(): LSPluginBaseInfo {
+  get baseInfo (): LSPluginBaseInfo {
     return this._baseInfo
     return this._baseInfo
   }
   }
 
 
-  get effect(): Boolean {
+  get effect (): Boolean {
     return checkEffect(this)
     return checkEffect(this)
   }
   }
 
 
-  get logger() {
+  get logger () {
     return logger
     return logger
   }
   }
 
 
-  get settings() {
+  get settings () {
     return this.baseInfo?.settings
     return this.baseInfo?.settings
   }
   }
 
 
-  get caller(): LSPluginCaller {
+  get caller (): LSPluginCaller {
     return this._caller
     return this._caller
   }
   }
 
 
-  resolveResourceFullUrl(filePath: string) {
+  resolveResourceFullUrl (filePath: string) {
     this.ensureConnected()
     this.ensureConnected()
     if (!filePath) return
     if (!filePath) return
     filePath = filePath.replace(/^[.\\/]+/, '')
     filePath = filePath.replace(/^[.\\/]+/, '')
@@ -710,12 +714,12 @@ export class LSPluginUser
   /**
   /**
    * @internal
    * @internal
    */
    */
-  _makeUserProxy(target: any, tag?: UserProxyTags) {
+  _makeUserProxy (target: any, tag?: UserProxyTags) {
     const that = this
     const that = this
     const caller = this.caller
     const caller = this.caller
 
 
     return new Proxy(target, {
     return new Proxy(target, {
-      get(target: any, propKey, receiver) {
+      get (target: any, propKey, receiver) {
         const origMethod = target[propKey]
         const origMethod = target[propKey]
 
 
         return function (this: any, ...args: any) {
         return function (this: any, ...args: any) {
@@ -731,13 +735,23 @@ export class LSPluginUser
             if (hookMatcher != null) {
             if (hookMatcher != null) {
               const f = hookMatcher[0].toLowerCase()
               const f = hookMatcher[0].toLowerCase()
               const s = hookMatcher.input!
               const s = hookMatcher.input!
-              const e = s.slice(f.length)
               const isOff = f === 'off'
               const isOff = f === 'off'
               const pid = that.baseInfo.id
               const pid = that.baseInfo.id
 
 
-              const type = `hook:${tag}:${safeSnakeCase(e)}`
-              const handler = args[0]
-              const opts = args[1]
+              let type = s.slice(f.length)
+              let handler = args[0]
+              let opts = args[1]
+
+              // condition mode
+              if (typeof handler === 'string' && typeof opts === 'function') {
+                handler = handler.replace(/^logseq./, ':')
+                type = `${type}${handler}`
+                handler = opts
+                opts = args[2]
+              }
+
+              type = `hook:${tag}:${safeSnakeCase(type)}`
+
               caller[f](type, handler)
               caller[f](type, handler)
 
 
               const unlisten = () => {
               const unlisten = () => {
@@ -775,64 +789,64 @@ export class LSPluginUser
     })
     })
   }
   }
 
 
-  _execCallableAPIAsync(method: callableMethods, ...args) {
+  _execCallableAPIAsync (method: callableMethods, ...args) {
     return this._caller.callAsync(`api:call`, {
     return this._caller.callAsync(`api:call`, {
       method,
       method,
       args,
       args,
     })
     })
   }
   }
 
 
-  _execCallableAPI(method: callableMethods, ...args) {
+  _execCallableAPI (method: callableMethods, ...args) {
     this._caller.call(`api:call`, {
     this._caller.call(`api:call`, {
       method,
       method,
       args,
       args,
     })
     })
   }
   }
 
 
-  _callWin(...args) {
+  _callWin (...args) {
     return this._execCallableAPIAsync(`_callMainWin`, ...args)
     return this._execCallableAPIAsync(`_callMainWin`, ...args)
   }
   }
 
 
   /**
   /**
    * The interface methods of {@link IAppProxy}
    * The interface methods of {@link IAppProxy}
    */
    */
-  get App(): IAppProxy {
+  get App (): IAppProxy {
     return this._makeUserProxy(app, 'app')
     return this._makeUserProxy(app, 'app')
   }
   }
 
 
-  get Editor(): IEditorProxy {
+  get Editor (): IEditorProxy {
     return this._makeUserProxy(editor, 'editor')
     return this._makeUserProxy(editor, 'editor')
   }
   }
 
 
-  get DB(): IDBProxy {
+  get DB (): IDBProxy {
     return this._makeUserProxy(db, 'db')
     return this._makeUserProxy(db, 'db')
   }
   }
 
 
-  get Git(): IGitProxy {
+  get Git (): IGitProxy {
     return this._makeUserProxy(git, 'git')
     return this._makeUserProxy(git, 'git')
   }
   }
 
 
-  get UI(): IUIProxy {
+  get UI (): IUIProxy {
     return this._makeUserProxy(ui, 'ui')
     return this._makeUserProxy(ui, 'ui')
   }
   }
 
 
-  get Assets(): IAssetsProxy {
+  get Assets (): IAssetsProxy {
     return this._makeUserProxy(assets, 'assets')
     return this._makeUserProxy(assets, 'assets')
   }
   }
 
 
-  get FileStorage(): LSPluginFileStorage {
+  get FileStorage (): LSPluginFileStorage {
     let m = this._mFileStorage
     let m = this._mFileStorage
     if (!m) m = this._mFileStorage = new LSPluginFileStorage(this)
     if (!m) m = this._mFileStorage = new LSPluginFileStorage(this)
     return m
     return m
   }
   }
 
 
-  get Request(): LSPluginRequest {
+  get Request (): LSPluginRequest {
     let m = this._mRequest
     let m = this._mRequest
     if (!m) m = this._mRequest = new LSPluginRequest(this)
     if (!m) m = this._mRequest = new LSPluginRequest(this)
     return m
     return m
   }
   }
 
 
-  get Experiments(): LSPluginExperiments {
+  get Experiments (): LSPluginExperiments {
     let m = this._mExperiments
     let m = this._mExperiments
     if (!m) m = this._mExperiments = new LSPluginExperiments(this)
     if (!m) m = this._mExperiments = new LSPluginExperiments(this)
     return m
     return m
@@ -844,7 +858,7 @@ export * from './LSPlugin'
 /**
 /**
  * @internal
  * @internal
  */
  */
-export function setupPluginUserInstance(
+export function setupPluginUserInstance (
   pluginBaseInfo: LSPluginBaseInfo,
   pluginBaseInfo: LSPluginBaseInfo,
   pluginCaller: LSPluginCaller
   pluginCaller: LSPluginCaller
 ) {
 ) {

+ 42 - 37
libs/src/helpers.ts

@@ -67,7 +67,7 @@ export class PluginLogger extends EventEmitter<'change'> {
   }
   }
 
 
   write(type: string, payload: any[], inConsole?: boolean) {
   write(type: string, payload: any[], inConsole?: boolean) {
-    if (payload?.length && (true === payload[payload.length - 1])) {
+    if (payload?.length && true === payload[payload.length - 1]) {
       inConsole = true
       inConsole = true
       payload.pop()
       payload.pop()
     }
     }
@@ -117,9 +117,13 @@ export class PluginLogger extends EventEmitter<'change'> {
 }
 }
 
 
 export function isValidUUID(s: string) {
 export function isValidUUID(s: string) {
-  return (typeof s === 'string' &&
-    (s.length === 36) &&
-    (/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi).test(s))
+  return (
+    typeof s === 'string' &&
+    s.length === 36 &&
+    /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test(
+      s
+    )
+  )
 }
 }
 
 
 export function genID() {
 export function genID() {
@@ -259,9 +263,9 @@ export function setupInjectedStyle(
   el.textContent = style
   el.textContent = style
 
 
   attrs &&
   attrs &&
-  Object.entries(attrs).forEach(([k, v]) => {
-    el.setAttribute(k, v)
-  })
+    Object.entries(attrs).forEach(([k, v]) => {
+      el.setAttribute(k, v)
+    })
 
 
   document.head.append(el)
   document.head.append(el)
 
 
@@ -337,22 +341,22 @@ export function setupInjectedUI(
 
 
     // update attributes
     // update attributes
     attrs &&
     attrs &&
-    Object.entries(attrs).forEach(([k, v]) => {
-      el.setAttribute(k, v)
-    })
+      Object.entries(attrs).forEach(([k, v]) => {
+        el.setAttribute(k, v)
+      })
 
 
     let positionDirty = el.dataset.dx != null
     let positionDirty = el.dataset.dx != null
     ui.style &&
     ui.style &&
-    Object.entries(ui.style).forEach(([k, v]) => {
-      if (
-        positionDirty &&
-        ['left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
-      ) {
-        return
-      }
-
-      el.style[k] = v
-    })
+      Object.entries(ui.style).forEach(([k, v]) => {
+        if (
+          positionDirty &&
+          ['left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
+        ) {
+          return
+        }
+
+        el.style[k] = v
+      })
     return
     return
   }
   }
 
 
@@ -372,14 +376,14 @@ export function setupInjectedUI(
   content.innerHTML = ui.template
   content.innerHTML = ui.template
 
 
   attrs &&
   attrs &&
-  Object.entries(attrs).forEach(([k, v]) => {
-    el.setAttribute(k, v)
-  })
+    Object.entries(attrs).forEach(([k, v]) => {
+      el.setAttribute(k, v)
+    })
 
 
   ui.style &&
   ui.style &&
-  Object.entries(ui.style).forEach(([k, v]) => {
-    el.style[k] = v
-  })
+    Object.entries(ui.style).forEach(([k, v]) => {
+      el.style[k] = v
+    })
 
 
   let teardownUI: () => void
   let teardownUI: () => void
   let disposeFloat: () => void
   let disposeFloat: () => void
@@ -392,11 +396,11 @@ export function setupInjectedUI(
     el.classList.add('lsp-ui-float-container', 'visible')
     el.classList.add('lsp-ui-float-container', 'visible')
     disposeFloat =
     disposeFloat =
       (pl._setupResizableContainer(el, key),
       (pl._setupResizableContainer(el, key),
-        pl._setupDraggableContainer(el, {
-          key,
-          close: () => teardownUI(),
-          title: attrs?.title,
-        }))
+      pl._setupDraggableContainer(el, {
+        key,
+        close: () => teardownUI(),
+        title: attrs?.title,
+      }))
   }
   }
 
 
   if (!!slot && ui.reset) {
   if (!!slot && ui.reset) {
@@ -424,7 +428,7 @@ export function setupInjectedUI(
     'keydown',
     'keydown',
     'change',
     'change',
     'input',
     'input',
-    'contextmenu'
+    'contextmenu',
   ].forEach((type) => {
   ].forEach((type) => {
     el.addEventListener(
     el.addEventListener(
       type,
       type,
@@ -435,7 +439,8 @@ export function setupInjectedUI(
 
 
         const { preventDefault } = trigger.dataset
         const { preventDefault } = trigger.dataset
         const msgType = trigger.dataset[`on${ucFirst(type)}`]
         const msgType = trigger.dataset[`on${ucFirst(type)}`]
-        if (msgType) pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
+        if (msgType)
+          pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
         if (preventDefault?.toLowerCase() === 'true') e.preventDefault()
         if (preventDefault?.toLowerCase() === 'true') e.preventDefault()
       },
       },
       false
       false
@@ -455,12 +460,12 @@ export function setupInjectedUI(
   return teardownUI
   return teardownUI
 }
 }
 
 
-export function cleanInjectedUI(
-  id: string
-) {
+export function cleanInjectedUI(id: string) {
   if (!injectedUIEffects.has(id)) return
   if (!injectedUIEffects.has(id)) return
   const clean = injectedUIEffects.get(id)
   const clean = injectedUIEffects.get(id)
-  try { clean() } catch (e) {
+  try {
+    clean()
+  } catch (e) {
     console.warn('[CLEAN Injected UI] ', id, e)
     console.warn('[CLEAN Injected UI] ', id, e)
   }
   }
 }
 }

+ 2 - 1
libs/src/modules/LSPlugin.Experiments.ts

@@ -76,7 +76,8 @@ export class LSPluginExperiments {
 
 
     return host.logseq.api.exper_register_extensions_enhancer(
     return host.logseq.api.exper_register_extensions_enhancer(
       this.ctx.baseInfo.id,
       this.ctx.baseInfo.id,
-      type, enhancer
+      type,
+      enhancer
     )
     )
   }
   }
 
 

+ 34 - 36
libs/src/modules/LSPlugin.Request.ts

@@ -31,23 +31,19 @@ export class LSPluginRequestTask<R = any> {
     private _requestId: RequestTaskID,
     private _requestId: RequestTaskID,
     private _requestOptions: Partial<IRequestOptions> = {}
     private _requestOptions: Partial<IRequestOptions> = {}
   ) {
   ) {
-
     this._promise = new Promise<any>((resolve, reject) => {
     this._promise = new Promise<any>((resolve, reject) => {
       if (!this._requestId) {
       if (!this._requestId) {
         return reject(null)
         return reject(null)
       }
       }
 
 
       // task result listener
       // task result listener
-      this._client.once(
-        genTaskCallbackType(this._requestId),
-        (e) => {
-          if (e && e instanceof Error) {
-            reject(e)
-          } else {
-            resolve(e)
-          }
+      this._client.once(genTaskCallbackType(this._requestId), (e) => {
+        if (e && e instanceof Error) {
+          reject(e)
+        } else {
+          resolve(e)
         }
         }
-      )
+      })
     })
     })
 
 
     const { success, fail, final } = this._requestOptions
     const { success, fail, final } = this._requestOptions
@@ -65,15 +61,9 @@ export class LSPluginRequestTask<R = any> {
   }
   }
 
 
   abort() {
   abort() {
-    if (
-      !this._requestOptions.abortable ||
-      this._aborted
-    ) return
-
-    this._client.ctx._execCallableAPI(
-      'http_request_abort',
-      this._requestId
-    )
+    if (!this._requestOptions.abortable || this._aborted) return
+
+    this._client.ctx._execCallableAPI('http_request_abort', this._requestId)
 
 
     this._aborted = true
     this._aborted = true
   }
   }
@@ -99,15 +89,12 @@ export class LSPluginRequest extends EventEmitter {
     super()
     super()
 
 
     // request callback listener
     // request callback listener
-    this.ctx.caller.on(
-      CLIENT_MSG_CALLBACK,
-      (e: any) => {
-        const reqId = e?.requestId
-        if (!reqId) return
+    this.ctx.caller.on(CLIENT_MSG_CALLBACK, (e: any) => {
+      const reqId = e?.requestId
+      if (!reqId) return
 
 
-        this.emit(genTaskCallbackType(reqId), e?.payload)
-      }
-    )
+      this.emit(genTaskCallbackType(reqId), e?.payload)
+    })
   }
   }
 
 
   static createRequestTask(
   static createRequestTask(
@@ -115,21 +102,32 @@ export class LSPluginRequest extends EventEmitter {
     requestID: RequestTaskID,
     requestID: RequestTaskID,
     requestOptions: Partial<IRequestOptions>
     requestOptions: Partial<IRequestOptions>
   ) {
   ) {
-    return new LSPluginRequestTask(
-      client, requestID, requestOptions
-    )
+    return new LSPluginRequestTask(client, requestID, requestOptions)
   }
   }
 
 
-  async _request<R extends {},
-    T extends WithOptional<IRequestOptions<R>, keyof Omit<IRequestOptions, 'url'>>>(options: T):
-    Promise<T extends Pick<IRequestOptions, 'abortable'> ? LSPluginRequestTask<R> : R> {
+  async _request<
+    R extends {},
+    T extends WithOptional<
+      IRequestOptions<R>,
+      keyof Omit<IRequestOptions, 'url'>
+    >
+  >(
+    options: T
+  ): Promise<
+    T extends Pick<IRequestOptions, 'abortable'> ? LSPluginRequestTask<R> : R
+  > {
     const pid = this.ctx.baseInfo.id
     const pid = this.ctx.baseInfo.id
     const { success, fail, final, ...requestOptions } = options
     const { success, fail, final, ...requestOptions } = options
-    const reqID = this.ctx.Experiments.invokeExperMethod('request', pid, requestOptions)
+    const reqID = this.ctx.Experiments.invokeExperMethod(
+      'request',
+      pid,
+      requestOptions
+    )
 
 
     const task = LSPluginRequest.createRequestTask(
     const task = LSPluginRequest.createRequestTask(
       this.ctx.Request,
       this.ctx.Request,
-      reqID, options
+      reqID,
+      options
     )
     )
 
 
     if (!requestOptions.abortable) {
     if (!requestOptions.abortable) {
@@ -142,4 +140,4 @@ export class LSPluginRequest extends EventEmitter {
   get ctx(): LSPluginUser {
   get ctx(): LSPluginUser {
     return this._ctx
     return this._ctx
   }
   }
-}
+}

+ 48 - 51
libs/src/modules/LSPlugin.Search.ts

@@ -3,7 +3,6 @@ import { LSPluginUser } from '../LSPlugin.user'
 import { isArray, isFunction, mapKeys } from 'lodash-es'
 import { isArray, isFunction, mapKeys } from 'lodash-es'
 
 
 export class LSPluginSearchService {
 export class LSPluginSearchService {
-
   /**
   /**
    * @param ctx
    * @param ctx
    * @param serviceHooks
    * @param serviceHooks
@@ -22,61 +21,59 @@ export class LSPluginSearchService {
     // hook events TODO: remove listeners
     // hook events TODO: remove listeners
     const wrapHookEvent = (k) => `service:search:${k}:${serviceHooks.name}`
     const wrapHookEvent = (k) => `service:search:${k}:${serviceHooks.name}`
 
 
-    Object.entries(
-      {
-        query: {
-          f: 'onQuery', args: ['graph', 'q', true], reply: true,
-          transformOutput: (data: any) => {
-            // TODO: transform keys?
-            if (isArray(data?.blocks)) {
-              data.blocks = data.blocks.map(it => {
-                return it && mapKeys(it, (_, k) => `block/${k}`)
-              })
-            }
-
-            return data
+    Object.entries({
+      query: {
+        f: 'onQuery',
+        args: ['graph', 'q', true],
+        reply: true,
+        transformOutput: (data: any) => {
+          // TODO: transform keys?
+          if (isArray(data?.blocks)) {
+            data.blocks = data.blocks.map((it) => {
+              return it && mapKeys(it, (_, k) => `block/${k}`)
+            })
           }
           }
+
+          return data
         },
         },
-        rebuildBlocksIndice: { f: 'onIndiceInit', args: ['graph', 'blocks'] },
-        transactBlocks: { f: 'onBlocksChanged', args: ['graph', 'data'] },
-        truncateBlocks: { f: 'onIndiceReset', args: ['graph'] },
-        removeDb: { f: 'onGraph', args: ['graph'] }
-      }
-    ).forEach(
-      ([k, v]) => {
-        const hookEvent = wrapHookEvent(k)
-        ctx.caller.on(hookEvent, async (payload: any) => {
-          if (isFunction(serviceHooks?.[v.f])) {
-            let ret = null
+      },
+      rebuildBlocksIndice: { f: 'onIndiceInit', args: ['graph', 'blocks'] },
+      transactBlocks: { f: 'onBlocksChanged', args: ['graph', 'data'] },
+      truncateBlocks: { f: 'onIndiceReset', args: ['graph'] },
+      removeDb: { f: 'onGraph', args: ['graph'] },
+    }).forEach(([k, v]) => {
+      const hookEvent = wrapHookEvent(k)
+      ctx.caller.on(hookEvent, async (payload: any) => {
+        if (isFunction(serviceHooks?.[v.f])) {
+          let ret = null
 
 
-            try {
-              ret = await serviceHooks[v.f].apply(
-                serviceHooks, (v.args || []).map((prop: any) => {
-                  if (!payload) return
-                  if (prop === true) return payload
-                  if (payload.hasOwnProperty(prop)) {
-                    const ret = payload[prop]
-                    delete payload[prop]
-                    return ret
-                  }
-                })
-              )
+          try {
+            ret = await serviceHooks[v.f].apply(
+              serviceHooks,
+              (v.args || []).map((prop: any) => {
+                if (!payload) return
+                if (prop === true) return payload
+                if (payload.hasOwnProperty(prop)) {
+                  const ret = payload[prop]
+                  delete payload[prop]
+                  return ret
+                }
+              })
+            )
 
 
-              if (v.transformOutput) {
-                ret = v.transformOutput(ret)
-              }
-            } catch (e) {
-              console.error('[SearchService] ', e)
-              ret = e
-            } finally {
-              if (v.reply) {
-                ctx.caller.call(
-                  `${hookEvent}:reply`, ret
-                )
-              }
+            if (v.transformOutput) {
+              ret = v.transformOutput(ret)
+            }
+          } catch (e) {
+            console.error('[SearchService] ', e)
+            ret = e
+          } finally {
+            if (v.reply) {
+              ctx.caller.call(`${hookEvent}:reply`, ret)
             }
             }
           }
           }
-        })
+        }
       })
       })
+    })
   }
   }
-}
+}

+ 1 - 1
libs/src/modules/LSPlugin.Storage.ts

@@ -73,7 +73,7 @@ class LSPluginFileStorage implements IAsyncStorage {
   allKeys(): Promise<Array<string>> {
   allKeys(): Promise<Array<string>> {
     return this.ctx.caller.callAsync(`api:call`, {
     return this.ctx.caller.callAsync(`api:call`, {
       method: 'list-plugin-storage-files',
       method: 'list-plugin-storage-files',
-      args: [this.ctxId, this.opts?.assets]
+      args: [this.ctxId, this.opts?.assets],
     })
     })
   }
   }
 
 

+ 3 - 1
libs/src/postmate/index.ts

@@ -81,7 +81,9 @@ export const sanitize = (message, allowedOrigin) => {
  */
  */
 export const resolveValue = (model, property, args) => {
 export const resolveValue = (model, property, args) => {
   const unwrappedContext =
   const unwrappedContext =
-    typeof model[property] === 'function' ? model[property].apply(null, args) : model[property]
+    typeof model[property] === 'function'
+      ? model[property].apply(null, args)
+      : model[property]
   return Promise.resolve(unwrappedContext)
   return Promise.resolve(unwrappedContext)
 }
 }
 
 

文件差异内容过多而无法显示
+ 0 - 0
resources/js/lsplugin.core.js


+ 2 - 2
src/main/frontend/components/command_palette.cljs

@@ -1,7 +1,7 @@
 (ns frontend.components.command-palette
 (ns frontend.components.command-palette
   (:require [frontend.handler.command-palette :as cp]
   (:require [frontend.handler.command-palette :as cp]
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.modules.shortcut.core :as shortcut]
-            [frontend.modules.shortcut.data-helper :as shortcut-helper]
+            [frontend.modules.shortcut.utils :as shortcut-utils]
             [frontend.context.i18n :refer [t]]
             [frontend.context.i18n :refer [t]]
             [frontend.search :as search]
             [frontend.search :as search]
             [frontend.ui :as ui]
             [frontend.ui :as ui]
@@ -11,7 +11,7 @@
 
 
 (defn translate [t {:keys [id desc]}]
 (defn translate [t {:keys [id desc]}]
   (when id
   (when id
-    (let [desc-i18n (t (shortcut-helper/decorate-namespace id))]
+    (let [desc-i18n (t (shortcut-utils/decorate-namespace id))]
       (if (string/starts-with? desc-i18n "{Missing key")
       (if (string/starts-with? desc-i18n "{Missing key")
         desc
         desc
         desc-i18n))))
         desc-i18n))))

+ 2 - 2
src/main/frontend/components/plugins.css

@@ -609,7 +609,7 @@
 
 
     .cp__settings-inner {
     .cp__settings-inner {
       aside {
       aside {
-        @apply max-h-[70vh] overflow-auto mb-[-17px] p-3;
+        @apply max-h-[70vh] overflow-auto p-3;
 
 
         ul {
         ul {
           @apply list-none p-0 m-0;
           @apply list-none p-0 m-0;
@@ -991,7 +991,7 @@ html[data-theme='dark'] {
 .ui__modal[label=plugins-dashboard] {
 .ui__modal[label=plugins-dashboard] {
   .panel-content {
   .panel-content {
     overflow-y: auto;
     overflow-y: auto;
-    max-height: calc(100vh - 100px);
+    max-height: calc(100vh - 50px);
   }
   }
 }
 }
 
 

+ 1 - 1
src/main/frontend/components/plugins_settings.cljs

@@ -29,7 +29,7 @@
   [val {:keys [key type title default description inputAs]} update-setting!]
   [val {:keys [key type title default description inputAs]} update-setting!]
 
 
   [:div.desc-item.as-input
   [:div.desc-item.as-input
-   {:data-key key}
+   {:data-key key :key key}
    [:h2 [:code key] (ui/icon "caret-right") [:strong title]]
    [:h2 [:code key] (ui/icon "caret-right") [:strong title]]
 
 
    [:label.form-control
    [:label.form-control

+ 32 - 5
src/main/frontend/components/settings.cljs

@@ -23,6 +23,7 @@
             [frontend.mobile.util :as mobile-util]
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.instrumentation.core :as instrument]
             [frontend.modules.instrumentation.core :as instrument]
             [frontend.modules.shortcut.data-helper :as shortcut-helper]
             [frontend.modules.shortcut.data-helper :as shortcut-helper]
+            [frontend.components.shortcut2 :as shortcut2]
             [frontend.spec.storage :as storage-spec]
             [frontend.spec.storage :as storage-spec]
             [frontend.state :as state]
             [frontend.state :as state]
             [frontend.storage :as storage]
             [frontend.storage :as storage]
@@ -578,13 +579,13 @@
 (rum/defc user-proxy-settings
 (rum/defc user-proxy-settings
   [{:keys [type protocol host port] :as agent-opts}]
   [{:keys [type protocol host port] :as agent-opts}]
   (ui/button [:span.flex.items-center
   (ui/button [:span.flex.items-center
-              [:strong.pr-1
+              [:span.pr-1
                (case type
                (case type
                  "system" "System Default"
                  "system" "System Default"
                  "direct" "Direct"
                  "direct" "Direct"
                  (and protocol host port (str protocol "://" host ":" port)))]
                  (and protocol host port (str protocol "://" host ":" port)))]
               (ui/icon "edit")]
               (ui/icon "edit")]
-             :small? true
+             :class "text-sm p-1"
              :on-click #(state/set-sub-modal!
              :on-click #(state/set-sub-modal!
                          (fn [_] (plugins/user-proxy-settings-panel agent-opts))
                          (fn [_] (plugins/user-proxy-settings-panel agent-opts))
                          {:id :https-proxy-panel :center? true})))
                          {:id :https-proxy-panel :center? true})))
@@ -1037,18 +1038,41 @@
 
 
 (def DEFAULT-ACTIVE-TAB-STATE (if config/ENABLE-SETTINGS-ACCOUNT-TAB [:account :account] [:general :general]))
 (def DEFAULT-ACTIVE-TAB-STATE (if config/ENABLE-SETTINGS-ACCOUNT-TAB [:account :account] [:general :general]))
 
 
+(rum/defc settings-effect
+  < rum/static
+  [active]
+
+  (rum/use-effect!
+    (fn []
+      (let [active (and (sequential? active) (name (first active)))
+            ^js ds (.-dataset js/document.body)]
+        (if active
+          (set! (.-settingsTab ds) active)
+          (js-delete ds "settingsTab"))
+        #(js-delete ds "settingsTab")))
+    [active])
+
+  [:<>])
+
 (rum/defcs settings
 (rum/defcs settings
   < (rum/local DEFAULT-ACTIVE-TAB-STATE ::active)
   < (rum/local DEFAULT-ACTIVE-TAB-STATE ::active)
     {:will-mount
     {:will-mount
      (fn [state]
      (fn [state]
        (state/load-app-user-cfgs)
        (state/load-app-user-cfgs)
        state)
        state)
+     :did-mount
+     (fn [state]
+       (let [active-tab (first (:rum/args state))
+             *active (::active state)]
+         (when (keyword? active-tab)
+           (reset! *active [active-tab nil])))
+       state)
      :will-unmount
      :will-unmount
      (fn [state]
      (fn [state]
        (state/close-settings!)
        (state/close-settings!)
        state)}
        state)}
     rum/reactive
     rum/reactive
-  [state]
+  [state _active-tab]
   (let [current-repo (state/sub :git/current-repo)
   (let [current-repo (state/sub :git/current-repo)
         ;; enable-block-timestamps? (state/enable-block-timestamps?)
         ;; enable-block-timestamps? (state/enable-block-timestamps?)
         _installed-plugins (state/sub :plugin/installed-plugins)
         _installed-plugins (state/sub :plugin/installed-plugins)
@@ -1056,9 +1080,8 @@
         *active (::active state)]
         *active (::active state)]
 
 
     [:div#settings.cp__settings-main
     [:div#settings.cp__settings-main
-
+     (settings-effect @*active)
      [:div.cp__settings-inner
      [:div.cp__settings-inner
-
       [:aside.md:w-64 {:style {:min-width "10rem"}}
       [:aside.md:w-64 {:style {:min-width "10rem"}}
        [:header.cp__settings-header
        [:header.cp__settings-header
         (ui/icon "settings")
         (ui/icon "settings")
@@ -1069,6 +1092,7 @@
                 [:account "account" (t :settings-page/tab-account) (ui/icon "user-circle")])
                 [:account "account" (t :settings-page/tab-account) (ui/icon "user-circle")])
                [:general "general" (t :settings-page/tab-general) (ui/icon "adjustments")]
                [:general "general" (t :settings-page/tab-general) (ui/icon "adjustments")]
                [:editor "editor" (t :settings-page/tab-editor) (ui/icon "writing")]
                [:editor "editor" (t :settings-page/tab-editor) (ui/icon "writing")]
+               [:keymap "keymap" (t :settings-page/tab-keymap) (ui/icon "keyboard")]
 
 
                (when (util/electron?)
                (when (util/electron?)
                  [:version-control "git" (t :settings-page/tab-version-control) (ui/icon "history")])
                  [:version-control "git" (t :settings-page/tab-version-control) (ui/icon "history")])
@@ -1114,6 +1138,9 @@
          :editor
          :editor
          (settings-editor current-repo)
          (settings-editor current-repo)
 
 
+         :keymap
+         (shortcut2/shortcut-keymap-x)
+
          :version-control
          :version-control
          (settings-git)
          (settings-git)
 
 

+ 40 - 52
src/main/frontend/components/settings.css

@@ -1,23 +1,35 @@
 .cp__settings {
 .cp__settings {
-  &-main {
-    aside {
+  &-inner {
+    @apply flex flex-col md:flex-row;
+
+    > aside {
       @apply bg-gray-400/5 p-4;
       @apply bg-gray-400/5 p-4;
+
+      > ul > li {
+        > a {
+          @apply mb-2;
+
+          > strong {
+            font-size: 14px;
+            font-weight: normal;
+            padding-left: 5px;
+            opacity: .9;
+          }
+        }
+
+        &.active {
+          background-color: var(--ls-quaternary-background-color);
+        }
+      }
     }
     }
 
 
-    article {
+    > article {
       @apply p-4 flex-1 min-h-[12rem] w-auto overflow-y-auto;
       @apply p-4 flex-1 min-h-[12rem] w-auto overflow-y-auto;
       @apply md:max-h-[70vh] md:w-[40rem];
       @apply md:max-h-[70vh] md:w-[40rem];
-      /* margin-right: -17px; */
-      /* margin-bottom: -17px; */
-
-      @screen md {
-        /* max-height: 70vh; */
-        /* width: 680px; */
-      }
     }
     }
 
 
-    aside > .cp__settings-header,
-    article > .cp__settings-header {
+    > aside > .cp__settings-header,
+    > article > .cp__settings-header {
       @apply h-10 py-2 flex flex-row items-center justify-start gap-2;
       @apply h-10 py-2 flex flex-row items-center justify-start gap-2;
     }
     }
 
 
@@ -41,13 +53,13 @@
       @apply text-xl lowercase;
       @apply text-xl lowercase;
     }
     }
 
 
-    h1.cp__settings-modal-title:first-letter, 
+    h1.cp__settings-modal-title:first-letter,
     h1.cp__settings-category-title:first-letter {
     h1.cp__settings-category-title:first-letter {
       @apply uppercase;
       @apply uppercase;
     }
     }
 
 
     .settings-menu {
     .settings-menu {
-      @apply p-0 m-0 mt-4 pr-3; 
+      @apply p-0 m-0 mt-4;
     }
     }
 
 
     .settings-menu-item {
     .settings-menu-item {
@@ -56,46 +68,10 @@
     }
     }
 
 
     .settings-menu-link {
     .settings-menu-link {
-      @apply px-2 py-1.5 select-none; 
+      @apply px-2 py-1.5 select-none;
       color: var(--ls-primary-text-color);
       color: var(--ls-primary-text-color);
     }
     }
-  }
-
-  &-inner {
-    @apply flex flex-col md:flex-row;
-
-    > aside {
-
-      ul {
 
 
-        > li {
-
-          > a {
-
-            > i {
-              overflow: hidden;
-              opacity: .9;
-            }
-
-            > strong {
-              font-size: 14px;
-              font-weight: normal;
-              padding-left: 5px;
-              margin-top: 2px;
-              opacity: .9;
-            }
-          }
-
-          &.active {
-            background-color: var(--ls-quaternary-background-color);
-
-            i {
-              opacity: 1;
-            }
-          }
-        }
-      }
-    }
 
 
     &.no-aside {
     &.no-aside {
       > article {
       > article {
@@ -392,7 +368,7 @@
         z-index: 1;
         z-index: 1;
         width: 100px;
         width: 100px;
         max-height: 180px;
         max-height: 180px;
-        border:1px solid var(--ls-border-color);
+        border: 1px solid var(--ls-border-color);
         border-radius: 4px;
         border-radius: 4px;
         overflow: auto;
         overflow: auto;
         overflow: overlay;
         overflow: overlay;
@@ -465,3 +441,15 @@ svg.git {
 svg.cmd {
 svg.cmd {
   margin-left: -1px;
   margin-left: -1px;
 }
 }
+
+body[data-settings-tab=keymap] {
+  .cp__settings-inner {
+    > article {
+      @apply md:w-[70vw] xl:max-w-[850px] p-0;
+
+      > header {
+        @apply p-4 pb-2 h-auto;
+      }
+    }
+  }
+}

+ 4 - 15
src/main/frontend/components/shortcut.cljs

@@ -3,6 +3,7 @@
             [frontend.context.i18n :refer [t]]
             [frontend.context.i18n :refer [t]]
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.modules.shortcut.data-helper :as dh]
             [frontend.modules.shortcut.data-helper :as dh]
+            [frontend.modules.shortcut.utils :as shortcut-utils]
             [frontend.state :as state]
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.ui :as ui]
             [frontend.extensions.latex :as latex]
             [frontend.extensions.latex :as latex]
@@ -104,7 +105,7 @@
                                [:code.text-xs (namespace k)]
                                [:code.text-xs (namespace k)]
                                [:small.pl-1 (:desc cmd)]]
                                [:small.pl-1 (:desc cmd)]]
 
 
-                              (not plugin?) (-> k (dh/decorate-namespace) (t))
+                              (not plugin?) (-> k (shortcut-utils/decorate-namespace) (t))
                               :else (str k))]
                               :else (str k))]
                   [:tr {:key (str k)}
                   [:tr {:key (str k)}
                    [:td.text-left.flex.items-center label]
                    [:td.text-left.flex.items-center label]
@@ -204,23 +205,11 @@
    (shortcut-table :shortcut.category/block-selection true)
    (shortcut-table :shortcut.category/block-selection true)
    (shortcut-table :shortcut.category/formatting true)
    (shortcut-table :shortcut.category/formatting true)
    (shortcut-table :shortcut.category/toggle true)
    (shortcut-table :shortcut.category/toggle true)
-   (when (state/enable-whiteboards?) (shortcut-table :shortcut.category/whiteboard true))
+   (when (state/enable-whiteboards?)
+     (shortcut-table :shortcut.category/whiteboard true))
    (shortcut-table :shortcut.category/plugins true)
    (shortcut-table :shortcut.category/plugins true)
    (shortcut-table :shortcut.category/others true)])
    (shortcut-table :shortcut.category/others true)])
 
 
-(rum/defc keymap-pane
-  []
-  (let [[ready?, set-ready!] (rum/use-state false)]
-    (rum/use-effect!
-      (fn [] (js/setTimeout #(set-ready! true) 32))
-      [])
-
-    [:div.cp__keymap-pane
-     [:h1.pb-2.text-3xl.pt-2 "Keymap"]
-     (if ready?
-       (keymap-tables)
-       [:p.flex.justify-center.py-20 (ui/loading "")])]))
-
 (rum/defc shortcut-page
 (rum/defc shortcut-page
   [{:keys [show-title?]
   [{:keys [show-title?]
     :or {show-title? true}}]
     :or {show-title? true}}]

+ 165 - 0
src/main/frontend/components/shortcut.css

@@ -25,4 +25,169 @@
       }
       }
     }
     }
   }
   }
+}
+
+.cp__shortcut-page-x {
+  @apply relative;
+
+  &-pane-controls {
+    @apply flex space-x-3 absolute top-[-4px] right-4 items-center;
+
+    .search-input-wrap {
+      @apply pr-1 relative;
+
+      a.x {
+        @apply flex items-center absolute right-1 top-0 py-[7px] px-1 opacity-60
+        hover:opacity-90;
+      }
+    }
+
+    input.form-input {
+      @apply py-1;
+    }
+
+    a.icon-link {
+      @apply opacity-80 hover:opacity-100 active:opacity-40 select-none;
+
+      color: var(--ls-secondary-text-color);
+    }
+
+    .keyboard-filter {
+      .dropdown-wrapper {
+        @apply shadow-lg w-[18rem];
+      }
+
+      .keyboard-filter-record {
+        > h2 {
+          @apply flex items-center justify-between px-1.5 py-1;
+
+          background-color: var(--ls-secondary-background-color);
+
+          > strong {
+            @apply text-[12px] opacity-80;
+
+            font-weight: 400;
+          }
+        }
+      }
+    }
+  }
+
+  > header {
+    @apply px-4 pb-4 pt-2;
+
+    > h2 {
+      @apply relative top-[-6px];
+    }
+  }
+
+  > article {
+    @apply relative pb-4 overflow-y-auto;
+
+    max-height: calc(70vh - 100px);
+    overflow-y: overlay;
+
+    > ul {
+      @apply px-4 m-0 py-0;
+
+      li {
+        @apply text-[15px] px-1;
+
+        &.th {
+          @apply rounded mb-2 sticky top-0 cursor-pointer
+          select-none active:opacity-80 px-2 py-1 z-[1];
+
+          background-color: var(--ls-tertiary-background-color);
+        }
+
+        .label-wrap {
+          @apply flex flex-1;
+        }
+
+        .action-wrap {
+          @apply flex space-x-2 items-center flex-nowrap
+          select-none active:opacity-70;
+
+          &.disabled {
+            @apply opacity-60 cursor-default;
+          }
+        }
+      }
+    }
+  }
+
+  &-record-dialog-inner {
+    @apply py-[28px] m-[-30px] px-[20px];
+
+    h1 {
+      @apply relative top-[-8px];
+    }
+
+    &:active, &:focus, &:focus-within {
+      outline: burlywood hidden medium;
+    }
+
+    .shortcuts-keys-wrap {
+      @apply flex items-center my-4 flex-wrap;
+
+      .shortcut-record-control {
+        @apply flex space-x-1 items-center select-none
+        rounded border-[2px] py-[2px] px-[2px];
+      }
+
+      .keyboard-shortcut {
+        > code {
+          @apply relative select-none tracking-wider;
+
+          a.x {
+            @apply hidden absolute right-[-8px] top-[-6px] h-[16px] w-[16px]
+            rounded-full bg-red-500 text-white leading-none items-center
+            justify-center cursor-pointer opacity-80 hover:opacity-100 active:opacity-50;
+          }
+
+          &:hover a.x {
+            @apply flex;
+          }
+        }
+      }
+    }
+
+    &.keypressed {
+      .shortcut-record-control {
+        @apply pt-0
+      }
+    }
+
+    .action-btns {
+      .keyboard-shortcut code {
+        @apply rounded-[3px];
+      }
+    }
+
+    .reset-btn {
+      @apply ml-4 opacity-50 cursor-default;
+    }
+
+    &.dirty {
+      .reset-btn {
+        @apply opacity-100 cursor-pointer;
+      }
+    }
+  }
+}
+
+.cp__shortcut-conflicts-list {
+  &-wrap {
+    > section {
+      @apply bg-gray-3 border-[2px] mb-3 dark:bg-transparent;
+
+      > ul {
+        @apply px-2 pb-2 m-0 list-none;
+      }
+
+      > h2 {
+        @apply flex items-center p-2 text-red-9 text-sm space-x-1 font-extrabold;
+      }
+    }
+  }
 }
 }

+ 476 - 0
src/main/frontend/components/shortcut2.cljs

@@ -0,0 +1,476 @@
+(ns frontend.components.shortcut2
+  (:require [clojure.string :as string]
+            [rum.core :as rum]
+            [frontend.context.i18n :refer [t]]
+            [cljs-bean.core :as bean]
+            [frontend.state :as state]
+            [frontend.search :as search]
+            [frontend.ui :as ui]
+            [frontend.rum :as r]
+            [goog.events :as events]
+            [promesa.core :as p]
+            [frontend.handler.notification :as notification]
+            [frontend.modules.shortcut.core :as shortcut]
+            [frontend.modules.shortcut.data-helper :as dh]
+            [frontend.util :as util]
+            [frontend.modules.shortcut.utils :as shortcut-utils]
+            [frontend.modules.shortcut.config :as shortcut-config])
+  (:import [goog.events KeyHandler]))
+
+(defonce categories
+         (vector :shortcut.category/basics
+                 :shortcut.category/navigating
+                 :shortcut.category/block-editing
+                 :shortcut.category/block-command-editing
+                 :shortcut.category/block-selection
+                 :shortcut.category/formatting
+                 :shortcut.category/toggle
+                 :shortcut.category/whiteboard
+                 :shortcut.category/plugins
+                 :shortcut.category/others))
+
+(defonce *refresh-sentry (atom 0))
+(defn refresh-shortcuts-list! [] (reset! *refresh-sentry (inc @*refresh-sentry)))
+(defonce *global-listener-setup? (atom false))
+(defonce *customize-modal-life-sentry (atom 0))
+
+(defn- to-vector [v]
+  (when-not (nil? v)
+    (if (sequential? v) (vec v) [v])))
+
+(declare customize-shortcut-dialog-inner)
+
+(rum/defc keyboard-filter-record-inner
+  [keystroke set-keystroke! close-fn]
+
+  (let [keypressed? (not= "" keystroke)]
+
+    (rum/use-effect!
+      (fn []
+        (let [key-handler (KeyHandler. js/document)]
+          ;; setup
+          (util/profile
+            "[shortcuts] unlisten*"
+            (shortcut/unlisten-all! true))
+          (events/listen key-handler "key"
+                         (fn [^js e]
+                           (.preventDefault e)
+                           (set-keystroke! #(util/trim-safe (str % (shortcut/keyname e))))))
+
+          ;; teardown
+          #(do
+             (util/profile
+               "[shortcuts] listen*"
+               (shortcut/listen-all!))
+             (.dispose key-handler))))
+      [])
+
+    [:div.keyboard-filter-record
+     [:h2
+      [:strong (t :keymap/keystroke-filter)]
+      [:span.flex.space-x-2
+       (when keypressed?
+         [:a.flex.items-center
+          {:on-click #(set-keystroke! "")} (ui/icon "zoom-reset" {:size 12})])
+       [:a.flex.items-center
+        {:on-click #(do (close-fn) (set-keystroke! ""))} (ui/icon "x" {:size 12})]]]
+     [:div.wrap.p-2
+      (if-not keypressed?
+        [:small (t :keymap/keystroke-record-desc)]
+        (when-not (string/blank? keystroke)
+          (ui/render-keyboard-shortcut [keystroke])))]]))
+
+(rum/defc pane-controls
+  [q set-q! filters set-filters! keystroke set-keystroke! toggle-categories-fn]
+  (let [*search-ref (rum/use-ref nil)]
+    [:div.cp__shortcut-page-x-pane-controls
+     [:a.flex.items-center.icon-link
+      {:on-click toggle-categories-fn
+       :title "Toggle categories pane"}
+      (ui/icon "fold")]
+
+     [:a.flex.items-center.icon-link
+      {:on-click refresh-shortcuts-list!
+       :title "Refresh all"}
+      (ui/icon "refresh")]
+
+     [:span.search-input-wrap
+      [:input.form-input.is-small
+       {:placeholder (t :keymap/search)
+        :ref         *search-ref
+        :value       (or q "")
+        :auto-focus  true
+        :on-key-down #(when (= 27 (.-keyCode %))
+                        (util/stop %)
+                        (if (string/blank? q)
+                          (some-> (rum/deref *search-ref) (.blur))
+                          (set-q! "")))
+        :on-change   #(let [v (util/evalue %)]
+                        (set-q! v))}]
+
+      (when-not (string/blank? q)
+        [:a.x
+         {:on-click (fn []
+                      (set-q! "")
+                      (js/setTimeout #(some-> (rum/deref *search-ref) (.focus)) 50))}
+         (ui/icon "x" {:size 14})])]
+
+     ;; keyboard filter
+     (ui/dropdown
+       (fn [{:keys [toggle-fn]}]
+         [:a.flex.items-center.icon-link
+          {:on-click toggle-fn} (ui/icon "keyboard")
+
+          (when-not (string/blank? keystroke)
+            (ui/point "bg-red-600.absolute" 4 {:style {:right -2 :top -2}}))])
+       (fn [{:keys [close-fn]}]
+         (keyboard-filter-record-inner keystroke set-keystroke! close-fn))
+       {:outside?      true
+        :trigger-class "keyboard-filter"})
+
+     ;; other filter
+     (ui/dropdown-with-links
+       (fn [{:keys [toggle-fn]}]
+         [:a.flex.items-center.icon-link.relative
+          {:on-click toggle-fn}
+          (ui/icon "filter")
+
+          (when (seq filters)
+            (ui/point "bg-red-600.absolute" 4 {:style {:right -2 :top -2}}))])
+
+       (for [k [:All :Disabled :Unset :Custom]
+             :let [all? (= k :All)
+                   checked? (or (contains? filters k) (and all? (nil? (seq filters))))]]
+
+         {:title   (if all? (t :keymap/all) (t (keyword :keymap (string/lower-case (name k)))))
+          :icon    (ui/icon (if checked? "checkbox" "square"))
+          :options {:on-click #(set-filters! (if all? #{} (let [f (if checked? disj conj)] (f filters k))))}})
+
+       nil)]))
+
+(rum/defc shortcut-desc-label
+  [id binding-map]
+  (when-let [id' (and id binding-map (some-> (str id) (string/replace "plugin." "")))]
+    [:span {:title (str id' "#" (some-> (:handler-id binding-map) (name)))}
+     [:span.pl-1 (dh/get-shortcut-desc (assoc binding-map :id id))]
+     [:small.pl-1 [:code.text-xs (str id')]]]))
+
+(defn- open-customize-shortcut-dialog!
+  [id]
+  (when-let [{:keys [binding user-binding] :as m} (dh/shortcut-item id)]
+    (let [binding (to-vector binding)
+          user-binding (and user-binding (to-vector user-binding))
+          modal-id (str :customize-shortcut id)
+          label (shortcut-desc-label id m)
+          args [id label binding user-binding
+                {:saved-cb (fn [] (-> (p/delay 500) (p/then refresh-shortcuts-list!)))
+                 :modal-id modal-id}]]
+      (state/set-sub-modal!
+        (fn [] (apply customize-shortcut-dialog-inner args))
+        {:center? true
+         :id      modal-id
+         :payload args}))))
+
+(rum/defc shortcut-conflicts-display
+  [_k conflicts-map]
+
+  [:div.cp__shortcut-conflicts-list-wrap
+   (for [[g ks] conflicts-map]
+     [:section.relative
+      [:h2 (ui/icon "alert-triangle" {:size 15})
+       [:span (t :keymap/conflicts-for-label)]
+       [:code (shortcut-utils/decorate-binding g)]]
+      [:ul
+       (for [v (vals ks)
+             :let [k (first v)
+                   vs (second v)]]
+         (for [[id' handler-id] vs
+               :let [m (dh/shortcut-item id')]
+               :when (not (nil? m))]
+           [:li
+            {:key (str id')}
+            [:a.select-none.hover:underline
+             {:on-click #(open-customize-shortcut-dialog! id')
+              :title (str handler-id)}
+             [:code.inline-block.mr-1.text-xs
+              (shortcut-utils/decorate-binding k)]
+             [:span
+              (dh/get-shortcut-desc m)
+              (ui/icon "external-link" {:size 18})]
+             [:code [:small (str id')]]]]))]])])
+
+(rum/defc ^:large-vars/cleanup-todo customize-shortcut-dialog-inner
+  [k action-name binding user-binding {:keys [saved-cb modal-id]}]
+  (let [*ref-el (rum/use-ref nil)
+        [modal-life _] (r/use-atom *customize-modal-life-sentry)
+        [keystroke set-keystroke!] (rum/use-state "")
+        [current-binding set-current-binding!] (rum/use-state (or user-binding binding))
+        [key-conflicts set-key-conflicts!] (rum/use-state nil)
+
+        handler-id (rum/use-memo #(dh/get-group k))
+        dirty? (not= (or user-binding binding) current-binding)
+        keypressed? (not= "" keystroke)
+
+        save-keystroke-fn!
+        (fn []
+          ;; parse current binding conflicts
+          (if-let [current-conflicts (seq (dh/parse-conflicts-from-binding current-binding keystroke))]
+            (notification/show!
+              (str "Shortcut conflicts from existing binding: "
+                   (pr-str (some->> current-conflicts (map #(shortcut-utils/decorate-binding %)))))
+              :error true :shortcut-conflicts/warning 5000)
+
+            ;; get conflicts from the existed bindings map
+            (let [conflicts-map (dh/get-conflicts-by-keys keystroke handler-id)]
+              (if-not (seq conflicts-map)
+                (do (set-current-binding! (conj current-binding keystroke))
+                    (set-keystroke! "")
+                    (set-key-conflicts! nil))
+
+                ;; show conflicts
+                (set-key-conflicts! conflicts-map)))))]
+
+    (rum/use-effect!
+      (fn []
+        (let [mid (state/sub :modal/id)
+              mid' (some-> (state/sub :modal/subsets) (last) (:modal/id))
+              el (rum/deref *ref-el)]
+          (when (or (and (not mid') (= mid modal-id))
+                    (= mid' modal-id))
+            (some-> el (.focus))
+            (js/setTimeout
+              #(some-> (.querySelector el ".shortcut-record-control a.submit")
+                       (.click)) 200))))
+      [modal-life])
+
+    (rum/use-effect!
+      (fn []
+        (let [^js el (rum/deref *ref-el)
+              key-handler (KeyHandler. el)
+
+              teardown-global!
+              (when-not @*global-listener-setup?
+                (shortcut/unlisten-all! true)
+                (reset! *global-listener-setup? true)
+                (fn []
+                  (shortcut/listen-all!)
+                  (reset! *global-listener-setup? false)))]
+
+          ;; setup
+          (events/listen key-handler "key"
+                         (fn [^js e]
+                           (.preventDefault e)
+                           (set-key-conflicts! nil)
+                           (set-keystroke! #(util/trim-safe (str % (shortcut/keyname e))))))
+
+          ;; active
+          (.focus el)
+
+          ;; teardown
+          #(do (some-> teardown-global! (apply nil))
+               (.dispose key-handler)
+               (swap! *customize-modal-life-sentry inc))))
+      [])
+
+    [:div.cp__shortcut-page-x-record-dialog-inner
+     {:class     (util/classnames [{:keypressed keypressed? :dirty dirty?}])
+      :tab-index -1
+      :ref       *ref-el}
+     [:div.sm:w-lsm
+      [:h1.text-2xl.pb-2
+       (t :keymap/customize-for-label)]
+
+      [:p.mb-4.text-md [:b action-name]]
+
+      [:div.shortcuts-keys-wrap
+       [:span.keyboard-shortcut.flex.flex-wrap.mr-2.space-x-2
+        (for [x current-binding]
+          [:code.tracking-wider
+           (-> x (string/trim) (string/lower-case) (shortcut-utils/decorate-binding))
+           [:a.x {:on-click (fn [] (set-current-binding!
+                                     (->> current-binding (remove #(= x %)) (into []))))}
+            (ui/icon "x" {:size 12})]])]
+
+       ;; add shortcut
+       [:div.shortcut-record-control
+        ;; keypressed state
+        (if keypressed?
+          [:<>
+           (when-not (string/blank? keystroke)
+             (ui/render-keyboard-shortcut [keystroke]))
+
+           [:a.flex.items-center.active:opacity-90.submit
+            {:on-click save-keystroke-fn!}
+            (ui/icon "check" {:size 14})]
+           [:a.flex.items-center.text-red-600.hover:text-red-700.active:opacity-90.cancel
+            {:on-click (fn []
+                         (set-keystroke! "")
+                         (set-key-conflicts! nil))}
+            (ui/icon "x" {:size 14})]]
+
+          [:code.flex.items-center
+           [:small.pr-1 (t :keymap/keystroke-record-setup-label)] (ui/icon "keyboard" {:size 14})])]]]
+
+     ;; conflicts results
+     (when (seq key-conflicts)
+       (shortcut-conflicts-display k key-conflicts))
+
+     [:div.action-btns.text-right.mt-6.flex.justify-between.items-center
+      ;; restore default
+      (when (sequential? binding)
+        [:a.flex.items-center.space-x-1.text-sm.opacity-70.hover:opacity-100
+         {:on-click #(set-current-binding! binding)}
+         (t :keymap/restore-to-default)
+         (for [it (some->> binding (map #(some->> % (dh/mod-key) (shortcut-utils/decorate-binding))))]
+           [:span.keyboard-shortcut.ml-1 [:code it]])])
+
+      [:span
+       (ui/button
+         (t :save)
+         :disabled (not dirty?)
+         :on-click (fn []
+                     ;; TODO: check conflicts for the single same leader key
+                     (let [binding' (if (nil? current-binding) [] current-binding)
+                           conflicts (dh/get-conflicts-by-keys binding' handler-id {:exclude-ids #{k}})]
+                       (if (seq conflicts)
+                         (set-key-conflicts! conflicts)
+                         (let [binding' (if (= binding binding') nil binding')]
+                           (shortcut/persist-user-shortcut! k binding')
+                           ;(notification/show! "Saved!" :success)
+                           (state/close-modal!)
+                           (saved-cb))))))
+
+       [:a.reset-btn
+        {:on-click (fn [] (set-current-binding! (or user-binding binding)))}
+        (t :reset)]]]]))
+
+(defn build-categories-map
+  []
+  (->> categories
+       (map #(vector % (into (sorted-map) (dh/binding-by-category %))))))
+
+(rum/defc ^:large-vars/cleanup-todo shortcut-keymap-x
+  []
+  (let [_ (r/use-atom shortcut-config/*category)
+        _ (r/use-atom *refresh-sentry)
+        [ready?, set-ready!] (rum/use-state false)
+        [filters, set-filters!] (rum/use-state #{})
+        [keystroke, set-keystroke!] (rum/use-state "")
+        [q set-q!] (rum/use-state nil)
+
+        categories-list-map (build-categories-map)
+        all-categories (into #{} (map first categories-list-map))
+        in-filters? (boolean (seq filters))
+        in-query? (not (string/blank? (util/trim-safe q)))
+        in-keystroke? (not (string/blank? keystroke))
+
+        [folded-categories set-folded-categories!] (rum/use-state #{})
+
+        matched-list-map
+        (when (and in-query? (not in-keystroke?))
+          (->> categories-list-map
+               (map (fn [[c binding-map]]
+                      [c (search/fuzzy-search
+                           binding-map q
+                           :extract-fn
+                           #(let [[id m] %]
+                              (str (name id) " " (dh/get-shortcut-desc (assoc m :id id)))))]))))
+
+        result-list-map (or matched-list-map categories-list-map)
+        toggle-categories! #(if (= folded-categories all-categories)
+                              (set-folded-categories! #{})
+                              (set-folded-categories! all-categories))]
+
+    (rum/use-effect!
+      (fn []
+        (js/setTimeout #(set-ready! true) 100))
+      [])
+
+    [:div.cp__shortcut-page-x
+     [:header.relative
+      [:h2.text-xs.opacity-70
+       (str (t :keymap/total)
+            " "
+            (if ready?
+              (apply + (map #(count (second %)) result-list-map))
+              " ..."))]
+
+      (pane-controls q set-q! filters set-filters! keystroke set-keystroke! toggle-categories!)]
+
+     [:article
+      (when-not ready?
+        [:p.py-8.flex.justify-center (ui/loading "")])
+
+      (when ready?
+        [:ul.list-none.m-0.py-3
+         (for [[c binding-map] result-list-map
+               :let [folded? (contains? folded-categories c)]]
+           [:<>
+            ;; category row
+            (when (and (not in-query?)
+                       (not in-filters?)
+                       (not in-keystroke?))
+              [:li.flex.justify-between.th
+               {:key      (str c)
+                :on-click #(let [f (if folded? disj conj)]
+                             (set-folded-categories! (f folded-categories c)))}
+               [:strong.font-semibold (t c)]
+               [:i.flex.items-center
+                (ui/icon (if folded? "chevron-left" "chevron-down"))]])
+
+            ;; binding row
+            (when (or in-query? in-filters? (not folded?))
+              (for [[id {:keys [binding user-binding] :as m}] binding-map
+                    :let [binding (to-vector binding)
+                          user-binding (and user-binding (to-vector user-binding))
+                          label (shortcut-desc-label id m)
+                          custom? (not (nil? user-binding))
+                          disabled? (or (false? user-binding)
+                                        (false? (first binding)))
+                          unset? (and (not disabled?)
+                                      (= user-binding []))]]
+
+                (when (or (nil? (seq filters))
+                          (when (contains? filters :Custom) custom?)
+                          (when (contains? filters :Disabled) disabled?)
+                          (when (contains? filters :Unset) unset?))
+
+                  ;; keystrokes filter
+                  (when (or (not in-keystroke?)
+                            (and (not disabled?)
+                                 (not unset?)
+                                 (let [binding' (or user-binding binding)
+                                       keystroke' (some-> (shortcut-utils/safe-parse-string-binding keystroke) (bean/->clj))]
+                                   (when (sequential? binding')
+                                     (some #(when-let [s (some-> % (dh/mod-key) (shortcut-utils/safe-parse-string-binding) (bean/->clj))]
+                                              (or (= s keystroke')
+                                                  (and (sequential? s) (sequential? keystroke')
+                                                       (apply = (map first [s keystroke']))))) binding')))))
+
+                    [:li.flex.items-center.justify-between.text-sm
+                     {:key (str id)}
+                     [:span.label-wrap label]
+
+                     [:a.action-wrap
+                      {:class    (util/classnames [{:disabled disabled?}])
+                       :on-click (when-not disabled?
+                                   #(open-customize-shortcut-dialog! id))}
+
+                      (cond
+                        (or user-binding (false? user-binding))
+                        [:code.dark:bg-green-800.bg-green-300
+                         (if unset?
+                           (t :keymap/unset)
+                           (str (t :keymap/custom) ": "
+                                (if disabled?
+                                  (t :keymap/disabled)
+                                  (bean/->js
+                                    (map #(if (false? %)
+                                            (t :keymap/disabled)
+                                            (shortcut-utils/decorate-binding %)) user-binding)))))]
+
+                        (not unset?)
+                        (for [x binding]
+                          [:code.tracking-wide
+                           {:key (str x)}
+                           (dh/binding-for-display id x)]))]]))))])])]]))

+ 1 - 1
src/main/frontend/components/theme.cljs

@@ -92,7 +92,7 @@
     (rum/use-effect!
     (rum/use-effect!
      #(state/set-modal!
      #(state/set-modal!
        (when settings-open?
        (when settings-open?
-         (fn [] [:div.settings-modal (settings/settings)])))
+         (fn [] [:div.settings-modal (settings/settings settings-open?)])))
      [settings-open?])
      [settings-open?])
 
 
     (rum/use-effect!
     (rum/use-effect!

+ 1 - 1
src/main/frontend/components/whiteboard.cljs

@@ -292,7 +292,7 @@
      (tldraw-app page-name block-id)]))
      (tldraw-app page-name block-id)]))
 
 
 (rum/defc whiteboard-route <
 (rum/defc whiteboard-route <
-(shortcut/mixin :shortcut.handler/whiteboard)
+(shortcut/mixin :shortcut.handler/whiteboard false)
   [route-match]
   [route-match]
   (let [name (get-in route-match [:parameters :path :name])
   (let [name (get-in route-match [:parameters :path :name])
         {:keys [block-id]} (get-in route-match [:parameters :query])]
         {:keys [block-id]} (get-in route-match [:parameters :query])]

+ 9 - 4
src/main/frontend/extensions/pdf/core.cljs

@@ -988,6 +988,11 @@
                             {:set-dirty-hls! set-dirty-hls!
                             {:set-dirty-hls! set-dirty-hls!
                              :set-hls-extra! set-hls-extra!}) "pdf-viewer")])))])))
                              :set-hls-extra! set-hls-extra!}) "pdf-viewer")])))])))
 
 
+(rum/defc pdf-container-outer
+  < (shortcut/mixin :shortcut.handler/pdf false)
+  [child]
+  [:<> child])
+
 (rum/defc pdf-container
 (rum/defc pdf-container
   [{:keys [identity] :as pdf-current}]
   [{:keys [identity] :as pdf-current}]
   (let [[prepared set-prepared!] (rum/use-state false)
   (let [[prepared set-prepared!] (rum/use-state false)
@@ -1029,8 +1034,7 @@
 
 
 (rum/defcs default-embed-playground
 (rum/defcs default-embed-playground
   < rum/static rum/reactive
   < rum/static rum/reactive
-    (shortcut/mixin :shortcut.handler/pdf)
-  []
+  [state]
   (let [pdf-current (state/sub :pdf/current)
   (let [pdf-current (state/sub :pdf/current)
         system-win? (state/sub :pdf/system-win?)]
         system-win? (state/sub :pdf/system-win?)]
     [:div.extensions__pdf-playground
     [:div.extensions__pdf-playground
@@ -1040,8 +1044,9 @@
 
 
      (when (and (not system-win?) pdf-current)
      (when (and (not system-win?) pdf-current)
        (js/ReactDOM.createPortal
        (js/ReactDOM.createPortal
-        (pdf-container pdf-current)
-        (js/document.querySelector "#app-single-container")))]))
+         (pdf-container-outer
+           (pdf-container pdf-current))
+         (js/document.querySelector "#app-single-container")))]))
 
 
 (rum/defcs system-embed-playground
 (rum/defcs system-embed-playground
   < rum/reactive
   < rum/reactive

+ 1 - 1
src/main/frontend/extensions/srs.cljs

@@ -512,7 +512,7 @@
            [:div.my-3 (ui/button "Review cards" :small? true)])]))))
            [:div.my-3 (ui/button "Review cards" :small? true)])]))))
 
 
 (rum/defc view-modal <
 (rum/defc view-modal <
-  (shortcut/mixin :shortcut.handler/cards)
+  (shortcut/mixin :shortcut.handler/cards false)
   [blocks option card-index]
   [blocks option card-index]
   [:div#cards-modal
   [:div#cards-modal
    (if (seq blocks)
    (if (seq blocks)

+ 3 - 2
src/main/frontend/handler/command_palette.cljs

@@ -2,6 +2,7 @@
   "System-component-like ns for command palette's functionality"
   "System-component-like ns for command palette's functionality"
   (:require [cljs.spec.alpha :as s]
   (:require [cljs.spec.alpha :as s]
             [frontend.modules.shortcut.data-helper :as shortcut-helper]
             [frontend.modules.shortcut.data-helper :as shortcut-helper]
+            [frontend.handler.plugin :as plugin-handler]
             [frontend.spec :as spec]
             [frontend.spec :as spec]
             [frontend.state :as state]
             [frontend.state :as state]
             [lambdaisland.glogi :as log]
             [lambdaisland.glogi :as log]
@@ -50,10 +51,10 @@
 (defn add-history [{:keys [id]}]
 (defn add-history [{:keys [id]}]
   (storage/set "commands-history" (conj (history) {:id id :timestamp (.getTime (js/Date.))})))
   (storage/set "commands-history" (conj (history) {:id id :timestamp (.getTime (js/Date.))})))
 
 
-(defn invoke-command [{:keys [action] :as cmd}]
+(defn invoke-command [{:keys [id action] :as cmd}]
   (add-history cmd)
   (add-history cmd)
   (state/close-modal!)
   (state/close-modal!)
-  (action))
+  (plugin-handler/hook-lifecycle-fn! id action))
 
 
 (defn top-commands [limit]
 (defn top-commands [limit]
   (->> (get-commands)
   (->> (get-commands)

+ 3 - 0
src/main/frontend/handler/config.cljs

@@ -18,6 +18,9 @@
       (repo-config-handler/read-repo-config content)
       (repo-config-handler/read-repo-config content)
       (let [result (parse-repo-config content)
       (let [result (parse-repo-config content)
             ks (if (vector? k) k [k])
             ks (if (vector? k) k [k])
+            v (cond->> v
+                       (map? v)
+                       (reduce-kv (fn [a k v] (rewrite/assoc a k v)) (rewrite/parse-string "{}")))
             new-result (rewrite/assoc-in result ks v)
             new-result (rewrite/assoc-in result ks v)
             new-content (str new-result)]
             new-content (str new-result)]
         (file-handler/set-file-content! repo path new-content) nil))))
         (file-handler/set-file-content! repo path new-content) nil))))

+ 4 - 7
src/main/frontend/handler/events.cljs

@@ -22,7 +22,6 @@
             [frontend.components.shell :as shell]
             [frontend.components.shell :as shell]
             [frontend.components.whiteboard :as whiteboard]
             [frontend.components.whiteboard :as whiteboard]
             [frontend.components.user.login :as login]
             [frontend.components.user.login :as login]
-            [frontend.components.shortcut :as shortcut]
             [frontend.config :as config]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
             [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db :as db]
@@ -457,8 +456,8 @@
   (commands/exec-plugin-simple-command! pid cmd action))
   (commands/exec-plugin-simple-command! pid cmd action))
 
 
 (defmethod handle :shortcut-handler-refreshed [[_]]
 (defmethod handle :shortcut-handler-refreshed [[_]]
-  (when-not @st/*inited?
-    (reset! st/*inited? true)
+  (when-not @st/*pending-inited?
+    (reset! st/*pending-inited? true)
     (st/consume-pending-shortcuts!)))
     (st/consume-pending-shortcuts!)))
 
 
 (defmethod handle :mobile/keyboard-will-show [[_ keyboard-height]]
 (defmethod handle :mobile/keyboard-will-show [[_ keyboard-height]]
@@ -936,10 +935,8 @@
 (defmethod handle :editor/quick-capture [[_ args]]
 (defmethod handle :editor/quick-capture [[_ args]]
   (quick-capture/quick-capture args))
   (quick-capture/quick-capture args))
 
 
-(defmethod handle :modal/keymap-manager [[_]]
-  (state/set-modal!
-    #(shortcut/keymap-pane)
-    {:label "keymap-manager"}))
+(defmethod handle :modal/keymap [[_]]
+  (state/open-settings! :keymap))
 
 
 (defmethod handle :editor/toggle-own-number-list [[_ blocks]]
 (defmethod handle :editor/toggle-own-number-list [[_ blocks]]
   (let [batch? (sequential? blocks)
   (let [batch? (sequential? blocks)

+ 18 - 1
src/main/frontend/handler/global_config.cljs

@@ -8,6 +8,7 @@
             [shadow.resource :as rc]
             [shadow.resource :as rc]
             [clojure.edn :as edn]
             [clojure.edn :as edn]
             [electron.ipc :as ipc]
             [electron.ipc :as ipc]
+            [borkdude.rewrite-edn :as rewrite]
             [logseq.common.path :as path]))
             [logseq.common.path :as path]))
 
 
 ;; Use defonce to avoid broken state on dev reload
 ;; Use defonce to avoid broken state on dev reload
@@ -38,7 +39,7 @@
 (defn set-global-config-state!
 (defn set-global-config-state!
   [content]
   [content]
   (let [config (edn/read-string content)]
   (let [config (edn/read-string content)]
-    (state/set-global-config! config)
+    (state/set-global-config! config content)
     config))
     config))
 
 
 (def default-content (rc/inline "templates/global-config.edn"))
 (def default-content (rc/inline "templates/global-config.edn"))
@@ -59,6 +60,22 @@
     (p/let [config-content (fs/read-file nil config-path)]
     (p/let [config-content (fs/read-file nil config-path)]
            (set-global-config-state! config-content))))
            (set-global-config-state! config-content))))
 
 
+(defn set-global-config-kv!
+  [k v]
+  (let [result (rewrite/parse-string
+                 (or (state/get-global-config-str-content) "{}"))
+        ks (if (sequential? k) k [k])
+        v (cond->> v
+                   (map? v)
+                   (reduce-kv (fn [a k v] (rewrite/assoc a k v)) (rewrite/parse-string "{}")))
+        new-result (if (and (= 1 (count ks))
+                            (nil? v))
+                     (rewrite/dissoc result (first ks))
+                     (rewrite/assoc-in result ks v))
+        new-str-content (str new-result)]
+    (fs/write-file! nil nil (global-config-path) new-str-content {:skip-compare? true})
+    (state/set-global-config! (rewrite/sexpr new-result) new-str-content)))
+
 (defn start
 (defn start
   "This component has four responsibilities on start:
   "This component has four responsibilities on start:
 - Fetch root-dir for later use with config paths
 - Fetch root-dir for later use with config paths

+ 5 - 3
src/main/frontend/handler/notification.cljs

@@ -18,11 +18,13 @@
   ([content]
   ([content]
    (show! content :info true nil 2000 nil))
    (show! content :info true nil 2000 nil))
   ([content status]
   ([content status]
-   (show! content status true nil 1500 nil))
+   (show! content status (not= status :error) nil 1500 nil))
   ([content status clear?]
   ([content status clear?]
    (show! content status clear? nil 1500 nil))
    (show! content status clear? nil 1500 nil))
   ([content status clear? uid]
   ([content status clear? uid]
    (show! content status clear? uid 1500 nil))
    (show! content status clear? uid 1500 nil))
+  ([content status clear? uid timeout]
+   (show! content status clear? uid timeout nil))
   ([content status clear? uid timeout close-cb]
   ([content status clear? uid timeout close-cb]
    (let [contents (state/get-notification-contents)
    (let [contents (state/get-notification-contents)
          uid (or uid (keyword (util/unique-id)))]
          uid (or uid (keyword (util/unique-id)))]
@@ -31,7 +33,7 @@
                                                           :status status
                                                           :status status
                                                           :close-cb close-cb}))
                                                           :close-cb close-cb}))
 
 
-     (when (and clear? (not= status :error))
-       (js/setTimeout #(clear! uid) (or timeout 1500)))
+     (when (and clear? (or timeout (not= status :error)))
+       (js/setTimeout #(clear! uid) (or timeout 2000)))
 
 
      uid)))
      uid)))

+ 12 - 3
src/main/frontend/handler/plugin.cljs

@@ -7,6 +7,7 @@
             [logseq.graph-parser.mldoc :as gp-mldoc]
             [logseq.graph-parser.mldoc :as gp-mldoc]
             [frontend.handler.notification :as notification]
             [frontend.handler.notification :as notification]
             [frontend.handler.common.plugin :as plugin-common-handler]
             [frontend.handler.common.plugin :as plugin-common-handler]
+            [frontend.modules.shortcut.utils :as shortcut-utils]
             [frontend.storage :as storage]
             [frontend.storage :as storage]
             [camel-snake-kebab.core :as csk]
             [camel-snake-kebab.core :as csk]
             [frontend.state :as state]
             [frontend.state :as state]
@@ -175,7 +176,7 @@
 
 
 (defn has-setting-schema?
 (defn has-setting-schema?
   [id]
   [id]
-  (when-let [pl (and id (get-plugin-inst (name id)))]
+  (when-let [^js pl (and id (get-plugin-inst (name id)))]
     (boolean (.-settingsSchema pl))))
     (boolean (.-settingsSchema pl))))
 
 
 (defn get-enabled-plugins-if-setting-schema
 (defn get-enabled-plugins-if-setting-schema
@@ -297,7 +298,7 @@
   (let [id      (keyword (str "plugin." pid "/" key))
   (let [id      (keyword (str "plugin." pid "/" key))
         binding (:binding keybinding)
         binding (:binding keybinding)
         binding (some->> (if (string? binding) [binding] (seq binding))
         binding (some->> (if (string? binding) [binding] (seq binding))
-                         (map util/normalize-user-keyname))
+                         (map shortcut-utils/undecorate-binding))
         binding (if util/mac?
         binding (if util/mac?
                   (or (:mac keybinding) binding) binding)
                   (or (:mac keybinding) binding) binding)
         mode    (or (:mode keybinding) :global)
         mode    (or (:mode keybinding) :global)
@@ -658,6 +659,15 @@
                        :remove disj)]
                        :remove disj)]
       (save-plugin-preferences! {:pinnedToolbarItems (op-fn pinned (name key))}))))
       (save-plugin-preferences! {:pinnedToolbarItems (op-fn pinned (name key))}))))
 
 
+(defn hook-lifecycle-fn!
+  [type f & args]
+  (when (and type (fn? f))
+    (when config/lsp-enabled?
+      (hook-plugin-app (str :before-command-invoked type) nil))
+    (apply f args)
+    (when config/lsp-enabled?
+      (hook-plugin-app (str :after-command-invoked type) nil))))
+
 ;; components
 ;; components
 (rum/defc lsp-indicator < rum/reactive
 (rum/defc lsp-indicator < rum/reactive
   []
   []
@@ -788,7 +798,6 @@
     (callback)
     (callback)
     (init-plugins! callback)))
     (init-plugins! callback)))
 
 
-
 (comment
 (comment
   {:pending        (count (:plugin/updates-pending @state/state))
   {:pending        (count (:plugin/updates-pending @state/state))
    :auto-checking? (boolean (:plugin/updates-auto-checking? @state/state))
    :auto-checking? (boolean (:plugin/updates-auto-checking? @state/state))

+ 26 - 24
src/main/frontend/mixins.cljs

@@ -29,31 +29,33 @@
 
 
 (defn hide-when-esc-or-outside
 (defn hide-when-esc-or-outside
   [state & {:keys [on-hide node visibilitychange? outside?]}]
   [state & {:keys [on-hide node visibilitychange? outside?]}]
-  (try
-    (let [dom-node (rum/dom-node state)]
-      (when-let [dom-node (or node dom-node)]
-        (let [click-fn (fn [e]
-                         (let [target (.. e -target)]
-                           ;; If the click target is outside of current node
-                           (when (and
-                                  (not (dom/contains dom-node target))
-                                  (not (.contains (.-classList target) "ignore-outside-event")))
-                             (on-hide state e :click))))]
-          (when-not (false? outside?)
-            (listen state js/window "mousedown" click-fn)))
-        (listen state js/window "keydown"
-                (fn [e]
-                  (case (.-keyCode e)
-                    ;; Esc
-                    27 (on-hide state e :esc)
-                    nil)))
-        (when visibilitychange?
-          (listen state js/window "visibilitychange"
+  (let [opts (last (:rum/args state))
+        outside? (cond-> opts (nil? outside?) (:outside?))]
+    (try
+      (let [dom-node (rum/dom-node state)]
+        (when-let [dom-node (or node dom-node)]
+          (let [click-fn (fn [e]
+                           (let [target (.. e -target)]
+                             ;; If the click target is outside of current node
+                             (when (and
+                                     (not (dom/contains dom-node target))
+                                     (not (.contains (.-classList target) "ignore-outside-event")))
+                               (on-hide state e :click))))]
+            (when-not (false? outside?)
+              (listen state js/window "mousedown" click-fn)))
+          (listen state js/window "keydown"
                   (fn [e]
                   (fn [e]
-                    (on-hide state e :visibilitychange))))))
-    (catch :default _e
-      ;; TODO: Unable to find node on an unmounted component.
-      nil)))
+                    (case (.-keyCode e)
+                      ;; Esc
+                      27 (on-hide state e :esc)
+                      nil)))
+          (when visibilitychange?
+            (listen state js/window "visibilitychange"
+                    (fn [e]
+                      (on-hide state e :visibilitychange))))))
+      (catch :default _e
+        ;; TODO: Unable to find node on an unmounted component.
+        nil))))
 
 
 (defn on-enter
 (defn on-enter
   [state & {:keys [on-enter node]}]
   [state & {:keys [on-enter node]}]

+ 673 - 696
src/main/frontend/modules/shortcut/config.cljs

@@ -1,5 +1,6 @@
 (ns frontend.modules.shortcut.config
 (ns frontend.modules.shortcut.config
-  (:require [frontend.components.commit :as commit]
+  (:require [clojure.string :as str]
+            [frontend.components.commit :as commit]
             [frontend.extensions.srs.handler :as srs]
             [frontend.extensions.srs.handler :as srs]
             [frontend.extensions.pdf.utils :as pdf-utils]
             [frontend.extensions.pdf.utils :as pdf-utils]
             [frontend.handler.config :as config-handler]
             [frontend.handler.config :as config-handler]
@@ -40,207 +41,207 @@
 ;;  * :fn - Fn or a qualified keyword that represents a fn
 ;;  * :fn - Fn or a qualified keyword that represents a fn
 ;;  * :inactive - Optional boolean to disable a shortcut for certain conditions
 ;;  * :inactive - Optional boolean to disable a shortcut for certain conditions
 ;;    e.g. a given platform or feature condition
 ;;    e.g. a given platform or feature condition
-(def ^:large-vars/data-var all-default-keyboard-shortcuts
+(def ^:large-vars/data-var all-built-in-keyboard-shortcuts
   ;; BUG: Actually, "enter" is registered by mixin behind a "when inputing" guard
   ;; BUG: Actually, "enter" is registered by mixin behind a "when inputing" guard
   ;; So this setting item does not cover all cases.
   ;; So this setting item does not cover all cases.
   ;; See-also: frontend.components.datetime/time-repeater
   ;; See-also: frontend.components.datetime/time-repeater
-  {:date-picker/complete         {:binding "enter"
-                                  :fn      ui-handler/shortcut-complete}
+  {:date-picker/complete                    {:binding "enter"
+                                             :fn      ui-handler/shortcut-complete}
 
 
-   :date-picker/prev-day         {:binding "left"
-                                  :fn      ui-handler/shortcut-prev-day}
+   :date-picker/prev-day                    {:binding "left"
+                                             :fn      ui-handler/shortcut-prev-day}
 
 
-   :date-picker/next-day         {:binding "right"
-                                  :fn      ui-handler/shortcut-next-day}
+   :date-picker/next-day                    {:binding "right"
+                                             :fn      ui-handler/shortcut-next-day}
 
 
-   :date-picker/prev-week        {:binding ["up" "ctrl+p"]
-                                  :fn      ui-handler/shortcut-prev-week}
+   :date-picker/prev-week                   {:binding ["up" "ctrl+p"]
+                                             :fn      ui-handler/shortcut-prev-week}
 
 
-   :date-picker/next-week        {:binding ["down" "ctrl+n"]
-                                  :fn      ui-handler/shortcut-next-week}
+   :date-picker/next-week                   {:binding ["down" "ctrl+n"]
+                                             :fn      ui-handler/shortcut-next-week}
 
 
-   :pdf/previous-page            {:binding "alt+p"
-                                  :fn      pdf-utils/prev-page}
+   :pdf/previous-page                       {:binding "alt+p"
+                                             :fn      pdf-utils/prev-page}
 
 
-   :pdf/next-page                {:binding "alt+n"
-                                  :fn      pdf-utils/next-page}
+   :pdf/next-page                           {:binding "alt+n"
+                                             :fn      pdf-utils/next-page}
 
 
-   :pdf/close                    {:binding "alt+x"
-                                  :fn      #(state/set-state! :pdf/current nil)}
+   :pdf/close                               {:binding "alt+x"
+                                             :fn      #(state/set-state! :pdf/current nil)}
 
 
-   :pdf/find                     {:binding "alt+f"
-                                  :fn      pdf-utils/open-finder}
+   :pdf/find                                {:binding "alt+f"
+                                             :fn      pdf-utils/open-finder}
 
 
-   :whiteboard/select            {:binding ["1" "w s"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "select")}
-   
-   :whiteboard/pan               {:binding ["2" "w p"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "move")}
-   
-   :whiteboard/portal            {:binding ["3" "w b"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "logseq-portal")}
+   :whiteboard/select                       {:binding ["1" "w s"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "select")}
 
 
-   :whiteboard/pencil            {:binding ["4" "w d"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "pencil")}
+   :whiteboard/pan                          {:binding ["2" "w p"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "move")}
 
 
-   :whiteboard/highlighter       {:binding ["5" "w h"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "highlighter")}
-   
-   :whiteboard/eraser            {:binding ["6" "w e"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "erase")}
-   
-   :whiteboard/connector         {:binding ["7" "w c"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "line")}
-   
-   :whiteboard/text              {:binding ["8" "w t"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "text")}
+   :whiteboard/portal                       {:binding ["3" "w b"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "logseq-portal")}
 
 
-   :whiteboard/rectangle         {:binding ["9" "w r"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "box")}
+   :whiteboard/pencil                       {:binding ["4" "w d"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "pencil")}
 
 
-   :whiteboard/ellipse           {:binding ["o" "w o"]
-                                  :fn      #(.selectTool ^js (state/active-tldraw-app) "ellipse")}
+   :whiteboard/highlighter                  {:binding ["5" "w h"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "highlighter")}
 
 
-   :whiteboard/reset-zoom        {:binding "shift+0"
-                                  :fn      #(.resetZoom (.-api ^js (state/active-tldraw-app)))}
+   :whiteboard/eraser                       {:binding ["6" "w e"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "erase")}
 
 
-   :whiteboard/zoom-to-fit       {:binding "shift+1"
-                                  :fn      #(.zoomToFit (.-api ^js (state/active-tldraw-app)))}
+   :whiteboard/connector                    {:binding ["7" "w c"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "line")}
 
 
-   :whiteboard/zoom-to-selection {:binding "shift+2"
-                                  :fn      #(.zoomToSelection (.-api ^js (state/active-tldraw-app)))}
+   :whiteboard/text                         {:binding ["8" "w t"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "text")}
 
 
-   :whiteboard/zoom-out          {:binding "shift+dash"
-                                  :fn      #(.zoomOut (.-api ^js (state/active-tldraw-app)) false)}
+   :whiteboard/rectangle                    {:binding ["9" "w r"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "box")}
 
 
-   :whiteboard/zoom-in           {:binding "shift+equals"
-                                  :fn      #(.zoomIn (.-api ^js (state/active-tldraw-app)) false)}
+   :whiteboard/ellipse                      {:binding ["o" "w o"]
+                                             :fn      #(.selectTool ^js (state/active-tldraw-app) "ellipse")}
 
 
-   :whiteboard/send-backward     {:binding "open-square-bracket"
-                                  :fn      #(.sendBackward ^js (state/active-tldraw-app))}
+   :whiteboard/reset-zoom                   {:binding "shift+0"
+                                             :fn      #(.resetZoom (.-api ^js (state/active-tldraw-app)))}
 
 
-   :whiteboard/send-to-back      {:binding "shift+open-square-bracket"
-                                  :fn      #(.sendToBack ^js (state/active-tldraw-app))}
+   :whiteboard/zoom-to-fit                  {:binding "shift+1"
+                                             :fn      #(.zoomToFit (.-api ^js (state/active-tldraw-app)))}
 
 
-   :whiteboard/bring-forward     {:binding "close-square-bracket"
-                                  :fn      #(.bringForward ^js (state/active-tldraw-app))}
+   :whiteboard/zoom-to-selection            {:binding "shift+2"
+                                             :fn      #(.zoomToSelection (.-api ^js (state/active-tldraw-app)))}
 
 
-   :whiteboard/bring-to-front    {:binding "shift+close-square-bracket"
-                                  :fn      #(.bringToFront ^js (state/active-tldraw-app))}
+   :whiteboard/zoom-out                     {:binding "shift+dash"
+                                             :fn      #(.zoomOut (.-api ^js (state/active-tldraw-app)) false)}
 
 
-   :whiteboard/lock              {:binding "mod+l"
-                                  :fn      #(.setLocked ^js (state/active-tldraw-app) true)}
+   :whiteboard/zoom-in                      {:binding "shift+equals"
+                                             :fn      #(.zoomIn (.-api ^js (state/active-tldraw-app)) false)}
 
 
-   :whiteboard/unlock            {:binding "mod+shift+l"
-                                  :fn      #(.setLocked ^js (state/active-tldraw-app) false)}
+   :whiteboard/send-backward                {:binding "open-square-bracket"
+                                             :fn      #(.sendBackward ^js (state/active-tldraw-app))}
 
 
-   :whiteboard/group             {:binding "mod+g"
-                                  :fn      #(.doGroup (.-api ^js (state/active-tldraw-app)))}
+   :whiteboard/send-to-back                 {:binding "shift+open-square-bracket"
+                                             :fn      #(.sendToBack ^js (state/active-tldraw-app))}
 
 
-   :whiteboard/ungroup           {:binding "mod+shift+g"
-                                  :fn      #(.unGroup (.-api ^js (state/active-tldraw-app)))}
+   :whiteboard/bring-forward                {:binding "close-square-bracket"
+                                             :fn      #(.bringForward ^js (state/active-tldraw-app))}
 
 
-   :whiteboard/toggle-grid       {:binding "t g"
-                                  :fn      #(.toggleGrid (.-api ^js (state/active-tldraw-app)))}
+   :whiteboard/bring-to-front               {:binding "shift+close-square-bracket"
+                                             :fn      #(.bringToFront ^js (state/active-tldraw-app))}
 
 
-   :auto-complete/complete       {:binding "enter"
-                                  :fn      ui-handler/auto-complete-complete}
+   :whiteboard/lock                         {:binding "mod+l"
+                                             :fn      #(.setLocked ^js (state/active-tldraw-app) true)}
 
 
-   :auto-complete/prev           {:binding ["up" "ctrl+p"]
-                                  :fn      ui-handler/auto-complete-prev}
+   :whiteboard/unlock                       {:binding "mod+shift+l"
+                                             :fn      #(.setLocked ^js (state/active-tldraw-app) false)}
 
 
-   :auto-complete/next           {:binding ["down" "ctrl+n"]
-                                  :fn      ui-handler/auto-complete-next}
+   :whiteboard/group                        {:binding "mod+g"
+                                             :fn      #(.doGroup (.-api ^js (state/active-tldraw-app)))}
 
 
-   :auto-complete/shift-complete {:binding "shift+enter"
-                                  :fn      ui-handler/auto-complete-shift-complete}
+   :whiteboard/ungroup                      {:binding "mod+shift+g"
+                                             :fn      #(.unGroup (.-api ^js (state/active-tldraw-app)))}
 
 
-   :auto-complete/open-link      {:binding "mod+o"
-                                  :fn      ui-handler/auto-complete-open-link}
+   :whiteboard/toggle-grid                  {:binding "t g"
+                                             :fn      #(.toggleGrid (.-api ^js (state/active-tldraw-app)))}
 
 
-   :cards/toggle-answers         {:binding "s"
-                                  :fn      srs/toggle-answers}
+   :auto-complete/complete                  {:binding "enter"
+                                             :fn      ui-handler/auto-complete-complete}
 
 
-   :cards/next-card              {:binding "n"
-                                  :fn      srs/next-card}
+   :auto-complete/prev                      {:binding ["up" "ctrl+p"]
+                                             :fn      ui-handler/auto-complete-prev}
 
 
-   :cards/forgotten              {:binding "f"
-                                  :fn      srs/forgotten}
+   :auto-complete/next                      {:binding ["down" "ctrl+n"]
+                                             :fn      ui-handler/auto-complete-next}
 
 
-   :cards/remembered             {:binding "r"
-                                  :fn      srs/remembered}
+   :auto-complete/shift-complete            {:binding "shift+enter"
+                                             :fn      ui-handler/auto-complete-shift-complete}
 
 
-   :cards/recall                 {:binding "t"
-                                  :fn      srs/recall}
+   :auto-complete/open-link                 {:binding "mod+o"
+                                             :fn      ui-handler/auto-complete-open-link}
 
 
-   :editor/escape-editing        {:binding false
-                                  :fn      (fn [_ _]
-                                             (editor-handler/escape-editing))}
+   :cards/toggle-answers                    {:binding "s"
+                                             :fn      srs/toggle-answers}
 
 
-   :editor/backspace             {:binding "backspace"
-                                  :fn      editor-handler/editor-backspace}
+   :cards/next-card                         {:binding "n"
+                                             :fn      srs/next-card}
 
 
-   :editor/delete                {:binding "delete"
-                                  :fn      editor-handler/editor-delete}
+   :cards/forgotten                         {:binding "f"
+                                             :fn      srs/forgotten}
 
 
-   :editor/new-block             {:binding "enter"
-                                  :fn      editor-handler/keydown-new-block-handler}
+   :cards/remembered                        {:binding "r"
+                                             :fn      srs/remembered}
 
 
-   :editor/new-line              {:binding "shift+enter"
-                                  :fn      editor-handler/keydown-new-line-handler}
+   :cards/recall                            {:binding "t"
+                                             :fn      srs/recall}
 
 
-   :editor/new-whiteboard        {:binding "n w"
-                                  :fn      #(whiteboard-handler/create-new-whiteboard-and-redirect!)}
+   :editor/escape-editing                   {:binding false
+                                             :fn      (fn [_ _]
+                                                        (editor-handler/escape-editing))}
 
 
-   :editor/follow-link           {:binding "mod+o"
-                                  :fn      editor-handler/follow-link-under-cursor!}
+   :editor/backspace                        {:binding "backspace"
+                                             :fn      editor-handler/editor-backspace}
 
 
-   :editor/open-link-in-sidebar  {:binding "mod+shift+o"
-                                  :fn      editor-handler/open-link-in-sidebar!}
+   :editor/delete                           {:binding "delete"
+                                             :fn      editor-handler/editor-delete}
 
 
-   :editor/bold                  {:binding "mod+b"
-                                  :fn      editor-handler/bold-format!}
+   :editor/new-block                        {:binding "enter"
+                                             :fn      editor-handler/keydown-new-block-handler}
 
 
-   :editor/italics               {:binding "mod+i"
-                                  :fn      editor-handler/italics-format!}
+   :editor/new-line                         {:binding "shift+enter"
+                                             :fn      editor-handler/keydown-new-line-handler}
 
 
-   :editor/highlight             {:binding "mod+shift+h"
-                                  :fn      editor-handler/highlight-format!}
+   :editor/new-whiteboard                   {:binding "n w"
+                                             :fn      #(whiteboard-handler/create-new-whiteboard-and-redirect!)}
 
 
-   :editor/strike-through        {:binding "mod+shift+s"
-                                  :fn      editor-handler/strike-through-format!}
+   :editor/follow-link                      {:binding "mod+o"
+                                             :fn      editor-handler/follow-link-under-cursor!}
 
 
-   :editor/clear-block           {:binding (if mac? "ctrl+l" "alt+l")
-                                  :fn      editor-handler/clear-block-content!}
+   :editor/open-link-in-sidebar             {:binding "mod+shift+o"
+                                             :fn      editor-handler/open-link-in-sidebar!}
 
 
-   :editor/kill-line-before      {:binding (if mac? "ctrl+u" "alt+u")
-                                  :fn      editor-handler/kill-line-before!}
+   :editor/bold                             {:binding "mod+b"
+                                             :fn      editor-handler/bold-format!}
 
 
-   :editor/kill-line-after       {:binding (if mac? false "alt+k")
-                                  :fn      editor-handler/kill-line-after!}
+   :editor/italics                          {:binding "mod+i"
+                                             :fn      editor-handler/italics-format!}
 
 
-   :editor/beginning-of-block    {:binding (if mac? false "alt+a")
-                                  :fn      editor-handler/beginning-of-block}
+   :editor/highlight                        {:binding "mod+shift+h"
+                                             :fn      editor-handler/highlight-format!}
 
 
-   :editor/end-of-block          {:binding (if mac? false "alt+e")
-                                  :fn      editor-handler/end-of-block}
+   :editor/strike-through                   {:binding "mod+shift+s"
+                                             :fn      editor-handler/strike-through-format!}
 
 
-   :editor/forward-word          {:binding (if mac? "ctrl+shift+f" "alt+f")
-                                  :fn      editor-handler/cursor-forward-word}
+   :editor/clear-block                      {:binding (if mac? "ctrl+l" "alt+l")
+                                             :fn      editor-handler/clear-block-content!}
 
 
-   :editor/backward-word         {:binding (if mac? "ctrl+shift+b" "alt+b")
-                                  :fn      editor-handler/cursor-backward-word}
+   :editor/kill-line-before                 {:binding (if mac? "ctrl+u" "alt+u")
+                                             :fn      editor-handler/kill-line-before!}
 
 
-   :editor/forward-kill-word     {:binding (if mac? "ctrl+w" "alt+d")
-                                  :fn      editor-handler/forward-kill-word}
+   :editor/kill-line-after                  {:binding (if mac? false "alt+k")
+                                             :fn      editor-handler/kill-line-after!}
 
 
-   :editor/backward-kill-word    {:binding (if mac? false "alt+w")
-                                  :fn      editor-handler/backward-kill-word}
+   :editor/beginning-of-block               {:binding (if mac? false "alt+a")
+                                             :fn      editor-handler/beginning-of-block}
+
+   :editor/end-of-block                     {:binding (if mac? false "alt+e")
+                                             :fn      editor-handler/end-of-block}
+
+   :editor/forward-word                     {:binding (if mac? "ctrl+shift+f" "alt+f")
+                                             :fn      editor-handler/cursor-forward-word}
+
+   :editor/backward-word                    {:binding (if mac? "ctrl+shift+b" "alt+b")
+                                             :fn      editor-handler/cursor-backward-word}
+
+   :editor/forward-kill-word                {:binding (if mac? "ctrl+w" "alt+d")
+                                             :fn      editor-handler/forward-kill-word}
+
+   :editor/backward-kill-word               {:binding (if mac? false "alt+w")
+                                             :fn      editor-handler/backward-kill-word}
 
 
    :editor/replace-block-reference-at-point {:binding "mod+shift+r"
    :editor/replace-block-reference-at-point {:binding "mod+shift+r"
                                              :fn      editor-handler/replace-block-reference-with-content-at-point}
                                              :fn      editor-handler/replace-block-reference-with-content-at-point}
-   :editor/copy-embed {:binding "mod+e"
-                       :fn      editor-handler/copy-current-block-embed}
+   :editor/copy-embed                       {:binding "mod+e"
+                                             :fn      editor-handler/copy-current-block-embed}
 
 
    :editor/paste-text-in-one-block-at-point {:binding "mod+shift+v"
    :editor/paste-text-in-one-block-at-point {:binding "mod+shift+v"
                                              :fn      paste-handler/editor-on-paste-raw!}
                                              :fn      paste-handler/editor-on-paste-raw!}
@@ -248,299 +249,299 @@
    :editor/insert-youtube-timestamp         {:binding "mod+shift+y"
    :editor/insert-youtube-timestamp         {:binding "mod+shift+y"
                                              :fn      commands/insert-youtube-timestamp}
                                              :fn      commands/insert-youtube-timestamp}
 
 
-   :editor/cycle-todo              {:binding "mod+enter"
-                                    :fn      editor-handler/cycle-todo!}
+   :editor/cycle-todo                       {:binding "mod+enter"
+                                             :fn      editor-handler/cycle-todo!}
 
 
-   :editor/up                      {:binding ["up" "ctrl+p"]
-                                    :fn      (editor-handler/shortcut-up-down :up)}
+   :editor/up                               {:binding ["up" "ctrl+p"]
+                                             :fn      (editor-handler/shortcut-up-down :up)}
 
 
-   :editor/down                    {:binding ["down" "ctrl+n"]
-                                    :fn      (editor-handler/shortcut-up-down :down)}
+   :editor/down                             {:binding ["down" "ctrl+n"]
+                                             :fn      (editor-handler/shortcut-up-down :down)}
 
 
-   :editor/left                    {:binding "left"
-                                    :fn      (editor-handler/shortcut-left-right :left)}
+   :editor/left                             {:binding "left"
+                                             :fn      (editor-handler/shortcut-left-right :left)}
 
 
-   :editor/right                   {:binding "right"
-                                    :fn      (editor-handler/shortcut-left-right :right)}
+   :editor/right                            {:binding "right"
+                                             :fn      (editor-handler/shortcut-left-right :right)}
 
 
-   :editor/move-block-up           {:binding (if mac? "mod+shift+up" "alt+shift+up")
-                                    :fn      (editor-handler/move-up-down true)}
+   :editor/move-block-up                    {:binding (if mac? "mod+shift+up" "alt+shift+up")
+                                             :fn      (editor-handler/move-up-down true)}
 
 
-   :editor/move-block-down         {:binding (if mac? "mod+shift+down" "alt+shift+down")
-                                    :fn      (editor-handler/move-up-down false)}
+   :editor/move-block-down                  {:binding (if mac? "mod+shift+down" "alt+shift+down")
+                                             :fn      (editor-handler/move-up-down false)}
 
 
    ;; FIXME: add open edit in non-selection mode
    ;; FIXME: add open edit in non-selection mode
-   :editor/open-edit               {:binding "enter"
-                                    :fn      (partial editor-handler/open-selected-block! :right)}
+   :editor/open-edit                        {:binding "enter"
+                                             :fn      (partial editor-handler/open-selected-block! :right)}
+
+   :editor/select-block-up                  {:binding "alt+up"
+                                             :fn      (editor-handler/on-select-block :up)}
 
 
-   :editor/select-block-up         {:binding "alt+up"
-                                    :fn      (editor-handler/on-select-block :up)}
+   :editor/select-block-down                {:binding "alt+down"
+                                             :fn      (editor-handler/on-select-block :down)}
 
 
-   :editor/select-block-down       {:binding "alt+down"
-                                    :fn      (editor-handler/on-select-block :down)}
+   :editor/select-up                        {:binding "shift+up"
+                                             :fn      (editor-handler/shortcut-select-up-down :up)}
 
 
-   :editor/select-up               {:binding "shift+up"
-                                    :fn      (editor-handler/shortcut-select-up-down :up)}
+   :editor/select-down                      {:binding "shift+down"
+                                             :fn      (editor-handler/shortcut-select-up-down :down)}
 
 
-   :editor/select-down             {:binding "shift+down"
-                                    :fn      (editor-handler/shortcut-select-up-down :down)}
+   :editor/delete-selection                 {:binding ["backspace" "delete"]
+                                             :fn      editor-handler/delete-selection}
 
 
-   :editor/delete-selection        {:binding ["backspace" "delete"]
-                                    :fn      editor-handler/delete-selection}
+   :editor/expand-block-children            {:binding "mod+down"
+                                             :fn      editor-handler/expand!}
 
 
-   :editor/expand-block-children   {:binding "mod+down"
-                                    :fn      editor-handler/expand!}
+   :editor/collapse-block-children          {:binding "mod+up"
+                                             :fn      editor-handler/collapse!}
 
 
-   :editor/collapse-block-children {:binding "mod+up"
-                                    :fn      editor-handler/collapse!}
+   :editor/indent                           {:binding "tab"
+                                             :fn      (editor-handler/keydown-tab-handler :right)}
 
 
-   :editor/indent                  {:binding "tab"
-                                    :fn      (editor-handler/keydown-tab-handler :right)}
+   :editor/outdent                          {:binding "shift+tab"
+                                             :fn      (editor-handler/keydown-tab-handler :left)}
 
 
-   :editor/outdent                 {:binding "shift+tab"
-                                    :fn      (editor-handler/keydown-tab-handler :left)}
+   :editor/copy                             {:binding "mod+c"
+                                             :fn      editor-handler/shortcut-copy}
 
 
-   :editor/copy                    {:binding "mod+c"
-                                    :fn      editor-handler/shortcut-copy}
+   :editor/copy-text                        {:binding "mod+shift+c"
+                                             :fn      editor-handler/shortcut-copy-text}
 
 
-   :editor/copy-text               {:binding "mod+shift+c"
-                                    :fn      editor-handler/shortcut-copy-text}
+   :editor/cut                              {:binding "mod+x"
+                                             :fn      editor-handler/shortcut-cut}
 
 
-   :editor/cut                     {:binding "mod+x"
-                                    :fn      editor-handler/shortcut-cut}
+   :editor/undo                             {:binding "mod+z"
+                                             :fn      history/undo!}
 
 
-   :editor/undo                    {:binding "mod+z"
-                                    :fn      history/undo!}
+   :editor/redo                             {:binding ["mod+shift+z" "mod+y"]
+                                             :fn      history/redo!}
 
 
-   :editor/redo                    {:binding ["mod+shift+z" "mod+y"]
-                                    :fn      history/redo!}
+   :editor/insert-link                      {:binding "mod+l"
+                                             :fn      #(editor-handler/html-link-format!)}
 
 
-   :editor/insert-link             {:binding "mod+l"
-                                    :fn      #(editor-handler/html-link-format!)}
+   :editor/select-all-blocks                {:binding "mod+shift+a"
+                                             :fn      editor-handler/select-all-blocks!}
 
 
-   :editor/select-all-blocks       {:binding "mod+shift+a"
-                                    :fn      editor-handler/select-all-blocks!}
+   :editor/select-parent                    {:binding "mod+a"
+                                             :fn      editor-handler/select-parent}
 
 
-   :editor/select-parent           {:binding "mod+a"
-                                    :fn      editor-handler/select-parent}
+   :editor/zoom-in                          {:binding (if mac? "mod+." "alt+right")
+                                             :fn      editor-handler/zoom-in!}
 
 
-   :editor/zoom-in                 {:binding (if mac? "mod+." "alt+right")
-                                    :fn      editor-handler/zoom-in!}
+   :editor/zoom-out                         {:binding (if mac? "mod+," "alt+left")
+                                             :fn      editor-handler/zoom-out!}
 
 
-   :editor/zoom-out                {:binding (if mac? "mod+," "alt+left")
-                                    :fn      editor-handler/zoom-out!}
+   :editor/toggle-undo-redo-mode            {:binding false
+                                             :fn      undo-redo/toggle-undo-redo-mode!}
 
 
-   :editor/toggle-undo-redo-mode   {:binding false
-                                    :fn      undo-redo/toggle-undo-redo-mode!}
-   
-   :editor/toggle-number-list      {:binding "t n"
-                                    :fn #(state/pub-event! [:editor/toggle-own-number-list (state/get-selection-block-ids)])}
+   :editor/toggle-number-list               {:binding "t n"
+                                             :fn      #(state/pub-event! [:editor/toggle-own-number-list (state/get-selection-block-ids)])}
 
 
-   :ui/toggle-brackets             {:binding "mod+c mod+b"
-                                    :fn      config-handler/toggle-ui-show-brackets!}
+   :ui/toggle-brackets                      {:binding "mod+c mod+b"
+                                             :fn      config-handler/toggle-ui-show-brackets!}
 
 
-   :go/search-in-page              {:binding "mod+shift+k"
-                                    :fn      #(do
-                                                (editor-handler/escape-editing)
-                                                (route-handler/go-to-search! :page))}
+   :go/search-in-page                       {:binding "mod+shift+k"
+                                             :fn      #(do
+                                                         (editor-handler/escape-editing)
+                                                         (route-handler/go-to-search! :page))}
 
 
-   :go/search                      {:binding "mod+k"
-                                    :fn      #(do
-                                                (editor-handler/escape-editing false)
-                                                (route-handler/go-to-search! :global))}
+   :go/search                               {:binding "mod+k"
+                                             :fn      #(do
+                                                         (editor-handler/escape-editing false)
+                                                         (route-handler/go-to-search! :global))}
 
 
-   :go/electron-find-in-page       {:binding "mod+f"
-                                    :inactive (not (util/electron?))
-                                    :fn      #(search-handler/open-find-in-page!)}
+   :go/electron-find-in-page                {:binding  "mod+f"
+                                             :inactive (not (util/electron?))
+                                             :fn       #(search-handler/open-find-in-page!)}
 
 
-   :go/electron-jump-to-the-next {:binding ["enter" "mod+g"]
-                                  :inactive (not (util/electron?))
-                                  :fn      #(search-handler/loop-find-in-page! false)}
+   :go/electron-jump-to-the-next            {:binding  ["enter" "mod+g"]
+                                             :inactive (not (util/electron?))
+                                             :fn       #(search-handler/loop-find-in-page! false)}
 
 
-   :go/electron-jump-to-the-previous {:binding ["shift+enter" "mod+shift+g"]
-                                      :inactive (not (util/electron?))
-                                      :fn      #(search-handler/loop-find-in-page! true)}
+   :go/electron-jump-to-the-previous        {:binding  ["shift+enter" "mod+shift+g"]
+                                             :inactive (not (util/electron?))
+                                             :fn       #(search-handler/loop-find-in-page! true)}
 
 
-   :go/journals                    {:binding "g j"
-                                    :fn      route-handler/go-to-journals!}
+   :go/journals                             {:binding "g j"
+                                             :fn      route-handler/go-to-journals!}
 
 
-   :go/backward                    {:binding "mod+open-square-bracket"
-                                    :fn      (fn [_] (js/window.history.back))}
+   :go/backward                             {:binding "mod+open-square-bracket"
+                                             :fn      (fn [_] (js/window.history.back))}
 
 
-   :go/forward                     {:binding "mod+close-square-bracket"
-                                    :fn      (fn [_] (js/window.history.forward))}
+   :go/forward                              {:binding "mod+close-square-bracket"
+                                             :fn      (fn [_] (js/window.history.forward))}
 
 
-   :search/re-index                {:binding "mod+c mod+s"
-                                    :fn      (fn [_] (search-handler/rebuild-indices! true))}
+   :search/re-index                         {:binding "mod+c mod+s"
+                                             :fn      (fn [_] (search-handler/rebuild-indices! true))}
 
 
-   :sidebar/open-today-page        {:binding (if mac? "mod+shift+j" "alt+shift+j")
-                                    :fn      page-handler/open-today-in-sidebar}
+   :sidebar/open-today-page                 {:binding (if mac? "mod+shift+j" "alt+shift+j")
+                                             :fn      page-handler/open-today-in-sidebar}
 
 
-   :sidebar/close-top              {:binding "c t"
-                                    :fn      #(state/sidebar-remove-block! 0)}
+   :sidebar/close-top                       {:binding "c t"
+                                             :fn      #(state/sidebar-remove-block! 0)}
 
 
-   :sidebar/clear                  {:binding "mod+c mod+c"
-                                    :fn      #(do
-                                                (state/clear-sidebar-blocks!)
-                                                (state/hide-right-sidebar!))}
+   :sidebar/clear                           {:binding "mod+c mod+c"
+                                             :fn      #(do
+                                                         (state/clear-sidebar-blocks!)
+                                                         (state/hide-right-sidebar!))}
 
 
-   :misc/copy                      {:binding "mod+c"
-                                    :fn      (fn [] (js/document.execCommand "copy"))}
+   :misc/copy                               {:binding "mod+c"
+                                             :fn      (fn [] (js/document.execCommand "copy"))}
 
 
-   :command-palette/toggle         {:binding "mod+shift+p"
-                                    :fn      #(do
-                                                (editor-handler/escape-editing)
-                                                (state/pub-event! [:modal/command-palette]))}
+   :command-palette/toggle                  {:binding "mod+shift+p"
+                                             :fn      #(do
+                                                         (editor-handler/escape-editing)
+                                                         (state/pub-event! [:modal/command-palette]))}
 
 
-   :graph/export-as-html           {:fn #(export-handler/download-repo-as-html!
-                                          (state/get-current-repo))
-                                    :binding false}
+   :graph/export-as-html                    {:fn      #(export-handler/download-repo-as-html!
+                                                         (state/get-current-repo))
+                                             :binding false}
 
 
-   :graph/open                     {:fn      #(do
-                                                (editor-handler/escape-editing)
-                                                (state/set-state! :ui/open-select :graph-open))
-                                    :binding "alt+shift+g"}
+   :graph/open                              {:fn      #(do
+                                                         (editor-handler/escape-editing)
+                                                         (state/set-state! :ui/open-select :graph-open))
+                                             :binding "alt+shift+g"}
 
 
-   :graph/remove                   {:fn      #(do
-                                                (editor-handler/escape-editing)
-                                                (state/set-state! :ui/open-select :graph-remove))
-                                    :binding false}
+   :graph/remove                            {:fn      #(do
+                                                         (editor-handler/escape-editing)
+                                                         (state/set-state! :ui/open-select :graph-remove))
+                                             :binding false}
 
 
-   :graph/add                      {:fn (fn [] (route-handler/redirect! {:to :repo-add}))
-                                    :binding false}
+   :graph/add                               {:fn      (fn [] (route-handler/redirect! {:to :repo-add}))
+                                             :binding false}
 
 
-   :graph/save                     {:fn #(state/pub-event! [:graph/save])
-                                    :binding false}
+   :graph/save                              {:fn      #(state/pub-event! [:graph/save])
+                                             :binding false}
 
 
-   :graph/re-index                 {:fn (fn []
-                                          (p/let [multiple-windows? (ipc/ipc "graphHasMultipleWindows" (state/get-current-repo))]
-                                            (state/pub-event! [:graph/ask-for-re-index (atom multiple-windows?) nil])))
-                                    :binding false}
+   :graph/re-index                          {:fn      (fn []
+                                                        (p/let [multiple-windows? (ipc/ipc "graphHasMultipleWindows" (state/get-current-repo))]
+                                                          (state/pub-event! [:graph/ask-for-re-index (atom multiple-windows?) nil])))
+                                             :binding false}
 
 
-   :command/run                    {:binding "mod+shift+1"
-                                    :inactive (not (util/electron?))
-                                    :fn      #(do
-                                                (editor-handler/escape-editing)
-                                                (state/pub-event! [:command/run]))}
+   :command/run                             {:binding  "mod+shift+1"
+                                             :inactive (not (util/electron?))
+                                             :fn       #(do
+                                                          (editor-handler/escape-editing)
+                                                          (state/pub-event! [:command/run]))}
 
 
-   :go/home                        {:binding "g h"
-                                    :fn      #(route-handler/redirect-to-home!)}
+   :go/home                                 {:binding "g h"
+                                             :fn      #(route-handler/redirect-to-home!)}
 
 
-   :go/all-pages                   {:binding "g a"
-                                    :fn      route-handler/redirect-to-all-pages!}
+   :go/all-pages                            {:binding "g a"
+                                             :fn      route-handler/redirect-to-all-pages!}
 
 
-   :go/graph-view                  {:binding "g g"
-                                    :fn      route-handler/redirect-to-graph-view!}
+   :go/graph-view                           {:binding "g g"
+                                             :fn      route-handler/redirect-to-graph-view!}
 
 
-   :go/all-graphs                  {:binding "g shift+g"
-                                    :fn      route-handler/redirect-to-all-graphs}
+   :go/all-graphs                           {:binding "g shift+g"
+                                             :fn      route-handler/redirect-to-all-graphs}
 
 
-   :go/whiteboards                  {:binding "g w"
-                                     :fn      route-handler/redirect-to-whiteboard-dashboard!}
+   :go/whiteboards                          {:binding "g w"
+                                             :fn      route-handler/redirect-to-whiteboard-dashboard!}
 
 
-   :go/keyboard-shortcuts          {:binding "g s"
-                                    :fn      #(state/pub-event! [:modal/keymap-manager])}
+   :go/keyboard-shortcuts                   {:binding "g s"
+                                             :fn      #(state/pub-event! [:modal/keymap])}
 
 
-   :go/tomorrow                    {:binding "g t"
-                                    :fn      journal-handler/go-to-tomorrow!}
+   :go/tomorrow                             {:binding "g t"
+                                             :fn      journal-handler/go-to-tomorrow!}
 
 
-   :go/next-journal                {:binding "g n"
-                                    :fn      journal-handler/go-to-next-journal!}
+   :go/next-journal                         {:binding "g n"
+                                             :fn      journal-handler/go-to-next-journal!}
 
 
-   :go/prev-journal                {:binding "g p"
-                                    :fn      journal-handler/go-to-prev-journal!}
+   :go/prev-journal                         {:binding "g p"
+                                             :fn      journal-handler/go-to-prev-journal!}
 
 
-   :go/flashcards                  {:binding "g f"
-                                    :fn      (fn []
-                                               (if (state/modal-opened?)
-                                                 (state/close-modal!)
-                                                 (state/pub-event! [:modal/show-cards])))}
+   :go/flashcards                           {:binding "g f"
+                                             :fn      (fn []
+                                                        (if (state/modal-opened?)
+                                                          (state/close-modal!)
+                                                          (state/pub-event! [:modal/show-cards])))}
 
 
-   :ui/toggle-document-mode        {:binding "t d"
-                                    :fn      state/toggle-document-mode!}
+   :ui/toggle-document-mode                 {:binding "t d"
+                                             :fn      state/toggle-document-mode!}
 
 
-   :ui/toggle-settings              {:binding (if mac? "t s" ["t s" "mod+,"])
-                                     :fn      ui-handler/toggle-settings-modal!}
+   :ui/toggle-settings                      {:binding (if mac? ["t s" "mod+,"] "t s")
+                                             :fn      ui-handler/toggle-settings-modal!}
 
 
-   :ui/toggle-right-sidebar         {:binding "t r"
-                                     :fn      ui-handler/toggle-right-sidebar!}
+   :ui/toggle-right-sidebar                 {:binding "t r"
+                                             :fn      ui-handler/toggle-right-sidebar!}
 
 
-   :ui/toggle-left-sidebar          {:binding "t l"
-                                     :fn      state/toggle-left-sidebar!}
+   :ui/toggle-left-sidebar                  {:binding "t l"
+                                             :fn      state/toggle-left-sidebar!}
 
 
-   :ui/toggle-help                  {:binding "shift+/"
-                                     :fn      ui-handler/toggle-help!}
+   :ui/toggle-help                          {:binding "shift+/"
+                                             :fn      ui-handler/toggle-help!}
 
 
-   :ui/toggle-theme                 {:binding "t t"
-                                     :fn      state/toggle-theme!}
+   :ui/toggle-theme                         {:binding "t t"
+                                             :fn      state/toggle-theme!}
 
 
-   :ui/toggle-contents              {:binding "alt+shift+c"
-                                     :fn      ui-handler/toggle-contents!}
+   :ui/toggle-contents                      {:binding "alt+shift+c"
+                                             :fn      ui-handler/toggle-contents!}
 
 
-   :command/toggle-favorite         {:binding "mod+shift+f"
-                                     :fn      page-handler/toggle-favorite!}
+   :command/toggle-favorite                 {:binding "mod+shift+f"
+                                             :fn      page-handler/toggle-favorite!}
 
 
-   :editor/open-file-in-default-app {:binding "mod+d mod+a"
-                                     :inactive (not (util/electron?))
-                                     :fn      page-handler/open-file-in-default-app}
+   :editor/open-file-in-default-app         {:binding  "mod+d mod+a"
+                                             :inactive (not (util/electron?))
+                                             :fn       page-handler/open-file-in-default-app}
 
 
-   :editor/open-file-in-directory   {:binding "mod+d mod+i"
-                                     :inactive (not (util/electron?))
-                                     :fn      page-handler/open-file-in-directory}
+   :editor/open-file-in-directory           {:binding  "mod+d mod+i"
+                                             :inactive (not (util/electron?))
+                                             :fn       page-handler/open-file-in-directory}
 
 
-   :editor/copy-current-file        {:binding false
-                                     :inactive (not (util/electron?))
-                                     :fn      page-handler/copy-current-file}
+   :editor/copy-current-file                {:binding  false
+                                             :inactive (not (util/electron?))
+                                             :fn       page-handler/copy-current-file}
 
 
-   :editor/copy-page-url            {:binding false
-                                     :inactive (not (util/electron?))
-                                     :fn      #(page-handler/copy-page-url)}
+   :editor/copy-page-url                    {:binding  false
+                                             :inactive (not (util/electron?))
+                                             :fn       #(page-handler/copy-page-url)}
 
 
-   :ui/toggle-wide-mode             {:binding "t w"
-                                     :fn      ui-handler/toggle-wide-mode!}
+   :ui/toggle-wide-mode                     {:binding "t w"
+                                             :fn      ui-handler/toggle-wide-mode!}
 
 
-   :ui/select-theme-color           {:binding "t i"
-                                     :fn      plugin-handler/show-themes-modal!}
+   :ui/select-theme-color                   {:binding "t i"
+                                             :fn      plugin-handler/show-themes-modal!}
 
 
-   :ui/goto-plugins                 {:binding "t p"
-                                     :inactive (not config/lsp-enabled?)
-                                     :fn      plugin-handler/goto-plugins-dashboard!}
+   :ui/goto-plugins                         {:binding  "t p"
+                                             :inactive (not config/lsp-enabled?)
+                                             :fn       plugin-handler/goto-plugins-dashboard!}
 
 
-   :ui/install-plugins-from-file    {:binding false
-                                     :inactive (not (config/plugin-config-enabled?))
-                                     :fn       plugin-config-handler/open-replace-plugins-modal}
+   :ui/install-plugins-from-file            {:binding  false
+                                             :inactive (not (config/plugin-config-enabled?))
+                                             :fn       plugin-config-handler/open-replace-plugins-modal}
 
 
-   :ui/clear-all-notifications      {:binding false
-                                     :fn      :frontend.handler.notification/clear-all!}
+   :ui/clear-all-notifications              {:binding false
+                                             :fn      :frontend.handler.notification/clear-all!}
 
 
-   :editor/toggle-open-blocks       {:binding "t o"
-                                     :fn      editor-handler/toggle-open!}
+   :editor/toggle-open-blocks               {:binding "t o"
+                                             :fn      editor-handler/toggle-open!}
 
 
-   :ui/toggle-cards                 {:binding "t c"
-                                     :fn      ui-handler/toggle-cards!}
+   :ui/toggle-cards                         {:binding "t c"
+                                             :fn      ui-handler/toggle-cards!}
 
 
-   :git/commit                      {:binding "mod+g c"
-                                     :inactive (not (util/electron?))
-                                     :fn      commit/show-commit-modal!}
+   :git/commit                              {:binding  "mod+g c"
+                                             :inactive (not (util/electron?))
+                                             :fn       commit/show-commit-modal!}
 
 
-   :dev/show-block-data            {:binding false
-                                    :inactive (not (state/developer-mode?))
-                                    :fn :frontend.handler.common.developer/show-block-data}
+   :dev/show-block-data                     {:binding  false
+                                             :inactive (not (state/developer-mode?))
+                                             :fn       :frontend.handler.common.developer/show-block-data}
 
 
-   :dev/show-block-ast             {:binding false
-                                    :inactive (not (state/developer-mode?))
-                                    :fn :frontend.handler.common.developer/show-block-ast}
+   :dev/show-block-ast                      {:binding  false
+                                             :inactive (not (state/developer-mode?))
+                                             :fn       :frontend.handler.common.developer/show-block-ast}
 
 
-   :dev/show-page-data             {:binding false
-                                    :inactive (not (state/developer-mode?))
-                                    :fn :frontend.handler.common.developer/show-page-data}
+   :dev/show-page-data                      {:binding  false
+                                             :inactive (not (state/developer-mode?))
+                                             :fn       :frontend.handler.common.developer/show-page-data}
 
 
-   :dev/show-page-ast              {:binding false
-                                    :inactive (not (state/developer-mode?))
-                                    :fn :frontend.handler.common.developer/show-page-ast}})
+   :dev/show-page-ast                       {:binding  false
+                                             :inactive (not (state/developer-mode?))
+                                             :fn       :frontend.handler.common.developer/show-page-ast}})
 
 
 (let [keyboard-commands
 (let [keyboard-commands
-      {::commands (set (keys all-default-keyboard-shortcuts))
+      {::commands       (set (keys all-built-in-keyboard-shortcuts))
        ::dicts/commands dicts/abbreviated-commands}]
        ::dicts/commands dicts/abbreviated-commands}]
   (assert (= (::commands keyboard-commands) (::dicts/commands keyboard-commands))
   (assert (= (::commands keyboard-commands) (::dicts/commands keyboard-commands))
           (str "Keyboard commands must have an english label"
           (str "Keyboard commands must have an english label"
@@ -557,7 +558,16 @@
       (throw (ex-info (str "Unable to resolve " keyword-fn " to a fn") {})))))
       (throw (ex-info (str "Unable to resolve " keyword-fn " to a fn") {})))))
 
 
 (defn build-category-map [ks]
 (defn build-category-map [ks]
-  (->> (select-keys all-default-keyboard-shortcuts ks)
+  (->> (if (sequential? ks)
+         ks (let [{:keys [ns includes excludes]} ks]
+              (->> (keys all-built-in-keyboard-shortcuts)
+                   (filter (fn [k]
+                             (and (or (and ns (keyword? k)
+                                           (contains? (->> (if (seqable? ns) (seq ns) [ns]) (map #(name %)) (set))
+                                                      (namespace k)))
+                                      (and includes (contains? (set includes) k)))
+                                  (if (not (seq excludes)) true (not (contains? (set excludes) k)))))))))
+       (select-keys all-built-in-keyboard-shortcuts)
        (remove (comp :inactive val))
        (remove (comp :inactive val))
        ;; Convert keyword fns to real fns
        ;; Convert keyword fns to real fns
        (map (fn [[k v]]
        (map (fn [[k v]]
@@ -567,392 +577,359 @@
        (into {})))
        (into {})))
 
 
 ;; This is the only var that should be publicly expose :fn functionality
 ;; This is the only var that should be publicly expose :fn functionality
-(defonce ^:large-vars/data-var config
+(defonce ^:large-vars/data-var *config
   (atom
   (atom
    {:shortcut.handler/date-picker
    {:shortcut.handler/date-picker
-    (build-category-map [:date-picker/complete
-                         :date-picker/prev-day
-                         :date-picker/next-day
-                         :date-picker/prev-week
-                         :date-picker/next-week])
+    (build-category-map {:ns :date-picker})
 
 
     :shortcut.handler/pdf
     :shortcut.handler/pdf
-    (-> (build-category-map [:pdf/previous-page
-                             :pdf/next-page
-                             :pdf/close
-                             :pdf/find])
+    (-> (build-category-map {:ns :pdf})
         (with-meta {:before m/enable-when-not-editing-mode!}))
         (with-meta {:before m/enable-when-not-editing-mode!}))
 
 
     :shortcut.handler/whiteboard
     :shortcut.handler/whiteboard
-    (-> (build-category-map [:whiteboard/select
-                             :whiteboard/pan
-                             :whiteboard/portal
-                             :whiteboard/pencil
-                             :whiteboard/highlighter
-                             :whiteboard/eraser
-                             :whiteboard/connector
-                             :whiteboard/text
-                             :whiteboard/rectangle
-                             :whiteboard/ellipse
-                             :whiteboard/reset-zoom
-                             :whiteboard/zoom-to-fit
-                             :whiteboard/zoom-to-selection
-                             :whiteboard/zoom-out
-                             :whiteboard/zoom-in
-                             :whiteboard/send-backward
-                             :whiteboard/send-to-back
-                             :whiteboard/bring-forward
-                             :whiteboard/bring-to-front
-                             :whiteboard/lock
-                             :whiteboard/unlock
-                             :whiteboard/group
-                             :whiteboard/ungroup
-                             :whiteboard/toggle-grid])
+    (-> (build-category-map {:ns :whiteboard})
         (with-meta {:before m/enable-when-not-editing-mode!}))
         (with-meta {:before m/enable-when-not-editing-mode!}))
 
 
     :shortcut.handler/auto-complete
     :shortcut.handler/auto-complete
-    (build-category-map [:auto-complete/complete
-                         :auto-complete/prev
-                         :auto-complete/next
-                         :auto-complete/shift-complete
-                         :auto-complete/open-link])
+    (build-category-map {:ns :auto-complete})
 
 
     :shortcut.handler/cards
     :shortcut.handler/cards
-    (-> (build-category-map [:cards/toggle-answers
-                             :cards/next-card
-                             :cards/forgotten
-                             :cards/remembered
-                             :cards/recall])
+    (-> (build-category-map {:ns :cards})
         (with-meta {:before m/enable-when-not-editing-mode!}))
         (with-meta {:before m/enable-when-not-editing-mode!}))
 
 
     :shortcut.handler/block-editing-only
     :shortcut.handler/block-editing-only
-    (->
-     (build-category-map [:editor/escape-editing
-                          :editor/backspace
-                          :editor/delete
-                          :editor/new-block
-                          :editor/new-line
-                          :editor/follow-link
-                          :editor/open-link-in-sidebar
-                          :editor/bold
-                          :editor/italics
-                          :editor/highlight
-                          :editor/strike-through
-                          :editor/clear-block
-                          :editor/kill-line-before
-                          :editor/kill-line-after
-                          :editor/beginning-of-block
-                          :editor/end-of-block
-                          :editor/forward-word
-                          :editor/backward-word
-                          :editor/forward-kill-word
-                          :editor/backward-kill-word
-                          :editor/replace-block-reference-at-point
-                          :editor/copy-embed
-                          :editor/paste-text-in-one-block-at-point
-                          :editor/insert-youtube-timestamp])
-     (with-meta {:before m/enable-when-editing-mode!}))
+    (-> (build-category-map
+          [:editor/escape-editing
+           :editor/backspace
+           :editor/delete
+           :editor/zoom-in
+           :editor/zoom-out
+           :editor/new-block
+           :editor/new-line
+           :editor/follow-link
+           :editor/open-link-in-sidebar
+           :editor/bold
+           :editor/italics
+           :editor/highlight
+           :editor/strike-through
+           :editor/clear-block
+           :editor/kill-line-before
+           :editor/kill-line-after
+           :editor/beginning-of-block
+           :editor/end-of-block
+           :editor/forward-word
+           :editor/backward-word
+           :editor/forward-kill-word
+           :editor/backward-kill-word
+           :editor/replace-block-reference-at-point
+           :editor/copy-embed
+           :editor/paste-text-in-one-block-at-point
+           :editor/insert-youtube-timestamp])
+        (with-meta {:before m/enable-when-editing-mode!}))
 
 
     :shortcut.handler/editor-global
     :shortcut.handler/editor-global
-    (->
-     (build-category-map [:graph/export-as-html
-                          :graph/open
-                          :graph/remove
-                          :graph/add
-                          :graph/save
-                          :graph/re-index
-                          :editor/cycle-todo
-                          :editor/up
-                          :editor/down
-                          :editor/left
-                          :editor/right
-                          :editor/select-up
-                          :editor/select-down
-                          :editor/move-block-up
-                          :editor/move-block-down
-                          :editor/open-edit
-                          :editor/select-block-up
-                          :editor/select-block-down
-                          :editor/select-parent
-                          :editor/delete-selection
-                          :editor/expand-block-children
-                          :editor/collapse-block-children
-                          :editor/indent
-                          :editor/outdent
-                          :editor/copy
-                          :editor/copy-text
-                          :editor/cut
-                          :command/toggle-favorite])
-     (with-meta {:before m/enable-when-not-component-editing!}))
+    (-> (build-category-map
+          [:graph/export-as-html
+           :graph/open
+           :graph/remove
+           :graph/add
+           :graph/save
+           :graph/re-index
+           :editor/cycle-todo
+           :editor/up
+           :editor/down
+           :editor/left
+           :editor/right
+           :editor/select-up
+           :editor/select-down
+           :editor/move-block-up
+           :editor/move-block-down
+           :editor/open-edit
+           :editor/select-block-up
+           :editor/select-block-down
+           :editor/select-parent
+           :editor/delete-selection
+           :editor/expand-block-children
+           :editor/collapse-block-children
+           :editor/indent
+           :editor/outdent
+           :editor/copy
+           :editor/copy-text
+           :editor/cut
+           :command/toggle-favorite])
+        (with-meta {:before m/enable-when-not-component-editing!}))
 
 
     :shortcut.handler/global-prevent-default
     :shortcut.handler/global-prevent-default
-    (->
-     (build-category-map [:editor/insert-link
-                          :editor/select-all-blocks
-                          :editor/zoom-in
-                          :editor/zoom-out
-                          :editor/toggle-undo-redo-mode
-                          :editor/toggle-number-list
-                          :editor/undo
-                          :editor/redo
-                          :ui/toggle-brackets
-                          :go/search-in-page
-                          :go/search
-                          :go/electron-find-in-page
-                          :go/electron-jump-to-the-next
-                          :go/electron-jump-to-the-previous
-                          :go/backward
-                          :go/forward
-                          :search/re-index
-                          :sidebar/open-today-page
-                          :sidebar/clear
-                          :command/run
-                          :command-palette/toggle])
-     (with-meta {:before m/prevent-default-behavior}))
+    (-> (build-category-map
+          [:editor/insert-link
+           :editor/select-all-blocks
+           :editor/toggle-undo-redo-mode
+           :editor/toggle-number-list
+           :editor/undo
+           :editor/redo
+           :ui/toggle-brackets
+           :go/search-in-page
+           :go/search
+           :go/electron-find-in-page
+           :go/electron-jump-to-the-next
+           :go/electron-jump-to-the-previous
+           :go/backward
+           :go/forward
+           :search/re-index
+           :sidebar/open-today-page
+           :sidebar/clear
+           :command/run
+           :command-palette/toggle])
+        (with-meta {:before m/prevent-default-behavior}))
+
+    :shortcut.handler/global-non-editing-only
+    (-> (build-category-map
+          [:go/home
+           :go/journals
+           :go/all-pages
+           :go/flashcards
+           :go/graph-view
+           :go/all-graphs
+           :go/whiteboards
+           :go/keyboard-shortcuts
+           :go/tomorrow
+           :go/next-journal
+           :go/prev-journal
+           :ui/toggle-document-mode
+           :ui/toggle-settings
+           :ui/toggle-right-sidebar
+           :ui/toggle-left-sidebar
+           :ui/toggle-help
+           :ui/toggle-theme
+           :ui/toggle-contents
+           :editor/open-file-in-default-app
+           :editor/open-file-in-directory
+           :editor/copy-current-file
+           :editor/copy-page-url
+           :editor/new-whiteboard
+           :ui/toggle-wide-mode
+           :ui/select-theme-color
+           :ui/goto-plugins
+           :ui/install-plugins-from-file
+           :editor/toggle-open-blocks
+           :ui/toggle-cards
+           :ui/clear-all-notifications
+           :git/commit
+           :sidebar/close-top
+           :dev/show-block-data
+           :dev/show-block-ast
+           :dev/show-page-data
+           :dev/show-page-ast])
+        (with-meta {:before m/enable-when-not-editing-mode!}))
 
 
     :shortcut.handler/misc
     :shortcut.handler/misc
     ;; always overrides the copy due to "mod+c mod+s"
     ;; always overrides the copy due to "mod+c mod+s"
-    {:misc/copy              (:misc/copy              all-default-keyboard-shortcuts)}
-
-    :shortcut.handler/global-non-editing-only
-    (->
-     (build-category-map [:go/home
-                          :go/journals
-                          :go/all-pages
-                          :go/flashcards
-                          :go/graph-view
-                          :go/all-graphs
-                          :go/whiteboards
-                          :go/keyboard-shortcuts
-                          :go/tomorrow
-                          :go/next-journal
-                          :go/prev-journal
-                          :ui/toggle-document-mode
-                          :ui/toggle-settings
-                          :ui/toggle-right-sidebar
-                          :ui/toggle-left-sidebar
-                          :ui/toggle-help
-                          :ui/toggle-theme
-                          :ui/toggle-contents
-                          :editor/open-file-in-default-app
-                          :editor/open-file-in-directory
-                          :editor/copy-current-file
-                          :editor/copy-page-url
-                          :editor/new-whiteboard
-                          :ui/toggle-wide-mode
-                          :ui/select-theme-color
-                          :ui/goto-plugins
-                          :ui/install-plugins-from-file
-                          :editor/toggle-open-blocks
-                          :ui/toggle-cards
-                          :ui/clear-all-notifications
-                          :git/commit
-                          :sidebar/close-top
-                          :dev/show-block-data
-                          :dev/show-block-ast
-                          :dev/show-page-data
-                          :dev/show-page-ast])
-     (with-meta {:before m/enable-when-not-editing-mode!}))}))
+    {:misc/copy (:misc/copy all-built-in-keyboard-shortcuts)}}))
 
 
 ;; To add a new entry to this map, first add it here and then
 ;; To add a new entry to this map, first add it here and then
 ;; a description for it in frontend.dicts.en/dicts
 ;; a description for it in frontend.dicts.en/dicts
-(def ^:large-vars/data-var category*
-  "Full list of categories for docs purpose"
-  {:shortcut.category/basics
-   [:editor/new-block
-    :editor/new-line
-    :editor/indent
-    :editor/outdent
-    :editor/select-all-blocks
-    :editor/select-parent
-    :go/search
-    :go/search-in-page
-    :go/electron-find-in-page
-    :go/electron-jump-to-the-next
-    :go/electron-jump-to-the-previous
-    :editor/undo
-    :editor/redo
-    :editor/copy
-    :editor/copy-text
-    :editor/cut]
-
-   :shortcut.category/formatting
-   [:editor/bold
-    :editor/insert-link
-    :editor/italics
-    :editor/strike-through
-    :editor/highlight]
-
-   :shortcut.category/navigating
-   [:editor/up
-    :editor/down
-    :editor/left
-    :editor/right
-    :editor/zoom-in
-    :editor/zoom-out
-    :editor/collapse-block-children
-    :editor/expand-block-children
-    :editor/toggle-open-blocks
-    :go/backward
-    :go/forward
-    :go/home
-    :go/journals
-    :go/all-pages
-    :go/graph-view
-    :go/all-graphs
-    :go/whiteboards
-    :go/flashcards
-    :go/tomorrow
-    :go/next-journal
-    :go/prev-journal
-    :go/keyboard-shortcuts]
-
-   :shortcut.category/block-editing
-   [:editor/backspace
-    :editor/delete
-    :editor/indent
-    :editor/outdent
-    :editor/new-block
-    :editor/new-line
-    :editor/zoom-in
-    :editor/zoom-out
-    :editor/cycle-todo
-    :editor/follow-link
-    :editor/open-link-in-sidebar
-    :editor/move-block-up
-    :editor/move-block-down
-    :editor/escape-editing]
-
-   :shortcut.category/block-command-editing
-   [:editor/backspace
-    :editor/clear-block
-    :editor/kill-line-before
-    :editor/kill-line-after
-    :editor/beginning-of-block
-    :editor/end-of-block
-    :editor/forward-word
-    :editor/backward-word
-    :editor/forward-kill-word
-    :editor/backward-kill-word
-    :editor/replace-block-reference-at-point
-    :editor/copy-embed
-    :editor/paste-text-in-one-block-at-point
-    :editor/select-up
-    :editor/select-down]
-
-   :shortcut.category/block-selection
-   [:editor/open-edit
-    :editor/select-all-blocks
-    :editor/select-parent
-    :editor/select-block-up
-    :editor/select-block-down
-    :editor/delete-selection]
-
-   :shortcut.category/toggle
-   [:ui/toggle-help
-    :editor/toggle-open-blocks
-    :editor/toggle-undo-redo-mode
-    :editor/toggle-number-list
-    :ui/toggle-wide-mode
-    :ui/toggle-cards
-    :ui/toggle-document-mode
-    :ui/toggle-brackets
-    :ui/toggle-theme
-    :ui/toggle-left-sidebar
-    :ui/toggle-right-sidebar
-    :ui/toggle-settings
-    :ui/toggle-contents]
-
-   :shortcut.category/whiteboard
-   [:editor/new-whiteboard
-    :whiteboard/select
-    :whiteboard/pan
-    :whiteboard/portal
-    :whiteboard/pencil
-    :whiteboard/highlighter
-    :whiteboard/eraser
-    :whiteboard/connector
-    :whiteboard/text
-    :whiteboard/rectangle
-    :whiteboard/ellipse
-    :whiteboard/reset-zoom
-    :whiteboard/zoom-to-fit
-    :whiteboard/zoom-to-selection
-    :whiteboard/zoom-out
-    :whiteboard/zoom-in
-    :whiteboard/send-backward
-    :whiteboard/send-to-back
-    :whiteboard/bring-forward
-    :whiteboard/bring-to-front
-    :whiteboard/lock
-    :whiteboard/unlock
-    :whiteboard/group
-    :whiteboard/ungroup
-    :whiteboard/toggle-grid]
-   
-   :shortcut.category/others
-   [:pdf/previous-page
-    :pdf/next-page
-    :pdf/close
-    :pdf/find
-    :command/toggle-favorite
-    :command/run
-    :command-palette/toggle
-    :graph/export-as-html
-    :graph/open
-    :graph/remove
-    :graph/add
-    :graph/save
-    :graph/re-index
-    :sidebar/close-top
-    :sidebar/clear
-    :sidebar/open-today-page
-    :search/re-index
-    :editor/insert-youtube-timestamp
-    :editor/open-file-in-default-app
-    :editor/open-file-in-directory
-    :editor/copy-page-url
-    :auto-complete/prev
-    :auto-complete/next
-    :auto-complete/complete
-    :auto-complete/shift-complete
-    :auto-complete/open-link
-    :date-picker/prev-day
-    :date-picker/next-day
-    :date-picker/prev-week
-    :date-picker/next-week
-    :date-picker/complete
-    :git/commit
-    :dev/show-block-data
-    :dev/show-block-ast
-    :dev/show-page-data
-    :dev/show-page-ast
-    :ui/clear-all-notifications]
-
-   :shortcut.category/plugins
-   []})
-
-(let [category-maps {::category (set (keys category*))
+;; Full list of categories for docs purpose
+(defonce ^:large-vars/data-var *category
+  (atom
+   {:shortcut.category/basics
+    [:editor/new-block
+     :editor/new-line
+     :editor/indent
+     :editor/outdent
+     :editor/select-all-blocks
+     :editor/select-parent
+     :go/search
+     :go/search-in-page
+     :go/electron-find-in-page
+     :go/electron-jump-to-the-next
+     :go/electron-jump-to-the-previous
+     :editor/undo
+     :editor/redo
+     :editor/copy
+     :editor/copy-text
+     :editor/cut]
+
+    :shortcut.category/formatting
+    [:editor/bold
+     :editor/insert-link
+     :editor/italics
+     :editor/strike-through
+     :editor/highlight]
+
+    :shortcut.category/navigating
+    [:editor/up
+     :editor/down
+     :editor/left
+     :editor/right
+     :editor/collapse-block-children
+     :editor/expand-block-children
+     :editor/toggle-open-blocks
+     :go/backward
+     :go/forward
+     :go/home
+     :go/journals
+     :go/all-pages
+     :go/graph-view
+     :go/all-graphs
+     :go/whiteboards
+     :go/flashcards
+     :go/tomorrow
+     :go/next-journal
+     :go/prev-journal
+     :go/keyboard-shortcuts]
+
+    :shortcut.category/block-editing
+    [:editor/backspace
+     :editor/delete
+     :editor/indent
+     :editor/outdent
+     :editor/new-block
+     :editor/new-line
+     :editor/zoom-in
+     :editor/zoom-out
+     :editor/cycle-todo
+     :editor/follow-link
+     :editor/open-link-in-sidebar
+     :editor/move-block-up
+     :editor/move-block-down
+     :editor/escape-editing]
+
+    :shortcut.category/block-command-editing
+    [:editor/backspace
+     :editor/clear-block
+     :editor/kill-line-before
+     :editor/kill-line-after
+     :editor/beginning-of-block
+     :editor/end-of-block
+     :editor/forward-word
+     :editor/backward-word
+     :editor/forward-kill-word
+     :editor/backward-kill-word
+     :editor/replace-block-reference-at-point
+     :editor/copy-embed
+     :editor/paste-text-in-one-block-at-point
+     :editor/select-up
+     :editor/select-down]
+
+    :shortcut.category/block-selection
+    [:editor/open-edit
+     :editor/select-all-blocks
+     :editor/select-parent
+     :editor/select-block-up
+     :editor/select-block-down
+     :editor/delete-selection]
+
+    :shortcut.category/toggle
+    [:ui/toggle-help
+     :editor/toggle-open-blocks
+     :editor/toggle-undo-redo-mode
+     :editor/toggle-number-list
+     :ui/toggle-wide-mode
+     :ui/toggle-cards
+     :ui/toggle-document-mode
+     :ui/toggle-brackets
+     :ui/toggle-theme
+     :ui/toggle-left-sidebar
+     :ui/toggle-right-sidebar
+     :ui/toggle-settings
+     :ui/toggle-contents]
+
+    :shortcut.category/whiteboard
+    [:editor/new-whiteboard
+     :whiteboard/select
+     :whiteboard/pan
+     :whiteboard/portal
+     :whiteboard/pencil
+     :whiteboard/highlighter
+     :whiteboard/eraser
+     :whiteboard/connector
+     :whiteboard/text
+     :whiteboard/rectangle
+     :whiteboard/ellipse
+     :whiteboard/reset-zoom
+     :whiteboard/zoom-to-fit
+     :whiteboard/zoom-to-selection
+     :whiteboard/zoom-out
+     :whiteboard/zoom-in
+     :whiteboard/send-backward
+     :whiteboard/send-to-back
+     :whiteboard/bring-forward
+     :whiteboard/bring-to-front
+     :whiteboard/lock
+     :whiteboard/unlock
+     :whiteboard/group
+     :whiteboard/ungroup
+     :whiteboard/toggle-grid]
+
+    :shortcut.category/others
+    [:pdf/previous-page
+     :pdf/next-page
+     :pdf/close
+     :pdf/find
+     :command/toggle-favorite
+     :command/run
+     :command-palette/toggle
+     :graph/export-as-html
+     :graph/open
+     :graph/remove
+     :graph/add
+     :graph/save
+     :graph/re-index
+     :sidebar/close-top
+     :sidebar/clear
+     :sidebar/open-today-page
+     :search/re-index
+     :editor/insert-youtube-timestamp
+     :editor/open-file-in-default-app
+     :editor/open-file-in-directory
+     :editor/copy-page-url
+     :auto-complete/prev
+     :auto-complete/next
+     :auto-complete/complete
+     :auto-complete/shift-complete
+     :auto-complete/open-link
+     :date-picker/prev-day
+     :date-picker/next-day
+     :date-picker/prev-week
+     :date-picker/next-week
+     :date-picker/complete
+     :git/commit
+     :dev/show-block-data
+     :dev/show-block-ast
+     :dev/show-page-data
+     :dev/show-page-ast
+     :ui/clear-all-notifications]
+
+    :shortcut.category/plugins
+    []}))
+
+(let [category-maps {::category       (set (keys @*category))
                      ::dicts/category dicts/categories}]
                      ::dicts/category dicts/categories}]
   (assert (= (::category category-maps) (::dicts/category category-maps))
   (assert (= (::category category-maps) (::dicts/category category-maps))
           (str "Keys for category maps must have an english label "
           (str "Keys for category maps must have an english label "
                (data/diff (::category category-maps) (::dicts/category category-maps)))))
                (data/diff (::category category-maps) (::dicts/category category-maps)))))
 
 
-(def category
+(defn get-category-shortcuts
   "Active list of categories for docs purpose"
   "Active list of categories for docs purpose"
-  (update-vals
-   category*
-   (fn [v]
-     (vec (remove #(:inactive (get all-default-keyboard-shortcuts %)) v)))))
+  [name]
+  (get @*category name))
 
 
 (def *shortcut-cmds (atom {}))
 (def *shortcut-cmds (atom {}))
 
 
 (defn add-shortcut!
 (defn add-shortcut!
   [handler-id id shortcut-map]
   [handler-id id shortcut-map]
-  (swap! config assoc-in [handler-id id] shortcut-map)
-  (swap! *shortcut-cmds assoc id (:cmd shortcut-map)))
+  (swap! *config assoc-in [handler-id id] shortcut-map)
+  (swap! *shortcut-cmds assoc id (:cmd shortcut-map))
+  (let [plugin? (str/starts-with? (str id) ":plugin.")
+        category (or (:category shortcut-map)
+                     (if plugin?
+                       :shortcut.category/plugins
+                       :shortcut.category/others))]
+    (swap! *category update category #(conj % id))))
 
 
 (defn remove-shortcut!
 (defn remove-shortcut!
   [handler-id id]
   [handler-id id]
-  (swap! config medley/dissoc-in [handler-id id])
-  (swap! *shortcut-cmds dissoc id))
+  (swap! *config medley/dissoc-in [handler-id id])
+  (swap! *shortcut-cmds dissoc id)
+  (doseq [category (keys @*category)]
+    (swap! *category update category (fn [ids] (remove #(= % id) ids)))))

+ 158 - 74
src/main/frontend/modules/shortcut/core.cljs

@@ -1,9 +1,12 @@
 (ns frontend.modules.shortcut.core
 (ns frontend.modules.shortcut.core
   (:require [clojure.string :as str]
   (:require [clojure.string :as str]
             [frontend.handler.config :as config-handler]
             [frontend.handler.config :as config-handler]
+            [frontend.handler.global-config :as global-config-handler]
+            [frontend.handler.plugin :as plugin-handler]
             [frontend.handler.notification :as notification]
             [frontend.handler.notification :as notification]
             [frontend.modules.shortcut.data-helper :as dh]
             [frontend.modules.shortcut.data-helper :as dh]
             [frontend.modules.shortcut.config :as shortcut-config]
             [frontend.modules.shortcut.config :as shortcut-config]
+            [frontend.modules.shortcut.utils :as shortcut-utils]
             [frontend.state :as state]
             [frontend.state :as state]
             [frontend.util :as util]
             [frontend.util :as util]
             [goog.events :as events]
             [goog.events :as events]
@@ -13,15 +16,15 @@
   (:import [goog.events KeyCodes KeyHandler KeyNames]
   (:import [goog.events KeyCodes KeyHandler KeyNames]
            [goog.ui KeyboardShortcutHandler]))
            [goog.ui KeyboardShortcutHandler]))
 
 
-(def *installed (atom {}))
-(def *inited? (atom false))
-(def *pending (atom []))
+(defonce *installed-handlers (atom {}))
+(defonce *pending-inited? (atom false))
+(defonce *pending-shortcuts (atom []))
 
 
 (def global-keys #js
 (def global-keys #js
-                  [KeyCodes/TAB
-                   KeyCodes/ENTER
-                   KeyCodes/BACKSPACE KeyCodes/DELETE
-                   KeyCodes/UP KeyCodes/LEFT KeyCodes/DOWN KeyCodes/RIGHT])
+        [KeyCodes/TAB
+         KeyCodes/ENTER
+         KeyCodes/BACKSPACE KeyCodes/DELETE
+         KeyCodes/UP KeyCodes/LEFT KeyCodes/DOWN KeyCodes/RIGHT])
 
 
 (def key-names (js->clj KeyNames))
 (def key-names (js->clj KeyNames))
 
 
@@ -29,16 +32,25 @@
 
 
 (defn consume-pending-shortcuts!
 (defn consume-pending-shortcuts!
   []
   []
-  (when (and @*inited? (seq @*pending))
-    (doseq [[handler-id id shortcut] @*pending]
+  (when (and @*pending-inited? (seq @*pending-shortcuts))
+    (doseq [[handler-id id shortcut] @*pending-shortcuts]
       (register-shortcut! handler-id id shortcut))
       (register-shortcut! handler-id id shortcut))
-    (reset! *pending [])))
+    (reset! *pending-shortcuts [])))
 
 
 (defn- get-handler-by-id
 (defn- get-handler-by-id
   [handler-id]
   [handler-id]
-  (-> (filter #(= (:group %) handler-id) (vals @*installed))
-      first
-      :handler))
+  (->> (vals @*installed-handlers)
+       (filter #(= (:group %) handler-id))
+       first
+       :handler))
+
+(defn- get-installed-ids-by-handler-id
+  [handler-id]
+  (some->> @*installed-handlers
+           (filter #(= (:group (second %)) handler-id))
+           (map first)
+           (remove nil?)
+           (vec)))
 
 
 (defn register-shortcut!
 (defn register-shortcut!
   "Register a shortcut, notice the id need to be a namespaced keyword to avoid
   "Register a shortcut, notice the id need to be a namespaced keyword to avoid
@@ -50,14 +62,14 @@
   ([handler-id id]
   ([handler-id id]
    (register-shortcut! handler-id id nil))
    (register-shortcut! handler-id id nil))
   ([handler-id id shortcut-map]
   ([handler-id id shortcut-map]
-   (if (and (keyword? handler-id) (not @*inited?))
-     (swap! *pending conj [handler-id id shortcut-map])
-     (when-let [handler (if (or (string? handler-id) (keyword? handler-id))
-                          (let [handler-id (keyword handler-id)]
-                            (get-handler-by-id handler-id))
+   (if (and (keyword? handler-id) (not @*pending-inited?))
+     (swap! *pending-shortcuts conj [handler-id id shortcut-map])
+     (when-let [^js handler (if (or (string? handler-id) (keyword? handler-id))
+                              (let [handler-id (keyword handler-id)]
+                                (get-handler-by-id handler-id))
 
 
-                          ;; handler
-                          handler-id)]
+                              ;; as Handler instance
+                              handler-id)]
 
 
        (when shortcut-map
        (when shortcut-map
          (shortcut-config/add-shortcut! handler-id id shortcut-map))
          (shortcut-config/add-shortcut! handler-id id shortcut-map))
@@ -66,7 +78,7 @@
          (doseq [k (dh/shortcut-binding id)]
          (doseq [k (dh/shortcut-binding id)]
            (try
            (try
              (log/debug :shortcut/register-shortcut {:id id :binding k})
              (log/debug :shortcut/register-shortcut {:id id :binding k})
-             (.registerShortcut handler (util/keyname id) (util/normalize-user-keyname k))
+             (.registerShortcut handler (util/keyname id) (shortcut-utils/undecorate-binding k))
              (catch :default e
              (catch :default e
                (log/error :shortcut/register-shortcut {:id      id
                (log/error :shortcut/register-shortcut {:id      id
                                                        :binding k
                                                        :binding k
@@ -81,15 +93,17 @@
   (when-let [handler (get-handler-by-id handler-id)]
   (when-let [handler (get-handler-by-id handler-id)]
     (when-let [ks (dh/shortcut-binding shortcut-id)]
     (when-let [ks (dh/shortcut-binding shortcut-id)]
       (doseq [k ks]
       (doseq [k ks]
-        (.unregisterShortcut ^js handler (util/normalize-user-keyname k))))
+        (.unregisterShortcut ^js handler (shortcut-utils/undecorate-binding k))))
     (shortcut-config/remove-shortcut! handler-id shortcut-id)))
     (shortcut-config/remove-shortcut! handler-id shortcut-id)))
 
 
 (defn uninstall-shortcut-handler!
 (defn uninstall-shortcut-handler!
-  [install-id]
-  (when-let [handler (-> (get @*installed install-id)
-                         :handler)]
-    (.dispose ^js handler)
-    (swap! *installed dissoc install-id)))
+  ([install-id] (uninstall-shortcut-handler! install-id false))
+  ([install-id refresh?]
+   (when-let [handler (-> (get @*installed-handlers install-id)
+                          :handler)]
+     (.dispose ^js handler)
+     (js/console.debug "[shortcuts]" "uninstall handler" (-> @*installed-handlers (get install-id) :group str) (if refresh? "*" ""))
+     (swap! *installed-handlers dissoc install-id))))
 
 
 (defn install-shortcut-handler!
 (defn install-shortcut-handler!
   [handler-id {:keys [set-global-keys?
   [handler-id {:keys [set-global-keys?
@@ -97,11 +111,15 @@
                       state]
                       state]
                :or   {set-global-keys? true
                :or   {set-global-keys? true
                       prevent-default? false}}]
                       prevent-default? false}}]
-  (when-let [install-id (get-handler-by-id handler-id)]
-    (uninstall-shortcut-handler! install-id))
+
+  ;; force uninstall existed handler
+  (some->>
+    (get-installed-ids-by-handler-id handler-id)
+    (map #(uninstall-shortcut-handler! % true))
+    (doall))
 
 
   (let [shortcut-map (dh/shortcut-map handler-id state)
   (let [shortcut-map (dh/shortcut-map handler-id state)
-        handler      (new KeyboardShortcutHandler js/window)]
+        handler (new KeyboardShortcutHandler js/window)]
     ;; set arrows enter, tab to global
     ;; set arrows enter, tab to global
     (when set-global-keys?
     (when set-global-keys?
       (.setGlobalKeys handler global-keys))
       (.setGlobalKeys handler global-keys))
@@ -114,66 +132,109 @@
       (register-shortcut! handler id))
       (register-shortcut! handler id))
 
 
     (let [f (fn [e]
     (let [f (fn [e]
-              (let [shortcut-map (dh/shortcut-map handler-id state)
-                    dispatch-fn (get shortcut-map (keyword (.-identifier e)))]
+              (let [id (keyword (.-identifier e))
+                    shortcut-map (dh/shortcut-map handler-id state) ;; required to get shortcut map dynamically
+                    dispatch-fn (get shortcut-map id)]
                 ;; trigger fn
                 ;; trigger fn
-                (when dispatch-fn (dispatch-fn e))))
+                (when dispatch-fn
+                  (plugin-handler/hook-lifecycle-fn! id dispatch-fn e))))
           install-id (random-uuid)
           install-id (random-uuid)
-          data       {install-id
-                      {:group      handler-id
-                       :dispatch-fn f
-                       :handler    handler}}]
+          data {install-id
+                {:group       handler-id
+                 :dispatch-fn f
+                 :handler     handler}}]
 
 
       (.listen handler EventType/SHORTCUT_TRIGGERED f)
       (.listen handler EventType/SHORTCUT_TRIGGERED f)
 
 
-      (swap! *installed merge data)
+      (js/console.debug "[shortcuts] install handler" (str handler-id))
+      (swap! *installed-handlers merge data)
 
 
       install-id)))
       install-id)))
 
 
 (defn- install-shortcuts!
 (defn- install-shortcuts!
-  []
-  (->> [:shortcut.handler/misc
-        :shortcut.handler/editor-global
-        :shortcut.handler/global-non-editing-only
-        :shortcut.handler/global-prevent-default]
+  [handler-ids]
+  (->> (or (seq handler-ids)
+           [:shortcut.handler/misc
+            :shortcut.handler/editor-global
+            :shortcut.handler/global-non-editing-only
+            :shortcut.handler/global-prevent-default])
        (map #(install-shortcut-handler! % {}))
        (map #(install-shortcut-handler! % {}))
        doall))
        doall))
 
 
-(defn mixin [handler-id]
+(defn mixin
+  ([handler-id] (mixin handler-id true))
+  ([handler-id remount-reinstall?]
+   (cond->
+     {:did-mount
+      (fn [state]
+        (let [install-id (install-shortcut-handler! handler-id {:state state})]
+          (assoc state ::install-id install-id)))
+
+      :will-unmount
+      (fn [state]
+        (when-let [install-id (::install-id state)]
+          (uninstall-shortcut-handler! install-id))
+        state)}
+
+     remount-reinstall?
+     (assoc
+       :will-remount
+       (fn [old-state new-state]
+         (util/profile "[shortcuts] reinstalled:"
+           (uninstall-shortcut-handler! (::install-id old-state))
+           (when-let [install-id (install-shortcut-handler! handler-id {:state new-state})]
+             (assoc new-state ::install-id install-id))))))))
+
+(defn mixin*
+  "This is an optimized version compared to (mixin).
+   And the shortcuts will not be frequently loaded and unloaded.
+   As well as ensuring unnecessary updates of components."
+  [handler-id]
   {:did-mount
   {:did-mount
    (fn [state]
    (fn [state]
-     (let [install-id (install-shortcut-handler! handler-id {:state state})]
-       (assoc state ::install-id install-id)))
+     (let [*state (volatile! state)
+           install-id (install-shortcut-handler! handler-id {:state *state})]
+       (assoc state ::install-id install-id
+                    ::*state *state)))
+
+   :will-remount
+   (fn [old-state new-state]
+     (when-let [*state (::*state old-state)]
+       (vreset! *state new-state))
+     new-state)
 
 
-   :will-remount (fn [old-state new-state]
-                  (uninstall-shortcut-handler! (::install-id old-state))
-                  (when-let [install-id (install-shortcut-handler! handler-id {:state new-state})]
-                    (assoc new-state ::install-id install-id)))
    :will-unmount
    :will-unmount
    (fn [state]
    (fn [state]
      (when-let [install-id (::install-id state)]
      (when-let [install-id (::install-id state)]
-       (uninstall-shortcut-handler! install-id))
+       (uninstall-shortcut-handler! install-id)
+       (some-> (::*state state) (vreset! nil)))
      state)})
      state)})
 
 
-(defn unlisten-all []
-  (doseq [{:keys [handler group]} (vals @*installed)
-          :when (not= group :shortcut.handler/misc)]
-    (.removeAllListeners handler)))
-
-(defn listen-all []
-  (doseq [{:keys [handler group dispatch-fn]} (vals @*installed)
+(defn unlisten-all!
+  ([] (unlisten-all! false))
+  ([dispose?]
+   (doseq [{:keys [handler group dispatch-fn]} (vals @*installed-handlers)
+           :when (not= group :shortcut.handler/misc)]
+     (if dispose?
+       (.dispose handler)
+       (events/unlisten handler EventType/SHORTCUT_TRIGGERED dispatch-fn)))))
+
+(defn listen-all! []
+  (doseq [{:keys [handler group dispatch-fn]} (vals @*installed-handlers)
           :when (not= group :shortcut.handler/misc)]
           :when (not= group :shortcut.handler/misc)]
-    (events/listen handler EventType/SHORTCUT_TRIGGERED dispatch-fn)))
+    (if (.isDisposed handler)
+      (install-shortcut-handler! group {})
+      (events/listen handler EventType/SHORTCUT_TRIGGERED dispatch-fn))))
 
 
 (def disable-all-shortcuts
 (def disable-all-shortcuts
   {:will-mount
   {:will-mount
    (fn [state]
    (fn [state]
-     (unlisten-all)
+     (unlisten-all!)
      state)
      state)
 
 
    :will-unmount
    :will-unmount
    (fn [state]
    (fn [state]
-     (listen-all)
+     (listen-all!)
      state)})
      state)})
 
 
 (defn refresh-internal!
 (defn refresh-internal!
@@ -182,27 +243,29 @@
   (when-not (:ui/shortcut-handler-refreshing? @state/state)
   (when-not (:ui/shortcut-handler-refreshing? @state/state)
     (state/set-state! :ui/shortcut-handler-refreshing? true)
     (state/set-state! :ui/shortcut-handler-refreshing? true)
 
 
-    (doseq [id (keys @*installed)]
-      (uninstall-shortcut-handler! id))
-    (install-shortcuts!)
+    (let [ids (keys @*installed-handlers)
+          _handler-ids (set (map :group (vals @*installed-handlers)))]
+      (doseq [id ids] (uninstall-shortcut-handler! id))
+      ;; TODO: should re-install existed handlers
+      (install-shortcuts! nil))
     (state/pub-event! [:shortcut-handler-refreshed])
     (state/pub-event! [:shortcut-handler-refreshed])
     (state/set-state! :ui/shortcut-handler-refreshing? false)))
     (state/set-state! :ui/shortcut-handler-refreshing? false)))
 
 
 (def refresh! (debounce refresh-internal! 1000))
 (def refresh! (debounce refresh-internal! 1000))
 
 
 (defn- name-with-meta [e]
 (defn- name-with-meta [e]
-  (let [ctrl    (.-ctrlKey e)
-        alt     (.-altKey e)
-        meta    (.-metaKey e)
-        shift   (.-shiftKey e)
+  (let [ctrl (.-ctrlKey e)
+        alt (.-altKey e)
+        meta (.-metaKey e)
+        shift (.-shiftKey e)
         keyname (get key-names (str (.-keyCode e)))]
         keyname (get key-names (str (.-keyCode e)))]
     (cond->> keyname
     (cond->> keyname
-      ctrl  (str "ctrl+")
-      alt   (str "alt+")
-      meta  (str "meta+")
-      shift (str "shift+"))))
+             ctrl (str "ctrl+")
+             alt (str "alt+")
+             meta (str "meta+")
+             shift (str "shift+"))))
 
 
-(defn- keyname [e]
+(defn keyname [e]
   (let [name (get key-names (str (.-keyCode e)))]
   (let [name (get key-names (str (.-keyCode e)))]
     (case name
     (case name
       nil nil
       nil nil
@@ -215,7 +278,7 @@
      (let [handler (KeyHandler. js/document)
      (let [handler (KeyHandler. js/document)
            keystroke (:rum/local state)]
            keystroke (:rum/local state)]
 
 
-       (doseq [id (keys @*installed)]
+       (doseq [id (keys @*installed-handlers)]
          (uninstall-shortcut-handler! id))
          (uninstall-shortcut-handler! id))
 
 
        (events/listen handler "key"
        (events/listen handler "key"
@@ -240,6 +303,27 @@
      (when-let [^js handler (::key-record-handler state)]
      (when-let [^js handler (::key-record-handler state)]
        (.dispose handler))
        (.dispose handler))
 
 
+     ;; force re-install shortcut handlers
      (js/setTimeout #(refresh!) 500)
      (js/setTimeout #(refresh!) 500)
 
 
      (dissoc state ::key-record-handler))})
      (dissoc state ::key-record-handler))})
+
+(defn persist-user-shortcut!
+  [id binding]
+  (let [graph-shortcuts (or (:shortcuts (state/get-graph-config)) {})
+        global-shortcuts (or (:shortcuts (state/get-global-config)) {})
+        global? true]
+    (letfn [(into-shortcuts [shortcuts]
+              (cond-> shortcuts
+                      (nil? binding)
+                      (dissoc id)
+
+                      (and global?
+                           (or (string? binding)
+                               (vector? binding)
+                               (boolean? binding)))
+                      (assoc id binding)))]
+      ;; TODO: exclude current graph config shortcuts
+      (when (nil? binding)
+        (config-handler/set-config! :shortcuts (into-shortcuts graph-shortcuts)))
+      (global-config-handler/set-global-config-kv! :shortcuts (into-shortcuts global-shortcuts)))))

+ 173 - 73
src/main/frontend/modules/shortcut/data_helper.cljs

@@ -1,11 +1,14 @@
 (ns frontend.modules.shortcut.data-helper
 (ns frontend.modules.shortcut.data-helper
   (:require [borkdude.rewrite-edn :as rewrite]
   (:require [borkdude.rewrite-edn :as rewrite]
+            [clojure.set :refer [rename-keys] :as set]
             [clojure.string :as str]
             [clojure.string :as str]
-            [clojure.set :refer [rename-keys]]
+            [cljs-bean.core :as bean]
+            [frontend.context.i18n :refer [t]]
             [frontend.config :as config]
             [frontend.config :as config]
             [frontend.db :as db]
             [frontend.db :as db]
             [frontend.handler.file :as file]
             [frontend.handler.file :as file]
             [frontend.modules.shortcut.config :as shortcut-config]
             [frontend.modules.shortcut.config :as shortcut-config]
+            [frontend.modules.shortcut.utils :as shortcut-utils]
             [frontend.state :as state]
             [frontend.state :as state]
             [frontend.util :as util]
             [frontend.util :as util]
             [lambdaisland.glogi :as log]
             [lambdaisland.glogi :as log]
@@ -13,29 +16,74 @@
             [frontend.handler.config :as config-handler])
             [frontend.handler.config :as config-handler])
   (:import [goog.ui KeyboardShortcutHandler]))
   (:import [goog.ui KeyboardShortcutHandler]))
 
 
+(declare get-group)
+
 ;; function vals->bindings is too time-consuming. Here we cache the results.
 ;; function vals->bindings is too time-consuming. Here we cache the results.
-(defn- flatten-key-bindings
-  [config]
-  (->> config
-       (into {})
-       (map (fn [[k {:keys [binding]}]]
-              {k binding}))
+(defn- flatten-bindings-by-id
+  [config user-shortcuts binding-only?]
+  (->> (vals config)
+       (apply merge)
+       (map (fn [[id {:keys [binding] :as opts}]]
+              {id (if binding-only?
+                    (get user-shortcuts id binding)
+                    (assoc opts :user-binding (get user-shortcuts id)
+                                :handler-id (get-group id)
+                                :id id))}))
        (into {})))
        (into {})))
 
 
-(def m-flatten-key-bindings (util/memoize-last flatten-key-bindings))
+(defn- flatten-bindings-by-key
+  [config user-shortcuts]
+  (reduce-kv
+    (fn [r handler-id vs]
+      (reduce-kv
+        (fn [r id {:keys [binding]}]
+          (if-let [ks (get user-shortcuts id binding)]
+            (let [ks (if (sequential? ks) ks [ks])]
+              (reduce (fn [a k]
+                        (let [k (shortcut-utils/undecorate-binding k)
+                              k' (shortcut-utils/safe-parse-string-binding k)
+                              k' (bean/->clj k')]
+                          (-> a
+                              (assoc-in [k' :key] k)
+                              (assoc-in [k' :refs id] handler-id)))) r ks))
+            r)) r vs))
+    {} config))
+
+(def m-flatten-bindings-by-id
+  (util/memoize-last flatten-bindings-by-id))
+
+(def m-flatten-bindings-by-key
+  (util/memoize-last flatten-bindings-by-key))
 
 
 (defn get-bindings
 (defn get-bindings
   []
   []
-  (m-flatten-key-bindings (vals @shortcut-config/config)))
+  (m-flatten-bindings-by-id @shortcut-config/*config (state/shortcuts) true))
 
 
-(defn- mod-key [shortcut]
-  (str/replace shortcut #"(?i)mod"
-               (if util/mac? "meta" "ctrl")))
+(defn get-bindings-keys-map
+  []
+  (m-flatten-bindings-by-key @shortcut-config/*config (state/shortcuts)))
+
+(defn get-bindings-ids-map
+  []
+  (m-flatten-bindings-by-id @shortcut-config/*config (state/shortcuts) false))
+
+(defn get-shortcut-desc
+  [binding-map]
+  (let [{:keys [id desc cmd]} binding-map
+        desc (or desc (:desc cmd) (some-> id (shortcut-utils/decorate-namespace) (t)))]
+    (if (or (nil? desc)
+            (and (string? desc) (str/starts-with? desc "{Missing")))
+      (str id) desc)))
+
+(defn mod-key [shortcut]
+  (when (string? shortcut)
+    (str/replace shortcut #"(?i)mod"
+                 (if util/mac? "meta" "ctrl"))))
 
 
 (defn shortcut-binding
 (defn shortcut-binding
+  "override by user custom binding"
   [id]
   [id]
-  (let [shortcut (get (state/shortcuts) id
-                      (get (get-bindings) id))]
+  (let [shortcut (get (get-bindings) id)]
     (cond
     (cond
       (nil? shortcut)
       (nil? shortcut)
       (log/warn :shortcut/binding-not-found {:id id})
       (log/warn :shortcut/binding-not-found {:id id})
@@ -47,62 +95,48 @@
 
 
       :else
       :else
       (->>
       (->>
-       (if (string? shortcut)
-         [shortcut]
-         shortcut)
-       (mapv mod-key)))))
+        (if (string? shortcut)
+          [shortcut]
+          shortcut)
+        (mapv mod-key)))))
 
 
 (defn shortcut-cmd
 (defn shortcut-cmd
   [id]
   [id]
   (get @shortcut-config/*shortcut-cmds id))
   (get @shortcut-config/*shortcut-cmds id))
 
 
+(defn shortcut-item
+  [id]
+  (get (get-bindings-ids-map) id))
+
 ;; returns a vector to preserve order
 ;; returns a vector to preserve order
 (defn binding-by-category [name]
 (defn binding-by-category [name]
-  (let [dict    (->> (vals @shortcut-config/config)
-                     (apply merge)
-                     (map (fn [[k _]]
-                            {k {:binding (shortcut-binding k)}}))
-                     (into {}))
+  (let [dict (get-bindings-ids-map)
         plugin? (= name :shortcut.category/plugins)]
         plugin? (= name :shortcut.category/plugins)]
     (->> (if plugin?
     (->> (if plugin?
            (->> (keys dict) (filter #(str/starts-with? (str %) ":plugin.")))
            (->> (keys dict) (filter #(str/starts-with? (str %) ":plugin.")))
-           (shortcut-config/category name))
-         (mapv (fn [k] [k (k dict)])))))
+           (shortcut-config/get-category-shortcuts name))
+         (mapv (fn [k] [k (assoc (get dict k) :category name)])))))
 
 
 (defn shortcut-map
 (defn shortcut-map
   ([handler-id]
   ([handler-id]
    (shortcut-map handler-id nil))
    (shortcut-map handler-id nil))
   ([handler-id state]
   ([handler-id state]
-   (let [raw       (get @shortcut-config/config handler-id)
+   (let [raw (get @shortcut-config/*config handler-id)
          handler-m (->> raw
          handler-m (->> raw
                         (map (fn [[k {:keys [fn]}]]
                         (map (fn [[k {:keys [fn]}]]
                                {k fn}))
                                {k fn}))
                         (into {}))
                         (into {}))
-         before    (-> raw meta :before)]
+         before (-> raw meta :before)]
      (cond->> handler-m
      (cond->> handler-m
-       state  (reduce-kv (fn [r k handle-fn]
-                           (assoc r k (partial handle-fn state)))
-                         {})
-       before (reduce-kv (fn [r k v]
-                           (assoc r k (before v)))
-                         {})))))
-
-(defn decorate-namespace [k]
-  (let [n (name k)
-        ns (namespace k)]
-    (keyword (str "command." ns) n)))
-
-(defn decorate-binding [binding]
-  (-> (if (string? binding) binding (str/join "+"  binding))
-      (str/replace "mod" (if util/mac? "⌘" "ctrl"))
-      (str/replace "alt" (if util/mac? "opt" "alt"))
-      (str/replace "shift+/" "?")
-      (str/replace "left" "←")
-      (str/replace "right" "→")
-      (str/replace "shift" "⇧")
-      (str/replace "open-square-bracket" "[")
-      (str/replace "close-square-bracket" "]")
-      (str/lower-case)))
+              state (reduce-kv (fn [r k handle-fn]
+                                 (let [handle-fn' (if (volatile? state)
+                                                    (fn [*state & args] (apply handle-fn (cons @*state args)))
+                                                    handle-fn)]
+                                   (assoc r k (partial handle-fn' state))))
+                               {})
+              before (reduce-kv (fn [r k v]
+                                  (assoc r k (before v)))
+                                {})))))
 
 
 ;; if multiple bindings, gen seq for first binding only for now
 ;; if multiple bindings, gen seq for first binding only for now
 (defn gen-shortcut-seq [id]
 (defn gen-shortcut-seq [id]
@@ -111,24 +145,24 @@
       []
       []
       (-> bindings
       (-> bindings
           first
           first
-          (str/split  #" |\+")))))
+          (str/split #" |\+")))))
 
 
 (defn binding-for-display [k binding]
 (defn binding-for-display [k binding]
   (let [tmp (cond
   (let [tmp (cond
               (false? binding)
               (false? binding)
               (cond
               (cond
-                (and util/mac? (= k :editor/kill-line-after))    "system default: ctrl+k"
+                (and util/mac? (= k :editor/kill-line-after)) "system default: ctrl+k"
                 (and util/mac? (= k :editor/beginning-of-block)) "system default: ctrl+a"
                 (and util/mac? (= k :editor/beginning-of-block)) "system default: ctrl+a"
-                (and util/mac? (= k :editor/end-of-block))       "system default: ctrl+e"
+                (and util/mac? (= k :editor/end-of-block)) "system default: ctrl+e"
                 (and util/mac? (= k :editor/backward-kill-word)) "system default: opt+delete"
                 (and util/mac? (= k :editor/backward-kill-word)) "system default: opt+delete"
-                :else "disabled")
+                :else (t :keymap/disabled))
 
 
               (string? binding)
               (string? binding)
-              (decorate-binding binding)
+              (shortcut-utils/decorate-binding binding)
 
 
               :else
               :else
               (->> binding
               (->> binding
-                   (map decorate-binding)
+                   (map shortcut-utils/decorate-binding)
                    (str/join " | ")))]
                    (str/join " | ")))]
 
 
     ;; Display "cmd" rather than "meta" to the user to describe the Mac
     ;; Display "cmd" rather than "meta" to the user to describe the Mac
@@ -157,26 +191,92 @@
   "Given shortcut key, return handler group
   "Given shortcut key, return handler group
   eg: :editor/new-line -> :shortcut.handler/block-editing-only"
   eg: :editor/new-line -> :shortcut.handler/block-editing-only"
   [k]
   [k]
-  (->> @shortcut-config/config
+  (->> @shortcut-config/*config
        (filter (fn [[_ v]] (contains? v k)))
        (filter (fn [[_ v]] (contains? v k)))
        (map key)
        (map key)
        (first)))
        (first)))
 
 
-(defn potential-conflict? [k]
-  (if-not (shortcut-binding k)
+(defn should-be-included-to-global-handler
+  [from-handler-id]
+  (if (contains? #{:shortcut.handler/pdf} from-handler-id)
+    #{from-handler-id :shortcut.handler/global-prevent-default}
+    #{from-handler-id}))
+
+(defn get-conflicts-by-keys
+  ([ks] (get-conflicts-by-keys ks :shortcut.handler/global-prevent-default {:group-global? true}))
+  ([ks handler-id] (get-conflicts-by-keys ks handler-id {:group-global? true}))
+  ([ks handler-id {:keys [exclude-ids group-global?]}]
+   (let [global-handlers #{:shortcut.handler/editor-global
+                           :shortcut.handler/global-non-editing-only
+                           :shortcut.handler/global-prevent-default
+                           :shortcut.handler/misc}
+         ks-bindings (get-bindings-keys-map)
+         handler-ids (should-be-included-to-global-handler handler-id)
+         global? (when group-global? (seq (set/intersection global-handlers handler-ids)))]
+     (->> (if (string? ks) [ks] ks)
+          (map (fn [k]
+                 (when-let [k' (shortcut-utils/undecorate-binding k)]
+                   (let [k (shortcut-utils/safe-parse-string-binding k')
+                         k (bean/->clj k)
+
+                         same-leading-key?
+                         (fn [[k' _]]
+                           (when (sequential? k)
+                             (or (= k k')
+                                 (and (> (count k') (count k))
+                                      (= (first k) (first k'))))))
+
+                         into-conflict-refs
+                         (fn [[k o]]
+                           (when-let [{:keys [key refs]} o]
+                             [k [key (reduce-kv (fn [r id handler-id']
+                                                  (if (and
+                                                        (not (contains? exclude-ids id))
+                                                        (or (= handler-ids #{handler-id'})
+                                                            (and (set? handler-ids) (contains? handler-ids handler-id'))
+                                                            (and global? (contains? global-handlers handler-id'))))
+                                                    (assoc r id handler-id')
+                                                    r)
+                                                  ) {} refs)]]))]
+
+                     [k' (->> ks-bindings
+                              (filterv same-leading-key?)
+                              (mapv into-conflict-refs)
+                              (remove #(empty? (second (second %1))))
+                              (into {}))]
+                     ))))
+          (remove #(empty? (vals (second %1))))
+          (into {})))))
+
+(defn parse-conflicts-from-binding
+  [from-binding target]
+  (when-let [from-binding (and (string? target)
+                               (sequential? from-binding)
+                               (seq from-binding))]
+    (when-let [target (some-> target (mod-key) (shortcut-utils/safe-parse-string-binding) (bean/->clj))]
+      (->> from-binding
+           (filterv
+             #(when-let [from (some-> % (mod-key) (shortcut-utils/safe-parse-string-binding) (bean/->clj))]
+                (or (= from target)
+                    (and (or (= (count from) 1)
+                             (= (count target) 1))
+                         (= (first target) (first from))))))))))
+
+(defn potential-conflict? [shortcut-id]
+  (if-not (shortcut-binding shortcut-id)
     false
     false
-    (let [handler-id    (get-group k)
-          shortcut-m    (shortcut-map handler-id)
+    (let [handler-id (get-group shortcut-id)
+          shortcut-m (shortcut-map handler-id)
           parse-shortcut #(try
           parse-shortcut #(try
-                           (KeyboardShortcutHandler/parseStringShortcut %)
-                           (catch :default e
-                             (js/console.error "[shortcut/parse-error]" (str % " - " (.-message e)))))
-          bindings      (->> (shortcut-binding k)
-                             (map mod-key)
-                             (map parse-shortcut)
-                             (map js->clj))
+                            (KeyboardShortcutHandler/parseStringShortcut %)
+                            (catch :default e
+                              (js/console.error "[shortcut/parse-error]" (str % " - " (.-message e)))))
+          bindings (->> (shortcut-binding shortcut-id)
+                        (map mod-key)
+                        (map parse-shortcut)
+                        (map js->clj))
           rest-bindings (->> (map key shortcut-m)
           rest-bindings (->> (map key shortcut-m)
-                             (remove #{k})
+                             (remove #{shortcut-id})
                              (map shortcut-binding)
                              (map shortcut-binding)
                              (filter vector?)
                              (filter vector?)
                              (mapcat identity)
                              (mapcat identity)
@@ -188,16 +288,16 @@
 
 
 (defn shortcut-data-by-id [id]
 (defn shortcut-data-by-id [id]
   (let [binding (shortcut-binding id)
   (let [binding (shortcut-binding id)
-        data    (->> (vals @shortcut-config/config)
-                     (into  {})
-                     id)]
+        data (->> (vals @shortcut-config/*config)
+                  (into {})
+                  id)]
     (assoc
     (assoc
       data
       data
       :binding
       :binding
       (binding-for-display id binding))))
       (binding-for-display id binding))))
 
 
 (defn shortcuts->commands [handler-id]
 (defn shortcuts->commands [handler-id]
-  (let [m (get @shortcut-config/config handler-id)]
+  (let [m (get @shortcut-config/*config handler-id)]
     (->> m
     (->> m
          (map (fn [[id _]] (-> (shortcut-data-by-id id)
          (map (fn [[id _]] (-> (shortcut-data-by-id id)
                                (assoc :id id :handler-id handler-id)
                                (assoc :id id :handler-id handler-id)

+ 58 - 0
src/main/frontend/modules/shortcut/utils.cljs

@@ -0,0 +1,58 @@
+(ns frontend.modules.shortcut.utils
+  (:require [clojure.string :as str]
+            [frontend.util :as util])
+  (:import [goog.ui KeyboardShortcutHandler]))
+
+(defn safe-parse-string-binding
+  [binding]
+  (try
+    (KeyboardShortcutHandler/parseStringShortcut binding)
+    (catch js/Error e
+      (js/console.warn "[shortcuts] parse key error: " e) binding)))
+
+(defn mod-key [binding]
+  (str/replace binding #"(?i)mod"
+               (if util/mac? "meta" "ctrl")))
+
+(defn undecorate-binding
+  [binding]
+  (when (string? binding)
+    (let [keynames {";" "semicolon"
+                    "=" "equals"
+                    "-" "dash"
+                    "[" "open-square-bracket"
+                    "]" "close-square-bracket"
+                    "'" "single-quote"
+                    "(" "shift+9"
+                    ")" "shift+0"
+                    "~" "shift+`"
+                    "⇧" "shift"
+                    "←" "left"
+                    "→" "right"}]
+      (-> binding
+          (str/replace #"[;=-\[\]'\(\)\~\→\←\⇧]" #(get keynames %))
+          (str/replace #"\s+" " ")
+          (mod-key)
+          (str/lower-case)))))
+
+(defn decorate-namespace [k]
+  (let [n (name k)
+        ns (namespace k)]
+    (keyword (str "command." ns) n)))
+
+(defn decorate-binding [binding]
+  (when (or (string? binding)
+            (sequential? binding))
+    (-> (if (string? binding) binding (str/join "+" binding))
+        (str/replace "mod" (if util/mac? "⌘" "ctrl"))
+        (str/replace "meta" (if util/mac? "⌘" "⊞ win"))
+        (str/replace "alt" (if util/mac? "opt" "alt"))
+        (str/replace "shift+/" "?")
+        (str/replace "left" "←")
+        (str/replace "right" "→")
+        (str/replace "shift" "⇧")
+        (str/replace "open-square-bracket" "[")
+        (str/replace "close-square-bracket" "]")
+        (str/replace "equals" "=")
+        (str/replace "semicolon" ";")
+        (str/lower-case))))

+ 0 - 5
src/main/frontend/routes.cljs

@@ -9,7 +9,6 @@
             [frontend.components.repo :as repo]
             [frontend.components.repo :as repo]
             [frontend.components.search :as search]
             [frontend.components.search :as search]
             [frontend.components.settings :as settings]
             [frontend.components.settings :as settings]
-            [frontend.components.shortcut :as shortcut]
             [frontend.components.whiteboard :as whiteboard] 
             [frontend.components.whiteboard :as whiteboard] 
             [frontend.extensions.zotero :as zotero]
             [frontend.extensions.zotero :as zotero]
             [frontend.components.bug-report :as bug-report]
             [frontend.components.bug-report :as bug-report]
@@ -69,10 +68,6 @@
     {:name :settings
     {:name :settings
      :view settings/settings}]
      :view settings/settings}]
 
 
-   ["/settings/shortcut"
-    {:name :shortcut-setting
-     :view shortcut/shortcut-page}]
-
    ["/settings/zotero"
    ["/settings/zotero"
     {:name :zotero-setting
     {:name :zotero-setting
      :view zotero/settings}]
      :view zotero/settings}]

+ 30 - 12
src/main/frontend/state.cljs

@@ -61,6 +61,7 @@
      :modal/label                           ""
      :modal/label                           ""
      :modal/show?                           false
      :modal/show?                           false
      :modal/panel-content                   nil
      :modal/panel-content                   nil
+     :modal/payload                         nil
      :modal/fullscreen?                     false
      :modal/fullscreen?                     false
      :modal/close-btn?                      nil
      :modal/close-btn?                      nil
      :modal/close-backdrop?                 true
      :modal/close-backdrop?                 true
@@ -344,6 +345,18 @@
              (merge current new)
              (merge current new)
              new)))))
              new)))))
 
 
+(defn get-global-config
+  []
+  (get-in @state [:config ::global-config]))
+
+(defn get-global-config-str-content
+  []
+  (get-in @state [:config ::global-config-str-content]))
+
+(defn get-graph-config
+  ([] (get-graph-config (get-current-repo)))
+  ([repo-url] (get-in @state [:config repo-url])))
+
 (defn get-config
 (defn get-config
   "User config for the given repo or current repo if none given. All config fetching
   "User config for the given repo or current repo if none given. All config fetching
 should be done through this fn in order to get global config and config defaults"
 should be done through this fn in order to get global config and config defaults"
@@ -352,8 +365,8 @@ should be done through this fn in order to get global config and config defaults
   ([repo-url]
   ([repo-url]
    (merge-configs
    (merge-configs
     default-config
     default-config
-    (get-in @state [:config ::global-config])
-    (get-in @state [:config repo-url]))))
+    (get-global-config)
+    (get-graph-config repo-url))))
 
 
 (defonce publishing? (atom nil))
 (defonce publishing? (atom nil))
 
 
@@ -1341,7 +1354,7 @@ Similar to re-frame subscriptions"
   ([panel-content]
   ([panel-content]
    (set-sub-modal! panel-content
    (set-sub-modal! panel-content
                    {:close-btn? true}))
                    {:close-btn? true}))
-  ([panel-content {:keys [id label close-btn? close-backdrop? show? center?] :as opts}]
+  ([panel-content {:keys [id label payload close-btn? close-backdrop? show? center?] :as opts}]
    (if (not (modal-opened?))
    (if (not (modal-opened?))
      (set-modal! panel-content opts)
      (set-modal! panel-content opts)
      (let [modals (:modal/subsets @state)
      (let [modals (:modal/subsets @state)
@@ -1351,6 +1364,7 @@ Similar to re-frame subscriptions"
                    #(not (nil? %1))
                    #(not (nil? %1))
                    {:modal/id            id
                    {:modal/id            id
                     :modal/label         (or label (if center? "ls-modal-align-center" ""))
                     :modal/label         (or label (if center? "ls-modal-align-center" ""))
+                    :modal/payload       payload
                     :modal/show?         (if (boolean? show?) show? true)
                     :modal/show?         (if (boolean? show?) show? true)
                     :modal/panel-content panel-content
                     :modal/panel-content panel-content
                     :modal/close-btn?    close-btn?
                     :modal/close-btn?    close-btn?
@@ -1380,7 +1394,7 @@ Similar to re-frame subscriptions"
    (set-modal! modal-panel-content
    (set-modal! modal-panel-content
                {:fullscreen? false
                {:fullscreen? false
                 :close-btn?  true}))
                 :close-btn?  true}))
-  ([modal-panel-content {:keys [id label fullscreen? close-btn? close-backdrop? center?]}]
+  ([modal-panel-content {:keys [id label payload fullscreen? close-btn? close-backdrop? center?]}]
    (let [opened? (modal-opened?)]
    (let [opened? (modal-opened?)]
      (when opened?
      (when opened?
        (close-modal!))
        (close-modal!))
@@ -1395,6 +1409,7 @@ Similar to re-frame subscriptions"
               :modal/label (or label (if center? "ls-modal-align-center" ""))
               :modal/label (or label (if center? "ls-modal-align-center" ""))
               :modal/show? (boolean modal-panel-content)
               :modal/show? (boolean modal-panel-content)
               :modal/panel-content modal-panel-content
               :modal/panel-content modal-panel-content
+              :modal/payload payload
               :modal/fullscreen? fullscreen?
               :modal/fullscreen? fullscreen?
               :modal/close-btn? close-btn?
               :modal/close-btn? close-btn?
               :modal/close-backdrop? (if (boolean? close-backdrop?) close-backdrop? true))))
               :modal/close-backdrop? (if (boolean? close-backdrop?) close-backdrop? true))))
@@ -1408,6 +1423,7 @@ Similar to re-frame subscriptions"
       (swap! state assoc
       (swap! state assoc
              :modal/id nil
              :modal/id nil
              :modal/label ""
              :modal/label ""
+             :modal/payload nil
              :modal/show? false
              :modal/show? false
              :modal/fullscreen? false
              :modal/fullscreen? false
              :modal/panel-content nil
              :modal/panel-content nil
@@ -1477,9 +1493,11 @@ Similar to re-frame subscriptions"
   (when value (set-state! [:config repo-url] value)))
   (when value (set-state! [:config repo-url] value)))
 
 
 (defn set-global-config!
 (defn set-global-config!
-  [value]
+  [value str-content]
   ;; Placed under :config so cursors can work seamlessly
   ;; Placed under :config so cursors can work seamlessly
-  (when value (set-config! ::global-config value)))
+  (when value
+    (set-config! ::global-config value)
+    (set-config! ::global-config-str-content str-content)))
 
 
 (defn get-wide-mode?
 (defn get-wide-mode?
   []
   []
@@ -1499,13 +1517,13 @@ Similar to re-frame subscriptions"
 
 
 (defn get-plugins-commands-with-type
 (defn get-plugins-commands-with-type
   [type]
   [type]
-  (filterv #(= (keyword (first %)) (keyword type))
-           (apply concat (vals (:plugin/simple-commands @state)))))
+  (->> (apply concat (vals (:plugin/simple-commands @state)))
+       (filterv #(= (keyword (first %)) (keyword type)))))
 
 
 (defn get-plugins-ui-items-with-type
 (defn get-plugins-ui-items-with-type
   [type]
   [type]
-  (filterv #(= (keyword (first %)) (keyword type))
-           (apply concat (vals (:plugin/installed-ui-items @state)))))
+  (->> (apply concat (vals (:plugin/installed-ui-items @state)))
+       (filterv #(= (keyword (first %)) (keyword type)))))
 
 
 (defn get-plugin-resources-with-type
 (defn get-plugin-resources-with-type
   [pid type]
   [pid type]
@@ -1734,8 +1752,8 @@ Similar to re-frame subscriptions"
   (set-state! :ui/settings-open? false))
   (set-state! :ui/settings-open? false))
 
 
 (defn open-settings!
 (defn open-settings!
-  []
-  (set-state! :ui/settings-open? true))
+  ([] (open-settings! true))
+  ([active-tab] (set-state! :ui/settings-open? active-tab)))
 
 
 ;; TODO: Move those to the uni `state`
 ;; TODO: Move those to the uni `state`
 
 

+ 7 - 6
src/main/frontend/ui.cljs

@@ -21,7 +21,7 @@
             [frontend.mobile.util :as mobile-util]
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.shortcut.config :as shortcut-config]
             [frontend.modules.shortcut.config :as shortcut-config]
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.modules.shortcut.core :as shortcut]
-            [frontend.modules.shortcut.data-helper :as shortcut-helper]
+            [frontend.modules.shortcut.utils :as shortcut-utils]
             [frontend.rum :as r]
             [frontend.rum :as r]
             [frontend.state :as state]
             [frontend.state :as state]
             [frontend.storage :as storage]
             [frontend.storage :as storage]
@@ -167,7 +167,7 @@
                    sequence)]
                    sequence)]
     [:span.keyboard-shortcut
     [:span.keyboard-shortcut
      (map-indexed (fn [i key]
      (map-indexed (fn [i key]
-                    (let [key' (shortcut-helper/decorate-binding (str key))]
+                    (let [key' (shortcut-utils/decorate-binding (str key))]
                       [:code {:key i}
                       [:code {:key i}
                       ;; Display "cmd" rather than "meta" to the user to describe the Mac
                       ;; Display "cmd" rather than "meta" to the user to describe the Mac
                       ;; mod key, because that's what the Mac keyboards actually say.
                       ;; mod key, because that's what the Mac keyboards actually say.
@@ -507,7 +507,7 @@
 
 
 (rum/defcs auto-complete <
 (rum/defcs auto-complete <
   (rum/local 0 ::current-idx)
   (rum/local 0 ::current-idx)
-  (shortcut/mixin :shortcut.handler/auto-complete)
+  (shortcut/mixin* :shortcut.handler/auto-complete)
   [state
   [state
    matched
    matched
    {:keys [on-chosen
    {:keys [on-chosen
@@ -567,9 +567,10 @@
        :aria-hidden "true"}]]]))
        :aria-hidden "true"}]]]))
 
 
 (defn keyboard-shortcut-from-config [shortcut-name]
 (defn keyboard-shortcut-from-config [shortcut-name]
-  (let [default-binding (:binding (get shortcut-config/all-default-keyboard-shortcuts shortcut-name))
-        custom-binding  (when (state/shortcuts) (get (state/shortcuts) shortcut-name))]
-    (or custom-binding default-binding)))
+  (let [built-in-binding (:binding (get shortcut-config/all-built-in-keyboard-shortcuts shortcut-name))
+        custom-binding  (when (state/shortcuts) (get (state/shortcuts) shortcut-name))
+        binding         (or custom-binding built-in-binding)]
+    (shortcut-utils/decorate-binding binding)))
 
 
 (rum/defc modal-overlay
 (rum/defc modal-overlay
   [state close-fn close-backdrop?]
   [state close-fn close-backdrop?]

+ 1 - 1
src/main/frontend/ui/date_picker.cljs

@@ -172,7 +172,7 @@
   {:init (fn [state]
   {:init (fn [state]
            (reset! *internal-model (first (:rum/args state)))
            (reset! *internal-model (first (:rum/args state)))
            state)}
            state)}
-  (shortcut/mixin :shortcut.handler/date-picker)
+  (shortcut/mixin :shortcut.handler/date-picker false)
   [_model {:keys [on-change disabled? start-of-week class style attr]
   [_model {:keys [on-change disabled? start-of-week class style attr]
            :or   {start-of-week (state/get-start-of-week)} ;; Default to Sunday
            :or   {start-of-week (state/get-start-of-week)} ;; Default to Sunday
            :as   args}]
            :as   args}]

+ 0 - 13
src/main/frontend/util.cljc

@@ -67,19 +67,6 @@
   [parts]
   [parts]
   (string/join "/" parts))
   (string/join "/" parts))
 
 
-(defn normalize-user-keyname
-  [k]
-  (let [keynames {";" "semicolon"
-                  "=" "equals"
-                  "-" "dash"
-                  "[" "open-square-bracket"
-                  "]" "close-square-bracket"
-                  "'" "single-quote"}]
-    (some-> (str k)
-            (string/lower-case)
-            (string/replace #"[;=-\[\]']" (fn [s]
-                                            (get keynames s))))))
-
 #?(:cljs
 #?(:cljs
    (defn safe-re-find
    (defn safe-re-find
      {:malli/schema [:=> [:cat :any :string] [:or :nil :string [:vector [:maybe :string]]]]}
      {:malli/schema [:=> [:cat :any :string] [:or :nil :string [:vector [:maybe :string]]]]}

+ 4 - 4
src/main/logseq/api.cljs

@@ -367,9 +367,9 @@
                                (if palette?
                                (if palette?
                                  (palette-handler/invoke-command palette-cmd)
                                  (palette-handler/invoke-command palette-cmd)
                                  (action')))
                                  (action')))
-                [handler-id id shortcut-map] (update shortcut-args 2 assoc :fn dispatch-cmd :cmd palette-cmd)]
-            (println :shortcut/register-shortcut [handler-id id shortcut-map])
-            (st/register-shortcut! handler-id id shortcut-map)))))))
+                [mode-id id shortcut-map] (update shortcut-args 2 merge cmd {:fn dispatch-cmd :cmd palette-cmd})]
+            (println :shortcut/register-shortcut [mode-id id shortcut-map])
+            (st/register-shortcut! mode-id id shortcut-map)))))))
 
 
 (defn ^:export unregister_plugin_simple_command
 (defn ^:export unregister_plugin_simple_command
   [pid]
   [pid]
@@ -422,7 +422,7 @@
                            (util/safe-lower-case)
                            (util/safe-lower-case)
                            (keyword)))]
                            (keyword)))]
       (when-let [action (get-in (palette-handler/get-commands-unique) [id :action])]
       (when-let [action (get-in (palette-handler/get-commands-unique) [id :action])]
-        (apply action args)))))
+        (apply plugin-handler/hook-lifecycle-fn! id action args)))))
 
 
 ;; flag - boolean | 'toggle'
 ;; flag - boolean | 'toggle'
 (def ^:export set_left_sidebar_visible
 (def ^:export set_left_sidebar_visible

+ 17 - 1
src/resources/dicts/en.edn

@@ -320,6 +320,7 @@
  :settings-page/current-version "Current version"
  :settings-page/current-version "Current version"
  :settings-page/tab-general "General"
  :settings-page/tab-general "General"
  :settings-page/tab-editor "Editor"
  :settings-page/tab-editor "Editor"
+ :settings-page/tab-keymap "Keymap"
  :settings-page/tab-version-control "Version control"
  :settings-page/tab-version-control "Version control"
  :settings-page/tab-account "Account"
  :settings-page/tab-account "Account"
  :settings-page/tab-advanced "Advanced"
  :settings-page/tab-advanced "Advanced"
@@ -359,6 +360,7 @@
  :close "Close"
  :close "Close"
  :delete "Delete"
  :delete "Delete"
  :save "Save"
  :save "Save"
+ :reset "Reset"
  :type "Type"
  :type "Type"
  :host "Host"
  :host "Host"
  :port "Port"
  :port "Port"
@@ -636,9 +638,23 @@
  :shortcut.category/block-command-editing "Block command editing"
  :shortcut.category/block-command-editing "Block command editing"
  :shortcut.category/block-selection "Block selection (press Esc to quit selection)"
  :shortcut.category/block-selection "Block selection (press Esc to quit selection)"
  :shortcut.category/toggle "Toggle"
  :shortcut.category/toggle "Toggle"
- :shortcut.category/whiteboard "Whiteboard"
  :shortcut.category/others "Others"
  :shortcut.category/others "Others"
  :shortcut.category/plugins "Plugins"
  :shortcut.category/plugins "Plugins"
+ :shortcut.category/whiteboard "Whiteboard"
+
+ :keymap/all "All"
+ :keymap/disabled "Disabled"
+ :keymap/unset "Unset"
+ :keymap/custom "Custom"
+ :keymap/search "Search"
+ :keymap/total "Total shortcuts"
+ :keymap/keystroke-filter "Keystroke filter"
+ :keymap/keystroke-record-desc "Press any sequence of keys to filter shortcuts"
+ :keymap/keystroke-record-setup-label "Press any sequence of keys to set a shortcut"
+ :keymap/restore-to-default "Restore to system default"
+ :keymap/customize-for-label "Customize shortcuts"
+ :keymap/conflicts-for-label "Keymap conflicts for"
+
  :window/minimize "Minimize"
  :window/minimize "Minimize"
  :window/maximize "Maximize"
  :window/maximize "Maximize"
  :window/restore "Restore"
  :window/restore "Restore"

+ 18 - 0
src/resources/dicts/zh-cn.edn

@@ -233,6 +233,7 @@
  :settings-page/show-full-blocks "显示块引用的所有行"
  :settings-page/show-full-blocks "显示块引用的所有行"
  :settings-page/tab-general "常规"
  :settings-page/tab-general "常规"
  :settings-page/tab-editor "编辑器"
  :settings-page/tab-editor "编辑器"
+ :settings-page/tab-keymap "快捷键"
  :settings-page/tab-assets "附件设置"
  :settings-page/tab-assets "附件设置"
  :settings-page/tab-advanced "高级设置"
  :settings-page/tab-advanced "高级设置"
  :settings-page/tab-features "更多功能"
  :settings-page/tab-features "更多功能"
@@ -293,6 +294,7 @@
  :close "关闭"
  :close "关闭"
  :delete "删除"
  :delete "删除"
  :save "保存"
  :save "保存"
+ :reset "重设"
  :type "类型"
  :type "类型"
  :host "主机"
  :host "主机"
  :port "端口"
  :port "端口"
@@ -486,6 +488,22 @@
  :shortcut.category/block-selection       "块选择操作"
  :shortcut.category/block-selection       "块选择操作"
  :shortcut.category/toggle                "切换"
  :shortcut.category/toggle                "切换"
  :shortcut.category/others                "其他"
  :shortcut.category/others                "其他"
+ :shortcut.category/plugins               "插件"
+ :shortcut.category/whiteboard            "白板"
+
+ :keymap/all "全部"
+ :keymap/disabled "已禁用"
+ :keymap/unset "未设置"
+ :keymap/custom "自定义"
+ :keymap/search "搜索"
+ :keymap/total "共计条目"
+ :keymap/keystroke-filter "按键过滤"
+ :keymap/keystroke-record-desc "请敲击键盘提供按键组合, 以过滤快捷键。"
+ :keymap/keystroke-record-setup-label "请敲击键盘提供按键组合"
+ :keymap/restore-to-default "恢复到默认映射"
+ :keymap/customize-for-label "自定义快捷键"
+ :keymap/conflicts-for-label "发现映射冲突 "
+
  :command.auto-complete/complete          "自动完成:选择当前项"
  :command.auto-complete/complete          "自动完成:选择当前项"
  :command.auto-complete/next              "自动完成:选择下一项"
  :command.auto-complete/next              "自动完成:选择下一项"
  :command.auto-complete/open-link         "自动完成:在浏览器中打开当前项"
  :command.auto-complete/open-link         "自动完成:在浏览器中打开当前项"

+ 49 - 0
src/test/frontend/modules/shortcut/core_test.cljs

@@ -0,0 +1,49 @@
+(ns frontend.modules.shortcut.core-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [clojure.string :as string]
+            [frontend.modules.shortcut.data-helper :as dh]
+            [frontend.util :as util]))
+
+(deftest test-core-basic
+  (testing "get handler id"
+    (is (= (dh/get-group :editor/copy) :shortcut.handler/editor-global))))
+
+(deftest test-shortcut-conflicts-detection
+  (testing "get conflicts with shortcut id")
+
+  (testing "get conflicts with binding keys"
+    (is (= (count (dh/get-conflicts-by-keys "mod+c")) 1))
+
+    (is (contains?
+          (->> (dh/get-conflicts-by-keys
+                 "mod+c" :shortcut.handler/editor-global
+                 {:exclude-ids #{:editor/copy} :group-global? true})
+               (vals) (mapcat #(vals %)) (some #(when (= (first %) (if util/mac? "meta+c" "ctrl+c")) (second %))))
+          :misc/copy))
+
+    (is (->> (dh/get-conflicts-by-keys ["t"])
+             (vals)
+             (first)
+             (vals)
+             (map first)
+             (every? #(string/starts-with? % "t")))
+        "get the conflicts from the only leader key")
+
+    (is (nil? (seq (dh/get-conflicts-by-keys ["g"] :shortcut.handler/cards)))
+        "specific handler with the global conflicting key"))
+
+  (testing "parse conflicts from the string binding list"
+    (is (= (dh/parse-conflicts-from-binding ["g" "g t"] "g")
+           ["g" "g t"]))
+
+    (is (= (dh/parse-conflicts-from-binding ["g" "g t" "t r"] "g t")
+           ["g" "g t"]))
+
+    (is (= (dh/parse-conflicts-from-binding ["g" "g t" "t r"] "g x")
+           ["g"]))
+
+    (is (= (dh/parse-conflicts-from-binding ["meta+x" "meta+x t" "t r"] "meta+x x")
+           ["meta+x"]))))
+
+(comment
+  (cljs.test/run-tests))

+ 44 - 44
src/test/frontend/util_test.cljs

@@ -25,9 +25,9 @@
 (deftest test-memoize-last
 (deftest test-memoize-last
   (testing "memoize-last add test"
   (testing "memoize-last add test"
     (let [actual-ops (atom 0)
     (let [actual-ops (atom 0)
-          m+         (util/memoize-last (fn [x1 x2]
-                                           (swap! actual-ops inc) ;; side effect for counting
-                                           (+ x1 x2)))]
+          m+ (util/memoize-last (fn [x1 x2]
+                                  (swap! actual-ops inc)    ;; side effect for counting
+                                  (+ x1 x2)))]
       (is (= (m+ 1 1) 2))
       (is (= (m+ 1 1) 2))
       (is (= @actual-ops 1))
       (is (= @actual-ops 1))
       (is (= (m+ 1 1) 2))
       (is (= (m+ 1 1) 2))
@@ -44,58 +44,58 @@
 
 
   (testing "memoize-last nested mapping test"
   (testing "memoize-last nested mapping test"
     (let [actual-ops (atom 0)
     (let [actual-ops (atom 0)
-          flatten-f  (util/memoize-last (fn [& args]
-                                           (swap! actual-ops inc) ;; side effect for counting
-                                           (apply #'shortcut-data-helper/flatten-key-bindings args)))
-          target     (atom {:part1 {:date-picker/complete         {:binding "enter"
-                                                                   :fn      "ui-handler/shortcut-complete"}
-                                    :date-picker/prev-day         {:binding "left"
-                                                                   :fn      "ui-handler/shortcut-prev-day"}}
-                            :part2 {:date-picker/next-day         {:binding "right"
-                                                                   :fn      "ui-handler/shortcut-next-day"}
-                                    :date-picker/prev-week        {:binding ["up" "ctrl+p"]
-                                                                   :fn      "ui-handler/shortcut-prev-week"}}})]
-      (is (= (flatten-f (vals @target)) {:date-picker/complete         "enter"
-                                         :date-picker/prev-day         "left"
-                                         :date-picker/next-day         "right"
-                                         :date-picker/prev-week        ["up" "ctrl+p"]}))
+          flatten-f (util/memoize-last (fn [& args]
+                                         (swap! actual-ops inc) ;; side effect for counting
+                                         (apply #'shortcut-data-helper/flatten-bindings-by-id (conj (vec args) nil true))))
+          target (atom {:part1 {:date-picker/complete {:binding "enter"
+                                                       :fn      "ui-handler/shortcut-complete"}
+                                :date-picker/prev-day {:binding "left"
+                                                       :fn      "ui-handler/shortcut-prev-day"}}
+                        :part2 {:date-picker/next-day  {:binding "right"
+                                                        :fn      "ui-handler/shortcut-next-day"}
+                                :date-picker/prev-week {:binding ["up" "ctrl+p"]
+                                                        :fn      "ui-handler/shortcut-prev-week"}}})]
+      (is (= (flatten-f @target) {:date-picker/complete  "enter"
+                                  :date-picker/prev-day  "left"
+                                  :date-picker/next-day  "right"
+                                  :date-picker/prev-week ["up" "ctrl+p"]}))
       (is (= @actual-ops 1))
       (is (= @actual-ops 1))
-      (is (= (flatten-f (vals @target)) {:date-picker/complete         "enter"
-                                         :date-picker/prev-day         "left"
-                                         :date-picker/next-day         "right"
-                                         :date-picker/prev-week        ["up" "ctrl+p"]}))
+      (is (= (flatten-f @target) {:date-picker/complete  "enter"
+                                  :date-picker/prev-day  "left"
+                                  :date-picker/next-day  "right"
+                                  :date-picker/prev-week ["up" "ctrl+p"]}))
       (is (= @actual-ops 1))
       (is (= @actual-ops 1))
       ;; edit value
       ;; edit value
       (swap! target assoc-in [:part1 :date-picker/complete :binding] "tab")
       (swap! target assoc-in [:part1 :date-picker/complete :binding] "tab")
-      (is (= (flatten-f (vals @target)) {:date-picker/complete         "tab"
-                                         :date-picker/prev-day         "left"
-                                         :date-picker/next-day         "right"
-                                         :date-picker/prev-week        ["up" "ctrl+p"]}))
+      (is (= (flatten-f @target) {:date-picker/complete  "tab"
+                                  :date-picker/prev-day  "left"
+                                  :date-picker/next-day  "right"
+                                  :date-picker/prev-week ["up" "ctrl+p"]}))
       (is (= @actual-ops 2))
       (is (= @actual-ops 2))
-      (is (= (flatten-f (vals @target)) {:date-picker/complete         "tab"
-                                         :date-picker/prev-day         "left"
-                                         :date-picker/next-day         "right"
-                                         :date-picker/prev-week        ["up" "ctrl+p"]}))
+      (is (= (flatten-f @target) {:date-picker/complete  "tab"
+                                  :date-picker/prev-day  "left"
+                                  :date-picker/next-day  "right"
+                                  :date-picker/prev-week ["up" "ctrl+p"]}))
       (is (= @actual-ops 2))
       (is (= @actual-ops 2))
-      (is (= (flatten-f (vals @target)) {:date-picker/complete         "tab"
-                                         :date-picker/prev-day         "left"
-                                         :date-picker/next-day         "right"
-                                         :date-picker/prev-week        ["up" "ctrl+p"]}))
+      (is (= (flatten-f @target) {:date-picker/complete  "tab"
+                                  :date-picker/prev-day  "left"
+                                  :date-picker/next-day  "right"
+                                  :date-picker/prev-week ["up" "ctrl+p"]}))
       (is (= @actual-ops 2))
       (is (= @actual-ops 2))
       ;; edit key
       ;; edit key
       (swap! target assoc :part3 {:date-picker/next-week {:binding "down"
       (swap! target assoc :part3 {:date-picker/next-week {:binding "down"
                                                           :fn      "ui-handler/shortcut-next-week"}})
                                                           :fn      "ui-handler/shortcut-next-week"}})
-      (is (= (flatten-f (vals @target)) {:date-picker/complete         "tab"
-                                         :date-picker/prev-day         "left"
-                                         :date-picker/next-day         "right"
-                                         :date-picker/prev-week        ["up" "ctrl+p"]
-                                         :date-picker/next-week        "down"}))
+      (is (= (flatten-f @target) {:date-picker/complete  "tab"
+                                  :date-picker/prev-day  "left"
+                                  :date-picker/next-day  "right"
+                                  :date-picker/prev-week ["up" "ctrl+p"]
+                                  :date-picker/next-week "down"}))
       (is (= @actual-ops 3))
       (is (= @actual-ops 3))
-      (is (= (flatten-f (vals @target)) {:date-picker/complete         "tab"
-                                         :date-picker/prev-day         "left"
-                                         :date-picker/next-day         "right"
-                                         :date-picker/prev-week        ["up" "ctrl+p"]
-                                         :date-picker/next-week        "down"}))
+      (is (= (flatten-f @target) {:date-picker/complete  "tab"
+                                  :date-picker/prev-day  "left"
+                                  :date-picker/next-day  "right"
+                                  :date-picker/prev-week ["up" "ctrl+p"]
+                                  :date-picker/next-week "down"}))
       (is (= @actual-ops 3)))))
       (is (= @actual-ops 3)))))
 
 
 (deftest test-media-format-from-input
 (deftest test-media-format-from-input

部分文件因为文件数量过多而无法显示