UNPKG

@atlaskit/editor-plugin-type-ahead

Version:

Type-ahead plugin for @atlaskit/editor-core

480 lines (473 loc) 19.5 kB
/** * @jsxRuntime classic * @jsx jsx */ import React, { Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; // eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled, @typescript-eslint/consistent-type-imports import { css, jsx } from '@emotion/react'; import { useIntl } from 'react-intl'; import { keyName as keyNameNormalized } from 'w3c-keyname'; import { getBrowserInfo } from '@atlaskit/editor-common/browser'; import { SelectItemMode, typeAheadListMessages } from '@atlaskit/editor-common/type-ahead'; import { AssistiveText } from '@atlaskit/editor-common/ui'; import { findParentNodeOfType } from '@atlaskit/editor-prosemirror/utils'; import { blockNodesVerticalMargin } from '@atlaskit/editor-shared-styles'; import { fg } from '@atlaskit/platform-feature-flags'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { CloseSelectionOptions, TYPE_AHEAD_DECORATION_ELEMENT_ID, TYPE_AHEAD_POPUP_CONTENT_CLASS } from '../pm-plugins/constants'; import { getPluginState } from '../pm-plugins/utils'; const placeholderStyles = css({ '&::after': { content: 'attr(data-place-holder)', color: "var(--ds-text-subtlest, #6B6E76)", position: 'relative', padding: "var(--ds-space-025, 2px)", left: "var(--ds-space-negative-050, -4px)", backgroundColor: "var(--ds-background-neutral, #0515240F)", // eslint-disable-next-line @atlaskit/design-system/no-unsafe-design-token-usage borderRadius: "var(--ds-radius-small, 3px)" } }); const queryWithoutPlaceholderStyles = css({ '&::after': { content: `''` } }); const querySpanStyles = css({ outline: 'none', // eslint-disable-next-line @atlaskit/ui-styling-standard/no-nested-selectors -- Ignored via go/DSP-18766 '& input': { width: '5px', border: 'none', background: 'transparent', padding: 0, margin: 0, // TODO: ED-17022 - Fixes firefox caret position // Do not migrate font when em is used as unit // eslint-disable-next-line @atlaskit/design-system/use-tokens-typography fontSize: '1em', // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values -- Ignored via go/DSP-18766 height: blockNodesVerticalMargin, caretColor: "var(--ds-text-accent-blue, #1558BC)" } }); const isNavigationKey = event => { return ['Enter', 'Tab', 'ArrowDown', 'ArrowUp'].includes(event.key); }; const isUndoRedoShortcut = event => { const key = keyNameNormalized(event); if (event.ctrlKey && key === 'y') { return 'historyRedo'; } if ((event.ctrlKey || event.metaKey) && event.shiftKey && key === 'Z') { return 'historyRedo'; } if ((event.ctrlKey || event.metaKey) && key === 'z') { return 'historyUndo'; } return false; }; const getAriaLabel = (triggerPrefix, _intl) => { switch (triggerPrefix) { case '@': return typeAheadListMessages.mentionInputLabel; case '/': return typeAheadListMessages.quickInsertInputLabel; case ':': return typeAheadListMessages.emojiInputLabel; default: return typeAheadListMessages.quickInsertInputLabel; } }; export const InputQuery = /*#__PURE__*/React.memo(({ triggerQueryPrefix, cancel, onQueryChange, onItemSelect, selectNextItem, selectPreviousItem, forceFocus, reopenQuery, onQueryFocus, onUndoRedo, editorView, items }) => { const ref = useRef(document.createElement('span')); const inputRef = useRef(null); const [query, setQuery] = useState(null); const isEditorControlsEnabled = editorExperiment('platform_editor_controls', 'variant1'); const isSearchPlaceholderEnabled = editorExperiment('platform_editor_controls', 'variant1') && fg('platform_editor_quick_insert_placeholder'); const selection = editorView.state.selection; const { table } = editorView.state.schema.nodes; const [showPlaceholder, setShowPlaceholder] = useState(isSearchPlaceholderEnabled && triggerQueryPrefix === '/' && // When triggered in very narrow table column, placeholder becomes ellipsis only // hence we disable it for now and revisit this scenario in ED-27480 !findParentNodeOfType(table)(selection)); const cleanedInputContent = useCallback(() => { var _ref$current; const raw = ((_ref$current = ref.current) === null || _ref$current === void 0 ? void 0 : _ref$current.textContent) || ''; return raw; }, []); const onKeyUp = useCallback(_event => { const text = cleanedInputContent(); onQueryChange(text); }, [onQueryChange, cleanedInputContent]); const onInput = useCallback(() => { if (cleanedInputContent()) { setShowPlaceholder(false); } }, [cleanedInputContent]); const [isInFocus, setInFocus] = useState(false); const checkKeyEvent = useCallback(event => { var _ref$current2; const key = keyNameNormalized(event); const sel = document.getSelection(); const raw = ((_ref$current2 = ref.current) === null || _ref$current2 === void 0 ? void 0 : _ref$current2.textContent) || ''; const text = cleanedInputContent(); let stopDefault = false; const { selectedIndex, removePrefixTriggerOnCancel } = getPluginState(editorView.state) || {}; setInFocus(true); switch (key) { case ' ': // space key if (text.length === 0) { cancel({ forceFocusOnEditor: true, text: ' ', addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true, setSelectionAt: CloseSelectionOptions.AFTER_TEXT_INSERTED }); stopDefault = true; } break; case 'Escape': case 'PageUp': case 'PageDown': case 'Home': cancel({ text, forceFocusOnEditor: true, addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true, setSelectionAt: CloseSelectionOptions.AFTER_TEXT_INSERTED }); stopDefault = true; break; case 'Backspace': if (raw.length === 0 || (sel === null || sel === void 0 ? void 0 : sel.anchorOffset) === 0) { event.stopPropagation(); event.preventDefault(); cancel({ forceFocusOnEditor: true, text, addPrefixTrigger: false, setSelectionAt: CloseSelectionOptions.BEFORE_TEXT_INSERTED }); } break; case 'Enter': // TODO: ED-14758 - Under the W3C specification, any keycode sent under IME would return a keycode 229 // event.isComposing can't be used alone as this also included a virtual keyboard under a keyboardless device, therefore, it seems the best practice would be intercepting the event as below. // Some suggested the other workaround maybe listen on`keypress` instead of `keydown` if (!event.isComposing && event.which !== 229 && event.keyCode !== 229) { if (selectedIndex === -1) { /** * TODO DTR-1401: (also see ED-17200) There are two options * here, either * - set the index directly to 1 in WrapperTypeAhead.tsx's * `insertSelectedItem` at the cost of breaking some of the a11y * focus changes, * - or do this jank at the cost of some small analytics noise. * * The focus behaviour still needs cleanup */ selectPreviousItem(); selectNextItem(); } onItemSelect(event.shiftKey ? SelectItemMode.SHIFT_ENTER : SelectItemMode.ENTER); } break; case 'Tab': event.shiftKey ? selectPreviousItem() : selectNextItem(); break; case 'ArrowDown': selectNextItem(); break; case 'ArrowUp': selectPreviousItem(); break; } const undoRedoType = isUndoRedoShortcut(event); if (onUndoRedo && undoRedoType && onUndoRedo(undoRedoType)) { stopDefault = true; } if (isNavigationKey(event) || stopDefault) { event.stopPropagation(); event.preventDefault(); return false; } }, [onUndoRedo, onItemSelect, selectNextItem, selectPreviousItem, cancel, cleanedInputContent, editorView.state, isEditorControlsEnabled]); useLayoutEffect(() => { if (!ref.current) { return; } const browser = getBrowserInfo(); const { current: element } = ref; const { removePrefixTriggerOnCancel } = getPluginState(editorView.state) || {}; const onFocusIn = _event => { onQueryFocus(); }; const keyDown = event => { const key = keyNameNormalized(event); if (['ArrowLeft', 'ArrowRight'].includes(key) && document.getSelection && document.getSelection()) { var _ref$current3; const q = ((_ref$current3 = ref.current) === null || _ref$current3 === void 0 ? void 0 : _ref$current3.textContent) || ''; const sel = document.getSelection(); const isMovingRight = sel && 'ArrowRight' === key && sel.anchorOffset === q.length; const isMovingLeft = sel && 'ArrowLeft' === key && (sel.anchorOffset === 0 || event.metaKey); if (!isMovingRight && !isMovingLeft) { return; } cancel({ forceFocusOnEditor: true, addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true, text: cleanedInputContent(), setSelectionAt: isMovingRight ? CloseSelectionOptions.AFTER_TEXT_INSERTED : CloseSelectionOptions.BEFORE_TEXT_INSERTED }); event.preventDefault(); event.stopPropagation(); return; } checkKeyEvent(event); }; const onPaste = event => { var _event$clipboardData, _event$clipboardData2; const html = (_event$clipboardData = event.clipboardData) === null || _event$clipboardData === void 0 ? void 0 : _event$clipboardData.getData('text/html'); const plainText = (_event$clipboardData2 = event.clipboardData) === null || _event$clipboardData2 === void 0 ? void 0 : _event$clipboardData2.getData('text/plain'); if (html && plainText) { event.preventDefault(); // insert the plain text into the type-ahead input field const selection = window.getSelection(); if (selection && ref.current) { if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(plainText)); range.collapse(false); } } } }; const onFocusOut = event => { var _window$getSelection; const { relatedTarget } = event; // Given the user is changing the focus // When the target is inside the TypeAhead Popup // Then the popup should stay open if (relatedTarget instanceof HTMLElement && relatedTarget.closest && relatedTarget.closest(`.${TYPE_AHEAD_POPUP_CONTENT_CLASS}`)) { return; } // Chrome and Edge may emit focusout events without direct input. // This path handles dismissals that don't involve item selection, so we ignore these events. // In Edge this also lead to duplication in the trigger character (@, /, :) as `cancel` would be called twice if ((browser.ie || browser.chrome) && !(((_window$getSelection = window.getSelection()) === null || _window$getSelection === void 0 ? void 0 : _window$getSelection.type) === 'Range') && // eslint-disable-next-line @typescript-eslint/no-explicit-any !event.sourceCapabilities) { return; } cancel({ addPrefixTrigger: isEditorControlsEnabled ? !removePrefixTriggerOnCancel : true, text: cleanedInputContent(), setSelectionAt: CloseSelectionOptions.BEFORE_TEXT_INSERTED, forceFocusOnEditor: false }); }; const close = () => { cancel({ addPrefixTrigger: false, text: '', forceFocusOnEditor: true, setSelectionAt: CloseSelectionOptions.BEFORE_TEXT_INSERTED }); }; const beforeinput = e => { var _target$textContent; setInFocus(false); const { target } = e; if (e.isComposing || !(target instanceof HTMLElement)) { return; } if (e.inputType === 'historyUndo' && ((_target$textContent = target.textContent) === null || _target$textContent === void 0 ? void 0 : _target$textContent.length) === 0) { e.preventDefault(); e.stopPropagation(); close(); return; } if (e.data != null && inputRef.current === null) { setQuery(''); // We need to change the content on Safari // and set the cursor at the right place if (browser.safari) { e.preventDefault(); const dataElement = document.createTextNode(e.data); element.appendChild(dataElement); const sel = window.getSelection(); const range = document.createRange(); range.setStart(dataElement, dataElement.length); range.collapse(true); sel === null || sel === void 0 ? void 0 : sel.removeAllRanges(); sel === null || sel === void 0 ? void 0 : sel.addRange(range); } } }; const onInput = () => {}; if (browser.safari) { // On Safari, for reasons beyond my understanding, // The undo behavior is totally different from other browsers // That why we need to have an specific branch only for Safari. const onInput = e => { var _target$textContent2; const { target } = e; if (!(e instanceof InputEvent) || e.isComposing || !(target instanceof HTMLElement)) { return; } if (e.inputType === 'historyUndo' && ((_target$textContent2 = target.textContent) === null || _target$textContent2 === void 0 ? void 0 : _target$textContent2.length) === 1) { e.preventDefault(); e.stopPropagation(); close(); return; } }; // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.addEventListener('input', onInput); } // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.addEventListener('focusout', onFocusOut); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.addEventListener('focusin', onFocusIn); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.addEventListener('keydown', keyDown); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.addEventListener('beforeinput', beforeinput); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.addEventListener('paste', onPaste); return () => { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.removeEventListener('focusout', onFocusOut); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.removeEventListener('focusin', onFocusIn); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.removeEventListener('keydown', keyDown); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.removeEventListener('beforeinput', beforeinput); // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.removeEventListener('paste', onPaste); if (browser.safari) { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element.removeEventListener('input', onInput); } }; }, [triggerQueryPrefix, cleanedInputContent, onQueryFocus, cancel, checkKeyEvent, editorView.state, isEditorControlsEnabled]); useLayoutEffect(() => { const hasReopenQuery = typeof reopenQuery === 'string' && reopenQuery.trim().length > 0; if (ref.current && forceFocus) { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion setQuery(hasReopenQuery ? reopenQuery : null); requestAnimationFrame(() => { if (!(ref !== null && ref !== void 0 && ref.current)) { return; } const sel = window.getSelection(); if (sel && hasReopenQuery && ref.current.lastChild instanceof Text) { const lastChild = ref.current.lastChild; const range = document.createRange(); range.setStart(ref.current.lastChild, lastChild.length); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } ref.current.focus(); setInFocus(true); }); } }, [forceFocus, reopenQuery]); const classNames = useMemo(() => { const classes = []; if (showPlaceholder) { // to avoid the placeholder wrapped to next line when triggered at the end of the line // see placeholderWrapStyles in editor-core/src/ui/ContentStyles/index.tsx classes.push('placeholder-decoration-wrap'); if (selection.$from.depth > 1) { // to hide placeholder overflow as ellipsis // see placeholderWrapStyles in editor-core/src/ui/ContentStyles/index.tsx classes.push('placeholder-decoration-hide-overflow'); } } return classes.join(' '); }, [showPlaceholder, selection]); const assistiveHintID = TYPE_AHEAD_DECORATION_ELEMENT_ID + '__assistiveHint'; const intl = useIntl(); return jsx(Fragment, null, triggerQueryPrefix, jsx("span", { css: [querySpanStyles, isSearchPlaceholderEnabled && queryWithoutPlaceholderStyles, showPlaceholder && placeholderStyles], contentEditable: true, ref: ref, onKeyUp: onKeyUp, tabIndex: -1, onInput: isSearchPlaceholderEnabled ? onInput : undefined, role: "combobox", "aria-controls": TYPE_AHEAD_DECORATION_ELEMENT_ID, "aria-autocomplete": "list", "aria-expanded": items.length !== 0, "aria-labelledby": assistiveHintID, suppressContentEditableWarning: true, "data-query-prefix": triggerQueryPrefix // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop , className: classNames, "data-place-holder": intl.formatMessage(typeAheadListMessages.quickInsertInputPlaceholderLabel) }, query === null ? jsx("input", { ref: inputRef, type: "text", "aria-label": intl.formatMessage(getAriaLabel(triggerQueryPrefix, intl)) }) : query), jsx("span", { id: assistiveHintID, style: { display: 'none' } }, intl.formatMessage(typeAheadListMessages.inputQueryAssistiveLabel)), jsx(AssistiveText, { assistiveText: items.length === 0 ? intl.formatMessage(typeAheadListMessages.noSearchResultsLabel, { itemsLength: items.length }) : '', isInFocus: items.length === 0 || isInFocus, id: TYPE_AHEAD_DECORATION_ELEMENT_ID })); }); InputQuery.displayName = 'InputQuery';