feat: add react-konva-utils for enhanced rendering and implement inline editing for CodeBlock and TextBlock components
This commit is contained in:
Generated
+26
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user