@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
231 lines (222 loc) • 11.8 kB
JavaScript
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();
};