@atlaskit/editor-plugin-base
Version:
Base plugin for @atlaskit/editor-core
194 lines (192 loc) • 8.36 kB
JavaScript
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());
}