@atlaskit/editor-plugin-local-id
Version:
LocalId plugin for @atlaskit/editor-core
287 lines (284 loc) • 14 kB
JavaScript
import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
import _toArray from "@babel/runtime/helpers/toArray";
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
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 var localIdPluginKey = new PluginKey('localIdPlugin');
var generateUUID = function generateUUID() {
return generateShortUUID();
};
// Fallback for Safari which doesn't support requestIdleCallback
var requestIdleCallbackWithFallback = function requestIdleCallbackWithFallback(callback) {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(callback);
} else {
// Fallback to requestAnimationFrame for Safari
requestAnimationFrame(callback);
}
};
export var createPlugin = function createPlugin(api) {
// Track if we've initialized existing UUIDs for this plugin instance
var hasInitializedExistingUUIDs = false;
return new SafePlugin({
key: localIdPluginKey,
view: function 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: function update() {}
};
}
requestIdleCallbackWithFallback(function () {
var tr = editorView.state.tr;
var nodesToUpdate = new Map(); // position -> localId
var _editorView$state$sch = editorView.state.schema.nodes,
text = _editorView$state$sch.text,
hardBreak = _editorView$state$sch.hardBreak,
mediaGroup = _editorView$state$sch.mediaGroup;
// 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
var ignoredNodeTypes = mediaGroup ? [text.name, hardBreak.name, mediaGroup.name] : [text.name, hardBreak.name];
editorView.state.doc.descendants(function (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: function 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: function 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;
}
var modified = false;
var tr = newState.tr;
var _newState$schema$node = newState.schema.nodes,
text = _newState$schema$node.text,
hardBreak = _newState$schema$node.hardBreak,
mediaGroup = _newState$schema$node.mediaGroup;
// 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
var 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];
var 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.
var positionsByNode = new Map();
var addedNodePos = new Map();
var localIds = new Set();
var nodesToUpdate = new Map(); // position -> localId
// Process only the nodes added in the transactions
transactions.forEach(function (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(function (step) {
if (!stepHasSlice(step)) {
return;
}
step.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) {
// Scan the changed range to find all nodes
tr.doc.nodesBetween(newStart, Math.min(newEnd, tr.doc.content.size), function (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;
var 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(function (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
var seenIds = new Set();
if (expValEquals('platform_editor_ai_tablecell_localids', 'isEnabled', true)) {
var _iterator = _createForOfIteratorHelper(addedNodes),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var node = _step.value;
var positions = positionsByNode.get(node);
if (!positions || positions.size === 0) {
continue;
}
var existingId = node.attrs.localId;
var needsNewIds = !existingId || localIds.has(existingId) || seenIds.has(existingId);
if (needsNewIds) {
// No usable localId: assign a fresh unique one to every position.
var _iterator2 = _createForOfIteratorHelper(positions),
_step2;
try {
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
var pos = _step2.value;
var newId = generateUUID();
nodesToUpdate.set(pos, newId);
seenIds.add(newId);
modified = true;
}
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
} 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);
var _Array$from = Array.from(positions),
_Array$from2 = _toArray(_Array$from),
_first = _Array$from2[0],
rest = _arrayLikeToArray(_Array$from2).slice(1);
var _iterator3 = _createForOfIteratorHelper(rest),
_step3;
try {
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
var _pos = _step3.value;
var _newId = generateUUID();
nodesToUpdate.set(_pos, _newId);
seenIds.add(_newId);
modified = true;
}
} catch (err) {
_iterator3.e(err);
} finally {
_iterator3.f();
}
} else if (existingId) {
seenIds.add(existingId);
}
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
} else {
var _iterator4 = _createForOfIteratorHelper(addedNodes),
_step4;
try {
for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
var _node = _step4.value;
if (!_node.attrs.localId || localIds.has(_node.attrs.localId) || seenIds.has(_node.attrs.localId)) {
var _pos2 = addedNodePos.get(_node);
if (_pos2 !== undefined) {
var _newId2 = generateUUID();
nodesToUpdate.set(_pos2, _newId2);
seenIds.add(_newId2);
modified = true;
}
}
if (_node.attrs.localId) {
seenIds.add(_node.attrs.localId);
}
}
} catch (err) {
_iterator4.e(err);
} finally {
_iterator4.f();
}
}
}
// 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 var batchAddLocalIdToNodes = function batchAddLocalIdToNodes(nodesToUpdate, tr) {
var batchData = Array.from(nodesToUpdate.entries()).map(function (_ref) {
var _ref2 = _slicedToArray(_ref, 2),
pos = _ref2[0],
localId = _ref2[1];
var node = tr.doc.nodeAt(pos);
if (!node) {
throw new Error("Node does not exist at position ".concat(pos));
}
return {
position: pos,
attrs: {
localId: localId
},
nodeType: node.type.name
};
});
tr.step(new BatchAttrsStep(batchData));
tr.setMeta('addToHistory', false);
};