mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
import { buildInputRules } from 'prosemirror-example-setup';
|
|
import schema from '../schema';
|
|
import { MarkType, ResolvedPos } from 'prosemirror-model';
|
|
import { EditorState, Plugin, Transaction } from 'prosemirror-state';
|
|
import { EditorView } from 'prosemirror-view';
|
|
import { closeHistory } from 'prosemirror-history';
|
|
import showCreateEditablePrompt from './joplinEditablePlugin/showCreateEditablePrompt';
|
|
|
|
|
|
interface InputRuleData {
|
|
match: RegExp;
|
|
commitCharacter: string|null;
|
|
handler: (view: EditorView, match: RegExpMatchArray, start: number, end: number, commitCharacter: string)=> Transaction|null;
|
|
}
|
|
|
|
// A custom input rule extension for inline input replacements.
|
|
//
|
|
// Ref: https://github.com/ProseMirror/prosemirror-inputrules/blob/43ef04ce9c1512ef8f2289578309c40b431ed3c5/src/inputrules.ts#L82
|
|
// See https://discuss.prosemirror.net/t/trigger-inputrule-on-enter/1118 for why this approach is needed.
|
|
const inlineInputRules = (rules: InputRuleData[], commitCharacterExpression: RegExp) => {
|
|
const getContentBeforeCursor = (cursorInformation: ResolvedPos) => {
|
|
const parent = cursorInformation.parent;
|
|
const offsetInParent = cursorInformation.parentOffset;
|
|
const maxLength = 256;
|
|
return parent.textBetween(Math.max(offsetInParent - maxLength, 0), offsetInParent);
|
|
};
|
|
|
|
const getApplicableRule = (state: EditorState, cursor: number, justTypedText: string) => {
|
|
const candidateRules = rules.filter(rule => justTypedText.endsWith(rule.commitCharacter ?? ''));
|
|
if (!candidateRules.length) {
|
|
return false;
|
|
}
|
|
|
|
const cursorInformation = state.doc.resolve(cursor);
|
|
const inCode = cursorInformation.parent.type.spec.code;
|
|
if (inCode) {
|
|
return false;
|
|
}
|
|
const beforeCursor = getContentBeforeCursor(cursorInformation) + justTypedText;
|
|
|
|
for (const rule of candidateRules) {
|
|
const match = beforeCursor.match(rule.match);
|
|
if (!match) continue;
|
|
|
|
return rule;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
type PluginState = { pendingRule: InputRuleData };
|
|
const run = (view: EditorView, cursor: number, commitData: string) => {
|
|
const commitCharacter = commitCharacterExpression.exec(commitData) ? commitData : '';
|
|
|
|
// Commit on either a commit character or when the user presses "enter".
|
|
if (!commitCharacter && commitData !== 'Enter') return false;
|
|
|
|
const availableRule = plugin.getState(view.state)?.pendingRule;
|
|
if (!availableRule) return false;
|
|
|
|
const beforeCursor = getContentBeforeCursor(view.state.doc.resolve(cursor));
|
|
const match = beforeCursor.match(availableRule.match);
|
|
if (match) {
|
|
const transaction = availableRule.handler(view, match, cursor - match[0].length, cursor, commitCharacter);
|
|
if (transaction) {
|
|
// closeHistory: Move the markup completion to a separate history event so that it
|
|
// can be undone separately.
|
|
view.dispatch(closeHistory(transaction));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const plugin = new Plugin<PluginState|null>({
|
|
state: {
|
|
init: () => null,
|
|
apply: (tr, lastValue) => {
|
|
const pendingRule = tr.getMeta(plugin);
|
|
if (pendingRule) return pendingRule;
|
|
if (tr.docChanged || tr.selectionSet) return null;
|
|
return lastValue;
|
|
},
|
|
},
|
|
props: {
|
|
handleTextInput(view, _from, to, text, defaultAction) {
|
|
const proposedRule = getApplicableRule(view.state, to, text);
|
|
if (proposedRule) {
|
|
const transaction = defaultAction().setMeta(plugin, { pendingRule: proposedRule });
|
|
view.dispatch(transaction);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
handleDOMEvents: {
|
|
compositionend: (view, event) => {
|
|
if (view.state.selection.empty) {
|
|
return run(view, view.state.selection.to, event.data);
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
handleKeyDown: (view, event) => {
|
|
return run(view, view.state.selection.to, event.key);
|
|
},
|
|
},
|
|
// TODO: Uncomment this? Doing so allows the undoInputRule command to undo this input rule.
|
|
// However, it will require storing additional information in the plugin state (e.g. last transaction,
|
|
// start, end position) to be compatible with the upstream input rule extension.
|
|
// isInputRules: true,
|
|
});
|
|
return plugin;
|
|
};
|
|
|
|
type OnReplace = (matches: RegExpMatchArray, view: EditorView)=> string;
|
|
interface InputRuleOptions {
|
|
contentRegex: string|RegExp;
|
|
commitCharacter: string|null; // null => only "Enter"
|
|
onReplace: OnReplace;
|
|
marks: MarkType[];
|
|
}
|
|
|
|
const makeInputRule = ({
|
|
contentRegex, commitCharacter, onReplace, marks,
|
|
}: InputRuleOptions): InputRuleData => {
|
|
const commitCharacterExp = '[.?!,:;¡¿() \\n]';
|
|
const regex = typeof contentRegex === 'string' ? new RegExp(`(^|${commitCharacterExp})${contentRegex}$`) : contentRegex;
|
|
return {
|
|
match: regex,
|
|
commitCharacter,
|
|
handler: (view, match, start, end, endCommitCharacter) => {
|
|
const state = view.state;
|
|
let transaction = state.tr.delete(start, end);
|
|
|
|
const startCommitCharacter = match[1];
|
|
|
|
// Remove the commit-character-related matches before forwarding the input
|
|
// to the replacement function.
|
|
const matchesWithoutCommitCharacters: RegExpMatchArray = [
|
|
match[0].substring(startCommitCharacter.length, match[0].length),
|
|
...match.slice(2, match.length),
|
|
];
|
|
matchesWithoutCommitCharacters.groups = match.groups;
|
|
|
|
const replacement = onReplace(matchesWithoutCommitCharacters, view);
|
|
transaction = transaction.insert(
|
|
transaction.mapping.map(start, -1),
|
|
[
|
|
!!startCommitCharacter && schema.text(startCommitCharacter),
|
|
!!replacement && schema.text(replacement, marks.map(type => schema.mark(type))),
|
|
!!endCommitCharacter && schema.text(endCommitCharacter),
|
|
].filter(node => !!node),
|
|
);
|
|
|
|
return transaction;
|
|
},
|
|
};
|
|
};
|
|
|
|
const baseInputRules = buildInputRules(schema);
|
|
const inlineContentExp = '\\S[^\\n]*\\S|\\S';
|
|
const noMatchRegex = /$^/;
|
|
const inputRulesExtension = [
|
|
baseInputRules,
|
|
inlineInputRules([
|
|
makeInputRule({
|
|
contentRegex: /(^|[\n])(```+)(\w*)$/,
|
|
commitCharacter: '',
|
|
onReplace: (match, view) => {
|
|
const blockStart = `${match[1]}${match[2]}\n`;
|
|
const block = `${blockStart}\n${match[1]}`;
|
|
showCreateEditablePrompt({
|
|
source: block,
|
|
inline: false,
|
|
cursor: blockStart.length,
|
|
})(view.state, view.dispatch, view);
|
|
return '';
|
|
},
|
|
marks: [],
|
|
}),
|
|
], noMatchRegex),
|
|
inlineInputRules([
|
|
makeInputRule({
|
|
contentRegex: `\\*\\*(${inlineContentExp})\\*\\*`,
|
|
commitCharacter: '*',
|
|
onReplace: (match) => match[1],
|
|
marks: [schema.marks.strong],
|
|
}),
|
|
makeInputRule({
|
|
contentRegex: `\\*(${inlineContentExp})\\*`,
|
|
commitCharacter: '*',
|
|
onReplace: (match) => match[1],
|
|
marks: [schema.marks.emphasis],
|
|
}),
|
|
makeInputRule({
|
|
contentRegex: `_(${inlineContentExp})_`,
|
|
commitCharacter: '_',
|
|
onReplace: (match) => match[1],
|
|
marks: [schema.marks.emphasis],
|
|
}),
|
|
makeInputRule({
|
|
contentRegex: `\`(${inlineContentExp})\``,
|
|
commitCharacter: '`',
|
|
onReplace: (match) => match[1],
|
|
marks: [schema.marks.code],
|
|
}),
|
|
], /[ .,?)!;]/),
|
|
];
|
|
export default inputRulesExtension;
|