feat: add syntax highlighting and line highlighting features with customizable themes
This commit is contained in:
Generated
+10
@@ -12,6 +12,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"immer": "^11.1.3",
|
||||
"konva": "^10.0.12",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-konva": "^19.2.1",
|
||||
@@ -2893,6 +2894,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.562.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"immer": "^11.1.3",
|
||||
"konva": "^10.0.12",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-konva": "^19.2.1",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { Group, Rect, Text, Transformer } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import type { CodeElement } from '../../types';
|
||||
import { tokenizeCode, getThemeBackground, isLightTheme, type LineTokens } from '../../utils/highlighter';
|
||||
|
||||
interface CodeBlockProps {
|
||||
element: CodeElement;
|
||||
@@ -14,6 +15,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
const groupRef = useRef<Konva.Group>(null);
|
||||
const trRef = useRef<Konva.Transformer>(null);
|
||||
const { x, y, width, height, rotation, props } = element;
|
||||
const [tokenizedLines, setTokenizedLines] = useState<LineTokens[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && trRef.current && groupRef.current) {
|
||||
@@ -22,16 +24,42 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
// Simple code rendering with line numbers
|
||||
// Tokenize code for syntax highlighting
|
||||
useEffect(() => {
|
||||
tokenizeCode(props.code, props.language, props.theme)
|
||||
.then(setTokenizedLines)
|
||||
.catch(() => {
|
||||
// Fallback to plain text
|
||||
setTokenizedLines(
|
||||
props.code.split('\n').map(line => ({
|
||||
tokens: [{ content: line, color: isLightTheme(props.theme) ? '#1f1f1f' : '#e5e5e5' }],
|
||||
}))
|
||||
);
|
||||
});
|
||||
}, [props.code, props.language, props.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && trRef.current && groupRef.current) {
|
||||
trRef.current.nodes([groupRef.current]);
|
||||
trRef.current.getLayer()?.batchDraw();
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
// Render code with syntax highlighting using tokens
|
||||
const renderCode = () => {
|
||||
const lines = props.code.split('\n');
|
||||
const lines = tokenizedLines.length > 0 ? tokenizedLines :
|
||||
props.code.split('\n').map(line => ({
|
||||
tokens: [{ content: line, color: isLightTheme(props.theme) ? '#1f1f1f' : '#e5e5e5' }],
|
||||
}));
|
||||
|
||||
const lineHeight = props.fontSize * props.lineHeight;
|
||||
const startY = props.padding;
|
||||
const lineNumberWidth = props.lineNumbers ? 40 : 0;
|
||||
const lineNumberWidth = props.lineNumbers ? 50 : 0;
|
||||
const lineNumColor = isLightTheme(props.theme) ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.4)';
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
lines.forEach((lineTokens, index) => {
|
||||
const yPos = startY + index * lineHeight;
|
||||
const lineNum = index + 1;
|
||||
|
||||
@@ -42,10 +70,11 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
|
||||
// Line highlight background
|
||||
if (highlight) {
|
||||
let bgColor = 'rgba(255, 255, 0, 0.15)'; // focus
|
||||
if (highlight.style === 'added') bgColor = 'rgba(0, 255, 0, 0.15)';
|
||||
if (highlight.style === 'removed') bgColor = 'rgba(255, 0, 0, 0.15)';
|
||||
let bgColor = 'rgba(255, 255, 0, 0.2)'; // focus - yellow
|
||||
if (highlight.style === 'added') bgColor = 'rgba(46, 160, 67, 0.25)';
|
||||
if (highlight.style === 'removed') bgColor = 'rgba(248, 81, 73, 0.25)';
|
||||
|
||||
// Add a subtle left border indicator
|
||||
elements.push(
|
||||
<Rect
|
||||
key={`highlight-${index}`}
|
||||
@@ -56,6 +85,21 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
fill={bgColor}
|
||||
/>
|
||||
);
|
||||
|
||||
// Left border for highlight
|
||||
elements.push(
|
||||
<Rect
|
||||
key={`highlight-border-${index}`}
|
||||
x={0}
|
||||
y={yPos - 2}
|
||||
width={3}
|
||||
height={lineHeight}
|
||||
fill={
|
||||
highlight.style === 'focus' ? '#facc15' :
|
||||
highlight.style === 'added' ? '#2ea043' : '#f85149'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Line number
|
||||
@@ -68,25 +112,33 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
text={String(lineNum)}
|
||||
fontSize={props.fontSize}
|
||||
fontFamily={props.fontFamily}
|
||||
fill={props.theme === 'dark' ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
|
||||
width={30}
|
||||
fill={lineNumColor}
|
||||
width={35}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Code text
|
||||
// Code text with syntax highlighting
|
||||
let xOffset = props.padding + lineNumberWidth + (props.lineNumbers ? 5 : 0);
|
||||
|
||||
lineTokens.tokens.forEach((token, tokenIndex) => {
|
||||
if (token.content) {
|
||||
elements.push(
|
||||
<Text
|
||||
key={`code-${index}`}
|
||||
x={props.padding + lineNumberWidth + 10}
|
||||
key={`code-${index}-${tokenIndex}`}
|
||||
x={xOffset}
|
||||
y={yPos}
|
||||
text={line || ' '}
|
||||
text={token.content}
|
||||
fontSize={props.fontSize}
|
||||
fontFamily={props.fontFamily}
|
||||
fill={props.theme === 'dark' ? '#e5e5e5' : '#1f1f1f'}
|
||||
fill={token.color}
|
||||
/>
|
||||
);
|
||||
// Approximate character width for monospace font
|
||||
xOffset += token.content.length * (props.fontSize * 0.6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return elements;
|
||||
@@ -118,7 +170,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
|
||||
});
|
||||
};
|
||||
|
||||
const bgColor = props.theme === 'dark' ? '#1e1e2e' : '#f8f8f8';
|
||||
const bgColor = getThemeBackground(props.theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import type { CodeElement } from '../../types';
|
||||
import { LANGUAGES, FONT_FAMILIES } from '../../types';
|
||||
import type { CodeElement, LineHighlight } from '../../types';
|
||||
import { LANGUAGES, FONT_FAMILIES, CODE_THEMES } from '../../types';
|
||||
import { detectLanguage } from '../../utils/highlighter';
|
||||
|
||||
interface CodeInspectorProps {
|
||||
@@ -10,6 +10,9 @@ interface CodeInspectorProps {
|
||||
|
||||
const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
|
||||
const { updateElement, saveToHistory } = useCanvasStore();
|
||||
const [highlightFrom, setHighlightFrom] = useState('');
|
||||
const [highlightTo, setHighlightTo] = useState('');
|
||||
const [highlightStyle, setHighlightStyle] = useState<'focus' | 'added' | 'removed'>('focus');
|
||||
|
||||
const update = (updates: Partial<CodeElement>) => {
|
||||
updateElement(element.id, updates);
|
||||
@@ -28,6 +31,27 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
|
||||
updateProps({ language: detected });
|
||||
};
|
||||
|
||||
const addHighlight = () => {
|
||||
const from = parseInt(highlightFrom);
|
||||
const to = parseInt(highlightTo) || from;
|
||||
if (isNaN(from) || from < 1) return;
|
||||
|
||||
const newHighlight: LineHighlight = { from, to: Math.max(from, to), style: highlightStyle };
|
||||
const highlights = [...element.props.highlights, newHighlight];
|
||||
updateProps({ highlights });
|
||||
saveToHistory();
|
||||
setHighlightFrom('');
|
||||
setHighlightTo('');
|
||||
};
|
||||
|
||||
const removeHighlight = (index: number) => {
|
||||
const highlights = element.props.highlights.filter((_, i) => i !== index);
|
||||
updateProps({ highlights });
|
||||
saveToHistory();
|
||||
};
|
||||
|
||||
const totalLines = element.props.code.split('\n').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Code editor */}
|
||||
@@ -69,27 +93,24 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Theme</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto p-1">
|
||||
{CODE_THEMES.map((theme) => (
|
||||
<button
|
||||
onClick={() => updateProps({ theme: 'dark' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
element.props.theme === 'dark'
|
||||
key={theme.id}
|
||||
onClick={() => updateProps({ theme: theme.id })}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-all ${
|
||||
element.props.theme === theme.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
: 'bg-neutral-700 text-neutral-300 hover:bg-neutral-600'
|
||||
}`}
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateProps({ theme: 'light' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
element.props.theme === 'light'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Light
|
||||
<div
|
||||
className="w-4 h-4 rounded border border-white/20 shrink-0"
|
||||
style={{ backgroundColor: theme.bg }}
|
||||
/>
|
||||
<span className="truncate">{theme.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,6 +216,84 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
|
||||
max={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Highlights */}
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">
|
||||
Line Highlights <span className="text-neutral-500">({totalLines} lines)</span>
|
||||
</label>
|
||||
|
||||
{/* Existing highlights */}
|
||||
{element.props.highlights.length > 0 && (
|
||||
<div className="space-y-1 mb-3">
|
||||
{element.props.highlights.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between bg-neutral-700 px-2 py-1.5 rounded text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
h.style === 'focus' ? 'bg-yellow-400' :
|
||||
h.style === 'added' ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-white">
|
||||
{h.from === h.to ? `Line ${h.from}` : `Lines ${h.from}-${h.to}`}
|
||||
</span>
|
||||
<span className="text-neutral-400 text-xs">({h.style})</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeHighlight(i)}
|
||||
className="text-neutral-400 hover:text-red-400 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new highlight */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="From"
|
||||
value={highlightFrom}
|
||||
onChange={(e) => setHighlightFrom(e.target.value)}
|
||||
className="w-20 bg-neutral-700 text-white px-2 py-1.5 rounded text-sm"
|
||||
min={1}
|
||||
max={totalLines}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="To"
|
||||
value={highlightTo}
|
||||
onChange={(e) => setHighlightTo(e.target.value)}
|
||||
className="w-20 bg-neutral-700 text-white px-2 py-1.5 rounded text-sm"
|
||||
min={1}
|
||||
max={totalLines}
|
||||
/>
|
||||
<select
|
||||
value={highlightStyle}
|
||||
onChange={(e) => setHighlightStyle(e.target.value as 'focus' | 'added' | 'removed')}
|
||||
className="flex-1 bg-neutral-700 text-white px-2 py-1.5 rounded text-sm"
|
||||
>
|
||||
<option value="focus">Focus</option>
|
||||
<option value="added">Added</option>
|
||||
<option value="removed">Removed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={addHighlight}
|
||||
disabled={!highlightFrom}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-neutral-600 disabled:opacity-50 text-white py-1.5 rounded text-sm transition-colors"
|
||||
>
|
||||
Add Highlight
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const {
|
||||
selectedElementId,
|
||||
deleteElement,
|
||||
duplicateElement,
|
||||
undo,
|
||||
redo,
|
||||
setZoom,
|
||||
zoom,
|
||||
showGrid,
|
||||
setShowGrid,
|
||||
setTool,
|
||||
} = useCanvasStore();
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.contentEditable === 'true'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMeta = e.metaKey || e.ctrlKey;
|
||||
|
||||
// Delete selected element (Backspace or Delete)
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') && selectedElementId) {
|
||||
e.preventDefault();
|
||||
deleteElement(selectedElementId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo: Cmd+Z
|
||||
if (isMeta && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Redo: Shift+Cmd+Z or Cmd+Y
|
||||
if ((isMeta && e.key === 'z' && e.shiftKey) || (isMeta && e.key === 'y')) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Duplicate: Cmd+D
|
||||
if (isMeta && e.key === 'd' && selectedElementId) {
|
||||
e.preventDefault();
|
||||
duplicateElement(selectedElementId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Zoom In: Cmd+Plus or Cmd+=
|
||||
if (isMeta && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault();
|
||||
setZoom(Math.min(4, zoom + 0.1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Zoom Out: Cmd+Minus
|
||||
if (isMeta && e.key === '-') {
|
||||
e.preventDefault();
|
||||
setZoom(Math.max(0.25, zoom - 0.1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset Zoom: Cmd+0
|
||||
if (isMeta && e.key === '0') {
|
||||
e.preventDefault();
|
||||
setZoom(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle Grid: Cmd+' (or Cmd+G as alternative)
|
||||
if (isMeta && (e.key === "'" || e.key === 'g') && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setShowGrid(!showGrid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tool shortcuts (without modifier)
|
||||
if (!isMeta && !e.shiftKey && !e.altKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'v':
|
||||
case 'escape':
|
||||
setTool('select');
|
||||
break;
|
||||
case 'c':
|
||||
setTool('code');
|
||||
break;
|
||||
case 't':
|
||||
setTool('text');
|
||||
break;
|
||||
case 'a':
|
||||
setTool('arrow');
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedElementId,
|
||||
deleteElement,
|
||||
duplicateElement,
|
||||
undo,
|
||||
redo,
|
||||
setZoom,
|
||||
zoom,
|
||||
showGrid,
|
||||
setShowGrid,
|
||||
setTool,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export const createCodeElement = (x: number, y: number): CodeElement => ({
|
||||
props: {
|
||||
code: '// Your code here\nconsole.log("Hello, World!");',
|
||||
language: 'javascript',
|
||||
theme: 'dark',
|
||||
theme: 'github-dark',
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
|
||||
+25
-2
@@ -31,7 +31,7 @@ export interface LineHighlight {
|
||||
export interface CodeBlockProps {
|
||||
code: string;
|
||||
language: string;
|
||||
theme: 'dark' | 'light';
|
||||
theme: CodeThemeId;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
@@ -124,7 +124,30 @@ export const ASPECT_RATIOS: AspectRatio[] = [
|
||||
{ name: 'Story', width: 1080, height: 1920 },
|
||||
];
|
||||
|
||||
export const CODE_THEMES = ['dark', 'light'] as const;
|
||||
export const CODE_THEMES = [
|
||||
{ id: 'github-dark', name: 'GitHub Dark', bg: '#0d1117' },
|
||||
{ id: 'github-light', name: 'GitHub Light', bg: '#ffffff' },
|
||||
{ id: 'dracula', name: 'Dracula', bg: '#282a36' },
|
||||
{ id: 'nord', name: 'Nord', bg: '#2e3440' },
|
||||
{ id: 'one-dark-pro', name: 'One Dark Pro', bg: '#282c34' },
|
||||
{ id: 'monokai', name: 'Monokai', bg: '#272822' },
|
||||
{ id: 'tokyo-night', name: 'Tokyo Night', bg: '#1a1b26' },
|
||||
{ id: 'vitesse-dark', name: 'Vitesse Dark', bg: '#121212' },
|
||||
{ id: 'vitesse-light', name: 'Vitesse Light', bg: '#ffffff' },
|
||||
{ id: 'material-theme-darker', name: 'Material Darker', bg: '#212121' },
|
||||
{ id: 'catppuccin-mocha', name: 'Catppuccin Mocha', bg: '#1e1e2e' },
|
||||
{ id: 'catppuccin-latte', name: 'Catppuccin Latte', bg: '#eff1f5' },
|
||||
{ id: 'slack-dark', name: 'Slack Dark', bg: '#222222' },
|
||||
{ id: 'poimandres', name: 'Poimandres', bg: '#1b1e28' },
|
||||
{ id: 'night-owl', name: 'Night Owl', bg: '#011627' },
|
||||
{ id: 'min-dark', name: 'Min Dark', bg: '#1f1f1f' },
|
||||
{ id: 'min-light', name: 'Min Light', bg: '#ffffff' },
|
||||
{ id: 'ayu-dark', name: 'Ayu Dark', bg: '#0b0e14' },
|
||||
{ id: 'solarized-dark', name: 'Solarized Dark', bg: '#002b36' },
|
||||
{ id: 'solarized-light', name: 'Solarized Light', bg: '#fdf6e3' },
|
||||
] as const;
|
||||
|
||||
export type CodeThemeId = typeof CODE_THEMES[number]['id'];
|
||||
|
||||
export const LANGUAGES = [
|
||||
'javascript',
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { codeToHtml } from 'shiki';
|
||||
import { codeToHtml, codeToTokens, type BundledTheme, type BundledLanguage } from 'shiki';
|
||||
import { CODE_THEMES, type CodeThemeId } from '../types';
|
||||
|
||||
let highlighterReady = false;
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: string,
|
||||
theme: 'dark' | 'light'
|
||||
theme: CodeThemeId
|
||||
): Promise<string> {
|
||||
try {
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: theme === 'dark' ? 'github-dark' : 'github-light',
|
||||
lang: language as BundledLanguage,
|
||||
theme: theme as BundledTheme,
|
||||
});
|
||||
highlighterReady = true;
|
||||
return html;
|
||||
@@ -20,6 +21,51 @@ export async function highlightCode(
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
content: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface LineTokens {
|
||||
tokens: TokenInfo[];
|
||||
}
|
||||
|
||||
export async function tokenizeCode(
|
||||
code: string,
|
||||
language: string,
|
||||
theme: CodeThemeId
|
||||
): Promise<LineTokens[]> {
|
||||
try {
|
||||
const result = await codeToTokens(code, {
|
||||
lang: language as BundledLanguage,
|
||||
theme: theme as BundledTheme,
|
||||
});
|
||||
highlighterReady = true;
|
||||
return result.tokens.map(line => ({
|
||||
tokens: line.map(token => ({
|
||||
content: token.content,
|
||||
color: token.color || '#ffffff',
|
||||
})),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Tokenization error:', error);
|
||||
// Fallback: return plain text tokens
|
||||
return code.split('\n').map(line => ({
|
||||
tokens: [{ content: line, color: '#ffffff' }],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function getThemeBackground(themeId: CodeThemeId): string {
|
||||
const theme = CODE_THEMES.find(t => t.id === themeId);
|
||||
return theme?.bg || '#1e1e2e';
|
||||
}
|
||||
|
||||
export function isLightTheme(themeId: CodeThemeId): boolean {
|
||||
const lightThemes = ['github-light', 'vitesse-light', 'catppuccin-latte', 'min-light', 'solarized-light'];
|
||||
return lightThemes.includes(themeId);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
|
||||
Reference in New Issue
Block a user