@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
439 lines (438 loc) • 16.8 kB
JavaScript
import { bind } from 'bind-event-listener';
import { ACTION, ACTION_SUBJECT_ID } from '@atlaskit/editor-common/analytics';
import { Experience, EXPERIENCE_ID, ExperienceCheckDomMutation, ExperienceCheckTimeout, getNodeQuery, getPopupContainerFromEditorView, popupWithNestedElement, getSelectionAncestorDOM } from '@atlaskit/editor-common/experiences';
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { PluginKey } from '@atlaskit/editor-prosemirror/state';
import { SYNCED_BLOCK_BUTTON_TEST_ID } from '../types';
import { syncedBlockPluginKey } from './main';
const TIMEOUT_DURATION = 30000;
const pluginKey = new PluginKey('syncedBlockMenuAndToolbarExperience');
const SYNCED_BLOCK_BUTTON_TEST_IDS = Object.values(SYNCED_BLOCK_BUTTON_TEST_ID);
const syncedBlockButtonIds = new Set(SYNCED_BLOCK_BUTTON_TEST_IDS);
export const getMenuAndToolbarExperiencesPlugin = ({
refs,
dispatchAnalyticsEvent
}) => {
let popupsTargetEl;
const editorViewRef = {
current: undefined
};
const getPopupsTarget = () => {
if (!popupsTargetEl) {
var _editorViewRef$curren;
popupsTargetEl = refs.popupsMountPoint || refs.wrapperElement || getPopupContainerFromEditorView(editorViewRef === null || editorViewRef === void 0 ? void 0 : (_editorViewRef$curren = editorViewRef.current) === null || _editorViewRef$curren === void 0 ? void 0 : _editorViewRef$curren.dom);
}
return popupsTargetEl;
};
const createSourcePrimaryToolbarExperience = new Experience(EXPERIENCE_ID.TOOLBAR_ACTION, {
action: ACTION.SYNCED_BLOCK_CREATE,
actionSubjectId: ACTION_SUBJECT_ID.PRIMARY_TOOLBAR,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), syncedBlockAddedToDomCheck(refs, editorViewRef)]
});
const createSourceBlockMenuExperience = new Experience(EXPERIENCE_ID.MENU_ACTION, {
action: ACTION.SYNCED_BLOCK_CREATE,
actionSubjectId: ACTION_SUBJECT_ID.BLOCK_MENU,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), syncedBlockAddedToDomCheck(refs, editorViewRef)]
});
const createSourceQuickInsertMenuExperience = new Experience(EXPERIENCE_ID.MENU_ACTION, {
action: ACTION.SYNCED_BLOCK_CREATE,
actionSubjectId: ACTION_SUBJECT_ID.QUICK_INSERT,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), syncedBlockAddedToDomCheck(refs, editorViewRef)]
});
const deleteReferenceSyncedBlockExperience = new Experience(EXPERIENCE_ID.TOOLBAR_ACTION, {
action: ACTION.REFERENCE_SYNCED_BLOCK_DELETE,
actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_TOOLBAR,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), referenceSyncBlockRemovedFromDomCheck(refs, editorViewRef)]
});
const unsyncReferenceSyncedBlockExperience = new Experience(EXPERIENCE_ID.TOOLBAR_ACTION, {
action: ACTION.REFERENCE_SYNCED_BLOCK_UNSYNC,
actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_TOOLBAR,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), referenceSyncBlockRemovedFromDomCheck(refs, editorViewRef)]
});
const unsyncSourceSyncedBlockExperience = new Experience(EXPERIENCE_ID.TOOLBAR_ACTION, {
action: ACTION.SYNCED_BLOCK_UNSYNC,
actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_TOOLBAR,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), syncBlockDeleteConfirmationModalAddedCheck()]
});
const deleteSourceSyncedBlockExperience = new Experience(EXPERIENCE_ID.TOOLBAR_ACTION, {
action: ACTION.SYNCED_BLOCK_DELETE,
actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_TOOLBAR,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), syncBlockDeleteConfirmationModalAddedCheck()]
});
const syncedLocationsExperience = new Experience(EXPERIENCE_ID.TOOLBAR_ACTION, {
action: ACTION.SYNCED_BLOCK_VIEW_SYNCED_LOCATIONS,
actionSubjectId: ACTION_SUBJECT_ID.SYNCED_BLOCK_TOOLBAR,
dispatchAnalyticsEvent,
checks: [new ExperienceCheckTimeout({
durationMs: TIMEOUT_DURATION
}), syncedLocationsDropdownOpenedCheck()]
});
const bindListeners = () => {
const unbindClickListener = bind(document, {
type: 'click',
listener: event => {
const target = event.target;
if (!target) {
return;
}
const button = target.closest('button[data-testid]');
if (!button || !(button instanceof HTMLButtonElement)) {
return;
}
const testId = button.dataset.testid;
if (!isSyncedBlockButtonId(testId)) {
return;
}
if (button.disabled) {
return;
}
handleButtonClick({
testId,
button,
createSourcePrimaryToolbarExperience,
createSourceBlockMenuExperience,
createSourceQuickInsertMenuExperience,
deleteReferenceSyncedBlockExperience,
unsyncReferenceSyncedBlockExperience,
unsyncSourceSyncedBlockExperience,
deleteSourceSyncedBlockExperience,
syncedLocationsExperience
});
},
options: {
capture: true
}
});
const unbindKeydownListener = bind(document, {
type: 'keydown',
listener: event => {
if (isEnterKey(event.key)) {
const typeaheadPopup = popupWithNestedElement(getPopupsTarget(), '.fabric-editor-typeahead');
if (!typeaheadPopup || !(typeaheadPopup instanceof HTMLElement)) {
return;
}
const targetElement = typeaheadPopup.querySelector('[role="option"][aria-selected="true"]');
if (!targetElement || !(targetElement instanceof HTMLElement)) {
return;
}
const testId = targetElement.dataset.testid;
if (testId === SYNCED_BLOCK_BUTTON_TEST_ID.quickInsertCreate) {
createSourceQuickInsertMenuExperience.start();
}
}
},
options: {
capture: true
}
});
return {
unbindClickListener,
unbindKeydownListener
};
};
return new SafePlugin({
key: pluginKey,
view: view => {
var _syncedBlockPluginKey;
editorViewRef.current = view;
// Track whether listeners have been bound. When the experiment is
// ON and the document initially has no synced blocks, we defer
// binding until `update()` detects that `hasSyncedBlocks` has
// flipped to `true` (e.g. via paste or collab insert). This avoids
// the ~2-5 ms TBT cost of capture-phase click/keydown handlers on
// the ~99.97 % of pages that never use synced blocks (EDITOR-6931).
let listenersBound = false;
let unbindClickListener;
let unbindKeydownListener;
const ensureListenersBound = () => {
if (listenersBound) {
return;
}
listenersBound = true;
const unbinders = bindListeners();
unbindClickListener = unbinders.unbindClickListener;
unbindKeydownListener = unbinders.unbindKeydownListener;
};
if ((_syncedBlockPluginKey = syncedBlockPluginKey.getState(view.state)) !== null && _syncedBlockPluginKey !== void 0 && _syncedBlockPluginKey.hasSyncedBlocks) {
ensureListenersBound();
}
return {
update: (view, prevState) => {
var _syncedBlockPluginKey2;
if (!listenersBound && view.state.doc !== prevState.doc && (_syncedBlockPluginKey2 = syncedBlockPluginKey.getState(view.state)) !== null && _syncedBlockPluginKey2 !== void 0 && _syncedBlockPluginKey2.hasSyncedBlocks) {
// Bind listeners now that synced blocks are present.
ensureListenersBound();
}
},
destroy: () => {
var _unbindClickListener, _unbindKeydownListene;
createSourcePrimaryToolbarExperience.abort({
reason: 'editorDestroyed'
});
createSourceBlockMenuExperience.abort({
reason: 'editorDestroyed'
});
createSourceQuickInsertMenuExperience.abort({
reason: 'editorDestroyed'
});
deleteReferenceSyncedBlockExperience.abort({
reason: 'editorDestroyed'
});
deleteSourceSyncedBlockExperience === null || deleteSourceSyncedBlockExperience === void 0 ? void 0 : deleteSourceSyncedBlockExperience.abort({
reason: 'editorDestroyed'
});
unsyncReferenceSyncedBlockExperience === null || unsyncReferenceSyncedBlockExperience === void 0 ? void 0 : unsyncReferenceSyncedBlockExperience.abort({
reason: 'editorDestroyed'
});
unsyncSourceSyncedBlockExperience === null || unsyncSourceSyncedBlockExperience === void 0 ? void 0 : unsyncSourceSyncedBlockExperience.abort({
reason: 'editorDestroyed'
});
syncedLocationsExperience === null || syncedLocationsExperience === void 0 ? void 0 : syncedLocationsExperience.abort({
reason: 'editorDestroyed'
});
(_unbindClickListener = unbindClickListener) === null || _unbindClickListener === void 0 ? void 0 : _unbindClickListener();
(_unbindKeydownListene = unbindKeydownListener) === null || _unbindKeydownListene === void 0 ? void 0 : _unbindKeydownListene();
}
};
}
});
};
const isSyncedBlockButtonId = value => {
return !!value && syncedBlockButtonIds.has(value);
};
const handleButtonClick = ({
testId,
button,
createSourcePrimaryToolbarExperience,
createSourceBlockMenuExperience,
createSourceQuickInsertMenuExperience,
deleteReferenceSyncedBlockExperience,
unsyncReferenceSyncedBlockExperience,
unsyncSourceSyncedBlockExperience,
deleteSourceSyncedBlockExperience,
syncedLocationsExperience
}) => {
switch (testId) {
case SYNCED_BLOCK_BUTTON_TEST_ID.primaryToolbarCreate:
createSourcePrimaryToolbarExperience.start({
forceRestart: true
});
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.blockMenuCreate:
createSourceBlockMenuExperience.start({
forceRestart: true
});
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.quickInsertCreate:
createSourceQuickInsertMenuExperience.start({
forceRestart: true
});
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarReferenceDelete:
deleteReferenceSyncedBlockExperience.start({
forceRestart: true
});
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarReferenceUnsync:
unsyncReferenceSyncedBlockExperience === null || unsyncReferenceSyncedBlockExperience === void 0 ? void 0 : unsyncReferenceSyncedBlockExperience.start({
forceRestart: true
});
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarSourceUnsync:
unsyncSourceSyncedBlockExperience === null || unsyncSourceSyncedBlockExperience === void 0 ? void 0 : unsyncSourceSyncedBlockExperience.start({
forceRestart: true
});
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarSourceDelete:
deleteSourceSyncedBlockExperience === null || deleteSourceSyncedBlockExperience === void 0 ? void 0 : deleteSourceSyncedBlockExperience.start({
forceRestart: true
});
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarSyncedLocationsTrigger:
// Only track when opening the dropdown
if (button.getAttribute('aria-pressed') === 'false') {
syncedLocationsExperience === null || syncedLocationsExperience === void 0 ? void 0 : syncedLocationsExperience.start({
forceRestart: true
});
}
break;
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarSourceOverflowTrigger:
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarReferenceOverflowTrigger:
case SYNCED_BLOCK_BUTTON_TEST_ID.syncedBlockToolbarCopy:
break;
default:
{
// Exhaustiveness check: if a new SyncedBlockToolbarButtonId is added
// but not handled above, TypeScript will error here.
const _exhaustiveCheck = testId;
return _exhaustiveCheck;
}
}
};
const isEnterKey = key => {
return key === 'Enter';
};
const syncedBlockAddedToDomCheck = (refs, editorViewRef) => new ExperienceCheckDomMutation({
onDomMutation: ({
mutations
}) => {
if (mutations.some(isBodiedSyncBlockAddedInMutation)) {
return {
status: 'success'
};
}
return undefined;
},
observeConfig: () => {
var _editorViewRef$curren2;
return [{
target: editorViewRef === null || editorViewRef === void 0 ? void 0 : (_editorViewRef$curren2 = editorViewRef.current) === null || _editorViewRef$curren2 === void 0 ? void 0 : _editorViewRef$curren2.dom,
options: {
childList: true
}
},
// When wrapping a node with breakout mark with sync block, breakout dom is reused
// hence we need to observe subtree to catch sync block mutation
{
target: getSelectionAncestorDOM(editorViewRef === null || editorViewRef === void 0 ? void 0 : editorViewRef.current),
options: {
childList: true,
subtree: true
}
}];
}
});
const isBodiedSyncBlockAddedInMutation = ({
type,
addedNodes
}) => {
return type === 'childList' && [...addedNodes].some(isBodiedSyncBlockWithinNode);
};
const isBodiedSyncBlockWithinNode = node => getNodeQuery('[data-prosemirror-node-name="bodiedSyncBlock"]')(node);
const referenceSyncBlockRemovedFromDomCheck = (refs, editorViewRef) => new ExperienceCheckDomMutation({
onDomMutation: ({
mutations
}) => {
if (mutations.some(isSyncBlockRemovedInMutation)) {
return {
status: 'success'
};
}
return undefined;
},
observeConfig: () => {
var _editorViewRef$curren3;
return [{
target: editorViewRef === null || editorViewRef === void 0 ? void 0 : (_editorViewRef$curren3 = editorViewRef.current) === null || _editorViewRef$curren3 === void 0 ? void 0 : _editorViewRef$curren3.dom,
options: {
childList: true
}
}, {
target: getSelectionAncestorDOM(editorViewRef === null || editorViewRef === void 0 ? void 0 : editorViewRef.current),
options: {
childList: true,
subtree: true
}
}];
}
});
const isSyncBlockRemovedInMutation = ({
type,
removedNodes
}) => {
return type === 'childList' && [...removedNodes].some(isSyncBlockWithinNode);
};
const isSyncBlockWithinNode = node => getNodeQuery('[data-prosemirror-node-name="syncBlock"]')(node);
const syncBlockDeleteConfirmationModalAddedCheck = () => new ExperienceCheckDomMutation({
onDomMutation: ({
mutations
}) => {
if (mutations.some(isDeleteConfirmationModalAddedInMutation)) {
return {
status: 'success'
};
}
return undefined;
},
observeConfig: () => {
return {
target: document.body,
options: {
childList: true,
subtree: true
}
};
}
});
const isDeleteConfirmationModalAddedInMutation = ({
type,
addedNodes
}) => {
return type === 'childList' && [...addedNodes].some(isDeleteConfirmationModalWithinNode);
};
const isDeleteConfirmationModalWithinNode = node => getNodeQuery('[data-testid="sync-block-delete-confirmation"]')(node);
const syncedLocationsDropdownOpenedCheck = () => new ExperienceCheckDomMutation({
onDomMutation: ({
mutations
}) => {
if (mutations.some(isSyncedLocationsDropdownErrorInMutation)) {
return {
status: 'failure'
};
}
if (mutations.some(isSyncedLocationsDropdownAddedInMutation)) {
return {
status: 'success'
};
}
return undefined;
},
observeConfig: () => {
return {
target: document.body,
options: {
childList: true,
subtree: true
}
};
}
});
const isSyncedLocationsDropdownAddedInMutation = ({
type,
addedNodes
}) => {
return type === 'childList' && [...addedNodes].some(isSyncedLocationsDropdownWithinNode);
};
const isSyncedLocationsDropdownErrorInMutation = ({
type,
addedNodes
}) => {
return type === 'childList' && [...addedNodes].some(isSyncedLocationsDropdownErrorWithinNode);
};
const isSyncedLocationsDropdownWithinNode = node => {
return !!(getNodeQuery('[data-testid="synced-locations-dropdown-content"]')(node) || getNodeQuery('[data-testid="synced-locations-dropdown-content-no-results"]')(node));
};
const isSyncedLocationsDropdownErrorWithinNode = node => {
return !!getNodeQuery('[data-testid="synced-locations-dropdown-content-error"]')(node);
};