diff --git a/apps/desktop/src/preview.test.ts b/apps/desktop/src/preview.test.ts index 0a63906b8..6b8c11fb9 100644 --- a/apps/desktop/src/preview.test.ts +++ b/apps/desktop/src/preview.test.ts @@ -68,6 +68,8 @@ describe("sanitizeDesktopPreviewBounds", () => { width: 480.2, height: 320.8, visible: true, + viewportWidth: 1440.2, + viewportHeight: 900.8, }), ).toEqual({ x: 10, @@ -75,6 +77,8 @@ describe("sanitizeDesktopPreviewBounds", () => { width: 480, height: 321, visible: true, + viewportWidth: 1440, + viewportHeight: 901, }); expect( @@ -84,6 +88,8 @@ describe("sanitizeDesktopPreviewBounds", () => { width: -10, height: 0, visible: true, + viewportWidth: Number.NaN, + viewportHeight: Number.NaN, }), ).toEqual({ x: 0, @@ -91,6 +97,8 @@ describe("sanitizeDesktopPreviewBounds", () => { width: 0, height: 0, visible: false, + viewportWidth: 0, + viewportHeight: 0, }); }); }); diff --git a/apps/desktop/src/previewBounds.test.ts b/apps/desktop/src/previewBounds.test.ts new file mode 100644 index 000000000..19a2c4c36 --- /dev/null +++ b/apps/desktop/src/previewBounds.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { projectPreviewBoundsToContent } from "./previewBounds"; + +describe("projectPreviewBoundsToContent", () => { + it("maps renderer bounds into native content coordinates using viewport scaling", () => { + expect( + projectPreviewBoundsToContent( + { + x: 100, + y: 52, + width: 400, + height: 300, + visible: true, + viewportWidth: 1000, + viewportHeight: 800, + }, + { width: 1200, height: 960 }, + ), + ).toEqual({ + x: 120, + y: 62, + width: 480, + height: 360, + }); + }); + + it("clamps projected bounds to the native content area", () => { + expect( + projectPreviewBoundsToContent( + { + x: 700, + y: 580, + width: 300, + height: 220, + visible: true, + viewportWidth: 1000, + viewportHeight: 800, + }, + { width: 900, height: 700 }, + ), + ).toEqual({ + x: 630, + y: 507, + width: 270, + height: 193, + }); + }); + + it("falls back to content-space coordinates when viewport metadata is unavailable", () => { + expect( + projectPreviewBoundsToContent( + { + x: 64, + y: 96, + width: 320, + height: 240, + visible: true, + viewportWidth: 0, + viewportHeight: 0, + }, + { width: 1200, height: 900 }, + ), + ).toEqual({ + x: 64, + y: 96, + width: 320, + height: 240, + }); + }); + + it("hides the native view for invisible or empty regions", () => { + expect( + projectPreviewBoundsToContent( + { + x: 10, + y: 10, + width: 320, + height: 240, + visible: false, + viewportWidth: 800, + viewportHeight: 600, + }, + { width: 1200, height: 900 }, + ), + ).toEqual({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + }); +}); diff --git a/apps/desktop/src/previewBounds.ts b/apps/desktop/src/previewBounds.ts new file mode 100644 index 000000000..a6cf13a14 --- /dev/null +++ b/apps/desktop/src/previewBounds.ts @@ -0,0 +1,45 @@ +import type { Rectangle } from "electron"; +import type { DesktopPreviewBounds } from "@okcode/contracts"; + +function normalizeAxisScale(contentSize: number, viewportSize: number): number { + if (contentSize <= 0) { + return 1; + } + if (viewportSize <= 0) { + return 1; + } + return contentSize / viewportSize; +} + +export function projectPreviewBoundsToContent( + bounds: DesktopPreviewBounds, + contentBounds: Pick, +): Rectangle { + const contentWidth = Math.max(0, Math.round(contentBounds.width)); + const contentHeight = Math.max(0, Math.round(contentBounds.height)); + + if ( + !bounds.visible || + bounds.width <= 0 || + bounds.height <= 0 || + contentWidth <= 0 || + contentHeight <= 0 + ) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + + const scaleX = normalizeAxisScale(contentWidth, bounds.viewportWidth); + const scaleY = normalizeAxisScale(contentHeight, bounds.viewportHeight); + const width = Math.min(Math.max(0, Math.round(bounds.width * scaleX)), contentWidth); + const height = Math.min(Math.max(0, Math.round(bounds.height * scaleY)), contentHeight); + + if (width <= 0 || height <= 0) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + + const maxX = Math.max(0, contentWidth - width); + const maxY = Math.max(0, contentHeight - height); + const x = Math.max(0, Math.min(Math.round(bounds.x * scaleX), maxX)); + const y = Math.max(0, Math.min(Math.round(bounds.y * scaleY), maxY)); + return { x, y, width, height }; +} diff --git a/apps/desktop/src/previewController.ts b/apps/desktop/src/previewController.ts index 73b418fd7..b9acc867c 100644 --- a/apps/desktop/src/previewController.ts +++ b/apps/desktop/src/previewController.ts @@ -12,6 +12,7 @@ import { sanitizeDesktopPreviewBounds, validateDesktopPreviewUrl, } from "./preview"; +import { projectPreviewBoundsToContent } from "./previewBounds"; const PREVIEW_WEB_PREFERENCES = { contextIsolation: true, @@ -36,6 +37,8 @@ export class DesktopPreviewController { width: 0, height: 0, visible: false, + viewportWidth: 0, + viewportHeight: 0, }; private unsubscribers: Array<() => void> = []; private disposingView = false; @@ -150,6 +153,7 @@ export class DesktopPreviewController { const view = new WebContentsView({ webPreferences: PREVIEW_WEB_PREFERENCES, }); + view.setBorderRadius(8); this.view = view; this.window.contentView.addChildView(view); this.bindView(view); @@ -280,30 +284,9 @@ export class DesktopPreviewController { return false; } - const contentBounds = this.window.getContentBounds(); - const rawWidth = Math.round(this.bounds.width); - const rawHeight = Math.round(this.bounds.height); - - if ( - !this.bounds.visible || - rawWidth <= 0 || - rawHeight <= 0 || - contentBounds.width <= 0 || - contentBounds.height <= 0 - ) { - this.view.setBounds({ x: 0, y: 0, width: 0, height: 0 }); - return false; - } - - const width = Math.min(rawWidth, contentBounds.width); - const height = Math.min(rawHeight, contentBounds.height); - const maxX = Math.max(0, contentBounds.width - width); - const maxY = Math.max(0, contentBounds.height - height); - const x = Math.max(0, Math.min(Math.round(this.bounds.x), maxX)); - const y = Math.max(0, Math.min(Math.round(this.bounds.y), maxY)); - - this.view.setBounds({ x, y, width, height }); - return width > 0 && height > 0; + const nextBounds = projectPreviewBoundsToContent(this.bounds, this.window.getContentBounds()); + this.view.setBounds(nextBounds); + return nextBounds.width > 0 && nextBounds.height > 0; } private disposeView(): void { diff --git a/apps/web/src/components/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index 958358d64..1d2833dca 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -31,6 +31,8 @@ const HIDDEN_PREVIEW_BOUNDS = { width: 0, height: 0, visible: false, + viewportWidth: 0, + viewportHeight: 0, } as const; export function resolvePreviewStatusCopy(state: DesktopPreviewState): string { @@ -147,6 +149,8 @@ export function PreviewPanel({ threadId, projectId, projectName, onClose }: Prev width: rect.width, height: rect.height, visible, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, }; }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1360c84a4..e69270c70 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -120,6 +120,8 @@ export interface DesktopPreviewBounds { width: number; height: number; visible: boolean; + viewportWidth: number; + viewportHeight: number; } export interface DesktopPreviewState { diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index 35661e381..a093f2ead 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -55,6 +55,8 @@ describe("sanitizeLocalPreviewBounds", () => { width: 480.4, height: 320.6, visible: true, + viewportWidth: 1440.4, + viewportHeight: 900.6, }; expect(sanitizeLocalPreviewBounds(floatingBounds)).toEqual({ x: 10, @@ -62,6 +64,8 @@ describe("sanitizeLocalPreviewBounds", () => { width: 480, height: 321, visible: true, + viewportWidth: 1440, + viewportHeight: 901, }); expect( @@ -71,6 +75,8 @@ describe("sanitizeLocalPreviewBounds", () => { width: -10, height: 0, visible: true, + viewportWidth: Number.NaN, + viewportHeight: Number.NaN, }), ).toEqual({ x: 0, @@ -78,6 +84,8 @@ describe("sanitizeLocalPreviewBounds", () => { width: 0, height: 0, visible: false, + viewportWidth: 0, + viewportHeight: 0, }); }); }); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index a0391604e..e4a6a7831 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -90,12 +90,20 @@ export function validateHttpPreviewUrl( export function sanitizeLocalPreviewBounds(bounds: DesktopPreviewBounds): DesktopPreviewBounds { const width = Number.isFinite(bounds.width) ? Math.max(0, Math.round(bounds.width)) : 0; const height = Number.isFinite(bounds.height) ? Math.max(0, Math.round(bounds.height)) : 0; + const viewportWidth = Number.isFinite(bounds.viewportWidth) + ? Math.max(0, Math.round(bounds.viewportWidth)) + : 0; + const viewportHeight = Number.isFinite(bounds.viewportHeight) + ? Math.max(0, Math.round(bounds.viewportHeight)) + : 0; return { x: Number.isFinite(bounds.x) ? Math.round(bounds.x) : 0, y: Number.isFinite(bounds.y) ? Math.round(bounds.y) : 0, width, height, + viewportWidth, + viewportHeight, visible: bounds.visible && width > 0 && height > 0, }; }