Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions assets/css/dock.css
Original file line number Diff line number Diff line change
Expand Up @@ -1426,3 +1426,29 @@
transition: none;
}
}

@media ( max-width: 480px ), ( max-height: 400px ) {
.os-dock[ data-os-dock-placement="bottom" ] {
padding: 3px 6px;
margin-bottom: 4px;
border-radius: 12px;
gap: 3px;
}

.os-dock__item {
width: 30px;
}

.os-dock__item-primary {
width: 30px;
height: 30px;
border-radius: 6px;
}

.os-dock__item-primary svg,
.os-dock__item-primary .dashicons {
width: 16px;
height: 16px;
font-size: 16px;
}
}
19 changes: 13 additions & 6 deletions src/boot/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,24 @@ export function clampGeometryToViewport(
): { x: number; y: number; width: number; height: number } {
const originX = rect.x ?? 0;
const originY = rect.y ?? 0;
const maxW = Math.max( 200, rect.width - VIEWPORT_CLAMP_MARGIN * 2 );
const maxH = Math.max( 200, rect.height - VIEWPORT_CLAMP_MARGIN * 2 );

const marginX = rect.width > VIEWPORT_CLAMP_MARGIN * 2 ? VIEWPORT_CLAMP_MARGIN : 0;
const marginY = rect.height > VIEWPORT_CLAMP_MARGIN * 2 ? VIEWPORT_CLAMP_MARGIN : 0;

const availableW = Math.max( 40, rect.width - marginX * 2 );
const availableH = Math.max( 40, rect.height - marginY * 2 );

const maxW = Math.min( rect.width, availableW );
const maxH = Math.min( rect.height, availableH );

const width = Math.min( win.width, maxW );
const height = Math.min( win.height, maxH );

const maxX = originX + Math.max( 0, rect.width - width - VIEWPORT_CLAMP_MARGIN );
const maxY = originY + Math.max( 0, rect.height - height - VIEWPORT_CLAMP_MARGIN );
const maxX = originX + Math.max( 0, rect.width - width - marginX );
const maxY = originY + Math.max( 0, rect.height - height - marginY );

const x = Math.max( originX + VIEWPORT_CLAMP_MARGIN, Math.min( win.x, maxX ) );
const y = Math.max( originY + VIEWPORT_CLAMP_MARGIN, Math.min( win.y, maxY ) );
const x = Math.max( originX + marginX, Math.min( win.x, maxX ) );
const y = Math.max( originY + marginY, Math.min( win.y, maxY ) );

return { x, y, width, height };
}
2 changes: 1 addition & 1 deletion src/release-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const CLOSE_ICON = osIconSvg( 'close', { size: null } );

const STYLES = `
.dm-release-card {
position: relative; box-sizing: border-box; width: 268px; padding: 11px;
position: relative; box-sizing: border-box; width: 268px; max-width: calc( 100vw - 32px ); max-height: calc( 100vh - 64px ); overflow-y: auto; padding: 11px;
border-radius: 14px; color: #fff;
font-family: var( --os-font, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif );
background: #34373f; border: 1px solid rgba( 255, 255, 255, 0.14 );
Expand Down
57 changes: 49 additions & 8 deletions src/window-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ import { destroyDesktopNameHud } from './desktop-name-hud';
import { cancelOverviewTimers, enterOverview, exitOverview } from './overview';
import { loadNativeWindowGeometry } from './native-window-geometry';
import { clampWindowPosition } from '../window/pointer';
import { workAreaRectOf, type WorkAreaRect } from '../work-area';
import {
subscribeWorkArea,
workAreaRectOf,
type WorkAreaRect,
} from '../work-area';

/** Base z-index for desktop windows. */
const BASE_Z_INDEX = 100;
Expand Down Expand Up @@ -266,6 +270,7 @@ export class WindowManager {
* can stay snapped to the available area.
*/
private desktopResizeObserver: ResizeObserver | null = null;
private unsubscribeWorkArea: ( () => void ) | null = null;

/**
* Debounce timer that clears `--reflowing` from stateful windows
Expand Down Expand Up @@ -414,6 +419,9 @@ export class WindowManager {
);
this.desktopResizeObserver.observe( desktop );
}
this.unsubscribeWorkArea = subscribeWorkArea( () =>
this.reflowStatefulWindows(),
);
this.installIframeFocusBridge();
}

Expand Down Expand Up @@ -537,11 +545,28 @@ export class WindowManager {
} else if ( w.state === 'normal' ) {
const currentX = parseInt( w.element.style.left, 10 ) || 0;
const currentY = parseInt( w.element.style.top, 10 ) || 0;
const width = w.element.offsetWidth || 0;
let width = w.element.offsetWidth || 0;
let height = w.element.offsetHeight || 0;
let dimensionsChanged = false;

const workArea = workAreaRectOf( this._desktop );
const maxW = Math.max( 40, workArea.width - 24 );
const maxH = Math.max( 40, workArea.height - 24 );

if ( workArea.width > 0 && width > maxW ) {
width = maxW;
w.element.style.width = `${ width }px`;
dimensionsChanged = true;
}
if ( workArea.height > 0 && height > maxH ) {
height = maxH;
w.element.style.height = `${ height }px`;
dimensionsChanged = true;
}

const safe = clampWindowPosition( currentX, currentY, width, area );
const safe = clampWindowPosition( currentX, currentY, width, workArea );

if ( currentX !== safe.x || currentY !== safe.y ) {
if ( dimensionsChanged || currentX !== safe.x || currentY !== safe.y ) {
w.element.classList.add( 'os-window--reflowing' );
w.element.style.left = `${ safe.x }px`;
w.element.style.top = `${ safe.y }px`;
Expand Down Expand Up @@ -1125,14 +1150,26 @@ export class WindowManager {
typeof v === 'number' && Number.isFinite( v ) ? v : fallback;
const safeFiltered: ResolvedWindowGeometry =
filtered && typeof filtered === 'object' ? filtered : preFilterGeometry;
const finalWidth = Math.max(
const effectiveMinW =
desktopRect.width > 0 ? Math.min( minWidth, desktopRect.width ) : minWidth;
const effectiveMinH =
desktopRect.height > 0 ? Math.min( minHeight, desktopRect.height ) : minHeight;

let finalWidth = Math.max(
coalesce( safeFiltered.width, resolvedWidth ),
minWidth,
effectiveMinW,
);
const finalHeight = Math.max(
let finalHeight = Math.max(
coalesce( safeFiltered.height, resolvedHeight ),
minHeight,
effectiveMinH,
);

if ( ! hasExplicitWidth && desktopRect.width > 0 && finalWidth > desktopRect.width ) {
finalWidth = desktopRect.width;
}
if ( ! hasExplicitHeight && desktopRect.height > 0 && finalHeight > desktopRect.height ) {
finalHeight = desktopRect.height;
}
const finalX = coalesce( safeFiltered.x, resolvedX );
const finalY = coalesce( safeFiltered.y, resolvedY );
const finalState: WindowState | undefined =
Expand Down Expand Up @@ -2478,6 +2515,10 @@ export class WindowManager {
if ( this._overviewActive ) {
exitOverview( this );
}
this.desktopResizeObserver?.disconnect();
this.desktopResizeObserver = null;
this.unsubscribeWorkArea?.();
this.unsubscribeWorkArea = null;
this.discardPrewarmed();
cancelOverviewTimers( this );
destroyDesktopNameHud();
Expand Down
12 changes: 9 additions & 3 deletions src/window/pointer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,12 +743,18 @@ export function clampWindowPosition(
width: number,
bounds: WorkAreaRect,
): { x: number; y: number } {
const minX = bounds.x + GRAB_MARGIN - width;
const maxX = bounds.x + bounds.width - GRAB_MARGIN;
const grabX = Math.min( GRAB_MARGIN, Math.max( 10, bounds.width / 4 ) );
const grabY = Math.min( GRAB_MARGIN, Math.max( 10, bounds.height / 4 ) );

const minX =
bounds.width < width + grabX
? bounds.x
: bounds.x + grabX - width;
const maxX = Math.max( minX, bounds.x + bounds.width - grabX );
const safeX = Math.max( minX, Math.min( x, maxX ) );

const minY = bounds.y + EDGE_MARGIN;
const maxY = bounds.y + bounds.height - GRAB_MARGIN;
const maxY = Math.max( minY, bounds.y + bounds.height - grabY );
const safeY = Math.max( minY, Math.min( y, maxY ) );

return { x: safeX, y: safeY };
Expand Down
196 changes: 196 additions & 0 deletions tests/vitest/high-zoom-window-clamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Tests for Issue #556: At high zoom / constrained viewports, active and restored
* windows remain visible and fitted inside the work area.
*/
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { clampGeometryToViewport } from '../../src/boot/geometry';
import { clampWindowPosition } from '../../src/window/pointer';
import { WindowManager } from '../../src/window-manager';
import { __resetNativeWindowGeometryForTests } from '../../src/window-manager/native-window-geometry';
import {
_resetWorkAreaForTests,
installWorkArea,
type WorkAreaController,
} from '../../src/work-area';
import { clearHooksStub, installHooksStub } from './helpers/hooks-stub';

describe( 'clampGeometryToViewport at high zoom / compact viewports (#556)', () => {
test( 'clamps large saved window to fit inside a 97x211 work area at 400% zoom', () => {
const win = {
id: 'test-win',
x: 100,
y: 100,
width: 600,
height: 450,
};
const rect = {
x: 0,
y: 0,
width: 97,
height: 211,
};

const clamped = clampGeometryToViewport( win, rect );
expect( clamped.width ).toBeLessThanOrEqual( rect.width );
expect( clamped.height ).toBeLessThanOrEqual( rect.height );
expect( clamped.x ).toBeGreaterThanOrEqual( 0 );
expect( clamped.y ).toBeGreaterThanOrEqual( 0 );
expect( clamped.x + clamped.width ).toBeLessThanOrEqual( rect.width );
expect( clamped.y + clamped.height ).toBeLessThanOrEqual( rect.height );
} );

test( 'clamps position and size when rect has non-zero origin and small dimensions', () => {
const win = {
id: 'test-win',
x: 500,
y: 500,
width: 800,
height: 600,
};
const rect = {
x: 10,
y: 20,
width: 150,
height: 180,
};

const clamped = clampGeometryToViewport( win, rect );
expect( clamped.width ).toBeLessThanOrEqual( rect.width );
expect( clamped.height ).toBeLessThanOrEqual( rect.height );
expect( clamped.x ).toBeGreaterThanOrEqual( rect.x );
expect( clamped.y ).toBeGreaterThanOrEqual( rect.y );
expect( clamped.x + clamped.width ).toBeLessThanOrEqual( rect.x + rect.width );
expect( clamped.y + clamped.height ).toBeLessThanOrEqual( rect.y + rect.height );
} );
} );

describe( 'clampWindowPosition on constrained work areas (#556)', () => {
test( 'does not push window offscreen when bounds.width is smaller than default grab margin', () => {
const bounds = {
x: 0,
y: 0,
width: 100,
height: 150,
};
const clamped = clampWindowPosition( 10, 10, 80, bounds );
expect( clamped.x ).toBeGreaterThanOrEqual( 0 );
expect( clamped.x ).toBeLessThanOrEqual( bounds.width );
expect( clamped.y ).toBeGreaterThanOrEqual( 0 );
expect( clamped.y ).toBeLessThanOrEqual( bounds.height );
} );
} );

describe( 'WindowManager high-zoom reflow (#556)', () => {
let shell: HTMLElement;
let area: HTMLElement;
let manager: WindowManager;
let workArea: WorkAreaController;

beforeEach( () => {
installHooksStub();
__resetNativeWindowGeometryForTests();
_resetWorkAreaForTests();

shell = document.createElement( 'div' );
const body = document.createElement( 'div' );
area = document.createElement( 'div' );
const dock = document.createElement( 'nav' );
dock.className = 'os-dock';
body.append( area, dock );
shell.append( body );
document.body.append( shell );

area.getBoundingClientRect = () => ( {
left: 0,
top: 0,
width: 100,
height: 200,
right: 100,
bottom: 200,
x: 0,
y: 0,
toJSON: () => ( {} ),
} as DOMRect );
Object.defineProperty( area, 'clientWidth', { value: 100, configurable: true } );
Object.defineProperty( area, 'clientHeight', { value: 200, configurable: true } );
dock.getBoundingClientRect = () => ( {
left: 0,
top: 170,
width: 100,
height: 30,
right: 100,
bottom: 200,
x: 0,
y: 170,
toJSON: () => ( {} ),
} as DOMRect );

workArea = installWorkArea( { shell, shellBody: body, area } );
manager = new WindowManager( area );
} );

afterEach( () => {
for ( const win of manager.getAll() ) {
win.destroy();
}
workArea.destroy();
manager.destroy();
document.body.innerHTML = '';
clearHooksStub();
__resetNativeWindowGeometryForTests();
_resetWorkAreaForTests();
} );

test( 'opening a window in a 100x200 viewport clamps dimensions within desktop bounds', async () => {
const win = await manager.open( {
id: 'test-high-zoom',
baseId: 'test-high-zoom',
url: 'http://example.test/wp-admin/test.php',
title: 'Test',
minWidth: 400,
minHeight: 300,
} );

expect( win.config.width ).toBeLessThanOrEqual( 100 );
expect( win.config.height ).toBeLessThanOrEqual( 200 );
expect( win.config.x ).toBeGreaterThanOrEqual( 0 );
expect( win.config.y ).toBeGreaterThanOrEqual( 0 );
} );

test( 'shrinking work area reflows open normal windows to fit within new boundaries', async () => {
const win = await manager.open( {
id: 'test-reflow-win',
baseId: 'test-reflow-win',
url: 'http://example.test/wp-admin/test2.php',
title: 'Test 2',
width: 60,
height: 60,
} );

// Simulate screen shrinking further (e.g. zooming in to 400%)
Object.defineProperty( area, 'clientWidth', { value: 60, configurable: true } );
Object.defineProperty( area, 'clientHeight', { value: 80, configurable: true } );
area.getBoundingClientRect = () => ( {
left: 0,
top: 0,
width: 60,
height: 80,
right: 60,
bottom: 80,
x: 0,
y: 0,
toJSON: () => ( {} ),
} as DOMRect );

// Trigger reflow
( manager as unknown as { reflowStatefulWindows: () => void } ).reflowStatefulWindows();

const left = parseInt( win.element.style.left, 10 ) || 0;
const top = parseInt( win.element.style.top, 10 ) || 0;
const width = win.element.offsetWidth || 0;
const height = win.element.offsetHeight || 0;

expect( left + width ).toBeLessThanOrEqual( 60 );
expect( top + height ).toBeLessThanOrEqual( 80 );
} );
} );