├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── rollup.config.js ├── src ├── actions.ts ├── cases.ts ├── enums.ts ├── index.ts ├── keyHandlers.ts ├── plugin.ts ├── types.ts └── utils.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "rules": { 4 | "import/no-unresolved": "off", 5 | "import/extensions": "off", 6 | "no-shadow": "off", 7 | "@typescript-eslint/no-explicit-any": "off", 8 | "no-promise-executor-return": "off", 9 | "consistent-return": "off", 10 | "import/prefer-default-export": "off" 11 | }, 12 | "env": { 13 | "browser": true 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint", 17 | "prettier", 18 | "jest" 19 | ], 20 | "extends": [ 21 | "airbnb", 22 | "plugin:@typescript-eslint/recommended", 23 | "plugin:import/typescript", 24 | "plugin:prettier/recommended" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### NextJS ### 2 | # Next build dir 3 | .next/ 4 | 5 | ### PhpStorm ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea 11 | 12 | # CMake 13 | cmake-build-*/ 14 | # File-based project format 15 | *.iws 16 | 17 | # IntelliJ 18 | out/ 19 | 20 | # mpeltonen/sbt-idea plugin 21 | .idea_modules/ 22 | 23 | # JIRA plugin 24 | atlassian-ide-plugin.xml 25 | 26 | # Cursive Clojure plugin 27 | .idea/replstate.xml 28 | 29 | # SonarLint plugin 30 | .idea/sonarlint/ 31 | 32 | # Crashlytics plugin (for Android Studio and IntelliJ) 33 | com_crashlytics_export_strings.xml 34 | crashlytics.properties 35 | crashlytics-build.properties 36 | fabric.properties 37 | 38 | # Editor-based Rest Client 39 | .idea/httpRequests 40 | 41 | # Android studio 3.1+ serialized cache file 42 | .idea/caches/build_file_checksums.ser 43 | 44 | ### PhpStorm Patch ### 45 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 46 | 47 | # *.iml 48 | # modules.xml 49 | # .idea/misc.xml 50 | # *.ipr 51 | 52 | # Sonarlint plugin 53 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 54 | .idea/**/sonarlint/ 55 | 56 | # SonarQube Plugin 57 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 58 | .idea/**/sonarIssues.xml 59 | 60 | # Markdown Navigator plugin 61 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 62 | .idea/**/markdown-navigator.xml 63 | .idea/**/markdown-navigator-enh.xml 64 | .idea/**/markdown-navigator/ 65 | 66 | # Cache file creation bug 67 | # See https://youtrack.jetbrains.com/issue/JBR-2257 68 | .idea/$CACHE_FILE$ 69 | 70 | # CodeStream plugin 71 | # https://plugins.jetbrains.com/plugin/12206-codestream 72 | .idea/codestream.xml 73 | 74 | # Azure Toolkit for IntelliJ plugin 75 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 76 | .idea/**/azureSettings.xml 77 | 78 | ### react ### 79 | .DS_* 80 | *.log 81 | logs 82 | **/*.backup.* 83 | **/*.back.* 84 | 85 | node_modules 86 | dist 87 | bower_components 88 | 89 | *.sublime* 90 | 91 | psd 92 | thumb 93 | sketch 94 | 95 | *.tgz 96 | ### yalc ### 97 | /.yalc 98 | yalc.lock -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # prosemirror-slash-menu 2 | 3 | ## 0.4.4 4 | 5 | ### Patch Changes 6 | 7 | - handlekeydown 8 | 9 | ## 0.4.3 10 | 11 | ### Patch Changes 12 | 13 | - slash menu command order 14 | 15 | ## 0.4.2 16 | 17 | ### Patch Changes 18 | 19 | - inline filter 20 | 21 | ## 0.4.0 22 | 23 | ### Minor Changes 24 | 25 | - inlineFilter 26 | 27 | ## 0.3.0 28 | 29 | ### Minor Changes 30 | 31 | - ignore case 32 | 33 | ## 0.2.0 34 | 35 | ### Minor Changes 36 | 37 | - slash menu grouping 38 | 39 | ## 0.1.9 40 | 41 | ### Patch Changes 42 | 43 | - package upgrade 44 | 45 | ## 0.1.8 46 | 47 | ### Patch Changes 48 | 49 | - fix readme ee img 50 | 51 | ## 0.1.7 52 | 53 | ### Patch Changes 54 | 55 | - remove preinstall 56 | 57 | ## 0.1.6 58 | 59 | ### Patch Changes 60 | 61 | - Readme 62 | 63 | ## 0.1.5 64 | 65 | ### Patch Changes 66 | 67 | - add changeset 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Emergence Engineering 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-slash-menu 2 | 3 | [![made by Emergence Engineering](https://emergence-engineering.com/ee-logo.svg)](https://emergence-engineering.com) 4 | 5 | [**Made by Emergence-Engineering**](https://emergence-engineering.com/) 6 | 7 | A ProseMirror plugin to handle the state of a slash menu. It is intended to be opened inline with `/`, searched and navigated by keyboard. 8 | It can be used together with [prosemirror-slash-menu-react](https://github.com/emergence-engineering/prosemirror-slash-menu-react) to provide a UI element for 9 | the plugin, or you could write your own. 10 | 11 | By Horváth Áron & [Viktor Váczi](https://emergence-engineering.com/cv/viktor) at [Emergence Engineering](https://emergence-engineering.com/) 12 | 13 | Try it out at 14 | 15 | ![alt text](https://github.com/emergence-engineering/prosemirror-slash-menu-react/blob/main/public/prosemirror-slash-menu.gif?raw=true) 16 | 17 | # Features 18 | 19 | - Opening the menu with `/` in an empty paragraph or after a space by default 20 | - Option to add custom opening conditions 21 | - Navigation and selection with keyboard 22 | - Commands can be easily added to the menu as a `MenuElement[]` 23 | - Filtering commands simply by typing while menu is open 24 | - Nested sub menus 25 | 26 | # Usage 27 | 28 | Add to your editor plugins with an initial array of menu elements. You can import an example list of MenuElements from [prosemirror-slash-menu-react](https://github.com/emergence-engineering/prosemirror-slash-menu-react). 29 | 30 | ``` 31 | plugins: [ 32 | ... 33 | SlashMenuPlugin(defaultElements), 34 | ... 35 | ], 36 | ``` 37 | 38 | ### With tiptap 39 | 40 | For usage with tiptap simply create an extension with the plugin. 41 | 42 | ```typescript 43 | Extension.create({ 44 | name: "SlashMenuPlugin", 45 | addProseMirrorPlugins() { 46 | return [SlashMenuPlugin(defaultElements)]; 47 | }, 48 | }); 49 | ``` 50 | 51 | # Arguments 52 | 53 | ```typescript 54 | SlashMenuPlugin = ( 55 | menuElements: MenuElement[], 56 | ignoredKeys?: string[], 57 | customConditions?: OpeningConditions, 58 | openInSelection?: boolean 59 | ) => void; 60 | ``` 61 | 62 | ### Menu Elements 63 | 64 | A menu elements can either be a simple `CommandItem` that executes an action, or it can be a `SubMenu` that can be opened to show its elements. 65 | You can nest submenus into other submenus as needed. 66 | The `locked` property can be used to hide a menu element from the user. The main idea behind it is to have a `SubMenu` that can only be opened by sending a transaction with `openSubMenu` meta. 67 | Once opened it behaves like a second, hidden slash menu. For eg. you can have a command that needs approval or rejection after execution, you could open the slash menu with just these two options that are otherwise hidden. 68 | The `group` property can be used to group elements together in the menu. It is used to separate elements in the UI. It is not necessary to group elements, but it can be useful for better organization. 69 | 70 | NOTE: It is necessary to add unique ids to every menu element. 71 | 72 | ```typescript 73 | export type ItemId = string | "root"; 74 | export type ItemType = "command" | "submenu"; 75 | 76 | type MenuItem = { 77 | id: ItemId; 78 | label: string; 79 | type: ItemType; 80 | available: () => boolean; 81 | locked?: boolean; 82 | group?: string; 83 | }; 84 | 85 | interface CommandItem extends MenuItem { 86 | type: "command"; 87 | command: (view: EditorView) => void; 88 | } 89 | 90 | interface SubMenu extends MenuItem { 91 | type: "submenu"; 92 | elements: MenuElement[]; 93 | } 94 | type MenuElement = CommandItem | SubMenu; 95 | ``` 96 | 97 | ### Ignored Keys 98 | 99 | There is an option to provide an array of key codes that the slash menu will ignore while filtering the commands. 100 | This can be useful if you have a special key in your app 101 | that you don't want the slash menu to capture. 102 | Note that there is an array of keys that are ignored by default (`defaultIgnoredKeys`), these are keys such as "Shift", "Control", "Home" etc. that have no use in filtering. Your custom keys will be appended to this array, not replace it. 103 | 104 | ### Opening Conditions 105 | 106 | You can pass your own conditions on when should the menu open or close with `customConditions`. 107 | 108 | ```typescript 109 | interface OpeningConditions { 110 | shouldOpen: ( 111 | state: SlashMenuState, 112 | event: KeyboardEvent, 113 | view: EditorView 114 | ) => boolean; 115 | shouldClose: ( 116 | state: SlashMenuState, 117 | event: KeyboardEvent, 118 | view: EditorView 119 | ) => boolean; 120 | } 121 | ``` 122 | 123 | ### Open in selection 124 | 125 | You have the option to open the menu even if you have something selected. 126 | 127 | `openInSelection: boolean` 128 | 129 | # Behaviour 130 | 131 | - The menu opens when '/' is pressed in an empty paragraph or after a space and will prevent the actual character to be inserted into the doc. 132 | - If you want to actually write the character '/', pressing the key again will close the menu and insert the character. 133 | - You can also close it with Backspace (in some cases) and Escape. 134 | - Up and Down arrow keys are used for selecting menu elements. 135 | - You can use Tab and Enter to execute commands or open the submenu. You can also open submenus with right arrow. 136 | - While Escape always closes the menu, Backspace will only close the sub menu if you are in one. 137 | - You can filter by simply typing while the menu is open, the menu will return any matches from the main menu elements and from all submenu elements. While filtering Backspace will not close the menu but work as intended. 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-slash-menu", 3 | "version": "0.4.4", 4 | "description": "Slash menu for ProseMirror", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "rollup -c --bundleConfigAsCjs", 11 | "rebuild": "rollup -c --bundleConfigAsCjs", 12 | "dev": "rollup -c -w --bundleConfigAsCjs", 13 | "yalc:watch": "nodemon --watch dist --exec 'yalc push'", 14 | "dev:watch": "pnpm-run-all --parallel dev yalc:watch", 15 | "format": "eslint src --ext .ts --fix", 16 | "prepublishOnly": "pnpm run build && pnpm test && pnpm run lint", 17 | "version": "pnpm run format && git add -A src", 18 | "postversion": "git push && git push --tags", 19 | "lint": "tsc --noEmit && eslint src --ext .ts", 20 | "test": "echo \"no test specified\" && exit 0", 21 | "upgrade-interactive": "npm-check --update", 22 | "publish:np": "np" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/emergence-engineering/prosemirror-slash-menu.git" 27 | }, 28 | "files": [ 29 | "dist/**/*" 30 | ], 31 | "author": "Emergence Engineering", 32 | "keywords": [ 33 | "ProseMirror", 34 | "slash", 35 | "menu" 36 | ], 37 | "license": "ISC", 38 | "bugs": { 39 | "url": "https://github.com/emergence-engineering/prosemirror-slash-menu/issues" 40 | }, 41 | "homepage": "https://github.com/emergence-engineering/prosemirror-slash-menu#readme", 42 | "dependencies": { 43 | "prosemirror-model": "^1.19.3", 44 | "prosemirror-state": "^1.4.3", 45 | "prosemirror-view": "^1.31.4", 46 | "rimraf": "^6.0.1" 47 | }, 48 | "devDependencies": { 49 | "@typescript-eslint/eslint-plugin": "^5.46.1", 50 | "@typescript-eslint/parser": "^5.46.1", 51 | "eslint": "^8.29.0", 52 | "eslint-config-airbnb": "^19.0.4", 53 | "eslint-config-prettier": "^8.5.0", 54 | "eslint-plugin-import": "^2.26.0", 55 | "eslint-plugin-jest": "^27.1.6", 56 | "eslint-plugin-jsx-a11y": "^6.6.1", 57 | "eslint-plugin-prettier": "^4.2.1", 58 | "eslint-plugin-react": "^7.31.11", 59 | "nodemon": "^2.0.22", 60 | "np": "^8.0.2", 61 | "npm-check": "^6.0.1", 62 | "npm-run-all": "^4.1.5", 63 | "prettier": "^2.8.8", 64 | "rollup": "^3.24.0", 65 | "rollup-plugin-minification": "^0.2.0", 66 | "rollup-plugin-peer-deps-external": "^2.2.4", 67 | "rollup-plugin-typescript2": "^0.34.1", 68 | "typescript": "^5.1.6" 69 | }, 70 | "engines": { 71 | "node": ">=12", 72 | "npm": ">=7" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import { terser } from "rollup-plugin-minification"; 3 | 4 | import pkg from "./package.json"; 5 | 6 | export default { 7 | name: "prosemirror-slash-menu", 8 | input: "src/index.ts", 9 | output: [ 10 | { 11 | file: pkg.main, 12 | format: "cjs", 13 | }, 14 | { file: pkg.module, format: "es" }, 15 | ], 16 | external: [...Object.keys(pkg.dependencies || {})], 17 | plugins: [typescript(), terser()], 18 | sourcemap: true, 19 | }; 20 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | findParent, 3 | getElementById, 4 | getNextItemId, 5 | getPreviousItemId, 6 | } from "./utils"; 7 | import { SlashMenuMeta, SlashMenuState, SubMenu } from "./types"; 8 | 9 | export const closeMenu = (initialState: SlashMenuState) => { 10 | const callback = initialState.callbackOnClose; 11 | if (callback) { 12 | callback(); 13 | } 14 | return initialState; 15 | }; 16 | export const openSubMenu = (state: SlashMenuState, meta: SlashMenuMeta) => { 17 | const menuElement = meta.element; 18 | if (menuElement?.type === "submenu") { 19 | return { 20 | ...state, 21 | open: true, 22 | filteredElements: menuElement.elements, 23 | selected: menuElement.elements[0].id, 24 | subMenuId: menuElement.id, 25 | }; 26 | } 27 | return state; 28 | }; 29 | 30 | export const closeSubMenu = ( 31 | state: SlashMenuState, 32 | meta: SlashMenuMeta, 33 | initialState: SlashMenuState 34 | ) => { 35 | const menuElement = meta.element as SubMenu; 36 | const callback = menuElement.callbackOnClose; 37 | if (callback) { 38 | callback(); 39 | } 40 | if (menuElement?.type === "submenu") { 41 | const parentId = findParent(menuElement.id, initialState.filteredElements); 42 | if (parentId === "root") { 43 | return { ...initialState, open: true }; 44 | } 45 | const parent = getElementById(parentId, initialState); 46 | if (parent?.type !== "submenu") return state; 47 | return { 48 | ...state, 49 | filteredElements: parent.elements, 50 | selected: parent.elements[0].id, 51 | subMenuId: parentId, 52 | }; 53 | } 54 | return state; 55 | }; 56 | 57 | export const nextItem = (state: SlashMenuState) => { 58 | const nextId = getNextItemId(state); 59 | if (!nextId) return state; 60 | return { ...state, selected: nextId }; 61 | }; 62 | 63 | export const prevItem = (state: SlashMenuState) => { 64 | const prevId = getPreviousItemId(state); 65 | if (!prevId) return state; 66 | return { ...state, selected: prevId }; 67 | }; 68 | export const filterItems = (state: SlashMenuState, filter: string) => { 69 | return { ...state, filter }; 70 | }; 71 | -------------------------------------------------------------------------------- /src/cases.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "prosemirror-view"; 2 | import { getElementById } from "./utils"; 3 | import { OpeningConditions, SlashMenuState } from "./types"; 4 | 5 | export enum SlashCases { 6 | OpenMenu = "openMenu", 7 | CloseMenu = "closeMenu", 8 | Execute = "Execute", 9 | NextItem = "NextItem", 10 | PrevItem = "PrevItem", 11 | inputChange = "InputChange", 12 | addChar = "addChar", 13 | removeChar = "removeChar", 14 | Ignore = "Ignore", 15 | Catch = "Catch", 16 | } 17 | const defaultConditions = (openInSelection = false): OpeningConditions => { 18 | return { 19 | shouldOpen: ( 20 | state: SlashMenuState, 21 | event: KeyboardEvent, 22 | view: EditorView 23 | ) => { 24 | const editorState = view.state; 25 | const resolvedPos = 26 | editorState.selection.from < 0 || 27 | editorState.selection.from > editorState.doc.content.size 28 | ? null 29 | : editorState.doc.resolve(editorState.selection.from); 30 | 31 | const parentNode = resolvedPos?.parent; 32 | const inParagraph = parentNode?.type.name === "paragraph"; 33 | const inEmptyPar = inParagraph && parentNode?.nodeSize === 2; 34 | const posInLine = editorState.selection.$head.parentOffset; 35 | const prevCharacter = 36 | editorState.selection.$head.parent.textContent.slice( 37 | posInLine - 1, 38 | posInLine 39 | ); 40 | const spaceBeforePos = 41 | prevCharacter === " " || prevCharacter === "" || prevCharacter === " "; 42 | return ( 43 | !state.open && 44 | event.key === "/" && 45 | inParagraph && 46 | (inEmptyPar || 47 | spaceBeforePos || 48 | (editorState.selection.from !== editorState.selection.to && 49 | openInSelection)) 50 | ); 51 | }, 52 | shouldClose: (state: SlashMenuState, event: KeyboardEvent) => 53 | state.open && 54 | (event.key === "/" || 55 | event.key === "Escape" || 56 | event.key === "Backspace") && 57 | state.filter.length === 0, 58 | }; 59 | }; 60 | export const getCase = ( 61 | state: SlashMenuState, 62 | event: KeyboardEvent, 63 | view: EditorView, 64 | ignoredKeys: string[], 65 | customConditions?: OpeningConditions, 66 | shouldOpenInSelection?: boolean 67 | ): SlashCases => { 68 | const condition = 69 | customConditions || defaultConditions(shouldOpenInSelection); 70 | const selected = getElementById(state.selected, state); 71 | if (condition.shouldOpen(state, event, view)) { 72 | return SlashCases.OpenMenu; 73 | } 74 | if (condition.shouldClose(state, event, view)) { 75 | return SlashCases.CloseMenu; 76 | } 77 | if (state.open) { 78 | if (event.key === "ArrowDown") { 79 | return SlashCases.NextItem; 80 | } 81 | if (event.key === "ArrowUp") { 82 | return SlashCases.PrevItem; 83 | } 84 | if ( 85 | event.key === "Enter" || 86 | event.key === "Tab" || 87 | (event.key === "ArrowRight" && selected?.type === "submenu") 88 | ) { 89 | return SlashCases.Execute; 90 | } 91 | if ( 92 | event.key === "Escape" || 93 | (event.key === "Backspace" && state.filter.length === 0) || 94 | (event.key === "ArrowLeft" && state.subMenuId) 95 | ) { 96 | return SlashCases.CloseMenu; 97 | } 98 | if (state.filter.length > 0 && event.key === "Backspace") { 99 | return SlashCases.removeChar; 100 | } 101 | if (!ignoredKeys.includes(event.key)) { 102 | return SlashCases.addChar; 103 | } 104 | if (event.key === "ArrowLeft" || event.key === "ArrowRight") { 105 | return SlashCases.Catch; 106 | } 107 | } 108 | 109 | return SlashCases.Ignore; 110 | }; 111 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum SlashMetaTypes { 2 | open = "open", 3 | close = "close", 4 | execute = "execute", 5 | nextItem = "nextItem", 6 | prevItem = "prevItem", 7 | openSubMenu = "openSubMenu", 8 | closeSubMenu = "closeSubMenu", 9 | inputChange = "inputChange", 10 | } 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { SlashMenuPlugin, SlashMenuKey } from "./plugin"; 2 | import { dispatchWithMeta, getElementById, defaultIgnoredKeys } from "./utils"; 3 | import { 4 | SlashMenuState, 5 | SlashMenuMeta, 6 | SubMenu, 7 | MenuElement, 8 | MenuItem, 9 | ItemId, 10 | CommandItem, 11 | ItemType, 12 | } from "./types"; 13 | import { SlashMetaTypes } from "./enums"; 14 | 15 | export { 16 | SlashMenuPlugin, 17 | SlashMenuKey, 18 | SlashMetaTypes, 19 | dispatchWithMeta, 20 | getElementById, 21 | defaultIgnoredKeys, 22 | SlashMenuState, 23 | SlashMenuMeta, 24 | SubMenu, 25 | MenuElement, 26 | MenuItem, 27 | ItemId, 28 | CommandItem, 29 | ItemType, 30 | }; 31 | -------------------------------------------------------------------------------- /src/keyHandlers.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "prosemirror-view"; 2 | import { dispatchWithMeta, getElementById } from "./utils"; 3 | import { SlashMenuKey } from "./plugin"; 4 | import { SlashMenuState } from "./types"; 5 | import { SlashMetaTypes } from "./enums"; 6 | 7 | const execute = (view: EditorView, state: SlashMenuState) => { 8 | const menuElement = getElementById(state.selected, state); 9 | if (!menuElement) return false; 10 | if (menuElement.type === "command") { 11 | dispatchWithMeta(view, SlashMenuKey, { 12 | type: SlashMetaTypes.execute, 13 | }); 14 | menuElement.command(view); 15 | } 16 | if (menuElement.type === "submenu") { 17 | dispatchWithMeta(view, SlashMenuKey, { 18 | type: SlashMetaTypes.openSubMenu, 19 | element: menuElement, 20 | }); 21 | } 22 | return true; 23 | }; 24 | 25 | const closeMenu = ( 26 | view: EditorView, 27 | state: SlashMenuState, 28 | initialState: SlashMenuState, 29 | event: KeyboardEvent 30 | ) => { 31 | const { subMenuId } = state; 32 | if (subMenuId) { 33 | dispatchWithMeta(view, SlashMenuKey, { 34 | type: SlashMetaTypes.closeSubMenu, 35 | element: getElementById(subMenuId, initialState), 36 | }); 37 | } else if (event.key === "/") { 38 | view.dispatch( 39 | view.state.tr.insertText("/").setMeta(SlashMenuKey, { 40 | type: SlashMetaTypes.close, 41 | }) 42 | ); 43 | } else 44 | dispatchWithMeta(view, SlashMenuKey, { 45 | type: SlashMetaTypes.close, 46 | }); 47 | return true; 48 | }; 49 | 50 | export const keyHandlers = { 51 | execute, 52 | closeMenu, 53 | }; 54 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from "prosemirror-state"; 2 | import { Slice } from "prosemirror-model"; 3 | import { 4 | dispatchWithMeta, 5 | getElementById, 6 | getFilteredItems, 7 | hasDuplicateIds, 8 | defaultIgnoredKeys, 9 | } from "./utils"; 10 | import { getCase, SlashCases } from "./cases"; 11 | import { 12 | closeMenu, 13 | closeSubMenu, 14 | nextItem, 15 | openSubMenu, 16 | prevItem, 17 | } from "./actions"; 18 | import { 19 | MenuElement, 20 | OpeningConditions, 21 | SlashMenuMeta, 22 | SlashMenuState, 23 | SubMenu, 24 | } from "./types"; 25 | import { SlashMetaTypes } from "./enums"; 26 | 27 | export const SlashMenuKey = new PluginKey("slash-menu-plugin"); 28 | export const SlashMenuPlugin = ( 29 | menuElements: MenuElement[], 30 | ignoredKeys?: string[], 31 | customConditions?: OpeningConditions, 32 | openInSelection?: boolean, 33 | inlineFilter?: boolean 34 | ) => { 35 | const initialState: SlashMenuState = { 36 | selected: menuElements[0].id, 37 | open: false, 38 | filter: "", 39 | ignoredKeys: ignoredKeys 40 | ? [...defaultIgnoredKeys, ...ignoredKeys] 41 | : defaultIgnoredKeys, 42 | filteredElements: menuElements.filter((element) => !element.locked), 43 | elements: menuElements, 44 | }; 45 | if (hasDuplicateIds(initialState)) { 46 | throw new Error("Menu elements must have unique id's!"); 47 | } 48 | return new Plugin({ 49 | key: SlashMenuKey, 50 | props: { 51 | handleKeyDown(view, event) { 52 | const editorState = view.state; 53 | const state = SlashMenuKey.getState(editorState); 54 | if (!state) return false; 55 | const slashCase = getCase( 56 | state, 57 | event, 58 | view, 59 | initialState.ignoredKeys, 60 | customConditions, 61 | openInSelection 62 | ); 63 | switch (slashCase) { 64 | case SlashCases.OpenMenu: 65 | dispatchWithMeta(view, SlashMenuKey, { type: SlashMetaTypes.open }); 66 | return !inlineFilter; 67 | case SlashCases.CloseMenu: { 68 | if (!state.open) { 69 | return false; 70 | } 71 | 72 | const { subMenuId } = state; 73 | 74 | if (subMenuId) { 75 | const submenu = getElementById( 76 | subMenuId, 77 | initialState 78 | ) as SubMenu; 79 | const callback = submenu?.callbackOnClose; 80 | if (!submenu?.locked) { 81 | if (callback) { 82 | callback(); 83 | } 84 | dispatchWithMeta(view, SlashMenuKey, { 85 | type: SlashMetaTypes.closeSubMenu, 86 | element: getElementById(subMenuId, initialState), 87 | }); 88 | } else 89 | dispatchWithMeta(view, SlashMenuKey, { 90 | type: SlashMetaTypes.close, 91 | }); 92 | } else if (event.key === "/") { 93 | view.dispatch( 94 | editorState.tr.insertText("/").setMeta(SlashMenuKey, { 95 | type: SlashMetaTypes.close, 96 | }) 97 | ); 98 | } else 99 | dispatchWithMeta(view, SlashMenuKey, { 100 | type: SlashMetaTypes.close, 101 | }); 102 | return true; 103 | } 104 | 105 | case SlashCases.Execute: { 106 | const menuElement = getElementById(state.selected, state); 107 | if (!menuElement) return false; 108 | if (menuElement.type === "command") { 109 | menuElement.command(view); 110 | dispatchWithMeta(view, SlashMenuKey, { 111 | type: SlashMetaTypes.execute, 112 | }); 113 | } 114 | if (menuElement.type === "submenu") { 115 | dispatchWithMeta(view, SlashMenuKey, { 116 | type: SlashMetaTypes.openSubMenu, 117 | element: menuElement, 118 | }); 119 | } 120 | 121 | return true; 122 | } 123 | case SlashCases.NextItem: 124 | dispatchWithMeta(view, SlashMenuKey, { 125 | type: SlashMetaTypes.nextItem, 126 | }); 127 | return true; 128 | case SlashCases.PrevItem: 129 | dispatchWithMeta(view, SlashMenuKey, { 130 | type: SlashMetaTypes.prevItem, 131 | }); 132 | return true; 133 | case SlashCases.addChar: { 134 | dispatchWithMeta(view, SlashMenuKey, { 135 | type: SlashMetaTypes.inputChange, 136 | filter: state.filter + event.key, 137 | }); 138 | return !inlineFilter; 139 | } 140 | case SlashCases.removeChar: { 141 | const newFilter = 142 | state.filter.length === 1 ? "" : state.filter.slice(0, -1); 143 | dispatchWithMeta(view, SlashMenuKey, { 144 | type: SlashMetaTypes.inputChange, 145 | filter: newFilter, 146 | }); 147 | return !inlineFilter; 148 | } 149 | case SlashCases.Catch: { 150 | return true; 151 | } 152 | case SlashCases.Ignore: 153 | default: 154 | return false; 155 | } 156 | }, 157 | }, 158 | 159 | state: { 160 | init() { 161 | return initialState; 162 | }, 163 | apply(tr, state) { 164 | const meta: SlashMenuMeta = tr.getMeta(SlashMenuKey); 165 | switch (meta?.type) { 166 | case SlashMetaTypes.open: 167 | return { ...initialState, open: true }; 168 | case SlashMetaTypes.close: 169 | return closeMenu(initialState); 170 | case SlashMetaTypes.execute: 171 | return initialState; 172 | case SlashMetaTypes.openSubMenu: 173 | return openSubMenu(state, meta); 174 | case SlashMetaTypes.closeSubMenu: 175 | return closeSubMenu(state, meta, initialState); 176 | case SlashMetaTypes.nextItem: 177 | return nextItem(state); 178 | case SlashMetaTypes.prevItem: 179 | return prevItem(state); 180 | case SlashMetaTypes.inputChange: { 181 | const newElements = meta.filter 182 | ? getFilteredItems(state, meta.filter) 183 | : initialState.elements; 184 | const selectedId = newElements?.[0]?.id; 185 | return { 186 | ...state, 187 | selected: selectedId || state.selected, 188 | filteredElements: newElements, 189 | filter: meta.filter || "", 190 | }; 191 | } 192 | default: 193 | return state; 194 | } 195 | }, 196 | }, 197 | initialState, 198 | }); 199 | }; 200 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "prosemirror-view"; 2 | 3 | import { SlashMetaTypes } from "./enums"; 4 | 5 | export type ItemId = string | "root"; 6 | export type ItemType = "command" | "submenu"; 7 | export type MenuItem = { 8 | id: ItemId; 9 | label: string; 10 | type: ItemType; 11 | available: (view: EditorView) => boolean; 12 | locked?: boolean; 13 | group?: string; 14 | }; 15 | 16 | export interface CommandItem extends MenuItem { 17 | type: "command"; 18 | command: (view: EditorView) => void; 19 | } 20 | 21 | // eslint-disable-next-line no-use-before-define 22 | export type MenuElement = CommandItem | SubMenu; 23 | 24 | export interface SubMenu extends MenuItem { 25 | type: "submenu"; 26 | elements: MenuElement[]; 27 | callbackOnClose?: () => void; 28 | } 29 | 30 | export type SlashMenuState = { 31 | selected: ItemId; 32 | filteredElements: MenuElement[]; 33 | open: boolean; 34 | subMenuId?: ItemId; 35 | filter: string; 36 | elements: MenuElement[]; 37 | ignoredKeys: string[]; 38 | callbackOnClose?: () => void; 39 | }; 40 | 41 | export interface SlashMenuMeta { 42 | type: SlashMetaTypes; 43 | element?: MenuElement; 44 | filter?: string; 45 | } 46 | export interface OpeningConditions { 47 | shouldOpen: ( 48 | state: SlashMenuState, 49 | event: KeyboardEvent, 50 | view: EditorView 51 | ) => boolean; 52 | shouldClose: ( 53 | state: SlashMenuState, 54 | event: KeyboardEvent, 55 | view: EditorView 56 | ) => boolean; 57 | } 58 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "prosemirror-view"; 2 | import { PluginKey } from "prosemirror-state"; 3 | import { 4 | ItemId, 5 | MenuElement, 6 | SlashMenuMeta, 7 | SlashMenuState, 8 | SubMenu, 9 | } from "./types"; 10 | 11 | export const getElementIds = (item: MenuElement): ItemId[] => { 12 | if (item.type === "submenu") 13 | return [ 14 | item.id, 15 | ...item.elements.map((item) => getElementIds(item)), 16 | ].flat(); 17 | return [item.id]; 18 | }; 19 | 20 | export const getAllElementIds = (config: SlashMenuState) => 21 | config.filteredElements.map((element) => getElementIds(element)).flat(); 22 | 23 | export const hasDuplicateIds = (config: SlashMenuState): boolean => { 24 | const ids = getAllElementIds(config); 25 | return ids.length !== new Set(ids).size; 26 | }; 27 | const getElements = (item: MenuElement): MenuElement[] => { 28 | if (item.type === "submenu") 29 | return [item, ...item.elements.map((item) => getElements(item))].flat(); 30 | return [item]; 31 | }; 32 | export const getAllElements = (state: SlashMenuState) => 33 | state.elements.map((element) => getElements(element)).flat(); 34 | 35 | export const getElementById = (id: ItemId, state: SlashMenuState) => { 36 | return getAllElements(state).find((element) => element.id === id); 37 | }; 38 | export const findParent = ( 39 | id: ItemId, 40 | elements: MenuElement[], 41 | subMenu: ItemId | "root" = "root" 42 | ): ItemId | "root" => { 43 | let parentId: ItemId = "root"; 44 | elements.forEach((item) => { 45 | if (item.type === "submenu") { 46 | if (item.id === id) parentId = subMenu; 47 | const elementIds = item.elements.map((item) => item.id); 48 | if (elementIds.includes(id)) { 49 | parentId = item.id; 50 | } else parentId = findParent(id, item.elements, item.id); 51 | } 52 | if (item.id === id) parentId = subMenu; 53 | }); 54 | return parentId; 55 | }; 56 | export const getNextItemId = (state: SlashMenuState): ItemId | undefined => { 57 | const parentId = findParent(state.selected, state.filteredElements); 58 | const parent = getElementById(parentId, state); 59 | if (parentId === "root") { 60 | const nextItemIndex = 61 | state.filteredElements.findIndex( 62 | (element) => element.id === state.selected 63 | ) + 1; 64 | if (nextItemIndex < state.filteredElements.length) { 65 | return state.filteredElements[nextItemIndex].id; 66 | } 67 | } 68 | if (parent && parent.type === "submenu") { 69 | const nextItemIndex = 70 | parent.elements.findIndex((element) => element.id === state.selected) + 1; 71 | if (nextItemIndex < parent.elements.length) { 72 | return parent.elements[nextItemIndex].id; 73 | } 74 | } 75 | }; 76 | export const getPreviousItemId = ( 77 | state: SlashMenuState 78 | ): ItemId | undefined => { 79 | const parentId = findParent(state.selected, state.filteredElements); 80 | const parent = getElementById(parentId, state); 81 | if (parentId === "root") { 82 | const prevItemIndex = 83 | state.filteredElements.findIndex( 84 | (element) => element.id === state.selected 85 | ) - 1; 86 | if (prevItemIndex >= 0) { 87 | return state.filteredElements[prevItemIndex].id; 88 | } 89 | } 90 | if (parent && parent.type === "submenu") { 91 | const prevItemIndex = 92 | parent.elements.findIndex((element) => element.id === state.selected) - 1; 93 | if (prevItemIndex >= 0) { 94 | return parent.elements[prevItemIndex].id; 95 | } 96 | } 97 | }; 98 | export const dispatchWithMeta = ( 99 | view: EditorView, 100 | key: PluginKey, 101 | meta: SlashMenuMeta 102 | ) => view.dispatch(view.state.tr.setMeta(key, meta)); 103 | 104 | export const getFilteredItems = (state: SlashMenuState, input: string) => { 105 | const regExp = new RegExp(`${input.toLowerCase().replace(/\s/g, "\\s")}`); 106 | if (state.subMenuId && state.subMenuId !== "root") { 107 | const submenu = getElementById(state.subMenuId, state) as SubMenu; 108 | return submenu.elements.filter( 109 | (element) => element.label.toLowerCase().match(regExp) !== null 110 | ); 111 | } 112 | return state.elements.filter( 113 | (element) => 114 | element.label.toLowerCase().match(regExp) !== null && !element.locked 115 | ); 116 | }; 117 | 118 | export const defaultIgnoredKeys = [ 119 | "Unidentified", 120 | "Alt", 121 | "AltGraph", 122 | "CapsLock", 123 | "Control", 124 | "Fn", 125 | "FnLock", 126 | "F1", 127 | "F2", 128 | "F3", 129 | "F4", 130 | "F5", 131 | "F6", 132 | "F7", 133 | "F8", 134 | "F9", 135 | "F10", 136 | "F11", 137 | "F12", 138 | "F13", 139 | "F14", 140 | "F15", 141 | "F16", 142 | "F17", 143 | "F18", 144 | "F19", 145 | "F20", 146 | "F21", 147 | "F22", 148 | "F23", 149 | "F24", 150 | "Hyper", 151 | "Meta", 152 | "NumLock", 153 | "PageDown", 154 | "PageUp", 155 | "Pause", 156 | "PrintScreen", 157 | "Redo", 158 | "ScrollLock", 159 | "Shift", 160 | "Super", 161 | "Symbol", 162 | "SymbolLock", 163 | "Enter", 164 | "Tab", 165 | "ArrowDown", 166 | "ArrowLeft", 167 | "ArrowRight", 168 | "ArrowUp", 169 | "End", 170 | "Home", 171 | "PageDown", 172 | "PageUp", 173 | "Backspace", 174 | "Clear", 175 | "Copy", 176 | "CrSel", 177 | "Cut", 178 | "Delete", 179 | "EraseEof", 180 | "ExSel", 181 | "Insert", 182 | "Paste", 183 | "Redo", 184 | "Undo", 185 | "Accept", 186 | "Again", 187 | "Attn", 188 | "Cancel", 189 | "ContextMenu", 190 | "Escape", 191 | "Execute", 192 | "Find", 193 | "Finish", 194 | "Help", 195 | "Pause", 196 | "Play", 197 | "Props", 198 | "Select", 199 | "ZoomIn", 200 | "ZoomOut", 201 | "BrightnessDown", 202 | "BrightnessUp", 203 | "Eject", 204 | "LogOff", 205 | "Power", 206 | "PowerOff", 207 | "PrintScreen", 208 | "Hibernate", 209 | "Standby", 210 | "WakeUp", 211 | "AllCandidates", 212 | "Alphanumeric", 213 | "CodeInput", 214 | "Compose", 215 | "Convert", 216 | "Dead", 217 | "FinalMode", 218 | "GroupFirst", 219 | "GroupLast", 220 | "GroupNext", 221 | "GroupPrevious", 222 | "ModeChange", 223 | "NextCandidate", 224 | "NonConvert", 225 | "PreviousCandidate", 226 | "Process", 227 | "SingleCandidate", 228 | "HangulMode", 229 | "HanjaMode", 230 | "JunjaMode", 231 | "Eisu", 232 | "Hankaku", 233 | "Hiragana", 234 | "HiraganaKatakana", 235 | "KanaMode", 236 | "KanjiMode", 237 | "Katakana", 238 | "Romaji", 239 | "Zenkaku", 240 | "ZenkakuHanaku", 241 | ]; 242 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["es6", "es2015", "dom", "es2017", "es2019"], 5 | "module": "esnext", 6 | "moduleResolution": "Node", 7 | "declaration": true, 8 | "declarationDir": "./dist", 9 | "outDir": "./dist", 10 | "strict": true, 11 | "allowSyntheticDefaultImports": true 12 | }, 13 | "compileOnSave": true, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["node_modules", "**/__tests__/*"] 16 | } 17 | --------------------------------------------------------------------------------