feat: add react-konva-utils for enhanced rendering and implement inline editing for CodeBlock and TextBlock components

This commit is contained in:
2026-01-07 18:39:39 +02:00
parent d642b7b5da
commit 07a48e67f4
8 changed files with 298 additions and 64 deletions
+26
View File
@@ -16,6 +16,7 @@
"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",
"react-konva-utils": "^2.0.0",
"shiki": "^3.21.0", "shiki": "^3.21.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -3302,6 +3303,21 @@
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
} }
}, },
"node_modules/react-konva-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-konva-utils/-/react-konva-utils-2.0.0.tgz",
"integrity": "sha512-pOb+TF13gFAjfPmUqsE42J4GJ+xhUS97qS32p0NRTqSeqtamWyKJikGa1XeVvV5yItu9SWDo7onL79GGPG96HQ==",
"license": "MIT",
"dependencies": {
"use-image": "^1.1.1"
},
"peerDependencies": {
"konva": "^8.3.5 || ^9.0.0 || ^10.0.0",
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0",
"react-konva": "^18.2.14 || ^19.0.10"
}
},
"node_modules/react-reconciler": { "node_modules/react-reconciler": {
"version": "0.33.0", "version": "0.33.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
@@ -3745,6 +3761,16 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-image": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.4.tgz",
"integrity": "sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "13.0.0", "version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"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",
"react-konva-utils": "^2.0.0",
"shiki": "^3.21.0", "shiki": "^3.21.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"uuid": "^13.0.0", "uuid": "^13.0.0",
+50 -15
View File
@@ -89,6 +89,25 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
const start = points[0]; const start = points[0];
const end = points[points.length - 1]; const end = points[points.length - 1];
const hasValidEndpoints =
!!start &&
!!end &&
Number.isFinite(start.x) &&
Number.isFinite(start.y) &&
Number.isFinite(end.x) &&
Number.isFinite(end.y);
const dx = hasValidEndpoints ? end.x - start.x : 0;
const dy = hasValidEndpoints ? end.y - start.y : 0;
const isDegenerate = !hasValidEndpoints || Math.hypot(dx, dy) < 0.5;
// Konva can throw when drawing shadows for 0x0 bounds (e.g. when start/end overlap).
// Use a tiny, non-zero end point for rendering only.
const renderStart = hasValidEndpoints ? start : { x: 0, y: 0 };
const renderEnd = hasValidEndpoints
? (isDegenerate ? { x: start.x + 1, y: start.y + 1 } : end)
: { x: 1, y: 1 };
// Get control points (either from props or auto-generate for curved) // Get control points (either from props or auto-generate for curved)
const controlPoints = useMemo(() => { const controlPoints = useMemo(() => {
if (props.style === 'curved') { if (props.style === 'curved') {
@@ -96,25 +115,25 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
return props.controlPoints; return props.controlPoints;
} }
// Auto-generate a control point // Auto-generate a control point
const midX = (start.x + end.x) / 2; const midX = (renderStart.x + renderEnd.x) / 2;
const midY = (start.y + end.y) / 2; const midY = (renderStart.y + renderEnd.y) / 2;
const dx = end.x - start.x; const dx = renderEnd.x - renderStart.x;
const dy = end.y - start.y; const dy = renderEnd.y - renderStart.y;
return [{ return [{
x: midX - dy * 0.3, x: midX - dy * 0.3,
y: midY + dx * 0.3, y: midY + dx * 0.3,
}]; }];
} }
return []; return [];
}, [props.style, props.controlPoints, start, end]); }, [props.style, props.controlPoints, renderStart.x, renderStart.y, renderEnd.x, renderEnd.y]);
// Calculate points for rendering // Calculate points for rendering
const flatPoints = useMemo(() => { const flatPoints = useMemo(() => {
if (props.style === 'curved') { if (props.style === 'curved') {
return getBezierPoints(start, end, controlPoints); return getBezierPoints(renderStart, renderEnd, controlPoints);
} }
return points.flatMap(p => [p.x, p.y]); return points.flatMap(p => [p.x, p.y]);
}, [props.style, points, start, end, controlPoints]); }, [props.style, points, renderStart, renderEnd, controlPoints]);
const handlePointDrag = (index: number, e: Konva.KonvaEventObject<DragEvent>) => { const handlePointDrag = (index: number, e: Konva.KonvaEventObject<DragEvent>) => {
const newPoints = [...points]; const newPoints = [...points];
@@ -141,13 +160,27 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
// Calculate label position // Calculate label position
const labelPosition = props.labelPosition ?? 0.5; const labelPosition = props.labelPosition ?? 0.5;
const labelPoint = props.style === 'curved' const labelPoint = props.style === 'curved'
? getPointOnBezier(start, end, controlPoints, labelPosition) ? getPointOnBezier(renderStart, renderEnd, controlPoints, labelPosition)
: { x: start.x + (end.x - start.x) * labelPosition, y: start.y + (end.y - start.y) * labelPosition }; : { x: renderStart.x + (renderEnd.x - renderStart.x) * labelPosition, y: renderStart.y + (renderEnd.y - renderStart.y) * labelPosition };
return ( return (
<Group> <Group>
{/* If endpoints overlap/invalid, render a small dot instead of a shadowed arrow to avoid Konva draw crashes. */}
{isDegenerate && (
<Circle
x={renderStart.x}
y={renderStart.y}
radius={Math.max(6, props.thickness * 2)}
fill={props.color}
opacity={0.8}
onClick={onSelect}
onTap={onSelect}
hitStrokeWidth={20}
/>
)}
{/* For curved arrows, use Line with many points */} {/* For curved arrows, use Line with many points */}
{props.style === 'curved' ? ( {!isDegenerate && props.style === 'curved' ? (
<Line <Line
points={flatPoints} points={flatPoints}
stroke={props.color} stroke={props.color}
@@ -163,6 +196,7 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
hitStrokeWidth={20} hitStrokeWidth={20}
/> />
) : ( ) : (
!isDegenerate && (
<KonvaArrow <KonvaArrow
ref={arrowRef} ref={arrowRef}
points={flatPoints} points={flatPoints}
@@ -181,16 +215,17 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
onTap={onSelect} onTap={onSelect}
hitStrokeWidth={20} hitStrokeWidth={20}
/> />
)
)} )}
{/* Arrow head for curved arrows (drawn separately) */} {/* Arrow head for curved arrows (drawn separately) */}
{props.style === 'curved' && props.head !== 'none' && ( {!isDegenerate && props.style === 'curved' && props.head !== 'none' && (
<KonvaArrow <KonvaArrow
points={[ points={[
flatPoints[flatPoints.length - 4] || start.x, flatPoints[flatPoints.length - 4] ?? renderStart.x,
flatPoints[flatPoints.length - 3] || start.y, flatPoints[flatPoints.length - 3] ?? renderStart.y,
end.x, renderEnd.x,
end.y, renderEnd.y,
]} ]}
stroke={props.color} stroke={props.color}
strokeWidth={props.thickness} strokeWidth={props.thickness}
+26 -28
View File
@@ -1,6 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Group, Rect, Text, Transformer } from 'react-konva'; import { Group, Rect, Text, Transformer } from 'react-konva';
import { Html } from 'react-konva-utils';
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'; import { tokenizeCode, getThemeBackground, isLightTheme, type LineTokens } from '../../utils/highlighter';
@@ -91,21 +91,17 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
const stage = group.getStage(); const stage = group.getStage();
if (!stage) return; if (!stage) return;
const stageBox = stage.container().getBoundingClientRect();
const scale = stage.scaleX(); const scale = stage.scaleX();
// Calculate absolute position accounting for stage position and scale // Calculate absolute position accounting for stage position and scale
const textColor = isLightTheme(props.theme) ? '#1f1f1f' : '#e5e5e5'; const textColor = isLightTheme(props.theme) ? '#1f1f1f' : '#e5e5e5';
const lineNumberWidth = props.lineNumbers ? 55 : 0; const lineNumberWidth = props.lineNumbers ? 55 : 0;
// Get group's position relative to stage
const absolutePos = group.getAbsolutePosition();
// Set textarea style first // Set textarea style - position relative to the Html container
setTextareaStyle({ setTextareaStyle({
position: 'fixed', position: 'absolute',
left: stageBox.left + absolutePos.x + (props.padding + lineNumberWidth) * scale, left: (props.padding + lineNumberWidth) * scale,
top: stageBox.top + absolutePos.y + props.padding * scale, top: props.padding * scale,
width: (width - props.padding * 2 - lineNumberWidth) * scale, width: (width - props.padding * 2 - lineNumberWidth) * scale,
height: (height - props.padding * 2) * scale, height: (height - props.padding * 2) * scale,
fontSize: props.fontSize * scale, fontSize: props.fontSize * scale,
@@ -122,7 +118,6 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
whiteSpace: 'pre', whiteSpace: 'pre',
zIndex: 1000, zIndex: 1000,
transformOrigin: 'top left', transformOrigin: 'top left',
transform: `rotate(${rotation}deg)`,
tabSize: 2, tabSize: 2,
caretColor: textColor, caretColor: textColor,
}); });
@@ -131,7 +126,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
setIsEditing(true); setIsEditing(true);
setEditValue(props.code); setEditValue(props.code);
} }
}, [element.locked, props, width, height, rotation]); }, [element.locked, props, width, height]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle Tab for indentation // Handle Tab for indentation
@@ -390,6 +385,26 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
> >
{!isEditing && renderCode()} {!isEditing && renderCode()}
</Group> </Group>
{/* Inline code editor */}
{isEditing && (
<Html
divProps={{ style: { pointerEvents: 'auto' } }}
>
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
style={textareaStyle}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
/>
</Html>
)}
</Group> </Group>
{isSelected && !isEditing && ( {isSelected && !isEditing && (
@@ -404,23 +419,6 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
}} }}
/> />
)} )}
{/* Inline code editor */}
{isEditing && createPortal(
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
style={textareaStyle}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
/>,
document.body
)}
</> </>
); );
}; };
+112 -3
View File
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Group, Rect, Text, Transformer } from 'react-konva'; import { Group, Rect, Text, Transformer } from 'react-konva';
import { Html } from 'react-konva-utils';
import type Konva from 'konva'; import type Konva from 'konva';
import type { TextElement } from '../../types'; import type { TextElement } from '../../types';
@@ -14,7 +15,11 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
const groupRef = useRef<Konva.Group>(null); const groupRef = useRef<Konva.Group>(null);
const textRef = useRef<Konva.Text>(null); const textRef = useRef<Konva.Text>(null);
const trRef = useRef<Konva.Transformer>(null); const trRef = useRef<Konva.Transformer>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [textDimensions, setTextDimensions] = useState({ width: 200, height: 30 }); const [textDimensions, setTextDimensions] = useState({ width: 200, height: 30 });
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(element.props.text);
const [textareaStyle, setTextareaStyle] = useState<React.CSSProperties>({ display: 'none' });
const { x, y, rotation, props } = element; const { x, y, rotation, props } = element;
useEffect(() => { useEffect(() => {
@@ -33,6 +38,19 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
} }
}, [props.text, props.fontSize, props.fontFamily, props.bold, props.italic]); }, [props.text, props.fontSize, props.fontFamily, props.bold, props.italic]);
// Sync editValue when text changes externally
useEffect(() => {
setEditValue(props.text);
}, [props.text]);
// Focus textarea when editing starts
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, [isEditing]);
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => { const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
onChange({ onChange({
x: e.target.x(), x: e.target.x(),
@@ -54,6 +72,74 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
}); });
}; };
const handleSaveEdit = useCallback(() => {
setIsEditing(false);
setTextareaStyle({ display: 'none' });
onChange({
props: { ...props, text: editValue },
});
}, [editValue, onChange, props]);
// Close editing when deselecting
useEffect(() => {
if (!isSelected && isEditing) {
handleSaveEdit();
}
}, [isSelected, isEditing, handleSaveEdit]);
const handleDoubleClick = useCallback((e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
e.cancelBubble = true;
onSelect();
if (element.locked || !groupRef.current) return;
const group = groupRef.current;
const stage = group.getStage();
if (!stage) return;
const scale = stage.scaleX();
setTextareaStyle({
position: 'absolute',
left: 0,
top: 0,
width: Math.max(40, textDimensions.width) * scale,
height: Math.max(24, textDimensions.height) * scale,
fontSize: props.fontSize * scale,
fontFamily: props.fontFamily,
fontWeight: props.bold ? 'bold' : 'normal',
fontStyle: props.italic ? 'italic' : 'normal',
lineHeight: '1.2',
background: 'transparent',
color: props.color,
border: 'none',
outline: 'none',
padding: '0',
margin: '0',
resize: 'none',
overflow: 'hidden',
whiteSpace: 'pre',
textAlign: props.align,
zIndex: 1000,
transformOrigin: 'top left',
caretColor: props.color,
});
setIsEditing(true);
setEditValue(props.text);
}, [element.locked, onSelect, props, textDimensions.height, textDimensions.width]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
handleSaveEdit();
}
if ((e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
e.preventDefault();
handleSaveEdit();
}
}, [handleSaveEdit]);
const totalWidth = textDimensions.width + props.padding * 2; const totalWidth = textDimensions.width + props.padding * 2;
const totalHeight = textDimensions.height + props.padding * 2; const totalHeight = textDimensions.height + props.padding * 2;
@@ -69,9 +155,11 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
x={x} x={x}
y={y} y={y}
rotation={rotation} rotation={rotation}
draggable={!element.locked} draggable={!element.locked && !isEditing}
onClick={onSelect} onClick={onSelect}
onTap={onSelect} onTap={onSelect}
onDblClick={handleDoubleClick}
onDblTap={handleDoubleClick}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onTransformEnd={handleTransformEnd} onTransformEnd={handleTransformEnd}
> >
@@ -97,10 +185,31 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
fill={props.color} fill={props.color}
align={props.align} align={props.align}
textDecoration={props.underline ? 'underline' : ''} textDecoration={props.underline ? 'underline' : ''}
visible={!isEditing}
/> />
{/* Inline text editor */}
{isEditing && (
<Html
divProps={{ style: { pointerEvents: 'auto' } }}
>
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
style={textareaStyle}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
/>
</Html>
)}
</Group> </Group>
{isSelected && ( {isSelected && !isEditing && (
<Transformer <Transformer
ref={trRef} ref={trRef}
flipEnabled={false} flipEnabled={false}
@@ -53,6 +53,72 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
const totalLines = element.props.code.split('\n').length; const totalLines = element.props.code.split('\n').length;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle Tab for indentation
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
if (e.shiftKey) {
// Shift+Tab: Remove indentation
const beforeCursor = value.substring(0, start);
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const linePrefix = value.substring(lineStart, start);
if (linePrefix.startsWith(' ')) {
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 2);
handleCodeChange(newValue);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 2);
}, 0);
} else if (linePrefix.startsWith('\t')) {
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 1);
handleCodeChange(newValue);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 1);
}, 0);
}
} else {
// Tab: Add indentation (2 spaces)
const newValue = value.substring(0, start) + ' ' + value.substring(end);
handleCodeChange(newValue);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
}
// Handle Enter for auto-indentation
if (e.key === 'Enter') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
const beforeCursor = value.substring(0, start);
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const currentLine = value.substring(lineStart, start);
const indentMatch = currentLine.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1] : '';
const trimmedLine = currentLine.trim();
const needsExtraIndent = trimmedLine.endsWith('{') || trimmedLine.endsWith(':') || trimmedLine.endsWith('(');
const extraIndent = needsExtraIndent ? ' ' : '';
const newValue = value.substring(0, start) + '\n' + indent + extraIndent + value.substring(end);
handleCodeChange(newValue);
const newCursorPos = start + 1 + indent.length + extraIndent.length;
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
}, 0);
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Code editor */} {/* Code editor */}
@@ -61,9 +127,11 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
<textarea <textarea
value={element.props.code} value={element.props.code}
onChange={(e) => handleCodeChange(e.target.value)} onChange={(e) => handleCodeChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={saveToHistory} onBlur={saveToHistory}
className="w-full h-32 bg-neutral-900 text-white text-sm font-mono p-3 rounded resize-y" className="w-full h-32 bg-neutral-900 text-white text-sm font-mono p-3 rounded resize-y"
spellCheck={false} spellCheck={false}
style={{ tabSize: 2 }}
/> />
</div> </div>
+3 -14
View File
@@ -1,19 +1,8 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@font-face {
font-family: 'JetBrains Mono';
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPVmUsaaDhw.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Inter';
src: url('https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hjp-Ek-_EeA.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
+12 -4
View File
@@ -3,15 +3,22 @@ import { CODE_THEMES, type CodeThemeId } from '../types';
let highlighterReady = false; let highlighterReady = false;
function normalizeThemeId(theme: unknown): CodeThemeId {
if (typeof theme !== 'string') return 'dracula';
const found = CODE_THEMES.some((t) => t.id === theme);
return (found ? theme : 'dracula') as CodeThemeId;
}
export async function highlightCode( export async function highlightCode(
code: string, code: string,
language: string, language: string,
theme: CodeThemeId theme: CodeThemeId
): Promise<string> { ): Promise<string> {
try { try {
const safeTheme = normalizeThemeId(theme);
const html = await codeToHtml(code, { const html = await codeToHtml(code, {
lang: language as BundledLanguage, lang: language as BundledLanguage,
theme: theme as BundledTheme, theme: safeTheme as BundledTheme,
}); });
highlighterReady = true; highlighterReady = true;
return html; return html;
@@ -36,9 +43,10 @@ export async function tokenizeCode(
theme: CodeThemeId theme: CodeThemeId
): Promise<LineTokens[]> { ): Promise<LineTokens[]> {
try { try {
const safeTheme = normalizeThemeId(theme);
const result = await codeToTokens(code, { const result = await codeToTokens(code, {
lang: language as BundledLanguage, lang: language as BundledLanguage,
theme: theme as BundledTheme, theme: safeTheme as BundledTheme,
}); });
highlighterReady = true; highlighterReady = true;
return result.tokens.map(line => ({ return result.tokens.map(line => ({
@@ -56,12 +64,12 @@ export async function tokenizeCode(
} }
} }
export function getThemeBackground(themeId: CodeThemeId): string { export function getThemeBackground(themeId: string): string {
const theme = CODE_THEMES.find(t => t.id === themeId); const theme = CODE_THEMES.find(t => t.id === themeId);
return theme?.bg || '#1e1e2e'; return theme?.bg || '#1e1e2e';
} }
export function isLightTheme(themeId: CodeThemeId): boolean { export function isLightTheme(themeId: string): boolean {
const lightThemes = ['github-light', 'vitesse-light', 'min-light', 'solarized-light', 'light-plus']; const lightThemes = ['github-light', 'vitesse-light', 'min-light', 'solarized-light', 'light-plus'];
return lightThemes.includes(themeId); return lightThemes.includes(themeId);
} }