index.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { Plugin } from 'prosemirror-state';
  2. import { findWrapping, canJoin } from 'prosemirror-transform';
  3. /**
  4. Input rules are regular expressions describing a piece of text
  5. that, when typed, causes something to happen. This might be
  6. changing two dashes into an emdash, wrapping a paragraph starting
  7. with `"> "` into a blockquote, or something entirely different.
  8. */
  9. class InputRule {
  10. // :: (RegExp, union<string, (state: EditorState, match: [string], start: number, end: number) → ?Transaction>)
  11. /**
  12. Create an input rule. The rule applies when the user typed
  13. something and the text directly in front of the cursor matches
  14. `match`, which should end with `$`.
  15. The `handler` can be a string, in which case the matched text, or
  16. the first matched group in the regexp, is replaced by that
  17. string.
  18. Or a it can be a function, which will be called with the match
  19. array produced by
  20. [`RegExp.exec`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec),
  21. as well as the start and end of the matched range, and which can
  22. return a [transaction](https://prosemirror.net/docs/ref/#state.Transaction) that describes the
  23. rule's effect, or null to indicate the input was not handled.
  24. */
  25. constructor(
  26. /**
  27. @internal
  28. */
  29. match, handler, options = {}) {
  30. this.match = match;
  31. this.match = match;
  32. this.handler = typeof handler == "string" ? stringHandler(handler) : handler;
  33. this.undoable = options.undoable !== false;
  34. this.inCode = options.inCode || false;
  35. }
  36. }
  37. function stringHandler(string) {
  38. return function (state, match, start, end) {
  39. let insert = string;
  40. if (match[1]) {
  41. let offset = match[0].lastIndexOf(match[1]);
  42. insert += match[0].slice(offset + match[1].length);
  43. start += offset;
  44. let cutOff = start - end;
  45. if (cutOff > 0) {
  46. insert = match[0].slice(offset - cutOff, offset) + insert;
  47. start = end;
  48. }
  49. }
  50. return state.tr.insertText(insert, start, end);
  51. };
  52. }
  53. const MAX_MATCH = 500;
  54. /**
  55. Create an input rules plugin. When enabled, it will cause text
  56. input that matches any of the given rules to trigger the rule's
  57. action.
  58. */
  59. function inputRules({ rules }) {
  60. let plugin = new Plugin({
  61. state: {
  62. init() { return null; },
  63. apply(tr, prev) {
  64. let stored = tr.getMeta(this);
  65. if (stored)
  66. return stored;
  67. return tr.selectionSet || tr.docChanged ? null : prev;
  68. }
  69. },
  70. props: {
  71. handleTextInput(view, from, to, text) {
  72. return run(view, from, to, text, rules, plugin);
  73. },
  74. handleDOMEvents: {
  75. compositionend: (view) => {
  76. setTimeout(() => {
  77. let { $cursor } = view.state.selection;
  78. if ($cursor)
  79. run(view, $cursor.pos, $cursor.pos, "", rules, plugin);
  80. });
  81. }
  82. }
  83. },
  84. isInputRules: true
  85. });
  86. return plugin;
  87. }
  88. function run(view, from, to, text, rules, plugin) {
  89. if (view.composing)
  90. return false;
  91. let state = view.state, $from = state.doc.resolve(from);
  92. let textBefore = $from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset, null, "\ufffc") + text;
  93. for (let i = 0; i < rules.length; i++) {
  94. let rule = rules[i];
  95. if ($from.parent.type.spec.code) {
  96. if (!rule.inCode)
  97. continue;
  98. }
  99. else if (rule.inCode === "only") {
  100. continue;
  101. }
  102. let match = rule.match.exec(textBefore);
  103. let tr = match && rule.handler(state, match, from - (match[0].length - text.length), to);
  104. if (!tr)
  105. continue;
  106. if (rule.undoable)
  107. tr.setMeta(plugin, { transform: tr, from, to, text });
  108. view.dispatch(tr);
  109. return true;
  110. }
  111. return false;
  112. }
  113. /**
  114. This is a command that will undo an input rule, if applying such a
  115. rule was the last thing that the user did.
  116. */
  117. const undoInputRule = (state, dispatch) => {
  118. let plugins = state.plugins;
  119. for (let i = 0; i < plugins.length; i++) {
  120. let plugin = plugins[i], undoable;
  121. if (plugin.spec.isInputRules && (undoable = plugin.getState(state))) {
  122. if (dispatch) {
  123. let tr = state.tr, toUndo = undoable.transform;
  124. for (let j = toUndo.steps.length - 1; j >= 0; j--)
  125. tr.step(toUndo.steps[j].invert(toUndo.docs[j]));
  126. if (undoable.text) {
  127. let marks = tr.doc.resolve(undoable.from).marks();
  128. tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks));
  129. }
  130. else {
  131. tr.delete(undoable.from, undoable.to);
  132. }
  133. dispatch(tr);
  134. }
  135. return true;
  136. }
  137. }
  138. return false;
  139. };
  140. /**
  141. Converts double dashes to an emdash.
  142. */
  143. const emDash = new InputRule(/--$/, "—");
  144. /**
  145. Converts three dots to an ellipsis character.
  146. */
  147. const ellipsis = new InputRule(/\.\.\.$/, "…");
  148. /**
  149. “Smart” opening double quotes.
  150. */
  151. const openDoubleQuote = new InputRule(/(?:^|[\s\{\[\(\<'"\u2018\u201C])(")$/, "“");
  152. /**
  153. “Smart” closing double quotes.
  154. */
  155. const closeDoubleQuote = new InputRule(/"$/, "”");
  156. /**
  157. “Smart” opening single quotes.
  158. */
  159. const openSingleQuote = new InputRule(/(?:^|[\s\{\[\(\<'"\u2018\u201C])(')$/, "‘");
  160. /**
  161. “Smart” closing single quotes.
  162. */
  163. const closeSingleQuote = new InputRule(/'$/, "’");
  164. /**
  165. Smart-quote related input rules.
  166. */
  167. const smartQuotes = [openDoubleQuote, closeDoubleQuote, openSingleQuote, closeSingleQuote];
  168. /**
  169. Build an input rule for automatically wrapping a textblock when a
  170. given string is typed. The `regexp` argument is
  171. directly passed through to the `InputRule` constructor. You'll
  172. probably want the regexp to start with `^`, so that the pattern can
  173. only occur at the start of a textblock.
  174. `nodeType` is the type of node to wrap in. If it needs attributes,
  175. you can either pass them directly, or pass a function that will
  176. compute them from the regular expression match.
  177. By default, if there's a node with the same type above the newly
  178. wrapped node, the rule will try to [join](https://prosemirror.net/docs/ref/#transform.Transform.join) those
  179. two nodes. You can pass a join predicate, which takes a regular
  180. expression match and the node before the wrapped node, and can
  181. return a boolean to indicate whether a join should happen.
  182. */
  183. function wrappingInputRule(regexp, nodeType, getAttrs = null, joinPredicate) {
  184. return new InputRule(regexp, (state, match, start, end) => {
  185. let attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
  186. let tr = state.tr.delete(start, end);
  187. let $start = tr.doc.resolve(start), range = $start.blockRange(), wrapping = range && findWrapping(range, nodeType, attrs);
  188. if (!wrapping)
  189. return null;
  190. tr.wrap(range, wrapping);
  191. let before = tr.doc.resolve(start - 1).nodeBefore;
  192. if (before && before.type == nodeType && canJoin(tr.doc, start - 1) &&
  193. (!joinPredicate || joinPredicate(match, before)))
  194. tr.join(start - 1);
  195. return tr;
  196. });
  197. }
  198. /**
  199. Build an input rule that changes the type of a textblock when the
  200. matched text is typed into it. You'll usually want to start your
  201. regexp with `^` to that it is only matched at the start of a
  202. textblock. The optional `getAttrs` parameter can be used to compute
  203. the new node's attributes, and works the same as in the
  204. `wrappingInputRule` function.
  205. */
  206. function textblockTypeInputRule(regexp, nodeType, getAttrs = null) {
  207. return new InputRule(regexp, (state, match, start, end) => {
  208. let $start = state.doc.resolve(start);
  209. let attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
  210. if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType))
  211. return null;
  212. return state.tr
  213. .delete(start, end)
  214. .setBlockType(start, start, nodeType, attrs);
  215. });
  216. }
  217. export { InputRule, closeDoubleQuote, closeSingleQuote, ellipsis, emDash, inputRules, openDoubleQuote, openSingleQuote, smartQuotes, textblockTypeInputRule, undoInputRule, wrappingInputRule };