UNPKG

@atlaskit/editor-plugin-toolbar

Version:

Toolbar plugin for @atlaskit/editor-core

218 lines 7.82 kB
import { ACTION_SUBJECT_ID } from '@atlaskit/editor-common/analytics'; import { containsPopupWithNestedElement, Experience, EXPERIENCE_ID, ExperienceCheckDomMutation, ExperienceCheckTimeout, getPopupContainerFromEditorView } from '@atlaskit/editor-common/experiences'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { fg } from '@atlaskit/platform-feature-flags/fg'; 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 }) => { const isIntentFixEnabled = fg('platform_editor_toolbar_intent_fix'); 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 (isIntentFixEnabled && 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) || isIntentFixEnabled && 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 }; if (isIntentFixEnabled) { mouseDownSelection = view.state.selection; } }, mouseup: (view, e) => { const initialMouseDownPos = mouseDownPos; const selectionChanged = hasSelectionChanged(mouseDownSelection, view.state.selection); if (isIntentFixEnabled) { mouseDownPos = undefined; mouseDownSelection = undefined; } if (!initialMouseDownPos || shouldSkipExperienceStart(view.state.selection)) { return; } const mouseCoordinatesChanged = e.clientX !== initialMouseDownPos.x || e.clientY !== initialMouseDownPos.y; if (mouseCoordinatesChanged && (!isIntentFixEnabled || 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 = (isIntentFixEnabled ? isPlainShiftArrowKey(event) : event.shiftKey && event.key.includes('Arrow')) && !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'; };