@atlaskit/editor-plugin-extension
Version:
editor-plugin-extension plugin for @atlaskit/editor-core
292 lines (287 loc) • 14.6 kB
JavaScript
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { createSelectionClickHandler, GapCursorSelection, isSelectionAtEndOfNode, isSelectionAtStartOfNode } from '@atlaskit/editor-common/selection';
import { NodeSelection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { findParentNodeOfTypeClosestToPos, findSelectedNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import { fg } from '@atlaskit/platform-feature-flags';
import { clearEditingContext, updateState } from '../editor-commands/commands';
import { lazyExtensionNodeView } from '../nodeviews/lazyExtension';
import { createPluginState, getPluginState } from './plugin-factory';
import { pluginKey } from './plugin-key';
import { updateEditButton } from './update-edit-button';
import { getSelectedDomElement, getSelectedExtension } from './utils';
const shouldShowEditButton = (extensionHandler, extensionProvider) => {
const usesLegacyMacroBrowser = !extensionHandler && !extensionProvider || typeof extensionHandler === 'function';
const usesModernUpdateMethod = typeof extensionHandler === 'object' && typeof extensionHandler.update === 'function';
if (usesLegacyMacroBrowser || usesModernUpdateMethod) {
return true;
}
return false;
};
const getUpdateExtensionPromise = async (view, extensionHandler, extensionProvider) => {
if (extensionHandler && typeof extensionHandler === 'object') {
// Old API with the `update` function
return extensionHandler.update;
} else if (extensionProvider) {
// New API with or without the `update` function, we don't know at this point
const updateMethod = await updateEditButton(view, extensionProvider);
if (updateMethod) {
return updateMethod;
}
}
throw new Error('No update method available');
};
export const createExtensionProviderHandler = view => async (name, provider) => {
if (name === 'extensionProvider' && provider) {
try {
const extensionProvider = await provider;
updateState({
extensionProvider
})(view.state, view.dispatch);
await updateEditButton(view, extensionProvider);
} catch {
updateState({
extensionProvider: undefined
})(view.state, view.dispatch);
}
}
};
export const handleUpdate = ({
view,
prevState,
domAtPos,
extensionHandlers,
applyChange
}) => {
const {
state,
dispatch
} = view;
const {
element,
localId,
extensionProvider,
showContextPanel,
showEditButton
} = getPluginState(state);
// This fetches the selected extension node, either by keyboard selection or click for all types of extensions
const selectedExtension = getSelectedExtension(state, true);
if (!selectedExtension) {
if (showContextPanel) {
clearEditingContext(applyChange)(state, dispatch);
}
return;
}
const {
node
} = selectedExtension;
const newElement = getSelectedDomElement(state.schema, domAtPos, selectedExtension);
// In some cases, showEditButton can be stale and the edit button doesn't show - @see ED-15285
// To be safe, we update the showEditButton state here
const shouldUpdateEditButton = !showEditButton && extensionProvider && element === newElement && !getSelectedExtension(prevState, true);
const isNewNodeSelected = node.attrs.localId ? localId !== node.attrs.localId :
// This is the current assumption and it's wrong but we are keeping it
// as fallback in case we need to turn off `allowLocalIdGeneration`
element !== newElement;
if (isNewNodeSelected || shouldUpdateEditButton) {
if (showContextPanel) {
clearEditingContext(applyChange)(state, dispatch);
return;
}
const {
extensionType
} = node.attrs;
const extensionHandler = extensionHandlers[extensionType];
// showEditButton might change async based on results from extension providers
const showEditButton = shouldShowEditButton(extensionHandler, extensionProvider);
const updateExtension = getUpdateExtensionPromise(view, extensionHandler, extensionProvider).catch(() => {
// do nothing;
});
updateState({
localId: node.attrs.localId,
showContextPanel: false,
element: newElement,
showEditButton,
updateExtension
})(state, dispatch);
}
// New DOM element doesn't necessarily mean it's a new Node
else if (element !== newElement) {
updateState({
element: newElement
})(state, dispatch);
}
return true;
};
export const createPlugin = (dispatch, providerFactory, extensionHandlers, portalProviderAPI, eventDispatcher, pluginInjectionApi, useLongPressSelection = false, options = {}, featureFlags, allowDragAndDrop = true, __rendererExtensionOptions
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/max-params
) => {
var _featureFlags$macroIn;
const state = createPluginState(dispatch, {
showEditButton: false,
showContextPanel: false
});
const extensionNodeViewOptions = {
appearance: options.appearance
};
const macroInteractionDesignFeatureFlags = {
showMacroInteractionDesignUpdates: (_featureFlags$macroIn = featureFlags === null || featureFlags === void 0 ? void 0 : featureFlags.macroInteractionUpdates) !== null && _featureFlags$macroIn !== void 0 ? _featureFlags$macroIn : false
};
const showLivePagesBodiedMacrosRendererView = __rendererExtensionOptions === null || __rendererExtensionOptions === void 0 ? void 0 : __rendererExtensionOptions.isAllowedToUseRendererView;
return new SafePlugin({
state,
view: editorView => {
const domAtPos = editorView.domAtPos.bind(editorView);
const extensionProviderHandler = createExtensionProviderHandler(editorView);
providerFactory.subscribe('extensionProvider', extensionProviderHandler);
return {
update: (view, prevState) => {
var _pluginInjectionApi$c;
handleUpdate({
view,
prevState,
domAtPos,
extensionHandlers,
applyChange: pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c = pluginInjectionApi.contextPanel) === null || _pluginInjectionApi$c === void 0 ? void 0 : _pluginInjectionApi$c.actions.applyChange
});
},
destroy: () => {
providerFactory.unsubscribe('extensionProvider', extensionProviderHandler);
}
};
},
key: pluginKey,
props: {
handleDOMEvents: {
/**
* ED-18072 - Cannot shift + arrow past bodied extension if it is not empty.
* This code is to handle the case where the selection starts inside or on the node and the user is trying to shift + arrow.
* For other part of the solution see code in: packages/editor/editor-core/src/plugins/selection/pm-plugins/events/keydown.ts
*/
keydown: (view, event) => {
if (event instanceof KeyboardEvent && event.shiftKey && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
const {
schema,
selection,
selection: {
$head
},
doc,
tr
} = view.state;
const {
bodiedExtension
} = schema.nodes;
if (selection instanceof TextSelection || selection instanceof NodeSelection) {
const maybeBodiedExtension = selection instanceof TextSelection ? findParentNodeOfTypeClosestToPos($head, bodiedExtension) : findSelectedNodeOfType(bodiedExtension)(selection);
if (maybeBodiedExtension) {
const end = maybeBodiedExtension.pos + maybeBodiedExtension.node.nodeSize;
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft' && isSelectionAtStartOfNode($head, maybeBodiedExtension)) {
const anchor = end + 1;
// an offset is used here so that left arrow selects the first character before the node (consistent with arrow right)
const headOffset = event.key === 'ArrowLeft' ? -1 : 0;
const head = maybeBodiedExtension.pos + headOffset;
const newSelection = TextSelection.create(doc, Math.max(anchor, selection.anchor), head);
view.dispatch(tr.setSelection(newSelection));
return true;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowRight' && isSelectionAtEndOfNode($head, maybeBodiedExtension)) {
const anchor = maybeBodiedExtension.pos - 1;
const head = end + 1;
const newSelection = TextSelection.create(doc, Math.min(anchor, selection.anchor), head);
view.dispatch(tr.setSelection(newSelection));
return true;
}
}
}
}
// Handle non shift key case for MBE
if (event instanceof KeyboardEvent && !event.shiftKey && event.key === 'ArrowLeft') {
const {
schema,
selection,
selection: {
$head
},
doc,
tr
} = view.state;
const {
multiBodiedExtension,
extensionFrame,
paragraph
} = schema.nodes;
if (selection instanceof GapCursorSelection || selection instanceof TextSelection && $head.parent.type === paragraph) {
const maybeMultiBodiedExtension = findParentNodeOfTypeClosestToPos($head, multiBodiedExtension);
if (maybeMultiBodiedExtension) {
var _tr$doc$nodeAt;
/* In case of gap cursor, we need to decrement the position by 1 as we need to check the node at previous position
* In case of text selection, we need to decrement the position by 2 as we need to jump back twice, once from text node and then its parent paragraph node
*/
const previousPositionDecrement = selection instanceof GapCursorSelection ? 1 : 2;
if (((_tr$doc$nodeAt = tr.doc.nodeAt($head.pos - previousPositionDecrement)) === null || _tr$doc$nodeAt === void 0 ? void 0 : _tr$doc$nodeAt.type) === extensionFrame) {
const newSelection = TextSelection.create(doc, tr.doc.resolve($head.pos - previousPositionDecrement).start($head.depth - previousPositionDecrement));
view.dispatch(tr.setSelection(newSelection));
}
}
}
}
return false;
}
},
nodeViews: {
// WARNING: referentiality-plugin also creates these nodeviews
extension: lazyExtensionNodeView('extension', portalProviderAPI, eventDispatcher, providerFactory, extensionHandlers, extensionNodeViewOptions, pluginInjectionApi, macroInteractionDesignFeatureFlags),
// WARNING: referentiality-plugin also creates these nodeviews
bodiedExtension: lazyExtensionNodeView('bodiedExtension', portalProviderAPI, eventDispatcher, providerFactory, extensionHandlers, extensionNodeViewOptions, pluginInjectionApi, macroInteractionDesignFeatureFlags, showLivePagesBodiedMacrosRendererView, __rendererExtensionOptions === null || __rendererExtensionOptions === void 0 ? void 0 : __rendererExtensionOptions.showUpdated1PBodiedExtensionUI, __rendererExtensionOptions === null || __rendererExtensionOptions === void 0 ? void 0 : __rendererExtensionOptions.rendererExtensionHandlers),
// WARNING: referentiality-plugin also creates these nodeviews
inlineExtension: lazyExtensionNodeView('inlineExtension', portalProviderAPI, eventDispatcher, providerFactory, extensionHandlers, extensionNodeViewOptions, pluginInjectionApi, macroInteractionDesignFeatureFlags),
multiBodiedExtension: lazyExtensionNodeView('multiBodiedExtension', portalProviderAPI, eventDispatcher, providerFactory, extensionHandlers, extensionNodeViewOptions, pluginInjectionApi, macroInteractionDesignFeatureFlags)
},
createSelectionBetween: function (view, anchor, head) {
const {
schema,
doc
} = view.state;
const {
multiBodiedExtension
} = schema.nodes;
const isAnchorInMBE = findParentNodeOfTypeClosestToPos(anchor, multiBodiedExtension);
const isHeadInMBE = findParentNodeOfTypeClosestToPos(head, multiBodiedExtension);
if (isAnchorInMBE !== undefined && isHeadInMBE === undefined) {
// Anchor is in MBE, where user started selecting within MBE and then moved outside
const newSelection = TextSelection.create(doc, isAnchorInMBE.pos < head.pos ? isAnchorInMBE.pos : isAnchorInMBE.pos + isAnchorInMBE.node.nodeSize,
// isAnchorInMBE.pos < head.pos represents downwards selection
head.pos);
return newSelection;
}
if (isAnchorInMBE === undefined && isHeadInMBE !== undefined) {
// Head is in MBE, where user started selecting outside MBE and then moved inside
const newSelection = TextSelection.create(doc, anchor.pos, isHeadInMBE.pos < anchor.pos ? isHeadInMBE.pos : isHeadInMBE.pos + isHeadInMBE.node.nodeSize // isHeadInMBE.pos < anchor.pos represents upwards selection
);
return newSelection;
}
return null;
},
handleClickOn: createSelectionClickHandler(['extension', 'bodiedExtension', 'multiBodiedExtension'], target => fg('platform_editor_legacy_content_macro') ? !target.closest('.extension-non-editable-area') && (!target.closest('.extension-content') || !!target.closest('.extension-container')) : !target.closest('.extension-content'),
// It's to enable nested extensions selection
{
useLongPressSelection
}),
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/max-params
handleDrop(view, event, slice, moved) {
if (fg('platform_editor_legacy_content_macro')) {
if (!allowDragAndDrop) {
var _slice$content$firstC;
// Completely disable DND for extension nodes when allowDragAndDrop is false
const isExtension = slice.content.childCount === 1 && ((_slice$content$firstC = slice.content.firstChild) === null || _slice$content$firstC === void 0 ? void 0 : _slice$content$firstC.type) === view.state.schema.nodes.extension;
return isExtension;
}
return false;
}
return false;
}
}
});
};