Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
816 changes: 580 additions & 236 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@
"react-redux": "^9.2.0",
"react-router-dom": "^5.3.4",
"react-split": "^2.0.14",
"react-syntax-highlighter": "^15.6.1",
"redux": "^5.0.1",
"redux-location-state": "^2.8.2",
"shiki": "^3.15.0",
"tslib": "^2.8.1",
"use-query-params": "^2.2.1",
"uuid": "^10.0.0",
Expand Down Expand Up @@ -143,7 +143,6 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@typescript-eslint/parser": "^8.34.1",
"copyfiles": "^2.4.1",
Expand Down
28 changes: 28 additions & 0 deletions src/components/SyntaxHighlighter/YDBSyntaxHighlighter.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,34 @@
}
}

&__content {
overflow: auto;

height: 100%;

background-color: var(--g-color-base-misc-light);
scrollbar-color: var(--g-color-scroll-handle) transparent;

pre {
margin: 0;
padding: var(--g-spacing-4) 0 var(--g-spacing-4) var(--g-spacing-4);

background: transparent !important;
}

code {
white-space: pre-wrap;
word-break: break-word;
@include mixins.text-code-2();
}

&_transparent {
pre {
background: transparent !important;
}
}
}

.data-table__row:hover &__copy,
.ydb-paginated-table__row:hover &__copy {
opacity: 1;
Expand Down
87 changes: 53 additions & 34 deletions src/components/SyntaxHighlighter/YDBSyntaxHighlighter.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
import React from 'react';

import {nanoid} from '@reduxjs/toolkit';
import {PrismLight as ReactSyntaxHighlighter} from 'react-syntax-highlighter';
import {useThemeType} from '@gravity-ui/uikit';

import type {ClipboardButtonProps} from '../ClipboardButton/ClipboardButton';
import {ClipboardButton} from '../ClipboardButton/ClipboardButton';

import {b} from './shared';
import {useSyntaxHighlighterStyle} from './themes';
import {highlightCode} from './shikiHighlighter';
import type {Language} from './types';
import {yql} from './yql';

import './YDBSyntaxHighlighter.scss';

async function registerLanguage(lang: Language) {
if (lang === 'yql') {
ReactSyntaxHighlighter.registerLanguage('yql', yql);
} else {
const {default: syntax} = await import(
`react-syntax-highlighter/dist/esm/languages/prism/${lang}`
);
ReactSyntaxHighlighter.registerLanguage(lang, syntax);
}
}

export interface WithClipboardButtonProp extends ClipboardButtonProps {
alwaysVisible?: boolean;
}
Expand All @@ -43,17 +30,34 @@ export function YDBSyntaxHighlighter({
transparentBackground = true,
withClipboardButton,
}: YDBSyntaxHighlighterProps) {
const [highlighterKey, setHighlighterKey] = React.useState('');
const [highlightedHtml, setHighlightedHtml] = React.useState<string>('');
const [isLoading, setIsLoading] = React.useState(true);

const style = useSyntaxHighlighterStyle(transparentBackground);
const themeType = useThemeType();

React.useEffect(() => {
async function registerLangAndUpdateKey() {
await registerLanguage(language);
setHighlighterKey(nanoid());
let cancelled = false;

async function highlight() {
setIsLoading(true);
try {
const html = await highlightCode(text, language, themeType);
if (!cancelled) {
setHighlightedHtml(html);
}
} catch (error) {
console.error('Failed to highlight code:', error);
} finally {
setIsLoading(false);
}
}
registerLangAndUpdateKey();
}, [language]);

highlight();

return () => {
cancelled = true;
};
}, [text, language, themeType]);

const renderCopyButton = () => {
if (!withClipboardButton) {
Expand All @@ -75,27 +79,42 @@ export function YDBSyntaxHighlighter({
};

let paddingStyles = {};

if (withClipboardButton?.alwaysVisible) {
if (withClipboardButton.withLabel) {
paddingStyles = {paddingRight: 80};
} else {
if (withClipboardButton.withLabel === false) {
paddingStyles = {paddingRight: 40};
} else {
paddingStyles = {paddingRight: 80};
}
}

const containerStyle: React.CSSProperties = {
...paddingStyles,
};

if (transparentBackground) {
containerStyle.background = 'transparent';
}

return (
<div className={b(null, className)}>
{renderCopyButton()}

<ReactSyntaxHighlighter
key={highlighterKey}
language={language}
style={style}
customStyle={{height: '100%', ...paddingStyles}}
>
{text}
</ReactSyntaxHighlighter>
{isLoading || !highlightedHtml ? (
<div
style={containerStyle}
className={b('content', {transparent: transparentBackground})}
>
<pre>
<code>{text}</code>
</pre>
</div>
) : (
<div
className={b('content', {transparent: transparentBackground})}
style={containerStyle}
dangerouslySetInnerHTML={{__html: highlightedHtml}}
/>
)}
</div>
);
}
116 changes: 116 additions & 0 deletions src/components/SyntaxHighlighter/shikiHighlighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type {Highlighter} from 'shiki';

import {yqlDarkTheme, yqlLightTheme} from './themes';
import type {Language, Theme} from './types';

import yqlGrammar from 'monaco-yql-languages/build/yql/YQL.tmLanguage.json';

// Custom themes for YQL
const YQL_LIGHT_THEME = 'yql-light';
const YQL_DARK_THEME = 'yql-dark';

// Standard themes for other languages
const STANDARD_LIGHT_THEME = 'github-light';
const STANDARD_DARK_THEME = 'github-dark';

// Cache the highlighter promise to prevent multiple instances
let highlighterPromise: Promise<Highlighter> | null = null;

// Track what's already loaded
const loadedLanguages = new Set<Language>();
const loadedThemes = new Set<Theme>();

/**
* Get or create the single highlighter instance
* Lazy loads the shiki library on first use
*/
async function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
// Dynamically import shiki library only when needed
highlighterPromise = (async () => {
const {createHighlighter} = await import('shiki');
return createHighlighter({
themes: [],
langs: [],
});
})();
}
return highlighterPromise;
}

/**
* Ensure language is loaded into the highlighter
*/
async function ensureLanguageLoaded(lang: Language): Promise<void> {
if (loadedLanguages.has(lang)) {
return;
}

const hl = await getHighlighter();

try {
if (lang === 'yql') {
await hl.loadLanguage(yqlGrammar);
} else {
await hl.loadLanguage(lang);
}
loadedLanguages.add(lang);
} catch (error) {
console.error(`Failed to load language: ${lang}`, error);
throw error;
}
}

/**
* Ensure theme is loaded into the highlighter
*/
async function ensureThemeLoaded(themeName: Theme): Promise<void> {
if (loadedThemes.has(themeName)) {
return;
}

const hl = await getHighlighter();

try {
if (themeName === YQL_LIGHT_THEME) {
await hl.loadTheme(yqlLightTheme);
} else if (themeName === YQL_DARK_THEME) {
await hl.loadTheme(yqlDarkTheme);
} else {
await hl.loadTheme(themeName);
}
loadedThemes.add(themeName);
} catch (error) {
console.error(`Failed to load theme: ${themeName}`, error);
throw error;
}
}

/**
* Highlight code with Shiki
* Uses a single highlighter instance with on-demand loading of languages and themes
*/
export async function highlightCode(
code: string,
lang: Language,
theme: 'light' | 'dark',
): Promise<string> {
// Determine theme name
const isYql = lang === 'yql';
const isDark = theme === 'dark';

let themeName: Theme = isDark ? STANDARD_DARK_THEME : STANDARD_LIGHT_THEME;
if (isYql) {
themeName = isDark ? YQL_DARK_THEME : YQL_LIGHT_THEME;
}

// Load language and theme if needed
await Promise.all([ensureLanguageLoaded(lang), ensureThemeLoaded(themeName)]);

const hl = await getHighlighter();

return hl.codeToHtml(code, {
lang,
theme: themeName,
});
}
Loading
Loading