Skip to content

Commit 063e774

Browse files
Merge pull request #6463 from continuedev/jacob/enhancement/nextedit-render
Jacob/enhancement/nextedit render
2 parents 8e93918 + ee0a9a4 commit 063e774

File tree

5 files changed

+247
-83
lines changed

5 files changed

+247
-83
lines changed

core/codeRenderer/CodeRenderer.ts

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,19 @@
88
* we rarely ever need syntax highlighting outside of
99
* creating a render of it.
1010
*/
11+
import {
12+
transformerMetaHighlight,
13+
transformerNotationDiff,
14+
transformerNotationFocus,
15+
transformerNotationHighlight,
16+
} from "@shikijs/transformers";
1117
import { JSDOM } from "jsdom";
12-
import { BundledTheme, codeToHtml, getSingletonHighlighter } from "shiki";
18+
import {
19+
BundledLanguage,
20+
BundledTheme,
21+
getSingletonHighlighter,
22+
Highlighter,
23+
} from "shiki";
1324
import { escapeForSVG, kebabOfStr } from "../util/text";
1425

1526
interface CodeRendererOptions {
@@ -42,6 +53,8 @@ export class CodeRenderer {
4253
private currentTheme: string = "dark-plus";
4354
private editorBackground: string = "#000000";
4455
private editorForeground: string = "#FFFFFF";
56+
private editorLineHighlight: string = "#000000";
57+
private highlighter: Highlighter | null = null;
4558

4659
private constructor() {}
4760

@@ -62,13 +75,17 @@ export class CodeRenderer {
6275
? "dark-plus"
6376
: kebabOfStr(themeName);
6477

65-
const highlighter = await getSingletonHighlighter({
78+
this.highlighter = await getSingletonHighlighter({
79+
langs: ["typescript"],
6680
themes: [this.currentTheme],
6781
});
6882

69-
const th = highlighter.getTheme(this.currentTheme);
83+
const th = this.highlighter.getTheme(this.currentTheme);
84+
7085
this.editorBackground = th.bg;
7186
this.editorForeground = th.fg;
87+
this.editorLineHighlight =
88+
th.colors!["editor.lineHighlightBackground"] ?? "#000000";
7289
} else {
7390
this.currentTheme = "dark-plus";
7491
}
@@ -148,10 +165,29 @@ export class CodeRenderer {
148165
async highlightCode(
149166
code: string,
150167
language: string = "javascript",
168+
currLineOffsetFromTop: number,
151169
): Promise<string> {
152-
return await codeToHtml(code, {
170+
const annotatedCode = code
171+
.split("\n")
172+
.map((line, i) =>
173+
i === currLineOffsetFromTop
174+
? line + " \/\/ \[\!code highlight\]"
175+
: line,
176+
)
177+
.join("\n");
178+
179+
await this.highlighter!.loadLanguage(language as BundledLanguage);
180+
181+
return this.highlighter!.codeToHtml(annotatedCode, {
153182
lang: language,
154183
theme: this.currentTheme,
184+
transformers: [
185+
// transformerColorizedBrackets(),
186+
transformerMetaHighlight(),
187+
transformerNotationHighlight(),
188+
transformerNotationDiff(),
189+
transformerNotationFocus(),
190+
],
155191
});
156192
}
157193

@@ -163,23 +199,40 @@ export class CodeRenderer {
163199
dimensions: Dimensions,
164200
lineHeight: number,
165201
options: ConversionOptions,
202+
currLineOffsetFromTop: number,
166203
): Promise<Buffer> {
167-
const highlightedCodeHtml = await this.highlightCode(code, language);
204+
const strokeWidth = 1;
205+
const highlightedCodeHtml = await this.highlightCode(
206+
code,
207+
language,
208+
currLineOffsetFromTop,
209+
);
210+
// console.log(highlightedCodeHtml);
168211

169-
const guts = this.convertShikiHtmlToSvgGut(
212+
const { guts, lineBackgrounds } = this.convertShikiHtmlToSvgGut(
170213
highlightedCodeHtml,
171214
fontSize,
172215
fontFamily,
173216
lineHeight,
217+
dimensions,
174218
);
175219
const backgroundColor = this.getBackgroundColor(highlightedCodeHtml);
176220

177-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${dimensions.width}" height="${dimensions.height}">
221+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${dimensions.width}" height="${dimensions.height}" shape-rendering="crispEdges">
222+
<style>
223+
:root {
224+
--purple: rgb(112, 114, 209);
225+
--green: rgb(136, 194, 163);
226+
--blue: rgb(107, 166, 205);
227+
}
228+
</style>
178229
<g>
179-
<rect width="${dimensions.width}" height="${dimensions.height}" fill="${backgroundColor}" stroke="${this.editorForeground}" stroke-width="1" />
230+
<rect x="0" y="0" rx="10" ry="10" width="${dimensions.width}" height="${dimensions.height}" fill="${this.editorBackground}" shape-rendering="crispEdges" />
231+
${lineBackgrounds}
180232
${guts}
181233
</g>
182234
</svg>`;
235+
console.log(svg);
183236

184237
return Buffer.from(svg, "utf8");
185238
}
@@ -189,7 +242,8 @@ export class CodeRenderer {
189242
fontSize: number,
190243
fontFamily: string,
191244
lineHeight: number,
192-
): string {
245+
dimensions: Dimensions,
246+
): { guts: string; lineBackgrounds: string } {
193247
const dom = new JSDOM(shikiHtml);
194248
const document = dom.window.document;
195249

@@ -204,19 +258,70 @@ export class CodeRenderer {
204258
const el = node as HTMLElement;
205259
const style = el.getAttribute("style") || "";
206260
const colorMatch = style.match(/color:\s*(#[0-9a-fA-F]{6})/);
207-
const fill = colorMatch ? ` fill="${colorMatch[1]}"` : "";
261+
const classes = el.getAttribute("class") || "";
262+
let fill = colorMatch ? ` fill="${colorMatch[1]}"` : "";
263+
if (classes.includes("highlighted")) {
264+
fill = ` fill="${this.editorLineHighlight}"`;
265+
}
208266
const content = el.textContent || "";
209267
return `<tspan xml:space="preserve"${fill}>${escapeForSVG(content)}</tspan>`;
210268
})
211269
.join("");
212270

213-
const y = (index + 1) * lineHeight;
214-
return `<text x="0" y="${y}" font-family="${fontFamily}" font-size="${fontSize.toString()}" xml:space="preserve">${spans}</text>`;
271+
// Typography notes:
272+
// Each line of code is a <text> inside a <rect>.
273+
// Math becomes interesting here; the y value is actually aligned to the topmost border.
274+
// So y = 0 will have the rect be flush with the top border.
275+
// More importantly, text will also be positioned that way.
276+
// Since y = 0 is the axis the text will align itself to, the default settings will actually have the text sitting "on top of" the y = 0 axis, which effectively shifts them up.
277+
// To prevent this, we want the alignment axis to be at the middle of each rect, and have the text align itself vertically to the center (skwered by the axis).
278+
// The first step is to add lineHeight / 2 to move the axis down.
279+
// The second step is to add 'dominant-baseline="central"' to vertically center the text.
280+
// Note that we choose "central" over "middle". "middle" will center the text too perfectly, which is actually undesirable!
281+
const y = index * lineHeight + lineHeight / 2;
282+
return `<text x="0" y="${y}" font-family="${fontFamily}" font-size="${fontSize.toString()}" xml:space="preserve" dominant-baseline="central" shape-rendering="crispEdges">${spans}</text>`;
215283
});
216284

217-
return `
218-
${svgLines.join("\n")}
219-
`.trim();
285+
const lineBackgrounds = lines
286+
.map((line, index) => {
287+
const classes = line?.getAttribute("class") || "";
288+
const bgColor = classes.includes("highlighted")
289+
? this.editorLineHighlight
290+
: this.editorBackground;
291+
const y = index * lineHeight;
292+
const isFirst = index === 0;
293+
const isLast = index === lines.length - 1;
294+
const radius = 10;
295+
// SVG notes:
296+
// By default SVGs have anti-aliasing on.
297+
// This is undesirable in our case because pixel-perfect alignment of these rectangles will introduce thin gaps.
298+
// Turning it off with 'shape-rendering="crispEdges"' solves the issue.
299+
return isFirst
300+
? `<path d="M ${0} ${y + lineHeight}
301+
L ${0} ${y + radius}
302+
Q ${0} ${y} ${radius} ${y}
303+
L ${dimensions.width - radius} ${y}
304+
Q ${dimensions.width} ${y} ${dimensions.width} ${y + radius}
305+
L ${dimensions.width} ${y + lineHeight}
306+
Z"
307+
fill="${bgColor}" />`
308+
: isLast
309+
? `<path d="M ${0} ${y}
310+
L ${0} ${y + lineHeight - radius}
311+
Q ${0} ${y + lineHeight} ${radius} ${y + lineHeight}
312+
L ${dimensions.width - radius} ${y + lineHeight}
313+
Q ${dimensions.width} ${y + lineHeight} ${dimensions.width} ${y + lineHeight - 10}
314+
L ${dimensions.width} ${y}
315+
Z"
316+
fill="${bgColor}" />`
317+
: `<rect x="0" y="${y}" rx="${radius}" ry="${radius}" width="100%" height="${lineHeight}" fill="${bgColor}" shape-rendering="crispEdges" />`;
318+
})
319+
.join("\n");
320+
321+
return {
322+
guts: svgLines.join("\n"),
323+
lineBackgrounds,
324+
};
220325
}
221326

222327
getBackgroundColor(shikiHtml: string): string {
@@ -244,6 +349,7 @@ export class CodeRenderer {
244349
dimensions: Dimensions,
245350
lineHeight: number,
246351
options: ConversionOptions,
352+
currLineOffsetFromTop: number,
247353
): Promise<DataUri> {
248354
switch (options.imageType) {
249355
// case "png":
@@ -265,6 +371,7 @@ export class CodeRenderer {
265371
dimensions,
266372
lineHeight,
267373
options,
374+
currLineOffsetFromTop,
268375
);
269376
return `data:image/svg+xml;base64,${svgBuffer.toString("base64")}`;
270377
}

core/package-lock.json

Lines changed: 55 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"@babel/preset-env": "^7.24.7",
1919
"@biomejs/biome": "1.6.4",
2020
"@google/generative-ai": "^0.11.4",
21+
"@shikijs/colorized-brackets": "^3.7.0",
22+
"@shikijs/transformers": "^3.7.0",
2123
"@types/diff": "^7.0.1",
2224
"@types/follow-redirects": "^1.14.4",
2325
"@types/jest": "^29.5.12",

0 commit comments

Comments
 (0)