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); }