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",
|
"@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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: {
|
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
@@ -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',
|
||||||
|
|||||||
@@ -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, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
Reference in New Issue
Block a user