prosemirror-autocomplete
Version:
Autocomplete suggestions for prosemirror
178 lines • 8.84 kB
JavaScript
import { undoInputRule } from 'prosemirror-inputrules';
import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { closeAutocomplete } from './actions';
import { KEEP_OPEN, ActionKind } from './types';
import { inSuggestion, pluginKey } from './utils';
const inactiveAutocompleteState = {
active: false,
decorations: DecorationSet.empty,
};
function actionFromEvent(event) {
switch (event.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
return event.key;
case 'Tab':
case 'Enter':
return ActionKind.enter;
case 'Escape':
return ActionKind.close;
default:
return null;
}
}
function cancelIfInsideAndPass(view) {
const plugin = pluginKey.get(view.state);
const { decorations } = plugin.getState(view.state);
if (inSuggestion(view.state.selection, decorations)) {
closeAutocomplete(view);
}
return false;
}
export function getDecorationPlugin(reducer) {
const plugin = new Plugin({
key: pluginKey,
view() {
return {
update: (view, prevState) => {
var _a, _b, _c, _d;
const prev = plugin.getState(prevState);
const next = plugin.getState(view.state);
const started = !prev.active && next.active;
const stopped = prev.active && !next.active;
const changed = next.active && !started && !stopped && prev.filter !== next.filter;
const action = {
view,
trigger: (_a = next.trigger) !== null && _a !== void 0 ? _a : prev.trigger,
filter: (_b = next.filter) !== null && _b !== void 0 ? _b : prev.filter,
range: (_c = next.range) !== null && _c !== void 0 ? _c : prev.range,
type: (_d = next.type) !== null && _d !== void 0 ? _d : prev.type,
};
if (started)
reducer(Object.assign(Object.assign({}, action), { kind: ActionKind.open }));
if (changed)
reducer(Object.assign(Object.assign({}, action), { kind: ActionKind.filter }));
if (stopped)
reducer(Object.assign(Object.assign({}, action), { kind: ActionKind.close }));
},
};
},
state: {
init: () => inactiveAutocompleteState,
apply(tr, state) {
var _a, _b, _c;
const meta = tr.getMeta(plugin);
if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'add') {
const { trigger, filter, type } = meta;
const from = tr.selection.from - trigger.length - ((_a = filter === null || filter === void 0 ? void 0 : filter.length) !== null && _a !== void 0 ? _a : 0);
const to = tr.selection.from;
const className = ((_b = type === null || type === void 0 ? void 0 : type.decorationAttrs) === null || _b === void 0 ? void 0 : _b.class)
? ['autocomplete', (_c = type === null || type === void 0 ? void 0 : type.decorationAttrs) === null || _c === void 0 ? void 0 : _c.class].join(' ')
: 'autocomplete';
const attrs = Object.assign(Object.assign({}, type === null || type === void 0 ? void 0 : type.decorationAttrs), { class: className });
const deco = Decoration.inline(from, to, attrs, {
inclusiveStart: false,
inclusiveEnd: true,
});
return {
active: true,
trigger: meta.trigger,
decorations: DecorationSet.create(tr.doc, [deco]),
filter: filter !== null && filter !== void 0 ? filter : '',
range: { from, to },
type,
};
}
const { decorations } = state;
const nextDecorations = decorations.map(tr.mapping, tr.doc);
const hasDecoration = nextDecorations.find().length > 0;
// If no decoration, explicitly remove, or click somewhere else in the editor
if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'remove' ||
!inSuggestion(tr.selection, nextDecorations) ||
!hasDecoration)
return inactiveAutocompleteState;
const { active, trigger, type } = state;
// Ensure that the trigger is in the decoration
const { from, to } = nextDecorations.find()[0];
const text = tr.doc.textBetween(from, to);
if (!text.startsWith(trigger))
return inactiveAutocompleteState;
return {
active,
trigger,
decorations: nextDecorations,
filter: text.slice(trigger.length),
range: { from, to },
type,
};
},
},
props: {
decorations: (state) => { var _a; return (_a = plugin.getState(state)) === null || _a === void 0 ? void 0 : _a.decorations; },
handlePaste: (view) => cancelIfInsideAndPass(view),
handleDrop: (view) => cancelIfInsideAndPass(view),
handleKeyDown(view, event) {
var _a, _b;
const { trigger, active, decorations, type } = plugin.getState(view.state);
if (!active || !inSuggestion(view.state.selection, decorations))
return false;
const { from, to } = decorations.find()[0];
const text = view.state.doc.textBetween(from, to);
// Be defensive, just in case the trigger doesn't exist
const filter = text.slice((_a = trigger === null || trigger === void 0 ? void 0 : trigger.length) !== null && _a !== void 0 ? _a : 1);
const checkCancelOnSpace = (_b = type === null || type === void 0 ? void 0 : type.cancelOnFirstSpace) !== null && _b !== void 0 ? _b : true;
if (checkCancelOnSpace &&
filter.length === 0 &&
(event.key === ' ' || event.key === 'Spacebar')) {
closeAutocomplete(view);
// Take over the space creation so no other input rules are fired
view.dispatch(view.state.tr.insertText(' ').scrollIntoView());
return true;
}
if (filter.length === 0 && event.key === 'Backspace') {
undoInputRule(view.state, view.dispatch);
closeAutocomplete(view);
return true;
}
const kind = actionFromEvent(event);
const action = {
view,
trigger,
filter,
range: { from, to },
type,
event,
};
switch (kind) {
case ActionKind.close:
// The user action will be handled in the view code above
// Allows clicking off to be handled in the same way
return closeAutocomplete(view);
case ActionKind.enter: {
// Only trigger the cancel if it is not expliticly handled in the select
const result = reducer(Object.assign(Object.assign({}, action), { kind: ActionKind.enter }));
if (result === KEEP_OPEN)
return true;
return result || closeAutocomplete(view);
}
case ActionKind.up:
case ActionKind.down:
return Boolean(reducer(Object.assign(Object.assign({}, action), { kind })));
case ActionKind.left:
case ActionKind.right:
if (!(type === null || type === void 0 ? void 0 : type.allArrowKeys))
return false;
return Boolean(reducer(Object.assign(Object.assign({}, action), { kind })));
default:
break;
}
return false;
},
},
});
return plugin;
}
//# sourceMappingURL=decoration.js.map