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
+50 -15
View File
@@ -89,6 +89,25 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
const start = points[0];
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)
const controlPoints = useMemo(() => {
if (props.style === 'curved') {
@@ -96,25 +115,25 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
return props.controlPoints;
}
// Auto-generate a control point
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
const dx = end.x - start.x;
const dy = end.y - start.y;
const midX = (renderStart.x + renderEnd.x) / 2;
const midY = (renderStart.y + renderEnd.y) / 2;
const dx = renderEnd.x - renderStart.x;
const dy = renderEnd.y - renderStart.y;
return [{
x: midX - dy * 0.3,
y: midY + dx * 0.3,
}];
}
return [];
}, [props.style, props.controlPoints, start, end]);
}, [props.style, props.controlPoints, renderStart.x, renderStart.y, renderEnd.x, renderEnd.y]);
// Calculate points for rendering
const flatPoints = useMemo(() => {
if (props.style === 'curved') {
return getBezierPoints(start, end, controlPoints);
return getBezierPoints(renderStart, renderEnd, controlPoints);
}
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 newPoints = [...points];
@@ -141,13 +160,27 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
// Calculate label position
const labelPosition = props.labelPosition ?? 0.5;
const labelPoint = props.style === 'curved'
? getPointOnBezier(start, end, controlPoints, labelPosition)
: { x: start.x + (end.x - start.x) * labelPosition, y: start.y + (end.y - start.y) * labelPosition };
? getPointOnBezier(renderStart, renderEnd, controlPoints, labelPosition)
: { x: renderStart.x + (renderEnd.x - renderStart.x) * labelPosition, y: renderStart.y + (renderEnd.y - renderStart.y) * labelPosition };
return (
<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 */}
{props.style === 'curved' ? (
{!isDegenerate && props.style === 'curved' ? (
<Line
points={flatPoints}
stroke={props.color}
@@ -163,6 +196,7 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
hitStrokeWidth={20}
/>
) : (
!isDegenerate && (
<KonvaArrow
ref={arrowRef}
points={flatPoints}
@@ -181,16 +215,17 @@ const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }
onTap={onSelect}
hitStrokeWidth={20}
/>
)
)}
{/* Arrow head for curved arrows (drawn separately) */}
{props.style === 'curved' && props.head !== 'none' && (
{!isDegenerate && props.style === 'curved' && props.head !== 'none' && (
<KonvaArrow
points={[
flatPoints[flatPoints.length - 4] || start.x,
flatPoints[flatPoints.length - 3] || start.y,
end.x,
end.y,
flatPoints[flatPoints.length - 4] ?? renderStart.x,
flatPoints[flatPoints.length - 3] ?? renderStart.y,
renderEnd.x,
renderEnd.y,
]}
stroke={props.color}
strokeWidth={props.thickness}
+26 -28
View File
@@ -1,6 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Group, Rect, Text, Transformer } from 'react-konva';
import { Html } from 'react-konva-utils';
import type Konva from 'konva';
import type { CodeElement } from '../../types';
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();
if (!stage) return;
const stageBox = stage.container().getBoundingClientRect();
const scale = stage.scaleX();
// Calculate absolute position accounting for stage position and scale
const textColor = isLightTheme(props.theme) ? '#1f1f1f' : '#e5e5e5';
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({
position: 'fixed',
left: stageBox.left + absolutePos.x + (props.padding + lineNumberWidth) * scale,
top: stageBox.top + absolutePos.y + props.padding * scale,
position: 'absolute',
left: (props.padding + lineNumberWidth) * scale,
top: props.padding * scale,
width: (width - props.padding * 2 - lineNumberWidth) * scale,
height: (height - props.padding * 2) * scale,
fontSize: props.fontSize * scale,
@@ -122,7 +118,6 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
whiteSpace: 'pre',
zIndex: 1000,
transformOrigin: 'top left',
transform: `rotate(${rotation}deg)`,
tabSize: 2,
caretColor: textColor,
});
@@ -131,7 +126,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
setIsEditing(true);
setEditValue(props.code);
}
}, [element.locked, props, width, height, rotation]);
}, [element.locked, props, width, height]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle Tab for indentation
@@ -390,6 +385,26 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
>
{!isEditing && renderCode()}
</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>
{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 { Html } from 'react-konva-utils';
import type Konva from 'konva';
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 textRef = useRef<Konva.Text>(null);
const trRef = useRef<Konva.Transformer>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
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;
useEffect(() => {
@@ -33,6 +38,19 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
}
}, [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>) => {
onChange({
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 totalHeight = textDimensions.height + props.padding * 2;
@@ -69,9 +155,11 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
x={x}
y={y}
rotation={rotation}
draggable={!element.locked}
draggable={!element.locked && !isEditing}
onClick={onSelect}
onTap={onSelect}
onDblClick={handleDoubleClick}
onDblTap={handleDoubleClick}
onDragEnd={handleDragEnd}
onTransformEnd={handleTransformEnd}
>
@@ -97,10 +185,31 @@ const TextBlock: React.FC<TextBlockProps> = ({ element, isSelected, onSelect, on
fill={props.color}
align={props.align}
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>
{isSelected && (
{isSelected && !isEditing && (
<Transformer
ref={trRef}
flipEnabled={false}
@@ -53,6 +53,72 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
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 (
<div className="space-y-4">
{/* Code editor */}
@@ -61,9 +127,11 @@ const CodeInspector: React.FC<CodeInspectorProps> = ({ element }) => {
<textarea
value={element.props.code}
onChange={(e) => handleCodeChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={saveToHistory}
className="w-full h-32 bg-neutral-900 text-white text-sm font-mono p-3 rounded resize-y"
spellCheck={false}
style={{ tabSize: 2 }}
/>
</div>