Skip to content

Instantly share code, notes, and snippets.

@BrianHung
Last active March 19, 2024 19:53
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save BrianHung/06000918ea955e52a595fa42c601c593 to your computer and use it in GitHub Desktop.
Save BrianHung/06000918ea955e52a595fa42c601c593 to your computer and use it in GitHub Desktop.
ProseMirror CodeBlock Syntax Highlighting using CM6
import {LanguageDescription, LanguageSupport} from "@codemirror/language"
import {languages} from "@codemirror/language-data"
import {highlightTree} from "@codemirror/highlight"
import {highlightStyle} from "./highlight-style"
export function syntaxHighlight(text: string, support: LanguageSupport, callback: (token: {text: string; style: string; from: number; to: number}) => void, options = {match: highlightStyle.match}) {
let pos = 0;
let tree = support.language.parseString(text);
highlightTree(tree, options.match, (from, to, classes) => {
from > pos && callback({text: text.slice(pos, from), style: null, from: pos, to: from});
callback({text: text.slice(pos, from), style: classes, from, to});
pos = to;
});
pos != tree.length && callback({text: text.slice(pos, tree.length), style: null, from: pos, to: tree.length});
}
export function findLanguage(mode: string) {
return mode && (
LanguageDescription.matchFilename(languages, mode) ||
LanguageDescription.matchLanguageName(languages, mode)
);
}
import {HighlightStyle, tags} from "@codemirror/highlight"
export const highlightStyle = HighlightStyle.define([
{tag: tags.link, class: "cm-link"},
{tag: tags.heading, class: "cm-heading"},
{tag: tags.emphasis, class: "cm-emphasis"},
{tag: tags.strong, class: "cm-strong"},
{tag: tags.keyword, class: "cm-keyword"},
{tag: tags.atom, class: "cm-atom"},
{tag: tags.bool, class: "cm-bool"},
{tag: tags.url, class: "cm-url"},
{tag: tags.labelName, class: "cm-labelName"},
{tag: tags.inserted, class: "cm-inserted"},
{tag: tags.deleted, class: "cm-deleted"},
{tag: tags.literal, class: "cm-literal"},
{tag: tags.string, class: "cm-string"},
{tag: tags.number, class: "cm-number"},
{tag: [tags.regexp, tags.escape, tags.special(tags.string)], class: "cm-string2"},
{tag: tags.variableName, class: "cm-variableName"},
{tag: tags.local(tags.variableName), class: "cm-variableName cm-local"},
{tag: tags.definition(tags.variableName), class: "cm-variableName cm-definition"},
{tag: tags.special(tags.variableName), class: "cm-variableName"},
{tag: tags.typeName, class: "cm-typeName"},
{tag: tags.namespace, class: "cm-namespace"},
{tag: tags.macroName, class: "cm-macroName"},
{tag: tags.definition(tags.propertyName), class: "cm-propertyName"},
{tag: tags.operator, class: "cm-operator"},
{tag: tags.comment, class: "cm-comment"},
{tag: tags.meta, class: "cm-meta"},
{tag: tags.invalid, class: "cm-invalid"},
{tag: tags.punctuation, class: "cm-punctuation"},
{tag: tags.modifier, class: "cm-modifier"},
{tag: tags.function(tags.definition(tags.variableName)), class: "cm-function cm-definition"},
{tag: tags.definition(tags.className), class: "cm-class cm-definition"},
{tag: tags.operatorKeyword, class: "cm-operator"},
]);
import { EditorState, Plugin, PluginKey } from "prosemirror-state"
import type { Transaction } from "prosemirror-state"
import type { Node as PMNode } from "prosemirror-model"
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
import type { LanguageDescription } from "@codemirror/language"
type NodePos = {node: PMNode, pos: number}
const highlightedNodes = ["mathblock", "codeblock"]
/**
* This class is required to dynamically import CodeMirror languages and
* dispatch transactions to editorview to re-apply syntax highlighting.
*/
class CodeMirrorView {
view: EditorView;
pluginKey: PluginKey;
importedLangs: Set<LanguageDescription>;
CodeMirror: typeof import("./codemirror-syntax-highlight") | null;
constructor(options: {view?: EditorView, pluginKey?: PluginKey}) {
this.view = options.view;
this.pluginKey = options.pluginKey;
this.importedLangs = new Set<LanguageDescription>();
this.importCodeMirror();
}
// keeps class instance of view up to date
update(view: EditorView) {
if (this.view == undefined && view instanceof EditorView)
this.CodeMirror && this.importedLangs.size && view.dispatch(view.state.tr.setMeta("syntaxhighlight", Array.from(this.importedLangs)))
this.view = view;
}
// no-op
destroy() {}
async importCodeMirror() {
this.CodeMirror = await import("./codemirror-syntax-highlight");
if (this.view) {
const modes: Set<LanguageDescription> = new Set();
const getModes = (node: PMNode) => {
if (highlightedNodes.includes(node.type.name) && node.attrs.lang) {
let lang = this.CodeMirror.findLanguage(node.attrs.lang);
lang && !lang.support && modes.add(lang);
}
return node.isBlock;
};
this.view.state.doc.descendants(getModes);
this.importModes(Array.from(modes));
}
}
async importModes(langs: LanguageDescription[]) {
langs = langs.filter(lang => !this.importedLangs.has(lang)) // filter out already imported languages
if (langs.length === 0) { return; }
langs.forEach(lang => this.importedLangs.add(lang));
const imports = langs.map(language => language.load());
await Promise.all(imports); // dispatch transaction to apply syntax highlighting after imports resolve
this.view && this.CodeMirror && this.view.dispatch(this.view.state.tr.setMeta("syntaxhighlight", langs))
}
}
/**
* Higher-order function to create line-number widget decorations.
*/
function lineNumberSpan(index: number, lineWidth: number) {
return function(): HTMLSpanElement {
const span = document.createElement("span");
span.className = "ProseMirror-linenumber";
span.innerText = "" + index;
Object.assign(span.style, {"display": "inline-block", "width": lineWidth + "ch", "user-select": "none"});
return span;
}
}
const defaultAttrsLang = {
"codeblock": null,
"mathblock": "latex",
}
// Initialize cmView here to allow mode importing on state init
const pluginKey = new PluginKey("SyntaxHighlight");
const cmView = new CodeMirrorView({pluginKey});
/*
* Computes syntax highlight decorations for nodes.
* @nodes: list of [node, pos]
*/
function getDecorations(nodePositions: NodePos[]): Decoration[] {
const CodeMirror = cmView.CodeMirror;
if (CodeMirror == undefined) return [];
const decorations: Decoration[] = []
const langImports: Set<LanguageDescription> = new Set();
nodePositions.forEach(({node, pos}: NodePos) => {
let text = node.textContent;
let lang = CodeMirror.findLanguage(node.attrs.lang || defaultAttrsLang[node.type.name]);
if (lang) {
if (lang.support) {
const startPos: number = pos + 1; // absolute start position of codeblock
CodeMirror.syntaxHighlight(text, lang.support, ({from, to, style}) => style && decorations.push(Decoration.inline(startPos + from, startPos + to, {class: style})));
// Push CodeMirror language name as a node decoration for language-specific css styling.
decorations.push(Decoration.node(pos, pos + node.nodeSize, {class: `language-${lang.name.toLowerCase()}`}))
} else {
// Import language if support is null.
langImports.add(lang);
}
}
if (node.attrs.lineNumbers) {
const linesOfText: string[] = text.split(/\r?\n|\r/);
const lineWidth = linesOfText.length.toString().length;
let startOfLine = pos + 1; // absolute position of current line
linesOfText.forEach(function getLineDecorations(line, index) {
decorations.push(Decoration.widget(startOfLine, lineNumberSpan(index + 1, lineWidth), {side: -1, ignoreSelection: true}));
startOfLine += line.length + 1;
})
}
});
cmView.importModes(Array.from(langImports));
return decorations;
}
/**
* Computes which nodes have been modified by this transaction.
* https://discuss.prosemirror.net/t/how-to-update-multiple-inline-decorations-on-node-change/1493
*/
function modifiedCodeblocks(tr: Transaction): NodePos[] {
const CodeMirror = cmView.CodeMirror;
if (CodeMirror == undefined) return [];
// Use node as key since a single node can be modified more than once.
const modified: Map<Number, NodePos> = new Map();
let positions: number[] = [];
// Calculate list of doc ranges which this transaction has modified.
tr.mapping.maps.forEach(stepMap => {
positions = positions.map(r => stepMap.map(r));
stepMap.forEach((oldStart, oldEnd, newStart, newEnd) => positions.push(newStart, newEnd))
})
const reduceModified = (node: PMNode, pos: number) => {
if (highlightedNodes.includes(node.type.name))
modified.set(pos, {node, pos});
return node.isBlock
}
for (let i = 0; i < positions.length; i+= 2) {
const from = positions[i], to = positions[i + 1];
tr.doc.nodesBetween(from + 1, to, reduceModified);
}
// Check if syntax highlighting needs to be re-applied with newly loaded modes.
const imported = tr.getMeta("syntaxhighlight")
if (imported === undefined) return Array.from(modified.values())
const reduceImported = (node: PMNode, pos: number) => {
if (highlightedNodes.includes(node.type.name) && imported.includes(CodeMirror.findLanguage(node.attrs.lang)))
modified.set(pos, {node, pos})
return node.isBlock
}
tr.doc.descendants(reduceImported)
return Array.from(modified.values());
}
export default function SyntaxHighlightPlugin(pluginOptions?) {
return new Plugin({
props: {
decorations(state): DecorationSet {
return this.getState(state);
},
},
state: {
init: (config: Record<string, any>, state: EditorState): DecorationSet => {
if (!cmView.CodeMirror) return DecorationSet.empty;
const codeblocks: NodePos[] = [];
const reduceCodeblocks = (node: PMNode, pos: number) => { highlightedNodes.includes(node.type.name) && codeblocks.push({node, pos}); return node.isBlock; };
state.doc.descendants(reduceCodeblocks);
return DecorationSet.create(state.doc, getDecorations(codeblocks));
},
apply: (tr: Transaction, decorationSet: DecorationSet): DecorationSet => {
// Keep old decorationSet if no change in document or no new imported languages.
const imported = tr.getMeta("syntaxhighlight")
if (tr.docChanged === false && imported === undefined) return decorationSet;
// Map previous decorationSet through transactions.
decorationSet = decorationSet.map(tr.mapping, tr.doc)
// Push codeblocks which have been modified or whose language has been imported on this transaction.
const modified = modifiedCodeblocks(tr);
if (modified.length === 0) return decorationSet;
// Reuse decorations in unmodified nodes and update decorations in modified nodes.
const decorationOld = modified.map(({node, pos}) => decorationSet.find(pos, pos + node.nodeSize)).flat();
decorationSet = decorationSet.remove(decorationOld);
decorationSet = decorationSet.add(tr.doc, getDecorations(modified));
return decorationSet;
},
},
key: pluginKey,
view(editorView) { // Use editor view to dispatch transaction once languages have been imported.
cmView.update(editorView);
return cmView;
}
})
};
@BrianHung
Copy link
Author

Most up to date version can be found at: https://github.com/BrianHung/editor/.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment