FlowWeb / src /features /canvas /components /nodes /TextNode.jsx
danylokhodus's picture
Fix text selection: stop pointerdown and touchstart events from bubbling to prevent canvas panning when selecting text in edit mode
06a320f
Raw
History Blame Contribute Delete
8.7 kB
import React, { useState, useEffect, useRef } from 'react';
import { Handle, Position, useUpdateNodeInternals, useReactFlow } from '@xyflow/react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../../../context/ThemeContext';
const TextNode = ({ id, data, selected, width }) => {
const { t } = useTranslation();
const { isDarkMode } = useTheme();
const updateNodeInternals = useUpdateNodeInternals();
const { getZoom, getNode, setNodes } = useReactFlow();
const [localText, setLocalText] = useState(data.label || '');
const [isResizing, setIsResizing] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const textareaRef = useRef(null);
// Auto-resize textarea height
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
const newHeight = textareaRef.current.scrollHeight;
textareaRef.current.style.height = newHeight + 'px';
// Notify React Flow that height (and handles) have shifted
updateNodeInternals(id);
}
}, [localText, width, data.width, id, updateNodeInternals]);
// Sync if data changes from remote
useEffect(() => {
if (document.activeElement !== textareaRef.current) {
setLocalText(data.label || '');
}
}, [data.label]);
// Sync node's draggable state with editing state
useEffect(() => {
setNodes((nds) =>
nds.map((n) => {
if (n.id === id) {
return {
...n,
draggable: isEditing ? false : !data.isPinned
};
}
return n;
})
);
}, [isEditing, id, data.isPinned, setNodes]);
const handleTextChange = (e) => {
const newText = e.target.value;
setLocalText(newText);
if (data.updateNodeData) {
data.updateNodeData(id, { label: newText });
}
};
const handleDoubleClick = (e) => {
e.stopPropagation();
setIsEditing(true);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, 0);
};
const handleBlur = () => {
setIsEditing(false);
if (data.updateNodeData) {
data.updateNodeData(id, { label: localText });
}
};
const handleResizeStart = (e, direction) => {
e.stopPropagation();
e.preventDefault();
const node = getNode(id);
if (!node) return;
setIsResizing(true);
const startX = e.clientX;
const startY = e.clientY;
const startPos = { ...node.position };
const startSize = {
width: node.width || node.style?.width || data.width || 250,
height: node.height || node.style?.height || 100
};
const zoom = getZoom();
let finalWidth = startSize.width;
let finalX = startPos.x;
const nodeElement = document.querySelector(`.react-flow__node[data-id="${id}"]`);
const handlePointerMove = (moveEvent) => {
const dx = (moveEvent.clientX - startX) / zoom;
const dy = (moveEvent.clientY - startY) / zoom;
// For TextNode, we only resize width
if (['right', 'top-right', 'bottom-right', 'top', 'bottom'].includes(direction)) {
// Dragging right-side / vertical handles: expand width to the right
const delta = ['top', 'bottom'].includes(direction) ? dy : dx;
finalWidth = Math.max(50, startSize.width + delta);
finalX = startPos.x;
} else if (['left', 'top-left', 'bottom-left'].includes(direction)) {
// Dragging left-side handles: expand width to the left, shifting position x
finalWidth = Math.max(50, startSize.width - dx);
finalX = startPos.x + (startSize.width - finalWidth);
}
if (nodeElement) {
nodeElement.style.width = `${finalWidth}px`;
nodeElement.style.transform = `translate(${finalX}px, ${startPos.y}px)`;
}
};
const handlePointerUp = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
setNodes((nds) =>
nds.map((n) => {
if (n.id === id) {
return {
...n,
position: { x: finalX, y: startPos.y },
width: finalWidth,
style: { ...n.style, width: finalWidth }
};
}
return n;
})
);
if (data.updateNodeData) {
data.updateNodeData(id, {
width: finalWidth,
x: finalX,
y: startPos.y
});
}
setIsResizing(false);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
};
const isCustomSized = width !== undefined || isResizing;
const styleWidth = isCustomSized ? '100%' : (data.width || 250);
const fontSize = data.fontSize || 18;
const color = data.color || (isDarkMode ? '#ffffff' : '#31363F');
// Common handle style & class (Removed transition-all to prevent scale animation lag)
const handleClass = "absolute w-3.5 h-3.5 bg-surface-container-lowest border-2 border-primary rounded-md shadow-[2px_2px_0px_0px_var(--color-primary)] z-50 hover:scale-110 active:scale-95 transition-transform select-none";
return (
<div
onDoubleClick={handleDoubleClick}
className={`relative group p-1 ${
selected ? 'ring-2 ring-primary/40 shadow-xl rounded-md' : ''
}`}
style={{
width: styleWidth,
height: 'auto',
minWidth: 50,
}}
>
{selected && !isEditing && (
<>
{/* Custom Dashed Outline */}
<div className="absolute inset-0 border-2 border-dashed border-primary opacity-60 rounded-lg pointer-events-none z-10" />
{/* Corners */}
<div
style={{ top: '-7px', left: '-7px' }}
className={`${handleClass} cursor-nwse-resize`}
onPointerDown={(e) => handleResizeStart(e, 'top-left')}
/>
<div
style={{ top: '-7px', right: '-7px' }}
className={`${handleClass} cursor-nesw-resize`}
onPointerDown={(e) => handleResizeStart(e, 'top-right')}
/>
<div
style={{ bottom: '-7px', left: '-7px' }}
className={`${handleClass} cursor-nesw-resize`}
onPointerDown={(e) => handleResizeStart(e, 'bottom-left')}
/>
<div
style={{ bottom: '-7px', right: '-7px' }}
className={`${handleClass} cursor-nwse-resize`}
onPointerDown={(e) => handleResizeStart(e, 'bottom-right')}
/>
{/* Sides */}
<div
style={{ top: '-7px', left: '50%', marginLeft: '-7px' }}
className={`${handleClass} cursor-ns-resize`}
onPointerDown={(e) => handleResizeStart(e, 'top')}
/>
<div
style={{ bottom: '-7px', left: '50%', marginLeft: '-7px' }}
className={`${handleClass} cursor-ns-resize`}
onPointerDown={(e) => handleResizeStart(e, 'bottom')}
/>
<div
style={{ left: '-7px', top: '50%', marginTop: '-7px' }}
className={`${handleClass} cursor-ew-resize`}
onPointerDown={(e) => handleResizeStart(e, 'left')}
/>
<div
style={{ right: '-7px', top: '50%', marginTop: '-7px' }}
className={`${handleClass} cursor-ew-resize`}
onPointerDown={(e) => handleResizeStart(e, 'right')}
/>
</>
)}
<textarea
ref={textareaRef}
value={localText}
onChange={handleTextChange}
onBlur={handleBlur}
readOnly={!isEditing}
placeholder={t('canvas.text_placeholder', 'Type something...')}
className={`w-full bg-transparent border-none outline-none resize-none overflow-hidden font-accent-note leading-tight p-0 ${
isEditing ? 'pointer-events-auto' : 'pointer-events-none'
}`}
style={{
fontSize: `${fontSize}px`,
color: color,
textAlign: data.textAlign || 'left',
cursor: isEditing ? 'text' : 'grab'
}}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => {
if (isEditing) e.stopPropagation();
}}
onMouseDown={(e) => {
if (isEditing) e.stopPropagation();
}}
onTouchStart={(e) => {
if (isEditing) e.stopPropagation();
}}
/>
<Handle type="target" position={Position.Top} className="!opacity-0" />
<Handle type="source" position={Position.Bottom} className="!opacity-0" />
</div>
);
};
export default TextNode;