@atlaskit/editor-plugin-local-id
Version:
LocalId plugin for @atlaskit/editor-core
497 lines (471 loc) • 17.8 kB
JavaScript
import { BatchAttrsStep, SetAttrsStep } from '@atlaskit/adf-schema/steps';
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { PluginKey } from '@atlaskit/editor-prosemirror/state';
import { AttrStep, DocAttrStep, ReplaceAroundStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
/**
* This is a safeguard limit to avoid tracking localIds in extremely large documents with too many localIds.
* If the number of unique localIds exceeds this limit, the watchmen plugin will disable itself to avoid performance issues.
* Reminder: The Map has a hard limit of 2^24 (16 million) entries in V8, please keep this value well below that
* to avoid any potential memory issues.
*/
const MAX_LOCAL_ID_MAP_SIZE = 2097152; // 2^21
/**
* Plugin state tracking all localIds in the document
*/
export const localIdWatchmenPluginKey = new PluginKey('localIdWatchmenPlugin');
/**
* Scans the entire document to find all active localIds
*/
const scanDocumentForLocalIds = doc => {
const localIds = new Set();
doc.descendants(node => {
var _node$attrs;
if ((_node$attrs = node.attrs) !== null && _node$attrs !== void 0 && _node$attrs.localId) {
localIds.add(node.attrs.localId);
}
// Check marks for localIds
if (node.marks) {
node.marks.forEach(mark => {
var _mark$attrs;
if ((_mark$attrs = mark.attrs) !== null && _mark$attrs !== void 0 && _mark$attrs.localId) {
localIds.add(mark.attrs.localId);
}
});
}
return localIds.size < MAX_LOCAL_ID_MAP_SIZE; // Continue traversing
});
return localIds;
};
const getReplacementStatusCode = (tr, step) => {
let method;
if (step instanceof AttrStep || step instanceof DocAttrStep) {
method = 'ByAttr';
} else if (step instanceof SetAttrsStep) {
method = 'BySetAttrs';
} else if (step instanceof BatchAttrsStep) {
method = 'ByBatchAttrs';
} else if (step instanceof ReplaceStep) {
const isDeleting = step.from < step.to; // range has content to remove
const isInserting = step.slice.content.size > 0; // slice has content to insert
if (isDeleting && !isInserting) {
method = 'ByDelete'; // removing content, inserting nothing
//} else if (!isDeleting && isInserting) {
//method = 'ByInsert'; // This situation cannot be tracked since this would be part of the "current" status
} else {
// isDeleting && isInserting
method = 'ByReplace';
}
} else if (step instanceof ReplaceAroundStep) {
method = 'ByReplaceAround';
} else {
method = 'ByUnknown';
}
if (tr.getMeta('isAIStreamingTransformation')) {
return `AIChange${method}`;
}
if (tr.getMeta('replaceDocument')) {
return `docChange${method}`;
}
if (tr.getMeta('isRemote')) {
return `remoteChange${method}`;
}
return `localChange${method}`;
};
/**
* Handles AttrStep and DocAttrStep which modify a single attribute
*/
const handleAttrStep = (tr, step, localIdStatus, preDoc) => {
if (step.attr !== 'localId') {
return {
localIdStatus,
modified: false
};
}
let modified = false;
const newlocalIdStatus = new Map(localIdStatus);
// Get the old value if it exists
let oldLocalId;
if (step instanceof AttrStep) {
try {
var _node$attrs2;
const node = preDoc.nodeAt(step.pos);
oldLocalId = node === null || node === void 0 ? void 0 : (_node$attrs2 = node.attrs) === null || _node$attrs2 === void 0 ? void 0 : _node$attrs2.localId;
} catch {
// Position might be invalid
}
}
// Handle the new value
const newLocalId = step.value;
if (oldLocalId && oldLocalId !== newLocalId) {
// Old localId is being replaced or removed
newlocalIdStatus.set(oldLocalId, getReplacementStatusCode(tr, step));
modified = true;
}
if (newLocalId) {
newlocalIdStatus.set(newLocalId, 'current');
modified = true;
}
return {
localIdStatus: newlocalIdStatus,
modified
};
};
/**
* Handles SetAttrsStep which sets multiple attributes at once
*/
const handleSetAttrsStep = (tr, step, localIdStatus, preDoc) => {
const attrs = step.attrs;
if (!attrs || !attrs.hasOwnProperty('localId')) {
return {
localIdStatus,
modified: false
};
}
let modified = false;
const newlocalIdStatus = new Map(localIdStatus);
// Get old localId from the node being modified
try {
var _node$attrs3;
const node = preDoc.nodeAt(step.pos);
const oldLocalId = node === null || node === void 0 ? void 0 : (_node$attrs3 = node.attrs) === null || _node$attrs3 === void 0 ? void 0 : _node$attrs3.localId;
if (oldLocalId && oldLocalId !== attrs.localId) {
newlocalIdStatus.set(oldLocalId, getReplacementStatusCode(tr, step));
modified = true;
}
} catch {
// Position might be invalid
}
const newLocalId = attrs.localId;
if (newLocalId) {
newlocalIdStatus.set(newLocalId, 'current');
modified = true;
}
return {
localIdStatus: newlocalIdStatus,
modified
};
};
/**
* Handles BatchAttrsStep which applies multiple attribute changes
*/
const handleBatchAttrsStep = (tr, step, localIdStatus, preDoc) => {
let modified = false;
const newlocalIdStatus = new Map(localIdStatus);
step.data.forEach(change => {
var _change$attrs;
if (!((_change$attrs = change.attrs) !== null && _change$attrs !== void 0 && _change$attrs.hasOwnProperty('localId'))) {
return;
}
// Get old localId from the node being modified
try {
var _node$attrs4;
const node = preDoc.nodeAt(change.position);
const oldLocalId = node === null || node === void 0 ? void 0 : (_node$attrs4 = node.attrs) === null || _node$attrs4 === void 0 ? void 0 : _node$attrs4.localId;
const newLocalId = change.attrs.localId;
if (oldLocalId && oldLocalId !== newLocalId) {
newlocalIdStatus.set(oldLocalId, getReplacementStatusCode(tr, step));
modified = true;
}
if (newLocalId) {
newlocalIdStatus.set(newLocalId, 'current');
modified = true;
}
} catch {
// Position might be invalid
}
});
return {
localIdStatus: newlocalIdStatus,
modified
};
};
/**
* Handles ReplaceStep which inserts or deletes content
*/
const handleReplaceStep = (tr, step, localIdStatus, preDoc, postDoc) => {
let modified = false;
try {
// Create a temporary set to collect new localIds
const changedLocaleIds = new Map();
const replaceCode = getReplacementStatusCode(tr, step);
step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
// For each step map item we can just look at the nodes in the old doc and mark them as "inactive" and then
// look at the nodes in the doc after the step has been applied and mark them as "active"
// then lastly we can compare these to the current states and only update what's different.
preDoc.nodesBetween(oldStart, oldEnd, node => {
var _node$attrs5;
if ((_node$attrs5 = node.attrs) !== null && _node$attrs5 !== void 0 && _node$attrs5.localId) {
changedLocaleIds.set(node.attrs.localId, replaceCode);
}
if (node.marks) {
node.marks.forEach(mark => {
var _mark$attrs2;
if ((_mark$attrs2 = mark.attrs) !== null && _mark$attrs2 !== void 0 && _mark$attrs2.localId) {
changedLocaleIds.set(node.attrs.localId, replaceCode);
}
});
}
});
postDoc.nodesBetween(newStart, newEnd, node => {
var _node$attrs6;
if ((_node$attrs6 = node.attrs) !== null && _node$attrs6 !== void 0 && _node$attrs6.localId) {
changedLocaleIds.set(node.attrs.localId, 'current');
}
if (node.marks) {
node.marks.forEach(mark => {
var _mark$attrs3;
if ((_mark$attrs3 = mark.attrs) !== null && _mark$attrs3 !== void 0 && _mark$attrs3.localId) {
changedLocaleIds.set(node.attrs.localId, 'current');
}
});
}
});
});
if (!!changedLocaleIds.size) {
const newlocalIdStatus = new Map(localIdStatus);
for (const [key, value] of changedLocaleIds) {
if (!localIdStatus.has(key) || localIdStatus.get(key) !== value) {
modified = true;
newlocalIdStatus.set(key, value);
}
}
return {
localIdStatus: newlocalIdStatus,
modified
};
}
} catch {
// If position calculation fails, do a full document rescan as fallback
// This shouldn't happen often but provides safety
}
return {
localIdStatus,
modified
};
};
/**
* Handles ReplaceAroundStep which wraps or unwraps content
*/
const handleReplaceAroundStep = (tr, step, localIdStatus, preDoc, postDoc) => {
let modified = false;
const newlocalIdStatus = new Map(localIdStatus);
// Scan the affected region before and after the step
const from = step.from;
const to = step.to;
try {
// Collect localIds from the old region
const oldLocalIds = new Set();
if (from < to && from >= 0 && to <= preDoc.content.size) {
preDoc.nodesBetween(from, to, node => {
var _node$attrs7;
if ((_node$attrs7 = node.attrs) !== null && _node$attrs7 !== void 0 && _node$attrs7.localId) {
oldLocalIds.add(node.attrs.localId);
}
if (node.marks) {
node.marks.forEach(mark => {
var _mark$attrs4;
if ((_mark$attrs4 = mark.attrs) !== null && _mark$attrs4 !== void 0 && _mark$attrs4.localId) {
oldLocalIds.add(mark.attrs.localId);
}
});
}
});
}
// Collect localIds from the new region
const map = step.getMap();
const newFrom = map.map(from, -1);
const newTo = map.map(to, 1);
const newLocalIds = new Set();
if (newFrom < newTo && newFrom >= 0 && newTo <= postDoc.content.size) {
postDoc.nodesBetween(newFrom, newTo, node => {
var _node$attrs8;
if ((_node$attrs8 = node.attrs) !== null && _node$attrs8 !== void 0 && _node$attrs8.localId) {
newLocalIds.add(node.attrs.localId);
}
if (node.marks) {
node.marks.forEach(mark => {
var _mark$attrs5;
if ((_mark$attrs5 = mark.attrs) !== null && _mark$attrs5 !== void 0 && _mark$attrs5.localId) {
newLocalIds.add(mark.attrs.localId);
}
});
}
});
}
// Find localIds that were removed
oldLocalIds.forEach(localId => {
if (!newLocalIds.has(localId) && newlocalIdStatus.get(localId) === 'current') {
newlocalIdStatus.set(localId, getReplacementStatusCode(tr, step));
modified = true;
}
});
// Find localIds that were added
newLocalIds.forEach(localId => {
if (!oldLocalIds.has(localId)) {
newlocalIdStatus.set(localId, 'current');
modified = true;
}
});
} catch {
// Position might be invalid, skip this step
}
return {
localIdStatus: newlocalIdStatus,
modified
};
};
/**
* Processes a transaction to update localId tracking state
*/
const processTransaction = (tr, currentState) => {
let localIdStatus = currentState.localIdStatus;
let modified = false;
// Process each step in the transaction
try {
tr.steps.forEach((step, index) => {
var _tr$docs$index, _tr$docs, _tr$docs2, _tr$docs3;
let result;
// steps are relative to their docs, so we ensure we reference the doc before/after the step was applied.
const preDoc = (_tr$docs$index = (_tr$docs = tr.docs) === null || _tr$docs === void 0 ? void 0 : _tr$docs[index]) !== null && _tr$docs$index !== void 0 ? _tr$docs$index : tr.doc;
const postDoc = (_tr$docs2 = (_tr$docs3 = tr.docs) === null || _tr$docs3 === void 0 ? void 0 : _tr$docs3[index + 1]) !== null && _tr$docs2 !== void 0 ? _tr$docs2 : tr.doc;
if (step instanceof AttrStep || step instanceof DocAttrStep) {
result = handleAttrStep(tr, step, localIdStatus, preDoc);
} else if (step instanceof SetAttrsStep) {
result = handleSetAttrsStep(tr, step, localIdStatus, preDoc);
} else if (step instanceof BatchAttrsStep) {
result = handleBatchAttrsStep(tr, step, localIdStatus, preDoc);
} else if (step instanceof ReplaceStep) {
result = handleReplaceStep(tr, step, localIdStatus, preDoc, postDoc);
} else if (step instanceof ReplaceAroundStep) {
result = handleReplaceAroundStep(tr, step, localIdStatus, preDoc, postDoc);
} else {
// Unknown step type, no changes
result = {
localIdStatus,
modified: false
};
}
localIdStatus = result.localIdStatus;
modified = modified || result.modified;
});
} catch {
// If any error occurs during step processing, we fallback to disabling the plugin
return {
enabled: false,
initLocalIdSize: currentState.initLocalIdSize,
localIdStatus: new Map(),
lastUpdated: Date.now()
};
}
// If nothing changed, return the same state object
if (!modified) {
return currentState;
}
// If we exceeded the max size while processing the steps, we need to disable the watchmen from further processing.
// If a Map size limit of 2^24 is exceeded then it's more than likely an error would have been thrown during processing
// which would also disable this plugin.
if (localIdStatus.size >= MAX_LOCAL_ID_MAP_SIZE) {
return {
enabled: false,
initLocalIdSize: currentState.initLocalIdSize,
localIdStatus: new Map(),
lastUpdated: Date.now()
};
}
// Return new state with updated sets
return {
enabled: true,
initLocalIdSize: currentState.initLocalIdSize,
localIdStatus,
lastUpdated: Date.now()
};
};
/**
* Creates the localId watchmen plugin
*/
export const createWatchmenPlugin = api => {
// Ensure limited mode is initialized
return new SafePlugin({
key: localIdWatchmenPluginKey,
state: {
init(_config, state) {
var _api$limitedMode$shar, _api$limitedMode, _api$limitedMode$shar2;
const isLimitedModeEnabled = (_api$limitedMode$shar = api === null || api === void 0 ? void 0 : (_api$limitedMode = api.limitedMode) === null || _api$limitedMode === void 0 ? void 0 : (_api$limitedMode$shar2 = _api$limitedMode.sharedState.currentState()) === null || _api$limitedMode$shar2 === void 0 ? void 0 : _api$limitedMode$shar2.enabled) !== null && _api$limitedMode$shar !== void 0 ? _api$limitedMode$shar : false;
if (isLimitedModeEnabled) {
return {
enabled: false,
initLocalIdSize: -1,
localIdStatus: new Map(),
lastUpdated: Date.now()
};
}
// Initialize by scanning the entire document
const activeLocalIds = scanDocumentForLocalIds(state.doc);
if (activeLocalIds.size >= MAX_LOCAL_ID_MAP_SIZE) {
return {
enabled: false,
initLocalIdSize: activeLocalIds.size,
localIdStatus: new Map(),
lastUpdated: Date.now()
};
}
return {
enabled: true,
initLocalIdSize: activeLocalIds.size,
localIdStatus: new Map(Array.from(activeLocalIds).map(key => [key, 'current'])),
lastUpdated: Date.now()
};
},
apply(tr, currentPluginState) {
const {
enabled
} = tr.getMeta(localIdWatchmenPluginKey) || {
enabled: currentPluginState.enabled
};
const newPluginState = currentPluginState;
if (enabled !== currentPluginState.enabled) {
// If this plugin enabled state is changing and it's being disabled at runtime then we will kill this plugin
// to avoid tracking localIds when in limited mode or there after.
// Once disabled it cannot be re-enabled without a full editor reload.
if (!enabled) {
return {
enabled: false,
initLocalIdSize: currentPluginState.initLocalIdSize,
localIdStatus: currentPluginState.localIdStatus,
lastUpdated: Date.now()
};
}
}
if (!newPluginState.enabled) {
// If this plugin has been disabled, do not track localIds.
return newPluginState;
}
// If no steps, nothing changed
if (tr.steps.length === 0 || !tr.docChanged) {
return newPluginState;
}
// Process the transaction to update state
return processTransaction(tr, newPluginState);
}
},
view(editorView) {
var _api$limitedMode2;
// If limited mode changes, for example if we start not limited but then all of a sudden become limited, we kill
// the watchment plugin to avoid tracking localIds when in limited mode. We also don't want/need to re-enable it once it's disabled.
const unsub = api === null || api === void 0 ? void 0 : (_api$limitedMode2 = api.limitedMode) === null || _api$limitedMode2 === void 0 ? void 0 : _api$limitedMode2.sharedState.onChange(({
nextSharedState
}) => {
const watchmentPluginState = localIdWatchmenPluginKey.getState(editorView.state);
if (nextSharedState.enabled && (watchmentPluginState === null || watchmentPluginState === void 0 ? void 0 : watchmentPluginState.enabled) === true) {
// if nextSharedState.enabled === true, then we need to disable the watchmen plugin, if not already disabled
editorView.dispatch(editorView.state.tr.setMeta(localIdWatchmenPluginKey, {
enabled: false
}));
}
});
return {
destroy: unsub
};
}
});
};