UNPKG

@atlaskit/editor-plugin-base

Version:

Base plugin for @atlaskit/editor-core

194 lines (192 loc) 8.36 kB
import { getBrowserInfo } from '@atlaskit/editor-common/browser'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { isTextSelection } from '@atlaskit/editor-common/utils'; import { ZERO_WIDTH_SPACE } from '@atlaskit/editor-common/whitespace'; import { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view'; export const inlineCursorTargetStateKey = new PluginKey('inlineCursorTargetPlugin'); const isInlineNodeView = node => { /** * If inlineNodeView is a hardbreak we don't want to add decorations * as it breaks soft line breaks for Japanese/Chinese keyboards. */ const isHardBreak = (node === null || node === void 0 ? void 0 : node.type) === (node === null || node === void 0 ? void 0 : node.type.schema.nodes.hardBreak); return node && node.type.isInline && !node.type.isText && !isHardBreak; }; export default (() => { return new SafePlugin({ key: inlineCursorTargetStateKey, state: { init: () => ({ cursorTarget: undefined }), apply(tr) { const { selection, doc } = tr; const { $from, $to } = selection; // In Safari, if the cursor target is to the right of the cursor it will block selections // made with shift + arrowRight and vice versa for shift + arrowLeft. This is due to a // contenteditable bug in safari, where editable elements block the selection but we need // the cursor target to be editable for the following: // - Ability to navigate with down/up arrows when between inline nodes // - Ability to navigate with down/up arrows when between the start of a paragraph & an inline node // - Ability to click and drag to select an inline node if it is the first node // To prevent blocking the selection, we check handleDOMEvents and add meta to // the transaction to prevent the plugin from making cursor target decorations. const safariShiftSelection = tr.getMeta(inlineCursorTargetStateKey); if (selection && isTextSelection(selection) && !safariShiftSelection) { const hasInlineNodeViewAfter = isInlineNodeView($from.nodeAfter); const hasInlineNodeViewBefore = isInlineNodeView($from.nodeBefore); const isAtStartAndInlineNodeViewAfter = $from.parentOffset === 0 && isInlineNodeView($from.nodeAfter); const isAtEndAndInlineNodeViewBefore = doc.resolve($from.pos).node().lastChild === $from.nodeBefore && isInlineNodeView($from.nodeBefore); const createWidget = side => { const node = document.createElement('span'); node.contentEditable = 'true'; node.setAttribute('aria-hidden', 'true'); node.appendChild(document.createTextNode(ZERO_WIDTH_SPACE)); node.className = 'cursor-target'; const { $from } = selection; const rightPosition = $from.pos; const leftPosition = $from.posAtIndex(Math.max($from.index() - 1, 0)); const widgetPos = side === 'left' ? leftPosition : rightPosition; return Decoration.widget(widgetPos, node, { raw: true, key: 'inlineCursor' // eslint-disable-next-line @typescript-eslint/no-explicit-any }); }; // Create editable decoration widgets around the current inline node to allow proper cursor navigation. if ((hasInlineNodeViewAfter || isAtEndAndInlineNodeViewBefore) && (hasInlineNodeViewBefore || isAtStartAndInlineNodeViewAfter)) { return { cursorTarget: { decorations: [createWidget('left'), createWidget('right')], positions: { from: $from.pos, to: $to.pos } } }; } } return { cursorTarget: undefined }; } }, props: { decorations(state) { const { doc } = state; const { cursorTarget } = inlineCursorTargetStateKey.getState(state); if (cursorTargetHasValidDecorations(cursorTarget)) { return DecorationSet.create(doc, cursorTarget.decorations); } return null; }, handleDOMEvents: { // Workaround to prevent the decorations created by the plugin from // blocking shift + arrow left/right selections in safari. When // a shift + arrow left/right event is detected, send meta data to the // plugin to prevent it from creating decorations. // TODO: ED-26959 - We may be able to remove this when playing the following ticket: // https://product-fabric.atlassian.net/browse/ED-14938 keydown: (view, event) => { const browser = getBrowserInfo(); if (browser.safari && event instanceof KeyboardEvent && event.shiftKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { view.dispatch(view.state.tr.setMeta(inlineCursorTargetStateKey, { cursorTarget: undefined })); } return false; }, // Check the DOM to see if there are inline cursor targets // after a composition event ends. If so, manually insert the // event data in order to prevent contents ending up inside // of the cursor target decorations. compositionend: (view, incorrectlyTypedEvent) => { // This is typed by the prosemirror definitions as Event, // this type is incorrect, and it is actually an InputEvent const event = incorrectlyTypedEvent; const { state } = view; const { cursorTarget } = inlineCursorTargetStateKey.getState(state); if (cursorTarget !== undefined) { handleTextInputInsideCursorTargetDecoration({ event, cursorTarget, view }); return true; } return false; }, // Check the DOM to see if there are inline cursor targets // before any input event. If so, manually insert the // event data in order to prevent contents ending up inside // of the cursor target decorations. beforeinput: (view, incorrectlyTypedEvent) => { // This is typed by the prosemirror definitions as Event, // this type is incorrect, and it is actually an InputEvent const event = incorrectlyTypedEvent; const { state } = view; const { cursorTarget } = inlineCursorTargetStateKey.getState(state); if (!event.isComposing && cursorTarget !== undefined) { handleTextInputInsideCursorTargetDecoration({ event, cursorTarget, view }); return true; } return false; } } } }); }); function cursorTargetHasValidDecorations(cursorTarget) { if (!cursorTarget || // Decorations can end up as null when the decorations prop is // called after the decorations have been removed from the dom. // https://github.com/ProseMirror/prosemirror-view/blob/8f0d313a6389b86a335274fba36534ba1cb21f12/src/decoration.js#L30 cursorTarget.decorations.includes(null)) { return false; } return true; } function handleTextInputInsideCursorTargetDecoration({ event, view, cursorTarget }) { event.stopPropagation(); event.preventDefault(); const content = event.data || ''; // ensure any custom handleTextInput handlers are called for the input event // ie. type ahead, emoji shortcuts. const defaultTransaction = () => view.state.tr.insertText(content, cursorTarget.positions.from, cursorTarget.positions.to); const potentiallyHandleByHandleTextInput = view.someProp('handleTextInput', f => f(view, cursorTarget.positions.from, cursorTarget.positions.to, content, defaultTransaction)); if (potentiallyHandleByHandleTextInput) { // if a handleTextInput handler has handled the event, we don't want to // manually update the document. return; } view.dispatch(defaultTransaction()); }