1import { syntaxTree } from "@codemirror/language";
2import { type EditorState, StateEffect } from "@codemirror/state";
3import {
4 Decoration,
5 type DecorationSet,
6 EditorView,
7 ViewPlugin,
8 type ViewUpdate,
9} from "@codemirror/view";
10
11import { languageOfInfoString } from "~/composer-code-languages";
12import { resolveDiffThemeName } from "~/lib/diffRendering";
13import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
14
15export function composerCodeBlocks(state: EditorState) {
16 const blocks: Array<{
17 from: number;
18 infoFrom: number;
19 infoTo: number;
20 info: string;
21 codeFrom: number;
22 codeTo: number;
23 code: string;
24 }> = [];
25 syntaxTree(state).iterate({
26 enter(node) {
27 if (node.name !== "FencedCode") return undefined;
28 const info = node.node.getChild("CodeInfo");
29 const mark = node.node.getChild("CodeMark");
30 if (!mark) return false;
31 const code = node.node.getChild("CodeText");
32 const openingLine = state.doc.lineAt(node.from);
33 const bodyFrom =
34 openingLine.number < state.doc.lines
35 ? state.doc.line(openingLine.number + 1).from
36 : openingLine.to;
37 const codeFrom = code?.from ?? bodyFrom;
38 const codeTo =
39 code &&
40 node.node.getChildren("CodeMark").length > 1 &&
41 state.sliceDoc(code.to - 1, code.to) === "\n"
42 ? Math.max(codeFrom, code.to - 1)
43 : (code?.to ?? bodyFrom);
44 blocks.push({
45 from: node.from,
46 infoFrom: info?.from ?? mark.to,
47 infoTo: info?.to ?? mark.to,
48 info: info ? state.sliceDoc(info.from, info.to) : "",
49 codeFrom,
50 codeTo,
51 code: state.sliceDoc(codeFrom, codeTo),
52 });
53 return false;
54 },
55 });
56 return blocks;
57}
58
59export const composerCodeBlockHighlight = ViewPlugin.fromClass(
60 class {
61 decorations: DecorationSet = Decoration.none;
62 readonly cache = new Map<string, readonly { from: number; to: number; color: string }[]>();
63 readonly repaint = StateEffect.define<null>();
64 readonly observer: MutationObserver;
65 disposed = false;
66 live = new Set<string>();
67 doc: EditorState["doc"] | null = null;
68 tree: ReturnType<typeof syntaxTree> | null = null;
69 theme = "";
70
71 constructor(readonly view: EditorView) {
72 this.observer = new MutationObserver(() => view.dispatch({ effects: this.repaint.of(null) }));
73 this.observer.observe(document.documentElement, {
74 attributeFilter: ["class", "data-theme-id"],
75 });
76 this.refresh();
77 }
78 update(update: ViewUpdate) {
79 if (
80 update.docChanged ||
81 syntaxTree(update.state) !== this.tree ||
82 update.transactions.some((transaction) =>
83 transaction.effects.some((effect) => effect.is(this.repaint)),
84 )
85 )
86 this.refresh();
87 }
88 refresh() {
89 const state = this.view.state;
90 const theme = resolveDiffThemeName(
91 document.documentElement.classList.contains("dark") ? "dark" : "light",
92 );
93 const blocks = composerCodeBlocks(state);
94 const signature = (block: (typeof blocks)[number]) =>
95 `${theme}\u0000${block.info}\u0000${block.code}`;
96 this.live = new Set(blocks.map(signature));
97 for (const key of this.cache.keys()) if (!this.live.has(key)) this.cache.delete(key);
98 this.decorations = Decoration.set(
99 blocks.flatMap((block) =>
100 (this.cache.get(signature(block)) ?? []).map((span) =>
101 Decoration.mark({ attributes: { style: `color: ${span.color}` } }).range(
102 block.codeFrom + span.from,
103 block.codeFrom + span.to,
104 ),
105 ),
106 ),
107 true,
108 );
109 if (this.doc === state.doc && this.tree === syntaxTree(state) && this.theme === theme) return;
110 this.doc = state.doc;
111 this.tree = syntaxTree(state);
112 this.theme = theme;
113 const pending = blocks.filter(
114 (block) => block.code.length <= 20_000 && !this.cache.has(signature(block)),
115 );
116 if (pending.length === 0) return;
117 void Promise.allSettled(
118 pending.map(async (block) => {
119 const key = signature(block);
120 const language = languageOfInfoString(block.info) || "text";
121 const highlighter = await getSyntaxHighlighterPromise(language);
122 if (this.disposed || this.cache.has(key) || !this.live.has(key)) return;
123 const spans: Array<{ from: number; to: number; color: string }> = [];
124 try {
125 let offset = 0;
126 for (const [index, line] of highlighter
127 .codeToTokens(block.code, { lang: language, theme })
128 .tokens.entries()) {
129 if (index > 0) offset += block.code.startsWith("\r\n", offset) ? 2 : 1;
130 for (const token of line) {
131 if (token.color && token.content.trim())
132 spans.push({
133 from: offset,
134 to: offset + token.content.length,
135 color: token.color,
136 });
137 offset += token.content.length;
138 }
139 }
140 } catch {
141 /* Unsupported languages retain source text. */
142 }
143 this.cache.set(key, spans);
144 }),
145 ).then(() => {
146 if (!this.disposed) this.view.dispatch({ effects: this.repaint.of(null) });
147 });
148 }
149 destroy() {
150 this.disposed = true;
151 this.observer.disconnect();
152 }
153 },
154 { decorations: (plugin) => plugin.decorations },
155);