@atlaskit/editor-plugin-local-id
Version:
LocalId plugin for @atlaskit/editor-core
207 lines (202 loc) • 9.69 kB
JavaScript
import { OverrideDocumentStep } from '@atlaskit/adf-schema/steps/override-document-step';
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 { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { addMissingLocalIds } from '../editor-commands';
import { batchAddLocalIdToNodes, localIdNotEmpty } from './add-missing-local-ids';
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(() => {
if (isExperimentEnabled('platform_editor_local_id_reliability')) {
api === null || api === void 0 ? void 0 : api.core.actions.execute(addMissingLocalIds());
return;
}
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 we track every position per node identity.
const positionsByNode = 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 collaborators.
//
// Applying a template to a blank page arrives as a remote
// OverrideDocumentStep (a full-document replacement with no localIds).
// We still want those template nodes to get localIds, so we let such
// transactions through. Ordinary remote edits are still skipped, and
// existing localIds are never overwritten.
Boolean(transaction.getMeta('isRemote')) && !transaction.steps.some(step => step instanceof OverrideDocumentStep)) {
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 => {
// Steps with a slice are scanned for nodes that need localIds.
// OverrideDocumentStep (template replacement) has no `slice`, so we also
// scan it so the inserted template nodes get localIds.
const isTemplateOverrideStep = step instanceof OverrideDocumentStep;
if (!stepHasSlice(step) && !isTemplateOverrideStep) {
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, _positionsByNode$get;
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;
// Always add to addedNodes for duplicate prevention
addedNodes.add(node);
const positions = (_positionsByNode$get = positionsByNode.get(node)) !== null && _positionsByNode$get !== void 0 ? _positionsByNode$get : new Set();
positions.add(pos);
positionsByNode.set(node, positions);
return true;
});
});
});
});
if (addedNodes.size > 0) {
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();
for (const node of addedNodes) {
const positions = positionsByNode.get(node);
if (!positions || positions.size === 0) {
continue;
}
const existingId = node.attrs.localId;
const needsNewIds = (isExperimentEnabled('platform_editor_local_id_reliability') ? !localIdNotEmpty(existingId) : !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);
}
}
}
// 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;
}
});
};