Preact: Refactor mobile support

Scrollable width is now set on `<html>` (and is inherited by `<body>`).

It's my hope that this fixes some scroll snap bugs on mobile.

We're also now looking at pointer events to decide whether to autofocus
text boxes.

Fixes #2419
This commit is contained in:
Guangcong Luo 2025-05-22 21:04:07 +00:00
parent 07217f22ba
commit b5865585e4
4 changed files with 94 additions and 49 deletions

View File

@ -2073,7 +2073,7 @@ export const PS = new class extends PSModel {
}
calculateLeftPanelWidth() {
const available = document.body.offsetWidth;
if (available < 800 || this.prefs.onepanel === 'vertical') {
if (document.documentElement.clientWidth < 800 || this.prefs.onepanel === 'vertical') {
return null;
}
// If we don't have both a left room and a right room, obviously

View File

@ -11,7 +11,7 @@
import preact from "../js/lib/preact";
import { Config, PS, type PSRoom, type RoomID } from "./client-main";
import { PSView } from "./panels";
import { NARROW_MODE_HEADER_WIDTH, PSView, VERTICAL_HEADER_WIDTH } from "./panels";
import type { Battle } from "./battle";
import { BattleLog } from "./battle-log"; // optional
@ -36,7 +36,7 @@ window.addEventListener('dragover', e => {
e.preventDefault();
});
export class PSHeader extends preact.Component<{ style: object }> {
export class PSHeader extends preact.Component {
static toggleMute = (e: Event) => {
PS.prefs.set('mute', !PS.prefs.mute);
PS.update();
@ -168,8 +168,31 @@ export class PSHeader extends preact.Component<{ style: object }> {
{closeButton}
</li>;
}
handleRoomTabOverflow = () => {
if (PS.leftPanelWidth === null || !this.base) return;
handleResize = () => {
if (!this.base) return;
if (PS.leftPanelWidth === null) {
const width = document.documentElement.clientWidth;
const oldNarrowMode = PSView.narrowMode;
PSView.narrowMode = width <= 700;
PSView.verticalHeaderWidth = PSView.narrowMode ? NARROW_MODE_HEADER_WIDTH : VERTICAL_HEADER_WIDTH;
document.documentElement.style.width = PSView.narrowMode ? `${width + NARROW_MODE_HEADER_WIDTH}px` : 'auto';
if (oldNarrowMode !== PSView.narrowMode) {
if (PSView.narrowMode) {
if (!PSView.textboxFocused) {
document.documentElement.classList?.add('scroll-snap-enabled');
}
} else {
document.documentElement.classList?.remove('scroll-snap-enabled');
}
PS.update();
}
return;
}
if (PSView.narrowMode) {
document.documentElement.classList?.remove('scroll-snap-enabled');
PSView.narrowMode = false;
}
const userbarLeft = this.base.querySelector('div.userbar')?.getBoundingClientRect()?.left;
const plusTabRight = this.base.querySelector('a.roomtab[aria-label="Join chat"]')?.getBoundingClientRect()?.right;
@ -187,11 +210,11 @@ export class PSHeader extends preact.Component<{ style: object }> {
PS.user.subscribe(() => {
this.forceUpdate();
});
window.addEventListener('resize', this.handleRoomTabOverflow);
this.handleRoomTabOverflow();
window.addEventListener('resize', this.handleResize);
this.handleResize();
}
override componentDidUpdate() {
this.handleRoomTabOverflow();
this.handleResize();
}
renderUser() {
if (!PS.connected) {
@ -210,7 +233,8 @@ export class PSHeader extends preact.Component<{ style: object }> {
}
renderVertical() {
return <div
id="header" class="header-vertical" style={this.props.style} onClick={PSView.scrollToHeader} role="navigation"
id="header" class="header-vertical" role="navigation"
style={`width:${PSView.verticalHeaderWidth - 7}px`} onClick={PSView.scrollToHeader}
>
<div class="maintabbarbottom"></div>
<div class="scrollable-part">
@ -232,6 +256,7 @@ export class PSHeader extends preact.Component<{ style: object }> {
</ul>
</div>
</div>
{null /* overflow */}
<div class="userbar">
{this.renderUser()} {}
<div style="float:right">
@ -247,15 +272,9 @@ export class PSHeader extends preact.Component<{ style: object }> {
}
override render() {
if (PS.leftPanelWidth === null) {
if (!PSView.textboxFocused) {
document.documentElement.classList?.add('scroll-snap-enabled');
}
return this.renderVertical();
} else {
document.documentElement.classList?.remove('scroll-snap-enabled');
}
return <div id="header" class="header" style={this.props.style} role="navigation">
return <div id="header" class="header" role="navigation">
<div class="maintabbarbottom"></div>
<div class="tabbar maintabbar"><div class="inner-1" role={PS.leftPanelWidth ? 'none' : 'tablist'}><div class="inner-2">
<ul class="maintabbar-left" style={{ width: `${PS.leftPanelWidth}px` }} role={PS.leftPanelWidth ? 'tablist' : 'none'}>
@ -305,25 +324,24 @@ export class PSMiniHeader extends preact.Component {
override render() {
if (PS.leftPanelWidth !== null) return null;
const minWidth = Math.min(500, Math.max(320, document.body.offsetWidth - 9));
const { icon, title } = PSHeader.roomInfo(PS.panel);
const userColor = window.BattleLog && `color:${BattleLog.usernameColor(PS.user.userid)}`;
const showMenuButton = document.documentElement.offsetWidth >= document.documentElement.scrollWidth;
const showMenuButton = PSView.narrowMode;
const notifying = (
showMenuButton && !window.scrollX && Object.values(PS.rooms).some(room => room!.notifications.length)
!showMenuButton && !window.scrollX && Object.values(PS.rooms).some(room => room!.notifications.length)
) ? ' notifying' : '';
const menuButton = showMenuButton ? (
const menuButton = !showMenuButton ? (
null
) : window.scrollX ? (
<button onClick={PSView.scrollToHeader} class={`mini-header-left ${notifying}`} aria-label="Menu">
<i class="fa fa-arrow-left" aria-hidden></i>
<i class="fa fa-bars" aria-hidden></i>
</button>
) : (
<button onClick={PSView.scrollToRoom} class="mini-header-left" aria-label="Menu">
<i class="fa fa-arrow-right" aria-hidden></i>
</button>
);
return <div class="mini-header" style={{ minWidth: `${minWidth}px` }}>
return <div class="mini-header" style={`left:${PSView.verticalHeaderWidth + (PSView.narrowMode ? 0 : -1)}px;`}>
{menuButton}
{icon} {title}
<button data-href="options" class="mini-header-right" aria-label="Options">

View File

@ -20,6 +20,9 @@ import { PS, type PSRoom, type RoomID } from "./client-main";
import type { ChatRoom } from "./panel-chat";
import { PSHeader, PSMiniHeader } from "./panel-topbar";
export const VERTICAL_HEADER_WIDTH = 240;
export const NARROW_MODE_HEADER_WIDTH = 280;
export class PSRouter {
roomid = '' as RoomID;
panelState = '';
@ -259,8 +262,7 @@ export class PSRoomPanel<T extends PSRoom = PSRoom> extends preact.Component<{ r
PS.closePopup();
}
focus() {
// mobile probably
if (document.body.offsetWidth < 500) return;
if (PSView.hasTapped) return;
const autofocus = this.base?.querySelector<HTMLElement>('.autofocus');
autofocus?.focus();
@ -317,8 +319,14 @@ export class PSView extends preact.Component {
static readonly isMac = navigator.platform?.startsWith('Mac');
static textboxFocused = false;
static dragend: ((ev: DragEvent) => void) | null = null;
/** was the last click event a tap? heristic for mobile/desktop */
static hasTapped = false;
/** mode where the tabbar is opened rather than always being there */
static narrowMode = false;
static verticalHeaderWidth = VERTICAL_HEADER_WIDTH;
static setTextboxFocused(focused: boolean) {
if (!PSView.isChrome || PS.leftPanelWidth !== null) return;
if (!PSView.narrowMode) return;
if (!PSView.isChrome && !PSView.isSafari) return;
// Chrome bug: on Android, it insistently scrolls everything leftmost when scroll snap is enabled
this.textboxFocused = focused;
@ -369,7 +377,7 @@ export class PSView extends preact.Component {
super();
PS.subscribe(() => this.forceUpdate());
if (PSView.isIOS) {
if (PSView.isSafari) {
// I don't want to prevent users from being able to zoom, but iOS Safari
// auto-zooms when focusing textboxes (unless the font size is 16px),
// and this apparently fixes it while still allowing zooming.
@ -397,6 +405,11 @@ export class PSView extends preact.Component {
}
});
window.addEventListener('pointerdown', ev => {
// can't be part of the click event because Safari pretends the pointer is a mouse
PSView.hasTapped = ev.pointerType === 'touch' || ev.pointerType === 'pen';
});
window.addEventListener('click', ev => {
let elem = ev.target as HTMLElement | null;
const clickedRoom = PS.getRoom(elem);
@ -588,23 +601,32 @@ export class PSView extends preact.Component {
});
}
static scrollToHeader() {
if (window.scrollX > 0) {
window.scrollTo({ left: 0 });
if (PSView.narrowMode && window.scrollX > 0) {
if (PSView.isSafari || PSView.isFirefox) {
// Safari bug: `scrollBy` doesn't actually work when scroll snap is enabled
// note: interferes with the `PSView.textboxFocused` workaround for a Chrome bug
document.documentElement.classList.remove('scroll-snap-enabled');
window.scrollTo(0, 0);
setTimeout(() => {
if (!PSView.textboxFocused) document.documentElement.classList.add('scroll-snap-enabled');
}, 1);
} else {
window.scrollTo(0, 0);
}
}
}
static scrollToRoom() {
if (document.documentElement.scrollWidth > document.documentElement.clientWidth && window.scrollX === 0) {
if ((PSView.isIOS || PSView.isFirefox) && PS.leftPanelWidth === null) {
if (PSView.narrowMode && window.scrollX === 0) {
if (PSView.isSafari || PSView.isFirefox) {
// Safari bug: `scrollBy` doesn't actually work when scroll snap is enabled
// note: interferes with the `PSMain.textboxFocused` workaround for a Chrome bug
// note: interferes with the `PSView.textboxFocused` workaround for a Chrome bug
document.documentElement.classList.remove('scroll-snap-enabled');
window.scrollBy(400, 0);
window.scrollTo(NARROW_MODE_HEADER_WIDTH, 0);
setTimeout(() => {
document.documentElement.classList.add('scroll-snap-enabled');
}, 0);
if (!PSView.textboxFocused) document.documentElement.classList.add('scroll-snap-enabled');
}, 1);
} else {
// intentionally around twice as big as necessary
window.scrollBy(400, 0);
window.scrollTo(NARROW_MODE_HEADER_WIDTH, 0);
}
}
}
@ -709,8 +731,8 @@ export class PSView extends preact.Component {
if (PS.leftPanelWidth === null) {
// vertical mode
if (room === PS.panel) {
const minWidth = Math.min(500, Math.max(320, document.body.offsetWidth - 9));
return { top: '25px', left: '200px', minWidth: `${minWidth}px` };
// const minWidth = Math.min(500, Math.max(320, document.body.offsetWidth - 9));
return { top: '30px', left: `${PSView.verticalHeaderWidth}px`, minWidth: `none` };
}
} else if (PS.leftPanelWidth === 0) {
// one panel visible
@ -799,12 +821,12 @@ export class PSView extends preact.Component {
if (availableHeight > sourceTop + sourceHeight + height + 5 &&
(sourceTop + sourceHeight < availableHeight * 2 / 3 || sourceTop + sourceHeight + 200 < availableHeight)) {
style.top = sourceTop + sourceHeight;
} else if (height + 5 <= sourceTop) {
} else if (height + 30 <= sourceTop) {
style.bottom = Math.max(availableHeight - sourceTop, 0);
} else if (height + 10 < availableHeight) {
} else if (height + 35 < availableHeight) {
style.bottom = 5;
} else {
style.top = 0;
style.top = 25;
}
const availableAlignedWidth = availableWidth - sourceLeft;
@ -846,7 +868,7 @@ export class PSView extends preact.Component {
}
}
return <div class="ps-frame" role="none">
<PSHeader style={{}} />
<PSHeader />
<PSMiniHeader />
{rooms}
{PS.popups.map(roomid => this.renderPopup(PS.rooms[roomid]!))}

View File

@ -7,6 +7,11 @@
padding: 0;
height: 100%;
overflow: visible;
overscroll-behavior-x: contain;
}
html {
scrollbar-width: none;
position: relative;
}
body {
color: white;
@ -96,7 +101,7 @@ li::marker {
scroll-snap-align: start;
}
.scroll-snap-enabled .mini-header {
scroll-snap-align: end;
scroll-snap-align: start;
}
.header {
@ -117,7 +122,7 @@ li::marker {
top: 0;
left: 0;
bottom: 0;
width: 193px;
width: 243px;
padding-right: 7px;
background: rgba(255,255,255,.3);
}
@ -135,7 +140,7 @@ li::marker {
top: auto;
left: 12px;
bottom: 12px;
width: 170px;
right: 17px;
word-wrap: break-word;
}
.userbar .username {
@ -191,12 +196,12 @@ li::marker {
}
.mini-header {
position: absolute;
left: 199px;
left: 249px;
right: 0;
top: 0;
padding: 0;
height: 24px;
line-height: 24px;
height: 29px;
line-height: 29px;
border-left: 0;
border-right: 0;
border-top: 0;
@ -229,7 +234,7 @@ li::marker {
border: 0;
border: 1px solid #777;
border-width: 0 0 0 1px;
padding: 0 6px;
padding: 0 9px;
}
.mini-header-left {
float: left;