Last active
March 19, 2024 19:53
-
-
Save BrianHung/06000918ea955e52a595fa42c601c593 to your computer and use it in GitHub Desktop.
ProseMirror CodeBlock Syntax Highlighting using CM6
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"}, | |
]); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
}) | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Most up to date version can be found at: https://github.com/BrianHung/editor/.