Skip to content

Instantly share code, notes, and snippets.

@BrianHung
Last active April 25, 2021 08:53
Show Gist options
  • Save BrianHung/08146f89ea903f893946963570263040 to your computer and use it in GitHub Desktop.
Save BrianHung/08146f89ea903f893946963570263040 to your computer and use it in GitHub Desktop.
ProseMirror Plugin for CodeBlock Decorations
import { Plugin, PluginKey, Extension } from 'tiptap'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { findBlockNodes } from 'prosemirror-utils'
import CodeMirror from 'codemirror/addon/runmode/runmode.node.js';
import "codemirror/mode/meta";
/*
* Global set of CodeMirror languages to dynamically import.
*/
const codeMirrorImports = new Set();
/*
* Computes syntax highlight decorations for nodes.
* @nodes: list of [node, pos]
*/
function getDecorations(nodes) {
const decorations = []
nodes.forEach(item => {
// Compute token styles with node attrs lang.
const tokens = [];
const getTokens = (text, style) => { tokens.push({text, style: style ? `cm-${style}` : null}) }
const lang = item.node.attrs.lang;
const text = item.node.textContent;
// Update codeMirrorImports if language is not loaded.
if (!CodeMirror.modes[lang]) codeMirrorImports.add(lang);
CodeMirror.runMode(text, lang, getTokens);
// Calculate token offsets and apply decorations.
let startPos = item.pos + 1;
tokens.map(token => { startPos += token.text.length;
return {...token, from: startPos - token.text.length, to: startPos};
}).forEach(token => {decorations.push(
Decoration.inline(token.from, token.to, {class: token.style}));
});
});
return decorations;
}
/*
* Computes which nodes have been modified by this transaction.
* @tr: ProseMirror transaction
* @nodes: list of [node, pos]
*/
function modifiedNodes(tr, nodes) {
if (!tr.mapping) return [];
let positions = [];
tr.mapping.maps.forEach(m => {
positions = positions.map(r => m.map(r));
if (m.ranges.length)
positions.push(m.ranges[0], m.ranges[0] + m.ranges[2]);
})
let modified = [];
nodes.forEach(item => {
const {node, pos} = item;
for (let i = 0, l = positions.length; i < l; i += 2) {
// Check if range starts or end in node, or if range wraps node.
let range = { from: positions[i], to: positions[i + 1] };
if ((range.from < pos && pos < range.to) || (pos < range.from && range.from < pos + node.nodeSize))
modified.push(item);
}
});
return modified;
}
const highlightKey = new PluginKey('highlight');
/*
* TODO: Find a way to cache decorations incrementally by text instead by node.
* https://discuss.prosemirror.net/t/how-to-update-multiple-inline-decorations-on-node-change/1493
*/
function HighlightPlugin() {
return new Plugin({
name: highlightKey,
state: {
init: (config, state) => {
const { doc } = state;
const matching = findBlockNodes(doc).filter(item => item.node.type.spec.code);
return DecorationSet.create(doc, getDecorations(matching));
},
apply: (tr, decorationSet) => {
const { doc, docChanged, mapping } = tr;
const matching = findBlockNodes(doc).filter(item => item.node.type.spec.code);
const modified = modifiedNodes(tr, matching);
// Check if decorations need to be calculated for newly imported languages.
const imported = [...codeMirrorImports].filter(mode => CodeMirror.modes[mode]);
imported.forEach(mode => codeMirrorImports.delete(mode));
modified.push(...matching.filter(item => imported.includes(item.node.attrs.lang)));
// Keep existing decoration set if document hasn't been modified.
if (!docChanged && !modified.length)
return decorationSet.map(mapping, doc);
// Cache decorations in unmodified nodes and update decorations in modified nodes.
let decorationOld = modified.map(item => decorationSet.find(item.pos, item.pos + item.node.nodeSize)).flat();
return decorationSet.map(mapping, doc).remove(decorationOld).add(doc, getDecorations(matching));
},
},
props: {
decorations(state) { return this.getState(state) },
},
view: view => ({
update: (view, prevState) => {
// Dispatch a no-op transaction to re-trigger syntax highlighting.
Promise.all([...codeMirrorImports].map(mode => import(`codemirror/mode/${mode}/${mode}.js`)))
.then(() => codeMirrorImports.size ? view.dispatch(view.state.tr) : null);
}
})
})
}
export default class Highlight extends Extension {
get name() {
return "highlight";
}
get plugins() {
return [HighlightPlugin()];
}
}
@BrianHung
Copy link
Author

BrianHung commented Mar 30, 2020

TODO: create a plugin view which handles importing CodeMirror modes, per this thread.

And... done!

@BrianHung
Copy link
Author

I have since updated the plugin to use typescript, an actual view element to manage imports instead of the set, and codemirror 6 which has an easier time dynamically importing modes with bundlers since it follows es modules:
https://gist.github.com/BrianHung/06000918ea955e52a595fa42c601c593

@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