diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index d51a827bc8..d1c2ba9733 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -63,6 +63,7 @@ export class Editor extends EventEmitter { editorProps: {}, parseOptions: {}, coreExtensionOptions: {}, + enableInputRules: true, isCommentsEnabled: false, isNewFile: false, scale: 1, @@ -1105,4 +1106,4 @@ export class Editor extends EventEmitter { }; } -} \ No newline at end of file +} diff --git a/packages/super-editor/src/core/ExtensionService.js b/packages/super-editor/src/core/ExtensionService.js index 6f462f7702..271d8f595d 100644 --- a/packages/super-editor/src/core/ExtensionService.js +++ b/packages/super-editor/src/core/ExtensionService.js @@ -5,6 +5,8 @@ import { getNodeType } from './helpers/getNodeType.js'; import { getExtensionConfigField } from './helpers/getExtensionConfigField.js'; import { getSchemaTypeByName } from './helpers/getSchemaTypeByName.js'; import { callOrGet } from './utilities/callOrGet.js'; +import { isExtensionRulesEnabled } from './helpers/isExtentionRulesEnabled.js'; +import { inputRulesPlugin } from './InputRule.js'; /** * ExtensionService is the main class to work with extensions. @@ -108,6 +110,8 @@ export class ExtensionService { const editor = this.editor; const extensions = ExtensionService.sortByPriority([...this.extensions].reverse()); + const inputRules = []; + const allPlugins = extensions .map((extension) => { const context = { @@ -133,6 +137,12 @@ export class ExtensionService { plugins.push(keymap(bindingsObject)); + const addInputRules = getExtensionConfigField(extension, 'addInputRules', context); + + if (isExtensionRulesEnabled(extension, editor.options.enableInputRules) && addInputRules) { + inputRules.push(...addInputRules()); + } + const addPmPlugins = getExtensionConfigField(extension, 'addPmPlugins', context); if (addPmPlugins) { @@ -144,7 +154,13 @@ export class ExtensionService { }) .flat(); - return [...allPlugins]; + return [ + inputRulesPlugin({ + editor, + rules: inputRules, + }), + ...allPlugins + ]; } /** diff --git a/packages/super-editor/src/core/InputRule.js b/packages/super-editor/src/core/InputRule.js new file mode 100644 index 0000000000..7d5ce700f1 --- /dev/null +++ b/packages/super-editor/src/core/InputRule.js @@ -0,0 +1,214 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Fragment } from 'prosemirror-model'; +import { CommandService } from './CommandService.js'; +import { chainableEditorState } from './helpers/chainableEditorState.js'; +import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'; +import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js'; +import { isRegExp } from './utilities/isRegExp.js'; + +export class InputRule { + match; + handler; + + constructor(config) { + this.match = config.match; + this.handler = config.handler; + } +} + +const inputRuleMatcherHandler = (text, match) => { + if (isRegExp(match)) { + return match.exec(text); + } + + const inputRuleMatch = match(text); + + if (!inputRuleMatch) { + return null; + } + + const result = [ inputRuleMatch.text ]; + + result.index = inputRuleMatch.index; + result.input = text; + result.data = inputRuleMatch.data; + + if (inputRuleMatch.replaceWith) { + if (!inputRuleMatch.text.includes(inputRuleMatch.replaceWith)) { + console.warn( + '[super-editor warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".', + ); + } + + result.push(inputRuleMatch.replaceWith); + } + + return result; +} + +const run = (config) => { + const { + editor, from, to, text, rules, plugin, + } = config; + const { view } = editor; + + if (view.composing) { + return false; + } + + const $from = view.state.doc.resolve(from); + + if ( + $from.parent.type.spec.code + || !!($from.nodeBefore || $from.nodeAfter)?.marks.find(mark => mark.type.spec.code) + ) { + return false; + } + + let matched = false; + const textBefore = getTextContentFromNodes($from) + text; + + rules.forEach(rule => { + if (matched) { + return; + } + + const match = inputRuleMatcherHandler(textBefore, rule.match) + + if (!match) { + return + } + + const tr = view.state.tr; + const state = chainableEditorState(tr, view.state); + const range = { + from: from - (match[0].length - text.length), + to, + }; + + const { commands, chain, can } = new CommandService({ + editor, + state, + }); + + const handler = rule.handler({ + state, + range, + match, + commands, + chain, + can, + }); + + // stop if there are no changes + if (handler === null || !tr.steps.length) { + return; + } + + // store transform as metadata + // so we can undo input rules within the `undoInputRules` command + tr.setMeta(plugin, { + transform: tr, + from, + to, + text, + }); + + view.dispatch(tr); + matched = true; + }) + + return matched; +} + +/** + * Create an input rules plugin. When enabled, it will cause text + * input that matches any of the given rules to trigger the rule’s + * action. + */ +export const inputRulesPlugin = ({ editor, rules }) => { + const plugin = new Plugin({ + key: new PluginKey('inputRulesPlugin'), + + state: { + init() { + return null; + }, + + apply(tr, prev, state) { + const stored = tr.getMeta(plugin); + + if (stored) { + return stored; + } + + // if InputRule is triggered by insertContent() + const simulatedInputMeta = tr.getMeta('applyInputRules'); + const isSimulatedInput = !!simulatedInputMeta; + + if (isSimulatedInput) { + setTimeout(() => { + let { text } = simulatedInputMeta; + + if (typeof text !== 'string') { + text = getHTMLFromFragment(Fragment.from(text), state.schema); + } + + const { from } = simulatedInputMeta; + const to = from + text.length; + + run({ + editor, + from, + to, + text, + rules, + plugin, + }); + }) + } + + return tr.selectionSet || tr.docChanged ? null : prev; + }, + }, + + props: { + handleTextInput(view, from, to, text) { + return run({ + editor, + from, + to, + text, + rules, + plugin, + }) + }, + + // add support for input rules to trigger on enter + // this is useful for example for code blocks + handleKeyDown(view, event) { + if (event.key !== 'Enter') { + return false; + } + + const { $cursor } = view.state.selection; + + if ($cursor) { + return run({ + editor, + from: $cursor.pos, + to: $cursor.pos, + text: '\n', + rules, + plugin, + }) + } + + return false; + }, + }, + + isInputRules: true, + }); + return plugin; +} diff --git a/packages/super-editor/src/core/commands/index.js b/packages/super-editor/src/core/commands/index.js index 0402b81308..5156952fe9 100644 --- a/packages/super-editor/src/core/commands/index.js +++ b/packages/super-editor/src/core/commands/index.js @@ -32,6 +32,7 @@ export * from './selectTextblockStart.js'; export * from './selectTextblockEnd.js'; export * from './insertContent.js'; export * from './insertContentAt.js'; +export * from './undoInputRule.js'; // Lists export * from './wrapInList.js'; diff --git a/packages/super-editor/src/core/commands/undoInputRule.js b/packages/super-editor/src/core/commands/undoInputRule.js new file mode 100644 index 0000000000..a575a27eef --- /dev/null +++ b/packages/super-editor/src/core/commands/undoInputRule.js @@ -0,0 +1,32 @@ +export const undoInputRule = () => ({ state, dispatch }) => { + const plugins = state.plugins; + + for (let i = 0; i < plugins.length; i += 1) { + const plugin = plugins[i]; + let undoable; + + // eslint-disable-next-line + if (plugin.spec.isInputRules && (undoable = plugin.getState(state))) { + if (dispatch) { + const tr = state.tr; + const toUndo = undoable.transform; + + for (let j = toUndo.steps.length - 1; j >= 0; j -= 1) { + tr.step(toUndo.steps[j].invert(toUndo.docs[j])); + } + + if (undoable.text) { + const marks = tr.doc.resolve(undoable.from).marks(); + + tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks)); + } else { + tr.delete(undoable.from, undoable.to); + } + } + + return true; + } + } + + return false; +} diff --git a/packages/super-editor/src/core/extensions/keymap.js b/packages/super-editor/src/core/extensions/keymap.js index 74f11599b4..6630e62f46 100644 --- a/packages/super-editor/src/core/extensions/keymap.js +++ b/packages/super-editor/src/core/extensions/keymap.js @@ -20,6 +20,7 @@ export const Keymap = Extension.create({ const handleBackspace = () => this.editor.commands.first(({ commands, tr }) => [ + () => commands.undoInputRule(), () => { tr.setMeta('inputType', 'deleteContentBackward'); return false; diff --git a/packages/super-editor/src/core/helpers/getHTMLFromFragment.js b/packages/super-editor/src/core/helpers/getHTMLFromFragment.js new file mode 100644 index 0000000000..bc06467f36 --- /dev/null +++ b/packages/super-editor/src/core/helpers/getHTMLFromFragment.js @@ -0,0 +1,12 @@ +import { DOMSerializer } from 'prosemirror-model'; + +export function getHTMLFromFragment(fragment, schema) { + const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment(fragment); + + const temporaryDocument = document.implementation.createHTMLDocument(); + const container = temporaryDocument.createElement('div'); + + container.appendChild(documentFragment); + + return container.innerHTML; +} diff --git a/packages/super-editor/src/core/helpers/getTextContentFromNodes.js b/packages/super-editor/src/core/helpers/getTextContentFromNodes.js new file mode 100644 index 0000000000..2ab7014e30 --- /dev/null +++ b/packages/super-editor/src/core/helpers/getTextContentFromNodes.js @@ -0,0 +1,30 @@ +/** + * Returns the text content of a resolved prosemirror position + * @param $from The resolved position to get the text content from + * @param maxMatch The maximum number of characters to match + * @returns The text content + */ +export const getTextContentFromNodes = ($from, maxMatch = 500) => { + let textBefore = ''; + + const sliceEndPos = $from.parentOffset; + + $from.parent.nodesBetween( + Math.max(0, sliceEndPos - maxMatch), + sliceEndPos, + (node, pos, parent, index) => { + const chunk = node.type.spec.toText?.({ + node, + pos, + parent, + index, + }) + || node.textContent + || '%leaf%' + + textBefore += node.isAtom && !node.isText ? chunk : chunk.slice(0, Math.max(0, sliceEndPos - pos)); + }, + ) + + return textBefore; +} diff --git a/packages/super-editor/src/core/helpers/isExtentionRulesEnabled.js b/packages/super-editor/src/core/helpers/isExtentionRulesEnabled.js new file mode 100644 index 0000000000..61a37020ac --- /dev/null +++ b/packages/super-editor/src/core/helpers/isExtentionRulesEnabled.js @@ -0,0 +1,13 @@ +export function isExtensionRulesEnabled(extension, enabled) { + if (Array.isArray(enabled)) { + return enabled.some(enabledExtension => { + const name = typeof enabledExtension === 'string' + ? enabledExtension + : enabledExtension.name; + + return name === extension.name; + }) + } + + return enabled; +} diff --git a/packages/super-editor/src/core/inputRules/wrappingInputRule.js b/packages/super-editor/src/core/inputRules/wrappingInputRule.js new file mode 100644 index 0000000000..9913866980 --- /dev/null +++ b/packages/super-editor/src/core/inputRules/wrappingInputRule.js @@ -0,0 +1,59 @@ +import { canJoin, findWrapping } from 'prosemirror-transform'; +import { InputRule } from '../InputRule.js'; +import { callOrGet } from '../utilities/callOrGet.js' + +/** + * Build an input rule for automatically wrapping a textblock when a + * given string is typed. When using a regular expresion you’ll + * probably want the regexp to start with `^`, so that the pattern can + * only occur at the start of a textblock. + */ +export function wrappingInputRule(config) { + return new InputRule({ + match: config.match, + handler: ({ + state, range, match, chain, + }) => { + const attributes = callOrGet(config.getAttributes, null, match) || {}; + const tr = state.tr.delete(range.from, range.to); + const $start = tr.doc.resolve(range.from); + const blockRange = $start.blockRange(); + const wrapping = blockRange && findWrapping(blockRange, config.type, attributes); + + if (!wrapping) { + return null; + } + + tr.wrap(blockRange, wrapping); + + if (config.keepMarks && config.editor) { + const { selection, storedMarks } = state; + const { splittableMarks } = config.editor.extensionService; + const marks = storedMarks || (selection.$to.parentOffset && selection.$from.marks()); + + if (marks) { + const filteredMarks = marks.filter(mark => splittableMarks.includes(mark.type.name)); + + tr.ensureMarks(filteredMarks); + } + } + if (config.keepAttributes) { + /** If the nodeType is `bulletList` or `orderedList` set the `nodeType` as `listItem` */ + const nodeType = config.type.name === 'bulletList' || config.type.name === 'orderedList' ? 'listItem' : config.type.name; + + chain().updateAttributes(nodeType, attributes).run(); + } + + const before = tr.doc.resolve(range.from - 1).nodeBefore; + + if ( + before + && before.type === config.type + && canJoin(tr.doc, range.from - 1) + && (!config.joinPredicate || config.joinPredicate(match, before)) + ) { + tr.join(range.from - 1); + } + }, + }) +} diff --git a/packages/super-editor/src/core/utilities/isRegExp.js b/packages/super-editor/src/core/utilities/isRegExp.js new file mode 100644 index 0000000000..196773d9f2 --- /dev/null +++ b/packages/super-editor/src/core/utilities/isRegExp.js @@ -0,0 +1,3 @@ +export const isRegExp = (value) => { + return Object.prototype.toString.call(value) === '[object RegExp]'; +}; diff --git a/packages/super-editor/src/core/utilities/objectIncludes.js b/packages/super-editor/src/core/utilities/objectIncludes.js index 78e057fbf9..5ff7c088e3 100644 --- a/packages/super-editor/src/core/utilities/objectIncludes.js +++ b/packages/super-editor/src/core/utilities/objectIncludes.js @@ -1,6 +1,4 @@ -const isRegExp = (value) => { - return Object.prototype.toString.call(value) === '[object RegExp]'; -}; +import { isRegExp } from './isRegExp.js'; /** * Check if obj1 includes obj2 diff --git a/packages/super-editor/src/extensions/bullet-list/bullet-list.js b/packages/super-editor/src/extensions/bullet-list/bullet-list.js index b42cbd50a7..163008c311 100644 --- a/packages/super-editor/src/extensions/bullet-list/bullet-list.js +++ b/packages/super-editor/src/extensions/bullet-list/bullet-list.js @@ -1,5 +1,11 @@ import { Node, Attribute } from '@core/index.js'; import { generateDocxListAttributes } from '@helpers/index.js'; +import { wrappingInputRule } from '../../core/inputRules/wrappingInputRule.js'; + +/** + * Matches a bullet list to a dash or asterisk. + */ +const inputRegex = /^\s*([-+*])\s$/; export const BulletList = Node.create({ name: 'bulletList', @@ -15,6 +21,7 @@ export const BulletList = Node.create({ itemTypeName: 'listItem', htmlAttributes: {}, keepMarks: true, + keepAttributes: false, }; }, @@ -61,4 +68,24 @@ export const BulletList = Node.create({ }, // Input rules. + addInputRules() { + let inputRule = wrappingInputRule({ + match: inputRegex, + type: this.type, + }) + + if (this.options.keepMarks || this.options.keepAttributes) { + inputRule = wrappingInputRule({ + match: inputRegex, + type: this.type, + keepMarks: this.options.keepMarks, + keepAttributes: this.options.keepAttributes, + getAttributes: () => { return this.editor.getAttributes('textStyle') }, + editor: this.editor, + }) + } + return [ + inputRule, + ]; + }, }); diff --git a/packages/super-editor/src/extensions/ordered-list/ordered-list.js b/packages/super-editor/src/extensions/ordered-list/ordered-list.js index 239dbcc232..6fb284dfe4 100644 --- a/packages/super-editor/src/extensions/ordered-list/ordered-list.js +++ b/packages/super-editor/src/extensions/ordered-list/ordered-list.js @@ -3,6 +3,12 @@ import { toKebabCase } from '@harbour-enterprises/common'; import { generateDocxListAttributes, findParentNode } from '@helpers/index.js'; import { orderedListSync as orderedListSyncPlugin } from './helpers/orderedListSyncPlugin.js'; import { orderedListMarker as orderedListMarkerPlugin } from './helpers/orderedListMarkerPlugin.js'; +import { wrappingInputRule } from '../../core/inputRules/wrappingInputRule.js'; + +/** + * Matches an ordered list to a 1. on input (or any number followed by a dot). + */ +const inputRegex = /^(\d+)\.\s$/; export const OrderedList = Node.create({ name: 'orderedList', @@ -18,6 +24,7 @@ export const OrderedList = Node.create({ itemTypeName: 'listItem', htmlAttributes: {}, keepMarks: true, + keepAttributes: false, listStyleTypes: ['decimal', 'lowerAlpha', 'lowerRoman'], }; }, @@ -216,6 +223,30 @@ export const OrderedList = Node.create({ addPmPlugins() { return [orderedListMarkerPlugin(), orderedListSyncPlugin()]; }, + + addInputRules() { + let inputRule = wrappingInputRule({ + match: inputRegex, + type: this.type, + getAttributes: match => ({ start: +match[1] }), + joinPredicate: (match, node) => node.childCount + node.attrs.start === +match[1], + }) + + if (this.options.keepMarks || this.options.keepAttributes) { + inputRule = wrappingInputRule({ + match: inputRegex, + type: this.type, + keepMarks: this.options.keepMarks, + keepAttributes: this.options.keepAttributes, + getAttributes: match => ({ start: +match[1], ...this.editor.getAttributes('textStyle') }), + joinPredicate: (match, node) => node.childCount + node.attrs.start === +match[1], + editor: this.editor, + }) + } + return [ + inputRule, + ]; + }, }); function randomId() {