diff --git a/src/main/window.ts b/src/main/window.ts index 3b376bb..be84318 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -5,6 +5,7 @@ import { attachCdp } from './cdp/attach' import { bus } from './events/bus' import { IPC, + DEVICE_PRESETS, type NavAction, type NavState, type OverlayControl, @@ -13,7 +14,9 @@ import { type BookmarksState, type HistoryState, type StorageDetail, - type StorageKV + type StorageKV, + type ViewportState, + type ViewportSet } from '../shared/events' import { listBookmarks, @@ -81,6 +84,16 @@ let chromeExpanded = false // una vez arrastrado, queda fijo en {x,y} (top-left). let overlayPos: { x: number; y: number } | null = null +// Viewport / device mode activo. presetId null = página ajustada a la ventana. +let viewport: { presetId: string | null; w: number; h: number; dpr: number; mobile: boolean; ua?: string; landscape: boolean } = { + presetId: null, + w: 0, + h: 0, + dpr: 1, + mobile: false, + landscape: false +} + const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), Math.max(lo, hi)) function activeTab(): Tab | undefined { @@ -108,12 +121,27 @@ function cornerPos(width: number, height: number, o: { w: number; h: number }): return { x: width - o.w - MARGIN, y: height - o.h - MARGIN } } +// Box de la página según el viewport activo, dentro del área bajo el chrome. +// Sin preset: llena el área. Con preset: box del device (swap si landscape), +// centrado horizontalmente y recortado (clamp) si no cabe en la ventana. +function viewportBox(width: number, availH: number): { x: number; w: number; h: number; clamped: boolean } { + if (viewport.presetId === null) return { x: 0, w: width, h: availH, clamped: false } + const dw = viewport.landscape ? viewport.h : viewport.w + const dh = viewport.landscape ? viewport.w : viewport.h + const w = Math.min(dw, width) + const h = Math.min(dh, availH) + return { x: Math.max(0, Math.floor((width - w) / 2)), w, h, clamped: w < dw || h < dh } +} + function layout(): void { const { width, height } = win.getContentBounds() const ch = chromeHeight() chromeView.setBounds({ x: 0, y: 0, width, height: chromeExpanded ? height : ch }) const at = activeTab() - if (at) at.view.setBounds({ x: 0, y: ch, width, height: height - ch }) + if (at) { + const box = viewportBox(width, height - ch) + at.view.setBounds({ x: box.x, y: ch, width: box.w, height: box.h }) + } const o = overlayCollapsed ? OVERLAY_COLLAPSED : overlaySize const pos = overlayPos ?? cornerPos(width, height, o) @@ -174,6 +202,52 @@ function sendHistory(): void { chromeView.webContents.send(IPC.historyState, state) } +// Aplica (o limpia) la emulación de device en la pestaña activa vía CDP. Las dims +// del override coinciden con el box realmente pintado (viewportBox) para que la +// superficie y lo que ve la página no se desincronicen. Best-effort: si el +// debugger no está adjunto o el comando falla, no rompe la navegación. +async function applyEmulation(wc: WebContents): Promise { + const dbg = wc.debugger + if (!dbg.isAttached()) return + try { + if (viewport.presetId === null) { + await dbg.sendCommand('Emulation.clearDeviceMetricsOverride') + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { enabled: false }) + // getUserAgent() devuelve la UA de sesión (no la del override CDP): sirve para restaurar. + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: wc.getUserAgent() }) + return + } + const { width, height } = win.getContentBounds() + const box = viewportBox(width, height - chromeHeight()) + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width: box.w, + height: box.h, + deviceScaleFactor: viewport.dpr, + mobile: viewport.mobile + }) + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { enabled: viewport.mobile }) + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: viewport.ua || wc.getUserAgent() }) + } catch (err) { + console.error('[emulation]', err) + } +} + +function sendViewport(): void { + const state: ViewportState = { + presetId: viewport.presetId, + width: viewport.landscape ? viewport.h : viewport.w, + height: viewport.landscape ? viewport.w : viewport.h, + dpr: viewport.dpr, + mobile: viewport.mobile, + landscape: viewport.landscape, + clamped: (() => { + const { width, height } = win.getContentBounds() + return viewportBox(width, height - chromeHeight()).clamped + })() + } + chromeView.webContents.send(IPC.viewportState, state) +} + // Persiste las pestañas abiertas (URLs + activa). No guarda durante el arranque // (antes de restaurar) para no pisar la sesión con una lista vacía. let restoring = true @@ -206,6 +280,12 @@ function wireTabEvents(tab: Tab): void { wc.on('did-navigate-in-page', update) wc.on('did-start-loading', update) wc.on('did-stop-loading', update) + // Una navegación completa resetea los overrides de Emulation: re-aplicar si esta + // pestaña es la activa y hay un device mode puesto. + wc.on('did-finish-load', () => { + if (tab.id === activeId && viewport.presetId !== null) void applyEmulation(wc) + }) + attachShortcuts(wc) } function normUrl(url: string): string { @@ -245,10 +325,12 @@ function activateTab(id: string): void { if (!next.disposeCdp) next.disposeCdp = attachCdp(next.view.webContents) layout() + void applyEmulation(next.view.webContents) // el device mode sigue a la pestaña activa sendTabs() sendNavState() sendBookmarks() sendHistory() + sendViewport() storeSession() } @@ -322,8 +404,14 @@ export function createWindow(): void { sendNavState() sendBookmarks() sendHistory() + sendViewport() }) + // Atajos globales: la barra propia y el overlay también deben responder cuando + // tienen el foco (no solo la página). Las pestañas se enganchan en wireTabEvents. + attachShortcuts(chromeView.webContents) + attachShortcuts(overlayView.webContents) + // Bus → overlay (IPC). Único puente main→UI de observabilidad. bus.onEvent((evt) => overlayView.webContents.send(IPC.event, evt)) @@ -378,6 +466,9 @@ function registerIpc(): void { ipcMain.on(IPC.tabClose, (_e, id: string) => closeTab(id)) ipcMain.on(IPC.tabActivate, (_e, id: string) => activateTab(id)) + // ---- viewports / device modes ---- + ipcMain.on(IPC.viewportSet, (_e, p: ViewportSet) => setViewport(p)) + // ---- bookmarks ---- ipcMain.on(IPC.bookmarkToggle, () => { const wc = activeTab()?.view.webContents @@ -507,6 +598,85 @@ function registerIpc(): void { }) } +// Resuelve un preset/custom/fit → estado de viewport, relayoutea y re-emula. +function setViewport(p: ViewportSet): void { + if (p.presetId === null) { + viewport = { presetId: null, w: 0, h: 0, dpr: 1, mobile: false, landscape: false } + } else if (p.presetId === 'custom') { + viewport = { + presetId: 'custom', + w: clamp(Math.round(p.width ?? 0), 200, 4000), + h: clamp(Math.round(p.height ?? 0), 200, 4000), + dpr: 1, + mobile: false, + landscape: !!p.landscape + } + } else { + const preset = DEVICE_PRESETS.find((d) => d.id === p.presetId) + if (!preset) return + viewport = { + presetId: preset.id, + w: preset.w, + h: preset.h, + dpr: preset.dpr, + mobile: preset.mobile, + ua: preset.ua, + landscape: !!p.landscape + } + } + layout() + const wc = activeTab()?.view.webContents + if (wc) void applyEmulation(wc) + sendViewport() + sendNavState() // las dims del viewport cambiaron +} + +// ---- atajos de teclado (before-input-event, REQ-026) ---- +// Se engancha a cada webContents (chrome, overlay y cada página): el atajo responde +// tenga el foco donde tenga. preventDefault evita que Chromium también lo procese. +function cycleTab(dir: 1 | -1): void { + if (tabs.length < 2) return + const i = tabs.findIndex((t) => t.id === activeId) + activateTab(tabs[(i + dir + tabs.length) % tabs.length].id) +} +function activateByIndex(n: number): void { + // 1..8 → esa pestaña; 9 → última (convención de navegadores). + const idx = n === 9 ? tabs.length - 1 : n - 1 + if (idx >= 0 && idx < tabs.length) activateTab(tabs[idx].id) +} +function attachShortcuts(wc: WebContents): void { + wc.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') return + const mod = input.control || input.meta + const shift = input.shift + const key = input.key.toLowerCase() + const page = (): WebContents | undefined => activeTab()?.view.webContents + let handled = true + + if (key === 'f5' || (mod && !shift && key === 'r')) page()?.reload() + else if (mod && shift && key === 'r') page()?.reloadIgnoringCache() + else if (mod && !shift && key === 't') createTab(START_URL) + else if (mod && !shift && key === 'w') closeTab(activeId) + else if (mod && key === 'tab') cycleTab(shift ? -1 : 1) + else if (mod && !shift && /^[1-9]$/.test(key)) activateByIndex(Number(key)) + else if ((mod && key === 'l') || (input.alt && key === 'd')) chromeView.webContents.send(IPC.focusAddress) + else if (mod && !shift && key === 'd') { + const w = page() + if (w) { toggleBookmark(w.getURL(), w.getTitle()); sendBookmarks() } + } else if (mod && !shift && key === 'b') { toggleBar(); layout(); sendBookmarks() } + else if (mod && shift && key === 'o') { + overlayCollapsed = !overlayCollapsed + layout() + overlayView.webContents.send(IPC.overlayState, overlayCollapsed) + } else if (mod && !shift && key === 'p') { + const w = page() + if (w && !w.isDestroyed()) w.print() + } else handled = false + + if (handled) event.preventDefault() + }) +} + export function focusPage(): WebContents | undefined { return activeTab()?.view.webContents } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6304631..7030a96 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,7 +9,9 @@ import { type TabsState, type BookmarksState, type HistoryState, - type StorageDetail + type StorageDetail, + type ViewportState, + type ViewportSet } from '../shared/events' // ============================================================================ @@ -83,6 +85,23 @@ const api = { return () => ipcRenderer.off(IPC.historyState, listener) }, + // ---- chrome: viewports / device modes ---- + viewportSet(payload: ViewportSet): void { + ipcRenderer.send(IPC.viewportSet, payload) + }, + onViewportState(fn: (state: ViewportState) => void): () => void { + const listener = (_e: unknown, state: ViewportState): void => fn(state) + ipcRenderer.on(IPC.viewportState, listener) + return () => ipcRenderer.off(IPC.viewportState, listener) + }, + + // ---- chrome: atajos (main → chrome: enfocar barra de direcciones) ---- + onFocusAddress(fn: () => void): () => void { + const listener = (): void => fn() + ipcRenderer.on(IPC.focusAddress, listener) + return () => ipcRenderer.off(IPC.focusAddress, listener) + }, + // ---- overlay: control de estado (colapsar / expandir) (D-017) ---- overlayControl(control: OverlayControl): void { ipcRenderer.send(IPC.overlayControl, control) diff --git a/src/renderer/chrome/Chrome.tsx b/src/renderer/chrome/Chrome.tsx index 351c43f..74f6585 100644 --- a/src/renderer/chrome/Chrome.tsx +++ b/src/renderer/chrome/Chrome.tsx @@ -1,5 +1,12 @@ -import { useEffect, useState } from 'react' -import type { NavState, TabsState, BookmarksState, HistoryEntry } from '../../shared/events' +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { + DEVICE_PRESETS, + type NavState, + type TabsState, + type BookmarksState, + type HistoryEntry, + type ViewportState +} from '../../shared/events' // Barra + tabs de Overrun (chromeView). Frameless propio (BRANDING), no la UI de Chrome. export function Chrome(): JSX.Element { @@ -10,6 +17,9 @@ export function Chrome(): JSX.Element { // Historial completo persistido (viene del main); alimenta el autocompletado. const [history, setHistory] = useState([]) const [showHist, setShowHist] = useState(false) + const [vp, setVp] = useState({ presetId: null, width: 0, height: 0, dpr: 1, mobile: false, landscape: false, clamped: false }) + const [vpMenu, setVpMenu] = useState(false) + const addrRef = useRef(null) useEffect(() => window.overrun.onNavState((s) => { setNav(s) @@ -18,6 +28,12 @@ export function Chrome(): JSX.Element { useEffect(() => window.overrun.onTabsState(setTabs), []) useEffect(() => window.overrun.onBookmarksState(setMarks), []) useEffect(() => window.overrun.onHistoryState((s) => setHistory(s.items)), []) + useEffect(() => window.overrun.onViewportState(setVp), []) + // Ctrl+L / Alt+D (desde el main): enfoca y selecciona la barra de direcciones. + useEffect(() => window.overrun.onFocusAddress(() => { + const el = addrRef.current + if (el) { el.focus(); el.select() } + }), []) const navigateTo = (raw: string): void => { const url = raw.trim() @@ -36,8 +52,8 @@ export function Chrome(): JSX.Element { // El dropdown se recorta por los bounds del chromeView; cuando está visible se // expande la vista del chrome a toda la ventana (transparente) para que flote. useEffect(() => { - window.overrun.chromeExpand(showHist && suggestions.length > 0) - }, [showHist, suggestions.length]) + window.overrun.chromeExpand((showHist && suggestions.length > 0) || vpMenu) + }, [showHist, suggestions.length, vpMenu]) return (
@@ -96,6 +112,7 @@ export function Chrome(): JSX.Element { {/* barra de direcciones + dropdown de historial (custom, en paleta) */}
setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && go()} @@ -114,11 +131,8 @@ export function Chrome(): JSX.Element {
)}
- {/* resolución actual del viewport de la página */} -
- - {nav.viewport.width} × {nav.viewport.height} -
+ {/* selector de viewport / device mode */} +
window.overrun.overlayControl('toggle')}> @@ -207,6 +221,95 @@ function HistRow({ entry, onPick }: { entry: HistoryEntry; onPick: () => void }) ) } +// Selector de viewport / device mode. Muestra el device activo + dims; el menú +// lista "ajustar a ventana", presets, custom W×H y swap de orientación. +function ViewportChip({ vp, nav, open, setOpen }: { vp: ViewportState; nav: NavState; open: boolean; setOpen: (v: boolean) => void }): JSX.Element { + const active = vp.presetId !== null + const preset = DEVICE_PRESETS.find((d) => d.id === vp.presetId) + const label = vp.presetId === null ? 'Ventana' : vp.presetId === 'custom' ? 'Custom' : preset?.label ?? 'Device' + const dims = vp.presetId === null ? `${nav.viewport.width} × ${nav.viewport.height}` : `${vp.width} × ${vp.height}` + const [cw, setCw] = useState('') + const [chh, setChh] = useState('') + + // Al abrir, precarga los campos custom con las dims actuales. + useEffect(() => { + if (open) { setCw(String(vp.width || nav.viewport.width)); setChh(String(vp.height || nav.viewport.height)) } + }, [open, vp.width, vp.height, nav.viewport.width, nav.viewport.height]) + + const set = (presetId: string | null, extra: { width?: number; height?: number; landscape?: boolean } = {}): void => { + window.overrun.viewportSet({ presetId, ...extra }) + setOpen(false) + } + const applyCustom = (): void => { + const w = Number(cw), h = Number(chh) + if (w > 0 && h > 0) set('custom', { width: w, height: h, landscape: vp.landscape }) + } + + return ( +
+
setOpen(!open)} title="Viewport / device mode" + style={{ display: 'flex', alignItems: 'center', gap: 8, height: 34, padding: '0 12px', borderRadius: 9, cursor: 'pointer', background: active ? 'oklch(0.82 0.15 195 / 0.14)' : 'var(--surface)', border: `1px solid ${active ? 'oklch(0.82 0.15 195 / 0.5)' : 'var(--line)'}`, color: active ? 'var(--cyan-bright)' : '#cfd3d9' }}> + + {label} + {dims} +
+ + {open && ( + <> + {/* backdrop: cierra al hacer clic fuera (el chrome está expandido sobre la página) */} +
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 25 }} /> +
+ set(null)} /> +
+ {DEVICE_PRESETS.map((d) => ( + set(d.id, { landscape: vp.landscape })} /> + ))} +
+ {/* custom W×H */} +
+ setCw(e.target.value.replace(/\D/g, ''))} onKeyDown={(e) => e.key === 'Enter' && applyCustom()} + placeholder="W" style={inp} /> + × + setChh(e.target.value.replace(/\D/g, ''))} onKeyDown={(e) => e.key === 'Enter' && applyCustom()} + placeholder="H" style={inp} /> + +
+ {/* orientación */} +
active && set(vp.presetId, { width: vp.width, height: vp.height, landscape: !vp.landscape })} + style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', borderTop: '1px solid var(--line-soft)', cursor: active ? 'pointer' : 'default', opacity: active ? 1 : 0.4 }}> + + {vp.landscape ? 'Landscape' : 'Portrait'} + + rotar +
+ {vp.clamped && ( +
+ Recortado: agrandá la ventana para ver el ancho completo. +
+ )} +
+ + )} +
+ ) +} + +const inp: CSSProperties = { width: 62, height: 26, padding: '0 8px', borderRadius: 6, background: 'var(--surface-2)', border: '1px solid var(--line)', color: '#cfd3d9', fontFamily: 'var(--font-mono)', fontSize: 11, outline: 'none', textAlign: 'center' } + +function VpRow({ label, hint, sel, onClick }: { label: string; hint: string; sel: boolean; onClick: () => void }): JSX.Element { + const [hover, setHover] = useState(false) + return ( +
setHover(true)} onMouseLeave={() => setHover(false)} + style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', cursor: 'pointer', background: hover ? 'oklch(0.82 0.15 195 / 0.1)' : 'transparent' }}> + + {label} + {hint} +
+ ) +} + function hostOf(url: string): string { try { return new URL(url).host diff --git a/src/shared/events.ts b/src/shared/events.ts index ff0a208..43f798e 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -260,7 +260,17 @@ export const IPC = { /** main → chrome: visitas recientes (persistidas) para sugerencias de la barra. */ historyState: 'overrun:history-state', /** chrome → main: limpiar el historial persistido. */ - historyClear: 'overrun:history-clear' + historyClear: 'overrun:history-clear', + + // ---- viewports / device modes ---- + /** main → chrome: viewport activo (preset, dims, orientación). */ + viewportState: 'overrun:viewport-state', + /** chrome → main: fijar viewport (preset / custom / ajustar a ventana). */ + viewportSet: 'overrun:viewport-set', + + // ---- atajos de teclado ---- + /** main → chrome: enfocar la barra de direcciones (Ctrl+L). */ + focusAddress: 'overrun:focus-address' } as const export interface ResponseBody { @@ -310,6 +320,69 @@ export interface Viewport { height: number } +// --------------------------------------------------------------------------- +// Viewports / device modes (v1 — REQ-025 / Emulation) +// +// El usuario testea la página a resoluciones concretas. `null` = ajustado a la +// ventana (sin override). Un preset (o custom) redimensiona la vista de la página +// a ese box y aplica CDP Emulation (DPR, touch, UA móvil) a la pestaña activa. +// --------------------------------------------------------------------------- + +const UA_IOS = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' +const UA_IPAD = + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' +const UA_ANDROID = + 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' + +export interface DevicePreset { + id: string + label: string + /** dims lógicas en portrait (CSS px). */ + w: number + h: number + /** device pixel ratio. */ + dpr: number + /** emula touch + flag mobile en media queries. */ + mobile: boolean + /** user-agent a forzar (solo presets móviles). */ + ua?: string +} + +/** Catálogo de dispositivos para el selector. Orden: móvil → tablet → desktop. */ +export const DEVICE_PRESETS: DevicePreset[] = [ + { id: 'iphone-se', label: 'iPhone SE', w: 375, h: 667, dpr: 2, mobile: true, ua: UA_IOS }, + { id: 'iphone-14', label: 'iPhone 14 Pro', w: 393, h: 852, dpr: 3, mobile: true, ua: UA_IOS }, + { id: 'pixel-7', label: 'Pixel 7', w: 412, h: 915, dpr: 2.625, mobile: true, ua: UA_ANDROID }, + { id: 'ipad-mini', label: 'iPad Mini', w: 768, h: 1024, dpr: 2, mobile: true, ua: UA_IPAD }, + { id: 'ipad-pro', label: 'iPad Pro 11"', w: 834, h: 1194, dpr: 2, mobile: true, ua: UA_IPAD }, + { id: 'laptop', label: 'Laptop', w: 1280, h: 800, dpr: 1, mobile: false }, + { id: 'desktop', label: 'Desktop HD', w: 1920, h: 1080, dpr: 1, mobile: false } +] + +export interface ViewportState { + /** id del preset, 'custom', o null = ajustado a la ventana. */ + presetId: string | null + /** dims lógicas activas (0 cuando presetId === null). */ + width: number + height: number + dpr: number + mobile: boolean + /** landscape = swap de w/h respecto al preset. */ + landscape: boolean + /** el box no cabe entero en la ventana y quedó recortado. */ + clamped: boolean +} + +/** chrome → main: fijar viewport. presetId null = ajustar a ventana. */ +export interface ViewportSet { + presetId: string | null + /** solo para presetId 'custom'. */ + width?: number + height?: number + landscape?: boolean +} + export interface NavState { url: string canGoBack: boolean