Skip to content

Commit 94553e4

Browse files
authored
fix: add caching for code block syntax highlighting (#18)
* fix: add caching for code block syntax highlighting Introduces a cache for tokenized code blocks to improve performance of syntax highlighting in the editor. Refactors decoration logic into a separate function and adds a utility to clear the cache, which can be used when switching themes or languages. * add caching for code block syntax highlighting
1 parent 7279e11 commit 94553e4

File tree

1 file changed

+104
-34
lines changed

1 file changed

+104
-34
lines changed

ui/src/editor/code-block-shiki/shiki-plugin.ts

Lines changed: 104 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,99 @@ import {
1717
loadTheme,
1818
} from "./highlighter";
1919

20+
interface CachedTokens {
21+
tokens: Array<Array<{ content: string; color: string }>>;
22+
themeBg: string;
23+
}
24+
25+
const tokensCache = new Map<string, CachedTokens>();
26+
27+
function simpleHash(str: string): number {
28+
let hash = 5381;
29+
for (let i = 0; i < str.length; i++) {
30+
hash = (hash * 33) ^ str.charCodeAt(i);
31+
}
32+
return hash >>> 0;
33+
}
34+
35+
function generateBlockHash(
36+
content: string,
37+
language: string,
38+
theme: string,
39+
): string {
40+
const contentHash = simpleHash(content);
41+
return `${language}:${theme}:${contentHash}`;
42+
}
43+
44+
function createBlockDecorations(
45+
block: { node: PMNode; pos: number },
46+
language: string,
47+
theme: string,
48+
highlighter: ReturnType<typeof getShiki>,
49+
): Decoration[] {
50+
if (!highlighter) return [];
51+
52+
const content = block.node.textContent;
53+
const hash = generateBlockHash(content, language, theme);
54+
const decorations: Decoration[] = [];
55+
56+
let cachedData = tokensCache.get(hash);
57+
58+
if (!cachedData) {
59+
const themeResolved = highlighter.getTheme(theme);
60+
const rawTokens = highlighter.codeToTokensBase(content, {
61+
lang: language as BundledLanguage,
62+
theme: theme as BundledTheme,
63+
});
64+
65+
const tokens = rawTokens.map((line) =>
66+
line.map((token) => ({
67+
content: token.content,
68+
color: token.color || "#000000",
69+
})),
70+
);
71+
72+
cachedData = {
73+
tokens,
74+
themeBg: themeResolved.bg || "",
75+
};
76+
77+
tokensCache.set(hash, cachedData);
78+
79+
if (tokensCache.size > 100) {
80+
const firstKey = tokensCache.keys().next().value;
81+
if (firstKey) {
82+
tokensCache.delete(firstKey);
83+
}
84+
}
85+
}
86+
87+
decorations.push(
88+
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
89+
style: `background-color: ${cachedData.themeBg}`,
90+
}),
91+
);
92+
93+
let from = block.pos + 1;
94+
for (const line of cachedData.tokens) {
95+
for (const token of line) {
96+
const to = from + token.content.length;
97+
98+
decorations.push(
99+
Decoration.inline(from, to, {
100+
style: `color: ${token.color}`,
101+
}),
102+
);
103+
104+
from = to;
105+
}
106+
107+
from += 1;
108+
}
109+
110+
return decorations;
111+
}
112+
20113
/** Create code decorations for the current document */
21114
function getDecorations({
22115
doc,
@@ -30,17 +123,17 @@ function getDecorations({
30123
defaultTheme: BundledTheme;
31124
}) {
32125
const decorations: Decoration[] = [];
33-
34126
const codeBlockCodes = findChildren(doc, (node) => node.type.name === name);
127+
const highlighter = getShiki();
128+
129+
if (!highlighter) {
130+
return DecorationSet.create(doc, decorations);
131+
}
35132

36133
codeBlockCodes.forEach((block) => {
37134
let language = block.node.attrs.language || defaultLanguage;
38135
const theme = block.node.attrs.theme || defaultTheme;
39136

40-
const highlighter = getShiki();
41-
42-
if (!highlighter) return;
43-
44137
if (!highlighter.getLoadedLanguages().includes(language)) {
45138
language = "plaintext";
46139
}
@@ -49,34 +142,14 @@ function getDecorations({
49142
? theme
50143
: highlighter.getLoadedThemes()[0];
51144

52-
const themeResolved = highlighter.getTheme(themeToApply);
53-
decorations.push(
54-
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
55-
style: `background-color: ${themeResolved.bg}`,
56-
}),
145+
const blockDecorations = createBlockDecorations(
146+
block,
147+
language,
148+
themeToApply,
149+
highlighter,
57150
);
58151

59-
const tokens = highlighter.codeToTokensBase(block.node.textContent, {
60-
lang: language,
61-
theme: themeToApply,
62-
});
63-
64-
let from = block.pos + 1;
65-
for (const line of tokens) {
66-
for (const token of line) {
67-
const to = from + token.content.length;
68-
69-
const decoration = Decoration.inline(from, to, {
70-
style: `color: ${token.color}`,
71-
});
72-
73-
decorations.push(decoration);
74-
75-
from = to;
76-
}
77-
78-
from += 1;
79-
}
152+
decorations.push(...blockDecorations);
80153
});
81154

82155
return DecorationSet.create(doc, decorations);
@@ -184,14 +257,12 @@ export function ShikiPlugin({
184257
// (for example, a transaction that affects the entire document).
185258
// Such transactions can happen during collab syncing via y-prosemirror, for example.
186259
transaction.steps.some((step) => {
187-
// @ts-expect-error
188260
return (
189261
// @ts-expect-error
190262
step.from !== undefined &&
191263
// @ts-expect-error
192264
step.to !== undefined &&
193265
oldNodes.some((node) => {
194-
// @ts-expect-error
195266
return (
196267
// @ts-expect-error
197268
node.pos >= step.from &&
@@ -201,7 +272,6 @@ export function ShikiPlugin({
201272
})
202273
);
203274
}));
204-
205275
// only create code decoration when it's necessary to do so
206276
if (
207277
transaction.getMeta("shikiPluginForceDecoration") ||

0 commit comments

Comments
 (0)