Let the sidebar be resized by dragging its edge
The sidebar's right edge is now a splitter, like the one between the message list and the reading pane: drag it between 240 and 480px, move it with the arrow keys, double-click to put it back. The width is a device setting, and stays null until someone drags, so a width set in the reader's own CSS through --sidebar-w is kept until they choose otherwise. Hidden on a phone, where the sidebar is a drawer, and while collapsed. Arrow keys on either splitter moved the pane and never saved it: the keyboard path called onResize without onEnd. It ends each key press now, and both views keep the in-progress size in a ref as well as state, so the end reads the value set in the same tick. The message-list splitter's accessible name was an untranslated literal. It goes through translate() now, and it and the sidebar's new name are in all nine catalogues. Closes #345
This commit is contained in:
+5
-3
@@ -71,9 +71,11 @@ per-account shape.
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Three panes: folder tree, message list, reading pane. The splitter between the
|
Three panes: folder tree, message list, reading pane. The splitter between the
|
||||||
list and the reading pane is dragged to resize, and the size is remembered per
|
list and the reading pane is dragged to resize, and so is the edge of the
|
||||||
device — a width chosen on a 27" monitor is wrong on a laptop, so it is one of
|
sidebar, between 240 and 480px; arrow keys move either one, and a double-click
|
||||||
the few settings that does not follow the account.
|
puts it back. The sizes are remembered per device — a width chosen on a 27"
|
||||||
|
monitor is wrong on a laptop, so they are among the few settings that do not
|
||||||
|
follow the account.
|
||||||
|
|
||||||
- **Reading pane** right of the list, below it, or off (messages open full width).
|
- **Reading pane** right of the list, below it, or off (messages open full width).
|
||||||
- **Density** comfortable, cozy or compact, which changes row height as well as padding.
|
- **Density** comfortable, cozy or compact, which changes row height as well as padding.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("which settings follow the account", () => {
|
|||||||
const synced = syncedPart(DEFAULT_SETTINGS);
|
const synced = syncedPart(DEFAULT_SETTINGS);
|
||||||
// A pane width picked on a monitor is wrong on a laptop, and the
|
// A pane width picked on a monitor is wrong on a laptop, and the
|
||||||
// notification toggles track a per-browser permission grant.
|
// notification toggles track a per-browser permission grant.
|
||||||
for (const key of ["listPaneWidth", "listPaneHeight", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) {
|
for (const key of ["listPaneWidth", "listPaneHeight", "sidebarWidth", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) {
|
||||||
expect(synced, key).not.toHaveProperty(key);
|
expect(synced, key).not.toHaveProperty(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1212,6 +1212,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "Maximieren",
|
"Maximize": "Maximieren",
|
||||||
"Full screen": "Vollbild",
|
"Full screen": "Vollbild",
|
||||||
"Resize panes": "Bereichsgröße ändern",
|
"Resize panes": "Bereichsgröße ändern",
|
||||||
|
"Resize message list": "Größe der Nachrichtenliste ändern",
|
||||||
|
"Resize sidebar": "Größe der Seitenleiste ändern",
|
||||||
"QR code": "QR-Code",
|
"QR code": "QR-Code",
|
||||||
"Draft discarded": "Entwurf verworfen",
|
"Draft discarded": "Entwurf verworfen",
|
||||||
"Draft saved": "Entwurf gespeichert",
|
"Draft saved": "Entwurf gespeichert",
|
||||||
|
|||||||
@@ -1185,6 +1185,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "Maximizar",
|
"Maximize": "Maximizar",
|
||||||
"Full screen": "Pantalla completa",
|
"Full screen": "Pantalla completa",
|
||||||
"Resize panes": "Cambiar el tamaño de los paneles",
|
"Resize panes": "Cambiar el tamaño de los paneles",
|
||||||
|
"Resize message list": "Cambiar el tamaño de la lista de mensajes",
|
||||||
|
"Resize sidebar": "Cambiar el tamaño de la barra lateral",
|
||||||
"QR code": "Código QR",
|
"QR code": "Código QR",
|
||||||
"Draft discarded": "Borrador descartado",
|
"Draft discarded": "Borrador descartado",
|
||||||
"Draft saved": "Borrador guardado",
|
"Draft saved": "Borrador guardado",
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "Agrandir",
|
"Maximize": "Agrandir",
|
||||||
"Full screen": "Plein écran",
|
"Full screen": "Plein écran",
|
||||||
"Resize panes": "Redimensionner les volets",
|
"Resize panes": "Redimensionner les volets",
|
||||||
|
"Resize message list": "Redimensionner la liste des messages",
|
||||||
|
"Resize sidebar": "Redimensionner la barre latérale",
|
||||||
"QR code": "QR code",
|
"QR code": "QR code",
|
||||||
"Draft discarded": "Brouillon abandonné",
|
"Draft discarded": "Brouillon abandonné",
|
||||||
"Draft saved": "Brouillon enregistré",
|
"Draft saved": "Brouillon enregistré",
|
||||||
|
|||||||
@@ -1193,6 +1193,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "最大化",
|
"Maximize": "最大化",
|
||||||
"Full screen": "全画面",
|
"Full screen": "全画面",
|
||||||
"Resize panes": "ペインの大きさを変更",
|
"Resize panes": "ペインの大きさを変更",
|
||||||
|
"Resize message list": "メール一覧の大きさを変更",
|
||||||
|
"Resize sidebar": "サイドバーの大きさを変更",
|
||||||
"QR code": "QR コード",
|
"QR code": "QR コード",
|
||||||
"Draft discarded": "下書きを破棄しました",
|
"Draft discarded": "下書きを破棄しました",
|
||||||
"Draft saved": "下書きを保存しました",
|
"Draft saved": "下書きを保存しました",
|
||||||
|
|||||||
@@ -1181,6 +1181,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "Maximaliseren",
|
"Maximize": "Maximaliseren",
|
||||||
"Full screen": "Volledig scherm",
|
"Full screen": "Volledig scherm",
|
||||||
"Resize panes": "Vensterdelen verslepen",
|
"Resize panes": "Vensterdelen verslepen",
|
||||||
|
"Resize message list": "Grootte van de berichtenlijst wijzigen",
|
||||||
|
"Resize sidebar": "Grootte van de zijbalk wijzigen",
|
||||||
"QR code": "QR-code",
|
"QR code": "QR-code",
|
||||||
"Draft discarded": "Concept weggegooid",
|
"Draft discarded": "Concept weggegooid",
|
||||||
"Draft saved": "Concept opgeslagen",
|
"Draft saved": "Concept opgeslagen",
|
||||||
|
|||||||
@@ -1188,6 +1188,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "Maximizar",
|
"Maximize": "Maximizar",
|
||||||
"Full screen": "Tela cheia",
|
"Full screen": "Tela cheia",
|
||||||
"Resize panes": "Redimensionar os painéis",
|
"Resize panes": "Redimensionar os painéis",
|
||||||
|
"Resize message list": "Redimensionar a lista de mensagens",
|
||||||
|
"Resize sidebar": "Redimensionar a barra lateral",
|
||||||
"QR code": "Código QR",
|
"QR code": "Código QR",
|
||||||
"Draft discarded": "Rascunho descartado",
|
"Draft discarded": "Rascunho descartado",
|
||||||
"Draft saved": "Rascunho salvo",
|
"Draft saved": "Rascunho salvo",
|
||||||
|
|||||||
@@ -1187,6 +1187,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "Развернуть",
|
"Maximize": "Развернуть",
|
||||||
"Full screen": "Во весь экран",
|
"Full screen": "Во весь экран",
|
||||||
"Resize panes": "Изменить размер панелей",
|
"Resize panes": "Изменить размер панелей",
|
||||||
|
"Resize message list": "Изменить размер списка писем",
|
||||||
|
"Resize sidebar": "Изменить размер боковой панели",
|
||||||
"QR code": "QR-код",
|
"QR code": "QR-код",
|
||||||
"Draft discarded": "Черновик удалён",
|
"Draft discarded": "Черновик удалён",
|
||||||
"Draft saved": "Черновик сохранён",
|
"Draft saved": "Черновик сохранён",
|
||||||
|
|||||||
@@ -1181,6 +1181,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "Розгорнути",
|
"Maximize": "Розгорнути",
|
||||||
"Full screen": "На весь екран",
|
"Full screen": "На весь екран",
|
||||||
"Resize panes": "Змінити розмір панелей",
|
"Resize panes": "Змінити розмір панелей",
|
||||||
|
"Resize message list": "Змінити розмір списку листів",
|
||||||
|
"Resize sidebar": "Змінити розмір бічної панелі",
|
||||||
"QR code": "QR-код",
|
"QR code": "QR-код",
|
||||||
"Draft discarded": "Чернетку видалено",
|
"Draft discarded": "Чернетку видалено",
|
||||||
"Draft saved": "Чернетку збережено",
|
"Draft saved": "Чернетку збережено",
|
||||||
|
|||||||
@@ -1192,6 +1192,8 @@ export const catalog: Catalog = {
|
|||||||
"Maximize": "最大化",
|
"Maximize": "最大化",
|
||||||
"Full screen": "全屏",
|
"Full screen": "全屏",
|
||||||
"Resize panes": "调整窗格大小",
|
"Resize panes": "调整窗格大小",
|
||||||
|
"Resize message list": "调整邮件列表大小",
|
||||||
|
"Resize sidebar": "调整侧边栏大小",
|
||||||
"QR code": "二维码",
|
"QR code": "二维码",
|
||||||
"Draft discarded": "草稿已放弃",
|
"Draft discarded": "草稿已放弃",
|
||||||
"Draft saved": "草稿已保存",
|
"Draft saved": "草稿已保存",
|
||||||
|
|||||||
@@ -256,6 +256,13 @@ export interface Settings {
|
|||||||
listPaneWidth: number;
|
listPaneWidth: number;
|
||||||
/** Height (px) of the message list when the reading pane is below. */
|
/** Height (px) of the message list when the reading pane is below. */
|
||||||
listPaneHeight: number;
|
listPaneHeight: number;
|
||||||
|
/**
|
||||||
|
* Width (px) of the sidebar, dragged by its edge (#345). Null until someone
|
||||||
|
* drags it, and null again after a double-click resets it -- which leaves the
|
||||||
|
* width to the stylesheet's `--sidebar-w`, so a reader who already widens the
|
||||||
|
* sidebar with their own CSS keeps what they had until they choose otherwise.
|
||||||
|
*/
|
||||||
|
sidebarWidth: number | null;
|
||||||
/** Outlook-style colour categories for calendar events. */
|
/** Outlook-style colour categories for calendar events. */
|
||||||
eventCategories: Array<{ name: string; color: string }>;
|
eventCategories: Array<{ name: string; color: string }>;
|
||||||
/** Default sending identity per account (JMAP has no such flag). */
|
/** Default sending identity per account (JMAP has no such flag). */
|
||||||
@@ -370,6 +377,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
sendAndArchive: false,
|
sendAndArchive: false,
|
||||||
listPaneWidth: 520,
|
listPaneWidth: 520,
|
||||||
listPaneHeight: 340,
|
listPaneHeight: 340,
|
||||||
|
sidebarWidth: null,
|
||||||
eventCategories: [
|
eventCategories: [
|
||||||
{ name: "Important", color: "#dc2626" },
|
{ name: "Important", color: "#dc2626" },
|
||||||
{ name: "Work", color: "#2563eb" },
|
{ name: "Work", color: "#2563eb" },
|
||||||
@@ -400,6 +408,7 @@ export const DEVICE_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>(
|
|||||||
"notificationSound",
|
"notificationSound",
|
||||||
"listPaneWidth",
|
"listPaneWidth",
|
||||||
"listPaneHeight",
|
"listPaneHeight",
|
||||||
|
"sidebarWidth",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** The part of the settings that is written to the account's settings file. */
|
/** The part of the settings that is written to the account's settings file. */
|
||||||
|
|||||||
+10
-1
@@ -1282,8 +1282,17 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
@keyframes push-pulse { 50% { opacity: .45; } }
|
@keyframes push-pulse { 50% { opacity: .45; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } }
|
||||||
|
|
||||||
.app-body { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 0; transition: grid-template-columns .2s var(--ease); }
|
.app-body { position: relative; display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 0; transition: grid-template-columns .2s var(--ease); }
|
||||||
.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); }
|
.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); }
|
||||||
|
/* A drag follows the pointer; the ease is for the collapse toggle, and applied
|
||||||
|
to every frame of a drag it leaves the edge trailing behind the hand. */
|
||||||
|
.app-body.resizing, .app-body.resizing > .sidebar-splitter { transition: none; }
|
||||||
|
/* The sidebar edge (#345). Laid over the seam rather than given a grid column:
|
||||||
|
the sidebar and the content meet at the content's own border, and a 6px
|
||||||
|
bar of border colour between them would draw a line the design never had.
|
||||||
|
Out of the flow, it shows only on hover and focus, like the list splitter. */
|
||||||
|
.app-body > .sidebar-splitter { position: absolute; top: 0; bottom: 0; left: calc(var(--sidebar-w) - 3px); background: transparent; transition: left .2s var(--ease); }
|
||||||
|
.app-body > .sidebar-splitter:hover, .app-body > .sidebar-splitter:focus-visible { background: var(--accent); }
|
||||||
.sidebar { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px 8px 8px; gap: 2px; overflow: hidden; }
|
.sidebar { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px 8px 8px; gap: 2px; overflow: hidden; }
|
||||||
.sidebar-scroll { overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; padding-bottom: 8px; }
|
.sidebar-scroll { overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; padding-bottom: 8px; }
|
||||||
.compose-btn { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px 0 18px; margin: 6px 4px 12px; border-radius: 16px; background: var(--bg-elev); box-shadow: var(--shadow-1); font-weight: 600; font-size: 1em; color: var(--fg); transition: box-shadow .15s, transform .05s, background .12s; white-space: nowrap; }
|
.compose-btn { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px 0 18px; margin: 6px 4px 12px; border-radius: 16px; background: var(--bg-elev); box-shadow: var(--shadow-1); font-weight: 600; font-size: 1em; color: var(--fg); transition: box-shadow .15s, transform .05s, background .12s; white-space: nowrap; }
|
||||||
|
|||||||
+10
-4
@@ -7,15 +7,16 @@ interface Props {
|
|||||||
onEnd?: () => void;
|
onEnd?: () => void;
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drag handle between two panes. Calls onResize with the pointer delta since the last event. */
|
/** Drag handle between two panes. Calls onResize with the pointer delta since the last event. */
|
||||||
export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Props) {
|
export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel, className }: Props) {
|
||||||
const last = useRef(0);
|
const last = useRef(0);
|
||||||
const active = useRef(false);
|
const active = useRef(false);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`splitter ${direction}`}
|
className={`splitter ${direction}${className ? ` ${className}` : ""}`}
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
|
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
|
||||||
aria-label={ariaLabel ?? t("Resize panes")}
|
aria-label={ariaLabel ?? t("Resize panes")}
|
||||||
@@ -47,8 +48,13 @@ export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Pro
|
|||||||
onEnd?.();
|
onEnd?.();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") onResize(-24);
|
const step = e.key === "ArrowLeft" || e.key === "ArrowUp" ? -24 : e.key === "ArrowRight" || e.key === "ArrowDown" ? 24 : 0;
|
||||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") onResize(24);
|
if (!step) return;
|
||||||
|
e.preventDefault();
|
||||||
|
// A key press is a whole drag in one: without the end, the size moved on
|
||||||
|
// screen and was never saved.
|
||||||
|
onResize(step);
|
||||||
|
onEnd?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="splitter-grip" />
|
<span className="splitter-grip" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
@@ -10,6 +10,7 @@ import { useMail } from "@/store/mail";
|
|||||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
|
import { Splitter } from "@/ui/Splitter";
|
||||||
import { SearchBar } from "./SearchBar";
|
import { SearchBar } from "./SearchBar";
|
||||||
import { MailboxTree } from "./mail/MailboxTree";
|
import { MailboxTree } from "./mail/MailboxTree";
|
||||||
import { FilesTree } from "./files/FilesTree";
|
import { FilesTree } from "./files/FilesTree";
|
||||||
@@ -25,6 +26,16 @@ import { hasAdministration } from "@/lib/adminAccess";
|
|||||||
import { usePermissions } from "./admin/usePermissions";
|
import { usePermissions } from "./admin/usePermissions";
|
||||||
import { AdminNav } from "./admin/AdminNav";
|
import { AdminNav } from "./admin/AdminNav";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* How far the sidebar edge can be dragged. Below about 228px the module bar
|
||||||
|
* cuts "Calendar" and "Contacts" short in English; the floor sits a little
|
||||||
|
* above that. Long folder names are allowed to ellipsise -- narrowing the pane
|
||||||
|
* is asking for that. The ceiling keeps a list and a reading pane beside it on
|
||||||
|
* an ordinary laptop screen.
|
||||||
|
*/
|
||||||
|
const SIDEBAR_MIN = 240;
|
||||||
|
const SIDEBAR_MAX = 480;
|
||||||
|
|
||||||
const PUSH_LABEL = {
|
const PUSH_LABEL = {
|
||||||
connected: "Live updates connected",
|
connected: "Live updates connected",
|
||||||
connecting: "Live updates reconnecting…",
|
connecting: "Live updates reconnecting…",
|
||||||
@@ -35,7 +46,19 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
||||||
|
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
|
||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
|
/*
|
||||||
|
* The width while a drag is in progress, kept here and written to settings
|
||||||
|
* once on release -- the same arrangement as the message-list splitter, so a
|
||||||
|
* drag is a re-render per frame and not a localStorage write per frame.
|
||||||
|
*/
|
||||||
|
const [liveSidebarWidth, setLiveSidebarWidth] = useState<number | null>(null);
|
||||||
|
// The same value, readable in the same tick it was set: a key press resizes
|
||||||
|
// and ends in one go, before any render could hand the state back.
|
||||||
|
const liveSidebarRef = useRef<number | null>(null);
|
||||||
|
const sidebarRef = useRef<HTMLElement>(null);
|
||||||
|
const shownSidebarWidth = liveSidebarWidth ?? sidebarWidth;
|
||||||
const [drawer, setDrawer] = useState(false);
|
const [drawer, setDrawer] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const openCompose = useCompose((s) => s.open);
|
const openCompose = useCompose((s) => s.open);
|
||||||
@@ -185,9 +208,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
<div
|
||||||
|
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||||
|
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
||||||
|
>
|
||||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||||
<aside className={`sidebar ${drawer ? "open" : ""}`}>
|
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
||||||
{/*
|
{/*
|
||||||
The way back out.
|
The way back out.
|
||||||
|
|
||||||
@@ -240,6 +266,34 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
{/* Not on a phone, where the sidebar is a drawer over the page, and not
|
||||||
|
while collapsed to icons, where there is no width to choose. */}
|
||||||
|
{!isMobile && !collapsed && (
|
||||||
|
<Splitter
|
||||||
|
direction="vertical"
|
||||||
|
className="sidebar-splitter"
|
||||||
|
ariaLabel={t("Resize sidebar")}
|
||||||
|
onResize={(delta) => {
|
||||||
|
// From the setting once there is one. Before that it is null and says
|
||||||
|
// nothing about a width set in the reader's own CSS, so the first
|
||||||
|
// drag starts from what is on screen. Not always from the screen:
|
||||||
|
// the width eases, and a second key press lands mid-transition,
|
||||||
|
// where the measured width is still the old one.
|
||||||
|
const start = liveSidebarRef.current ?? useSettings.getState().settings.sidebarWidth ?? sidebarRef.current?.getBoundingClientRect().width ?? 256;
|
||||||
|
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth - 600));
|
||||||
|
const next = Math.round(Math.min(max, Math.max(SIDEBAR_MIN, start + delta)));
|
||||||
|
liveSidebarRef.current = next;
|
||||||
|
setLiveSidebarWidth(next);
|
||||||
|
}}
|
||||||
|
onEnd={() => {
|
||||||
|
const width = liveSidebarRef.current;
|
||||||
|
liveSidebarRef.current = null;
|
||||||
|
setLiveSidebarWidth(null);
|
||||||
|
if (width != null) update({ sidebarWidth: width });
|
||||||
|
}}
|
||||||
|
onReset={() => update({ sidebarWidth: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/*
|
{/*
|
||||||
Scoped to the content, not the shell. If Chrome's translator breaks a
|
Scoped to the content, not the shell. If Chrome's translator breaks a
|
||||||
message list, the top bar, the folder tree and any open composer are
|
message list, the top bar, the folder tree and any open composer are
|
||||||
|
|||||||
@@ -385,18 +385,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
const layoutRef = useRef<HTMLDivElement>(null);
|
const layoutRef = useRef<HTMLDivElement>(null);
|
||||||
const updateSettings = useSettings((s) => s.update);
|
const updateSettings = useSettings((s) => s.update);
|
||||||
const [liveSize, setLiveSize] = useState<number | null>(null);
|
const [liveSize, setLiveSize] = useState<number | null>(null);
|
||||||
|
// Mirrors liveSize for the end of a key press, which follows the resize in
|
||||||
|
// the same tick -- before the state could have come back round.
|
||||||
|
const liveSizeRef = useRef<number | null>(null);
|
||||||
const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth);
|
const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth);
|
||||||
const onSplit = (delta: number) => {
|
const onSplit = (delta: number) => {
|
||||||
const el = layoutRef.current;
|
const el = layoutRef.current;
|
||||||
const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200;
|
const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200;
|
||||||
const min = settings.readingPane === "bottom" ? 160 : 320;
|
const min = settings.readingPane === "bottom" ? 160 : 320;
|
||||||
const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420));
|
const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420));
|
||||||
setLiveSize((cur) => Math.min(max, Math.max(min, (cur ?? paneSize) + delta)));
|
const next = Math.min(max, Math.max(min, (liveSizeRef.current ?? paneSize) + delta));
|
||||||
|
liveSizeRef.current = next;
|
||||||
|
setLiveSize(next);
|
||||||
};
|
};
|
||||||
const onSplitEnd = () => {
|
const onSplitEnd = () => {
|
||||||
if (liveSize == null) return;
|
const size = liveSizeRef.current;
|
||||||
updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: liveSize } : { listPaneWidth: liveSize });
|
liveSizeRef.current = null;
|
||||||
setLiveSize(null);
|
setLiveSize(null);
|
||||||
|
if (size != null) updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: size } : { listPaneWidth: size });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -416,7 +422,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showList && showReading && settings.readingPane !== "off" && !narrow && (
|
{showList && showReading && settings.readingPane !== "off" && !narrow && (
|
||||||
<Splitter direction={settings.readingPane === "bottom" ? "horizontal" : "vertical"} onResize={onSplit} onEnd={onSplitEnd} onReset={() => updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel="Resize message list" />
|
<Splitter direction={settings.readingPane === "bottom" ? "horizontal" : "vertical"} onResize={onSplit} onEnd={onSplitEnd} onReset={() => updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel={translate("Resize message list")} />
|
||||||
)}
|
)}
|
||||||
{showReading && (
|
{showReading && (
|
||||||
<div className="mail-reading-pane">
|
<div className="mail-reading-pane">
|
||||||
|
|||||||
Reference in New Issue
Block a user