import 'package:flutter/gestures.dart' show PointerScrollEvent, PointerSignalEvent; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'timeline_scope.dart'; /// Handles pan/zoom gestures for the timeline. /// /// Must be used within a [ZTimelineScope]. Supports: /// - Two-finger pinch-to-zoom (with focal point) /// - Single-finger horizontal pan /// - Ctrl/Cmd + mouse wheel zoom /// - Horizontal mouse scroll for pan /// - Keyboard shortcuts: arrows (pan), +/- (zoom) /// - Mouse cursor feedback (grab/grabbing) /// /// ```dart /// ZTimelineScope( /// viewport: viewport, /// child: ZTimelineInteractor( /// child: ZTimelineView(...), /// ), /// ) /// ``` class ZTimelineInteractor extends StatefulWidget { const ZTimelineInteractor({ required this.child, this.autofocus = true, super.key, }); /// The widget to wrap with gesture handling. final Widget child; /// Whether to automatically focus this widget for keyboard input. final bool autofocus; @override State createState() => _ZTimelineInteractorState(); } class _ZTimelineInteractorState extends State { double _prevScaleValue = 1.0; Offset? _lastFocalPoint; late FocusNode _focusNode; @override void initState() { super.initState(); _focusNode = FocusNode(); } @override void dispose() { _focusNode.dispose(); super.dispose(); } double get _width { final renderBox = context.findRenderObject() as RenderBox?; return renderBox?.size.width ?? 1.0; } void _handleScaleStart(ScaleStartDetails details) { _prevScaleValue = 1.0; _lastFocalPoint = details.focalPoint; final scope = ZTimelineScope.of(context); scope.interaction.setGrabbing(true); } void _handleScaleUpdate(ScaleUpdateDetails details) { final scope = ZTimelineScope.of(context); final config = scope.config; final width = _width; // Two-finger pinch-to-zoom if (details.pointerCount >= 2 && config.enablePinchZoom) { if (details.scale != _prevScaleValue) { final scaleFactor = details.scale / _prevScaleValue; _prevScaleValue = details.scale; final focalPosition = (details.focalPoint.dx / width).clamp(0.0, 1.0); _performZoom(scaleFactor, focusPosition: focalPosition); } } // Single-finger pan else if (details.pointerCount == 1 && config.enablePan && !scope.interaction.isDraggingEntry) { if (_lastFocalPoint != null) { final diff = details.focalPoint - _lastFocalPoint!; final ratio = -diff.dx / width; if (ratio != 0) { scope.viewport.pan(ratio); } } } _lastFocalPoint = details.focalPoint; } void _handleScaleEnd(ScaleEndDetails details) { _lastFocalPoint = null; final scope = ZTimelineScope.of(context); scope.interaction.setGrabbing(false); } void _handlePointerSignal(PointerSignalEvent event) { if (event is! PointerScrollEvent) return; final scope = ZTimelineScope.of(context); final config = scope.config; final width = _width; final pressed = HardwareKeyboard.instance.logicalKeysPressed; final isCtrlOrMeta = pressed.contains(LogicalKeyboardKey.controlLeft) || pressed.contains(LogicalKeyboardKey.controlRight) || pressed.contains(LogicalKeyboardKey.metaLeft) || pressed.contains(LogicalKeyboardKey.metaRight); // Ctrl/Cmd + scroll = zoom if (isCtrlOrMeta && config.enableMouseWheelZoom) { final renderBox = context.findRenderObject() as RenderBox?; final local = renderBox?.globalToLocal(event.position) ?? Offset.zero; final focusPosition = (local.dx / width).clamp(0.0, 1.0); final factor = event.scrollDelta.dy < 0 ? config.zoomFactorIn : config.zoomFactorOut; _performZoom(factor, focusPosition: focusPosition); return; } // Horizontal scroll = pan if (config.enablePan) { final dx = event.scrollDelta.dx; if (dx == 0.0) return; final ratio = dx / width; if (ratio != 0) { scope.viewport.pan(ratio); } } } void _performZoom(double factor, {double focusPosition = 0.5}) { final scope = ZTimelineScope.of(context); final config = scope.config; final viewport = scope.viewport; // Check limits before zooming final currentDuration = viewport.end.difference(viewport.start); final newDurationMs = (currentDuration.inMilliseconds / factor).round(); final newDuration = Duration(milliseconds: newDurationMs); // Prevent zooming in too far if (factor > 1 && newDuration < config.minZoomDuration) return; // Prevent zooming out too far if (factor < 1 && newDuration > config.maxZoomDuration) return; viewport.zoom(factor, focusPosition: focusPosition); } Map _buildKeyboardBindings( ZTimelineScopeData scope, ) { final config = scope.config; if (!config.enableKeyboardShortcuts) { return const {}; } return { // Pan left const SingleActivator(LogicalKeyboardKey.arrowLeft): () { if (config.enablePan) { scope.viewport.pan(-config.keyboardPanRatio); } }, // Pan right const SingleActivator(LogicalKeyboardKey.arrowRight): () { if (config.enablePan) { scope.viewport.pan(config.keyboardPanRatio); } }, // Zoom in (equals key, typically + without shift) const SingleActivator(LogicalKeyboardKey.equal): () { _performZoom(config.zoomFactorIn); }, // Zoom out const SingleActivator(LogicalKeyboardKey.minus): () { _performZoom(config.zoomFactorOut); }, // Zoom in (numpad +) const SingleActivator(LogicalKeyboardKey.numpadAdd): () { _performZoom(config.zoomFactorIn); }, // Zoom out (numpad -) const SingleActivator(LogicalKeyboardKey.numpadSubtract): () { _performZoom(config.zoomFactorOut); }, }; } @override Widget build(BuildContext context) { final scope = ZTimelineScope.of(context); return CallbackShortcuts( bindings: _buildKeyboardBindings(scope), child: Focus( autofocus: widget.autofocus, focusNode: _focusNode, child: Listener( onPointerSignal: _handlePointerSignal, child: ListenableBuilder( listenable: scope.interaction, builder: (context, child) { return MouseRegion( cursor: scope.interaction.isGrabbing ? SystemMouseCursors.grabbing : SystemMouseCursors.grab, child: child, ); }, child: GestureDetector( onScaleStart: _handleScaleStart, onScaleUpdate: _handleScaleUpdate, onScaleEnd: _handleScaleEnd, behavior: HitTestBehavior.deferToChild, child: widget.child, ), ), ), ), ); } }