Files
zendegi/packages/z-timeline/lib/src/widgets/timeline_interactor.dart

243 lines
7.0 KiB
Dart

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<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
}
class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
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<ShortcutActivator, VoidCallback> _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,
),
),
),
),
);
}
}