Spaces:
Running
Running
Fix text selection: stop pointerdown and touchstart events from bubbling to prevent canvas panning when selecting text in edit mode
06a320f | 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; | |