feat: add syntax highlighting and line highlighting features with customizable themes

This commit is contained in:
2026-01-07 16:50:20 +02:00
parent 866929c358
commit 05b80d1e0c
8 changed files with 410 additions and 54 deletions
+10
View File
@@ -12,6 +12,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"immer": "^11.1.3", "immer": "^11.1.3",
"konva": "^10.0.12", "konva": "^10.0.12",
"lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-konva": "^19.2.1", "react-konva": "^19.2.1",
@@ -2893,6 +2894,15 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -14,6 +14,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"immer": "^11.1.3", "immer": "^11.1.3",
"konva": "^10.0.12", "konva": "^10.0.12",
"lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-konva": "^19.2.1", "react-konva": "^19.2.1",
+75 -23
View File
@@ -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 { Group, Rect, Text, Transformer } from 'react-konva';
import type Konva from 'konva'; import type Konva from 'konva';
import type { CodeElement } from '../../types'; import type { CodeElement } from '../../types';
import { tokenizeCode, getThemeBackground, isLightTheme, type LineTokens } from '../../utils/highlighter';
interface CodeBlockProps { interface CodeBlockProps {
element: CodeElement; element: CodeElement;
@@ -14,6 +15,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
const groupRef = useRef<Konva.Group>(null); const groupRef = useRef<Konva.Group>(null);
const trRef = useRef<Konva.Transformer>(null); const trRef = useRef<Konva.Transformer>(null);
const { x, y, width, height, rotation, props } = element; const { x, y, width, height, rotation, props } = element;
const [tokenizedLines, setTokenizedLines] = useState<LineTokens[]>([]);
useEffect(() => { useEffect(() => {
if (isSelected && trRef.current && groupRef.current) { if (isSelected && trRef.current && groupRef.current) {
@@ -22,16 +24,42 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
} }
}, [isSelected]); }, [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 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 lineHeight = props.fontSize * props.lineHeight;
const startY = props.padding; 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[] = []; const elements: React.ReactNode[] = [];
lines.forEach((line, index) => { lines.forEach((lineTokens, index) => {
const yPos = startY + index * lineHeight; const yPos = startY + index * lineHeight;
const lineNum = index + 1; const lineNum = index + 1;
@@ -42,10 +70,11 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
// Line highlight background // Line highlight background
if (highlight) { if (highlight) {
let bgColor = 'rgba(255, 255, 0, 0.15)'; // focus let bgColor = 'rgba(255, 255, 0, 0.2)'; // focus - yellow
if (highlight.style === 'added') bgColor = 'rgba(0, 255, 0, 0.15)'; if (highlight.style === 'added') bgColor = 'rgba(46, 160, 67, 0.25)';
if (highlight.style === 'removed') bgColor = 'rgba(255, 0, 0, 0.15)'; if (highlight.style === 'removed') bgColor = 'rgba(248, 81, 73, 0.25)';
// Add a subtle left border indicator
elements.push( elements.push(
<Rect <Rect
key={`highlight-${index}`} key={`highlight-${index}`}
@@ -56,6 +85,21 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
fill={bgColor} 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 // Line number
@@ -68,25 +112,33 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
text={String(lineNum)} text={String(lineNum)}
fontSize={props.fontSize} fontSize={props.fontSize}
fontFamily={props.fontFamily} fontFamily={props.fontFamily}
fill={props.theme === 'dark' ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'} fill={lineNumColor}
width={30} width={35}
align="right" align="right"
/> />
); );
} }
// Code text // Code text with syntax highlighting
elements.push( let xOffset = props.padding + lineNumberWidth + (props.lineNumbers ? 5 : 0);
<Text
key={`code-${index}`} lineTokens.tokens.forEach((token, tokenIndex) => {
x={props.padding + lineNumberWidth + 10} if (token.content) {
y={yPos} elements.push(
text={line || ' '} <Text
fontSize={props.fontSize} key={`code-${index}-${tokenIndex}`}
fontFamily={props.fontFamily} x={xOffset}
fill={props.theme === 'dark' ? '#e5e5e5' : '#1f1f1f'} 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; 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 ( return (
<> <>
+123 -24
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React, { useState } from 'react';
import { useCanvasStore } from '../../store/canvasStore'; import { useCanvasStore } from '../../store/canvasStore';
import type { CodeElement } from '../../types'; import type { CodeElement, LineHighlight } from '../../types';
import { LANGUAGES, FONT_FAMILIES } from '../../types'; import { LANGUAGES, FONT_FAMILIES, CODE_THEMES } from '../../types';
import { detectLanguage } from '../../utils/highlighter'; import { detectLanguage } from '../../utils/highlighter';
interface CodeInspectorProps { interface CodeInspectorProps {
@@ -10,6 +10,9 @@ interface CodeInspectorProps {
const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => { const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
const { updateElement, saveToHistory } = useCanvasStore(); 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>) => { const update = (updates: Partial<CodeElement>) => {
updateElement(element.id, updates); updateElement(element.id, updates);
@@ -28,6 +31,27 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
updateProps({ language: detected }); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Code editor */} {/* Code editor */}
@@ -69,27 +93,24 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
{/* Theme */} {/* Theme */}
<div> <div>
<label className="block text-sm text-neutral-400 mb-2">Theme</label> <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">
<button {CODE_THEMES.map((theme) => (
onClick={() => updateProps({ theme: 'dark' })} <button
className={`flex-1 py-2 rounded text-sm ${ key={theme.id}
element.props.theme === 'dark' onClick={() => updateProps({ theme: theme.id })}
? 'bg-blue-600 text-white' className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-all ${
: 'bg-neutral-700 text-neutral-300' element.props.theme === theme.id
}`} ? 'bg-blue-600 text-white'
> : 'bg-neutral-700 text-neutral-300 hover:bg-neutral-600'
Dark }`}
</button> >
<button <div
onClick={() => updateProps({ theme: 'light' })} className="w-4 h-4 rounded border border-white/20 shrink-0"
className={`flex-1 py-2 rounded text-sm ${ style={{ backgroundColor: theme.bg }}
element.props.theme === 'light' />
? 'bg-blue-600 text-white' <span className="truncate">{theme.name}</span>
: 'bg-neutral-700 text-neutral-300' </button>
}`} ))}
>
Light
</button>
</div> </div>
</div> </div>
@@ -195,6 +216,84 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
max={64} max={64}
/> />
</div> </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> </div>
); );
}; };
+125
View File
@@ -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]);
}
+1 -1
View File
@@ -237,7 +237,7 @@ export const createCodeElement = (x: number, y: number): CodeElement => ({
props: { props: {
code: '// Your code here\nconsole.log("Hello, World!");', code: '// Your code here\nconsole.log("Hello, World!");',
language: 'javascript', language: 'javascript',
theme: 'dark', theme: 'github-dark',
fontFamily: 'JetBrains Mono', fontFamily: 'JetBrains Mono',
fontSize: 14, fontSize: 14,
lineHeight: 1.5, lineHeight: 1.5,
+25 -2
View File
@@ -31,7 +31,7 @@ export interface LineHighlight {
export interface CodeBlockProps { export interface CodeBlockProps {
code: string; code: string;
language: string; language: string;
theme: 'dark' | 'light'; theme: CodeThemeId;
fontFamily: string; fontFamily: string;
fontSize: number; fontSize: number;
lineHeight: number; lineHeight: number;
@@ -124,7 +124,30 @@ export const ASPECT_RATIOS: AspectRatio[] = [
{ name: 'Story', width: 1080, height: 1920 }, { 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 = [ export const LANGUAGES = [
'javascript', 'javascript',
+50 -4
View File
@@ -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; let highlighterReady = false;
export async function highlightCode( export async function highlightCode(
code: string, code: string,
language: string, language: string,
theme: 'dark' | 'light' theme: CodeThemeId
): Promise<string> { ): Promise<string> {
try { try {
const html = await codeToHtml(code, { const html = await codeToHtml(code, {
lang: language, lang: language as BundledLanguage,
theme: theme === 'dark' ? 'github-dark' : 'github-light', theme: theme as BundledTheme,
}); });
highlighterReady = true; highlighterReady = true;
return html; 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 { function escapeHtml(text: string): string {
return text return text
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')