237 lines
6.0 KiB
TypeScript
237 lines
6.0 KiB
TypeScript
import flattenDeep from "lodash/flattenDeep";
|
|
import padStart from "lodash/padStart";
|
|
import { Node } from "prosemirror-model";
|
|
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
|
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
import refractor from "refractor/core";
|
|
import { isRemoteTransaction } from "../lib/multiplayer";
|
|
import { findBlockNodes } from "../queries/findChildren";
|
|
|
|
export const LANGUAGES = {
|
|
none: "Plain text", // additional entry to disable highlighting
|
|
bash: "Bash",
|
|
clike: "C",
|
|
cpp: "C++",
|
|
csharp: "C#",
|
|
css: "CSS",
|
|
elixir: "Elixir",
|
|
erlang: "Erlang",
|
|
go: "Go",
|
|
graphql: "GraphQL",
|
|
groovy: "Groovy",
|
|
haskell: "Haskell",
|
|
hcl: "HCL",
|
|
markup: "HTML",
|
|
ini: "INI",
|
|
java: "Java",
|
|
javascript: "JavaScript",
|
|
json: "JSON",
|
|
jsx: "JSX",
|
|
kotlin: "Kotlin",
|
|
lisp: "Lisp",
|
|
lua: "Lua",
|
|
mermaidjs: "Mermaid Diagram",
|
|
nix: "Nix",
|
|
objectivec: "Objective-C",
|
|
ocaml: "OCaml",
|
|
perl: "Perl",
|
|
php: "PHP",
|
|
powershell: "Powershell",
|
|
python: "Python",
|
|
ruby: "Ruby",
|
|
rust: "Rust",
|
|
scala: "Scala",
|
|
sass: "Sass",
|
|
scss: "SCSS",
|
|
sql: "SQL",
|
|
solidity: "Solidity",
|
|
swift: "Swift",
|
|
toml: "TOML",
|
|
tsx: "TSX",
|
|
typescript: "TypeScript",
|
|
vb: "Visual Basic",
|
|
verilog: "Verilog",
|
|
vhdl: "VHDL",
|
|
yaml: "YAML",
|
|
zig: "Zig",
|
|
};
|
|
|
|
type ParsedNode = {
|
|
text: string;
|
|
classes: string[];
|
|
};
|
|
|
|
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
|
|
|
|
function getDecorations({
|
|
doc,
|
|
name,
|
|
lineNumbers,
|
|
}: {
|
|
/** The prosemirror document to operate on. */
|
|
doc: Node;
|
|
/** The node name. */
|
|
name: string;
|
|
/** Whether to include decorations representing line numbers */
|
|
lineNumbers?: boolean;
|
|
}) {
|
|
const decorations: Decoration[] = [];
|
|
const blocks: { node: Node; pos: number }[] = findBlockNodes(
|
|
doc,
|
|
true
|
|
).filter((item) => item.node.type.name === name);
|
|
|
|
function parseNodes(
|
|
nodes: refractor.RefractorNode[],
|
|
classNames: string[] = []
|
|
): {
|
|
text: string;
|
|
classes: string[];
|
|
}[] {
|
|
return flattenDeep(
|
|
nodes.map((node) => {
|
|
if (node.type === "element") {
|
|
const classes = [...classNames, ...(node.properties.className || [])];
|
|
return parseNodes(node.children, classes);
|
|
}
|
|
|
|
return {
|
|
text: node.value,
|
|
classes: classNames,
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
blocks.forEach((block) => {
|
|
let startPos = block.pos + 1;
|
|
const language = block.node.attrs.language;
|
|
if (!language || language === "none" || !refractor.registered(language)) {
|
|
return;
|
|
}
|
|
|
|
const lineDecorations = [];
|
|
|
|
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
|
|
if (lineNumbers) {
|
|
const lineCount =
|
|
(block.node.textContent.match(/\n/g) || []).length + 1;
|
|
const gutterWidth = String(lineCount).length;
|
|
|
|
const lineCountText = new Array(lineCount)
|
|
.fill(0)
|
|
.map((_, i) => padStart(`${i + 1}`, gutterWidth, " "))
|
|
.join("\n");
|
|
|
|
lineDecorations.push(
|
|
Decoration.node(
|
|
block.pos,
|
|
block.pos + block.node.nodeSize,
|
|
{
|
|
"data-line-numbers": `${lineCountText}`,
|
|
style: `--line-number-gutter-width: ${gutterWidth};`,
|
|
},
|
|
{
|
|
key: `line-${lineCount}-gutter`,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
const nodes = refractor.highlight(block.node.textContent, language);
|
|
const newDecorations = parseNodes(nodes)
|
|
.map((node: ParsedNode) => {
|
|
const from = startPos;
|
|
const to = from + node.text.length;
|
|
|
|
startPos = to;
|
|
|
|
return {
|
|
...node,
|
|
from,
|
|
to,
|
|
};
|
|
})
|
|
.filter((node) => node.classes && node.classes.length)
|
|
.map((node) =>
|
|
Decoration.inline(node.from, node.to, {
|
|
class: node.classes.join(" "),
|
|
})
|
|
)
|
|
.concat(lineDecorations);
|
|
|
|
cache[block.pos] = {
|
|
node: block.node,
|
|
decorations: newDecorations,
|
|
};
|
|
}
|
|
|
|
cache[block.pos].decorations.forEach((decoration) => {
|
|
decorations.push(decoration);
|
|
});
|
|
});
|
|
|
|
Object.keys(cache)
|
|
.filter((pos) => !blocks.find((block) => block.pos === Number(pos)))
|
|
.forEach((pos) => {
|
|
delete cache[Number(pos)];
|
|
});
|
|
|
|
return DecorationSet.create(doc, decorations);
|
|
}
|
|
|
|
export default function Prism({
|
|
name,
|
|
lineNumbers,
|
|
}: {
|
|
/** The node name. */
|
|
name: string;
|
|
/** Whether to include decorations representing line numbers */
|
|
lineNumbers?: boolean;
|
|
}) {
|
|
let highlighted = false;
|
|
|
|
return new Plugin({
|
|
key: new PluginKey("prism"),
|
|
state: {
|
|
init: (_, { doc }) => DecorationSet.create(doc, []),
|
|
apply: (transaction: Transaction, decorationSet, oldState, state) => {
|
|
const nodeName = state.selection.$head.parent.type.name;
|
|
const previousNodeName = oldState.selection.$head.parent.type.name;
|
|
const codeBlockChanged =
|
|
transaction.docChanged && [nodeName, previousNodeName].includes(name);
|
|
|
|
if (
|
|
!highlighted ||
|
|
codeBlockChanged ||
|
|
isRemoteTransaction(transaction)
|
|
) {
|
|
highlighted = true;
|
|
return getDecorations({ doc: transaction.doc, name, lineNumbers });
|
|
}
|
|
|
|
return decorationSet.map(transaction.mapping, transaction.doc);
|
|
},
|
|
},
|
|
view: (view) => {
|
|
if (!highlighted) {
|
|
// we don't highlight code blocks on the first render as part of mounting
|
|
// as it's expensive (relative to the rest of the document). Instead let
|
|
// it render un-highlighted and then trigger a defered render of Prism
|
|
// by updating the plugins metadata
|
|
setTimeout(() => {
|
|
if (!view.isDestroyed) {
|
|
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
|
|
}
|
|
}, 10);
|
|
}
|
|
return {};
|
|
},
|
|
props: {
|
|
decorations(state) {
|
|
return this.getState(state);
|
|
},
|
|
},
|
|
});
|
|
}
|