feat: implement inline code editing with customizable textarea and keyboard shortcuts

This commit is contained in:
2026-01-07 18:00:13 +02:00
parent 84b7a6a80b
commit 955d54d2f2
+203 -11
View File
@@ -1,4 +1,5 @@
import React, { useRef, useEffect, useState } 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 type Konva from 'konva'; import type Konva from 'konva';
import type { CodeElement } from '../../types'; import type { CodeElement } from '../../types';
@@ -14,8 +15,11 @@ interface CodeBlockProps {
const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, onChange }) => { const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, onChange }) => {
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 textareaRef = useRef<HTMLTextAreaElement>(null);
const { x, y, width, height, rotation, props } = element; const { x, y, width, height, rotation, props } = element;
const [tokenizedLines, setTokenizedLines] = useState<LineTokens[]>([]); const [tokenizedLines, setTokenizedLines] = useState<LineTokens[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(props.code);
useEffect(() => { useEffect(() => {
if (isSelected && trRef.current && groupRef.current) { if (isSelected && trRef.current && groupRef.current) {
@@ -45,6 +49,171 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
} }
}, [isSelected]); }, [isSelected]);
// Sync editValue when code changes externally
useEffect(() => {
setEditValue(props.code);
}, [props.code]);
// Focus textarea when editing starts
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
// Move cursor to end
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}, [isEditing]);
// State for textarea position (computed when editing starts)
const [textareaStyle, setTextareaStyle] = useState<React.CSSProperties>({ display: 'none' });
const handleSaveEdit = useCallback(() => {
setIsEditing(false);
setTextareaStyle({ display: 'none' });
onChange({
props: { ...props, code: editValue }
});
}, [editValue, onChange, props]);
// Close editing when deselecting
useEffect(() => {
if (!isSelected && isEditing) {
handleSaveEdit();
}
}, [isSelected, isEditing, handleSaveEdit]);
const handleDoubleClick = useCallback((e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
// Prevent event from bubbling to avoid triggering canvas click
e.cancelBubble = true;
if (!element.locked && groupRef.current) {
const group = groupRef.current;
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
setTextareaStyle({
position: 'fixed',
left: stageBox.left + absolutePos.x + (props.padding + lineNumberWidth) * scale,
top: stageBox.top + absolutePos.y + props.padding * scale,
width: (width - props.padding * 2 - lineNumberWidth) * scale,
height: (height - props.padding * 2) * scale,
fontSize: props.fontSize * scale,
fontFamily: props.fontFamily,
lineHeight: props.lineHeight,
background: 'transparent',
color: textColor,
border: 'none',
outline: 'none',
padding: '0',
margin: '0',
resize: 'none',
overflow: 'auto',
whiteSpace: 'pre',
zIndex: 1000,
transformOrigin: 'top left',
transform: `rotate(${rotation}deg)`,
tabSize: 2,
caretColor: textColor,
});
// Then enable editing
setIsEditing(true);
setEditValue(props.code);
}
}, [element.locked, props, width, height, rotation]);
const handleKeyDown = useCallback((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);
// Find the start of the current line
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const linePrefix = value.substring(lineStart, start);
// Check if line starts with spaces or tab
if (linePrefix.startsWith(' ')) {
// Remove 2 spaces
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 2);
setEditValue(newValue);
// Adjust cursor position
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = Math.max(lineStart, start - 2);
}, 0);
} else if (linePrefix.startsWith('\t')) {
// Remove tab
const newValue = value.substring(0, lineStart) + value.substring(lineStart + 1);
setEditValue(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);
setEditValue(newValue);
// Move cursor after the inserted spaces
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;
// Find the current line's indentation
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] : '';
// Check if the line ends with { or : (for additional indentation)
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);
setEditValue(newValue);
// Move cursor after the newline and indentation
const newCursorPos = start + 1 + indent.length + extraIndent.length;
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
}, 0);
}
// Handle Escape to cancel/save
if (e.key === 'Escape') {
handleSaveEdit();
}
}, [handleSaveEdit]);
// Render code with syntax highlighting using tokens // Render code with syntax highlighting using tokens
const renderCode = () => { const renderCode = () => {
const lines = tokenizedLines.length > 0 ? tokenizedLines : const lines = tokenizedLines.length > 0 ? tokenizedLines :
@@ -181,7 +350,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
width={width} width={width}
height={height} height={height}
rotation={rotation} rotation={rotation}
draggable={!element.locked} draggable={!element.locked && !isEditing}
onClick={onSelect} onClick={onSelect}
onTap={onSelect} onTap={onSelect}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -199,25 +368,31 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ element, isSelected, onSelect, on
shadowColor={props.shadow.color} shadowColor={props.shadow.color}
/> />
{/* Background */} {/* Background - handles double click for inline editing */}
<Rect <Rect
width={width} width={width}
height={height} height={height}
fill={bgColor} fill={bgColor}
cornerRadius={props.cornerRadius} cornerRadius={props.cornerRadius}
onDblClick={handleDoubleClick}
onDblTap={handleDoubleClick}
/> />
{/* Code content */} {/* Code content - also handles double click */}
<Group clipFunc={(ctx) => { <Group
ctx.beginPath(); clipFunc={(ctx) => {
ctx.roundRect(0, 0, width, height, props.cornerRadius); ctx.beginPath();
ctx.closePath(); ctx.roundRect(0, 0, width, height, props.cornerRadius);
}}> ctx.closePath();
{renderCode()} }}
onDblClick={handleDoubleClick}
onDblTap={handleDoubleClick}
>
{!isEditing && renderCode()}
</Group> </Group>
</Group> </Group>
{isSelected && ( {isSelected && !isEditing && (
<Transformer <Transformer
ref={trRef} ref={trRef}
flipEnabled={false} flipEnabled={false}
@@ -229,6 +404,23 @@ 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
)}
</> </>
); );
}; };