Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/super-editor/src/core/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class Editor extends EventEmitter {
editorProps: {},
parseOptions: {},
coreExtensionOptions: {},
enableInputRules: true,
isCommentsEnabled: false,
isNewFile: false,
scale: 1,
Expand Down Expand Up @@ -1105,4 +1106,4 @@ export class Editor extends EventEmitter {
};

}
}
}
18 changes: 17 additions & 1 deletion packages/super-editor/src/core/ExtensionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {
Expand All @@ -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) {
Expand All @@ -144,7 +154,13 @@ export class ExtensionService {
})
.flat();

return [...allPlugins];
return [
inputRulesPlugin({
editor,
rules: inputRules,
}),
...allPlugins
];
}

/**
Expand Down
214 changes: 214 additions & 0 deletions packages/super-editor/src/core/InputRule.js
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/super-editor/src/core/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions packages/super-editor/src/core/commands/undoInputRule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const undoInputRule = () => ({ state, dispatch }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer not do undoInputRule and keep it as simple as possible.

@harbournick, what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When used with ordered list it’s more essential (from my point of view) to undo the rule on delete to leave the initial typing
If not the initial typing will be removed

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;
}
1 change: 1 addition & 0 deletions packages/super-editor/src/core/extensions/keymap.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const Keymap = Extension.create({

const handleBackspace = () =>
this.editor.commands.first(({ commands, tr }) => [
() => commands.undoInputRule(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice !

() => {
tr.setMeta('inputType', 'deleteContentBackward');
return false;
Expand Down
12 changes: 12 additions & 0 deletions packages/super-editor/src/core/helpers/getHTMLFromFragment.js
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions packages/super-editor/src/core/helpers/getTextContentFromNodes.js
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions packages/super-editor/src/core/helpers/isExtentionRulesEnabled.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading