From 7299f9d408d0c9e65a028929fb5fac18d5748a90 Mon Sep 17 00:00:00 2001 From: Matan Date: Wed, 15 Jul 2026 16:53:08 +0300 Subject: [PATCH] fix: stop scroll ticks cancelling the floating toolbar's pending show The desktop floating toolbar debounced two independent triggers onto a single key ('show the toolbar'): - selection changes, at 200ms - scroll offset changes, at Duration.zero Debounce.debounce() keyed by name means the later call replaces the earlier one, so whichever fired last won and the other was silently dropped. During a drag that auto-scrolls -- selecting text and pulling past the viewport edge, exactly when the toolbar must track a selection extent that is off-screen until the scroll happens -- the two interleave unpredictably, and a scroll tick could cancel the pending, more authoritative selection-driven show. The toolbar then fails to appear. Give each trigger its own debounce key so they no longer evict each other, and cancel both wherever the toolbar is cleared or disposed. Also defer the scroll-triggered show to a post-frame callback. Auto-scroll fires _onScrollPositionChanged from its own ticker, in the same call stack as the offset change and ahead of the layout pass that repositions the scrolled content -- so _showToolbar could read stale geometry from the previous frame. The post-frame callback lets layout settle first, with a mounted guard. Co-Authored-By: Claude Opus 4.8 --- .../toolbar/desktop/floating_toolbar.dart | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/src/editor/toolbar/desktop/floating_toolbar.dart b/lib/src/editor/toolbar/desktop/floating_toolbar.dart index eb29dbffd..cb821e8f4 100644 --- a/lib/src/editor/toolbar/desktop/floating_toolbar.dart +++ b/lib/src/editor/toolbar/desktop/floating_toolbar.dart @@ -100,7 +100,8 @@ class _FloatingToolbarState extends State @override void dispose() { - Debounce.cancel(_debounceKey); + Debounce.cancel(_selectionDebounceKey); + Debounce.cancel(_scrollDebounceKey); _toolbarContainer?.remove(); _toolbarContainer?.dispose(); @@ -128,7 +129,7 @@ class _FloatingToolbarState extends State void didChangeMetrics() { super.didChangeMetrics(); hasMetricsChanged = true; - _showAfterDelay(isMetricsChanged: true); + _showAfterDelay(_selectionDebounceKey, isMetricsChanged: true); } @override @@ -158,6 +159,7 @@ class _FloatingToolbarState extends State } else if (!disableToolbar) { // uses debounce to avoid the computing the rects too frequently. _showAfterDelay( + _selectionDebounceKey, duration: const Duration(milliseconds: 200), isMetricsChanged: hasMetricsChanged, ); @@ -168,27 +170,40 @@ class _FloatingToolbarState extends State void _onScrollPositionChanged() { _clear(); - // TODO: optimize the toolbar showing logic, making it more smooth. - // A quick idea: based on the scroll controller's offset to display the toolbar. - _showAfterDelay(); + // Auto-scroll drives this from its own ticker, ahead of the layout pass + // that repositions the scrolled content. Defer so the toolbar measures + // settled geometry instead of the previous frame's. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // TODO: optimize the toolbar showing logic, making it more smooth. + // A quick idea: based on the scroll controller's offset to display the toolbar. + _showAfterDelay(_scrollDebounceKey); + }); } - final String _debounceKey = 'show the toolbar'; + // Selection- and scroll-triggered shows debounce separately. On a shared key + // the last caller won, so a scroll tick could cancel a pending 200ms + // selection-driven show — and the two interleave during any drag that + // auto-scrolls. + final String _selectionDebounceKey = 'show the toolbar - selection'; + final String _scrollDebounceKey = 'show the toolbar - scroll'; void _clear() { - Debounce.cancel(_debounceKey); + Debounce.cancel(_selectionDebounceKey); + Debounce.cancel(_scrollDebounceKey); _toolbarContainer?.remove(); _toolbarContainer = null; } - void _showAfterDelay({ + void _showAfterDelay( + String debounceKey, { Duration duration = Duration.zero, bool isMetricsChanged = false, }) { // uses debounce to avoid the computing the rects too frequently. Debounce.debounce( - _debounceKey, + debounceKey, duration, () { _clear(); // clear the previous toolbar.