@atlaskit/editor-plugin-code-block
Version:
Code block plugin for @atlaskit/editor-core
552 lines (547 loc) • 19.5 kB
JavaScript
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, MODE, PLATFORMS, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
import { copyToClipboard, getAnalyticsPayload } from '@atlaskit/editor-common/clipboard';
import { codeBlockWrappedStates, getDefaultCodeBlockAttrs, isCodeBlockWordWrapEnabled } from '@atlaskit/editor-common/code-block';
import { withAnalytics } from '@atlaskit/editor-common/editor-analytics';
import { contentAllowedInCodeBlock, shouldSplitSelectedNodeOnNodeInsertion } from '@atlaskit/editor-common/insert';
import { editorCommandToPMCommand } from '@atlaskit/editor-common/preset';
import { findCodeBlock } from '@atlaskit/editor-common/transforms';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import { findParentNodeOfType, findSelectedNodeOfType, isNodeSelection, removeParentNodeOfType, removeSelectedNode, safeInsert } from '@atlaskit/editor-prosemirror/utils';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { ACTIONS } from '../pm-plugins/actions';
import { autoDetectPluginKey } from '../pm-plugins/auto-detect-state';
import { copySelectionPluginKey } from '../pm-plugins/codeBlockCopySelectionPlugin';
import { pluginKey } from '../pm-plugins/plugin-key';
import { transformToCodeBlockAction } from '../pm-plugins/transform-to-code-block';
import { createAutoDetectEntry, getLocalId, hasEnoughTextForAutoDetection } from '../utils/auto-detect-state';
export const removeCodeBlockWithAnalytics = editorAnalyticsAPI => {
return withAnalytics(editorAnalyticsAPI, {
action: ACTION.DELETED,
actionSubject: ACTION_SUBJECT.CODE_BLOCK,
attributes: {
inputMethod: INPUT_METHOD.FLOATING_TB
},
eventType: EVENT_TYPE.TRACK
})(removeCodeBlock);
};
export const removeCodeBlock = (state, dispatch) => {
const {
schema: {
nodes
},
tr
} = state;
if (dispatch) {
let removeTr = tr;
if (findSelectedNodeOfType(nodes.codeBlock)(tr.selection)) {
removeTr = removeSelectedNode(tr);
} else {
removeTr = removeParentNodeOfType(nodes.codeBlock)(tr);
}
dispatch(removeTr);
}
return true;
};
export const changeLanguage = editorAnalyticsAPI => (language, selectionSource) => (state, dispatch) => {
var _pluginKey$getState, _autoDetectPluginKey$;
const {
codeBlock
} = state.schema.nodes;
const pos = (_pluginKey$getState = pluginKey.getState(state)) === null || _pluginKey$getState === void 0 ? void 0 : _pluginKey$getState.pos;
if (typeof pos !== 'number') {
return false;
}
const node = state.doc.nodeAt(pos);
const localId = node === null || node === void 0 ? void 0 : node.attrs.localId;
const shouldIncludeAutoDetectionContext = expValEquals('platform_editor_code_block_q4_lovability', 'isEnabled', true) && fg('platform_editor_code_block_language_detection_flow');
const previousAutoDetectEntry = shouldIncludeAutoDetectionContext ? (_autoDetectPluginKey$ = autoDetectPluginKey.getState(state)) === null || _autoDetectPluginKey$ === void 0 ? void 0 : _autoDetectPluginKey$.languageDetectionMap[localId] : undefined;
const tr = state.tr.setNodeMarkup(pos, codeBlock, {
...(node === null || node === void 0 ? void 0 : node.attrs),
language
}).setMeta('scrollIntoView', false);
if (shouldIncludeAutoDetectionContext) {
tr.setMeta(autoDetectPluginKey, {
type: ACTIONS.REMOVE_AUTO_DETECT_ENTRY,
data: {
localId
}
});
}
const selection = isNodeSelection(state.selection) ? NodeSelection.create(tr.doc, pos) : tr.selection;
const result = tr.setSelection(selection);
if (dispatch) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.LANGUAGE_SELECTED,
actionSubject: ACTION_SUBJECT.CODE_BLOCK,
attributes: {
language: language !== null && language !== void 0 ? language : 'none',
...(selectionSource ? {
selectionSource
} : {}),
...(shouldIncludeAutoDetectionContext ? {
autoDetectionResult: previousAutoDetectEntry === null || previousAutoDetectEntry === void 0 ? void 0 : previousAutoDetectEntry.detectionResult,
autoDetectedLanguage: previousAutoDetectEntry === null || previousAutoDetectEntry === void 0 ? void 0 : previousAutoDetectEntry.autoDetectedLanguage
} : {})
},
eventType: EVENT_TYPE.TRACK
})(result);
dispatch(result);
}
return true;
};
/** Queue auto-detection for selected code block. */
export const detectLanguage = () => (state, dispatch) => {
var _pluginKey$getState2;
const pos = (_pluginKey$getState2 = pluginKey.getState(state)) === null || _pluginKey$getState2 === void 0 ? void 0 : _pluginKey$getState2.pos;
if (typeof pos !== 'number') {
return false;
}
const node = state.doc.nodeAt(pos);
if (!node) {
return false;
}
const localId = getLocalId(node);
if (!localId) {
return false;
}
const autoDetectState = autoDetectPluginKey.getState(state);
const previousEntry = autoDetectState === null || autoDetectState === void 0 ? void 0 : autoDetectState.languageDetectionMap[localId];
const entry = createAutoDetectEntry(node, pos, hasEnoughTextForAutoDetection(node.textContent), previousEntry, {
preserveDetectionResult: false
});
const tr = state.tr.setNodeMarkup(pos, state.schema.nodes.codeBlock, {
...node.attrs,
language: null
}).setMeta(autoDetectPluginKey, {
type: ACTIONS.SET_AUTO_DETECT_ENTRY,
data: {
localId,
entry
}
}).setMeta('scrollIntoView', false);
const selection = isNodeSelection(state.selection) ? NodeSelection.create(tr.doc, pos) : tr.selection;
const result = tr.setSelection(selection);
if (dispatch) {
dispatch(result);
}
return true;
};
const setResolveFormatCodeMeta = (tr, {
languageSource,
localId,
outcome,
requestId,
errorType
}) => tr.setMeta(pluginKey, {
type: ACTIONS.RESOLVE_FORMAT_CODE,
data: {
languageSource,
localId,
outcome,
requestId,
...(errorType ? {
errorType
} : {})
}
});
const replaceCodeBlockText = ({
codeBlockNode,
content,
pos,
tr
}) => {
const from = pos + 1;
const to = pos + codeBlockNode.nodeSize - 1;
tr.delete(from, to);
if (content) {
tr.insertText(content, from);
}
// The editor scroll plugin scrolls doc-changing transactions by default.
return tr.setMeta('scrollIntoView', false);
};
const attachFormatCodeAnalytics = ({
editorAnalyticsAPI,
languageSource,
result,
tr
}) => {
if (result.status === 'failed') {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.ERRORED,
actionSubject: ACTION_SUBJECT.CODE_BLOCK,
attributes: {
errorType: result.errorType,
language: result.language,
languageSource
},
eventType: EVENT_TYPE.TRACK
})(tr);
return;
}
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.FORMATTED,
actionSubject: ACTION_SUBJECT.CODE_BLOCK,
attributes: {
language: result.language,
languageSource,
outcome: result.status
},
eventType: EVENT_TYPE.TRACK
})(tr);
};
const createResolveFormatCodeTransaction = ({
editorAnalyticsAPI,
localId,
pendingFormat,
result,
tr
}) => {
const {
languageSource,
requestId
} = pendingFormat;
const codeBlockNode = tr.doc.nodeAt(pendingFormat.pos);
const hasMatchingCodeBlock = (codeBlockNode === null || codeBlockNode === void 0 ? void 0 : codeBlockNode.type) === tr.doc.type.schema.nodes.codeBlock && (codeBlockNode === null || codeBlockNode === void 0 ? void 0 : codeBlockNode.attrs.localId) === localId;
if (!hasMatchingCodeBlock) {
// Keep failure telemetry even when the target block is no longer available.
if (result.status === 'failed') {
attachFormatCodeAnalytics({
editorAnalyticsAPI,
languageSource,
result,
tr
});
}
return setResolveFormatCodeMeta(tr, {
languageSource,
localId,
outcome: 'unchanged',
requestId
});
}
let resultTransaction = tr;
if (result.status === 'formatted') {
resultTransaction = replaceCodeBlockText({
codeBlockNode,
content: result.content,
pos: pendingFormat.pos,
tr
});
}
attachFormatCodeAnalytics({
editorAnalyticsAPI,
languageSource,
result,
tr: resultTransaction
});
return setResolveFormatCodeMeta(resultTransaction, {
errorType: result.status === 'failed' ? result.errorType : undefined,
languageSource,
localId,
outcome: result.status,
requestId
});
};
export const createFormatCodeOnClick = ({
api,
editorAnalyticsAPI,
formatCodeProvider
}) => (state, dispatch) => {
var _currentNode$attrs$la, _autoDetectPluginKey$2, _api$core;
if (!formatCodeProvider) {
return false;
}
const currentCodeBlockState = pluginKey.getState(state);
const currentPos = currentCodeBlockState === null || currentCodeBlockState === void 0 ? void 0 : currentCodeBlockState.pos;
if (!currentCodeBlockState || typeof currentPos !== 'number') {
return false;
}
const currentNode = state.doc.nodeAt(currentPos);
if (!currentNode || currentNode.type !== state.schema.nodes.codeBlock) {
return false;
}
const currentLanguage = (_currentNode$attrs$la = currentNode.attrs.language) !== null && _currentNode$attrs$la !== void 0 ? _currentNode$attrs$la : '';
const currentLocalId = currentNode.attrs.localId;
if (currentCodeBlockState.pendingFormats[currentLocalId]) {
return true;
}
const autoDetectEntry = (_autoDetectPluginKey$2 = autoDetectPluginKey.getState(state)) === null || _autoDetectPluginKey$2 === void 0 ? void 0 : _autoDetectPluginKey$2.languageDetectionMap[currentLocalId];
const languageSource = (autoDetectEntry === null || autoDetectEntry === void 0 ? void 0 : autoDetectEntry.autoDetectedLanguage) === currentLanguage ? 'auto-detected' : 'selected';
const content = currentNode.textContent;
const requestId = crypto.randomUUID();
api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(({
tr
}) => tr.setMeta(pluginKey, {
type: ACTIONS.START_FORMAT_CODE,
data: {
languageSource,
localId: currentLocalId,
pos: currentPos,
requestId
}
}));
void formatCodeProvider.formatCode({
content,
language: currentLanguage
}).catch(() => ({
errorType: 'formatter-execution-failed',
language: currentLanguage,
status: 'failed'
})).then(result => {
var _api$codeBlock, _api$codeBlock$shared, _api$core2;
const pendingFormat = api === null || api === void 0 ? void 0 : (_api$codeBlock = api.codeBlock) === null || _api$codeBlock === void 0 ? void 0 : (_api$codeBlock$shared = _api$codeBlock.sharedState.currentState()) === null || _api$codeBlock$shared === void 0 ? void 0 : _api$codeBlock$shared.pendingFormats[currentLocalId];
if (!pendingFormat || pendingFormat.requestId !== requestId) {
return;
}
api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions.execute(({
tr
}) => createResolveFormatCodeTransaction({
editorAnalyticsAPI,
localId: currentLocalId,
pendingFormat,
result,
tr
}));
});
return true;
};
export const copyContentToClipboardWithAnalytics = editorAnalyticsAPI => (state, dispatch) => {
const {
schema: {
nodes
},
tr
} = state;
const codeBlock = findParentNodeOfType(nodes.codeBlock)(tr.selection);
const textContent = codeBlock && codeBlock.node.textContent;
if (textContent) {
copyToClipboard(textContent);
const copyToClipboardTr = tr;
copyToClipboardTr.setMeta(pluginKey, {
type: ACTIONS.SET_COPIED_TO_CLIPBOARD,
data: true
});
copyToClipboardTr.setMeta(copySelectionPluginKey, 'remove-selection');
if (editorAnalyticsAPI) {
const analyticsPayload = getAnalyticsPayload(state, ACTION.COPIED);
if (analyticsPayload) {
analyticsPayload.attributes.inputMethod = INPUT_METHOD.FLOATING_TB;
analyticsPayload.attributes.nodeType = codeBlock === null || codeBlock === void 0 ? void 0 : codeBlock.node.type.name;
editorAnalyticsAPI.attachAnalyticsEvent(analyticsPayload)(copyToClipboardTr);
}
}
if (dispatch) {
dispatch(copyToClipboardTr);
}
}
return true;
};
export const copyContentToClipboard = (state, dispatch) => {
const {
schema: {
nodes
},
tr
} = state;
const codeBlock = findParentNodeOfType(nodes.codeBlock)(tr.selection);
const textContent = codeBlock && codeBlock.node.textContent;
if (textContent) {
copyToClipboard(textContent);
const copyToClipboardTr = tr;
copyToClipboardTr.setMeta(pluginKey, {
type: ACTIONS.SET_COPIED_TO_CLIPBOARD,
data: true
});
copyToClipboardTr.setMeta(copySelectionPluginKey, 'remove-selection');
if (dispatch) {
dispatch(copyToClipboardTr);
}
}
return true;
};
export const resetCopiedState = (state, dispatch) => {
const {
tr
} = state;
const codeBlockState = pluginKey.getState(state);
const resetCopiedStateTr = tr;
if (codeBlockState && codeBlockState.contentCopied) {
resetCopiedStateTr.setMeta(pluginKey, {
type: ACTIONS.SET_COPIED_TO_CLIPBOARD,
data: false
});
resetCopiedStateTr.setMeta(copySelectionPluginKey, 'remove-selection');
if (dispatch) {
dispatch(resetCopiedStateTr);
}
} else {
const clearSelectionStateTransaction = state.tr;
clearSelectionStateTransaction.setMeta(copySelectionPluginKey, 'remove-selection');
// note: dispatch should always be defined when called from the
// floating toolbar. Howver the Command type which floating toolbar uses
// (and resetCopiedState) uses suggests it's optional.
if (dispatch) {
dispatch(clearSelectionStateTransaction);
}
}
return true;
};
export const ignoreFollowingMutations = (state, dispatch) => {
const {
tr
} = state;
const ignoreFollowingMutationsTr = tr;
ignoreFollowingMutationsTr.setMeta(pluginKey, {
type: ACTIONS.SET_SHOULD_IGNORE_FOLLOWING_MUTATIONS,
data: true
});
if (dispatch) {
dispatch(ignoreFollowingMutationsTr);
}
return true;
};
export const resetShouldIgnoreFollowingMutations = (state, dispatch) => {
const {
tr
} = state;
const ignoreFollowingMutationsTr = tr;
ignoreFollowingMutationsTr.setMeta(pluginKey, {
type: ACTIONS.SET_SHOULD_IGNORE_FOLLOWING_MUTATIONS,
data: false
});
if (dispatch) {
dispatch(ignoreFollowingMutationsTr);
}
return true;
};
/**
* This function creates a new transaction that inserts a code block,
* if there is text selected it will wrap the current selection if not it will
* append the codeblock to the end of the document.
*/
export function createInsertCodeBlockTransaction({
state
}) {
let {
tr
} = state;
const {
from
} = state.selection;
const {
codeBlock
} = state.schema.nodes;
const codeBlockAttrs = getDefaultCodeBlockAttrs();
const grandParentNode = state.selection.$from.node(-1);
const grandParentNodeType = grandParentNode === null || grandParentNode === void 0 ? void 0 : grandParentNode.type;
const parentNodeType = state.selection.$from.parent.type;
/** We always want to append a codeBlock unless we're inserting into a paragraph
* AND it's a valid child of the grandparent node.
* Insert the current selection as codeBlock content unless it contains nodes other
* than paragraphs and inline.
*/
const canInsertCodeBlock = shouldSplitSelectedNodeOnNodeInsertion({
parentNodeType,
grandParentNodeType,
content: codeBlock.createAndFill()
}) && contentAllowedInCodeBlock(state);
if (canInsertCodeBlock) {
tr = transformToCodeBlockAction(state, from, codeBlockAttrs);
} else {
safeInsert(codeBlock.createAndFill(codeBlockAttrs))(tr).scrollIntoView();
}
return tr;
}
export function insertCodeBlockWithAnalytics(inputMethod, analyticsAPI) {
return withAnalytics(analyticsAPI, {
action: ACTION.INSERTED,
actionSubject: ACTION_SUBJECT.DOCUMENT,
actionSubjectId: ACTION_SUBJECT_ID.CODE_BLOCK,
attributes: {
inputMethod: inputMethod
},
eventType: EVENT_TYPE.TRACK
})(function (state, dispatch) {
const tr = createInsertCodeBlockTransaction({
state
});
if (dispatch) {
dispatch(tr);
}
return true;
});
}
/**
* Add the given node to the codeBlockWrappedStates WeakMap with the toggle boolean value.
*/
export const toggleWordWrapStateForCodeBlockNode = editorAnalyticsAPI => (state, dispatch) => {
const codeBlock = findCodeBlock(state);
const codeBlockNode = codeBlock === null || codeBlock === void 0 ? void 0 : codeBlock.node;
const {
tr
} = state;
if (!codeBlockWrappedStates || !codeBlockNode) {
return false;
}
const updatedToggleState = !isCodeBlockWordWrapEnabled(codeBlockNode);
if (expValEquals('platform_editor_code_block_q4_lovability', 'isEnabled', true)) {
tr.setNodeMarkup(codeBlock.pos, undefined, {
...codeBlockNode.attrs,
wrap: updatedToggleState
});
if (fg('platform_editor_code_block_dogfooding_patch')) {
tr.setMeta('scrollIntoView', false);
}
} else {
codeBlockWrappedStates.set(codeBlockNode, updatedToggleState);
}
tr.setMeta(pluginKey, {
type: ACTIONS.SET_IS_WRAPPED,
data: updatedToggleState
});
if (dispatch) {
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.TOGGLE_CODE_BLOCK_WRAP,
actionSubject: ACTION_SUBJECT.CODE_BLOCK,
attributes: {
platform: PLATFORMS.WEB,
mode: MODE.EDITOR,
wordWrapEnabled: updatedToggleState,
codeBlockNodeSize: codeBlockNode.nodeSize
},
eventType: EVENT_TYPE.TRACK
})(tr);
dispatch(tr);
}
return true;
};
export const toggleLineNumbersForCodeBlockNodeEditorCommand = editorAnalyticsAPI => ({
tr
}) => {
const {
codeBlock: codeBlockType
} = tr.doc.type.schema.nodes;
const codeBlock = findSelectedNodeOfType(codeBlockType)(tr.selection) || findParentNodeOfType(codeBlockType)(tr.selection);
if (!codeBlock) {
return null;
}
const codeBlockNode = codeBlock.node;
const lineNumbersHidden = !Boolean(codeBlockNode.attrs.hideLineNumbers);
tr.setNodeMarkup(codeBlock.pos, undefined, {
...codeBlockNode.attrs,
hideLineNumbers: lineNumbersHidden
});
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.TOGGLE_CODE_BLOCK_LINE_NUMBERS,
actionSubject: ACTION_SUBJECT.CODE_BLOCK,
attributes: {
platform: PLATFORMS.WEB,
lineNumbersHidden,
codeBlockNodeSize: codeBlockNode.nodeSize
},
eventType: EVENT_TYPE.TRACK
})(tr);
return tr;
};
export const toggleLineNumbersForCodeBlockNode = editorAnalyticsAPI => editorCommandToPMCommand(toggleLineNumbersForCodeBlockNodeEditorCommand(editorAnalyticsAPI));