prosemirror-highlight
Version:
A ProseMirror plugin to highlight code blocks
222 lines (218 loc) • 6.94 kB
JavaScript
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
//#region src/cache.ts
/**
* Represents a cache of doc positions to the node and decorations at that position
*/
var DecorationCache = class DecorationCache {
constructor(cache) {
this.cache = new Map(cache);
}
/**
* Gets the cache entry at the given doc position, or null if it doesn't exist
* @param pos The doc position of the node you want the cache for
*/
get(pos) {
return this.cache.get(pos);
}
/**
* Sets the cache entry at the given position with the give node/decoration
* values
* @param pos The doc position of the node to set the cache for
* @param node The node to place in cache
* @param decorations The decorations to place in cache
*/
set(pos, node, decorations) {
if (pos < 0) return;
this.cache.set(pos, [node, decorations]);
}
/**
* Removes the value at the oldPos (if it exists) and sets the new position to
* the given values
* @param oldPos The old node position to overwrite
* @param newPos The new node position to set the cache for
* @param node The new node to place in cache
* @param decorations The new decorations to place in cache
*/
replace(oldPos, newPos, node, decorations) {
this.remove(oldPos);
this.set(newPos, node, decorations);
}
/**
* Removes the cache entry at the given position
* @param pos The doc position to remove from cache
*/
remove(pos) {
this.cache.delete(pos);
}
/**
* Invalidates the cache by removing all decoration entries on nodes that have
* changed, updating the positions of the nodes that haven't and removing all
* the entries that have been deleted; NOTE: this does not affect the current
* cache, but returns an entirely new one
* @param tr A transaction to map the current cache to
*/
invalidate(tr) {
const returnCache = new DecorationCache(this.cache);
const mapping = tr.mapping;
this.cache.forEach(([node, decorations], pos) => {
if (pos < 0) return;
const result = mapping.mapResult(pos);
const mappedNode = tr.doc.nodeAt(result.pos);
if (result.deleted || !mappedNode?.eq(node)) returnCache.remove(pos);
else if (pos !== result.pos) {
const updatedDecorations = decorations.map((d) => {
return d.map(mapping, 0, 0);
}).filter((d) => d != null);
returnCache.replace(pos, result.pos, mappedNode, updatedDecorations);
}
});
return returnCache;
}
};
//#endregion
//#region src/plugin.ts
/**
* Creates a plugin that highlights the contents of all nodes (via Decorations)
* with a type passed in blockTypes
*/
function createHighlightPlugin({ parser, nodeTypes = ["code_block", "codeBlock"], languageExtractor = (node) => node.attrs.language }) {
const key = new PluginKey("prosemirror-highlight");
return new Plugin({
key,
state: {
init(_, instance) {
const cache = new DecorationCache();
const [decorations, promises] = calculateDecoration(instance.doc, parser, nodeTypes, languageExtractor, cache);
return {
cache,
decorations,
promises
};
},
apply: (tr, data) => {
const cache = data.cache.invalidate(tr);
const refresh = !!tr.getMeta("prosemirror-highlight-refresh");
if (!tr.docChanged && !refresh) return {
cache,
decorations: data.decorations?.map(tr.mapping, tr.doc),
promises: data.promises
};
const [decorations, promises] = calculateDecoration(tr.doc, parser, nodeTypes, languageExtractor, cache);
return {
cache,
decorations,
promises
};
}
},
view: (view) => {
const promises = /* @__PURE__ */ new Set();
const refresh = () => {
if (promises.size > 0) return;
const tr = view.state.tr.setMeta("prosemirror-highlight-refresh", true);
view.dispatch(tr);
};
const check = () => {
const state = key.getState(view.state);
for (const promise of state?.promises ?? []) {
promises.add(promise);
promise.then(() => {
promises.delete(promise);
refresh();
}).catch((error) => {
console.error("[prosemirror-highlight] Error resolving parser:", error);
promises.delete(promise);
});
}
};
check();
return { update: () => {
check();
} };
},
props: { decorations(state) {
return this.getState(state)?.decorations;
} }
});
}
function calculateDecoration(doc, parser, nodeTypes, languageExtractor, cache) {
const allDecorations = [];
const promises = [];
const nodes = collectCodeBlocks(doc, nodeTypes);
try {
for (const [node, pos] of nodes) {
const language = languageExtractor(node);
const cached = cache.get(pos);
if (cached) {
const [_, decorations] = cached;
if (decorations.length > 0) allDecorations.push(decorations);
} else {
const parsed = parser({
content: node.textContent,
language: language || void 0,
pos,
size: node.nodeSize
});
if (parsed && Array.isArray(parsed)) {
cache.set(pos, node, parsed);
if (parsed.length > 0) allDecorations.push(parsed);
} else if (parsed instanceof Promise) {
cache.remove(pos);
promises.push(parsed);
} else console.error(`[prosemirror-highlight] Invalid parser result:`, parsed);
}
}
} catch (error) {
console.error(`[prosemirror-highlight] Error parsing code blocks:`, error);
}
return [allDecorations.length > 0 ? DecorationSet.create(doc, allDecorations.flat()) : void 0, promises];
}
function collectCodeBlocks(doc, nodeTypes) {
const nodes = [];
doc.descendants((node, pos) => {
if (node.type.isTextblock && nodeTypes.includes(node.type.name)) {
nodes.push([node, pos]);
return false;
}
});
return nodes;
}
//#endregion
//#region src/line-number.ts
/**
* Returns a new parser that adds line numbers to the parsed decorations.
*
* Line numbers are added as `<span class="line-number">` elements with
* the line number as the text content.
*/
function withLineNumbers(parser) {
return function parserWithLineNumbers(options) {
const parsed = parser(options);
if (parsed && Array.isArray(parsed)) {
const { pos, content } = options;
const start = pos + 1;
const lineStarts = [start];
for (const match of content.matchAll(/(?:\r?\n)/g)) lineStarts.push(start + match.index + match[0].length);
const decorations = [];
for (const [index, lineStart] of lineStarts.entries()) decorations.push(createLineStartWidget(lineStart, index + 1));
return [...decorations, ...parsed];
}
return parsed;
};
}
function createLineStartWidget(from, lineNumber) {
return Decoration.widget(from, () => createLineStartSpan(lineNumber), {
key: "line-number",
side: -20
});
}
function createLineStartSpan(lineNumber) {
const span = document.createElement("span");
span.className = "line-number";
span.textContent = lineNumber.toString();
return span;
}
//#endregion
export { DecorationCache, createHighlightPlugin, withLineNumbers };
//# sourceMappingURL=index.js.map