From 1ae7ae41c90bfe7252f4a194475d487d515e692b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 21:41:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=85=A8=E5=B1=8F=E5=AD=90?= =?UTF-8?q?=E9=A1=B5=E5=A4=96=E5=A3=B3=E5=B9=B6=E6=8E=A5=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components/modal/FullscreenSubpage.tsx:新增共享全屏子页,portal + 焦点陷阱 + role=dialog/aria-modal,Esc 不关闭、只能由内容自己的按钮退出。 components/modal/windowChrome.ts:把标题栏放行判据从 ThemedModal 抽出,供两种模态复用。 components/modal/ThemedModal.tsx:改为引用抽出的判据。 view/project-development/export/index.tsx:导出面板改用 FullscreenSubpage,删掉自写的 portal 包裹。 --- .../components/modal/FullscreenSubpage.tsx | 65 +++++++++++++++++++ .../src/components/modal/ThemedModal.tsx | 19 +----- .../src/components/modal/windowChrome.ts | 19 ++++++ .../view/project-development/export/index.tsx | 16 +---- 4 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx create mode 100644 apps/ai-game-creator-shell/src/components/modal/windowChrome.ts diff --git a/apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx b/apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx new file mode 100644 index 000000000..f8787896d --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/FullscreenSubpage.tsx @@ -0,0 +1,65 @@ +import { FocusTrap } from 'focus-trap-react'; +import { type ReactNode, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import { isWindowChromeBarTarget } from './windowChrome'; + +/** + * 全屏子页外壳:portal 到 `document.body`,整屏接管,并把键盘焦点锁在里面。 + * + * 与 [`ThemedModal`] 的分工:弹窗是页面上盖一层卡片,子页是整屏换页(导出产物面板、UI 编辑器 + * 这类)。两者都要求焦点留在模态内,但子页**没有关闭语义**: + * - 只有内容自己提供关闭按钮,**Esc 永远不关闭**——编辑 / 填表到一半误按 Esc 就丢工作不可接受; + * - 背景也不接收点击,没有「点遮罩关闭」。 + * + * `platform-theme--light` 只为共享组件提供 `--platform-*` 变量;子页自己的版式与配色由调用方 + * 用 Tailwind 内联值决定。 + */ +export function FullscreenSubpage({ + ariaLabel, + children, + className, +}: { + ariaLabel: string; + children: ReactNode; + /** 追加到固定覆盖层上的类名,例如 UI 编辑器要的 `!bg-transparent !p-0`。 */ + className?: string; +}) { + const panelRef = useRef(null); + + if (typeof document === 'undefined') return <>{children}; + + return createPortal( + isWindowChromeBarTarget(event.target), + fallbackFocus: () => panelRef.current!, + returnFocusOnDeactivate: true, + }} + > +
+
+ {children} +
+
+
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx index ec75bfca4..79cb127ba 100644 --- a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx +++ b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx @@ -2,25 +2,10 @@ import { FocusTrap } from 'focus-trap-react'; import { type CSSProperties, type ReactNode, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; +import { isWindowChromeBarTarget } from './windowChrome'; + type ThemedModalTheme = 'light' | 'dark'; -/** - * 自绘标题栏的标记:它是窗口边框,不属于模态内容。 - * - * focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接 - * `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏 - * 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱 - * 挡在模态之外,点空白处不会误触底层界面。 - */ -const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]'; - -function isWindowChromeBarTarget(target: EventTarget | null) { - return ( - target instanceof Element && - target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null - ); -} - export type ThemedModalProps = { open: boolean; ariaLabel: string; diff --git a/apps/ai-game-creator-shell/src/components/modal/windowChrome.ts b/apps/ai-game-creator-shell/src/components/modal/windowChrome.ts new file mode 100644 index 000000000..090120dc6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/windowChrome.ts @@ -0,0 +1,19 @@ +/** + * 自绘标题栏的标记:它是窗口边框,不属于模态内容。 + * + * focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接 + * `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏 + * 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱 + * 挡在模态之外,点空白处不会误触底层界面。 + * + * 单独成文件(而不是从 `ThemedModal` 导出):那边是纯组件文件,多导出一个函数会触发 + * `react-refresh/only-export-components`。 + */ +const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]'; + +export function isWindowChromeBarTarget(target: EventTarget | null) { + return ( + target instanceof Element && + target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx index 0276fc6de..d6cf75ca8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/index.tsx @@ -1,5 +1,4 @@ -import { createPortal } from 'react-dom'; - +import { FullscreenSubpage } from '../../../components/modal/FullscreenSubpage'; import type { DirectProjectConversationReadGate, DirectProjectConversationWriteGate, @@ -66,16 +65,7 @@ export default function ArtifactsPanel({ ); - if (typeof document === 'undefined') return content; - - return createPortal( - // `platform-theme--light` 只为共享组件(工具卡 / 思考块 / 输入区)提供 `--platform-*` - // 变量;本面板自己的版式与配色仍全部走 Tailwind 内联值。 -
-
- {content} -
-
, - document.body, + return ( + {content} ); }