From 05b80d1e0ceabb593a6bb26f2b7938469d10ecc4 Mon Sep 17 00:00:00 2001 From: yveskalume Date: Wed, 7 Jan 2026 16:50:20 +0200 Subject: [PATCH] feat: add syntax highlighting and line highlighting features with customizable themes --- package-lock.json | 10 ++ package.json | 1 + src/components/elements/CodeBlock.tsx | 98 ++++++++++---- src/components/inspector/CodeInspector.tsx | 147 +++++++++++++++++---- src/hooks/useKeyboardShortcuts.ts | 125 ++++++++++++++++++ src/store/canvasStore.ts | 2 +- src/types/index.ts | 27 +++- src/utils/highlighter.ts | 54 +++++++- 8 files changed, 410 insertions(+), 54 deletions(-) create mode 100644 src/hooks/useKeyboardShortcuts.ts diff --git a/package-lock.json b/package-lock.json index 908eb27..da2b870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6cca541..066a896 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/elements/CodeBlock.tsx b/src/components/elements/CodeBlock.tsx index 1f2c876..7baa55f 100644 --- a/src/components/elements/CodeBlock.tsx +++ b/src/components/elements/CodeBlock.tsx @@ -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 = ({ element, isSelected, onSelect, on const groupRef = useRef(null); const trRef = useRef(null); const { x, y, width, height, rotation, props } = element; + const [tokenizedLines, setTokenizedLines] = useState([]); useEffect(() => { if (isSelected && trRef.current && groupRef.current) { @@ -22,16 +24,42 @@ const CodeBlock: React.FC = ({ 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 = ({ 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( = ({ element, isSelected, onSelect, on fill={bgColor} /> ); + + // Left border for highlight + elements.push( + + ); } // Line number @@ -68,25 +112,33 @@ const CodeBlock: React.FC = ({ 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( - - ); + // Code text with syntax highlighting + let xOffset = props.padding + lineNumberWidth + (props.lineNumbers ? 5 : 0); + + lineTokens.tokens.forEach((token, tokenIndex) => { + if (token.content) { + elements.push( + + ); + // Approximate character width for monospace font + xOffset += token.content.length * (props.fontSize * 0.6); + } + }); }); return elements; @@ -118,7 +170,7 @@ const CodeBlock: React.FC = ({ element, isSelected, onSelect, on }); }; - const bgColor = props.theme === 'dark' ? '#1e1e2e' : '#f8f8f8'; + const bgColor = getThemeBackground(props.theme); return ( <> diff --git a/src/components/inspector/CodeInspector.tsx b/src/components/inspector/CodeInspector.tsx index dff9148..e8ecbc6 100644 --- a/src/components/inspector/CodeInspector.tsx +++ b/src/components/inspector/CodeInspector.tsx @@ -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 = ({ 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) => { updateElement(element.id, updates); @@ -28,6 +31,27 @@ const CodeInspector: React.FC = ({ 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 (
{/* Code editor */} @@ -69,27 +93,24 @@ const CodeInspector: React.FC = ({ element }) => { {/* Theme */}
-
- - +
+ {CODE_THEMES.map((theme) => ( + + ))}
@@ -195,6 +216,84 @@ const CodeInspector: React.FC = ({ element }) => { max={64} />
+ + {/* Line Highlights */} +
+ + + {/* Existing highlights */} + {element.props.highlights.length > 0 && ( +
+ {element.props.highlights.map((h, i) => ( +
+
+ + + {h.from === h.to ? `Line ${h.from}` : `Lines ${h.from}-${h.to}`} + + ({h.style}) +
+ +
+ ))} +
+ )} + + {/* Add new highlight */} +
+
+ setHighlightFrom(e.target.value)} + className="w-20 bg-neutral-700 text-white px-2 py-1.5 rounded text-sm" + min={1} + max={totalLines} + /> + setHighlightTo(e.target.value)} + className="w-20 bg-neutral-700 text-white px-2 py-1.5 rounded text-sm" + min={1} + max={totalLines} + /> + +
+ +
+
); }; diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..c7bd613 --- /dev/null +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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]); +} diff --git a/src/store/canvasStore.ts b/src/store/canvasStore.ts index b3fa1fc..fde5d58 100644 --- a/src/store/canvasStore.ts +++ b/src/store/canvasStore.ts @@ -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, diff --git a/src/types/index.ts b/src/types/index.ts index fd463b7..63ef467 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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', diff --git a/src/utils/highlighter.ts b/src/utils/highlighter.ts index 5f9999b..6f8a7f1 100644 --- a/src/utils/highlighter.ts +++ b/src/utils/highlighter.ts @@ -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 { 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 { + 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, '&')