accept line numbers

This commit is contained in:
Nate Kelley 2025-07-22 15:58:55 -06:00
parent 7d7fa078a2
commit 10be9b41b7
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 60 additions and 114 deletions

View File

@ -153,9 +153,6 @@ const HiddenSection: React.FC<{
</div>
);
const lineNumberStyles: React.CSSProperties = {
minWidth: '2.25em'
};
const MemoizedSyntaxHighlighter = React.memo(
({ lineNumber, text }: { lineNumber: number; text: string }) => {
return (
@ -163,7 +160,6 @@ const MemoizedSyntaxHighlighter = React.memo(
language={'yaml'}
showLineNumbers
startingLineNumber={lineNumber}
lineNumberStyle={lineNumberStyles}
className={'m-0! w-fit! border-none! p-0!'}>
{text}
</SyntaxHighlighter>

View File

@ -1,32 +0,0 @@
import React from 'react';
import type { ThemedToken } from 'shiki';
interface LineProps {
tokens: ThemedToken[];
lineNumber: number;
showLineNumber: boolean;
animation?: string;
animationDuration?: number;
isNew?: boolean;
}
export const Line: React.FC<LineProps> = React.memo(
({ tokens, lineNumber, showLineNumber, animation, animationDuration = 700, isNew = false }) => {
const lineStyle =
isNew && animation && animation !== 'none'
? { animation: `${animation} ${animationDuration}ms ease-in-out forwards` }
: undefined;
return (
<div className="line" style={lineStyle}>
{tokens.map((token, index) => (
<span key={index} style={{ color: token.color }}>
{token.content}
</span>
))}
</div>
);
}
);
Line.displayName = 'Line';

View File

@ -1,28 +1,26 @@
/* Styles for the Shiki syntax highlighter wrapper */
.shikiWrapper :global(pre) {
margin: 0;
padding: 0;
padding: 0em;
overflow-x: auto;
background: transparent !important;
}
.shikiWrapper :global(code) {
background: transparent !important;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
line-height: 1.45;
}
/* Single-line code padding for non-line-number view */
.shikiWrapper:not(.shikiWrapperWithLineNumbers) :global(pre) {
padding: 1rem;
}
/* CSS counter-based line numbers */
.shikiWrapper.withLineNumbers :global(code) {
counter-reset: step var(--line-number-start, 0);
}
.shikiWrapper.withLineNumbers :global(code .line)::before {
/* Line class for syntax highlighted lines */
.line {
display: block;
}
.shikiWrapper.withLineNumbers .line::before {
content: counter(step);
counter-increment: step;
width: 2rem;

View File

@ -95,7 +95,7 @@ export const SqlExample: Story = {
language: 'sql',
isDarkMode: false,
showLineNumbers: false,
className: 'border rounded-lg p-4 mr-2 max-w-[350px]'
className: 'border rounded-lg pl-4 py-3 mr-2 max-w-[350px]'
}
};
@ -222,6 +222,7 @@ const StreamingAnimationStory = ({
animation={animation}
isDarkMode={isDarkMode}
showLineNumbers={showLineNumbers}
animationDuration={800}
className="rounded-lg border p-4">
{currentCode}
</SyntaxHighlighter>
@ -231,7 +232,7 @@ const StreamingAnimationStory = ({
export const StreamingFadeIn: Story = {
render: () => (
<StreamingAnimationStory code={sqlCode} language="sql" animation="fadeIn" delay={300} />
<StreamingAnimationStory code={sqlCode} language="sql" animation="fadeIn" delay={100} />
)
};

View File

@ -1,9 +1,9 @@
import { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { cn } from '@/lib/classMerge';
import { getCodeTokens } from './shiki-instance';
import styles from './SyntaxHighlighter.module.css';
import { animations, type MarkdownAnimation } from '../animation-common';
import { Line } from './Line';
import type { ThemedToken } from 'shiki';
export const SyntaxHighlighter = (props: {
children: string;
@ -11,6 +11,7 @@ export const SyntaxHighlighter = (props: {
showLineNumbers?: boolean;
startingLineNumber?: number;
className?: string;
containerClassName?: string;
customStyle?: React.CSSProperties;
isDarkMode?: boolean;
animation?: MarkdownAnimation;
@ -22,6 +23,7 @@ export const SyntaxHighlighter = (props: {
showLineNumbers = false,
startingLineNumber = 1,
className = '',
containerClassName = '',
customStyle = {},
isDarkMode = false,
animation = 'none',
@ -31,23 +33,12 @@ export const SyntaxHighlighter = (props: {
const [tokens, setTokens] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
// Track which lines have been rendered before
const renderedLinesRef = useRef<Set<number>>(new Set());
const previousLineCountRef = useRef(0);
useEffect(() => {
const loadTokens = async () => {
try {
const theme = isDarkMode ? 'buster-dark' : 'buster-light';
const theme = isDarkMode ? 'github-dark' : 'github-light';
const tokenData = await getCodeTokens(children, language, theme);
// Check if content got shorter (lines were removed)
const currentLineCount = tokenData.tokens.length;
if (currentLineCount < previousLineCountRef.current) {
renderedLinesRef.current.clear();
}
previousLineCountRef.current = currentLineCount;
setTokens(tokenData);
setIsLoading(false);
} catch (error) {
@ -61,7 +52,9 @@ export const SyntaxHighlighter = (props: {
if (isLoading || !tokens) {
return (
<div className={cn(styles.shikiContainer, className)} style={customStyle}>
<div
className={cn(styles.shikiContainer, containerClassName, 'invisible')}
style={customStyle}>
<pre>
<code>{children}</code>
</pre>
@ -70,38 +63,34 @@ export const SyntaxHighlighter = (props: {
}
return (
<div className={cn(styles.shikiContainer, className)} style={customStyle}>
<div className={cn(styles.shikiContainer, containerClassName)} style={customStyle}>
<div
className={cn(
styles.shikiWrapper,
showLineNumbers && styles.withLineNumbers,
animation !== 'none' && styles.animated,
'overflow-x-auto'
'overflow-x-auto',
className
)}
style={
showLineNumbers && startingLineNumber !== 1
style={{
background: tokens.bg,
color: tokens.fg,
...(showLineNumbers && startingLineNumber !== 1
? ({
'--line-number-start': startingLineNumber - 1
} as React.CSSProperties)
: undefined
}>
<pre style={{ background: tokens.bg, color: tokens.fg }}>
: undefined)
}}>
<pre>
<code>
{tokens.tokens.map((line: any[], index: number) => {
const isNewLine = !renderedLinesRef.current.has(index);
if (isNewLine) {
renderedLinesRef.current.add(index);
}
return (
<Line
key={index}
tokens={line}
lineNumber={index + 1}
showLineNumber={showLineNumbers}
animation={animation !== 'none' ? animations[animation] : undefined}
animationDuration={animationDuration}
isNew={isNewLine}
/>
);
})}
@ -111,3 +100,32 @@ export const SyntaxHighlighter = (props: {
</div>
);
};
// Line component for rendering individual lines with animation support
interface LineProps {
tokens: ThemedToken[];
lineNumber: number;
animation?: string;
animationDuration?: number;
}
const Line: React.FC<LineProps> = React.memo(
({ tokens, animation, lineNumber, animationDuration = 700 }) => {
const lineStyle =
animation && animation !== 'none'
? { animation: `${animation} ${animationDuration}ms ease-in-out forwards` }
: undefined;
return (
<div className={styles.line} style={lineStyle} data-line-number={lineNumber}>
{tokens.map((token, index) => (
<span key={index} style={{ color: token.color }}>
{token.content}
</span>
))}
</div>
);
}
);
Line.displayName = 'Line';

View File

@ -8,14 +8,6 @@ import githubDark from '@shikijs/themes/github-dark';
let highlighterInstance: HighlighterCore | null = null;
let initializationPromise: Promise<HighlighterCore> | null = null;
// Cache for highlighted code
const highlightCache = new Map<string, string>();
// Generate cache key
const getCacheKey = (code: string, language: string, theme: string): string => {
return `${language}:${theme}:${code}`;
};
// Initialize the highlighter with pre-loaded languages and themes
export const initializeHighlighter = async (): Promise<HighlighterCore> => {
// Return existing instance if available
@ -45,20 +37,13 @@ export const initializeHighlighter = async (): Promise<HighlighterCore> => {
}
};
// Highlight code with caching
// Highlight code
export const highlightCode = async (
code: string,
language: 'sql' | 'yaml',
theme: 'github-light' | 'github-dark',
transformers?: ShikiTransformer[]
): Promise<string> => {
// Check cache first
const cacheKey = getCacheKey(code, language, theme);
const cached = highlightCache.get(cacheKey);
if (cached && !transformers?.length) {
return cached;
}
// Get or initialize highlighter
const highlighter = await initializeHighlighter();
@ -69,44 +54,24 @@ export const highlightCode = async (
transformers
});
// Cache if no transformers (transformers might add dynamic content like line numbers)
if (!transformers?.length) {
highlightCache.set(cacheKey, html);
}
return html;
};
// Get tokens for code with caching
const tokenCache = new Map<string, any>();
// Get tokens for code
export const getCodeTokens = async (
code: string,
language: 'sql' | 'yaml',
theme: 'buster-light' | 'buster-dark'
theme: 'github-light' | 'github-dark'
): Promise<any> => {
const cacheKey = getCacheKey(code, language, theme);
const cached = tokenCache.get(cacheKey);
if (cached) {
return cached;
}
const highlighter = await initializeHighlighter();
const tokens = highlighter.codeToTokens(code, {
lang: language,
theme
});
tokenCache.set(cacheKey, tokens);
return tokens;
};
// Clear cache (useful for memory management if needed)
export const clearHighlightCache = (): void => {
highlightCache.clear();
tokenCache.clear();
};
// Pre-initialize highlighter on module load for better performance
if (typeof window !== 'undefined') {
initializeHighlighter().catch((error) => {