UNPKG

prosemirror-highlight

Version:

A ProseMirror plugin to highlight code blocks

222 lines (218 loc) 6.94 kB
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