fix: tighter folder tree, smaller mail list font, smooth resize via DOM ref
- MailFolderTree: reduce padding/gap ~25% (py-2→py-1, gap-2→gap-1.5) - MailList: text-sm→text-xs for from and subject lines - ResizablePanel: use DOM ref for width during drag instead of React state - No re-renders during drag, only single setWidth on mouseup - panelRef on outer div, contentRef on inner div - pointer-events:none on content during drag - contain:strict on content area
This commit is contained in:
@@ -147,7 +147,7 @@ function FolderNode({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onSelect(folder.id)}
|
onClick={() => onSelect(folder.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex-1 flex items-center gap-2 px-2 py-2 md:py-1.5 rounded-md text-sm min-h-touch',
|
'flex-1 flex items-center gap-1.5 px-2 py-1 md:py-1 rounded-md text-sm min-h-touch',
|
||||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-primary-50 text-primary-700 font-medium'
|
? 'bg-primary-50 text-primary-700 font-medium'
|
||||||
|
|||||||
@@ -184,12 +184,12 @@ export function MailList({
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
|
<span className={clsx('text-xs truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
|
||||||
{decodeMimeHeader(mail.from_name) || mail.from_address}
|
{decodeMimeHeader(mail.from_name) || mail.from_address}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={clsx('text-xs md:text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
<p className={clsx('text-xs truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||||
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
|
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
|
||||||
</p>
|
</p>
|
||||||
{mail.labels && mail.labels.length > 0 && (
|
{mail.labels && mail.labels.length > 0 && (
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
* positioned absolutely on the right edge (z-index above scrollbars).
|
* positioned absolutely on the right edge (z-index above scrollbars).
|
||||||
* Scrollable content goes into an inner div so that the scrollbar never
|
* Scrollable content goes into an inner div so that the scrollbar never
|
||||||
* overlaps the drag handle.
|
* overlaps the drag handle.
|
||||||
|
*
|
||||||
|
* Performance: during drag, width is applied directly to the DOM via ref —
|
||||||
|
* no React re-render happens until mouseup. This prevents janky resizing
|
||||||
|
* when panel children (mail list, mail detail) are expensive to render.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
@@ -38,21 +42,25 @@ export function ResizablePanel({
|
|||||||
...rest
|
...rest
|
||||||
}: ResizablePanelProps) {
|
}: ResizablePanelProps) {
|
||||||
const [width, setWidth] = useState(initialWidth);
|
const [width, setWidth] = useState(initialWidth);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const isResizing = useRef(false);
|
const isResizing = useRef(false);
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
const startWidth = useRef(0);
|
const startWidth = useRef(0);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
isResizing.current = true;
|
isResizing.current = true;
|
||||||
setIsDragging(true);
|
|
||||||
startX.current = e.clientX;
|
startX.current = e.clientX;
|
||||||
startWidth.current = width;
|
startWidth.current = width;
|
||||||
document.body.style.cursor = 'col-resize';
|
document.body.style.cursor = 'col-resize';
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
|
// Disable pointer events on content during drag to prevent reflows
|
||||||
|
if (contentRef.current) {
|
||||||
|
contentRef.current.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[width],
|
[width],
|
||||||
);
|
);
|
||||||
@@ -62,15 +70,27 @@ export function ResizablePanel({
|
|||||||
if (!isResizing.current) return;
|
if (!isResizing.current) return;
|
||||||
const delta = e.clientX - startX.current;
|
const delta = e.clientX - startX.current;
|
||||||
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + delta));
|
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + delta));
|
||||||
setWidth(newWidth);
|
// Apply width directly to DOM — no React re-render during drag
|
||||||
|
if (panelRef.current) {
|
||||||
|
panelRef.current.style.width = `${newWidth}px`;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
if (isResizing.current) {
|
if (isResizing.current) {
|
||||||
isResizing.current = false;
|
isResizing.current = false;
|
||||||
setIsDragging(false);
|
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
|
if (contentRef.current) {
|
||||||
|
contentRef.current.style.pointerEvents = '';
|
||||||
|
}
|
||||||
|
// Sync final width to React state (single re-render on mouseup)
|
||||||
|
if (panelRef.current) {
|
||||||
|
const finalWidth = panelRef.current.style.width;
|
||||||
|
if (finalWidth) {
|
||||||
|
setWidth(parseInt(finalWidth));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +105,7 @@ export function ResizablePanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={panelRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
resizable ? 'relative flex-shrink-0 flex flex-col' : 'relative flex-1 flex flex-col',
|
resizable ? 'relative flex-shrink-0 flex flex-col' : 'relative flex-1 flex flex-col',
|
||||||
className,
|
className,
|
||||||
@@ -94,11 +115,10 @@ export function ResizablePanel({
|
|||||||
>
|
>
|
||||||
{/* Inner scrollable content area — scrollbar stays inside, never overlaps handle */}
|
{/* Inner scrollable content area — scrollbar stays inside, never overlaps handle */}
|
||||||
<div
|
<div
|
||||||
|
ref={contentRef}
|
||||||
className="flex-1 overflow-y-auto overflow-x-hidden min-h-0"
|
className="flex-1 overflow-y-auto overflow-x-hidden min-h-0"
|
||||||
style={{
|
style={{
|
||||||
contain: 'strict',
|
contain: 'strict',
|
||||||
pointerEvents: isDragging ? 'none' : 'auto',
|
|
||||||
willChange: isDragging ? 'width' : undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user