UNPKG

@atlaskit/editor-plugin-synced-block

Version:

SyncedBlock plugin for @atlaskit/editor-core

253 lines (244 loc) 13.6 kB
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(); }; };