UNPKG

@atlaskit/editor-plugin-toolbar

Version:

Toolbar plugin for @atlaskit/editor-core

216 lines • 7.75 kB
import { ACTION_SUBJECT_ID } from '@atlaskit/editor-common/analytics/types/enums'; import { containsPopupWithNestedElement, getPopupContainerFromEditorView } from '@atlaskit/editor-common/experiences'; import { EXPERIENCE_ID } from '@atlaskit/editor-common/experiences/consts'; import { Experience } from '@atlaskit/editor-common/experiences/Experience'; import { ExperienceCheckDomMutation } from '@atlaskit/editor-common/experiences/ExperienceCheckDomMutation'; import { ExperienceCheckTimeout } from '@atlaskit/editor-common/experiences/ExperienceCheckTimeout'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { hasSelectionChanged, isPlainShiftArrowKey, isSelectionToolbarSuppressedByUserIntent } from '../../ui/SelectionToolbar/visibility'; const pluginKey = new PluginKey('selectionToolbarOpenExperience'); const START_METHOD = { MOUSE_UP: 'mouseUp', KEY_DOWN: 'keyDown' }; const ABORT_REASON = { SELECTION_CLEARED: 'selectionCleared', BLOCK_MENU_OPENED: 'blockMenuOpened', EDITOR_DESTROYED: 'editorDestroyed', USER_INTENT_SUPPRESSED: 'userIntentSuppressed' }; /** * This experience tracks when the selection toolbar is opened. * * Start: When user makes a selection via mouseup or shift+arrow key down * Success: When the selection toolbar is added to the DOM within 1000ms of start * Failure: When 1000ms passes without the selection toolbar being added to the DOM * Abort: When selection transitions to empty or block menu is opened * * @see https://hello.atlassian.net/wiki/spaces/EDITOR/pages/6262117789/Experience+tracking+Selection+toolbar+open */ export const getSelectionToolbarOpenExperiencePlugin = ({ refs, dispatchAnalyticsEvent, getCurrentUserIntent // eslint-disable-next-line @typescript-eslint/no-empty-object-type }) => { let editorView; let targetEl; let shiftArrowKeyPressed = false; let mouseDownPos; let mouseDownSelection; const getTarget = () => { if (!targetEl) { var _editorView; targetEl = refs.popupsMountPoint || getPopupContainerFromEditorView((_editorView = editorView) === null || _editorView === void 0 ? void 0 : _editorView.dom); } return targetEl; }; const isCurrentUserIntentSuppressingToolbar = selection => { if (!selection) { return false; } const isCellSelection = !selection.empty && '$anchorCell' in selection; return isSelectionToolbarSuppressedByUserIntent(getCurrentUserIntent === null || getCurrentUserIntent === void 0 ? void 0 : getCurrentUserIntent(), isCellSelection); }; const experience = new Experience(EXPERIENCE_ID.TOOLBAR_OPEN, { actionSubjectId: ACTION_SUBJECT_ID.SELECTION_TOOLBAR, dispatchAnalyticsEvent, checks: [new ExperienceCheckTimeout({ durationMs: 1000, onTimeout: () => { var _editorView2, _editorView3; if (isCurrentUserIntentSuppressingToolbar((_editorView2 = editorView) === null || _editorView2 === void 0 ? void 0 : _editorView2.state.selection)) { return { status: 'abort', reason: ABORT_REASON.USER_INTENT_SUPPRESSED }; } else if (isBlockMenuWithinNode(getTarget())) { return { status: 'abort', reason: ABORT_REASON.BLOCK_MENU_OPENED }; } else if (isSelectionWithoutTextContent((_editorView3 = editorView) === null || _editorView3 === void 0 ? void 0 : _editorView3.state.selection)) { return { status: 'abort', reason: ABORT_REASON.SELECTION_CLEARED }; } } }), new ExperienceCheckDomMutation({ onDomMutation: ({ mutations }) => { if (mutations.some(isSelectionToolbarAddedInMutation)) { return { status: 'success' }; } }, observeConfig: () => ({ target: getTarget(), options: { childList: true } }) })] }); const shouldSkipExperienceStart = selection => { if (isSelectionWithoutTextContent(selection) || isSelectionWithinCodeBlock(selection) || isCurrentUserIntentSuppressingToolbar(selection)) { return true; } const target = getTarget(); if (!target) { // when target is not found, skip the experience start return true; } return isSelectionToolbarWithinNode(target) || isBlockMenuWithinNode(target); }; return new SafePlugin({ key: pluginKey, state: { init: () => ({}), apply: (_tr, pluginState, oldState, newState) => { if (!oldState.selection.empty && isSelectionWithoutTextContent(newState.selection)) { experience.abort({ reason: ABORT_REASON.SELECTION_CLEARED }); } const shouldStartExperience = shiftArrowKeyPressed && !newState.selection.eq(oldState.selection) && !shouldSkipExperienceStart(newState.selection); if (shouldStartExperience) { experience.start({ method: START_METHOD.KEY_DOWN }); shiftArrowKeyPressed = false; } return pluginState; } }, props: { handleDOMEvents: { mousedown: (view, e) => { mouseDownPos = { x: e.clientX, y: e.clientY }; mouseDownSelection = view.state.selection; }, mouseup: (view, e) => { const initialMouseDownPos = mouseDownPos; const selectionChanged = hasSelectionChanged(mouseDownSelection, view.state.selection); mouseDownPos = undefined; mouseDownSelection = undefined; if (!initialMouseDownPos || shouldSkipExperienceStart(view.state.selection)) { return; } const mouseCoordinatesChanged = e.clientX !== initialMouseDownPos.x || e.clientY !== initialMouseDownPos.y; if (mouseCoordinatesChanged && selectionChanged) { experience.start({ method: START_METHOD.MOUSE_UP }); } }, dblclick: view => { if (shouldSkipExperienceStart(view.state.selection)) { return; } experience.start({ method: START_METHOD.MOUSE_UP }); }, keydown: (_view, event) => { shiftArrowKeyPressed = isPlainShiftArrowKey(event) && !isSelectionToolbarWithinNode(getTarget()); }, keyup: () => { shiftArrowKeyPressed = false; } } }, view: view => { editorView = view; return { destroy: () => { experience.abort({ reason: ABORT_REASON.EDITOR_DESTROYED }); } }; } }); }; const isSelectionToolbarAddedInMutation = ({ type, addedNodes }) => { return type === 'childList' && [...addedNodes].some(isSelectionToolbarWithinNode); }; const isSelectionToolbarWithinNode = node => { return containsPopupWithNestedElement(node, '[data-testid="editor-floating-toolbar"]'); }; const isBlockMenuWithinNode = node => { return containsPopupWithNestedElement(node, '[data-testid="editor-block-menu"]'); }; const isSelectionWithoutTextContent = selection => { if (!selection || selection.empty) { return true; } let hasText = false; selection.$from.doc.nodesBetween(selection.from, selection.to, node => { if (hasText) { return false; } if (node.isText && node.text && node.text.length > 0) { hasText = true; return false; } return true; }); return !hasText; }; const isSelectionWithinCodeBlock = selection => { const { $from, $to } = selection; return $from.sameParent($to) && $from.parent.type.name === 'codeBlock'; };