125 lines · 4.9 KB · TypeScript
RawDownload
/** * Syntax highlighting for the visual engine's code blocks: lowlight (MIT) * tokens as ProseMirror inline decorations over the code text (spec R-BLK-7). * * A decorated span never contains a line ending: WebKit cannot move the caret * across a newline that sits inside an inline element, so token ranges are * cut at every newline. Only the code blocks a transaction touched are * highlighted again; the rest of the decorations are mapped. * * Clean implementation for Lattice; spec: docs/visual-editor-spec.md. */import { Extension } from "@tiptap/core";import type { Node as PmNode } from "@tiptap/pm/model";import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state";import { Decoration, DecorationSet } from "@tiptap/pm/view";import latex from "highlight.js/lib/languages/latex";import { common, createLowlight } from "lowlight";import { resolveCodeLanguage } from "./code-languages"; const lowlight = createLowlight(common);lowlight.register("latex", latex); type Token = { from: number; to: number; className: string };type HastNode = { type: string; value?: string; properties?: { className?: string[] }; children?: HastNode[] }; /** Token ranges (relative to the code's start) for `code` in `grammar`. */function highlightTokens(code: string, grammar: string): Token[] {  if (!lowlight.registered(grammar)) return [];  let tree: HastNode;  try {    tree = lowlight.highlight(grammar, code) as unknown as HastNode;  } catch {    return [];  }  const tokens: Token[] = [];  let offset = 0;  const walk = (node: HastNode, classes: string[]) => {    if (node.type === "text") {      const value = node.value ?? "";      if (classes.length) {        // Cut at every line ending so no span holds a newline.        let start = 0;        for (let index = 0; index <= value.length; index += 1) {          if (index === value.length || value[index] === "\n") {            if (index > start) tokens.push({ from: offset + start, to: offset + index, className: classes.join(" ") });            start = index + 1;          }        }      }      offset += value.length;      return;    }    const own = node.properties?.className ?? [];    for (const child of node.children ?? []) walk(child, [...classes, ...own]);  };  walk(tree, []);  return tokens;} function blockDecorations(node: PmNode, position: number): Decoration[] {  const grammar = resolveCodeLanguage(node.attrs.language as string | null)?.grammar;  if (!grammar || !node.textContent) return [];  return highlightTokens(node.textContent, grammar).map((token) => (    Decoration.inline(position + 1 + token.from, position + 1 + token.to, { class: token.className })  ));} function highlightAll(doc: PmNode): DecorationSet {  const decorations: Decoration[] = [];  doc.descendants((node, position) => {    if (node.type.name === "codeBlock") {      decorations.push(...blockDecorations(node, position));      return false;    }    return !node.isTextblock;  });  return DecorationSet.create(doc, decorations);} /** Map the old decorations and re-highlight only the code blocks the transaction changed. */function highlightChanges(set: DecorationSet, transaction: Transaction): DecorationSet {  let next = set.map(transaction.mapping, transaction.doc);  const touched = new Map<number, PmNode>();  transaction.mapping.maps.forEach((map, index) => {    map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {      const rest = transaction.mapping.slice(index + 1);      const from = Math.min(rest.map(newStart, -1), transaction.doc.content.size);      const to = Math.min(rest.map(newEnd, 1), transaction.doc.content.size);      transaction.doc.nodesBetween(from, Math.max(from, to), (node, position) => {        if (node.type.name === "codeBlock") {          touched.set(position, node);          return false;        }        return !node.isTextblock;      });    });  });  // Attribute-only changes (a language switch) map no ranges; find their blocks too.  transaction.steps.forEach((step, index) => {    const position = (step as unknown as { pos?: number }).pos;    if (typeof position !== "number") return;    const mapped = transaction.mapping.slice(index + 1).map(position);    const node = transaction.doc.nodeAt(mapped);    if (node?.type.name === "codeBlock") touched.set(mapped, node);  });  for (const [position, node] of touched) {    next = next.remove(next.find(position, position + node.nodeSize));    next = next.add(transaction.doc, blockDecorations(node, position));  }  return next;} const key = new PluginKey<DecorationSet>("latticeCodeHighlight"); export const CodeHighlight = Extension.create({  name: "latticeCodeHighlight",  addProseMirrorPlugins: () => [new Plugin<DecorationSet>({    key,    state: {      init: (_config, state) => highlightAll(state.doc),      apply: (transaction, set) => (transaction.docChanged ? highlightChanges(set, transaction) : set),    },    props: { decorations: (state) => key.getState(state) },  })],});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t