fix(mobile): fixed toolbar position (#14329)

## 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

<img
src="https://github.com/user-attachments/assets/8f54590c-1d2c-4c87-abab-32206df17ebf"
width="250">

## 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

<img
src="https://github.com/user-attachments/assets/45130860-f01a-45c1-87c5-d43264f88613"
width="250">

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
passabilities.eth
2026-02-23 11:36:09 -06:00
committed by GitHub
parent e617740974
commit 3e39dbb298

View File

@@ -20,6 +20,7 @@ import {
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { matchModels } from '@blocksuite/affine-shared/utils'; import { matchModels } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import { import {
Bound, Bound,
getCommonBound, getCommonBound,
@@ -109,6 +110,17 @@ export class AffineToolbarWidget extends WidgetComponent {
} }
} }
editor-toolbar[data-mobile='true'] {
position: fixed;
top: auto;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
max-width: calc(100vw - 32px);
overflow-x: auto;
touch-action: pan-x;
}
${unsafeCSS(darkToolbarStyles('editor-toolbar'))} ${unsafeCSS(darkToolbarStyles('editor-toolbar'))}
${unsafeCSS(lightToolbarStyles('editor-toolbar'))} ${unsafeCSS(lightToolbarStyles('editor-toolbar'))}
`; `;
@@ -268,9 +280,110 @@ export class AffineToolbarWidget extends WidgetComponent {
const { flags, flavour$, message$, placement$ } = toolbarRegistry; const { flags, flavour$, message$, placement$ } = toolbarRegistry;
const context = new ToolbarContext(std); const context = new ToolbarContext(std);
// TODO(@fundon): fix toolbar position shaking when the wheel scrolls const isNativeTextSelection = () => {
// document.body.append(toolbar); const dbSel = std.selection.find(DatabaseSelection);
this.shadowRoot!.append(toolbar); const dbViewSel = dbSel?.viewSelection;
if (
dbViewSel &&
((dbViewSel.selectionType === 'area' && dbViewSel.isEditing) ||
(dbViewSel.selectionType === 'cell' && dbViewSel.isEditing))
) {
return true;
}
const tableViewSelection = std.selection.find(TableSelection)?.data;
return tableViewSelection?.type === 'area';
};
let updateMobilePosition: (() => void) | null = null;
if (IS_MOBILE) {
toolbar.dataset.mobile = 'true';
this.shadowRoot!.append(toolbar);
// Position toolbar above virtual keyboard using Visual Viewport API
updateMobilePosition = () => {
const vv = window.visualViewport;
if (!vv) return;
const keyboardHeight = window.innerHeight - vv.height - vv.offsetTop;
toolbar.style.bottom = `${Math.max(16, keyboardHeight + 16)}px`;
};
if (window.visualViewport) {
disposables.addFromEvent(
window.visualViewport,
'resize',
updateMobilePosition
);
disposables.addFromEvent(
window.visualViewport,
'scroll',
updateMobilePosition
);
}
// Keep mobile selection in sync with toolbar flags. On some mobile browsers,
// long-press selection may skip the std selection stream intermittently.
const syncMobileTextSelection = () => {
if (!context.activated) {
flags.toggle(Flag.Text, false);
return;
}
if (isNativeTextSelection()) {
flags.toggle(Flag.Text, false);
return;
}
const selection = window.getSelection();
const hasSelection =
selection &&
selection.rangeCount > 0 &&
!selection.isCollapsed &&
selection.toString().length > 0;
const range = hasSelection ? selection.getRangeAt(0) : null;
const inEditor = Boolean(
range && host.contains(range.commonAncestorContainer)
);
batch(() => {
flags.toggle(Flag.Text, inEditor);
if (!inEditor || !range) return;
this.setReferenceElementWithRange(range);
sideOptions$.value = null;
flavour$.value = 'affine:note';
placement$.value = toolbarRegistry.getModulePlacement('affine:note');
flags.refresh(Flag.Text);
});
};
let selectionTimeout: ReturnType<typeof setTimeout> | null = null;
let touchTimeout: ReturnType<typeof setTimeout> | null = null;
const scheduleSyncMobileTextSelection = (delay: number) => {
if (selectionTimeout) clearTimeout(selectionTimeout);
selectionTimeout = setTimeout(syncMobileTextSelection, delay);
};
const scheduleTouchSync = (delay: number) => {
if (touchTimeout) clearTimeout(touchTimeout);
touchTimeout = setTimeout(syncMobileTextSelection, delay);
};
disposables.addFromEvent(document, 'selectionchange', () => {
scheduleSyncMobileTextSelection(50);
});
disposables.addFromEvent(host, 'touchend', () => {
scheduleTouchSync(100);
});
disposables.add(() => {
if (selectionTimeout) clearTimeout(selectionTimeout);
if (touchTimeout) clearTimeout(touchTimeout);
});
// Ensures a stable initial offset before the first viewport event arrives.
updateMobilePosition?.();
} else {
this.shadowRoot!.append(toolbar);
}
// Formatting // Formatting
// Selects text in note. // Selects text in note.
@@ -305,30 +418,12 @@ export class AffineToolbarWidget extends WidgetComponent {
disposables.addFromEvent(document, 'selectionchange', () => { disposables.addFromEvent(document, 'selectionchange', () => {
const range = std.range.value ?? null; const range = std.range.value ?? null;
let activated = context.activated && Boolean(range && !range.collapsed); let activated = context.activated && Boolean(range && !range.collapsed);
let isNative = false;
if (activated) { if (activated) {
const result = std.selection.find(DatabaseSelection); activated = isNativeTextSelection();
const viewSelection = result?.viewSelection;
if (viewSelection) {
isNative =
(viewSelection.selectionType === 'area' &&
viewSelection.isEditing) ||
(viewSelection.selectionType === 'cell' && viewSelection.isEditing);
}
if (!isNative) {
const result = std.selection.find(TableSelection);
const viewSelection = result?.data;
if (viewSelection) {
isNative = viewSelection.type === 'area';
}
}
} }
batch(() => { batch(() => {
activated &&= isNative;
// Focues outside: `doc-title` // Focues outside: `doc-title`
if ( if (
flags.check(Flag.Text) && flags.check(Flag.Text) &&
@@ -662,6 +757,14 @@ export class AffineToolbarWidget extends WidgetComponent {
disposables.add( disposables.add(
effect(() => { effect(() => {
if (IS_MOBILE) {
const value = flags.value$.value;
if (!context.activated) return;
if (Flag.None === value || flags.contains(Flag.Hiding, value)) return;
updateMobilePosition?.();
return;
}
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
abortController.abort(); abortController.abort();
} }