diff --git a/assets/css/dock.css b/assets/css/dock.css index 4d452c22..e5491dcf 100644 --- a/assets/css/dock.css +++ b/assets/css/dock.css @@ -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; + } +} diff --git a/src/boot/geometry.ts b/src/boot/geometry.ts index 44be9658..8f984cc3 100644 --- a/src/boot/geometry.ts +++ b/src/boot/geometry.ts @@ -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 }; } diff --git a/src/release-card.ts b/src/release-card.ts index 22f97b0c..98e2c947 100644 --- a/src/release-card.ts +++ b/src/release-card.ts @@ -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 ); diff --git a/src/window-manager/index.ts b/src/window-manager/index.ts index 8e728d08..bc9a1669 100644 --- a/src/window-manager/index.ts +++ b/src/window-manager/index.ts @@ -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; @@ -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 @@ -414,6 +419,9 @@ export class WindowManager { ); this.desktopResizeObserver.observe( desktop ); } + this.unsubscribeWorkArea = subscribeWorkArea( () => + this.reflowStatefulWindows(), + ); this.installIframeFocusBridge(); } @@ -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`; @@ -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 = @@ -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(); diff --git a/src/window/pointer.ts b/src/window/pointer.ts index 2c7ade92..0134f693 100644 --- a/src/window/pointer.ts +++ b/src/window/pointer.ts @@ -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 }; diff --git a/tests/vitest/high-zoom-window-clamp.test.ts b/tests/vitest/high-zoom-window-clamp.test.ts new file mode 100644 index 00000000..6e9adfcc --- /dev/null +++ b/tests/vitest/high-zoom-window-clamp.test.ts @@ -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 ); + } ); +} );