UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

256 lines (249 loc) • 14 kB
import { BLOCK_CONTROLS_LEFT_SURFACE, BLOCK_CONTROLS_RIGHT_SURFACE } from '@atlaskit/editor-common/block-controls/surface-keys'; import { getNodeIdProvider } from '@atlaskit/editor-common/node-anchor-provider'; import { resolveSurface } from '@atlaskit/editor-ui-control-model/surface-renderer'; import { createSurfaceAnchorDecorations, TYPE_SURFACE_ANCHOR_DEC } from '../pm-plugins/decorations-surface-anchor'; import { key as blockControlsKey } from '../pm-plugins/main'; import { bindSparseGutterHover } from '../pm-plugins/sparse-gutter-hover'; import { reconcileSparseSurfaceCandidates } from '../pm-plugins/utils/sparse-surface-candidates'; import { observeSurfaceViewport } from '../pm-plugins/utils/surface-viewport'; import { getBlockControlsSurfaceTargets } from '../ui/block-controls-surface-targets'; import { createSurfaceDescriptorResolver } from './block-controls-surface-descriptors'; import { getSurfaceLifecycle } from './surface-lifecycle'; const haveAnchorPositionsChanged = (positions, anchors) => positions.length !== anchors.size || positions.some(position => !anchors.has(position)); const areAnchorsEqual = (a, b) => a.size === b.size && [...a].every(([position, name]) => b.get(position) === name); /** Limited mode and dragging remove every sparse anchor decoration from the document. */ const areSurfacesSuppressed = (api, state) => { var _api$limitedMode, _api$limitedMode$shar, _blockControlsKey$get; return Boolean((api === null || api === void 0 ? void 0 : (_api$limitedMode = api.limitedMode) === null || _api$limitedMode === void 0 ? void 0 : (_api$limitedMode$shar = _api$limitedMode.sharedState.currentState()) === null || _api$limitedMode$shar === void 0 ? void 0 : _api$limitedMode$shar.enabled) || ((_blockControlsKey$get = blockControlsKey.getState(state)) === null || _blockControlsKey$get === void 0 ? void 0 : _blockControlsKey$get.isDragging)); }; /** An open block menu keeps its trigger node, and that node's root, as active surface positions. */ const getMenuActiveNodes = (controls, doc) => { const activeNodesByPosition = new Map(); const menu = controls !== null && controls !== void 0 && controls.isMenuOpen ? controls.menuTriggerByNode : undefined; if (menu && controls !== null && controls !== void 0 && controls.menuTriggerBy && doc.nodeAt(menu.pos)) { var _menu$rootPos; const menuNode = { ...menu, anchorName: controls.menuTriggerBy }; activeNodesByPosition.set(menu.pos, menuNode); activeNodesByPosition.set((_menu$rootPos = menu.rootPos) !== null && _menu$rootPos !== void 0 ? _menu$rootPos : menu.pos, menuNode); } return activeNodesByPosition; }; const reconcileCandidates = ({ activeNode, activeNodesByPosition, api, previous, registryChanged, state, visiblePositions }) => reconcileSparseSurfaceCandidates({ activeNode, activeNodesByPosition, newState: state, state: previous.candidates, visiblePositions, protectedPositions: [...activeNodesByPosition.keys()], resolvedSurfaces: [BLOCK_CONTROLS_LEFT_SURFACE, BLOCK_CONTROLS_RIGHT_SURFACE].map(surface => { var _api$uiControlRegistr, _api$uiControlRegistr2; return resolveSurface((_api$uiControlRegistr = api === null || api === void 0 ? void 0 : (_api$uiControlRegistr2 = api.uiControlRegistry) === null || _api$uiControlRegistr2 === void 0 ? void 0 : _api$uiControlRegistr2.actions.getComponents(surface)) !== null && _api$uiControlRegistr !== void 0 ? _api$uiControlRegistr : [], surface); }), documentChanged: previous.documentChanged, invalidation: { ...previous.invalidation, all: registryChanged } }); /** True when the committed state already matches the candidates and no anchor awaits retirement. */ const isReconciled = (previous, candidates, lifecycle) => !haveAnchorPositionsChanged(candidates.positions, previous.anchors) && candidates === previous.candidates && !previous.documentChanged && !previous.invalidation && !lifecycle.hasRetirableAnchors(previous.decorations); /** * Builds anchor decorations for the candidate positions, keeping each node's existing anchor name * and any anchor a committed or pending surface still uses. */ const createReconciledDecorations = (view, previous, positions, lifecycle) => { const previousAnchors = new Map(previous.decorations.find(undefined, undefined, spec => spec.type === TYPE_SURFACE_ANCHOR_DEC).map(decoration => [decoration.from, String(decoration.spec.anchorName)])); const nodeIdProvider = getNodeIdProvider(view); const { anchors: nextAnchors, decorations: desiredDecorations } = createSurfaceAnchorDecorations(view.state, positions, (node, position, previousAnchorName) => { if (previousAnchorName) { nodeIdProvider.setIdForNode(node, previousAnchorName); return previousAnchorName; } return nodeIdProvider.getOrGenerateId(node, position); }, previousAnchors); return { // Retiring anchors after a commit leaves the anchor map unchanged, so keep its identity. anchors: areAnchorsEqual(nextAnchors, previous.anchors) ? previous.anchors : nextAnchors, decorations: lifecycle.retainAnchors(view.state.doc, desiredDecorations, previous.decorations) }; }; const resolveSurfaceSnapshot = ({ activeNode, activeNodesByPosition, anchors, api, invalidated, positions, resolvers, state }) => { var _activeNode$handleOpt, _api$uiControlRegistr3, _api$uiControlRegistr4, _api$uiControlRegistr5, _api$uiControlRegistr6; const candidatePositions = new Set(positions); const input = { activeNode, state, surfaceActiveNodes: activeNodesByPosition, surfaceAnchors: anchors, targets: getBlockControlsSurfaceTargets(activeNode, positions).filter(({ position }) => candidatePositions.has(position)) }; return { forceVisibleOnMouseOut: Boolean(activeNode === null || activeNode === void 0 ? void 0 : (_activeNode$handleOpt = activeNode.handleOptions) === null || _activeNode$handleOpt === void 0 ? void 0 : _activeNode$handleOpt.isFocused), left: resolvers.left({ ...input, components: (_api$uiControlRegistr3 = api === null || api === void 0 ? void 0 : (_api$uiControlRegistr4 = api.uiControlRegistry) === null || _api$uiControlRegistr4 === void 0 ? void 0 : _api$uiControlRegistr4.actions.getComponents(BLOCK_CONTROLS_LEFT_SURFACE)) !== null && _api$uiControlRegistr3 !== void 0 ? _api$uiControlRegistr3 : [] }, invalidated), right: resolvers.right({ ...input, components: (_api$uiControlRegistr5 = api === null || api === void 0 ? void 0 : (_api$uiControlRegistr6 = api.uiControlRegistry) === null || _api$uiControlRegistr6 === void 0 ? void 0 : _api$uiControlRegistr6.actions.getComponents(BLOCK_CONTROLS_RIGHT_SURFACE)) !== null && _api$uiControlRegistr5 !== void 0 ? _api$uiControlRegistr5 : [] }, invalidated) }; }; /** Changes that can add or remove anchor suppression, which also invalidates control visibility. */ const haveSuppressionInputsChanged = (controls, previousControls) => (controls === null || controls === void 0 ? void 0 : controls.isDragging) !== (previousControls === null || previousControls === void 0 ? void 0 : previousControls.isDragging) || (controls === null || controls === void 0 ? void 0 : controls.isResizerResizing) !== (previousControls === null || previousControls === void 0 ? void 0 : previousControls.isResizerResizing); /** Changes that can move surfaces between blocks without invalidating control visibility. */ const haveSurfaceInputsChanged = (sparse, controls, previousControls) => Boolean((sparse === null || sparse === void 0 ? void 0 : sparse.documentChanged) || (sparse === null || sparse === void 0 ? void 0 : sparse.invalidation)) || (controls === null || controls === void 0 ? void 0 : controls.activeNode) !== (previousControls === null || previousControls === void 0 ? void 0 : previousControls.activeNode) || (controls === null || controls === void 0 ? void 0 : controls.isMenuOpen) !== (previousControls === null || previousControls === void 0 ? void 0 : previousControls.isMenuOpen) || (controls === null || controls === void 0 ? void 0 : controls.menuTriggerByNode) !== (previousControls === null || previousControls === void 0 ? void 0 : previousControls.menuTriggerByNode); const subscribeToInvalidation = (api, invalidate) => { var _api$uiControlRegistr7, _api$editorDisabled, _api$editorViewMode, _api$limitedMode2, _api$showDiff, _api$userIntent; const unsubscribers = [api === null || api === void 0 ? void 0 : (_api$uiControlRegistr7 = api.uiControlRegistry) === null || _api$uiControlRegistr7 === void 0 ? void 0 : _api$uiControlRegistr7.actions.subscribe(invalidate), api === null || api === void 0 ? void 0 : (_api$editorDisabled = api.editorDisabled) === null || _api$editorDisabled === void 0 ? void 0 : _api$editorDisabled.sharedState.onChange(invalidate), api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : _api$editorViewMode.sharedState.onChange(invalidate), api === null || api === void 0 ? void 0 : (_api$limitedMode2 = api.limitedMode) === null || _api$limitedMode2 === void 0 ? void 0 : _api$limitedMode2.sharedState.onChange(invalidate), api === null || api === void 0 ? void 0 : (_api$showDiff = api.showDiff) === null || _api$showDiff === void 0 ? void 0 : _api$showDiff.sharedState.onChange(invalidate), api === null || api === void 0 ? void 0 : (_api$userIntent = api.userIntent) === null || _api$userIntent === void 0 ? void 0 : _api$userIntent.sharedState.onChange(invalidate)]; return () => unsubscribers.forEach(unsubscribe => unsubscribe === null || unsubscribe === void 0 ? void 0 : unsubscribe()); }; /** * Sparse surfaces plugin view for the CLS fix. Each animation frame reconciles which blocks have * surfaces, commits their anchor decorations, and publishes the surfaces React renders through * `SurfaceLifecycle`, which retains anchors until React commits their replacement. */ export const createSparseSurfacesViewWithLifecycle = ({ api, pluginKey, view }) => { const win = view.dom.ownerDocument.defaultView; const lifecycle = getSurfaceLifecycle(view); const resolvers = { left: createSurfaceDescriptorResolver('left'), right: createSurfaceDescriptorResolver('right') }; let frame; let visiblePositions = []; let registryChanged = true; let surfaceStateChanged = false; const updateAvailableAnchors = () => { const sparse = pluginKey.getState(view.state); if (sparse) { lifecycle.updateAnchors(sparse.decorations, areSurfacesSuppressed(api, view.state)); } }; const reconcile = () => { var _api$limitedMode3, _api$limitedMode3$sha; frame = undefined; const previous = pluginKey.getState(view.state); if (!previous || api !== null && api !== void 0 && (_api$limitedMode3 = api.limitedMode) !== null && _api$limitedMode3 !== void 0 && (_api$limitedMode3$sha = _api$limitedMode3.sharedState.currentState()) !== null && _api$limitedMode3$sha !== void 0 && _api$limitedMode3$sha.enabled) { return; } const controls = blockControlsKey.getState(view.state); const activeNode = controls === null || controls === void 0 ? void 0 : controls.activeNode; const activeNodesByPosition = getMenuActiveNodes(controls, view.state.doc); const candidates = reconcileCandidates({ activeNode, activeNodesByPosition, api, previous, registryChanged, state: view.state, visiblePositions }); const visibilityChanged = registryChanged; registryChanged = false; if (!surfaceStateChanged && !visibilityChanged && isReconciled(previous, candidates, lifecycle)) { return; } surfaceStateChanged = false; const { anchors, decorations } = createReconciledDecorations(view, previous, candidates.positions, lifecycle); view.dispatch(view.state.tr.setMeta(pluginKey, { reconciliation: { activeNodesByPosition, anchors, candidates, decorations }, type: 'commitReconciliation' })); lifecycle.publish(resolveSurfaceSnapshot({ activeNode, activeNodesByPosition, anchors, api, invalidated: visibilityChanged || Boolean(previous.invalidation), positions: candidates.positions, resolvers, state: view.state }), anchors); }; const schedule = () => { if (frame === undefined && win) { frame = win.requestAnimationFrame(reconcile); } }; const invalidate = () => { if (areSurfacesSuppressed(api, view.state)) { updateAvailableAnchors(); } registryChanged = true; schedule(); }; updateAvailableAnchors(); // A committed tree may no longer need older anchors; the next reconcile retires them. lifecycle.setOnCommit(schedule); const observer = observeSurfaceViewport(view, positions => { visiblePositions = positions; schedule(); }); const unsubscribe = subscribeToInvalidation(api, invalidate); const unbindGutterHover = bindSparseGutterHover({ api, view }); return { update(currentView, previousState) { updateAvailableAnchors(); observer.update(); const controls = blockControlsKey.getState(currentView.state); const previousControls = blockControlsKey.getState(previousState); if (haveSuppressionInputsChanged(controls, previousControls)) { invalidate(); } if (haveSurfaceInputsChanged(pluginKey.getState(currentView.state), controls, previousControls)) { surfaceStateChanged = true; schedule(); } }, destroy() { if (frame !== undefined) { win === null || win === void 0 ? void 0 : win.cancelAnimationFrame(frame); } lifecycle.destroy(); observer.destroy(); unbindGutterHover === null || unbindGutterHover === void 0 ? void 0 : unbindGutterHover(); unsubscribe(); } }; };