From ca4a58c762fde2d069fe7b59ad7bd6cf2a971951 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 13 Sep 2026 14:19:46 +0800 Subject: [PATCH] fix: keep full-window dialogs responsive to mouse input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render feedback, parsing diagnostics, knowledge confirmations, WeChat binding and image preview overlays through body portals so native titlebar drag regions cannot intercept their controls. Preserve themed form styles and modal focus isolation. Validation: 320 targeted tests, typecheck and lint passed. Native Windows hover and close checks passed for feedback and parsing diagnostics in light and dark themes at two sizes. Full suite timed out without a summary. Release note: 修复反馈等弹窗与窗口标题栏重叠时,关闭按钮无法正常悬停和点击的问题。 --- UI-DESIGN.md | 3 ++ .../in-app-feedback/technical-design.md | 2 ++ src/renderer/src/App.test.tsx | 5 +-- src/renderer/src/App.tsx | 6 ++-- .../src/ChannelSettingsSection.test.tsx | 3 ++ src/renderer/src/ChannelSettingsSection.tsx | 6 ++-- .../DocumentParsingSettingsSection.test.tsx | 10 +++--- .../src/DocumentParsingSettingsSection.tsx | 6 ++-- src/renderer/src/FeedbackDialog.test.tsx | 1 + src/renderer/src/FeedbackDialog.tsx | 6 ++-- src/renderer/src/KnowledgeWorkspace.test.tsx | 33 +++++++------------ src/renderer/src/KnowledgeWorkspace.tsx | 21 +++++++----- src/renderer/src/styles.css | 22 ++++++++++--- 13 files changed, 74 insertions(+), 50 deletions(-) diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 30d5741a..95b5f257 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -393,6 +393,9 @@ `34px × 34px` 点击区域。 - 发布说明 Modal 与其他应用级 Modal 一样使用整窗遮罩;遮罩必须覆盖主侧栏、右侧内容和 顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。 +- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,并声明 + `-webkit-app-region: no-drag`,避免页面层叠上下文中的遮罩受 Electron 原生拖动区域影响。 + 保留 React Context、关闭与焦点管理;表单样式不得依赖已移出的页面祖先选择器。 - 当前会话存在 Task 时,Task 上下文栏位于标题栏下方并承载任务摘要与详情;没有 Task 时 整行隐藏。折叠状态保持为 `46px` 单行表面,与右侧工作栏标签栏等高,左右分隔线必须水平 对齐。该栏不重复放置新建入口。 diff --git a/docs/features/in-app-feedback/technical-design.md b/docs/features/in-app-feedback/technical-design.md index a6ab956e..d30b5aed 100644 --- a/docs/features/in-app-feedback/technical-design.md +++ b/docs/features/in-app-feedback/technical-design.md @@ -156,6 +156,8 @@ Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详 ### 3.4 可访问性 - 对话框使用 `role="dialog"`、`aria-modal="true"` 和可见标题。 +- 整窗遮罩通过 React portal 挂到 `document.body`,遵循 + [全局遮罩与原生拖动区域规则](../../../UI-DESIGN.md#610-应用顶栏与全局操作)。 - 复用 `activateModalFocus()` 与 `trapTabFocus()`,打开后聚焦反馈类型,关闭后恢复入口 按钮焦点。 - 非提交状态支持 Escape 关闭;提交中保持对话框打开,直到请求完成或到达有界超时。 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index f627a1ea..20a627f1 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -5693,6 +5693,7 @@ describe("App", () => { const imageDialog = await screen.findByRole("dialog", { name: "页面截图.png", }); + expect(imageDialog.parentElement?.parentElement).toBe(document.body); expect( within(imageDialog).getByRole("img", { name: "页面截图.png" }), ).toHaveAttribute("src", imageAttachment.contentUrl); @@ -5700,7 +5701,7 @@ describe("App", () => { name: "关闭图片查看器", }); expect(closeViewer).toHaveFocus(); - expect(document.querySelector("main")?.inert).toBe(true); + expect(document.querySelector(".app-shell")?.inert).toBe(true); fireEvent.keyDown(closeViewer, { key: "Tab" }); expect( within(imageDialog).getByRole("button", { @@ -5712,7 +5713,7 @@ describe("App", () => { expect( screen.queryByRole("dialog", { name: "页面截图.png" }), ).not.toBeInTheDocument(); - expect(document.querySelector("main")?.inert).toBe(false); + expect(document.querySelector(".app-shell")?.inert).toBe(false); fireEvent.click( within(userArticle).getByRole("button", { name: "下载图片 页面截图.png", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 525845b6..63fdb859 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -56,6 +56,7 @@ import { type SetStateAction, } from "react"; import { useTranslation } from "react-i18next"; +import { createPortal } from "react-dom"; import { knowledgeReferenceKey } from "../../shared/knowledge-reference"; import type { TFunction } from "i18next"; import type { @@ -10928,7 +10929,7 @@ function App(): React.JSX.Element { reference={citationDialog.reference} /> )} - {imageViewerItem && ( + {imageViewerItem && createPortal(
{ @@ -10980,7 +10981,8 @@ function App(): React.JSX.Element { {imageViewerItem.title}
- + , + document.body )} {customTaskDialog && activeProject?.kind === "user" && ( { const close = await screen.findByRole('button', { name: '关闭微信绑定' }) + expect(screen.getByRole('dialog').parentElement?.parentElement).toBe( + document.body + ) expect( screen.getByText( '请在微信中依次打开“设置 → 插件 → ClawBot → 开始扫一扫”,扫描下方二维码。二维码不会发送到第三方页面。' diff --git a/src/renderer/src/ChannelSettingsSection.tsx b/src/renderer/src/ChannelSettingsSection.tsx index a3860ebd..a84f0822 100644 --- a/src/renderer/src/ChannelSettingsSection.tsx +++ b/src/renderer/src/ChannelSettingsSection.tsx @@ -6,6 +6,7 @@ import { } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' import QRCode from 'qrcode' import type { ChannelConnectionTestResult, @@ -512,7 +513,7 @@ function WeixinQrDialog({ ) : undefined - return ( + return createPortal(
)}
-
+ , + document.body ) } diff --git a/src/renderer/src/DocumentParsingSettingsSection.test.tsx b/src/renderer/src/DocumentParsingSettingsSection.test.tsx index 863c907d..46212c7e 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.test.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.test.tsx @@ -513,7 +513,7 @@ describe('DocumentParsingSettingsSection', () => { }) it('runs a real-file diagnostic flow and displays its result', async () => { - render() + const { container } = render() await screen.findByText('PP-OCRv6 Tiny') const trigger = screen.getByRole('button', { @@ -525,15 +525,13 @@ describe('DocumentParsingSettingsSection', () => { const dialog = await screen.findByRole('dialog', { name: '解析测试结果' }) + expect(dialog.parentElement?.parentElement).toBe(document.body) expect(dialog).toHaveTextContent('扫描件识别正文') const close = screen.getByRole('button', { name: '关闭结果' }) await waitFor(() => expect(close).toHaveFocus()) - const backgroundSection = trigger.closest( - '.settings-section' - ) - expect(backgroundSection?.inert).toBe(true) + expect(container.inert).toBe(true) expect( fireEvent.keyDown(dialog, { key: 'Tab' }) ).toBe(false) @@ -544,7 +542,7 @@ describe('DocumentParsingSettingsSection', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() ) await waitFor(() => expect(trigger).toHaveFocus()) - expect(backgroundSection?.inert).toBe(false) + expect(container.inert).toBe(false) expect(test).toHaveBeenCalledWith('chat-attachment') expect(update).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/DocumentParsingSettingsSection.tsx b/src/renderer/src/DocumentParsingSettingsSection.tsx index 2099bcff..e68f89c1 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.tsx @@ -20,6 +20,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' import type { DocumentParsingDiagnostic, DocumentOcrModelCatalogViewEntry, @@ -104,7 +105,7 @@ function DiagnosticDialog({ useEffect(() => { return activateModalFocus(() => closeRef.current) }, []) - return ( + return createPortal(
{ @@ -190,7 +191,8 @@ function DiagnosticDialog({
)} - + , + document.body ) } diff --git a/src/renderer/src/FeedbackDialog.test.tsx b/src/renderer/src/FeedbackDialog.test.tsx index c1815ca3..7bebb7f7 100644 --- a/src/renderer/src/FeedbackDialog.test.tsx +++ b/src/renderer/src/FeedbackDialog.test.tsx @@ -198,6 +198,7 @@ describe('FeedbackDialog', () => { const dialog = screen.getByRole('dialog', { name: '提交反馈' }) + expect(dialog.parentElement).toBe(document.body) const category = screen.getByLabelText('反馈类型') expect(category).toHaveFocus() fireEvent.change(screen.getByLabelText('标题'), { diff --git a/src/renderer/src/FeedbackDialog.tsx b/src/renderer/src/FeedbackDialog.tsx index 334c31c1..17090619 100644 --- a/src/renderer/src/FeedbackDialog.tsx +++ b/src/renderer/src/FeedbackDialog.tsx @@ -12,6 +12,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' import type { AppInfo } from '../../shared/contracts' import { feedbackCategories, @@ -326,7 +327,7 @@ export function FeedbackDialog({ } } - return ( + return createPortal(
)}
- + , + document.body ) } diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx index 28ebe310..e606988e 100644 --- a/src/renderer/src/KnowledgeWorkspace.test.tsx +++ b/src/renderer/src/KnowledgeWorkspace.test.tsx @@ -385,6 +385,7 @@ describe('KnowledgeWorkspace', () => { '将删除此来源的 1 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。' ) expect(onRemoveSource).not.toHaveBeenCalled() + expect(screen.getByRole('alertdialog').parentElement).toBe(document.body) fireEvent.click( screen.getByRole('button', { name: '移除来源' }) ) @@ -1920,7 +1921,9 @@ describe('KnowledgeWorkspace', () => { '将永久删除实体“GoodBuddy”及其 1 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。' ) expect(onDeleteEntity).not.toHaveBeenCalled() + expect(screen.getByRole('alertdialog').parentElement).toBe(document.body) fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() fireEvent.click( screen.getByRole('button', { name: '删除关系 使用' }) @@ -2086,7 +2089,7 @@ describe('KnowledgeWorkspace', () => { }) it('isolates and traps focus in the library edit dialog', async () => { - render() + const { container } = render() const trigger = screen.getByRole('button', { name: '编辑' }) trigger.focus() @@ -2095,13 +2098,10 @@ describe('KnowledgeWorkspace', () => { const dialog = screen.getByRole('dialog', { name: '编辑知识库' }) + expect(dialog.parentElement).toBe(document.body) const nameInput = screen.getByLabelText('名称') expect(nameInput).toHaveFocus() - expect( - document.querySelector( - '.knowledge-workspace__main' - )?.inert - ).toBe(true) + expect(container.inert).toBe(true) fireEvent.keyDown(nameInput, { key: 'Tab', shiftKey: true }) expect( screen.getByRole('button', { name: '保存修改' }) @@ -2109,16 +2109,12 @@ describe('KnowledgeWorkspace', () => { fireEvent.keyDown(dialog, { key: 'Escape' }) await waitFor(() => expect(trigger).toHaveFocus()) - expect( - document.querySelector( - '.knowledge-workspace__main' - )?.inert - ).toBe(false) + expect(container.inert).toBe(false) }) it('confirms that deleting a managed library removes managed copies', async () => { const onDeleteLibrary = vi.fn() - render( + const { container } = render( @@ -2131,19 +2127,12 @@ describe('KnowledgeWorkspace', () => { const dialog = screen.getByRole('dialog', { name: '删除知识库确认' }) + expect(dialog.parentElement).toBe(document.body) expect(screen.getByRole('button', { name: '取消' })).toHaveFocus() - expect( - document.querySelector( - '.knowledge-workspace__main' - )?.inert - ).toBe(true) + expect(container.inert).toBe(true) fireEvent.keyDown(dialog, { key: 'Escape' }) await waitFor(() => expect(trigger).toHaveFocus()) - expect( - document.querySelector( - '.knowledge-workspace__main' - )?.inert - ).toBe(false) + expect(container.inert).toBe(false) fireEvent.click(trigger) expect( screen.getByText( diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx index 60761117..79ff1a08 100644 --- a/src/renderer/src/KnowledgeWorkspace.tsx +++ b/src/renderer/src/KnowledgeWorkspace.tsx @@ -41,6 +41,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' import type { TFunction } from 'i18next' import type { KnowledgeDocumentItem as SharedKnowledgeDocumentItem, @@ -890,7 +891,7 @@ function EditLibraryDialog({ } } - return ( + return createPortal(
- + , + document.body ) } @@ -998,7 +1000,7 @@ function DeleteLibraryDialog({ } } - return ( + return createPortal(
- + , + document.body ) } @@ -1097,7 +1100,7 @@ function RemoveSourceDialog({ } } - return ( + return createPortal(
- + , + document.body ) } @@ -1248,7 +1252,7 @@ function GraphDestructiveDialog({ } } - return ( + return createPortal(
- + , + document.body ) } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 87d0fe1c..be06c487 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -6955,12 +6955,13 @@ button > svg { } .image-viewer-backdrop { + -webkit-app-region: no-drag; position: fixed; z-index: 80; display: grid; padding: var(--space-4); background: var(--overlay-backdrop); - inset: 38px 0 0; + inset: 0; place-items: center; } @@ -9935,12 +9936,13 @@ details.settings-section > :not(summary) + :not(summary) { } .channel-qr-backdrop { + -webkit-app-region: no-drag; position: fixed; z-index: var(--z-dialog); display: grid; padding: var(--space-4); background: var(--overlay-backdrop); - inset: 38px 0 0; + inset: 0; place-items: center; } @@ -10060,6 +10062,7 @@ details.settings-section > :not(summary) + :not(summary) { } .feedback-dialog-backdrop { + -webkit-app-region: no-drag; position: fixed; z-index: var(--z-dialog); display: grid; @@ -10749,6 +10752,7 @@ details.settings-section > :not(summary) + :not(summary) { } .document-parsing-diagnostic-backdrop { + -webkit-app-region: no-drag; position: fixed; z-index: var(--z-dialog); display: grid; @@ -12794,6 +12798,7 @@ details.settings-section > :not(summary) + :not(summary) { } .knowledge-confirm-dialog-backdrop { + -webkit-app-region: no-drag; position: fixed; z-index: var(--z-dialog); display: grid; @@ -12836,6 +12841,7 @@ details.settings-section > :not(summary) + :not(summary) { } .knowledge-library-dialog-backdrop { + -webkit-app-region: no-drag; position: fixed; z-index: var(--z-dialog); display: grid; @@ -12890,7 +12896,7 @@ details.settings-section > :not(summary) + :not(summary) { gap: var(--space-4); } -.knowledge-page +:is(.knowledge-page, .knowledge-library-dialog) :is( input:not([type]), input[type='text'], @@ -15954,7 +15960,15 @@ details.settings-section > :not(summary) + :not(summary) { color: var(--text-primary); } -:root[data-theme='dark'] .app-shell strong { +:root[data-theme='dark'] :is( + .app-shell, + .feedback-dialog, + .document-parsing-diagnostic, + .knowledge-library-dialog, + .knowledge-confirm-dialog, + .channel-qr-dialog, + .image-viewer-dialog +) strong { color: var(--text-primary); }