From bf3bcaa3fe49cff1fa448013b4c0c5775ea37e62 Mon Sep 17 00:00:00 2001 From: Pasumao <1830316810@qq.com> Date: Sat, 29 Aug 2026 12:45:22 +0800 Subject: [PATCH] fix(windows): keep interactive elements clickable through the titlebar drag region The injected full-width drag region sits above every page element at z-index 2147483644, so -webkit-app-region resolves to drag for the whole 36px strip and the global no-drag rule on buttons below it never wins (app-region is resolved by paint order; pointer-events does not opt out). Every interactive control that lives inside the strip - the session header, sidebar collapse, third-party plugin headers - was unclickable on Windows. Fix: punch transparent no-drag patches one layer above the drag region for every interactive element intersecting the strip, tracked with a MutationObserver plus scroll/resize listeners (rAF-throttled). Patches are pointer-events:none so real clicks fall through to the controls; empty strip areas keep dragging the window. Works for any renderer content, including third-party client plugins. --- src/preload/windows-titlebar.ts | 74 +++++++++++++++++++++++++++++++++ test/windows-titlebar.test.ts | 19 +++++++++ 2 files changed, 93 insertions(+) diff --git a/src/preload/windows-titlebar.ts b/src/preload/windows-titlebar.ts index 145a2c4a3..0ad88095e 100644 --- a/src/preload/windows-titlebar.ts +++ b/src/preload/windows-titlebar.ts @@ -1,9 +1,14 @@ import type { IpcRenderer } from 'electron' +import { WINDOWS_TITLEBAR_HEIGHT } from '../shared/desktop-menu' const LAYOUT_STYLE_ID = 'dsh-desktop-windows-titlebar-layout-style' const DRAG_REGION_ID = 'dsh-desktop-windows-drag-region' const SIDEBAR_WIDTH_PROPERTY = '--dsh-desktop-windows-sidebar-width' const CAPTION_WIDTH_PROPERTY = '--dsh-desktop-windows-caption-width' +const NO_DRAG_PATCH_SELECTOR = + 'button, a, input, select, textarea, [role="button"], [data-dsh-no-drag]' +// One above the drag region (2147483644), below the update card (2147483646). +const NO_DRAG_PATCH_Z_INDEX = '2147483645' interface TitlebarLayoutMountOptions { document: Document @@ -16,6 +21,7 @@ export function mountWindowsTitlebarLayout(options: TitlebarLayoutMountOptions): installLayout(document) installDragRegion(document) + installNoDragPatches(document) trackSidebarLayout(document) document.addEventListener('pointerdown', () => { @@ -92,6 +98,74 @@ function installDragRegion(document: Document): void { document.body.appendChild(dragRegion) } +/** + * The drag region above intentionally sits on top of every page element, so + * `-webkit-app-region` resolves to `drag` for the whole strip and the global + * `no-drag` rule on buttons below it never wins (app-region is resolved by + * paint order, not DOM proximity — pointer-events does not opt out of it). + * Punch transparent `no-drag` holes one layer above the drag region for every + * interactive element that intersects the strip, so Harness controls (and + * third-party plugin UI) stay clickable while the rest of the strip keeps + * dragging the window. + */ +function installNoDragPatches(document: Document): void { + const patches = new Map() + + const sync = (): void => { + for (const [element, patch] of patches) { + if (!element.isConnected) { + patch.remove() + patches.delete(element) + } + } + for (const element of document.querySelectorAll(NO_DRAG_PATCH_SELECTOR)) { + const rect = element.getBoundingClientRect() + if (rect.width <= 0 || rect.bottom <= 0 || rect.top >= WINDOWS_TITLEBAR_HEIGHT) { + const stale = patches.get(element) + if (stale) { + stale.remove() + patches.delete(element) + } + continue + } + const top = Math.max(rect.top, 0) + const height = Math.min(rect.bottom, WINDOWS_TITLEBAR_HEIGHT) - top + let patch = patches.get(element) + if (!patch) { + patch = document.createElement('div') + patch.setAttribute('aria-hidden', 'true') + patch.style.position = 'fixed' + patch.style.pointerEvents = 'none' + patch.style.userSelect = 'none' + patch.style.background = 'transparent' + patch.style.zIndex = NO_DRAG_PATCH_Z_INDEX + patch.style.setProperty('-webkit-app-region', 'no-drag') + document.body.appendChild(patch) + patches.set(element, patch) + } + patch.style.left = `${rect.left}px` + patch.style.top = `${top}px` + patch.style.width = `${rect.width}px` + patch.style.height = `${height}px` + } + } + + let frame: number | null = null + const schedule = (): void => { + if (frame !== null) return + frame = window.requestAnimationFrame(() => { + frame = null + sync() + }) + } + + const observer = new MutationObserver(schedule) + observer.observe(document.documentElement, { childList: true, subtree: true }) + window.addEventListener('resize', schedule, { passive: true }) + window.addEventListener('scroll', schedule, { passive: true, capture: true }) + sync() +} + function trackSidebarLayout(document: Document): void { let observedSidebarColumn: HTMLElement | null = null const resizeObserver = new ResizeObserver(() => updateSidebarWidth()) diff --git a/test/windows-titlebar.test.ts b/test/windows-titlebar.test.ts index ef735302b..4288147e3 100644 --- a/test/windows-titlebar.test.ts +++ b/test/windows-titlebar.test.ts @@ -48,6 +48,25 @@ describe('Windows titlebar menu', () => { expect(preload).toContain("document.documentElement.style.setProperty(SIDEBAR_WIDTH_PROPERTY, '0px')") }) + it('punches no-drag holes above the drag region so strip controls stay clickable', async () => { + const preload = await readFile('src/preload/windows-titlebar.ts', 'utf8') + + expect(preload).toContain('installNoDragPatches(document)') + expect(preload).toContain( + "'button, a, input, select, textarea, [role=\"button\"], [data-dsh-no-drag]'" + ) + // Patches must sit one layer above the drag region to win the app-region + // resolution, and must not intercept clicks themselves. + expect(preload).toContain("const NO_DRAG_PATCH_Z_INDEX = '2147483645'") + expect(preload).toContain("patch.style.zIndex = NO_DRAG_PATCH_Z_INDEX") + expect(preload).toContain("patch.style.pointerEvents = 'none'") + expect(preload).toContain("patch.style.setProperty('-webkit-app-region', 'no-drag')") + // Patches only cover the titlebar strip and track layout changes. + expect(preload).toContain('rect.top >= WINDOWS_TITLEBAR_HEIGHT') + expect(preload).toContain("addEventListener('scroll', schedule, { passive: true, capture: true })") + expect(preload).toContain("new MutationObserver(schedule)") + }) + it('accepts only the fixed menu command allowlist', async () => { const main = await readFile('src/main/index.ts', 'utf8')