UNPKG

@atlaskit/editor-plugin-type-ahead

Version:

Type-ahead plugin for @atlaskit/editor-core

349 lines (347 loc) 13.3 kB
/** * * 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()); } } }; };