UNPKG

@atlaskit/editor-plugin-synced-block

Version:

SyncedBlock plugin for @atlaskit/editor-core

231 lines (222 loc) 11.8 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; 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. const STOPPED_EVENT_TYPES = ['mousedown', 'mousemove', 'mouseup', 'click', 'dblclick', 'selectstart']; export class SyncBlock extends ReactNodeView { constructor(props) { super(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", () => { const pos = this.getPos(); if (pos !== undefined) { removeSyncedBlockAtPos(this.api, pos); } }); _defineProperty(this, "fetchSyncBlockSourceInfoStable", sourceAri => { var _this$api$syncedBlock, _this$api, _this$api$syncedBlock2, _this$api$syncedBlock3; // store is guaranteed non-null: render() guards on syncBlockStore // before these callbacks can be invoked. const store = (_this$api$syncedBlock = (_this$api = this.api) === null || _this$api === void 0 ? void 0 : (_this$api$syncedBlock2 = _this$api.syncedBlock) === null || _this$api$syncedBlock2 === void 0 ? void 0 : (_this$api$syncedBlock3 = _this$api$syncedBlock2.sharedState.currentState()) === null || _this$api$syncedBlock3 === void 0 ? void 0 : _this$api$syncedBlock3.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; } // Stored reference so the listener can be removed in destroy() to // avoid a memory leak on every nodeview destruction. createDomRef() { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage -- NodeView DOM must be created against active runtime document const 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 = 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. */ stopEvent(event) { const 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(`.${SyncBlockSharedCssClassName.prefix}`) && !target.closest(`.${SyncBlockLabelSharedCssClassName.labelClassName}`)) { const 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') { const 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') { const 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') { const pos = this.getPos(); if (typeof pos === 'number') { try { const { tr } = this.view.state; this.view.dispatch(tr.setSelection(NodeSelection.create(tr.doc, pos))); } catch { // pos no longer valid — leave selection unchanged } } } } return true; } } return false; } 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; } update(node, decorations, innerDecorations) { return super.update(node, decorations, innerDecorations, this.validUpdate); } render({ getPos }) { var _this$options, _this$api$syncedBlock4, _this$api2, _this$api2$syncedBloc, _this$api2$syncedBloc2, _this$api3, _this$api3$analytics, _this$options2; if (!((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.syncedBlockRenderer)) { return null; } const { resourceId, localId } = this.node.attrs; if (!resourceId || !localId) { return null; } const syncBlockStore = (_this$api$syncedBlock4 = (_this$api2 = this.api) === null || _this$api2 === void 0 ? void 0 : (_this$api2$syncedBloc = _this$api2.syncedBlock) === null || _this$api2$syncedBloc === void 0 ? void 0 : (_this$api2$syncedBloc2 = _this$api2$syncedBloc.sharedState.currentState()) === null || _this$api2$syncedBloc2 === void 0 ? void 0 : _this$api2$syncedBloc2.syncBlockStore) !== null && _this$api$syncedBlock4 !== void 0 ? _this$api$syncedBlock4 : 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 ? void 0 : (_this$api3$analytics = _this$api3.analytics) === null || _this$api3$analytics === void 0 ? void 0 : _this$api3$analytics.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 })))); } destroy() { var _this$unsubscribe; (_this$unsubscribe = this.unsubscribe) === null || _this$unsubscribe === void 0 ? 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 ? void 0 : _this$dom.removeEventListener('dragstart', this.dragStartHandler); this.dragStartHandler = undefined; } super.destroy(); } } export const syncBlockNodeView = ({ options, pmPluginFactoryParams, api }) => (node, view, getPos) => { const { portalProviderAPI, eventDispatcher } = pmPluginFactoryParams; return new SyncBlock({ api, options, node, view, getPos: getPos, portalProviderAPI, eventDispatcher }).init(); };