UNPKG

@atlaskit/editor-plugin-local-id

Version:

LocalId plugin for @atlaskit/editor-core

242 lines (239 loc) 10.5 kB
import { BatchAttrsStep } from '@atlaskit/adf-schema/steps'; import { tintDirtyTransaction } from '@atlaskit/editor-common/collab'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { stepHasSlice } from '@atlaskit/editor-common/utils'; import { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { generateShortUUID, generatedShortUUIDs } from './generateShortUUID'; export const localIdPluginKey = new PluginKey('localIdPlugin'); const generateUUID = () => { return generateShortUUID(); }; // Fallback for Safari which doesn't support requestIdleCallback const requestIdleCallbackWithFallback = callback => { if (typeof requestIdleCallback !== 'undefined') { requestIdleCallback(callback); } else { // Fallback to requestAnimationFrame for Safari requestAnimationFrame(callback); } }; export const createPlugin = api => { // Track if we've initialized existing UUIDs for this plugin instance let hasInitializedExistingUUIDs = false; return new SafePlugin({ key: localIdPluginKey, view: editorView => { /** * This performs a one-time scan of the document to add local IDs * to nodes that don't have them. It's designed to run only once per * editor instance to avoid performance issues. */ if (api !== null && api !== void 0 && api.collabEdit) { return { update: () => {} }; } requestIdleCallbackWithFallback(() => { const tr = editorView.state.tr; const nodesToUpdate = new Map(); // position -> localId const { text, hardBreak, mediaGroup } = editorView.state.schema.nodes; // Media group is ignored for now // https://bitbucket.org/atlassian/adf-schema/src/fb2236147a0c2bc9c8efbdb75fd8f8c411df44ba/packages/adf-schema/src/next-schema/nodes/mediaGroup.ts#lines-12 const ignoredNodeTypes = mediaGroup ? [text.name, hardBreak.name, mediaGroup.name] : [text.name, hardBreak.name]; editorView.state.doc.descendants((node, pos) => { var _node$type$spec$attrs; if (!ignoredNodeTypes.includes(node.type.name) && !node.attrs.localId && !!((_node$type$spec$attrs = node.type.spec.attrs) !== null && _node$type$spec$attrs !== void 0 && _node$type$spec$attrs.localId)) { nodesToUpdate.set(pos, generateUUID()); } return true; // Continue traversing }); if (nodesToUpdate.size > 0) { batchAddLocalIdToNodes(nodesToUpdate, tr); tintDirtyTransaction(tr); editorView.dispatch(tr); } }); return { update: () => {} }; }, /** * Handles adding local IDs to new nodes that are created and have the localId attribute * This ensures uniqueness of localIds on nodes being created or edited */ appendTransaction: (transactions, _oldState, newState) => { var _api$composition$shar; if (api !== null && api !== void 0 && (_api$composition$shar = api.composition.sharedState.currentState()) !== null && _api$composition$shar !== void 0 && _api$composition$shar.isComposing) { return undefined; } let modified = false; const tr = newState.tr; const { text, hardBreak, mediaGroup } = newState.schema.nodes; // Media group is ignored for now // https://bitbucket.org/atlassian/adf-schema/src/fb2236147a0c2bc9c8efbdb75fd8f8c411df44ba/packages/adf-schema/src/next-schema/nodes/mediaGroup.ts#lines-12 const ignoredNodeTypes = [text === null || text === void 0 ? void 0 : text.name, hardBreak === null || hardBreak === void 0 ? void 0 : hardBreak.name, mediaGroup === null || mediaGroup === void 0 ? void 0 : mediaGroup.name]; const addedNodes = new Set(); // A single PMNode reference can appear at multiple positions in the doc (e.g. // `createTable` from `prosemirror-utils` reuses cell node objects across // non-header rows), so the new code path tracks every position per node identity. // The legacy path retains the single-position-per-node map for compatibility. const positionsByNode = new Map(); const addedNodePos = new Map(); const localIds = new Set(); const nodesToUpdate = new Map(); // position -> localId // Process only the nodes added in the transactions transactions.forEach(transaction => { if (!transaction.docChanged) { return; } if (transaction.getMeta('uiEvent') === 'cut' || // We skip remote transactions as we don't want to affect transactions created // by other users Boolean(transaction.getMeta('isRemote'))) { return; } // Ignore local ID updates for certain transactions // this is purposely not a public API as we should not use // this except in some circumstances (ie. streaming) if (transaction.getMeta('ignoreLocalIdUpdate')) { return; } transaction.steps.forEach(step => { if (!stepHasSlice(step)) { return; } step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => { // Scan the changed range to find all nodes tr.doc.nodesBetween(newStart, Math.min(newEnd, tr.doc.content.size), (node, pos) => { var _node$type$spec$attrs2; if (ignoredNodeTypes.includes(node.type.name) || !((_node$type$spec$attrs2 = node.type.spec.attrs) !== null && _node$type$spec$attrs2 !== void 0 && _node$type$spec$attrs2.localId)) { return true; } modified = true; if (fg('platform_editor_use_localid_dedupe')) { // Always add to addedNodes for duplicate prevention addedNodes.add(node); if (expValEquals('platform_editor_ai_tablecell_localids', 'isEnabled', true)) { var _positionsByNode$get; const positions = (_positionsByNode$get = positionsByNode.get(node)) !== null && _positionsByNode$get !== void 0 ? _positionsByNode$get : new Set(); positions.add(pos); positionsByNode.set(node, positions); } else { addedNodePos.set(node, pos); } } else { if (!(node !== null && node !== void 0 && node.attrs.localId)) { nodesToUpdate.set(pos, generateUUID()); } } return true; }); }); }); }); if (addedNodes.size > 0 && fg('platform_editor_use_localid_dedupe')) { newState.doc.descendants(node => { var _node$attrs; // Also track existing UUIDs in the global Set for short UUID collision detection if ((_node$attrs = node.attrs) !== null && _node$attrs !== void 0 && _node$attrs.localId && !hasInitializedExistingUUIDs) { generatedShortUUIDs.add(node.attrs.localId); } if (addedNodes.has(node)) { return true; } localIds.add(node.attrs.localId); return true; }); hasInitializedExistingUUIDs = true; // Also ensure the added have no duplicates const seenIds = new Set(); if (expValEquals('platform_editor_ai_tablecell_localids', 'isEnabled', true)) { for (const node of addedNodes) { const positions = positionsByNode.get(node); if (!positions || positions.size === 0) { continue; } const existingId = node.attrs.localId; const needsNewIds = !existingId || localIds.has(existingId) || seenIds.has(existingId); if (needsNewIds) { // No usable localId: assign a fresh unique one to every position. for (const pos of positions) { const newId = generateUUID(); nodesToUpdate.set(pos, newId); seenIds.add(newId); modified = true; } } else if (positions.size > 1) { // Shared node reference: keep the existing id at the first position, // assign fresh ones to the rest so they don't share the same localId. seenIds.add(existingId); const [_first, ...rest] = Array.from(positions); for (const pos of rest) { const newId = generateUUID(); nodesToUpdate.set(pos, newId); seenIds.add(newId); modified = true; } } else if (existingId) { seenIds.add(existingId); } } } else { for (const node of addedNodes) { if (!node.attrs.localId || localIds.has(node.attrs.localId) || seenIds.has(node.attrs.localId)) { const pos = addedNodePos.get(node); if (pos !== undefined) { const newId = generateUUID(); nodesToUpdate.set(pos, newId); seenIds.add(newId); modified = true; } } if (node.attrs.localId) { seenIds.add(node.attrs.localId); } } } } // Apply local ID updates based on the improvements feature flag: // - When enabled: Batch all updates into a single BatchAttrsStep // - When disabled: Individual steps were already applied above during node processing if (modified && nodesToUpdate.size > 0) { batchAddLocalIdToNodes(nodesToUpdate, tr); } return modified ? tr : undefined; } }); }; /** * Batch adds local IDs to nodes using a BatchAttrsStep * @param nodesToUpdate Map of position -> localId for nodes that need updates * @param tr */ export const batchAddLocalIdToNodes = (nodesToUpdate, tr) => { const batchData = Array.from(nodesToUpdate.entries()).map(([pos, localId]) => { const node = tr.doc.nodeAt(pos); if (!node) { throw new Error(`Node does not exist at position ${pos}`); } return { position: pos, attrs: { localId }, nodeType: node.type.name }; }); tr.step(new BatchAttrsStep(batchData)); tr.setMeta('addToHistory', false); };