├── .gitignore ├── .npmignore ├── .npmrc ├── .tern-project ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src ├── README.md ├── index.ts ├── inputrules.ts ├── keymap.ts ├── menu.ts └── prompt.ts └── style └── style.css /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /dist 4 | /notes.txt 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /test 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "libs": ["browser"], 3 | "plugins": { 4 | "node": {}, 5 | "complete_strings": {}, 6 | "es_modules": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.2.3 (2024-08-16) 2 | 3 | ### Bug fixes 4 | 5 | Make the type of the `fullMenu` option `MenuElement[][]` instead of `MenuItem[][]`. 6 | 7 | ## 1.2.2 (2023-05-17) 8 | 9 | ### Bug fixes 10 | 11 | Include CommonJS type declarations in the package to please new TypeScript resolution settings. 12 | 13 | ## 1.2.1 (2022-06-22) 14 | 15 | ### Bug fixes 16 | 17 | Export CSS file from package.json. 18 | 19 | ## 1.2.0 (2022-05-30) 20 | 21 | ### New features 22 | 23 | Include TypeScript type declarations. 24 | 25 | ## 1.1.2 (2019-11-20) 26 | 27 | ### Bug fixes 28 | 29 | Rename ES module files to use a .js extension, since Webpack gets confused by .mjs 30 | 31 | ## 1.1.1 (2019-11-19) 32 | 33 | ### Bug fixes 34 | 35 | The file referred to in the package's `module` field now is compiled down to ES5. 36 | 37 | ## 1.1.0 (2019-11-08) 38 | 39 | ### New features 40 | 41 | Add a `module` field to package json file. 42 | 43 | ## 1.0.1 (2017-11-24) 44 | 45 | ### Bug fixes 46 | 47 | The example menu now allows you to enter relative links without automatically adding http:// in front of them. 48 | 49 | ## 1.0.0 (2017-10-13) 50 | 51 | First stable release. 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | - [Getting help](#getting-help) 4 | - [Submitting bug reports](#submitting-bug-reports) 5 | - [Contributing code](#contributing-code) 6 | 7 | ## Getting help 8 | 9 | Community discussion, questions, and informal bug reporting is done on the 10 | [discuss.ProseMirror forum](http://discuss.prosemirror.net). 11 | 12 | ## Submitting bug reports 13 | 14 | Report bugs on the 15 | [GitHub issue tracker](http://github.com/prosemirror/prosemirror/issues). 16 | Before reporting a bug, please read these pointers. 17 | 18 | - The issue tracker is for *bugs*, not requests for help. Questions 19 | should be asked on the [forum](http://discuss.prosemirror.net). 20 | 21 | - Include information about the version of the code that exhibits the 22 | problem. For browser-related issues, include the browser and browser 23 | version on which the problem occurred. 24 | 25 | - Mention very precisely what went wrong. "X is broken" is not a good 26 | bug report. What did you expect to happen? What happened instead? 27 | Describe the exact steps a maintainer has to take to make the 28 | problem occur. A screencast can be useful, but is no substitute for 29 | a textual description. 30 | 31 | - A great way to make it easy to reproduce your problem, if it can not 32 | be trivially reproduced on the website demos, is to submit a script 33 | that triggers the issue. 34 | 35 | ## Contributing code 36 | 37 | - Make sure you have a [GitHub Account](https://github.com/signup/free) 38 | 39 | - Fork the relevant repository 40 | ([how to fork a repo](https://help.github.com/articles/fork-a-repo)) 41 | 42 | - Create a local checkout of the code. You can use the 43 | [main repository](https://github.com/prosemirror/prosemirror) to 44 | easily check out all core modules. 45 | 46 | - Make your changes, and commit them 47 | 48 | - Follow the code style of the rest of the project (see below). Run 49 | `npm run lint` (in the main repository checkout) to make sure that 50 | the linter is happy. 51 | 52 | - If your changes are easy to test or likely to regress, add tests in 53 | the relevant `test/` directory. Either put them in an existing 54 | `test-*.js` file, if they fit there, or add a new file. 55 | 56 | - Make sure all tests pass. Run `npm run test` to verify tests pass 57 | (you will need Node.js v6+). 58 | 59 | - Submit a pull request ([how to create a pull request](https://help.github.com/articles/fork-a-repo)). 60 | Don't put more than one feature/fix in a single pull request. 61 | 62 | By contributing code to ProseMirror you 63 | 64 | - Agree to license the contributed code under the project's [MIT 65 | license](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE). 66 | 67 | - Confirm that you have the right to contribute and license the code 68 | in question. (Either you hold all rights on the code, or the rights 69 | holder has explicitly granted the right to use it like this, 70 | through a compatible open source license or through a direct 71 | agreement with you.) 72 | 73 | ### Coding standards 74 | 75 | - ES6 syntax, targeting an ES5 runtime (i.e. don't use library 76 | elements added by ES6, don't use ES7/ES.next syntax). 77 | 78 | - 2 spaces per indentation level, no tabs. 79 | 80 | - No semicolons except when necessary. 81 | 82 | - Follow the surrounding code when it comes to spacing, brace 83 | placement, etc. 84 | 85 | - Brace-less single-statement bodies are encouraged (whenever they 86 | don't impact readability). 87 | 88 | - [getdocs](https://github.com/marijnh/getdocs)-style doc comments 89 | above items that are part of the public API. 90 | 91 | - When documenting non-public items, you can put the type after a 92 | single colon, so that getdocs doesn't pick it up and add it to the 93 | API reference. 94 | 95 | - The linter (`npm run lint`) complains about unused variables and 96 | functions. Prefix their names with an underscore to muffle it. 97 | 98 | - ProseMirror does *not* follow JSHint or JSLint prescribed style. 99 | Patches that try to 'fix' code to pass one of these linters will not 100 | be accepted. 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015-2017 by Marijn Haverbeke and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-example-setup 2 | 3 | [ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror-example-setup/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**GITTER**](https://gitter.im/ProseMirror/prosemirror) ] 4 | 5 | This is a non-core example module for [ProseMirror](https://prosemirror.net). 6 | ProseMirror is a well-behaved rich semantic content editor based on 7 | contentEditable, with support for collaborative editing and custom 8 | document schemas. 9 | 10 | This module provides an example of the glue code one might write to 11 | tie the modules that make up ProseMirror into an actual presentable 12 | editor. It is not meant to be very reusable, though it might be 13 | helpful to get something up-and-running quickly. 14 | 15 | The [project page](https://prosemirror.net) has more information, a 16 | number of [examples](https://prosemirror.net/examples/) and the 17 | [documentation](https://prosemirror.net/docs/). 18 | 19 | This code is released under an 20 | [MIT license](https://github.com/prosemirror/prosemirror/tree/master/LICENSE). 21 | There's a [forum](http://discuss.prosemirror.net) for general 22 | discussion and support requests, and the 23 | [Github bug tracker](https://github.com/prosemirror/prosemirror-example-setup/issues) 24 | is the place to report issues. 25 | 26 | ## Documentation 27 | 28 | This module exports helper functions for deriving a set of basic menu 29 | items, input rules, or key bindings from a schema. These values need 30 | to know about the schema for two reasons—they need access to specific 31 | instances of node and mark types, and they need to know which of the 32 | node and mark types that they know about are actually present in the 33 | schema. 34 | 35 | * **`exampleSetup`**`(options: Object) → Plugin[]`\ 36 | Create an array of plugins pre-configured for the given schema. 37 | The resulting array will include the following plugins: 38 | 39 | * Input rules for smart quotes and creating the block types in the 40 | schema using markdown conventions (say `"> "` to create a 41 | blockquote) 42 | 43 | * A keymap that defines keys to create and manipulate the nodes in the 44 | schema 45 | 46 | * A keymap binding the default keys provided by the 47 | prosemirror-commands module 48 | 49 | * The undo history plugin 50 | 51 | * The drop cursor plugin 52 | 53 | * The gap cursor plugin 54 | 55 | * A custom plugin that adds a `menuContent` prop for the 56 | prosemirror-menu wrapper, and a CSS class that enables the 57 | additional styling defined in `style/style.css` in this package 58 | 59 | Probably only useful for quickly setting up a passable 60 | editor—you'll need more control over your settings in most 61 | real-world situations. 62 | 63 | * **`options`**`: Object` 64 | 65 | * **`schema`**`: Schema`\ 66 | The schema to generate key bindings and menu items for. 67 | 68 | * **`mapKeys`**`?: Object`\ 69 | Can be used to [adjust](#example-setup.buildKeymap) the key bindings created. 70 | 71 | * **`menuBar`**`?: boolean`\ 72 | Set to false to disable the menu bar. 73 | 74 | * **`history`**`?: boolean`\ 75 | Set to false to disable the history plugin. 76 | 77 | * **`floatingMenu`**`?: boolean`\ 78 | Set to false to make the menu bar non-floating. 79 | 80 | * **`menuContent`**`?: MenuItem[][]`\ 81 | Can be used to override the menu content. 82 | 83 | 84 | * **`buildMenuItems`**`(schema: Schema) → {makeHead2?: MenuItem, makeHead3?: MenuItem, makeHead4?: MenuItem, makeHead5?: MenuItem, makeHead6?: MenuItem}`\ 85 | Given a schema, look for default mark and node types in it and 86 | return an object with relevant menu items relating to those marks. 87 | 88 | * **`returns`**`: {makeHead2?: MenuItem, makeHead3?: MenuItem, makeHead4?: MenuItem, makeHead5?: MenuItem, makeHead6?: MenuItem}` 89 | 90 | * **`toggleStrong`**`?: MenuItem`\ 91 | A menu item to toggle the [strong mark](#schema-basic.StrongMark). 92 | 93 | * **`toggleEm`**`?: MenuItem`\ 94 | A menu item to toggle the [emphasis mark](#schema-basic.EmMark). 95 | 96 | * **`toggleCode`**`?: MenuItem`\ 97 | A menu item to toggle the [code font mark](#schema-basic.CodeMark). 98 | 99 | * **`toggleLink`**`?: MenuItem`\ 100 | A menu item to toggle the [link mark](#schema-basic.LinkMark). 101 | 102 | * **`insertImage`**`?: MenuItem`\ 103 | A menu item to insert an [image](#schema-basic.Image). 104 | 105 | * **`wrapBulletList`**`?: MenuItem`\ 106 | A menu item to wrap the selection in a [bullet list](#schema-list.BulletList). 107 | 108 | * **`wrapOrderedList`**`?: MenuItem`\ 109 | A menu item to wrap the selection in an [ordered list](#schema-list.OrderedList). 110 | 111 | * **`wrapBlockQuote`**`?: MenuItem`\ 112 | A menu item to wrap the selection in a [block quote](#schema-basic.BlockQuote). 113 | 114 | * **`makeParagraph`**`?: MenuItem`\ 115 | A menu item to set the current textblock to be a normal 116 | [paragraph](#schema-basic.Paragraph). 117 | 118 | * **`makeCodeBlock`**`?: MenuItem`\ 119 | A menu item to set the current textblock to be a 120 | [code block](#schema-basic.CodeBlock). 121 | 122 | * **`makeHead1`**`?: MenuItem`\ 123 | Menu items to set the current textblock to be a 124 | [heading](#schema-basic.Heading) of level _N_. 125 | 126 | * **`insertHorizontalRule`**`?: MenuItem`\ 127 | A menu item to insert a horizontal rule. 128 | 129 | * **`insertMenu`**`: Dropdown`\ 130 | A dropdown containing the `insertImage` and 131 | `insertHorizontalRule` items. 132 | 133 | * **`typeMenu`**`: Dropdown`\ 134 | A dropdown containing the items for making the current 135 | textblock a paragraph, code block, or heading. 136 | 137 | * **`blockMenu`**`: MenuElement[][]`\ 138 | Array of block-related menu items. 139 | 140 | * **`inlineMenu`**`: MenuElement[][]`\ 141 | Inline-markup related menu items. 142 | 143 | * **`fullMenu`**`: MenuElement[][]`\ 144 | An array of arrays of menu elements for use as the full menu 145 | for, for example the [menu 146 | bar](https://github.com/prosemirror/prosemirror-menu#user-content-menubar). 147 | 148 | 149 | * **`buildKeymap`**`(schema: Schema, mapKeys: Object) → Object`\ 150 | Inspect the given schema looking for marks and nodes from the 151 | basic schema, and if found, add key bindings related to them. 152 | This will add: 153 | 154 | * **Mod-b** for toggling [strong](#schema-basic.StrongMark) 155 | * **Mod-i** for toggling [emphasis](#schema-basic.EmMark) 156 | * **Mod-`** for toggling [code font](#schema-basic.CodeMark) 157 | * **Ctrl-Shift-0** for making the current textblock a paragraph 158 | * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current 159 | textblock a heading of the corresponding level 160 | * **Ctrl-Shift-Backslash** to make the current textblock a code block 161 | * **Ctrl-Shift-8** to wrap the selection in an ordered list 162 | * **Ctrl-Shift-9** to wrap the selection in a bullet list 163 | * **Ctrl->** to wrap the selection in a block quote 164 | * **Enter** to split a non-empty textblock in a list item while at 165 | the same time splitting the list item 166 | * **Mod-Enter** to insert a hard break 167 | * **Mod-_** to insert a horizontal rule 168 | * **Backspace** to undo an input rule 169 | * **Alt-ArrowUp** to `joinUp` 170 | * **Alt-ArrowDown** to `joinDown` 171 | * **Mod-BracketLeft** to `lift` 172 | * **Escape** to `selectParentNode` 173 | 174 | You can suppress or map these bindings by passing a `mapKeys` 175 | argument, which maps key names (say `"Mod-B"` to either `false`, to 176 | remove the binding, or a new key name string. 177 | 178 | 179 | * **`buildInputRules`**`(schema: Schema) → Plugin`\ 180 | A set of input rules for creating the basic block quotes, lists, 181 | code blocks, and heading. 182 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-example-setup", 3 | "version": "1.2.3", 4 | "description": "An example for how to set up a ProseMirror editor", 5 | "type": "module", 6 | "main": "dist/index.cjs", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs" 13 | }, 14 | "./style/style.css": "./style/style.css" 15 | }, 16 | "sideEffects": ["./style/style.css"], 17 | "style": "style/style.css", 18 | "license": "MIT", 19 | "maintainers": [ 20 | { 21 | "name": "Marijn Haverbeke", 22 | "email": "marijn@haverbeke.berlin", 23 | "web": "http://marijnhaverbeke.nl" 24 | } 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "git://github.com/prosemirror/prosemirror-example-setup.git" 29 | }, 30 | "dependencies": { 31 | "prosemirror-inputrules": "^1.0.0", 32 | "prosemirror-schema-list": "^1.0.0", 33 | "prosemirror-keymap": "^1.0.0", 34 | "prosemirror-history": "^1.0.0", 35 | "prosemirror-commands": "^1.0.0", 36 | "prosemirror-state": "^1.0.0", 37 | "prosemirror-menu": "^1.0.0", 38 | "prosemirror-dropcursor": "^1.0.0", 39 | "prosemirror-gapcursor": "^1.0.0" 40 | }, 41 | "devDependencies": { 42 | "@prosemirror/buildhelper": "^0.1.5" 43 | }, 44 | "scripts": { 45 | "prepare": "pm-buildhelper src/index.ts" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | This module exports helper functions for deriving a set of basic menu 2 | items, input rules, or key bindings from a schema. These values need 3 | to know about the schema for two reasons—they need access to specific 4 | instances of node and mark types, and they need to know which of the 5 | node and mark types that they know about are actually present in the 6 | schema. 7 | 8 | @exampleSetup 9 | 10 | @buildMenuItems 11 | 12 | @buildKeymap 13 | 14 | @buildInputRules 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {keymap} from "prosemirror-keymap" 2 | import {history} from "prosemirror-history" 3 | import {baseKeymap} from "prosemirror-commands" 4 | import {Plugin} from "prosemirror-state" 5 | import {dropCursor} from "prosemirror-dropcursor" 6 | import {gapCursor} from "prosemirror-gapcursor" 7 | import {menuBar, MenuElement} from "prosemirror-menu" 8 | import {Schema} from "prosemirror-model" 9 | 10 | import {buildMenuItems} from "./menu" 11 | import {buildKeymap} from "./keymap" 12 | import {buildInputRules} from "./inputrules" 13 | 14 | export {buildMenuItems, buildKeymap, buildInputRules} 15 | 16 | /// Create an array of plugins pre-configured for the given schema. 17 | /// The resulting array will include the following plugins: 18 | /// 19 | /// * Input rules for smart quotes and creating the block types in the 20 | /// schema using markdown conventions (say `"> "` to create a 21 | /// blockquote) 22 | /// 23 | /// * A keymap that defines keys to create and manipulate the nodes in the 24 | /// schema 25 | /// 26 | /// * A keymap binding the default keys provided by the 27 | /// prosemirror-commands module 28 | /// 29 | /// * The undo history plugin 30 | /// 31 | /// * The drop cursor plugin 32 | /// 33 | /// * The gap cursor plugin 34 | /// 35 | /// * A custom plugin that adds a `menuContent` prop for the 36 | /// prosemirror-menu wrapper, and a CSS class that enables the 37 | /// additional styling defined in `style/style.css` in this package 38 | /// 39 | /// Probably only useful for quickly setting up a passable 40 | /// editor—you'll need more control over your settings in most 41 | /// real-world situations. 42 | export function exampleSetup(options: { 43 | /// The schema to generate key bindings and menu items for. 44 | schema: Schema 45 | 46 | /// Can be used to [adjust](#example-setup.buildKeymap) the key bindings created. 47 | mapKeys?: {[key: string]: string | false} 48 | 49 | /// Set to false to disable the menu bar. 50 | menuBar?: boolean 51 | 52 | /// Set to false to disable the history plugin. 53 | history?: boolean 54 | 55 | /// Set to false to make the menu bar non-floating. 56 | floatingMenu?: boolean 57 | 58 | /// Can be used to override the menu content. 59 | menuContent?: MenuElement[][] 60 | }) { 61 | let plugins = [ 62 | buildInputRules(options.schema), 63 | keymap(buildKeymap(options.schema, options.mapKeys)), 64 | keymap(baseKeymap), 65 | dropCursor(), 66 | gapCursor() 67 | ] 68 | if (options.menuBar !== false) 69 | plugins.push(menuBar({floating: options.floatingMenu !== false, 70 | content: options.menuContent || buildMenuItems(options.schema).fullMenu})) 71 | if (options.history !== false) 72 | plugins.push(history()) 73 | 74 | return plugins.concat(new Plugin({ 75 | props: { 76 | attributes: {class: "ProseMirror-example-setup-style"} 77 | } 78 | })) 79 | } 80 | -------------------------------------------------------------------------------- /src/inputrules.ts: -------------------------------------------------------------------------------- 1 | import {inputRules, wrappingInputRule, textblockTypeInputRule, 2 | smartQuotes, emDash, ellipsis} from "prosemirror-inputrules" 3 | import {NodeType, Schema} from "prosemirror-model" 4 | 5 | /// Given a blockquote node type, returns an input rule that turns `"> "` 6 | /// at the start of a textblock into a blockquote. 7 | export function blockQuoteRule(nodeType: NodeType) { 8 | return wrappingInputRule(/^\s*>\s$/, nodeType) 9 | } 10 | 11 | /// Given a list node type, returns an input rule that turns a number 12 | /// followed by a dot at the start of a textblock into an ordered list. 13 | export function orderedListRule(nodeType: NodeType) { 14 | return wrappingInputRule(/^(\d+)\.\s$/, nodeType, match => ({order: +match[1]}), 15 | (match, node) => node.childCount + node.attrs.order == +match[1]) 16 | } 17 | 18 | /// Given a list node type, returns an input rule that turns a bullet 19 | /// (dash, plush, or asterisk) at the start of a textblock into a 20 | /// bullet list. 21 | export function bulletListRule(nodeType: NodeType) { 22 | return wrappingInputRule(/^\s*([-+*])\s$/, nodeType) 23 | } 24 | 25 | /// Given a code block node type, returns an input rule that turns a 26 | /// textblock starting with three backticks into a code block. 27 | export function codeBlockRule(nodeType: NodeType) { 28 | return textblockTypeInputRule(/^```$/, nodeType) 29 | } 30 | 31 | /// Given a node type and a maximum level, creates an input rule that 32 | /// turns up to that number of `#` characters followed by a space at 33 | /// the start of a textblock into a heading whose level corresponds to 34 | /// the number of `#` signs. 35 | export function headingRule(nodeType: NodeType, maxLevel: number) { 36 | return textblockTypeInputRule(new RegExp("^(#{1," + maxLevel + "})\\s$"), 37 | nodeType, match => ({level: match[1].length})) 38 | } 39 | 40 | /// A set of input rules for creating the basic block quotes, lists, 41 | /// code blocks, and heading. 42 | export function buildInputRules(schema: Schema) { 43 | let rules = smartQuotes.concat(ellipsis, emDash), type 44 | if (type = schema.nodes.blockquote) rules.push(blockQuoteRule(type)) 45 | if (type = schema.nodes.ordered_list) rules.push(orderedListRule(type)) 46 | if (type = schema.nodes.bullet_list) rules.push(bulletListRule(type)) 47 | if (type = schema.nodes.code_block) rules.push(codeBlockRule(type)) 48 | if (type = schema.nodes.heading) rules.push(headingRule(type, 6)) 49 | return inputRules({rules}) 50 | } 51 | -------------------------------------------------------------------------------- /src/keymap.ts: -------------------------------------------------------------------------------- 1 | import {wrapIn, setBlockType, chainCommands, toggleMark, exitCode, 2 | joinUp, joinDown, lift, selectParentNode} from "prosemirror-commands" 3 | import {wrapInList, splitListItem, liftListItem, sinkListItem} from "prosemirror-schema-list" 4 | import {undo, redo} from "prosemirror-history" 5 | import {undoInputRule} from "prosemirror-inputrules" 6 | import {Command} from "prosemirror-state" 7 | import {Schema} from "prosemirror-model" 8 | 9 | const mac = typeof navigator != "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) : false 10 | 11 | /// Inspect the given schema looking for marks and nodes from the 12 | /// basic schema, and if found, add key bindings related to them. 13 | /// This will add: 14 | /// 15 | /// * **Mod-b** for toggling [strong](#schema-basic.StrongMark) 16 | /// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark) 17 | /// * **Mod-`** for toggling [code font](#schema-basic.CodeMark) 18 | /// * **Ctrl-Shift-0** for making the current textblock a paragraph 19 | /// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current 20 | /// textblock a heading of the corresponding level 21 | /// * **Ctrl-Shift-Backslash** to make the current textblock a code block 22 | /// * **Ctrl-Shift-8** to wrap the selection in an ordered list 23 | /// * **Ctrl-Shift-9** to wrap the selection in a bullet list 24 | /// * **Ctrl->** to wrap the selection in a block quote 25 | /// * **Enter** to split a non-empty textblock in a list item while at 26 | /// the same time splitting the list item 27 | /// * **Mod-Enter** to insert a hard break 28 | /// * **Mod-_** to insert a horizontal rule 29 | /// * **Backspace** to undo an input rule 30 | /// * **Alt-ArrowUp** to `joinUp` 31 | /// * **Alt-ArrowDown** to `joinDown` 32 | /// * **Mod-BracketLeft** to `lift` 33 | /// * **Escape** to `selectParentNode` 34 | /// 35 | /// You can suppress or map these bindings by passing a `mapKeys` 36 | /// argument, which maps key names (say `"Mod-B"` to either `false`, to 37 | /// remove the binding, or a new key name string. 38 | export function buildKeymap(schema: Schema, mapKeys?: {[key: string]: false | string}) { 39 | let keys: {[key: string]: Command} = {}, type 40 | function bind(key: string, cmd: Command) { 41 | if (mapKeys) { 42 | let mapped = mapKeys[key] 43 | if (mapped === false) return 44 | if (mapped) key = mapped 45 | } 46 | keys[key] = cmd 47 | } 48 | 49 | bind("Mod-z", undo) 50 | bind("Shift-Mod-z", redo) 51 | bind("Backspace", undoInputRule) 52 | if (!mac) bind("Mod-y", redo) 53 | 54 | bind("Alt-ArrowUp", joinUp) 55 | bind("Alt-ArrowDown", joinDown) 56 | bind("Mod-BracketLeft", lift) 57 | bind("Escape", selectParentNode) 58 | 59 | if (type = schema.marks.strong) { 60 | bind("Mod-b", toggleMark(type)) 61 | bind("Mod-B", toggleMark(type)) 62 | } 63 | if (type = schema.marks.em) { 64 | bind("Mod-i", toggleMark(type)) 65 | bind("Mod-I", toggleMark(type)) 66 | } 67 | if (type = schema.marks.code) 68 | bind("Mod-`", toggleMark(type)) 69 | 70 | if (type = schema.nodes.bullet_list) 71 | bind("Shift-Ctrl-8", wrapInList(type)) 72 | if (type = schema.nodes.ordered_list) 73 | bind("Shift-Ctrl-9", wrapInList(type)) 74 | if (type = schema.nodes.blockquote) 75 | bind("Ctrl->", wrapIn(type)) 76 | if (type = schema.nodes.hard_break) { 77 | let br = type, cmd = chainCommands(exitCode, (state, dispatch) => { 78 | if (dispatch) dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView()) 79 | return true 80 | }) 81 | bind("Mod-Enter", cmd) 82 | bind("Shift-Enter", cmd) 83 | if (mac) bind("Ctrl-Enter", cmd) 84 | } 85 | if (type = schema.nodes.list_item) { 86 | bind("Enter", splitListItem(type)) 87 | bind("Mod-[", liftListItem(type)) 88 | bind("Mod-]", sinkListItem(type)) 89 | } 90 | if (type = schema.nodes.paragraph) 91 | bind("Shift-Ctrl-0", setBlockType(type)) 92 | if (type = schema.nodes.code_block) 93 | bind("Shift-Ctrl-\\", setBlockType(type)) 94 | if (type = schema.nodes.heading) 95 | for (let i = 1; i <= 6; i++) bind("Shift-Ctrl-" + i, setBlockType(type, {level: i})) 96 | if (type = schema.nodes.horizontal_rule) { 97 | let hr = type 98 | bind("Mod-_", (state, dispatch) => { 99 | if (dispatch) dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()) 100 | return true 101 | }) 102 | } 103 | 104 | return keys 105 | } 106 | -------------------------------------------------------------------------------- /src/menu.ts: -------------------------------------------------------------------------------- 1 | import {wrapItem, blockTypeItem, Dropdown, DropdownSubmenu, joinUpItem, liftItem, 2 | selectParentNodeItem, undoItem, redoItem, icons, MenuItem, MenuElement, MenuItemSpec} from "prosemirror-menu" 3 | import {NodeSelection, EditorState, Command} from "prosemirror-state" 4 | import {Schema, NodeType, MarkType} from "prosemirror-model" 5 | import {toggleMark} from "prosemirror-commands" 6 | import {wrapInList} from "prosemirror-schema-list" 7 | import {TextField, openPrompt} from "./prompt" 8 | 9 | // Helpers to create specific types of items 10 | 11 | function canInsert(state: EditorState, nodeType: NodeType) { 12 | let $from = state.selection.$from 13 | for (let d = $from.depth; d >= 0; d--) { 14 | let index = $from.index(d) 15 | if ($from.node(d).canReplaceWith(index, index, nodeType)) return true 16 | } 17 | return false 18 | } 19 | 20 | function insertImageItem(nodeType: NodeType) { 21 | return new MenuItem({ 22 | title: "Insert image", 23 | label: "Image", 24 | enable(state) { return canInsert(state, nodeType) }, 25 | run(state, _, view) { 26 | let {from, to} = state.selection, attrs = null 27 | if (state.selection instanceof NodeSelection && state.selection.node.type == nodeType) 28 | attrs = state.selection.node.attrs 29 | openPrompt({ 30 | title: "Insert image", 31 | fields: { 32 | src: new TextField({label: "Location", required: true, value: attrs && attrs.src}), 33 | title: new TextField({label: "Title", value: attrs && attrs.title}), 34 | alt: new TextField({label: "Description", 35 | value: attrs ? attrs.alt : state.doc.textBetween(from, to, " ")}) 36 | }, 37 | callback(attrs) { 38 | view.dispatch(view.state.tr.replaceSelectionWith(nodeType.createAndFill(attrs)!)) 39 | view.focus() 40 | } 41 | }) 42 | } 43 | }) 44 | } 45 | 46 | function cmdItem(cmd: Command, options: Partial) { 47 | let passedOptions: MenuItemSpec = { 48 | label: options.title as string | undefined, 49 | run: cmd 50 | } 51 | for (let prop in options) (passedOptions as any)[prop] = (options as any)[prop] 52 | if (!options.enable && !options.select) 53 | passedOptions[options.enable ? "enable" : "select"] = state => cmd(state) 54 | 55 | return new MenuItem(passedOptions) 56 | } 57 | 58 | function markActive(state: EditorState, type: MarkType) { 59 | let {from, $from, to, empty} = state.selection 60 | if (empty) return !!type.isInSet(state.storedMarks || $from.marks()) 61 | else return state.doc.rangeHasMark(from, to, type) 62 | } 63 | 64 | function markItem(markType: MarkType, options: Partial) { 65 | let passedOptions: Partial = { 66 | active(state) { return markActive(state, markType) } 67 | } 68 | for (let prop in options) (passedOptions as any)[prop] = (options as any)[prop] 69 | return cmdItem(toggleMark(markType), passedOptions) 70 | } 71 | 72 | function linkItem(markType: MarkType) { 73 | return new MenuItem({ 74 | title: "Add or remove link", 75 | icon: icons.link, 76 | active(state) { return markActive(state, markType) }, 77 | enable(state) { return !state.selection.empty }, 78 | run(state, dispatch, view) { 79 | if (markActive(state, markType)) { 80 | toggleMark(markType)(state, dispatch) 81 | return true 82 | } 83 | openPrompt({ 84 | title: "Create a link", 85 | fields: { 86 | href: new TextField({ 87 | label: "Link target", 88 | required: true 89 | }), 90 | title: new TextField({label: "Title"}) 91 | }, 92 | callback(attrs) { 93 | toggleMark(markType, attrs)(view.state, view.dispatch) 94 | view.focus() 95 | } 96 | }) 97 | } 98 | }) 99 | } 100 | 101 | function wrapListItem(nodeType: NodeType, options: Partial) { 102 | return cmdItem(wrapInList(nodeType, (options as any).attrs), options) 103 | } 104 | 105 | type MenuItemResult = { 106 | /// A menu item to toggle the [strong mark](#schema-basic.StrongMark). 107 | toggleStrong?: MenuItem 108 | 109 | /// A menu item to toggle the [emphasis mark](#schema-basic.EmMark). 110 | toggleEm?: MenuItem 111 | 112 | /// A menu item to toggle the [code font mark](#schema-basic.CodeMark). 113 | toggleCode?: MenuItem 114 | 115 | /// A menu item to toggle the [link mark](#schema-basic.LinkMark). 116 | toggleLink?: MenuItem 117 | 118 | /// A menu item to insert an [image](#schema-basic.Image). 119 | insertImage?: MenuItem 120 | 121 | /// A menu item to wrap the selection in a [bullet list](#schema-list.BulletList). 122 | wrapBulletList?: MenuItem 123 | 124 | /// A menu item to wrap the selection in an [ordered list](#schema-list.OrderedList). 125 | wrapOrderedList?: MenuItem 126 | 127 | /// A menu item to wrap the selection in a [block quote](#schema-basic.BlockQuote). 128 | wrapBlockQuote?: MenuItem 129 | 130 | /// A menu item to set the current textblock to be a normal 131 | /// [paragraph](#schema-basic.Paragraph). 132 | makeParagraph?: MenuItem 133 | 134 | /// A menu item to set the current textblock to be a 135 | /// [code block](#schema-basic.CodeBlock). 136 | makeCodeBlock?: MenuItem 137 | 138 | /// Menu items to set the current textblock to be a 139 | /// [heading](#schema-basic.Heading) of level _N_. 140 | makeHead1?: MenuItem 141 | makeHead2?: MenuItem 142 | makeHead3?: MenuItem 143 | makeHead4?: MenuItem 144 | makeHead5?: MenuItem 145 | makeHead6?: MenuItem 146 | 147 | /// A menu item to insert a horizontal rule. 148 | insertHorizontalRule?: MenuItem 149 | 150 | /// A dropdown containing the `insertImage` and 151 | /// `insertHorizontalRule` items. 152 | insertMenu: Dropdown 153 | 154 | /// A dropdown containing the items for making the current 155 | /// textblock a paragraph, code block, or heading. 156 | typeMenu: Dropdown 157 | 158 | /// Array of block-related menu items. 159 | blockMenu: MenuElement[][] 160 | 161 | /// Inline-markup related menu items. 162 | inlineMenu: MenuElement[][] 163 | 164 | /// An array of arrays of menu elements for use as the full menu 165 | /// for, for example the [menu 166 | /// bar](https://github.com/prosemirror/prosemirror-menu#user-content-menubar). 167 | fullMenu: MenuElement[][] 168 | } 169 | 170 | /// Given a schema, look for default mark and node types in it and 171 | /// return an object with relevant menu items relating to those marks. 172 | export function buildMenuItems(schema: Schema): MenuItemResult { 173 | let r: MenuItemResult = {} as any 174 | let mark: MarkType | undefined 175 | if (mark = schema.marks.strong) 176 | r.toggleStrong = markItem(mark, {title: "Toggle strong style", icon: icons.strong}) 177 | if (mark = schema.marks.em) 178 | r.toggleEm = markItem(mark, {title: "Toggle emphasis", icon: icons.em}) 179 | if (mark = schema.marks.code) 180 | r.toggleCode = markItem(mark, {title: "Toggle code font", icon: icons.code}) 181 | if (mark = schema.marks.link) 182 | r.toggleLink = linkItem(mark) 183 | 184 | let node: NodeType | undefined 185 | if (node = schema.nodes.image) 186 | r.insertImage = insertImageItem(node) 187 | if (node = schema.nodes.bullet_list) 188 | r.wrapBulletList = wrapListItem(node, { 189 | title: "Wrap in bullet list", 190 | icon: icons.bulletList 191 | }) 192 | if (node = schema.nodes.ordered_list) 193 | r.wrapOrderedList = wrapListItem(node, { 194 | title: "Wrap in ordered list", 195 | icon: icons.orderedList 196 | }) 197 | if (node = schema.nodes.blockquote) 198 | r.wrapBlockQuote = wrapItem(node, { 199 | title: "Wrap in block quote", 200 | icon: icons.blockquote 201 | }) 202 | if (node = schema.nodes.paragraph) 203 | r.makeParagraph = blockTypeItem(node, { 204 | title: "Change to paragraph", 205 | label: "Plain" 206 | }) 207 | if (node = schema.nodes.code_block) 208 | r.makeCodeBlock = blockTypeItem(node, { 209 | title: "Change to code block", 210 | label: "Code" 211 | }) 212 | if (node = schema.nodes.heading) 213 | for (let i = 1; i <= 10; i++) 214 | (r as any)["makeHead" + i] = blockTypeItem(node, { 215 | title: "Change to heading " + i, 216 | label: "Level " + i, 217 | attrs: {level: i} 218 | }) 219 | if (node = schema.nodes.horizontal_rule) { 220 | let hr = node 221 | r.insertHorizontalRule = new MenuItem({ 222 | title: "Insert horizontal rule", 223 | label: "Horizontal rule", 224 | enable(state) { return canInsert(state, hr) }, 225 | run(state, dispatch) { dispatch(state.tr.replaceSelectionWith(hr.create())) } 226 | }) 227 | } 228 | 229 | let cut = (arr: T[]) => arr.filter(x => x) as NonNullable[] 230 | r.insertMenu = new Dropdown(cut([r.insertImage, r.insertHorizontalRule]), {label: "Insert"}) 231 | r.typeMenu = new Dropdown(cut([r.makeParagraph, r.makeCodeBlock, r.makeHead1 && new DropdownSubmenu(cut([ 232 | r.makeHead1, r.makeHead2, r.makeHead3, r.makeHead4, r.makeHead5, r.makeHead6 233 | ]), {label: "Heading"})]), {label: "Type..."}) 234 | 235 | r.inlineMenu = [cut([r.toggleStrong, r.toggleEm, r.toggleCode, r.toggleLink])] 236 | r.blockMenu = [cut([r.wrapBulletList, r.wrapOrderedList, r.wrapBlockQuote, joinUpItem, 237 | liftItem, selectParentNodeItem])] 238 | r.fullMenu = r.inlineMenu.concat([[r.insertMenu, r.typeMenu]], [[undoItem, redoItem]], r.blockMenu) 239 | 240 | return r 241 | } 242 | -------------------------------------------------------------------------------- /src/prompt.ts: -------------------------------------------------------------------------------- 1 | import {Attrs} from "prosemirror-model" 2 | 3 | const prefix = "ProseMirror-prompt" 4 | 5 | export function openPrompt(options: { 6 | title: string, 7 | fields: {[name: string]: Field}, 8 | callback: (attrs: Attrs) => void 9 | }) { 10 | let wrapper = document.body.appendChild(document.createElement("div")) 11 | wrapper.className = prefix 12 | 13 | let mouseOutside = (e: MouseEvent) => { if (!wrapper.contains(e.target as HTMLElement)) close() } 14 | setTimeout(() => window.addEventListener("mousedown", mouseOutside), 50) 15 | let close = () => { 16 | window.removeEventListener("mousedown", mouseOutside) 17 | if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper) 18 | } 19 | 20 | let domFields: HTMLElement[] = [] 21 | for (let name in options.fields) domFields.push(options.fields[name].render()) 22 | 23 | let submitButton = document.createElement("button") 24 | submitButton.type = "submit" 25 | submitButton.className = prefix + "-submit" 26 | submitButton.textContent = "OK" 27 | let cancelButton = document.createElement("button") 28 | cancelButton.type = "button" 29 | cancelButton.className = prefix + "-cancel" 30 | cancelButton.textContent = "Cancel" 31 | cancelButton.addEventListener("click", close) 32 | 33 | let form = wrapper.appendChild(document.createElement("form")) 34 | if (options.title) form.appendChild(document.createElement("h5")).textContent = options.title 35 | domFields.forEach(field => { 36 | form.appendChild(document.createElement("div")).appendChild(field) 37 | }) 38 | let buttons = form.appendChild(document.createElement("div")) 39 | buttons.className = prefix + "-buttons" 40 | buttons.appendChild(submitButton) 41 | buttons.appendChild(document.createTextNode(" ")) 42 | buttons.appendChild(cancelButton) 43 | 44 | let box = wrapper.getBoundingClientRect() 45 | wrapper.style.top = ((window.innerHeight - box.height) / 2) + "px" 46 | wrapper.style.left = ((window.innerWidth - box.width) / 2) + "px" 47 | 48 | let submit = () => { 49 | let params = getValues(options.fields, domFields) 50 | if (params) { 51 | close() 52 | options.callback(params) 53 | } 54 | } 55 | 56 | form.addEventListener("submit", e => { 57 | e.preventDefault() 58 | submit() 59 | }) 60 | 61 | form.addEventListener("keydown", e => { 62 | if (e.keyCode == 27) { 63 | e.preventDefault() 64 | close() 65 | } else if (e.keyCode == 13 && !(e.ctrlKey || e.metaKey || e.shiftKey)) { 66 | e.preventDefault() 67 | submit() 68 | } else if (e.keyCode == 9) { 69 | window.setTimeout(() => { 70 | if (!wrapper.contains(document.activeElement)) close() 71 | }, 500) 72 | } 73 | }) 74 | 75 | let input = form.elements[0] as HTMLElement 76 | if (input) input.focus() 77 | } 78 | 79 | function getValues(fields: {[name: string]: Field}, domFields: readonly HTMLElement[]) { 80 | let result = Object.create(null), i = 0 81 | for (let name in fields) { 82 | let field = fields[name], dom = domFields[i++] 83 | let value = field.read(dom), bad = field.validate(value) 84 | if (bad) { 85 | reportInvalid(dom, bad) 86 | return null 87 | } 88 | result[name] = field.clean(value) 89 | } 90 | return result 91 | } 92 | 93 | function reportInvalid(dom: HTMLElement, message: string) { 94 | // FIXME this is awful and needs a lot more work 95 | let parent = dom.parentNode! 96 | let msg = parent.appendChild(document.createElement("div")) 97 | msg.style.left = (dom.offsetLeft + dom.offsetWidth + 2) + "px" 98 | msg.style.top = (dom.offsetTop - 5) + "px" 99 | msg.className = "ProseMirror-invalid" 100 | msg.textContent = message 101 | setTimeout(() => parent.removeChild(msg), 1500) 102 | } 103 | 104 | /// The type of field that `openPrompt` expects to be passed to it. 105 | export abstract class Field { 106 | /// Create a field with the given options. Options support by all 107 | /// field types are: 108 | constructor( 109 | /// @internal 110 | readonly options: { 111 | /// The starting value for the field. 112 | value?: any 113 | 114 | /// The label for the field. 115 | label: string 116 | 117 | /// Whether the field is required. 118 | required?: boolean 119 | 120 | /// A function to validate the given value. Should return an 121 | /// error message if it is not valid. 122 | validate?: (value: any) => string | null 123 | 124 | /// A cleanup function for field values. 125 | clean?: (value: any) => any 126 | } 127 | ) {} 128 | 129 | /// Render the field to the DOM. Should be implemented by all subclasses. 130 | abstract render(): HTMLElement 131 | 132 | /// Read the field's value from its DOM node. 133 | read(dom: HTMLElement) { return (dom as any).value } 134 | 135 | /// A field-type-specific validation function. 136 | validateType(value: any): string | null { return null } 137 | 138 | /// @internal 139 | validate(value: any): string | null { 140 | if (!value && this.options.required) 141 | return "Required field" 142 | return this.validateType(value) || (this.options.validate ? this.options.validate(value) : null) 143 | } 144 | 145 | clean(value: any): any { 146 | return this.options.clean ? this.options.clean(value) : value 147 | } 148 | } 149 | 150 | /// A field class for single-line text fields. 151 | export class TextField extends Field { 152 | render() { 153 | let input = document.createElement("input") 154 | input.type = "text" 155 | input.placeholder = this.options.label 156 | input.value = this.options.value || "" 157 | input.autocomplete = "off" 158 | return input 159 | } 160 | } 161 | 162 | 163 | /// A field class for dropdown fields based on a plain `