From ef63afc276816aeb7a72ced7d263aebf29e08f64 Mon Sep 17 00:00:00 2001 From: "burrito.space" Date: Tue, 7 Jul 2026 19:53:00 +0200 Subject: [PATCH] feat(desktop): dbl-click page content maximizes, triple-click fullscreens (peek d8ce0ddf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content-area double-click over "dead" page space toggles the hybrid host's maximize; triple-click toggles fullscreen. Suppressed over editable/interactive targets (input/textarea/select/button/link/contenteditable/widget-roles, shadow- DOM-aware via composedPath) and when the multi-click selects text (double-click- a-word / triple-click-a-line), so ordinary selection never resizes the window. Detection lives content-side in the universal preload (runtime-external-preload.cjs) — the only realm with the DOM target + live selection; main's input-event stream has clickCount but no target. Rides Chromium's native multi-click counter (MouseEvent.detail), no timers/debounce (fsm-pilled). New main module hybrid-window-gesture.ts maps detent 2 -> maximize, 3 -> fullscreen; maximize is routed back through the shared chrome overlay (page:request-toggle-maximize) so the overlay stays the single source of truth for maximize state + chrome reflow. Scope: covers http(s) page-host content (hybrid WebContentsView). peek:// tiles use a different preload and are not covered by this pass. --- .../runtime-external-preload.cjs | 52 +++++++++++ apps/desktop/main/hybrid-window-gesture.ts | 86 +++++++++++++++++++ apps/desktop/main/ipc.ts | 6 ++ apps/desktop/renderer/page/overlay.js | 12 +++ 4 files changed, 156 insertions(+) create mode 100644 apps/desktop/main/hybrid-window-gesture.ts diff --git a/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs b/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs index 0e7a0ca6..c714bf19 100644 --- a/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs +++ b/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs @@ -83,6 +83,58 @@ function _peekScheduleReport() { Promise.resolve().then(_peekReportEditableFocus document.addEventListener('focusin', _peekScheduleReport, true); document.addEventListener('focusout', _peekScheduleReport, true); +// === Content-area double/triple-click window gesture (peek d8ce0ddf) ========= +// A double-click over "dead" page content toggles the host window's maximize; a +// triple-click toggles fullscreen. Detected HERE in the page realm because only +// it has the DOM target (composedPath, shadow-DOM-aware) and the live text +// selection needed to SUPPRESS the gesture — over an editable/interactive control +// (input/button/link/etc.) OR when the multi-click is selecting text +// (double-click-a-word / triple-click-a-line must never resize the window). Rides +// Chromium's native multi-click counter (MouseEvent.detail) — no timers, no +// debounce (fsm-pilled). We report only the clean case; main (hybrid-window- +// gesture.ts) maps detent 2 → maximize, detent 3 → fullscreen. A triple-click +// fires detent 2 then detent 3 in the same native sequence — by design the window +// grows monotonically (normal → maximized → fullscreen). +const IPC_CONTENT_WINDOW_GESTURE = 'page:content-window-gesture'; + +// Broader than _peekIsEditableElement: the gesture must also skip discrete +// interactive controls a click is meant to activate (buttons, links, media, +// widget roles), not just editable fields. +function _peekIsInteractiveTarget(el) { + if (_peekIsEditableElement(el)) return true; + const tag = (el.tagName || '').toLowerCase(); + if (tag === 'button' || tag === 'a' || tag === 'summary' || tag === 'option' + || tag === 'label' || tag === 'video' || tag === 'audio') return true; + const role = (el.getAttribute ? (el.getAttribute('role') || '') : '').toLowerCase(); + const interactiveRoles = ['button', 'link', 'checkbox', 'radio', 'menuitem', + 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'slider', 'option', 'textbox']; + return interactiveRoles.includes(role); +} + +function _peekGestureSuppressed(e) { + // 1) Interactive/editable target anywhere along the (shadow-aware) event path. + const path = typeof e.composedPath === 'function' ? e.composedPath() : []; + for (const node of path) { + if (!node || node.nodeType !== 1) continue; + if (_peekIsInteractiveTarget(node)) return true; + } + // 2) The multi-click selected text (double-click a word / triple-click a line). + // By click time Chromium has already applied the selection, so a + // non-collapsed selection means "the user is selecting, not gesturing." + try { + const sel = window.getSelection && window.getSelection(); + if (sel && !sel.isCollapsed && String(sel).length > 0) return true; + } catch (_) { /* getSelection unavailable */ } + return false; +} + +document.addEventListener('click', function (e) { + const detent = e.detail; // native multi-click count: 2 = double, 3 = triple + if (detent !== 2 && detent !== 3) return; + if (_peekGestureSuppressed(e)) return; + try { ipcRenderer.send(IPC_CONTENT_WINDOW_GESTURE, { detent }); } catch (_) { /* no main */ } +}, true); + /** * MAIN-world polyfill source. Installed via webFrame.executeJavaScript so * page scripts (in the page's main JS world) can call chrome.runtime.sendMessage. diff --git a/apps/desktop/main/hybrid-window-gesture.ts b/apps/desktop/main/hybrid-window-gesture.ts new file mode 100644 index 00000000..739a13ad --- /dev/null +++ b/apps/desktop/main/hybrid-window-gesture.ts @@ -0,0 +1,86 @@ +/** + * Content-area double/triple-click window gesture (peek d8ce0ddf). + * + * The universal content preload (chrome-api-polyfills/runtime-external-preload.cjs) + * detects a double- or triple-click over "dead" page content — one that does NOT + * land on an editable/interactive control and does NOT select text — and reports + * it as `ipcRenderer.send('page:content-window-gesture', { detent })`, where + * `detent` is Chromium's native multi-click count (2 = double, 3 = triple). The + * suppression (editable/interactive target, live text selection) is evaluated + * CONTENT-SIDE because only the page realm has the DOM target/composedPath and the + * live selection; main's `input-event` stream carries `clickCount` but no target. + * + * This module maps that gesture to a window action against the host BaseWindow: + * detent 2 → toggle maximize — routed back THROUGH the shared chrome overlay + * (`page:request-toggle-maximize` → overlay's `toggleMaximize`) so the + * overlay stays the single source of truth for maximize state + + * chrome reflow. The overlay owns `maximized`/`preMaxBounds` and drives + * the SAME toggle the navbar double-click uses; toggling bounds here + * directly would desync it. + * detent 3 → toggle fullscreen — `BaseWindow.setFullScreen` (main owns the host). + * + * Deterministic, no timers: it rides Chromium's native multi-click counter rather + * than debouncing double-vs-triple itself (fsm-pilled). A triple-click therefore + * fires detent 2 THEN detent 3 within the same native sequence — by design the + * window grows monotonically (normal → maximized → fullscreen); exiting fullscreen + * leaves it maximized. + */ +import { createRequire } from 'node:module'; +import type { WebContents } from 'electron'; +import { publish, getSystemAddress } from './pubsub.js'; +import { getHybridWindowByContentWC } from './hybrid-page-host-registry.js'; + +// `ipcMain` is fetched lazily (only when the receiver is actually registered at +// runtime) rather than via a top-level `import { ipcMain } from 'electron'`. +// A top-level RUNTIME electron import makes this module un-importable under the +// unit runners (`ELECTRON_RUN_AS_NODE=1 electron --test` / `node --test`), where +// `electron` resolves to a path string with no named exports — which would trap +// the pure `windowGestureAction` behind an un-loadable module. Type-only imports +// (above) are erased at compile time and stay safe. This matches the repo +// convention that unit-tested main modules keep electron imports type-only. +const requireElectron = createRequire(import.meta.url); + +export type WindowGestureAction = 'toggle-maximize' | 'toggle-fullscreen' | null; + +/** Pure: map a native click detent to the window action it triggers. */ +export function windowGestureAction(detent: unknown): WindowGestureAction { + if (detent === 2) return 'toggle-maximize'; + if (detent === 3) return 'toggle-fullscreen'; + return null; +} + +let registered = false; + +/** + * Register the `page:content-window-gesture` IPC receiver exactly once. Idempotent — + * safe to call from every `wireHybridContentEvents` invocation. + */ +export function initHybridWindowGestureHandling(): void { + if (registered) return; + registered = true; + const { ipcMain } = requireElectron('electron') as typeof import('electron'); + ipcMain.on('page:content-window-gesture', (event, payload: unknown) => { + const detent = (payload as { detent?: unknown } | null)?.detent; + handleContentWindowGesture(event.sender, detent); + }); +} + +/** + * Route a content window gesture from `sender` (the content WebContents) to its + * host BaseWindow. Exported for the unit test (a real content WC is unavailable to + * node-side tests, so the test seeds the registry and drives this directly). + */ +export function handleContentWindowGesture(sender: WebContents, detent: unknown): void { + const action = windowGestureAction(detent); + if (!action) return; + const entry = getHybridWindowByContentWC(sender); + if (!entry || entry.baseWin.isDestroyed()) return; + if (action === 'toggle-maximize') { + // Route through the overlay (it owns maximize state + chrome reflow); see header. + publish(getSystemAddress(), 'page:request-toggle-maximize', { windowId: entry.windowId }); + } else { + try { + entry.baseWin.setFullScreen(!entry.baseWin.isFullScreen()); + } catch { /* window gone mid-toggle */ } + } +} diff --git a/apps/desktop/main/ipc.ts b/apps/desktop/main/ipc.ts index 0a91493c..30ed01fe 100644 --- a/apps/desktop/main/ipc.ts +++ b/apps/desktop/main/ipc.ts @@ -321,6 +321,7 @@ import { isEditableFocusedForWc, clearEditableFocusForWc, } from './hybrid-editable-focus.js'; +import { initHybridWindowGestureHandling } from './hybrid-window-gesture.js'; import { getIzuiCoordinator, @@ -1357,6 +1358,11 @@ export function wireHybridContentEvents( initHybridEditableFocusTracking(); contentWC.once('destroyed', () => clearEditableFocusForWc(contentWC.id)); + // Content-area double/triple-click window gesture (peek d8ce0ddf): register the + // `page:content-window-gesture` receiver once (idempotent). detent 2 → toggle + // maximize (via the overlay), detent 3 → toggle fullscreen. See hybrid-window-gesture.ts. + initHybridWindowGestureHandling(); + // ── Loading-timeout safety net (Stage B) ──────────────────────────────── // Armed on `did-start-loading`, cleared on `did-finish-load` / `did-stop-loading`. // Fires for hung loads (server accepts connection but never responds — the diff --git a/apps/desktop/renderer/page/overlay.js b/apps/desktop/renderer/page/overlay.js index c6e2e12d..5a563399 100644 --- a/apps/desktop/renderer/page/overlay.js +++ b/apps/desktop/renderer/page/overlay.js @@ -1257,6 +1257,18 @@ navbar.addEventListener('dblclick', (e) => { toggleMaximize(); }); +// Content-area double-click → maximize toggle (peek d8ce0ddf). The content +// preload detects a "dead space" double-click over the page (no editable/ +// interactive target, no text selected); main republishes it here so the overlay +// — the single source of truth for maximize state + chrome reflow — runs the +// SAME toggleMaximize the navbar double-click uses, keeping both in sync. (A +// content triple-click drives fullscreen in main directly; that path is +// OS-driven and needs no overlay state, so it isn't routed here.) +api.subscribe('page:request-toggle-maximize', (msg) => { + if (!isActive(msg)) return; + toggleMaximize(); +}); + // ── P1.4b-4: find-in-page ───────────────────────────────────────────────────── // The overlay owns the find-bar UI (mirrors page.js's in-renderer find bar). The // hybrid content webContents is MAIN-owned, so instead of calling findInPage -- 2.51.2