Skip to content

Comments

fix(mobile): fixed toolbar position#14329

Merged
darkskygit merged 4 commits intotoeverything:canaryfrom
passabilities:fix/mobile-web-toolbar
Feb 23, 2026
Merged

fix(mobile): fixed toolbar position#14329
darkskygit merged 4 commits intotoeverything:canaryfrom
passabilities:fix/mobile-web-toolbar

Conversation

@passabilities
Copy link
Contributor

@passabilities passabilities commented Jan 27, 2026

Summary

Fixed the text formatting toolbar not working properly on mobile web browsers.

Problem

The toolbar had multiple issues on mobile devices:

  • It would render off-screen or be covered by the virtual keyboard
  • The flag-based rendering system caused visibility issues on mobile
  • Long-press text selection didn't trigger the toolbar
  • Wide toolbars could overflow the viewport

Solution

  • Use fixed positioning at bottom of screen on mobile devices
  • Position toolbar above virtual keyboard using Visual Viewport API
  • Handle toolbar visibility directly via selectionchange event
  • Bypass flag-based rendering system on mobile to avoid rendering issues
  • Add touchend listener to handle long-press text selection
  • Limit toolbar max-width to viewport minus padding
  • Enable horizontal scrolling for overflow content

Test plan

  • Tested on mobile Safari (iOS)
  • Tested on mobile Chrome (Android)
  • Verified desktop browsers still work correctly
  • Verified the toolbar is fixed to the bottom of the screen and above virtual keyboard
  • Verified long-press text selection triggers toolbar

Summary by CodeRabbit

  • Improvements
    • Mobile toolbar now anchors to the bottom, adapts width, and repositions dynamically to stay above on-screen keyboards.
    • Toolbar visibility is context-aware, showing when native-like text selections occur and hiding otherwise; touch interactions are handled for reliable toggling.
    • Desktop experience and public APIs remain unchanged.

✏️ Tip: You can customize this high-level summary in your review settings.

@CLAassistant
Copy link

CLAassistant commented Jan 27, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

Adds mobile-specific toolbar behavior: on mobile the toolbar is appended to the shadow DOM, fixed at the bottom, hidden by default, and shown/positioned based on selection and Visual Viewport (keyboard) resize/scroll; desktop behavior remains unchanged.

Changes

Cohort / File(s) Summary
Mobile Toolbar Positioning & Selection Handling
blocksuite/affine/widgets/toolbar/src/toolbar.ts
Introduce IS_MOBILE branch: append toolbar to shadowRoot for mobile, set fixed bottom positioning and reduced max-width, hide by default. Add updateMobilePosition using Visual Viewport API, wire resize/scroll listeners and disposables. Add debounced selectionchange/touchend logic, isNativeTextSelection helper, and mobile-specific effect to recalc position when flags change. Preserve desktop flow and exports.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Document
  participant VisualViewport
  participant Toolbar

  User->>Document: touch / select text
  Document->>Document: dispatch selectionchange / touchend
  Document->>Toolbar: detect selection -> request show/hide
  VisualViewport->>Toolbar: resize/scroll events (keyboard)
  Toolbar->>Toolbar: updateMobilePosition() -> compute bottom offset
  Toolbar->>Document: set styles (fixed, bottom: Xpx), render & show
  User->>Document: dismiss keyboard or clear selection
  Document->>Toolbar: request hide
  Toolbar->>Toolbar: hide, remove listeners as needed
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I tunneled to the little screen,

sank my paws where keyboards lean.
When you tap and text is found,
I float up snug above the ground.
Tap away — I’ll hide, unseen. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the primary change: fixing mobile toolbar positioning issues.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@blocksuite/affine/widgets/toolbar/src/toolbar.ts`:
- Around line 312-315: The code calls sel.getRangeAt(0) which can throw if
sel.rangeCount is 0; update the selection check to ensure sel.rangeCount > 0
before calling getRangeAt (e.g., compute hasSelection as sel && sel.rangeCount >
0 && !sel.isCollapsed && sel.toString().length > 0) or wrap getRangeAt in a safe
conditional/try-catch; modify the variables in this block (sel, hasSelection,
range) so range is only assigned when the rangeCount guard passes.
- Around line 328-335: The pending timeouts created for selectionchange and
touchend (the local timeout variable and the anonymous setTimeout in
disposables.addFromEvent(host, 'touchend', ...)) are not cleared on component
disconnect, risking updateToolbar running after teardown; store the touchend
timeout reference alongside the existing timeout variable and ensure both are
cleared inside the component's dispose/cleanup (where disposables are disposed)
by calling clearTimeout on each reference before nulling them so updateToolbar
is never invoked after disconnect.
- Around line 275-287: The toolbar loses theme-specific styles when appended to
document.body because darkToolbarStyles/lightToolbarStyles are scoped inside
AffineToolbarWidget's shadow DOM and no data-app-theme is set on the toolbar;
update the logic that appends the toolbar (the code that sets styles and calls
document.body.append(toolbar)) to either set the toolbar's data-app-theme
attribute to the current theme (e.g., copy from AffineToolbarWidget or document
root) or inject the darkToolbarStyles/lightToolbarStyles into document.head so
the toolbar can pick up theme rules; locate references to AffineToolbarWidget,
darkToolbarStyles, lightToolbarStyles, and the toolbar element to implement the
chosen fix.
🧹 Nitpick comments (1)
blocksuite/affine/widgets/toolbar/src/toolbar.ts (1)

272-338: Consider consolidating mobile checks to avoid unnecessary flag operations.

The current implementation toggles flags (e.g., Flag.Text, Flag.Block) on mobile even though the effects that consume them early-return. While this works correctly, it results in unnecessary signal updates and subscription callbacks.

For a cleaner separation, consider wrapping the flag-dependent subscription blocks (lines 342-591) in a !IS_MOBILE check, or use a more unified approach where mobile has its own dedicated handlers.

This is not blocking since the current approach is functionally correct.

@codecov
Copy link

codecov bot commented Jan 28, 2026

Codecov Report

❌ Patch coverage is 4.68750% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.99%. Comparing base (744c78a) to head (c087613).
⚠️ Report is 2 commits behind head on canary.

Files with missing lines Patch % Lines
blocksuite/affine/widgets/toolbar/src/toolbar.ts 4.68% 59 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           canary   #14329      +/-   ##
==========================================
+ Coverage   49.37%   56.99%   +7.61%     
==========================================
  Files        2558     2861     +303     
  Lines      145614   154768    +9154     
  Branches    20939    23256    +2317     
==========================================
+ Hits        71902    88213   +16311     
+ Misses      71727    63626    -8101     
- Partials     1985     2929     +944     
Flag Coverage Δ
server-test 74.97% <ø> (+3.33%) ⬆️
unittest 34.04% <4.68%> (+7.98%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@passabilities passabilities changed the title fix(toolbar): add mobile browser support fix(mobile): fixed toolbar position on mobile browser Jan 28, 2026
@passabilities passabilities changed the title fix(mobile): fixed toolbar position on mobile browser fix(mobile): fixed toolbar position Jan 28, 2026
@passabilities
Copy link
Contributor Author

I've updated the description to include screenshots of the problem I'm having on mobile

passabilities and others added 4 commits February 24, 2026 00:52
- Use fixed positioning at bottom of screen on mobile devices
- Handle toolbar visibility directly via selectionchange event
- Position toolbar above virtual keyboard using Visual Viewport API
- Bypass flag-based rendering system on mobile to avoid rendering issues
- Add touchend listener to handle long-press text selection
- Limit toolbar max-width to viewport minus padding
- Enable horizontal scrolling for overflow content
- Allow pan-x touch action for toolbar scrolling
- Keep toolbar in shadowRoot so theme styles naturally apply
- Add top: auto to override default positioning from static styles
- Add rangeCount check to prevent DOMException when accessing selection range
- Clear timeouts on component disconnect to prevent stale updates
@darkskygit darkskygit force-pushed the fix/mobile-web-toolbar branch from 5383703 to c087613 Compare February 23, 2026 17:08
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
blocksuite/affine/widgets/toolbar/src/toolbar.ts (1)

428-433: ⚠️ Potential issue | 🟡 Minor

Mobile toolbar flicker: generic selectionchange handler may transiently clear Flag.Text before the mobile sync restores it.

On mobile, when std.range.value is null (native touch selection not tracked by the BlockSuite range manager):

  1. range?.commonAncestorContainerundefined, so std.host.contains(null) returns false.
  2. !false = trueflags.toggle(Flag.Text, false) fires synchronously, hiding the toolbar.
  3. The mobile-specific 50 ms-debounced syncMobileTextSelection then re-enables Flag.Text.

Because the toolbar CSS transition is 120 ms, this 50 ms hide→show cycle is likely perceptible. syncMobileTextSelection already handles the "selection outside editor" case via its inEditor check, so this synchronous clear is redundant on mobile.

🐛 Proposed fix
       // Focues outside: `doc-title`
       if (
+        !IS_MOBILE &&
         flags.check(Flag.Text) &&
         !std.host.contains(range?.commonAncestorContainer ?? null)
       ) {
         flags.toggle(Flag.Text, false);
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blocksuite/affine/widgets/toolbar/src/toolbar.ts` around lines 428 - 433, The
selectionchange handler is synchronously clearing flags.toggle(Flag.Text, false)
when range?.commonAncestorContainer is undefined, which causes a visible
hide→show flicker on mobile; change the handler to skip toggling Flag.Text when
std.range.value is null on touch/mobile devices (detect via
navigator.maxTouchPoints>0 or 'ontouchstart' in window) so that mobile-specific
syncMobileTextSelection can handle inEditor logic and re-enable Flag.Text
without a transient hide; keep the existing std.host.contains(...) check for
non-mobile flows and only short-circuit the toggle when on mobile and
std.range.value is null.
🧹 Nitpick comments (2)
blocksuite/affine/widgets/toolbar/src/toolbar.ts (2)

363-370: Optional: scheduleTouchSync's delay parameter is always 100.

scheduleTouchSync is called exactly once, with a hard-coded 100. The parameter adds no flexibility here.

♻️ Proposed simplification
-      const scheduleTouchSync = (delay: number) => {
+      const scheduleTouchSync = () => {
         if (touchTimeout) clearTimeout(touchTimeout);
-        touchTimeout = setTimeout(syncMobileTextSelection, delay);
+        touchTimeout = setTimeout(syncMobileTextSelection, 100);
       };
       ...
       disposables.addFromEvent(host, 'touchend', () => {
-        scheduleTouchSync(100);
+        scheduleTouchSync();
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blocksuite/affine/widgets/toolbar/src/toolbar.ts` around lines 363 - 370,
scheduleTouchSync currently accepts a delay parameter but is always invoked with
a hard-coded 100, so remove the unused parameter and simplify the function:
change scheduleTouchSync to take no args, have it clear touchTimeout and call
setTimeout(syncMobileTextSelection, 100), and keep
scheduleSyncMobileTextSelection as-is (uses selectionTimeout and variable
delay). Update any callers to call scheduleTouchSync() without arguments and
reference touchTimeout, syncMobileTextSelection, and selectionTimeout to locate
the functions/vars.

283-296: Optional: factor out the duplicated isEditing check.

isEditing is tested twice; factoring it out makes the intent clearer.

♻️ Proposed refactor
   if (
     dbViewSel &&
-    ((dbViewSel.selectionType === 'area' && dbViewSel.isEditing) ||
-      (dbViewSel.selectionType === 'cell' && dbViewSel.isEditing))
+    dbViewSel.isEditing &&
+    (dbViewSel.selectionType === 'area' || dbViewSel.selectionType === 'cell')
   ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blocksuite/affine/widgets/toolbar/src/toolbar.ts` around lines 283 - 296, The
isNativeTextSelection function duplicates checks for dbViewSel.isEditing;
refactor by extracting a local boolean like isEditing =
Boolean(dbViewSel?.isEditing) (or const isEditing = dbViewSel?.isEditing) and
then use that single variable in the conditional that tests
dbViewSel.selectionType === 'area' || dbViewSel.selectionType === 'cell' to
simplify the logic; keep the subsequent TableSelection check
(std.selection.find(TableSelection)?.data) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@blocksuite/affine/widgets/toolbar/src/toolbar.ts`:
- Around line 428-433: The selectionchange handler is synchronously clearing
flags.toggle(Flag.Text, false) when range?.commonAncestorContainer is undefined,
which causes a visible hide→show flicker on mobile; change the handler to skip
toggling Flag.Text when std.range.value is null on touch/mobile devices (detect
via navigator.maxTouchPoints>0 or 'ontouchstart' in window) so that
mobile-specific syncMobileTextSelection can handle inEditor logic and re-enable
Flag.Text without a transient hide; keep the existing std.host.contains(...)
check for non-mobile flows and only short-circuit the toggle when on mobile and
std.range.value is null.

---

Nitpick comments:
In `@blocksuite/affine/widgets/toolbar/src/toolbar.ts`:
- Around line 363-370: scheduleTouchSync currently accepts a delay parameter but
is always invoked with a hard-coded 100, so remove the unused parameter and
simplify the function: change scheduleTouchSync to take no args, have it clear
touchTimeout and call setTimeout(syncMobileTextSelection, 100), and keep
scheduleSyncMobileTextSelection as-is (uses selectionTimeout and variable
delay). Update any callers to call scheduleTouchSync() without arguments and
reference touchTimeout, syncMobileTextSelection, and selectionTimeout to locate
the functions/vars.
- Around line 283-296: The isNativeTextSelection function duplicates checks for
dbViewSel.isEditing; refactor by extracting a local boolean like isEditing =
Boolean(dbViewSel?.isEditing) (or const isEditing = dbViewSel?.isEditing) and
then use that single variable in the conditional that tests
dbViewSel.selectionType === 'area' || dbViewSel.selectionType === 'cell' to
simplify the logic; keep the subsequent TableSelection check
(std.selection.find(TableSelection)?.data) unchanged.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5383703 and c087613.

📒 Files selected for processing (1)
  • blocksuite/affine/widgets/toolbar/src/toolbar.ts

@darkskygit darkskygit merged commit 3e39dbb into toeverything:canary Feb 23, 2026
58 of 59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants