@atlaskit/editor-plugin-type-ahead
Version:
Type-ahead plugin for @atlaskit/editor-core
349 lines (347 loc) • 13.3 kB
JavaScript
/**
*
* Revamped typeahead using decorations instead of the `typeAheadQuery` mark
*
* https://product-fabric.atlassian.net/wiki/spaces/E/pages/2992177582/Technical+TypeAhead+Data+Flow
*
*
*/
import React from 'react';
import { typeAheadQuery } from '@atlaskit/adf-schema';
import { ACTION, ACTION_SUBJECT, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { SelectItemMode, TypeAheadAvailableNodes } from '@atlaskit/editor-common/type-ahead';
import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { fg } from '@atlaskit/platform-feature-flags';
import { closeTypeAhead } from './pm-plugins/commands/close-type-ahead';
import { insertTypeAheadItem } from './pm-plugins/commands/insert-type-ahead-item';
import { openTypeAheadAtCursor } from './pm-plugins/commands/open-typeahead-at-cursor';
import { inputRulePlugin } from './pm-plugins/input-rules';
import { createPlugin as createInsertItemPlugin } from './pm-plugins/insert-item-plugin';
import { pluginKey as typeAheadPluginKey } from './pm-plugins/key';
import { createPlugin } from './pm-plugins/main';
import { StatsModifier } from './pm-plugins/stats-modifier';
import { findHandler, getPluginState, getTypeAheadHandler, getTypeAheadQuery, isTypeAheadAllowed, isTypeAheadOpen } from './pm-plugins/utils';
import { ContentComponent } from './ui/ContentComponent';
const createOpenAtTransaction = editorAnalyticsAPI => props => tr => {
const {
triggerHandler,
inputMethod,
query,
removePrefixTriggerOnCancel
} = props;
openTypeAheadAtCursor({
triggerHandler,
inputMethod,
query,
removePrefixTriggerOnCancel
})({
tr
});
// This function is called from the editor-plugin-emoji and editor-plugin-type-ahead
// createOpenAtTransaction <- createOpenTypeAhead <- actions.open
// <- emoji-plugin (Not used)
// <- type-ahead-plugin (Used)
// and this caused the analytics event to be fired twice, as other places are relying on the
// `onEditorViewStateUpdated` method to fire the analytics event
// We want to disable this event
if (!fg('platform_editor_controls_patch_analytics_3')) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.INVOKED,
actionSubject: ACTION_SUBJECT.TYPEAHEAD,
actionSubjectId: triggerHandler.id,
attributes: {
inputMethod
},
eventType: EVENT_TYPE.UI
})(tr);
}
return true;
};
const createOpenTypeAhead = (editorViewRef, editorAnalyticsAPI) => props => {
if (!editorViewRef.current) {
return false;
}
const {
current: view
} = editorViewRef;
const {
tr
} = view.state;
createOpenAtTransaction(editorAnalyticsAPI)(props)(tr);
view.dispatch(tr);
return true;
};
const createInsertTypeAheadItem = editorViewRef => props => {
if (!editorViewRef.current) {
return false;
}
const {
current: view
} = editorViewRef;
const {
triggerHandler,
contentItem,
query,
sourceListItem,
mode
} = props;
insertTypeAheadItem(view)({
handler: triggerHandler,
item: contentItem,
mode: mode || SelectItemMode.SELECTED,
query,
sourceListItem
});
return true;
};
const createFindHandlerByTrigger = editorViewRef => trigger => {
if (!editorViewRef.current) {
return null;
}
const {
current: view
} = editorViewRef;
return findHandler(trigger, view.state);
};
const createCloseTypeAhead = editorViewRef => options => {
if (!editorViewRef.current) {
return false;
}
const {
current: view
} = editorViewRef;
const currentQuery = getTypeAheadQuery(view.state) || '';
const {
state
} = view;
let tr = state.tr;
if (options.attachCommand) {
const fakeDispatch = customTr => {
tr = customTr;
};
options.attachCommand(state, fakeDispatch);
}
closeTypeAhead(tr);
if (options.insertCurrentQueryAsRawText && currentQuery && currentQuery.length > 0) {
const handler = getTypeAheadHandler(state);
if (!handler) {
return false;
}
const text = handler.trigger.concat(currentQuery);
tr.replaceSelectionWith(state.schema.text(text));
}
view.dispatch(tr);
if (!view.hasFocus()) {
view.focus();
}
return true;
};
/**
*
* Revamped typeahead using decorations instead of the `typeAheadQuery` mark
*
* https://product-fabric.atlassian.net/wiki/spaces/E/pages/2992177582/Technical+TypeAhead+Data+Flow
*
*
*/
export const typeAheadPlugin = ({
api
}) => {
var _api$analytics, _api$analytics2;
const popupMountRef = {
current: null
};
const editorViewRef = {
current: null
};
return {
name: 'typeAhead',
marks() {
// We need to keep this to make sure
// All documents with typeahead marks will be loaded normally
return [{
name: 'typeAheadQuery',
mark: typeAheadQuery
}];
},
pmPlugins(typeAhead = []) {
return [{
name: 'typeAhead',
plugin: ({
dispatch,
getIntl,
nodeViewPortalProviderAPI
}) => createPlugin({
getIntl,
popupMountRef,
reactDispatch: dispatch,
typeAheadHandlers: typeAhead,
nodeViewPortalProviderAPI,
api
})
}, {
name: 'typeAheadEditorViewRef',
plugin: () => {
return new SafePlugin({
view(view) {
editorViewRef.current = view;
return {
destroy() {
editorViewRef.current = null;
}
};
}
});
}
}, {
name: 'typeAheadInsertItem',
plugin: createInsertItemPlugin
}, {
name: 'typeAheadInputRule',
plugin: ({
schema,
featureFlags
}) => inputRulePlugin(schema, typeAhead, featureFlags)
}];
},
getSharedState(editorState) {
var _state$decorationSet, _state$decorationElem, _state$items, _state$errorInfo, _state$selectedIndex;
if (!editorState) {
return {
query: '',
isOpen: false,
isAllowed: false,
currentHandler: undefined,
decorationSet: DecorationSet.empty,
decorationElement: null,
triggerHandler: undefined,
items: [],
errorInfo: null,
selectedIndex: 0
};
}
const isOpen = isTypeAheadOpen(editorState);
const state = getPluginState(editorState);
return {
query: getTypeAheadQuery(editorState) || '',
currentHandler: getTypeAheadHandler(editorState),
isOpen,
isAllowed: !isOpen,
decorationSet: (_state$decorationSet = state === null || state === void 0 ? void 0 : state.decorationSet) !== null && _state$decorationSet !== void 0 ? _state$decorationSet : DecorationSet.empty,
decorationElement: (_state$decorationElem = state === null || state === void 0 ? void 0 : state.decorationElement) !== null && _state$decorationElem !== void 0 ? _state$decorationElem : null,
triggerHandler: state === null || state === void 0 ? void 0 : state.triggerHandler,
items: (_state$items = state === null || state === void 0 ? void 0 : state.items) !== null && _state$items !== void 0 ? _state$items : [],
errorInfo: (_state$errorInfo = state === null || state === void 0 ? void 0 : state.errorInfo) !== null && _state$errorInfo !== void 0 ? _state$errorInfo : null,
selectedIndex: (_state$selectedIndex = state === null || state === void 0 ? void 0 : state.selectedIndex) !== null && _state$selectedIndex !== void 0 ? _state$selectedIndex : 0
};
},
actions: {
isOpen: isTypeAheadOpen,
isAllowed: isTypeAheadAllowed,
open: createOpenTypeAhead(editorViewRef, api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions),
openAtTransaction: createOpenAtTransaction(api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions),
findHandlerByTrigger: createFindHandlerByTrigger(editorViewRef),
insert: createInsertTypeAheadItem(editorViewRef),
close: createCloseTypeAhead(editorViewRef)
},
contentComponent({
editorView,
containerElement,
popupsMountPoint,
popupsBoundariesElement,
popupsScrollableElement,
wrapperElement
}) {
popupMountRef.current = {
popupsMountPoint: popupsMountPoint || wrapperElement || undefined,
popupsBoundariesElement,
popupsScrollableElement: popupsScrollableElement || containerElement || undefined
};
if (!editorView) {
return null;
}
return /*#__PURE__*/React.createElement(ContentComponent, {
editorView: editorView,
popupMountRef: popupMountRef,
api: api
});
},
onEditorViewStateUpdated({
originalTransaction,
oldEditorState,
newEditorState
}) {
const oldPluginState = getPluginState(oldEditorState);
const newPluginState = getPluginState(newEditorState);
if (!oldPluginState || !newPluginState) {
return;
}
const {
triggerHandler: oldTriggerHandler
} = oldPluginState;
const {
triggerHandler: newTriggerHandler
} = newPluginState;
const isANewHandler = oldTriggerHandler !== newTriggerHandler;
if (oldTriggerHandler !== null && oldTriggerHandler !== void 0 && oldTriggerHandler.dismiss && isANewHandler) {
const typeAheadMessage = originalTransaction.getMeta(typeAheadPluginKey);
const wasItemInserted = typeAheadMessage && typeAheadMessage.action === 'INSERT_RAW_QUERY';
oldTriggerHandler.dismiss({
editorState: newEditorState,
query: oldPluginState.query,
stats: (oldPluginState.stats || new StatsModifier()).serialize(),
wasItemInserted
});
}
if (newTriggerHandler !== null && newTriggerHandler !== void 0 && newTriggerHandler.onOpen && isANewHandler) {
if (fg('platform_editor_ease_of_use_metrics')) {
var _api$metrics;
api === null || api === void 0 ? void 0 : (_api$metrics = api.metrics) === null || _api$metrics === void 0 ? void 0 : _api$metrics.commands.handleIntentToStartEdit({
shouldStartTimer: false,
shouldPersistActiveSession: true
});
}
newTriggerHandler.onOpen(newEditorState);
}
const oldIsOpen = isTypeAheadOpen(oldEditorState);
const newIsOpen = isTypeAheadOpen(newEditorState);
if (newTriggerHandler && isANewHandler) {
// if the typeahead opens another typeahead via the quickInsert we do NOT want to fire this analytic event (mentions and emojis) as it is already being fired from editor-plugin-analytics
const isDuplicateInvokedEvent = newPluginState.inputMethod === INPUT_METHOD.QUICK_INSERT && Object.values(TypeAheadAvailableNodes).includes(newTriggerHandler.id);
if (!isDuplicateInvokedEvent) {
if (fg('platform_editor_controls_patch_analytics_3')) {
var _api$analytics3, _api$analytics3$actio;
api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : (_api$analytics3$actio = _api$analytics3.actions) === null || _api$analytics3$actio === void 0 ? void 0 : _api$analytics3$actio.fireAnalyticsEvent({
action: ACTION.INVOKED,
actionSubject: ACTION_SUBJECT.TYPEAHEAD,
actionSubjectId: newTriggerHandler.id || 'not_set',
attributes: {
inputMethod: newPluginState.inputMethod || INPUT_METHOD.KEYBOARD
},
eventType: EVENT_TYPE.UI
}, undefined, {
context: {
selection: newEditorState.selection
}
});
} else {
var _api$analytics4, _api$analytics4$actio;
api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : (_api$analytics4$actio = _api$analytics4.actions) === null || _api$analytics4$actio === void 0 ? void 0 : _api$analytics4$actio.fireAnalyticsEvent({
action: ACTION.INVOKED,
actionSubject: ACTION_SUBJECT.TYPEAHEAD,
actionSubjectId: newTriggerHandler.id || 'not_set',
attributes: {
inputMethod: newPluginState.inputMethod || INPUT_METHOD.KEYBOARD
},
eventType: EVENT_TYPE.UI
});
}
}
} else if (oldIsOpen && !newIsOpen && fg('platform_editor_ease_of_use_metrics')) {
var _api$metrics2;
api === null || api === void 0 ? void 0 : api.core.actions.execute(api === null || api === void 0 ? void 0 : (_api$metrics2 = api.metrics) === null || _api$metrics2 === void 0 ? void 0 : _api$metrics2.commands.startActiveSessionTimer());
}
}
};
};