243 lines
7.0 KiB
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|