@atlaskit/editor-plugin-code-block
Version:
Code block plugin for @atlaskit/editor-core
187 lines (184 loc) • 7.52 kB
JavaScript
import { getInsertedCodeBlocksInTransaction } from '@atlaskit/editor-common/code-block';
import { pmHistoryPluginKey } from '@atlaskit/editor-common/utils';
import { getAllChangedCodeBlocksInTransaction } from '../pm-plugins/utils';
const MIN_AUTO_DETECT_TEXT_LENGTH = 20;
export const shouldTriggerLargeChangeDetection = (lastObservedText, text) => {
if (!lastObservedText) {
return text.length > 0;
}
return Math.abs(text.length - lastObservedText.length) > lastObservedText.length / 2;
};
export const getFirstLine = text => {
var _text$split$;
return (_text$split$ = text.split('\n')[0]) !== null && _text$split$ !== void 0 ? _text$split$ : '';
};
export const hasEnoughTextForAutoDetection = text => text.trim().length >= MIN_AUTO_DETECT_TEXT_LENGTH;
export const getLocalId = node => typeof node.attrs.localId === 'string' ? node.attrs.localId : null;
export const createAutoDetectEntry = (node, pos, isPending, previous, options = {}) => ({
lastObservedText: node.textContent,
lastObservedFirstLine: getFirstLine(node.textContent),
isPending,
detectionResult: options.preserveDetectionResult === false ? undefined : previous === null || previous === void 0 ? void 0 : previous.detectionResult,
autoDetectedLanguage: previous === null || previous === void 0 ? void 0 : previous.autoDetectedLanguage,
pos
});
export const queueAutoDetection = (languageDetectionMap, node, pos, isPending) => {
const localId = getLocalId(node);
if (!localId) {
return languageDetectionMap;
}
return {
...languageDetectionMap,
[localId]: createAutoDetectEntry(node, pos, isPending, languageDetectionMap[localId])
};
};
export const removeAutoDetection = (languageDetectionMap, localId) => {
if (!languageDetectionMap[localId]) {
return languageDetectionMap;
}
const nextLanguageDetectionMap = {
...languageDetectionMap
};
delete nextLanguageDetectionMap[localId];
return nextLanguageDetectionMap;
};
const getCodeBlockLocalIdsRemovedFromChangedRanges = (tr, codeBlockType) => {
const localIds = new Set();
tr.steps.forEach((step, stepIndex) => {
const docAtStep = tr.docs[stepIndex];
step.getMap().forEach((oldStart, oldEnd) => {
if (oldStart === oldEnd) {
return;
}
const clampedOldEnd = Math.min(oldEnd, docAtStep.content.size);
docAtStep.nodesBetween(oldStart, clampedOldEnd, (node, pos) => {
if (node.type !== codeBlockType) {
return true;
}
const isWholeCodeBlockRemoved = pos >= oldStart && pos + node.nodeSize <= clampedOldEnd;
if (!isWholeCodeBlockRemoved) {
return false;
}
const localId = getLocalId(node);
if (localId) {
localIds.add(localId);
}
return false;
});
});
});
return localIds;
};
const isHistoryMeta = meta => typeof meta === 'object' && meta !== null && 'redo' in meta;
const getCodeBlockTransactionChanges = (tr, codeBlockType) => {
const insertedNodesWithPos = getInsertedCodeBlocksInTransaction(tr, codeBlockType);
const removedFromChangedRangesLocalIds = getCodeBlockLocalIdsRemovedFromChangedRanges(tr, codeBlockType);
const insertedLocalIds = new Set();
const insertedCodeBlocks = [];
insertedNodesWithPos.forEach(({
node,
pos
}) => {
const localId = getLocalId(node);
if (!localId) {
return;
}
insertedLocalIds.add(localId);
if (!removedFromChangedRangesLocalIds.has(localId)) {
insertedCodeBlocks.push({
localId,
node,
pos
});
}
});
const deletedLocalIds = new Set();
removedFromChangedRangesLocalIds.forEach(localId => {
if (!insertedLocalIds.has(localId)) {
deletedLocalIds.add(localId);
}
});
return {
deletedLocalIds,
insertedCodeBlocks
};
};
export const updateAutoDetectState = (tr, pluginState) => {
const {
codeBlock
} = tr.doc.type.schema.nodes;
const isPaste = tr.getMeta('paste') === true || tr.getMeta('uiEvent') === 'paste';
const isExternalContentChange = tr.getMeta('replaceDocument') === true || tr.getMeta('isRemote') === true;
const historyMeta = tr.getMeta(pmHistoryPluginKey);
const isUndo = isHistoryMeta(historyMeta) && historyMeta.redo === false;
const hasTrackedEntries = Object.keys(pluginState.languageDetectionMap).length > 0;
// Page loads and remote edits should not start auto-detection for code blocks.
if (!hasTrackedEntries && isExternalContentChange) {
return pluginState.languageDetectionMap;
}
// Existing entries still need mapping/deletion cleanup, but external edits should not refresh text.
const changedCodeBlockNodes = isExternalContentChange ? [] : getAllChangedCodeBlocksInTransaction(tr);
if (!hasTrackedEntries && !changedCodeBlockNodes.length) {
return pluginState.languageDetectionMap;
}
const {
deletedLocalIds,
insertedCodeBlocks
} = getCodeBlockTransactionChanges(tr, codeBlock);
let languageDetectionMap = hasTrackedEntries ? Object.fromEntries(Object.entries(pluginState.languageDetectionMap).map(([localId, entry]) => [localId, {
...entry,
pos: tr.mapping.map(entry.pos)
}])) : pluginState.languageDetectionMap;
if (!isExternalContentChange) {
insertedCodeBlocks.forEach(({
localId,
node,
pos
}) => {
if (node.attrs.language) {
languageDetectionMap = removeAutoDetection(languageDetectionMap, localId);
return;
}
languageDetectionMap = queueAutoDetection(languageDetectionMap, node, pos, hasEnoughTextForAutoDetection(node.textContent));
});
changedCodeBlockNodes.forEach(({
node,
pos
}) => {
var _tr$before$nodeAt;
const localId = getLocalId(node);
if (!localId || !languageDetectionMap[localId]) {
return;
}
const currentLanguage = node.attrs.language;
const previousEntry = languageDetectionMap[localId];
// Undo metadata does not identify the changed node; compare the pre/post language
// so text undo inside auto-detection mode keeps the entry.
const previousLanguage = isUndo ? (_tr$before$nodeAt = tr.before.nodeAt(tr.mapping.invert().map(pos))) === null || _tr$before$nodeAt === void 0 ? void 0 : _tr$before$nodeAt.attrs.language : undefined;
if (isUndo && previousLanguage && !currentLanguage || currentLanguage && currentLanguage !== previousEntry.autoDetectedLanguage) {
languageDetectionMap = removeAutoDetection(languageDetectionMap, localId);
return;
}
const text = node.textContent;
const firstLine = getFirstLine(text);
const shouldTriggerDetection = previousEntry.isPending || isPaste || firstLine !== previousEntry.lastObservedFirstLine || shouldTriggerLargeChangeDetection(previousEntry.lastObservedText, text);
const isPending = hasEnoughTextForAutoDetection(text) && shouldTriggerDetection;
// Only pending detection refreshes the text snapshot; otherwise gradual typing
// should continue comparing against the last attempted detection.
languageDetectionMap = isPending ? queueAutoDetection(languageDetectionMap, node, pos, true) : {
...languageDetectionMap,
[localId]: {
...previousEntry,
isPending: false,
pos
}
};
});
}
deletedLocalIds.forEach(localId => {
if (languageDetectionMap[localId]) {
languageDetectionMap = removeAutoDetection(languageDetectionMap, localId);
}
});
return languageDetectionMap;
};