mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(ui): keep the saved sidebar width when a narrower sidebar clamps it
The workspace context panels and Favorites share one stored `sidebar-width` but clamp it to different limits. Loading wrote the clamped value back, so visiting Settings (max 400) cut a categories panel widened to 520 down to 400; the saved width could only shrink. Load now clamps for rendering only. Legacy alias keys migrate their raw value, an unparseable value falls back to the default without a write, and only a drag that changes the width persists — a click on the handle no longer saves the clamped width either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c8f5028c7
commit
6edec0fc76
5 files changed
+324
-30
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
A sidebar you widen now keeps its width after you visit a page with a narrower sidebar, such as Settings. Opening that page no longer saves its smaller width over yours.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* The workspace context panels share one stored width but clamp it to their
|
||||
* own limits. Opening a narrower panel used to save its clamped width, so a
|
||||
* categories panel widened past Settings' 400px maximum came back at 400.
|
||||
*/
|
||||
|
||||
const SHARED_WIDTH_KEY = 'sidebar-width';
|
||||
|
||||
/** The CSS width the directive sets; the panels are content-box. */
|
||||
function renderedWidth(panel: Locator): Promise<number> {
|
||||
return panel.evaluate((el) => parseFloat(getComputedStyle(el).width));
|
||||
}
|
||||
|
||||
function storedWidth(page: Page): Promise<string | null> {
|
||||
return page.evaluate((key) => localStorage.getItem(key), SHARED_WIDTH_KEY);
|
||||
}
|
||||
|
||||
async function dragPanelTo(
|
||||
page: Page,
|
||||
panel: Locator,
|
||||
width: number
|
||||
): Promise<void> {
|
||||
const handle = panel.locator(':scope > .resize-handle');
|
||||
const box = await handle.boundingBox();
|
||||
if (!box) throw new Error('The panel has no resize handle');
|
||||
const startX = Math.round(box.x + box.width / 2);
|
||||
const y = Math.round(box.y + box.height / 2);
|
||||
// The directive starts a drag from offsetWidth (border box included).
|
||||
const delta = width - (await panel.evaluate((el) => el.offsetWidth));
|
||||
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + delta, y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe('Electron shared sidebar width', () => {
|
||||
test('@persistence @electron keeps a widened categories panel after Settings clamps it', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await addXtreamPortal(page);
|
||||
await openWorkspaceSection(page, 'Movies');
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
const categories = page.locator('aside.context-panel--route');
|
||||
await expect(categories).toBeVisible();
|
||||
const categoriesUrl = page.url();
|
||||
|
||||
await dragPanelTo(page, categories, 520);
|
||||
await expect.poll(() => renderedWidth(categories)).toBe(520);
|
||||
await expect.poll(() => storedWidth(page)).toBe('520');
|
||||
|
||||
await openSettings(page);
|
||||
const settings = page.locator('aside.context-panel--settings');
|
||||
await expect(settings).toBeVisible();
|
||||
await expect.poll(() => renderedWidth(settings)).toBe(400);
|
||||
expect(await storedWidth(page)).toBe('520');
|
||||
|
||||
await page.goBack();
|
||||
await page.waitForURL(categoriesUrl);
|
||||
await expect(categories).toBeVisible();
|
||||
await expect.poll(() => renderedWidth(categories)).toBe(520);
|
||||
expect(await storedWidth(page)).toBe('520');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -212,6 +212,14 @@ directive-generated `.resize-handle` sets this centrally in `resizable.scss`.
|
||||
The shared live-layout sidebar reserves 8 px at its right edge so the inward
|
||||
half of the 12 px resize handle cannot cover the channel scrollbar.
|
||||
|
||||
## Resizable Sidebar Widths
|
||||
|
||||
Workspace context panels and Favorites share one `sidebar-width` key (its
|
||||
legacy aliases map onto it) but have different limits. `ResizableDirective`
|
||||
renders the stored width clamped to the host's own limits and never writes
|
||||
that clamped value back: only a drag that changes the width persists, so a
|
||||
narrow sidebar such as Settings cannot shrink the width a wider one saved.
|
||||
|
||||
## Keyboard Scrolling and Channel Focus
|
||||
|
||||
`ChannelScrollFocusDirective` belongs on the actual channel scroll owner,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ResizableDirective } from './resizable.directive';
|
||||
|
||||
@Component({
|
||||
imports: [ResizableDirective],
|
||||
template: `
|
||||
<aside
|
||||
appResizable
|
||||
[minWidth]="minWidth"
|
||||
[maxWidth]="maxWidth"
|
||||
[defaultWidth]="defaultWidth"
|
||||
[storageKey]="storageKey"
|
||||
[handlePosition]="handlePosition"
|
||||
></aside>
|
||||
`,
|
||||
})
|
||||
class Host {
|
||||
minWidth = 200;
|
||||
maxWidth = 600;
|
||||
defaultWidth = 300;
|
||||
storageKey = 'sidebar-width';
|
||||
handlePosition: 'right' | 'left' = 'right';
|
||||
}
|
||||
|
||||
describe('ResizableDirective', () => {
|
||||
const fixtures: ComponentFixture<Host>[] = [];
|
||||
|
||||
function render(host: Partial<Host> = {}): HTMLElement {
|
||||
const fixture = TestBed.createComponent(Host);
|
||||
Object.assign(fixture.componentInstance, host);
|
||||
fixture.detectChanges();
|
||||
fixtures.push(fixture);
|
||||
|
||||
const aside: HTMLElement = fixture.nativeElement.querySelector('aside');
|
||||
// jsdom has no layout; a drag starts from the rendered width.
|
||||
Object.defineProperty(aside, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get: () => parseInt(aside.style.width, 10) || 0,
|
||||
});
|
||||
return aside;
|
||||
}
|
||||
|
||||
function drag(aside: HTMLElement, fromX: number, toX: number): void {
|
||||
const handle = aside.querySelector('.resize-handle') as HTMLElement;
|
||||
handle.dispatchEvent(
|
||||
new MouseEvent('mousedown', { clientX: fromX, bubbles: true })
|
||||
);
|
||||
if (toX !== fromX) {
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('mousemove', { clientX: toX, bubbles: true })
|
||||
);
|
||||
}
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('mouseup', { clientX: toX, bubbles: true })
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixtures.splice(0).forEach((fixture) => fixture.destroy());
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('clamps a shared width for rendering without writing it back', () => {
|
||||
localStorage.setItem('sidebar-width', '520');
|
||||
|
||||
const settings = render({ minWidth: 200, maxWidth: 400 });
|
||||
|
||||
expect(settings.style.width).toBe('400px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('520');
|
||||
|
||||
const categories = render({ minWidth: 240, maxWidth: 560 });
|
||||
|
||||
expect(categories.style.width).toBe('520px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('520');
|
||||
});
|
||||
|
||||
it('does not raise a shared width a wider minimum clamps up', () => {
|
||||
localStorage.setItem('sidebar-width', '210');
|
||||
|
||||
const favorites = render({ minWidth: 250, maxWidth: 600 });
|
||||
|
||||
expect(favorites.style.width).toBe('250px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('210');
|
||||
});
|
||||
|
||||
it('reads an alias key from the shared width without writing it', () => {
|
||||
localStorage.setItem('sidebar-width', '520');
|
||||
|
||||
const panel = render({
|
||||
maxWidth: 400,
|
||||
storageKey: 'workspace-settings-panel-width',
|
||||
});
|
||||
|
||||
expect(panel.style.width).toBe('400px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('520');
|
||||
expect(
|
||||
localStorage.getItem('workspace-settings-panel-width')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates the raw legacy width, not the clamped one', () => {
|
||||
localStorage.setItem('downloads-sidebar-width', '520');
|
||||
|
||||
const downloads = render({
|
||||
maxWidth: 400,
|
||||
storageKey: 'downloads-sidebar-width',
|
||||
});
|
||||
|
||||
expect(downloads.style.width).toBe('400px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('520');
|
||||
expect(localStorage.getItem('downloads-sidebar-width')).toBeNull();
|
||||
});
|
||||
|
||||
it('persists the width a user drags to', () => {
|
||||
localStorage.setItem('sidebar-width', '300');
|
||||
const aside = render({ maxWidth: 560 });
|
||||
|
||||
drag(aside, 100, 180);
|
||||
|
||||
expect(aside.style.width).toBe('380px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('380');
|
||||
});
|
||||
|
||||
it('persists a left-handle drag on a key of its own', () => {
|
||||
localStorage.setItem('live-channels-sidebar-width', '400');
|
||||
const aside = render({
|
||||
storageKey: 'live-channels-sidebar-width',
|
||||
handlePosition: 'left',
|
||||
});
|
||||
|
||||
drag(aside, 400, 350);
|
||||
|
||||
expect(aside.style.width).toBe('450px');
|
||||
expect(localStorage.getItem('live-channels-sidebar-width')).toBe('450');
|
||||
expect(localStorage.getItem('sidebar-width')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not persist the clamped width when the handle is only clicked', () => {
|
||||
localStorage.setItem('sidebar-width', '520');
|
||||
const settings = render({ maxWidth: 400 });
|
||||
|
||||
drag(settings, 100, 100);
|
||||
|
||||
expect(settings.style.width).toBe('400px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('520');
|
||||
});
|
||||
|
||||
it('falls back to the default without writing an unparseable width', () => {
|
||||
localStorage.setItem('sidebar-width', 'wide');
|
||||
|
||||
const aside = render({ defaultWidth: 290 });
|
||||
|
||||
expect(aside.style.width).toBe('290px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBe('wide');
|
||||
});
|
||||
|
||||
it('keeps an unparseable legacy width instead of migrating it', () => {
|
||||
localStorage.setItem('downloads-sidebar-width', 'wide');
|
||||
|
||||
const aside = render({
|
||||
defaultWidth: 290,
|
||||
storageKey: 'downloads-sidebar-width',
|
||||
});
|
||||
|
||||
expect(aside.style.width).toBe('290px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBeNull();
|
||||
expect(localStorage.getItem('downloads-sidebar-width')).toBe('wide');
|
||||
});
|
||||
|
||||
it('renders the default without writing when nothing is stored', () => {
|
||||
const aside = render({ defaultWidth: 290 });
|
||||
|
||||
expect(aside.style.width).toBe('290px');
|
||||
expect(localStorage.getItem('sidebar-width')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,11 @@ const SIDEBAR_WIDTH_KEY_ALIASES = new Set([
|
||||
'iptvnator_video_sidebar_width',
|
||||
]);
|
||||
|
||||
function parseStoredWidth(value: string): number | null {
|
||||
const width = parseInt(value, 10);
|
||||
return Number.isNaN(width) ? null : width;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResizableDirective - Makes an element horizontally resizable by dragging its edge.
|
||||
*
|
||||
@@ -72,6 +77,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
|
||||
private isResizing = signal(false);
|
||||
private startX = 0;
|
||||
private startWidth = 0;
|
||||
private widthBeforeResize = 0;
|
||||
private currentWidth = signal(0);
|
||||
|
||||
// Event listener references for cleanup
|
||||
@@ -164,39 +170,41 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.renderer.appendChild(this.el.nativeElement, handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebars sharing a key have different limits, so the stored width is
|
||||
* clamped for rendering only: writing it back would hand every other
|
||||
* sidebar the narrowest (or widest) limit. Only a user resize persists.
|
||||
*/
|
||||
private loadPersistedWidth(): void {
|
||||
const storedWidth = this.readStoredWidth();
|
||||
this.setWidthImmediate(
|
||||
storedWidth === null
|
||||
? this.defaultWidth()
|
||||
: this.clampWidth(storedWidth)
|
||||
);
|
||||
}
|
||||
|
||||
/** The unclamped stored width, migrating a legacy key's raw value. */
|
||||
private readStoredWidth(): number | null {
|
||||
const { key, legacyKeys } = this.resolveStorageKey();
|
||||
let widthToApply = this.defaultWidth();
|
||||
|
||||
if (key) {
|
||||
let stored = localStorage.getItem(key);
|
||||
let sourceLegacyKey: string | null = null;
|
||||
|
||||
if (!stored) {
|
||||
for (const legacyKey of legacyKeys) {
|
||||
const legacyValue = localStorage.getItem(legacyKey);
|
||||
if (legacyValue) {
|
||||
stored = legacyValue;
|
||||
sourceLegacyKey = legacyKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stored) {
|
||||
const width = parseInt(stored, 10);
|
||||
if (!isNaN(width)) {
|
||||
widthToApply = this.clampWidth(width);
|
||||
localStorage.setItem(key, widthToApply.toString());
|
||||
if (sourceLegacyKey && sourceLegacyKey !== key) {
|
||||
localStorage.removeItem(sourceLegacyKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
return parseStoredWidth(stored);
|
||||
}
|
||||
|
||||
// Apply width immediately without transition to avoid flash
|
||||
this.setWidthImmediate(widthToApply);
|
||||
for (const legacyKey of legacyKeys) {
|
||||
const legacyValue = localStorage.getItem(legacyKey);
|
||||
if (!legacyValue) continue;
|
||||
|
||||
const width = parseStoredWidth(legacyValue);
|
||||
if (width !== null) {
|
||||
localStorage.setItem(key, legacyValue);
|
||||
localStorage.removeItem(legacyKey);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Set width immediately without CSS transition */
|
||||
@@ -246,6 +254,7 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.isResizing.set(true);
|
||||
this.startX = clientX;
|
||||
this.startWidth = this.el.nativeElement.offsetWidth;
|
||||
this.widthBeforeResize = this.currentWidth();
|
||||
this.resizeStart.emit();
|
||||
}
|
||||
|
||||
@@ -284,7 +293,11 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
this.isResizing.set(false);
|
||||
this.removeGlobalListeners();
|
||||
this.persistWidth();
|
||||
// A click on the handle is not a resize: persisting would save the
|
||||
// width this sidebar clamped instead of the shared one.
|
||||
if (this.currentWidth() !== this.widthBeforeResize) {
|
||||
this.persistWidth();
|
||||
}
|
||||
this.resizeEnd.emit(this.currentWidth());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user