Preact: Add vertical header layout for mobile

It's finally here and it's beautiful! I've had this design in my mind
for around ten years, ever since I first started working on a Preact
client.

The idea is that on small screens, you'd just scroll left to get to
the menu, instead of needing to press the menu button. This turned out
to work surprisingly well and I'm pretty happy with it.

This also exposed a number of bugs. It turns out most of our popups are
not written with horizontal scrolling in mind, so I had to fix
popup rooms as well as BattleTooltips.

There's still plenty of improvements possible, but I'm already really happy
with what I've done so far.
This commit is contained in:
Guangcong Luo 2025-04-10 09:15:12 +00:00
parent a476ded769
commit 0eeed0d7ce
11 changed files with 425 additions and 173 deletions

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<!--
,///////,
+#########+ ///////,
@ -24,7 +24,7 @@ https://psim.us/dev
-->
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0" />
<title>Showdown!</title>
<link rel="shortcut icon" href="favicon.ico" id="dynamic-favicon" />
<link rel="stylesheet" href="/style/battle.css?" />
@ -104,7 +104,7 @@ https://psim.us/dev
<script defer src="/js/lib/jquery-2.2.4.min.js"></script>
<script defer src="/data/graphics.js?"></script>
<script defer src="/data/text.js?"></script>
<script defer src="/js/battle-tooltips.js"></script>
<script defer src="/js/battle-tooltips.js?"></script>
<script defer src="/js/battle.js?"></script>
<script defer src="/js/battle-choices.js?"></script>
<script defer src="/js/panel-battle.js?"></script>

View File

@ -419,7 +419,7 @@ export class BattleTooltips {
$wrapper.removeClass('tooltip-locked');
}
$wrapper.css({
left: x,
left: Math.min(x, document.documentElement.clientWidth - 400),
top: y,
});
innerHTML = `<div class="tooltipinner"><div class="tooltip tooltip-${type!}">${innerHTML}</div></div>`;
@ -448,8 +448,11 @@ export class BattleTooltips {
}
let width = $(BattleTooltips.elem).outerWidth()!;
if (x > document.documentElement.clientWidth - width - 2) {
x = document.documentElement.clientWidth - width - 2;
const availableWidth = document.documentElement.clientWidth + window.scrollX;
if (x > availableWidth - width - 2) {
x = availableWidth - width - 2;
$wrapper.css('left', x);
} else if (x > document.documentElement.clientWidth - 400) {
$wrapper.css('left', x);
}

View File

@ -882,21 +882,21 @@ export const PS = new class extends PSModel {
panel: PSRoom = null!;
/**
* Not to be confused with PSPrefs.onepanel, which is permanent.
* PS.onePanelMode will be true if one-panel mode is on, but it will
* also be true if the right panel is temporarily hidden (by opening
* the Rooms panel and clicking "Hide")
* Normally tracks PSPrefs.onepanel, but will become 'vertical'
* at low widths.
*
* Will NOT be true if only one panel fits onto the screen at the
* moment, but resizing will display multiple panels for that,
* check `PS.leftRoomWidth === 0`
*/
onePanelMode = false;
onePanelMode: boolean | 'vertical' = false;
/**
* 0 = only one panel visible.
* * 0 = only one panel visible
* * null = vertical nav layout
* n.b. PS will only update if the left room width changes. Resizes
* that don't change the left room width will not trigger an update.
*/
leftPanelWidth = 0;
leftPanelWidth: number | null = 0;
mainmenu: MainMenuRoom = null!;
/**
@ -988,21 +988,25 @@ export const PS = new class extends PSModel {
};
}
updateLayout(alreadyUpdating?: boolean) {
const leftRoomWidth = this.calculateLeftRoomWidth();
let roomHeight = document.body.offsetHeight - 56;
let totalWidth = document.body.offsetWidth;
if (leftRoomWidth) {
this.leftPanel.width = leftRoomWidth;
const leftPanelWidth = this.calculateLeftPanelWidth();
const totalWidth = document.body.offsetWidth;
const totalHeight = document.body.offsetHeight;
const roomHeight = totalHeight - 56;
if (leftPanelWidth === null) {
this.panel.width = totalWidth - 200;
this.panel.height = totalHeight;
} else if (leftPanelWidth) {
this.leftPanel.width = leftPanelWidth;
this.leftPanel.height = roomHeight;
this.rightPanel!.width = totalWidth + 1 - leftRoomWidth;
this.rightPanel!.width = totalWidth + 1 - leftPanelWidth;
this.rightPanel!.height = roomHeight;
} else {
this.panel.width = totalWidth;
this.panel.height = roomHeight;
}
if (this.leftPanelWidth !== leftRoomWidth) {
this.leftPanelWidth = leftRoomWidth;
if (this.leftPanelWidth !== leftPanelWidth) {
this.leftPanelWidth = leftPanelWidth;
if (!alreadyUpdating) this.update(true);
}
}
@ -1119,7 +1123,7 @@ export const PS = new class extends PSModel {
this.connection.send(fullMsg);
}
isVisible(room: PSRoom) {
if (this.leftPanelWidth === 0) {
if (!this.leftPanelWidth) {
// one panel visible
return room === this.panel;
} else {
@ -1127,7 +1131,11 @@ export const PS = new class extends PSModel {
return room === this.rightPanel || room === this.leftPanel;
}
}
calculateLeftRoomWidth() {
calculateLeftPanelWidth() {
const available = document.body.offsetWidth;
if (available < 800) {
return null;
}
// If we don't have both a left room and a right room, obviously
// just show one room
if (!this.leftPanel || !this.rightPanel || this.onePanelMode) {
@ -1139,7 +1147,6 @@ export const PS = new class extends PSModel {
const left = this.getWidthFor(this.leftPanel);
const right = this.getWidthFor(this.rightPanel);
const available = document.body.offsetWidth;
let excess = available - (left.width + right.width);
if (excess >= 0) {
@ -1252,12 +1259,12 @@ export const PS = new class extends PSModel {
if (!this.isVisible(room)) {
room.hiddenInit = true;
}
if (room.location === 'left') {
this.leftPanel = this.panel = room;
while (this.popups.length) this.leave(this.popups.pop()!);
this.room = room;
} else if (room.location === 'right') {
this.rightPanel = this.panel = room;
if (PS.isNormalRoom(room)) {
if (room.location === 'right') {
this.rightPanel = this.panel = room;
} else {
this.leftPanel = this.panel = room;
}
while (this.popups.length) this.leave(this.popups.pop()!);
this.room = room;
} else { // popup or mini-window
@ -1272,6 +1279,9 @@ export const PS = new class extends PSModel {
return true;
}
horizontalNav(room = this.room) {
if (this.leftPanelWidth === null) {
return { rooms: [], index: -1 };
}
const rooms = this.leftRoomList.concat(this.rightRoomList);
const miniRoom = this.miniRoomList[0] !== 'news' ? this.miniRoomList[0] : null;
if (miniRoom) rooms.splice(1, 0, miniRoom);
@ -1282,6 +1292,11 @@ export const PS = new class extends PSModel {
return { rooms, index };
}
verticalNav(room = this.room) {
if (this.leftPanelWidth === null) {
const rooms = ['' as RoomID, ...this.miniRoomList, ...this.leftRoomList.slice(1), ...this.rightRoomList];
const index = rooms.indexOf(room.id);
return { rooms, index };
}
if (room.location !== 'mini-window') {
return { rooms: [], index: -1 };
}
@ -1354,9 +1369,11 @@ export const PS = new class extends PSModel {
if (!upRoom && !downRoom) return '';
let buf = ' ';
if (upRoom) buf += `Alt+\u2191 ${upRoom.title}`;
// const altLabel = navigator.platform?.startsWith('Mac') ? '⌥' : 'ᴀʟᴛ';
const altLabel = navigator.platform?.startsWith('Mac') ? 'ᴏᴘᴛ' : 'ᴀʟᴛ';
if (upRoom) buf += `${altLabel}\u2191 ${upRoom.title}`;
buf += " | ";
if (downRoom) buf += `${downRoom.title} Alt+\u2193`;
if (downRoom) buf += `${altLabel}\u2193 ${downRoom.title}`;
return buf;
}
@ -1441,11 +1458,11 @@ export const PS = new class extends PSModel {
}
}
roomVisible(room: PSRoom): boolean {
if (room.location === 'left' || room.location === 'right') {
return this.leftPanelWidth === 0 ? room === this.panel : room === this.leftPanel || room === this.rightPanel;
if (PS.isNormalRoom(room)) {
return !this.leftPanelWidth ? room === this.panel : room === this.leftPanel || room === this.rightPanel;
}
if (room.location === 'mini-window') {
return this.leftPanelWidth === 0 ? this.mainmenu === this.panel : this.mainmenu === this.leftPanel;
return !this.leftPanelWidth ? this.mainmenu === this.panel : this.mainmenu === this.leftPanel;
}
// some kind of popup
return true;
@ -1470,6 +1487,15 @@ export const PS = new class extends PSModel {
this.update();
}
isPopup(room: PSRoom | undefined | null) {
if (!room) return false;
return room.location === 'popup' || room.location === 'semimodal-popup' || room.location === 'modal-popup';
}
isNormalRoom(room: PSRoom | undefined | null) {
if (!room) return false;
return room.location === 'left' || room.location === 'right' ||
(room.location === 'mini-window' && PS.leftPanelWidth === null);
}
moveRoom(room: PSRoom, location: PSRoomLocation, background?: boolean, index?: number) {
if (room.location === location && index === undefined) {
if (background === true) {
@ -1486,7 +1512,7 @@ export const PS = new class extends PSModel {
return;
}
const POPUPS = ['popup', 'semimodal-popup', 'modal-popup'];
if (POPUPS.includes(room.location) && POPUPS.includes(location)) {
if (this.isPopup(room) && POPUPS.includes(location)) {
room.location = location;
return;
}
@ -1548,6 +1574,7 @@ export const PS = new class extends PSModel {
if (location === 'left') this.leftPanel = this.panel = room;
if (location === 'right') this.rightPanel = this.panel = room;
if (location === 'mini-window') this.leftPanel = this.panel = this.mainmenu;
if (location === 'mini-window' && PS.leftPanelWidth === null) this.leftPanel = this.panel = room;
this.room = room;
}
}
@ -1599,6 +1626,13 @@ export const PS = new class extends PSModel {
this.leave(this.popups[this.popups.length - 1]);
if (!skipUpdate) this.update();
}
closeAllPopups(skipUpdate?: boolean) {
// this is more sensibly a while-loop, but I don't want accidental infinite loops
for (let i = this.popups.length - 1; i >= 0; i--) {
this.leave(this.popups[i]);
}
if (!skipUpdate) this.update();
}
join(roomid: RoomID, options?: Partial<RoomOptions> | null, noFocus?: boolean) {
if (this.room.id === roomid) return;
if (PS.rooms[roomid]) {

View File

@ -311,19 +311,35 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
override componentDidMount() {
const room = this.props.room;
const $elem = $(this.base!);
const battle = room.battle || new Battle({
const battle = (room.battle ||= new Battle({
id: room.id as any,
$frame: $elem.find('.battle'),
$logFrame: $elem.find('.battle-log'),
log: room.backlog?.map(args => '|' + args.join('|')),
});
}));
const scene = battle.scene as BattleScene;
room.backlog = null;
room.battle ||= battle;
room.log ||= battle.scene.log;
(battle.scene as BattleScene).tooltips.listen($elem.find('.battle-controls'));
room.log ||= scene.log;
scene.tooltips.listen($elem.find('.battle-controls-container'));
scene.tooltips.listen(scene.log.elem);
super.componentDidMount();
battle.seekTurn(Infinity);
battle.subscribe(() => this.forceUpdate());
}
battleHeight = 360;
updateLayout() {
if (!this.base) return;
const room = this.props.room;
const width = this.base.offsetWidth;
if (width && width < 640) {
const scale = (width / 640);
room.battle?.scene.$frame!.css('transform', `scale(${scale})`);
this.battleHeight = Math.round(360 * scale);
} else {
room.battle?.scene.$frame!.css('transform', 'none');
this.battleHeight = 360;
}
}
override receiveLine(args: Args) {
const room = this.props.room;
switch (args[0]) {
@ -371,8 +387,8 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
renderControls() {
const room = this.props.room;
if (!room.battle) return null;
if (room.side) {
return this.renderPlayerControls();
if (room.side && room.request) {
return this.renderPlayerControls(room.request);
}
const atEnd = room.battle.atQueueEnd;
return <div class="controls">
@ -589,11 +605,9 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
}
return buf;
}
renderPlayerControls() {
renderPlayerControls(request: BattleRequest) {
const room = this.props.room;
const request = room.request;
let choices = room.choices;
if (!request) return 'Error: Missing request';
if (!choices) return 'Error: Missing BattleChoiceBuilder';
if (choices.request !== request) {
choices = new BattleChoiceBuilder(request);
@ -746,8 +760,25 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
}
override render() {
const room = this.props.room;
this.updateLayout();
return <PSPanelWrapper room={room} focusClick>
if (room.width < 700) {
return <PSPanelWrapper room={room} focusClick scrollable="hidden">
<BattleDiv room={room} />
<ChatLog
class="battle-log hasuserlist" room={room} top={this.battleHeight} noSubscription
>
<div class="battle-controls" role="complementary" aria-label="Battle Controls">
{this.renderControls()}
</div>
</ChatLog>
<ChatTextEntry room={room} onMessage={this.send} onKey={this.onKey} left={0} />
<ChatUserList room={room} top={this.battleHeight} minimized />
<div class="battle-controls-container"></div>
</PSPanelWrapper>;
}
return <PSPanelWrapper room={room} focusClick scrollable="hidden">
<BattleDiv room={room} />
<ChatLog
class="battle-log hasuserlist" room={room} left={640} noSubscription
@ -756,8 +787,10 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
</ChatLog>
<ChatTextEntry room={room} onMessage={this.send} onKey={this.onKey} left={640} />
<ChatUserList room={room} left={640} minimized />
<div class="battle-controls" role="complementary" aria-label="Battle Controls" style="top: 370px;">
{this.renderControls()}
<div class="battle-controls-container">
<div class="battle-controls" role="complementary" aria-label="Battle Controls" style="top: 370px;">
{this.renderControls()}
</div>
</div>
</PSPanelWrapper>;
}

View File

@ -533,7 +533,9 @@ class ChatPanel extends PSRoomPanel<ChatRoom> {
}
}
export class ChatUserList extends preact.Component<{ room: ChatRoom, left?: number, minimized?: boolean }> {
export class ChatUserList extends preact.Component<{
room: ChatRoom, left?: number, top?: number, minimized?: boolean,
}> {
subscription: PSSubscription | null = null;
override state = {
expanded: false,
@ -557,7 +559,7 @@ export class ChatUserList extends preact.Component<{ room: ChatRoom, left?: numb
));
return <ul
class={'userlist' + (this.props.minimized ? (this.state.expanded ? ' userlist-maximized' : ' userlist-minimized') : '')}
style={{ left: this.props.left || 0 }}
style={{ left: this.props.left || 0, top: this.props.top || 0 }}
>
<li class="userlist-count" onClick={this.toggleExpanded}><small>{room.userCount} users</small></li>
{userList.map(([userid, name]) => {

View File

@ -436,6 +436,8 @@ class MainMenuPanel extends PSRoomPanel<MainMenuRoom> {
}
};
renderMiniRooms() {
if (PS.leftPanelWidth === null) return null;
return PS.miniRoomList.map(roomid => {
const room = PS.rooms[roomid]!;
return <div
@ -515,7 +517,7 @@ class MainMenuPanel extends PSRoomPanel<MainMenuRoom> {
override render() {
const onlineButton = ' button' + (PS.isOffline ? ' disabled' : '');
return <PSPanelWrapper room={this.props.room} scrollable>
<div class="mainmenuwrapper" onDragEnter={this.handleDragEnter}>
<div class={`mainmenuwrapper${this.props.room.width < 620 ? ' tiny-layout' : ''}`} onDragEnter={this.handleDragEnter}>
<div class="leftmenu">
<div class="activitymenu">
{this.renderMiniRooms()}

View File

@ -23,7 +23,7 @@ class RoomsPanel extends PSRoomPanel {
static readonly routes = ['rooms'];
static readonly Model = RoomsRoom;
static readonly location = 'right';
static readonly icon = <i class="fa fa-plus" style="margin:7px auto -6px auto"></i>;
static readonly icon = <i class="fa fa-plus rooms-plus"></i>;
hidden = false;
search = '';
lastKeyCode = 0;

View File

@ -10,7 +10,7 @@
*/
import preact from "../js/lib/preact";
import { PS, type RoomID } from "./client-main";
import { PS, type PSRoom, type RoomID } from "./client-main";
import { PSMain } from "./panels";
import type { Battle } from "./battle";
import { BattleLog } from "./battle-log";
@ -77,23 +77,13 @@ export class PSHeader extends preact.Component<{ style: object }> {
PS.dragging = { type: 'room', roomid };
};
renderRoomTab(id: RoomID) {
const room = PS.rooms[id];
if (!room) return null;
const closable = (id === '' || id === 'rooms' ? '' : ' closable');
const cur = PS.isVisible(room) ? ' cur' : '';
const notifying = room.notifications.length ? ' notifying' : room.isSubtleNotifying ? ' subtle-notifying' : '';
static roomInfo(room: PSRoom) {
const RoomType = PS.roomTypes[room.type];
let className = `roomtab button${notifying}${closable}${cur}`;
let icon = RoomType?.icon || <i class="fa fa-file-text-o"></i>;
let title = room.title;
let closeButton = null;
switch (room.type) {
case 'rooms':
title = '';
break;
case 'battle':
let idChunks = id.substr(7).split('-');
let idChunks = room.id.slice(7).split('-');
let formatName;
// TODO: relocate to room implementation
if (idChunks.length <= 1) {
@ -127,6 +117,20 @@ export class PSHeader extends preact.Component<{ style: object }> {
}
break;
}
return { icon, title };
}
renderRoomTab(id: RoomID) {
const room = PS.rooms[id];
if (!room) return null;
const closable = (id === '' || id === 'rooms' ? '' : ' closable');
const cur = PS.isVisible(room) ? ' cur' : '';
const notifying = room.notifications.length ? ' notifying' : room.isSubtleNotifying ? ' subtle-notifying' : '';
let className = `roomtab button${notifying}${closable}${cur}`;
let { icon, title } = PSHeader.roomInfo(room);
if (room.type === 'rooms') title = '';
let closeButton = null;
if (closable) {
closeButton = <button class="closebutton" name="closeRoom" value={id} aria-label="Close">
<i class="fa fa-times-circle"></i>
@ -161,10 +165,58 @@ export class PSHeader extends preact.Component<{ style: object }> {
}
const userColor = window.BattleLog && { color: BattleLog.usernameColor(PS.user.userid) };
return <span class="username" style={userColor}>
<i class="fa fa-user" style="color:#779EC5"></i> <span class="usernametext">{PS.user.name}</span>
<span class="usernametext">{PS.user.name}</span>
</span>;
}
scrollToHeader = () => {
if (window.scrollX > 0) {
window.scrollTo({ left: 0 });
}
};
renderVertical() {
return <div id="header" class="header-vertical" style={this.props.style} onClick={this.scrollToHeader}>
<img
class="logo"
src={`https://${Config.routes.client}/favicon-256.png`}
alt="Pokémon Showdown! (beta)"
width="50" height="50"
/>
<div class="maintabbarbottom"></div>
<div class="tablist"><div class="inner">
<ul>
{this.renderRoomTab(PS.leftRoomList[0])}
</ul>
<ul>
{PS.miniRoomList.map(roomid => this.renderRoomTab(roomid))}
</ul>
<ul>
{PS.leftRoomList.slice(1).map(roomid => this.renderRoomTab(roomid))}
</ul>
<ul class="siderooms">
{PS.rightRoomList.map(roomid => this.renderRoomTab(roomid))}
</ul>
</div></div>
<div class="userbar">
{this.renderUser()} {}
<div style="float:right">
<button class="icon button" name="joinRoom" value="volume" title="Sound" aria-label="Sound">
<i class={PS.prefs.mute ? 'fa fa-volume-off' : 'fa fa-volume-up'}></i>
</button> {}
<button class="icon button" name="joinRoom" value="options" title="Options" aria-label="Options">
<i class="fa fa-cog"></i>
</button>
</div>
</div>
</div>;
}
override render() {
if (PS.leftPanelWidth === null) {
document.documentElement.classList?.add('vertical-header-layout');
return this.renderVertical();
} else {
document.documentElement.classList?.remove('vertical-header-layout');
}
return <div id="header" class="header" style={this.props.style}>
<img
class="logo"
@ -180,7 +232,7 @@ export class PSHeader extends preact.Component<{ style: object }> {
<ul>
{PS.leftRoomList.slice(1).map(roomid => this.renderRoomTab(roomid))}
</ul>
<ul class="siderooms" style={{ float: 'none', marginLeft: PS.leftPanelWidth - 52 }}>
<ul class="siderooms" style={{ float: 'none', marginLeft: Math.max(PS.leftPanelWidth - 52, 0) }}>
{PS.rightRoomList.map(roomid => this.renderRoomTab(roomid))}
</ul>
</div></div>
@ -197,4 +249,14 @@ export class PSHeader extends preact.Component<{ style: object }> {
}
}
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);
return <div class="mini-header" style={{ minWidth: `${minWidth}px` }}>{icon} {title}</div>;
}
}
preact.render(<PSMain />, document.body, document.getElementById('ps-frame')!);

View File

@ -16,7 +16,7 @@ import type { Args } from "./battle-text-parser";
import { BattleTooltips } from "./battle-tooltips";
import type { PSStreamModel, PSSubscription } from "./client-core";
import { PS, type PSRoom, type RoomID } from "./client-main";
import { PSHeader } from "./panel-topbar";
import { PSHeader, PSMiniHeader } from "./panel-topbar";
export class PSRouter {
roomid = '' as RoomID;
@ -212,6 +212,9 @@ export class PSRoomPanel<T extends PSRoom = PSRoom> extends preact.Component<{ r
PS.closePopup();
}
focus() {
// mobile probably
if (document.body.offsetWidth < 500) return;
const autofocus = this.base?.querySelector<HTMLElement>('.autofocus');
autofocus?.focus();
(autofocus as HTMLInputElement)?.select?.();
@ -224,30 +227,31 @@ export class PSRoomPanel<T extends PSRoom = PSRoom> extends preact.Component<{ r
}
export function PSPanelWrapper(props: {
room: PSRoom, children: preact.ComponentChildren, scrollable?: boolean, width?: number | 'auto',
focusClick?: boolean,
room: PSRoom, children: preact.ComponentChildren,
focusClick?: boolean, scrollable?: boolean | 'hidden', width?: number | 'auto',
}) {
const room = props.room;
if (room.location === 'mini-window') {
if (room.location === 'mini-window' && PS.leftPanelWidth !== null) {
if (room.id === 'news') {
return <div id={`room-${room.id}`}>{props.children}</div>;
}
return <div
id={`room-${room.id}`} class={'mini-window-contents ps-room-light' + (props.scrollable ? ' scrollable' : '')}
id={`room-${room.id}`} class={'mini-window-contents ps-room-light' + (props.scrollable === true ? ' scrollable' : '')}
onClick={props.focusClick ? PSMain.focusIfNoSelection : undefined}
>
{props.children}
</div>;
}
if (room.location !== 'left' && room.location !== 'right') {
if (PS.isPopup(room)) {
const style = PSMain.getPopupStyle(room, props.width);
return <div class="ps-popup" id={`room-${room.id}`} style={style}>
{props.children}
</div>;
}
const style = PSMain.posStyle(room);
const style = PSMain.posStyle(room) as any;
if (props.scrollable === 'hidden') style.overflow = 'hidden';
return <div
class={'ps-room' + (room.id === '' ? '' : ' ps-room-light') + (props.scrollable ? ' scrollable' : '')}
class={'ps-room' + (room.id === '' ? '' : ' ps-room-light') + (props.scrollable === true ? ' scrollable' : '')}
id={`room-${room.id}`}
style={style} onClick={props.focusClick ? PSMain.focusIfNoSelection : undefined}
>
@ -260,6 +264,13 @@ export class PSMain extends preact.Component {
super();
PS.subscribe(() => this.forceUpdate());
if (navigator.userAgent.includes(' Safari/')) {
// 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.
document.querySelector('meta[name=viewport]')?.setAttribute('content', 'width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0');
}
window.addEventListener('click', e => {
let elem = e.target as HTMLElement | null;
if (elem?.className === 'ps-overlay') {
@ -300,6 +311,9 @@ export class PSMain extends preact.Component {
parentElem: elem,
location,
});
if (!PS.isPopup(PS.rooms[roomid])) {
PS.closeAllPopups();
}
e.preventDefault();
e.stopImmediatePropagation();
}
@ -343,8 +357,8 @@ export class PSMain extends preact.Component {
}
if (PS.room !== clickedRoom) {
if (clickedRoom) PS.room = clickedRoom;
// eslint-disable-next-line no-unmodified-loop-condition
while (PS.popups.length && (!clickedRoom || clickedRoom.id !== PS.popups[PS.popups.length - 1])) {
for (let i = PS.popups.length - 1; i >= 0; i--) {
if (clickedRoom && clickedRoom.id === PS.popups[i]) break;
PS.closePopup();
}
PS.update();
@ -437,6 +451,17 @@ export class PSMain extends preact.Component {
const room = PS.getRoom(ev.target as HTMLElement, true);
if (!room) return;
if (document.documentElement.scrollWidth > document.documentElement.clientWidth && window.scrollX === 0) {
if (navigator.userAgent.includes(' Safari/') && PS.leftPanelWidth === null) {
// Safari is buggy here and requires temporarily disabling scroll snap
document.documentElement.classList.remove('vertical-header-layout');
window.scrollBy(400, 0);
setTimeout(() => document.documentElement.classList.add('vertical-header-layout'));
} else {
// intentionally around twice as big as necessary
window.scrollBy(400, 0);
}
}
if (window.getSelection?.()?.type === 'Range') return;
ev.preventDefault();
PS.setFocus(room);
@ -486,47 +511,22 @@ export class PSMain extends preact.Component {
BattleTooltips.hideTooltip();
}
static posStyle(room: PSRoom) {
let pos: PanelPosition | null = null;
if (PS.leftPanelWidth === 0) {
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` };
}
} else if (PS.leftPanelWidth === 0) {
// one panel visible
if (room === PS.panel) pos = { top: 56 };
if (room === PS.panel) return {};
} else {
// both panels visible
if (room === PS.leftPanel) pos = { top: 56, right: PS.leftPanelWidth };
if (room === PS.rightPanel) pos = { top: 56, left: PS.leftPanelWidth };
if (room === PS.leftPanel) return { width: `${PS.leftPanelWidth}px`, right: 'auto' };
if (room === PS.rightPanel) return { top: 56, left: PS.leftPanelWidth + 1 };
}
if (!pos) return { display: 'none' };
let top: number | null = (pos.top || 0);
let height: number | null = null;
let bottom: number | null = (pos.bottom || 0);
if (bottom > 0 || top < 0) {
height = bottom - top;
if (height < 0) throw new RangeError("Invalid pos range");
if (top < 0) top = null;
else bottom = null;
}
let left: number | null = (pos.left || 0);
let width: number | null = null;
let right: number | null = (pos.right || 0);
if (right > 0 || left < 0) {
width = right - left - 1;
if (width < 0) throw new RangeError("Invalid pos range");
if (left < 0) left = null;
else right = null;
}
return {
display: 'block',
top: top === null ? `auto` : `${top}px`,
height: height === null ? `auto` : `${height}px`,
bottom: bottom === null ? `auto` : `${-bottom}px`,
left: left === null ? `auto` : `${left}px`,
width: width === null ? `auto` : `${width}px`,
right: right === null ? `auto` : `${-right}px`,
};
return { display: 'none' };
}
static getPopupStyle(room: PSRoom, width?: number | 'auto'): any {
if (room.location === 'modal-popup' || !room.parentElem) {
@ -547,49 +547,68 @@ export class PSMain extends preact.Component {
position: 'absolute',
margin: 0,
};
let offset = room.parentElem.getBoundingClientRect();
let sourceWidth = offset.width;
let sourceHeight = offset.height;
// semimodal popups exist in a fixed-positioned overlay and are
// positioned relative to the overlay (the viewport).
// regular popups are positioned relative to the document root, and so
// need to account for scrolling.
const isFixed = room.location !== 'popup';
const offsetLeft = isFixed ? 0 : window.scrollX;
const offsetTop = isFixed ? 0 : window.scrollY;
const availableWidth = document.documentElement.clientWidth + offsetLeft;
const availableHeight = document.documentElement.clientHeight;
let availableHeight = document.documentElement.clientHeight;
let height = room.height;
const source = room.parentElem.getBoundingClientRect();
const sourceWidth = source.width;
const sourceHeight = source.height;
const sourceTop = source.top + offsetTop;
const sourceLeft = source.left + offsetLeft;
const height = room.height;
width = width || room.width;
if (room.rightPopup) {
if (availableHeight > offset.top + height + 5 &&
(offset.top < availableHeight * 2 / 3 || offset.top + 200 < availableHeight)) {
style.top = offset.top;
} else if (offset.top + sourceHeight >= height) {
style.bottom = Math.max(availableHeight - offset.top - sourceHeight, 0);
if (availableHeight > sourceTop + height + 5 &&
(sourceTop < availableHeight * 2 / 3 || sourceTop + 200 < availableHeight)) {
style.top = sourceTop;
} else if (sourceTop + sourceHeight >= height) {
style.bottom = Math.max(availableHeight - sourceTop - sourceHeight, 0);
} else {
style.top = Math.max(0, availableHeight - height);
}
let offsetLeft = offset.left + sourceWidth;
if (width !== 'auto' && offsetLeft + width > document.documentElement.clientWidth) {
style.right = 1;
const popupLeft = sourceLeft + sourceWidth;
if (width !== 'auto' && popupLeft + width > availableWidth) {
// can't fit, give up and put it in the normal place
style = {
position: 'absolute',
margin: 0,
};
} else {
style.left = offsetLeft;
style.left = popupLeft;
}
} else {
}
if (availableHeight > offset.top + sourceHeight + height + 5 &&
(offset.top + sourceHeight < availableHeight * 2 / 3 || offset.top + sourceHeight + 200 < availableHeight)) {
style.top = offset.top + sourceHeight;
} else if (height + 5 <= offset.top) {
style.bottom = Math.max(availableHeight - offset.top, 0);
if (style.left === undefined) {
if (availableHeight > sourceTop + sourceHeight + height + 5 &&
(sourceTop + sourceHeight < availableHeight * 2 / 3 || sourceTop + sourceHeight + 200 < availableHeight)) {
style.top = sourceTop + sourceHeight;
} else if (height + 5 <= sourceTop) {
style.bottom = Math.max(availableHeight - sourceTop, 0);
} else if (height + 10 < availableHeight) {
style.bottom = 5;
} else {
style.top = 0;
}
let availableWidth = document.documentElement.clientWidth - offset.left;
if (width !== 'auto' && availableWidth < width + 10) {
style.right = 10;
const availableAlignedWidth = availableWidth - sourceLeft;
if (width !== 'auto' && availableAlignedWidth < width + 10) {
// while `right: 10` would be simpler, it doesn't work if there is horizontal scrolling,
// like in the mobile layout
style.left = Math.max(availableWidth - width - 10, offsetLeft);
} else {
style.left = offset.left;
style.left = sourceLeft;
}
}
@ -617,20 +636,19 @@ export class PSMain extends preact.Component {
let rooms = [] as preact.VNode[];
for (const roomid in PS.rooms) {
const room = PS.rooms[roomid]!;
if (room.location === 'left' || room.location === 'right') {
if (PS.isNormalRoom(room)) {
rooms.push(this.renderRoom(room));
}
}
return <div class="ps-frame">
<PSHeader style={{ top: 0, left: 0, right: 0, height: '50px' }} />
<PSHeader style={{}} />
<PSMiniHeader />
{rooms}
{PS.popups.map(roomid => this.renderPopup(PS.rooms[roomid]!))}
</div>;
}
}
type PanelPosition = { top?: number, bottom?: number, left?: number, right?: number } | null;
export function SanitizedHTML(props: { children: string }) {
return <div dangerouslySetInnerHTML={{ __html: BattleLog.sanitizeHTML(props.children) }} />;
}

View File

@ -34,20 +34,21 @@ body {
}
.dark .tabbar a.button,
.dark .tabbar a.button:hover {
.dark .tabbar a.button:hover,
.dark .header-vertical .tablist a.button,
.dark .header-vertical .tablist a.button:hover {
box-shadow: inset 0.5px 1px 1px rgba(255, 255, 255, 0.5);
}
.dark .tabbar a.button:active,
.dark .tabbar a.button.cur,
.dark .tabbar a.button.cur:hover,
.dark .tabbar a.button.cur:active {
.dark .tabbar a.button.cur:active,
.dark .header-vertical .tablist a.button:active,
.dark .header-vertical .tablist a.button.cur,
.dark .header-vertical .tablist a.button.cur:hover,
.dark .header-vertical .tablist a.button.cur:active {
box-shadow: none;
}
.dark .maintabbarbottom {
background: #555555;
border-color: #5A5A5A;
border-top-color: #34373b;
}
pre {
white-space: pre-wrap;
@ -58,6 +59,16 @@ pre {
* Header
*********************************************************/
.vertical-header-layout {
scroll-snap-type: x mandatory;
}
.vertical-header-layout .header-vertical {
scroll-snap-align: start;
}
.vertical-header-layout .mini-header {
scroll-snap-align: end;
}
.header {
position: relative;
height: 50px;
@ -71,6 +82,17 @@ pre {
float: left;
margin: 0;
}
.header-vertical {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 194px;
background: rgba(255,255,255,.3);
}
.dark .header, .dark .header-vertical {
background: rgba(0,0,0,.30);
}
.userbar {
position: absolute;
@ -78,10 +100,22 @@ pre {
right: 12px;
font-weight: bold;
}
.header-vertical .userbar {
right: auto;
top: auto;
left: 12px;
bottom: 12px;
width: 170px;
word-wrap: break-word;
}
.userbar .username {
color: black;
text-shadow: 1px 1px 0 #f8f8f8, 1px -1px 0 #f8f8f8, -1px 1px 0 #f8f8f8, -1px -1px 0 #f8f8f8;
}
.dark .userbar .username {
color: #DDD;
text-shadow: 1px 1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, -1px -1px 0 #000;
}
.username {
cursor: pointer;
}
@ -89,6 +123,9 @@ pre {
.usernametext {
display: none
}
.header-vertical .usernametext {
display: inline;
}
}
.userbar button.icon {
height: 25px;
@ -120,6 +157,11 @@ pre {
line-height: 100%;
}
.mini-header, .maintabbarbottom {
background: #f8f8f8;
border: solid 1px #AAAAAA;
color: black;
}
.maintabbarbottom {
content: "";
display: block;
@ -128,15 +170,58 @@ pre {
right: 0;
bottom: -6px;
height: 6px;
background: #f8f8f8;
border: solid 1px #AAAAAA;
border-left: 0;
border-right: 0;
border-left-width: 0;
border-right-width: 0;
margin: -1px 0 0 0;
-webkit-box-shadow: 0 1px 2px rgba(0,0,0,.2);
-moz-box-shadow: 0 1px 2px rgba(0,0,0,.2);
box-shadow: 0 1px 2px rgba(0,0,0,.2);
}
.mini-header {
position: absolute;
left: 199px;
right: 0;
top: 0;
padding: 0;
height: 24px;
line-height: 24px;
border-left: 0;
border-right: 0;
border-top: 0;
}
.mini-header i.text {
font-size: 12px;
font-style: normal;
border: 1px solid #777;
padding: 1px 3px;
border-radius: 5px;
vertical-align: middle;
margin-top: -2px;
display: inline-block;
line-height: 12px;
}
.header-vertical .maintabbarbottom {
top: 0;
bottom: 0;
left: 194px;
right: auto;
width: 6px;
height: auto;
border-top: 0;
border-bottom: 0;
border-left-width: 1px;
border-right-width: 1px;
margin: 0 0 0 -1px;
}
.dark .mini-header, .dark .maintabbarbottom {
background: #555555;
border-color: #34373b;
color: #CCCCCC;
}
.dark .maintabbarbottom {
border-bottom-color: #5A5A5A;
}
.tabbar.maintabbar {
margin-left: 52px;
margin-right: 165px;
@ -201,6 +286,9 @@ pre {
font-size: 14px;
height: 14px;
}
.tabbar a.button i.rooms-plus {
margin: 7px auto -6px auto;
}
.tabbar a.button span {
display: block;
overflow: hidden;
@ -303,10 +391,11 @@ span.header-username:hover {
padding: 0;
}
.tablist ul {
padding: 3px 0 4px 0;
padding: 3px 0 4px 5px;
}
.tablist li {
margin: 0 -5px -1px -5px;
clear: both;
margin: -1px 0 0 0;
padding: 0;
}
.tablist .button {
@ -331,14 +420,26 @@ span.header-username:hover {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
.header-vertical .tablist li .button {
width: auto;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right-width: 0;
margin: 0 1px 0 2px;
box-shadow: none;
}
.header-vertical .tablist li .button.cur {
margin: 0;
}
.tablist .closebutton {
display: block;
float: right;
margin-top: -28px;
margin-right: 4px;
padding: 5px 0 0 0;
height: 23px;
font-size: 14px;
width: 28px;
width: 24px;
text-align: center;
position: relative;
@ -386,7 +487,7 @@ span.header-username:hover {
}
.ps-overlay {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
@ -550,7 +651,7 @@ p.or:after {
width: 292px;
}
.tiny-layout .mini-window {
margin: 0 0 12px 0;
margin: 0 auto 12px;
}
.mini-window h3 {
background: rgba(248,248,248,.8);
@ -1442,7 +1543,7 @@ pre.textbox.textbox-empty[placeholder]:before {
transform-origin: top left;
-webkit-transform-origin: top left;
}
.ps-room .battle-controls {
.battle-controls {
position: absolute;
top: 370px;
left: 0;
@ -1451,12 +1552,13 @@ pre.textbox.textbox-empty[placeholder]:before {
.battle-chat-toggle {
display: none;
}
.tiny-layout.ps-room .battle-controls {
left: 0;
right: 0;
.battle-log .battle-controls {
top: 0;
position: static;
width: auto;
padding-bottom: 6px;
}
.tiny-layout .movecontrols, .tiny-layout .shiftcontrols, .tiny-layout .switchcontrols {
.battle-log .movecontrols, .battle-log .shiftcontrols, .battle-log .switchcontrols {
max-width: 330px;
margin: 0 auto;
}
@ -1736,6 +1838,11 @@ pre.textbox.textbox-empty[placeholder]:before {
margin-right: -10px;
padding-left: 4px;
}
.switchmenu:after {
content: '';
display: block;
clear: both;
}
.switchmenu button,
.movebutton {
position: relative;
@ -2033,10 +2140,6 @@ pre.textbox.textbox-empty[placeholder]:before {
* Dark mode!
*********************************************************/
.dark .header {
background: rgba(0,0,0,.30);
}
.dark .ps-room.ps-room-light,
.dark .ps-room-light,
.dark .tournament-box,
@ -2087,11 +2190,6 @@ pre.textbox.textbox-empty[placeholder]:before {
backdrop-filter: blur(4px);
}
.dark .userbar .username {
color: #DDD;
text-shadow: 1px 1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, -1px -1px 0 #000;
}
/* popups */
.dark .ps-popup {

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0" />
<title>Showdown!</title>
<link rel="shortcut icon" href="favicon.ico" id="dynamic-favicon" />
<link rel="stylesheet" href="style/battle.css" />