Last active
April 25, 2021 08:53
-
-
Save BrianHung/08146f89ea903f893946963570263040 to your computer and use it in GitHub Desktop.
ProseMirror Plugin for CodeBlock Decorations
This file contains hidden or 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 { 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()]; | |
} | |
} |
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
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
TODO: create a plugin view which handles importing CodeMirror modes, per this thread.
And... done!