From 07a48e67f4b371ef77c9ba573e5872b5e41cfaae Mon Sep 17 00:00:00 2001 From: yveskalume Date: Wed, 7 Jan 2026 18:39:39 +0200 Subject: [PATCH] feat: add react-konva-utils for enhanced rendering and implement inline editing for CodeBlock and TextBlock components --- package-lock.json | 26 +++++ package.json | 1 + src/components/elements/Arrow.tsx | 65 +++++++++--- src/components/elements/CodeBlock.tsx | 54 +++++----- src/components/elements/TextBlock.tsx | 115 ++++++++++++++++++++- src/components/inspector/CodeInspector.tsx | 68 ++++++++++++ src/index.css | 17 +-- src/utils/highlighter.ts | 16 ++- 8 files changed, 298 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index da2b870..fcda6fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-konva": "^19.2.1", + "react-konva-utils": "^2.0.0", "shiki": "^3.21.0", "tailwindcss": "^4.1.18", "uuid": "^13.0.0", @@ -3302,6 +3303,21 @@ "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": { "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", @@ -3745,6 +3761,16 @@ "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": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", diff --git a/package.json b/package.json index 066a896..3cb3a8e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-konva": "^19.2.1", + "react-konva-utils": "^2.0.0", "shiki": "^3.21.0", "tailwindcss": "^4.1.18", "uuid": "^13.0.0", diff --git a/src/components/elements/Arrow.tsx b/src/components/elements/Arrow.tsx index 3c9871d..486798f 100644 --- a/src/components/elements/Arrow.tsx +++ b/src/components/elements/Arrow.tsx @@ -89,6 +89,25 @@ const Arrow: React.FC = ({ 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 = ({ 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) => { const newPoints = [...points]; @@ -141,13 +160,27 @@ const Arrow: React.FC = ({ 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 ( + {/* If endpoints overlap/invalid, render a small dot instead of a shadowed arrow to avoid Konva draw crashes. */} + {isDegenerate && ( + + )} + {/* For curved arrows, use Line with many points */} - {props.style === 'curved' ? ( + {!isDegenerate && props.style === 'curved' ? ( = ({ element, isSelected, onSelect, onChange } hitStrokeWidth={20} /> ) : ( + !isDegenerate && ( = ({ 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' && ( = ({ 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 = ({ 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 = ({ 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) => { // Handle Tab for indentation @@ -390,6 +385,26 @@ const CodeBlock: React.FC = ({ element, isSelected, onSelect, on > {!isEditing && renderCode()} + + {/* Inline code editor */} + {isEditing && ( + +