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