feat: add syntax highlighting and line highlighting features with customizable themes
This commit is contained in:
@@ -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
|
||||
elements.push(
|
||||
<Text
|
||||
key={`code-${index}`}
|
||||
x={props.padding + lineNumberWidth + 10}
|
||||
y={yPos}
|
||||
text={line || ' '}
|
||||
fontSize={props.fontSize}
|
||||
fontFamily={props.fontFamily}
|
||||
fill={props.theme === 'dark' ? '#e5e5e5' : '#1f1f1f'}
|
||||
/>
|
||||
);
|
||||
// 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}-${tokenIndex}`}
|
||||
x={xOffset}
|
||||
y={yPos}
|
||||
text={token.content}
|
||||
fontSize={props.fontSize}
|
||||
fontFamily={props.fontFamily}
|
||||
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">
|
||||
<button
|
||||
onClick={() => updateProps({ theme: 'dark' })}
|
||||
className={`flex-1 py-2 rounded text-sm ${
|
||||
element.props.theme === 'dark'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-neutral-700 text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
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
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto p-1">
|
||||
{CODE_THEMES.map((theme) => (
|
||||
<button
|
||||
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 hover:bg-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user