@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
253 lines (244 loc) • 13.6 kB
JavaScript
import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
import _createClass from "@babel/runtime/helpers/createClass";
import _possibleConstructorReturn from "@babel/runtime/helpers/possibleConstructorReturn";
import _getPrototypeOf from "@babel/runtime/helpers/getPrototypeOf";
import _get from "@babel/runtime/helpers/get";
import _inherits from "@babel/runtime/helpers/inherits";
import _defineProperty from "@babel/runtime/helpers/defineProperty";
function _callSuper(t, o, e) { return o = _getPrototypeOf(o), _possibleConstructorReturn(t, _isNativeReflectConstruct() ? Reflect.construct(o, e || [], _getPrototypeOf(t).constructor) : o.apply(t, e)); }
function _isNativeReflectConstruct() { try { var t = !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); } catch (t) {} return (_isNativeReflectConstruct = function _isNativeReflectConstruct() { return !!t; })(); }
function _superPropGet(t, o, e, r) { var p = _get(_getPrototypeOf(1 & r ? t.prototype : t), o, e); return 2 & r && "function" == typeof p ? function (t) { return p.apply(e, t); } : p; }
import React from 'react';
import { ACTION_SUBJECT } from '@atlaskit/editor-common/analytics';
import { ErrorBoundary } from '@atlaskit/editor-common/error-boundary';
import ReactNodeView from '@atlaskit/editor-common/react-node-view';
// oxlint-disable-next-line import/no-duplicates
import { SyncBlockSharedCssClassName, SyncBlockLabelSharedCssClassName, SyncBlockActionsProvider } from '@atlaskit/editor-common/sync-block';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import { removeSyncedBlockAtPos } from '../editor-commands';
import { SyncBlockRendererWrapper } from '../ui/SyncBlockRendererWrapper';
import { SyncBlockSSRReactContextsProvider } from '../ui/SyncBlockSSRReactContextsProvider';
// Event types that should be intercepted (returned as handled) when they
// originate inside the sync block content area, so ProseMirror does not
// convert them into node-level selections or drag operations and the browser
// can perform native text selection/cut instead.
var STOPPED_EVENT_TYPES = ['mousedown', 'mousemove', 'mouseup', 'click', 'dblclick', 'selectstart'];
export var SyncBlock = /*#__PURE__*/function (_ReactNodeView) {
function SyncBlock(props) {
var _this;
_classCallCheck(this, SyncBlock);
_this = _callSuper(this, SyncBlock, [props.node, props.view, props.getPos, props.portalProviderAPI, props.eventDispatcher, props]);
// Stable callback references — defined as arrow properties so they keep a
// fixed identity across render() calls, avoiding defeats of React.memo.
// The experiment gate lives in render(); these are always available.
_defineProperty(_this, "removeSyncBlockStable", function () {
var pos = _this.getPos();
if (pos !== undefined) {
removeSyncedBlockAtPos(_this.api, pos);
}
});
_defineProperty(_this, "fetchSyncBlockSourceInfoStable", function (sourceAri) {
var _this$api$syncedBlock, _this$api;
// store is guaranteed non-null: render() guards on syncBlockStore
// before these callbacks can be invoked.
var store = (_this$api$syncedBlock = (_this$api = _this.api) === null || _this$api === void 0 || (_this$api = _this$api.syncedBlock) === null || _this$api === void 0 || (_this$api = _this$api.sharedState.currentState()) === null || _this$api === void 0 ? void 0 : _this$api.syncBlockStore) !== null && _this$api$syncedBlock !== void 0 ? _this$api$syncedBlock : _this.syncBlockStore;
return store ? store.referenceManager.fetchSyncBlockSourceInfoBySourceAri(sourceAri) : Promise.resolve(undefined);
});
_this.options = props.options;
_this.api = props.api;
_this.syncBlockStore = props.syncBlockStore;
_this.intl = props.intl;
return _this;
}
_inherits(SyncBlock, _ReactNodeView);
return _createClass(SyncBlock, [{
key: "createDomRef",
value:
// Stored reference so the listener can be removed in destroy() to
// avoid a memory leak on every nodeview destruction.
function createDomRef() {
// eslint-disable-next-line @atlaskit/platform/no-direct-document-usage -- NodeView DOM must be created against active runtime document
var domRef = document.createElement('div');
domRef.classList.add(SyncBlockSharedCssClassName.prefix);
// Prevent native browser drag on the contentEditable="false" wrapper.
// Without this, clicking in empty space (outside the contentEditable
// renderer but inside the domRef) initiates a native element drag.
domRef.draggable = false;
this.dragStartHandler = function (e) {
e.preventDefault();
};
// eslint-disable-next-line @atlaskit/design-system/no-direct-use-of-web-platform-drag-and-drop, @repo/internal/dom-events/no-unsafe-event-listeners
domRef.addEventListener('dragstart', this.dragStartHandler);
return domRef;
}
/**
* Allow mouse and selection events inside the renderer content to pass
* through to the browser so that users can select and copy text within a
* reference sync block.
*
* Events that originate inside the sync block content area (but not the label)
* are stopped so ProseMirror does not intercept them for node-level selection.
* This includes the full click-drag cycle (mousedown, mousemove, mouseup),
* click, dblclick, selectstart and cut. The `cut` event is stopped because
* mousedown explicitly sets a NodeSelection on the sync block — without
* stopping `cut`, a subsequent Ctrl+X would cause ProseMirror to delete the
* entire sync block node instead of cutting the user's text selection.
*
* Copy events are conditionally stopped: when the user has an active native
* text selection inside the renderer, `copy` is stopped so the browser handles
* it natively (copying the selected text). When there is no text selection
* (just a PM NodeSelection), `copy` is NOT stopped so ProseMirror's copy
* handler runs and sets the "sync-block-copied" flag for the reference paste flow.
*
* Events on the SyncBlockLabel are left for ProseMirror to handle, preserving
* label click interactions and the floating toolbar.
*
* The renderer wrapper sets contentEditable="true" to create a re-editable
* island inside ProseMirror's contentEditable="false" nodeview, enabling
* native text selection and preventing browser drag behaviour.
*/
}, {
key: "stopEvent",
value: function stopEvent(event) {
var target = event.target;
if (!(target instanceof Element)) {
return false;
}
// Stop events inside the sync block content area, but not on the
// SyncBlockLabel (to preserve label click interactions).
if (target.closest(".".concat(SyncBlockSharedCssClassName.prefix)) && !target.closest(".".concat(SyncBlockLabelSharedCssClassName.labelClassName))) {
var eventType = event.type;
// Stop `copy` only when there is an active native text selection
// inside the renderer. This lets the browser handle text copy
// natively. When there is no text selection (just a PM
// NodeSelection), we let PM handle the copy event so the
// "sync-block-copied" flag is set for the reference paste flow.
if (eventType === 'copy') {
var selection = window.getSelection();
return !!(selection && selection.toString().length > 0);
}
// For `cut`: when text is selected inside the renderer, stop the
// event and prevent default to avoid the browser removing text from
// the read-only content. When no text is selected (NodeSelection),
// let PM handle it so cut deletes the sync block as expected.
if (eventType === 'cut') {
var _selection = window.getSelection();
if (_selection && _selection.toString().length > 0) {
event.preventDefault();
return true;
}
return false;
}
// Stop keyboard events that would cause PM to replace the
// NodeSelection with typed text (deleting the sync block).
// Allow modifier-key combos (Cmd+C, Cmd+A, etc.) through.
// Allow Delete/Backspace through so PM's delete handler can
// process them (e.g. show offline error flag).
if ((eventType === 'keydown' || eventType === 'keypress') && event instanceof KeyboardEvent && !event.metaKey && !event.ctrlKey && event.key !== 'Delete' && event.key !== 'Backspace') {
return true;
}
if (STOPPED_EVENT_TYPES.includes(eventType)) {
// Ensure the syncBlock has a NodeSelection so the floating
// toolbar is visible while the user interacts with the renderer.
// stopEvent prevents PM from processing the mousedown, so we
// need to explicitly set the selection ourselves.
if (eventType === 'mousedown' && !(this.view.state.selection instanceof NodeSelection && this.view.state.selection.node === this.node)) {
if (typeof this.getPos === 'function') {
var pos = this.getPos();
if (typeof pos === 'number') {
try {
var tr = this.view.state.tr;
this.view.dispatch(tr.setSelection(NodeSelection.create(tr.doc, pos)));
} catch (_unused) {
// pos no longer valid — leave selection unchanged
}
}
}
}
return true;
}
}
return false;
}
}, {
key: "validUpdate",
value: function validUpdate(currentNode, newNode) {
// Only consider as the valid update if the localId and resourceId are the same
// This prevents PM reusing the same node view for different sync block node in live page transition
return currentNode.attrs.localId === newNode.attrs.localId && currentNode.attrs.resourceId === newNode.attrs.resourceId;
}
}, {
key: "update",
value: function update(node, decorations, innerDecorations) {
return _superPropGet(SyncBlock, "update", this, 3)([node, decorations, innerDecorations, this.validUpdate]);
}
}, {
key: "render",
value: function render(_ref) {
var _this$options, _this$api$syncedBlock2, _this$api2, _this$api3, _this$options2;
var getPos = _ref.getPos;
if (!((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.syncedBlockRenderer)) {
return null;
}
var _this$node$attrs = this.node.attrs,
resourceId = _this$node$attrs.resourceId,
localId = _this$node$attrs.localId;
if (!resourceId || !localId) {
return null;
}
var syncBlockStore = (_this$api$syncedBlock2 = (_this$api2 = this.api) === null || _this$api2 === void 0 || (_this$api2 = _this$api2.syncedBlock) === null || _this$api2 === void 0 || (_this$api2 = _this$api2.sharedState.currentState()) === null || _this$api2 === void 0 ? void 0 : _this$api2.syncBlockStore) !== null && _this$api$syncedBlock2 !== void 0 ? _this$api$syncedBlock2 : this.syncBlockStore;
if (!syncBlockStore) {
return null;
}
// get document node from data provider
return /*#__PURE__*/React.createElement(SyncBlockSSRReactContextsProvider, {
intl: this.intl
}, /*#__PURE__*/React.createElement(ErrorBoundary, {
component: ACTION_SUBJECT.SYNCED_BLOCK,
dispatchAnalyticsEvent: (_this$api3 = this.api) === null || _this$api3 === void 0 || (_this$api3 = _this$api3.analytics) === null || _this$api3 === void 0 ? void 0 : _this$api3.actions.fireAnalyticsEvent,
fallbackComponent: null
}, /*#__PURE__*/React.createElement(SyncBlockActionsProvider, {
removeSyncBlock: this.removeSyncBlockStable,
fetchSyncBlockSourceInfo: this.fetchSyncBlockSourceInfoStable
}, /*#__PURE__*/React.createElement(SyncBlockRendererWrapper, {
localId: localId,
resourceId: resourceId,
node: this.node,
syncBlockStore: syncBlockStore,
syncedBlockRenderer: (_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.syncedBlockRenderer,
api: this.api
}))));
}
}, {
key: "destroy",
value: function destroy() {
var _this$unsubscribe;
(_this$unsubscribe = this.unsubscribe) === null || _this$unsubscribe === void 0 || _this$unsubscribe.call(this);
if (this.dragStartHandler) {
var _this$dom;
// eslint-disable-next-line @atlaskit/design-system/no-direct-use-of-web-platform-drag-and-drop, @repo/internal/dom-events/no-unsafe-event-listeners
(_this$dom = this.dom) === null || _this$dom === void 0 || _this$dom.removeEventListener('dragstart', this.dragStartHandler);
this.dragStartHandler = undefined;
}
_superPropGet(SyncBlock, "destroy", this, 3)([]);
}
}]);
}(ReactNodeView);
export var syncBlockNodeView = function syncBlockNodeView(_ref2) {
var options = _ref2.options,
pmPluginFactoryParams = _ref2.pmPluginFactoryParams,
api = _ref2.api;
return function (node, view, getPos) {
var portalProviderAPI = pmPluginFactoryParams.portalProviderAPI,
eventDispatcher = pmPluginFactoryParams.eventDispatcher;
return new SyncBlock({
api: api,
options: options,
node: node,
view: view,
getPos: getPos,
portalProviderAPI: portalProviderAPI,
eventDispatcher: eventDispatcher
}).init();
};
};