mirror of https://github.com/buster-so/buster.git
accept line numbers
This commit is contained in:
parent
7d7fa078a2
commit
10be9b41b7
|
@ -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>
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
|
@ -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} />
|
||||
)
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) => {
|
||||
|
|
Loading…
Reference in New Issue