fix: keep full-window dialogs responsive to mouse input

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: 修复反馈等弹窗与窗口标题栏重叠时,关闭按钮无法正常悬停和点击的问题。
This commit is contained in:
mesalogo
2026-09-13 14:19:46 +08:00
parent ea45533137
commit ca4a58c762
13 changed files with 74 additions and 50 deletions
+3 -2
View File
@@ -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<HTMLElement>(".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<HTMLElement>(".app-shell")?.inert).toBe(false);
fireEvent.click(
within(userArticle).getByRole("button", {
name: "下载图片 页面截图.png",
+4 -2
View File
@@ -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(
<div
className="image-viewer-backdrop"
onMouseDown={(event) => {
@@ -10980,7 +10981,8 @@ function App(): React.JSX.Element {
<img alt={imageViewerItem.title} src={imageViewerItem.src} />
</div>
</section>
</div>
</div>,
document.body
)}
{customTaskDialog && activeProject?.kind === "user" && (
<CustomTaskDialog
@@ -363,6 +363,9 @@ describe('ChannelSettingsSection', () => {
const close = await screen.findByRole('button', {
name: '关闭微信绑定'
})
expect(screen.getByRole('dialog').parentElement?.parentElement).toBe(
document.body
)
expect(
screen.getByText(
'请在微信中依次打开“设置 → 插件 → ClawBot → 开始扫一扫”,扫描下方二维码。二维码不会发送到第三方页面。'
+4 -2
View File
@@ -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(
<div className="channel-qr-backdrop">
<section
aria-labelledby="channel-qr-title"
@@ -639,7 +640,8 @@ function WeixinQrDialog({
</div>
)}
</section>
</div>
</div>,
document.body
)
}
@@ -513,7 +513,7 @@ describe('DocumentParsingSettingsSection', () => {
})
it('runs a real-file diagnostic flow and displays its result', async () => {
render(<DocumentParsingSettingsSection />)
const { container } = render(<DocumentParsingSettingsSection />)
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<HTMLElement>(
'.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()
})
@@ -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(
<div
className="document-parsing-diagnostic-backdrop"
onMouseDown={(event) => {
@@ -190,7 +191,8 @@ function DiagnosticDialog({
</div>
)}
</section>
</div>
</div>,
document.body
)
}
+1
View File
@@ -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('标题'), {
+4 -2
View File
@@ -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(
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
@@ -760,6 +761,7 @@ export function FeedbackDialog({
</form>
)}
</div>
</div>
</div>,
document.body
)
}
+11 -22
View File
@@ -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(<KnowledgeWorkspace {...createProps()} />)
const { container } = render(<KnowledgeWorkspace {...createProps()} />)
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<HTMLElement>(
'.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<HTMLElement>(
'.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(
<KnowledgeWorkspace
{...createProps({ onDeleteLibrary })}
/>
@@ -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<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
expect(container.inert).toBe(true)
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
expect(container.inert).toBe(false)
fireEvent.click(trigger)
expect(
screen.getByText(
+13 -8
View File
@@ -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(
<div
aria-label={t('edit.ariaLabel')}
aria-modal="true"
@@ -961,7 +962,8 @@ function EditLibraryDialog({
</button>
</div>
</form>
</div>
</div>,
document.body
)
}
@@ -998,7 +1000,7 @@ function DeleteLibraryDialog({
}
}
return (
return createPortal(
<div
aria-label={t('delete.ariaLabel')}
aria-modal="true"
@@ -1056,7 +1058,8 @@ function DeleteLibraryDialog({
</button>
</div>
</div>
</div>
</div>,
document.body
)
}
@@ -1097,7 +1100,7 @@ function RemoveSourceDialog({
}
}
return (
return createPortal(
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
@@ -1155,7 +1158,8 @@ function RemoveSourceDialog({
</button>
</div>
</section>
</div>
</div>,
document.body
)
}
@@ -1248,7 +1252,7 @@ function GraphDestructiveDialog({
}
}
return (
return createPortal(
<div
aria-describedby={descriptionId}
aria-labelledby={titleId}
@@ -1296,7 +1300,8 @@ function GraphDestructiveDialog({
</button>
</div>
</section>
</div>
</div>,
document.body
)
}
+18 -4
View File
@@ -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);
}