UNPKG

@atlaskit/editor-plugin-local-id

Version:

LocalId plugin for @atlaskit/editor-core

497 lines (471 loc) 17.8 kB
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 }; } }); };