mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-24 16:18:39 +08:00
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:
committed by
GitHub
parent
e617740974
commit
3e39dbb298
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user