@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
204 lines (201 loc) • 10.8 kB
JavaScript
import React from 'react';
import { syncBlock, bodiedSyncBlock } from '@atlaskit/adf-schema';
import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks';
import { SyncBlockStoreManager } from '@atlaskit/editor-synced-block-provider';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { flushBodiedSyncBlocks, flushSyncBlocks, discardUnpublishedSyncBlocks } from './editor-actions';
import { copySyncedBlockReferenceToClipboardEditorCommand, createSyncedBlock } from './editor-commands';
import { createPlugin, syncedBlockPluginKey } from './pm-plugins/main';
import { getMenuAndToolbarExperiencesPlugin } from './pm-plugins/menu-and-toolbar-experiences';
import { getBlockMenuComponents } from './ui/block-menu-components';
import { DeleteConfirmationModal } from './ui/DeleteConfirmationModal';
import { Flag } from './ui/Flag';
import { getToolbarConfig } from './ui/floating-toolbar';
import { getQuickInsertConfig } from './ui/quick-insert';
import { getSyncedBlockQuickInsertComponents } from './ui/quick-insert/getSyncedBlockQuickInsertComponents';
import { SourceSyncBlockPlaceholder } from './ui/SourceSyncBlockPlaceholder';
import { SyncBlockRefresher } from './ui/SyncBlockRefresher';
import { getToolbarComponents } from './ui/toolbar-components';
/**
* EDITOR-6929 / PR-G: Guard contentComponent rendering.
* When `hasSyncedBlocks` is false return null
* to avoid mounting SyncBlockRefresher, DeleteConfirmationModal, and Flag —
* their hooks (useSharedPluginStateWithSelector) would execute selectors on
* every transaction for no benefit on the ~99.98% of pages with zero synced
* blocks.
*/
const LazySyncedBlockUI = ({
syncBlockStore: syncBlockStoreManager,
api,
editorView,
onFeedbackPromptShown,
onGiveFeedback
}) => {
const hasSyncBlocks = useSharedPluginStateWithSelector(api, ['syncedBlock'], states => {
var _states$syncedBlockSt;
return (_states$syncedBlockSt = states.syncedBlockState) === null || _states$syncedBlockSt === void 0 ? void 0 : _states$syncedBlockSt.hasSyncedBlocks;
});
if (!hasSyncBlocks) {
return null;
}
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(SyncBlockRefresher, {
syncBlockStoreManager: syncBlockStoreManager,
api: api
}), /*#__PURE__*/React.createElement(DeleteConfirmationModal, {
syncBlockStoreManager: syncBlockStoreManager,
api: api,
editorView: editorView
}), /*#__PURE__*/React.createElement(Flag, {
api: api,
onFeedbackPromptShown: onFeedbackPromptShown,
onGiveFeedback: onGiveFeedback
}));
};
export const syncedBlockPlugin = ({
config,
api
}) => {
var _api$editorViewMode, _api$editorViewMode$s, _api$analytics, _api$analytics$action, _api$blockMenu, _config$enableSourceC, _api$toolbar, _config$enableSourceC2;
const refs = {};
const viewMode = api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode;
const syncBlockStore = new SyncBlockStoreManager(config === null || config === void 0 ? void 0 : config.syncBlockDataProvider, viewMode, config === null || config === void 0 ? void 0 : config.__livePage);
syncBlockStore.setFireAnalyticsEvent(api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : (_api$analytics$action = _api$analytics.actions) === null || _api$analytics$action === void 0 ? void 0 : _api$analytics$action.fireAnalyticsEvent);
// --- Memoized getSharedState (EDITOR-6929 / PR-F) ---
// Cache the last returned shared state object. On each call, perform a
// shallow comparison of all fields against the cached value. If nothing
// changed, return the cached reference so SharedStateAPI subscribers
// (React components) skip re-rendering.
let cachedSharedState;
api === null || api === void 0 ? void 0 : (_api$blockMenu = api.blockMenu) === null || _api$blockMenu === void 0 ? void 0 : _api$blockMenu.actions.registerBlockMenuComponents(getBlockMenuComponents(api, (_config$enableSourceC = config === null || config === void 0 ? void 0 : config.enableSourceCreation) !== null && _config$enableSourceC !== void 0 ? _config$enableSourceC : false));
api === null || api === void 0 ? void 0 : (_api$toolbar = api.toolbar) === null || _api$toolbar === void 0 ? void 0 : _api$toolbar.actions.registerComponents(getToolbarComponents(api, (_config$enableSourceC2 = config === null || config === void 0 ? void 0 : config.enableSourceCreation) !== null && _config$enableSourceC2 !== void 0 ? _config$enableSourceC2 : false));
const isRegisteredSlashCommandEnabled = isExperimentEnabled('platform_editor_slash_command');
if (isRegisteredSlashCommandEnabled && config !== null && config !== void 0 && config.enableSourceCreation) {
var _api$uiControlRegistr;
api === null || api === void 0 ? void 0 : (_api$uiControlRegistr = api.uiControlRegistry) === null || _api$uiControlRegistr === void 0 ? void 0 : _api$uiControlRegistr.actions.register(getSyncedBlockQuickInsertComponents({
api,
syncBlockStore
}));
}
return {
name: 'syncedBlock',
nodes() {
return [{
name: 'syncBlock',
node: syncBlock
}, {
name: 'bodiedSyncBlock',
node: bodiedSyncBlock
}];
},
pmPlugins() {
return [{
name: 'syncedBlockPlugin',
plugin: params => createPlugin(config, params, syncBlockStore, api)
}, {
name: 'menuAndToolbarExperiencesPlugin',
plugin: () => getMenuAndToolbarExperiencesPlugin({
refs,
dispatchAnalyticsEvent: payload => {
var _api$analytics2, _api$analytics2$actio;
return api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : (_api$analytics2$actio = _api$analytics2.actions) === null || _api$analytics2$actio === void 0 ? void 0 : _api$analytics2$actio.fireAnalyticsEvent(payload);
}
})
}];
},
commands: {
copySyncedBlockReferenceToClipboard: inputMethod => copySyncedBlockReferenceToClipboardEditorCommand(syncBlockStore, inputMethod, api, config === null || config === void 0 ? void 0 : config.__livePage),
insertSyncedBlock: inputMethod => ({
tr
}) => {
var _api$analytics3;
if (!(config !== null && config !== void 0 && config.enableSourceCreation)) {
return null;
}
return createSyncedBlock({
tr,
syncBlockStore,
fireAnalyticsEvent: api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : _api$analytics3.actions.fireAnalyticsEvent,
inputMethod
}) || null;
}
},
actions: {
flushBodiedSyncBlocks: () => {
return flushBodiedSyncBlocks(syncBlockStore);
},
flushSyncedBlocks: () => {
return flushSyncBlocks(syncBlockStore);
},
discardUnpublishedSyncBlocks: () => {
return discardUnpublishedSyncBlocks(syncBlockStore);
}
},
pluginsOptions: {
...(!isRegisteredSlashCommandEnabled && {
quickInsert: getQuickInsertConfig(config, api, syncBlockStore)
}),
floatingToolbar: (state, intl) => {
var _syncedBlockPluginKey;
// When registered slash-command support is on and the document has no synced blocks,
// skip the toolbar config entirely to avoid the per-selection-change
// cost of findSyncBlockOrBodiedSyncBlock (EDITOR-6931).
if (!((_syncedBlockPluginKey = syncedBlockPluginKey.getState(state)) !== null && _syncedBlockPluginKey !== void 0 && _syncedBlockPluginKey.hasSyncedBlocks)) {
return undefined;
}
return getToolbarConfig(state, intl, api, syncBlockStore, config === null || config === void 0 ? void 0 : config.__livePage, config === null || config === void 0 ? void 0 : config.onGiveFeedback);
}
},
contentComponent: ({
containerElement,
wrapperElement,
popupsMountPoint,
editorView
}) => {
refs.containerElement = containerElement || undefined;
refs.popupsMountPoint = popupsMountPoint || undefined;
refs.wrapperElement = wrapperElement || undefined;
return /*#__PURE__*/React.createElement(React.Fragment, null, expValEquals('platform_editor_sync_block_activation', 'isEnabled', true) && /*#__PURE__*/React.createElement(SourceSyncBlockPlaceholder, null), /*#__PURE__*/React.createElement(LazySyncedBlockUI, {
syncBlockStore: syncBlockStore,
api: api,
editorView: editorView,
onFeedbackPromptShown: config === null || config === void 0 ? void 0 : config.onFeedbackPromptShown,
onGiveFeedback: config === null || config === void 0 ? void 0 : config.onGiveFeedback
}));
},
getSharedState: editorState => {
if (!editorState) {
return;
}
const pluginState = syncedBlockPluginKey.getState(editorState);
const {
activeFlag,
syncBlockStore: currentSyncBlockStore,
bodiedSyncBlockDeletionStatus,
retryCreationPosMap,
hasSyncedBlocks,
hasUnsavedBodiedSyncBlockChanges
} = pluginState;
// --- EDITOR-6929 / PR-F: return a stable reference when all
// fields are unchanged to prevent unnecessary React re-renders. ---
if (cachedSharedState !== undefined && cachedSharedState.activeFlag === activeFlag && cachedSharedState.syncBlockStore === currentSyncBlockStore && cachedSharedState.bodiedSyncBlockDeletionStatus === bodiedSyncBlockDeletionStatus && cachedSharedState.retryCreationPosMap === retryCreationPosMap && cachedSharedState.hasSyncedBlocks === hasSyncedBlocks && cachedSharedState.hasUnsavedBodiedSyncBlockChanges === hasUnsavedBodiedSyncBlockChanges) {
return cachedSharedState;
}
const nextSharedState = {
activeFlag,
syncBlockStore: currentSyncBlockStore,
bodiedSyncBlockDeletionStatus,
retryCreationPosMap,
hasSyncedBlocks,
hasUnsavedBodiedSyncBlockChanges
};
cachedSharedState = nextSharedState;
return nextSharedState;
},
// Destroy the SyncBlockStoreManager on editor unmount to cancel
// pending timers, subscriptions, and in-flight fetches.
destroy() {
syncBlockStore.destroy();
}
};
};