packages/remirror__extension-code-block/src/code-block-extension.ts
import refractor from 'refractor/core.js';
import {
ApplySchemaAttributes,
assertGet,
command,
CommandFunction,
CreateExtensionPlugin,
extension,
ExtensionTag,
findNodeAtSelection,
findParentNodeOfType,
GetAttributes,
getMatchString,
getStyle,
InputRule,
isElementDomNode,
isNodeActive,
isNodeOfType,
isTextSelection,
keyBinding,
KeyBindingProps,
NamedShortcut,
NodeExtension,
NodeExtensionSpec,
nodeInputRule,
NodeSpecOverride,
OnSetOptionsProps,
PosProps,
ProsemirrorAttributes,
removeNodeAtPosition,
replaceNodeAtPosition,
setBlockType,
toggleBlockItem,
} from '@remirror/core';
import { keydownHandler } from '@remirror/pm/keymap';
import { TextSelection } from '@remirror/pm/state';
import { ExtensionCodeBlockTheme as Theme } from '@remirror/theme';
import { CodeBlockState } from './code-block-plugin';
import type { CodeBlockAttributes, CodeBlockOptions } from './code-block-types';
import {
codeBlockToDOM,
formatCodeBlockFactory,
getLanguage,
getLanguageFromDom,
toggleCodeBlockOptions,
updateNodeAttributes,
} from './code-block-utils';
@extension<CodeBlockOptions>({
defaultOptions: {
supportedLanguages: [],
toggleName: 'paragraph',
formatter: ({ source }) => ({ cursorOffset: 0, formatted: source }),
syntaxTheme: 'a11y_dark',
defaultLanguage: 'markup',
defaultWrap: false,
// See https://github.com/remirror/remirror/issues/624 for the ''
plainTextClassName: '',
getLanguageFromDom,
},
staticKeys: ['getLanguageFromDom'],
})
export class CodeBlockExtension extends NodeExtension<CodeBlockOptions> {
get name() {
return 'codeBlock' as const;
}
createTags() {
return [ExtensionTag.Block, ExtensionTag.Code];
}
/**
* Add the languages to the environment if they have not yet been added.
*/
protected init(): void {
this.registerLanguages();
}
createNodeSpec(extra: ApplySchemaAttributes, override: NodeSpecOverride): NodeExtensionSpec {
const githubHighlightRegExp = /highlight-(?:text|source)-([\da-z]+)/;
return {
content: 'text*',
marks: '',
defining: true,
draggable: false,
...override,
code: true,
attrs: {
...extra.defaults(),
language: { default: this.options.defaultLanguage },
wrap: { default: this.options.defaultWrap },
},
parseDOM: [
// Add support for github code blocks.
{
tag: 'div.highlight',
preserveWhitespace: 'full',
getAttrs: (node) => {
if (!isElementDomNode(node)) {
return false;
}
const codeElement = node.querySelector('pre.code');
if (!isElementDomNode(codeElement)) {
return false;
}
const wrap = getStyle(codeElement, 'white-space') === 'pre-wrap';
const language = node.className
.match(githubHighlightRegExp)?.[1]
?.replace('language-', '');
return { ...extra.parse(node), language, wrap };
},
},
{
tag: 'pre',
preserveWhitespace: 'full',
getAttrs: (node) => {
if (!isElementDomNode(node)) {
return false;
}
const codeElement = node.querySelector('code');
if (!isElementDomNode(codeElement)) {
return false;
}
const wrap = getStyle(codeElement, 'white-space') === 'pre-wrap';
const language = this.options.getLanguageFromDom(codeElement, node);
return { ...extra.parse(node), language, wrap };
},
},
...(override.parseDOM ?? []),
],
toDOM: (node) => codeBlockToDOM(node, extra),
};
}
/**
* Add the syntax theme class to the editor.
*/
createAttributes(): ProsemirrorAttributes {
return { class: (Theme as any)[this.options.syntaxTheme.toUpperCase()] };
}
/**
* Create an input rule that listens converts the code fence into a code block
* when typing triple back tick followed by a space.
*/
createInputRules(): InputRule[] {
const regexp = /^```([\dA-Za-z]*) $/;
const getAttributes: GetAttributes = (match) => {
const language = getLanguage({
language: getMatchString(match, 1),
fallback: this.options.defaultLanguage,
});
return { language };
};
return [
nodeInputRule({
regexp,
type: this.type,
beforeDispatch: ({ tr, start }) => {
const $pos = tr.doc.resolve(start);
tr.setSelection(TextSelection.near($pos));
},
getAttributes: getAttributes,
}),
];
}
protected onSetOptions(props: OnSetOptionsProps<CodeBlockOptions>): void {
const { changes } = props;
if (changes.supportedLanguages.changed) {
// Update the registered languages when language support is dynamically
// added.
this.registerLanguages();
}
if (changes.syntaxTheme.changed) {
// Update the attributes when the syntax theme changes to add the new
// style to the main editor element.
this.store.updateAttributes();
}
}
/**
* Create the custom code block plugin which handles the delete key amongst other things.
*/
createPlugin(): CreateExtensionPlugin<CodeBlockState> {
const pluginState = new CodeBlockState(this.type, this);
/**
* Handles deletions within the plugin state.
*/
const handler = () => {
pluginState.setDeleted(true);
// Return false to allow any lower priority keyboard handlers to run.
return false;
};
return {
state: {
init(_, state) {
return pluginState.init(state);
},
apply(tr, _, __, state) {
return pluginState.apply(tr, state);
},
},
props: {
handleKeyDown: keydownHandler({
Backspace: handler,
'Mod-Backspace': handler,
Delete: handler,
'Mod-Delete': handler,
'Ctrl-h': handler,
'Alt-Backspace': handler,
'Ctrl-d': handler,
'Ctrl-Alt-Backspace': handler,
'Alt-Delete': handler,
'Alt-d': handler,
}),
decorations() {
pluginState.setDeleted(false);
return pluginState.decorationSet;
},
},
};
}
/**
* Call this method to toggle the code block.
*
* @remarks
*
* ```ts
* actions.toggleCodeBlock({ language: 'ts' });
* ```
*
* The above makes the current node a codeBlock with the language ts or
* remove the code block altogether.
*/
@command(toggleCodeBlockOptions)
toggleCodeBlock(attributes: Partial<CodeBlockAttributes> = {}): CommandFunction {
return toggleBlockItem({
type: this.type,
toggleType: this.options.toggleName,
attrs: { language: this.options.defaultLanguage, ...attributes },
});
}
/**
* Creates a code at the current position.
*
* ```ts
* commands.createCodeBlock({ language: 'js' });
* ```
*/
@command()
createCodeBlock(attributes: CodeBlockAttributes): CommandFunction {
return setBlockType(this.type, attributes);
}
/**
* Update the code block at the current position. Primarily this is used
* to change the language.
*
* ```ts
* if (commands.updateCodeBlock.isEnabled()) {
* commands.updateCodeBlock({ language: 'markdown' });
* }
* ```
*/
@command()
updateCodeBlock(attributes: CodeBlockAttributes): CommandFunction {
return updateNodeAttributes(this.type)(attributes);
}
/**
* Format the code block with the code formatting function passed as an
* option.
*
* Code formatters (like prettier) add a lot to the bundle size and hence
* it is up to you to provide a formatter which will be run on the entire
* code block when this method is used.
*
* ```ts
* if (actions.formatCodeBlock.isActive()) {
* actions.formatCodeBlockFactory();
* // Or with a specific position
* actions.formatCodeBlock({ pos: 100 }) // to format a separate code block
* }
* ```
*/
@command()
formatCodeBlock(props?: Partial<PosProps>): CommandFunction {
return formatCodeBlockFactory({
type: this.type,
formatter: this.options.formatter,
defaultLanguage: this.options.defaultLanguage,
})(props);
}
@keyBinding({ shortcut: 'Tab' })
tabKey({ state, dispatch }: KeyBindingProps): boolean {
const { selection, tr, schema } = state;
// Check to ensure that this is the correct node.
const { node } = findNodeAtSelection(selection);
if (!isNodeOfType({ node, types: this.type })) {
return false;
}
if (selection.empty) {
tr.insertText('\t');
} else {
const { from, to } = selection;
tr.replaceWith(from, to, schema.text('\t'));
}
if (dispatch) {
dispatch(tr);
}
return true;
}
@keyBinding({ shortcut: 'Backspace' })
backspaceKey({ dispatch, tr, state }: KeyBindingProps): boolean {
// If the selection is not empty, return false and let other extension
// (ie: BaseKeymapExtension) to do the deleting operation.
if (!tr.selection.empty) {
return false;
}
// Check that this is the correct node.
const parent = findParentNodeOfType({ types: this.type, selection: tr.selection });
if (parent?.start !== tr.selection.from) {
return false;
}
const { pos, node, start } = parent;
const toggleNode = assertGet(state.schema.nodes, this.options.toggleName);
if (node.textContent.trim() === '') {
// eslint-disable-next-line unicorn/consistent-destructuring
if (tr.doc.lastChild === node && tr.doc.firstChild === node) {
replaceNodeAtPosition({ pos, tr, content: toggleNode.create() });
} else {
removeNodeAtPosition({ pos, tr });
}
} else if (start > 2) {
// Jump to the previous node.
tr.setSelection(TextSelection.near(tr.doc.resolve(start - 2)));
} else {
// There is no content before the codeBlock so simply create a new
// block and jump into it.
tr.insert(0, toggleNode.create());
tr.setSelection(TextSelection.near(tr.doc.resolve(1)));
}
if (dispatch) {
dispatch(tr);
}
return true;
}
@keyBinding({ shortcut: 'Enter' })
enterKey({ dispatch, tr }: KeyBindingProps): boolean {
if (!(isTextSelection(tr.selection) && tr.selection.empty)) {
return false;
}
const { nodeBefore, parent } = tr.selection.$anchor;
if (!nodeBefore?.isText || !parent.type.isTextblock) {
return false;
}
const regex = /^```([A-Za-z]*)?$/;
const { text, nodeSize } = nodeBefore;
const { textContent } = parent;
if (!text) {
return false;
}
const matchesNodeBefore = text.match(regex);
const matchesParent = textContent.match(regex);
if (!matchesNodeBefore || !matchesParent) {
return false;
}
const [, lang] = matchesNodeBefore;
const language = getLanguage({
language: lang,
fallback: this.options.defaultLanguage,
});
const pos = tr.selection.$from.before();
const end = pos + nodeSize + 1; // +1 to account for the extra pos a node takes up
tr.replaceWith(pos, end, this.type.create({ language }));
// Set the selection to within the codeBlock
tr.setSelection(TextSelection.near(tr.doc.resolve(pos + 1)));
if (dispatch) {
dispatch(tr);
}
return true;
}
@keyBinding({ shortcut: NamedShortcut.Format })
formatShortcut({ tr }: KeyBindingProps): boolean {
const commands = this.store.commands;
if (!isNodeActive({ type: this.type, state: tr })) {
return false;
}
const enabled = commands.formatCodeBlock.isEnabled();
if (enabled) {
commands.formatCodeBlock();
}
return enabled;
}
/**
* Register passed in languages.
*/
private registerLanguages() {
for (const language of this.options.supportedLanguages) {
refractor.register(language);
}
}
}
export { getLanguage };
declare global {
namespace Remirror {
interface AllExtensions {
codeBlock: CodeBlockExtension;
}
}
}