diff --git a/packages/vyuh_node_flow/lib/src/editor/element_scope.dart b/packages/vyuh_node_flow/lib/src/editor/element_scope.dart index a1a3834..562dc35 100644 --- a/packages/vyuh_node_flow/lib/src/editor/element_scope.dart +++ b/packages/vyuh_node_flow/lib/src/editor/element_scope.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import '../graph/coordinates.dart'; @@ -339,6 +340,31 @@ class _ElementScopeState extends State with AutoPanMixin { event.kind == PointerDeviceKind.invertedStylus; } + /// Whether [globalPosition] lands on an interactive child that must own the + /// touch rather than have it hijacked into a node drag: + /// * a scrollable viewport ([RenderAbstractViewport], e.g. a ListView + /// embedded inside a node), or + /// * an editable text field ([RenderEditable]) — so long-press selection, + /// the selection handles and the magnifier keep working. + /// The editor canvas uses an [InteractiveViewer] (not a render viewport), so + /// it never matches here. + bool _pointerOverInteractiveChild(Offset globalPosition) { + if (!mounted) return false; + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView( + result, + globalPosition, + View.of(context).viewId, + ); + for (final entry in result.path) { + final target = entry.target; + if (target is RenderAbstractViewport || target is RenderEditable) { + return true; + } + } + return false; + } + // --------------------------------------------------------------------------- // AutoPanMixin Implementation // --------------------------------------------------------------------------- @@ -560,6 +586,12 @@ class _ElementScopeState extends State with AutoPanMixin { !widget.shouldCaptureTouch!(event.localPosition)) { return; } + // If the touch lands on an interactive child (a scrollable, or a text + // field — for long-press selection), let that child own the gesture + // instead of dragging the node. + if (_pointerOverInteractiveChild(event.position)) { + return; + } // Pre-lock the canvas on touch down so the canvas doesn't pan // while we decide whether this is a tap or a drag. if (widget.createSession != null && _session == null) { @@ -604,6 +636,25 @@ class _ElementScopeState extends State with AutoPanMixin { if ((event.localPosition - startLocal).distance < slop) { return; } + // If, by the time the drag actually starts, the pointer is over an + // interactive child (scrollable or text field), abandon the node-drag + // capture so that child can take over. Covers the case where + // pointer-down began outside it (e.g. on a focused field's selection + // overlay) and the finger then moved onto it — e.g. dragging a text + // selection handle. + if (_pointerOverInteractiveChild(event.position)) { + if (_preDragLock && !_isDragging) { + _session?.end(); + _session = null; + _preDragLock = false; + } + _touchPointerId = null; + _touchStartLocal = null; + _touchStartGlobal = null; + _lastTouchLocal = null; + _touchDragStarted = false; + return; + } _touchDragStarted = true; _startDrag( DragStartDetails( diff --git a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart index 95a8f62..cddbbe5 100644 --- a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart +++ b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' hide HitTestResult; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -306,6 +307,20 @@ class NodeFlowEditor extends StatefulWidget { class _NodeFlowEditorState extends State> with TickerProviderStateMixin, ViewportAnimationMixin { late final TransformationController _transformationController; + // Keeps the canvas subtree (and its state, e.g. a focused TextField) alive when + // we swap the InteractiveViewer for a plain Transform while the canvas is locked. + final GlobalKey _canvasContentKey = GlobalKey(); + // Drives the InteractiveViewer↔Transform swap for text editing through a mobx + // observable instead of setState. Read inside the canvas Observer, a change + // rebuilds only that Observer (build phase) — exactly like canvasLocked — so + // reparenting the GlobalKey content happens in build, never inside the enclosing + // LayoutBuilder's layout pass (illegal when the focused field has an active + // text-selection OverlayPortal). setState instead marked the LayoutBuilder + // _needsBuild, deferring the reparent INTO layout — the crash this avoids. + final Observable _editingText = Observable(false); + // Cached keyboard visibility (from didChangeDependencies) so the swap builder + // never reads MediaQuery directly. + bool _keyboardVisible = false; final List _disposers = []; bool _isSyncingViewportFromTransform = false; @@ -438,6 +453,11 @@ class _NodeFlowEditorState extends State> // Register keyboard handler for shift key cursor changes HardwareKeyboard.instance.addHandler(_handleKeyEvent); + // React to focus changes so the InteractiveViewer/Transform swap can engage + // the instant a text field gains focus (immediate, unlike the keyboard insets + // which lag behind) — see [_updateEditingText]. + FocusManager.instance.addListener(_onFocusChange); + // Provide transformation controller to debug extension for layer rendering widget.controller.debug?.setTransformationController( _transformationController, @@ -510,6 +530,16 @@ class _NodeFlowEditorState extends State> } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Track keyboard visibility here (subscribes to MediaQuery) instead of reading + // it inside the swap builder; a change republishes [_editingText] via the + // notifier, so the swap's content reparent stays in the build phase. + _keyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0; + _updateEditingText(); + } + /// Collects plugin layers for the given position relative to a core layer. /// /// Returns all widgets from plugins that implement [LayerProvider] and @@ -587,6 +617,48 @@ class _NodeFlowEditorState extends State> builder: (context, child) { // When canvas is locked, disable both pan and zoom final isLocked = widget.controller.canvasLocked; + // Also bypass the InteractiveViewer while editing a + // node's text field. Driven by a mobx observable + // (_editingText, updated on focus/keyboard changes) so + // reading it here makes THIS Observer rebuild in the + // build phase — keeping the canvas reparent out of the + // LayoutBuilder's layout pass. Kept engaged the WHOLE + // editing session so a touch-release rebuild can't + // dismiss the just-made text selection. + final editingText = _editingText.value; + // The InteractiveViewer→Transform swap is a TOUCH-ONLY + // workaround. InteractiveViewer always keeps a + // ScaleGestureRecognizer in the arena (even with pan/scale + // disabled), which steals long-press text selection from a + // TextField in a node; for touch we drop to a bare + // Transform (no gesture detector → descendant gestures + // win), kept in sync with autopan by the AnimatedBuilder. + // On DESKTOP we NEVER swap: the upstream package already + // pans/zooms on desktop with the InteractiveViewer kept and + // gated by isLocked, and swapping would reparent the + // GlobalKey content while a mouse-hover Tooltip + // OverlayPortal is active — illegal inside the enclosing + // LayoutBuilder's layout pass (the Linux-only crash). + if (_isTouchPlatform && (isLocked || editingText)) { + return ClipRect( + child: AnimatedBuilder( + animation: _transformationController, + child: child, + builder: (context, child) => OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0, + minHeight: 0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Transform( + transform: + _transformationController.value, + child: child, + ), + ), + ), + ); + } return InteractiveViewer( transformationController: _transformationController, @@ -608,167 +680,170 @@ class _NodeFlowEditorState extends State> child: child, ); }, - child: UnboundedSizedBox( - width: constraints.maxWidth, - height: constraints.maxHeight, - child: UnboundedStack( - clipBehavior: Clip.none, - children: [ - // Extension layers: before grid - ..._getPluginLayers( - context, - NodeFlowLayer.grid, - LayerRelation.before, - ), - - // Background grid - GridLayer( - controller: widget.controller, - theme: theme, - transformationController: - _transformationController, - ), - - // Extension layers: after grid, before backgroundNodes - ..._getPluginLayers( - context, - NodeFlowLayer.grid, - LayerRelation.after, - ), - ..._getPluginLayers( - context, - NodeFlowLayer.backgroundNodes, - LayerRelation.before, - ), - - // Background nodes (GroupNode) - drag handled via NodeWidget - NodesLayer.background( - widget.controller, - widget.nodeBuilder, - portBuilder: widget.portBuilder, - thumbnailBuilder: widget.thumbnailBuilder, - onNodeTap: _handleNodeTap, - onNodeDoubleTap: _handleNodeDoubleTap, - onNodeContextMenu: _handleNodeContextMenu, - onNodeMouseEnter: _handleNodeMouseEnter, - onNodeMouseLeave: _handleNodeMouseLeave, - onPortContextMenu: _handlePortContextMenu, - portSnapDistance: widget - .controller - .config - .portSnapDistance - .value, - ), - - // Extension layers: after backgroundNodes, before connections - ..._getPluginLayers( - context, - NodeFlowLayer.backgroundNodes, - LayerRelation.after, - ), - ..._getPluginLayers( - context, - NodeFlowLayer.connections, - LayerRelation.before, - ), - - // Connections - ConnectionsLayer( - controller: widget.controller, - animation: _connectionAnimationController, - connectionStyleBuilder: - widget.connectionStyleBuilder, - ), - - // Extension layers: after connections, before connectionLabels - ..._getPluginLayers( - context, - NodeFlowLayer.connections, - LayerRelation.after, - ), - ..._getPluginLayers( - context, - NodeFlowLayer.connectionLabels, - LayerRelation.before, - ), - - // Connection labels - ConnectionLabelsLayer( - controller: widget.controller, - labelBuilder: widget.labelBuilder, - ), - - // Extension layers: after connectionLabels, before middleNodes - ..._getPluginLayers( - context, - NodeFlowLayer.connectionLabels, - LayerRelation.after, - ), - ..._getPluginLayers( - context, - NodeFlowLayer.middleNodes, - LayerRelation.before, - ), - - // Middle layer nodes (regular nodes) - NodesLayer.middle( - widget.controller, - widget.nodeBuilder, - portBuilder: widget.portBuilder, - thumbnailBuilder: widget.thumbnailBuilder, - onNodeTap: _handleNodeTap, - onNodeDoubleTap: _handleNodeDoubleTap, - onNodeContextMenu: _handleNodeContextMenu, - onNodeMouseEnter: _handleNodeMouseEnter, - onNodeMouseLeave: _handleNodeMouseLeave, - onPortContextMenu: _handlePortContextMenu, - portSnapDistance: widget - .controller - .config - .portSnapDistance - .value, - ), - - // Extension layers: after middleNodes, before foregroundNodes - ..._getPluginLayers( - context, - NodeFlowLayer.middleNodes, - LayerRelation.after, - ), - ..._getPluginLayers( - context, - NodeFlowLayer.foregroundNodes, - LayerRelation.before, - ), - - // Foreground nodes (CommentNode) - drag handled via NodeWidget - NodesLayer.foreground( - widget.controller, - widget.nodeBuilder, - portBuilder: widget.portBuilder, - thumbnailBuilder: widget.thumbnailBuilder, - onNodeTap: _handleNodeTap, - onNodeDoubleTap: _handleNodeDoubleTap, - onNodeContextMenu: _handleNodeContextMenu, - onNodeMouseEnter: _handleNodeMouseEnter, - onNodeMouseLeave: _handleNodeMouseLeave, - onPortContextMenu: _handlePortContextMenu, - portSnapDistance: widget - .controller - .config - .portSnapDistance - .value, - ), - - // Extension layers: after foregroundNodes - // Note: SnapLinesLayer and DebugLayersStack are now provided - // via LayerProvider by their respective extensions - ..._getPluginLayers( - context, - NodeFlowLayer.foregroundNodes, - LayerRelation.after, - ), - ], + child: KeyedSubtree( + key: _canvasContentKey, + child: UnboundedSizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: UnboundedStack( + clipBehavior: Clip.none, + children: [ + // Extension layers: before grid + ..._getPluginLayers( + context, + NodeFlowLayer.grid, + LayerRelation.before, + ), + + // Background grid + GridLayer( + controller: widget.controller, + theme: theme, + transformationController: + _transformationController, + ), + + // Extension layers: after grid, before backgroundNodes + ..._getPluginLayers( + context, + NodeFlowLayer.grid, + LayerRelation.after, + ), + ..._getPluginLayers( + context, + NodeFlowLayer.backgroundNodes, + LayerRelation.before, + ), + + // Background nodes (GroupNode) - drag handled via NodeWidget + NodesLayer.background( + widget.controller, + widget.nodeBuilder, + portBuilder: widget.portBuilder, + thumbnailBuilder: widget.thumbnailBuilder, + onNodeTap: _handleNodeTap, + onNodeDoubleTap: _handleNodeDoubleTap, + onNodeContextMenu: _handleNodeContextMenu, + onNodeMouseEnter: _handleNodeMouseEnter, + onNodeMouseLeave: _handleNodeMouseLeave, + onPortContextMenu: _handlePortContextMenu, + portSnapDistance: widget + .controller + .config + .portSnapDistance + .value, + ), + + // Extension layers: after backgroundNodes, before connections + ..._getPluginLayers( + context, + NodeFlowLayer.backgroundNodes, + LayerRelation.after, + ), + ..._getPluginLayers( + context, + NodeFlowLayer.connections, + LayerRelation.before, + ), + + // Connections + ConnectionsLayer( + controller: widget.controller, + animation: _connectionAnimationController, + connectionStyleBuilder: + widget.connectionStyleBuilder, + ), + + // Extension layers: after connections, before connectionLabels + ..._getPluginLayers( + context, + NodeFlowLayer.connections, + LayerRelation.after, + ), + ..._getPluginLayers( + context, + NodeFlowLayer.connectionLabels, + LayerRelation.before, + ), + + // Connection labels + ConnectionLabelsLayer( + controller: widget.controller, + labelBuilder: widget.labelBuilder, + ), + + // Extension layers: after connectionLabels, before middleNodes + ..._getPluginLayers( + context, + NodeFlowLayer.connectionLabels, + LayerRelation.after, + ), + ..._getPluginLayers( + context, + NodeFlowLayer.middleNodes, + LayerRelation.before, + ), + + // Middle layer nodes (regular nodes) + NodesLayer.middle( + widget.controller, + widget.nodeBuilder, + portBuilder: widget.portBuilder, + thumbnailBuilder: widget.thumbnailBuilder, + onNodeTap: _handleNodeTap, + onNodeDoubleTap: _handleNodeDoubleTap, + onNodeContextMenu: _handleNodeContextMenu, + onNodeMouseEnter: _handleNodeMouseEnter, + onNodeMouseLeave: _handleNodeMouseLeave, + onPortContextMenu: _handlePortContextMenu, + portSnapDistance: widget + .controller + .config + .portSnapDistance + .value, + ), + + // Extension layers: after middleNodes, before foregroundNodes + ..._getPluginLayers( + context, + NodeFlowLayer.middleNodes, + LayerRelation.after, + ), + ..._getPluginLayers( + context, + NodeFlowLayer.foregroundNodes, + LayerRelation.before, + ), + + // Foreground nodes (CommentNode) - drag handled via NodeWidget + NodesLayer.foreground( + widget.controller, + widget.nodeBuilder, + portBuilder: widget.portBuilder, + thumbnailBuilder: widget.thumbnailBuilder, + onNodeTap: _handleNodeTap, + onNodeDoubleTap: _handleNodeDoubleTap, + onNodeContextMenu: _handleNodeContextMenu, + onNodeMouseEnter: _handleNodeMouseEnter, + onNodeMouseLeave: _handleNodeMouseLeave, + onPortContextMenu: _handlePortContextMenu, + portSnapDistance: widget + .controller + .config + .portSnapDistance + .value, + ), + + // Extension layers: after foregroundNodes + // Note: SnapLinesLayer and DebugLayersStack are now provided + // via LayerProvider by their respective extensions + ..._getPluginLayers( + context, + NodeFlowLayer.foregroundNodes, + LayerRelation.after, + ), + ], + ), ), ), ), @@ -973,6 +1048,7 @@ class _NodeFlowEditorState extends State> void dispose() { // Remove keyboard handler HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + FocusManager.instance.removeListener(_onFocusChange); // Remove transform listener before disposing _transformationController.removeListener(_syncViewportFromTransform); @@ -991,6 +1067,55 @@ class _NodeFlowEditorState extends State> super.dispose(); } + void _onFocusChange() { + // Publish the editing state via the notifier (NOT setState) so the swap's + // content reparent runs in the build phase, not layout — see [_editingText]. + _updateEditingText(); + } + + /// Whether the user is currently editing a node's text field. While editing we + /// keep the plain-Transform path (no InteractiveViewer gestures) so text + /// selection works AND the swap doesn't flip back on touch-release (which would + /// dismiss the selection). Uses focus (immediate) OR the keyboard insets + /// (fallback, cached in [_keyboardVisible]). In this editor, any non-canvas + /// primary focus is a form field. Read live by the tap-focus guard; published to + /// the [_editingText] observable (which drives the swap) by [_updateEditingText]. + bool get _isEditingNodeText { + // TOUCH ONLY. The sticky editing swap exists because, after a long-press + // selection, the finger lifts (releasing canvasLocked) yet the keyboard and + // selection must persist — so we hold the InteractiveViewer out by focus. On + // desktop none of that applies: text selection is a click-drag, during which + // the pointer-down canvasLocked swap already gives the field a recognizer-free + // Transform path; a merely-focused field must NOT reparent the canvas, because + // its hover Tooltip / selection OverlayPortal would reactivate during the + // enclosing LayoutBuilder's layout pass and throw. That reparent-in-layout is + // why this crashed on Linux/desktop but not on touch (no mouse hover there). + if (!_isTouchPlatform) return false; + if (_keyboardVisible) return true; + final focus = FocusManager.instance.primaryFocus; + return focus != null && + focus != widget.controller.canvasFocusNode && + focus.context != null; + } + + /// Whether the host is a touch platform (where the sticky editing swap is + /// needed). Desktop uses the pointer-down canvasLocked swap instead. + bool get _isTouchPlatform => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.fuchsia; + + /// Publishes [_isEditingNodeText] to the [_editingText] observable so the canvas + /// Observer re-evaluates the InteractiveViewer/Transform swap in the build phase + /// (never inside the LayoutBuilder's layout pass). + void _updateEditingText() { + if (!mounted) return; + final editing = _isEditingNodeText; + if (_editingText.value != editing) { + runInAction(() => _editingText.value = editing); + } + } + // Event handlers /// Syncs the controller's viewport with the transformation controller. @@ -1060,9 +1185,7 @@ class _NodeFlowEditorState extends State> // We don't call setViewport here - the listener is the authoritative source. // Fire viewport move event with current viewport state - widget.controller.events.viewport?.onMove?.call( - widget.controller.viewport, - ); + widget.controller.events.viewport?.onMove?.call(widget.controller.viewport); } void _onInteractionEnd(ScaleEndDetails details) { diff --git a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart index 62bbcf7..ec56ce4 100644 --- a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart +++ b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart @@ -234,8 +234,12 @@ extension _HitTestingExtension on _NodeFlowEditorState { /// Handles tap events for all hit target types (nodes, connections, canvas). /// Detects single taps and double-taps, firing appropriate callbacks. void _handleTapEvent(Offset position, HitTestResult hitResult) { - // Ensure canvas has PRIMARY focus for keyboard shortcuts to work - if (!widget.controller.canvasFocusNode.hasPrimaryFocus) { + // Ensure canvas has PRIMARY focus for keyboard shortcuts to work — but NOT + // while the user is editing a node's text field. Otherwise every tap (incl. + // a stationary long-press, which counts as a tap) would steal focus from the + // field, closing the keyboard and dismissing the text selection. + if (!widget.controller.canvasFocusNode.hasPrimaryFocus && + !_isEditingNodeText) { widget.controller.canvasFocusNode.requestFocus(); }