From 3b89eb20c9f5059c211d9f1c8249257f3c7e84f0 Mon Sep 17 00:00:00 2001 From: liquidliang Date: Tue, 20 Jan 2026 20:49:09 +0800 Subject: [PATCH] =?UTF-8?q?docs(claude):=20=E6=B7=BB=E5=8A=A0=E6=96=B0?= =?UTF-8?q?=E8=A7=84=E5=88=99=20-=20React=20Context=20=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E4=B8=AD=E5=BF=85=E9=A1=BB=E4=BD=BF=E7=94=A8=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=BC=8F=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Context 提供的回调函数中更新状态时,使用闭包中的 context.appState 可能获取到过期的引用。 应该使用函数式更新 setAppState(prev => ...) 确保始终使用最新状态。 这个问题在多个弹窗/抽屉同时打开时特别容易出现。 --- CLAUDE.md | 56 ++++++ .../ttd-dialog/markdown-to-drawnix.tsx | 6 +- .../ttd-dialog/mermaid-to-drawnix.tsx | 6 +- .../src/components/ttd-dialog/ttd-dialog.tsx | 99 +++++----- .../src/components/winbox/WinBoxWindow.tsx | 179 +++++++++++++++++- .../src/components/winbox/winbox-custom.scss | 20 +- packages/drawnix/src/drawnix.tsx | 14 +- packages/drawnix/src/hooks/use-drawnix.tsx | 37 +++- 8 files changed, 341 insertions(+), 76 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e4f38774..ebb5ca96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1375,6 +1375,62 @@ const updateConfigThrottled = useCallback( - 节流时间根据操作开销选择:轻量操作 50-100ms,重量操作(SVG/Canvas)100-200ms - 使用 `useMemo` 包装 throttle 函数,避免每次渲染创建新实例 +#### React Context 回调中必须使用函数式更新 + +**场景**: 在 Context 提供的回调函数(如 `openDialog`, `closeDialog`)中更新状态时 + +❌ **错误示例**: +```typescript +// 错误:使用闭包中的 context.appState,可能是过期的引用 +const closeDialog = (dialogType: DialogType) => { + const newOpenDialogTypes = new Set(context.appState.openDialogTypes); + newOpenDialogTypes.delete(dialogType); + context.setAppState({ + ...context.appState, // 闭包中的旧状态! + openDialogTypes: newOpenDialogTypes, + }); +}; + +// 问题场景: +// 1. 打开弹窗 A:openDialogTypes = { A } +// 2. 打开弹窗 B:openDialogTypes = { A, B } +// 3. 关闭弹窗 A 时,closeDialog 中的 context.appState 可能仍是 { A } +// 4. 结果:openDialogTypes 变成 {},弹窗 B 也被关闭了! +``` + +✅ **正确示例**: +```typescript +// 正确:使用函数式更新,确保始终使用最新的状态 +const closeDialog = (dialogType: DialogType) => { + context.setAppState((prevState) => { + const newOpenDialogTypes = new Set(prevState.openDialogTypes); + newOpenDialogTypes.delete(dialogType); + return { + ...prevState, + openDialogTypes: newOpenDialogTypes, + }; + }); +}; + +// 同样适用于 openDialog +const openDialog = (dialogType: DialogType) => { + context.setAppState((prevState) => { + const newOpenDialogTypes = new Set(prevState.openDialogTypes); + newOpenDialogTypes.add(dialogType); + return { + ...prevState, + openDialogTypes: newOpenDialogTypes, + }; + }); +}; +``` + +**原因**: +- Context 的回调函数可能被旧的事件处理器或 useCallback 缓存调用 +- 闭包中的 `context.appState` 是创建回调时的快照,不是最新状态 +- 函数式更新 `setState(prev => ...)` 保证 `prev` 始终是最新状态 +- 这个问题在多个弹窗/抽屉同时打开时特别容易出现 + ### CSS/SCSS 规范 - 使用 BEM 命名规范 - 优先使用设计系统 CSS 变量 diff --git a/packages/drawnix/src/components/ttd-dialog/markdown-to-drawnix.tsx b/packages/drawnix/src/components/ttd-dialog/markdown-to-drawnix.tsx index cc401621..c2ed5c91 100644 --- a/packages/drawnix/src/components/ttd-dialog/markdown-to-drawnix.tsx +++ b/packages/drawnix/src/components/ttd-dialog/markdown-to-drawnix.tsx @@ -6,7 +6,7 @@ import { TTDDialogPanel } from './ttd-dialog-panel'; import { TTDDialogInput } from './ttd-dialog-input'; import { TTDDialogOutput } from './ttd-dialog-output'; import { TTDDialogSubmitShortcut } from './ttd-dialog-submit-shortcut'; -import { useDrawnix } from '../../hooks/use-drawnix'; +import { useDrawnix, DialogType } from '../../hooks/use-drawnix'; import { useI18n } from '../../i18n'; import { useBoard } from '@plait-board/react-board'; import { @@ -75,7 +75,7 @@ const getMarkdownExample = (language: 'zh' | 'en') => { const MarkdownToDrawnix = () => { - const { appState, setAppState } = useDrawnix(); + const { appState, setAppState, closeDialog } = useDrawnix(); const { t, language } = useI18n(); const [markdownToDrawnixLib, setMarkdownToDrawnixLib] = useState({ @@ -178,7 +178,7 @@ const MarkdownToDrawnix = () => { }); } - setAppState({ ...appState, openDialogType: null }); + closeDialog(DialogType.markdownToDrawnix); }; return ( diff --git a/packages/drawnix/src/components/ttd-dialog/mermaid-to-drawnix.tsx b/packages/drawnix/src/components/ttd-dialog/mermaid-to-drawnix.tsx index 3098a672..b66aa252 100644 --- a/packages/drawnix/src/components/ttd-dialog/mermaid-to-drawnix.tsx +++ b/packages/drawnix/src/components/ttd-dialog/mermaid-to-drawnix.tsx @@ -6,7 +6,7 @@ import { TTDDialogPanel } from './ttd-dialog-panel'; import { TTDDialogInput } from './ttd-dialog-input'; import { TTDDialogOutput } from './ttd-dialog-output'; import { TTDDialogSubmitShortcut } from './ttd-dialog-submit-shortcut'; -import { useDrawnix } from '../../hooks/use-drawnix'; +import { useDrawnix, DialogType } from '../../hooks/use-drawnix'; import { useI18n } from '../../i18n'; import { useBoard } from '@plait-board/react-board'; import { @@ -36,7 +36,7 @@ const MERMAID_EXAMPLE = 'flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]'; const MermaidToDrawnix = () => { - const { appState, setAppState } = useDrawnix(); + const { appState, setAppState, closeDialog } = useDrawnix(); const { t, language } = useI18n(); const [mermaidToDrawnixLib, setMermaidToDrawnixLib] = useState({ @@ -141,7 +141,7 @@ const MermaidToDrawnix = () => { }); } - setAppState({ ...appState, openDialogType: null }); + closeDialog(DialogType.mermaidToDrawnix); }; return ( diff --git a/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx b/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx index 204a2217..0c970084 100644 --- a/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx +++ b/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx @@ -24,7 +24,7 @@ const BatchImageGeneration = lazy(() => import('./batch-image-generation')); type ImageGenerationMode = 'single' | 'batch'; const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => { - const { appState, setAppState } = useDrawnix(); + const { appState, setAppState, openDialog, closeDialog } = useDrawnix(); const { language } = useI18n(); const board = useBoard(); @@ -133,7 +133,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => // 当对话框将要打开时,预先计算是否需要自动放大 // 这需要在 WinBox 组件渲染前确定,且逻辑需要与 AIImageGeneration 的模式判断一致 useEffect(() => { - if (appState.openDialogType === DialogType.aiImageGeneration) { + if (appState.openDialogTypes.has(DialogType.aiImageGeneration)) { // 如果有初始图片或初始提示词,说明是带内容进入,不自动放大(强制单图模式) const hasInitialContent = (aiImageData.initialImages && aiImageData.initialImages.length > 0) || @@ -151,35 +151,38 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => setImageDialogAutoMaximize(false); } } - }, [appState.openDialogType, aiImageData.initialImages, aiImageData.initialPrompt]); + }, [appState.openDialogTypes, aiImageData.initialImages, aiImageData.initialPrompt]); - // 使用 useRef 来跟踪上一次的 openDialogType,避免不必要的处理 - const prevOpenDialogTypeRef = useRef(null); + // 使用 useRef 来跟踪上一次打开的弹窗类型,避免不必要的处理 + const prevOpenDialogsRef = useRef>(new Set()); // 当 AI 图片生成对话框打开时,处理选中内容 useEffect(() => { - // 确保board存在并且弹窗确实要打开 - if (!board || !appState.openDialogType) { - prevOpenDialogTypeRef.current = appState.openDialogType; + // 确保board存在 + if (!board) { return; } - // 检查是否真的是新的对话框打开,而不是重复触发 - if (prevOpenDialogTypeRef.current === appState.openDialogType) { - // console.log('Dialog type unchanged, skipping processing...'); - return; - } + const currentDialogs = appState.openDialogTypes; + const prevDialogs = prevOpenDialogsRef.current; + + // 检查是否有新打开的图片生成弹窗 + const isImageDialogNewlyOpened = currentDialogs.has(DialogType.aiImageGeneration) && + !prevDialogs.has(DialogType.aiImageGeneration); + + // 检查是否有新打开的视频生成弹窗 + const isVideoDialogNewlyOpened = currentDialogs.has(DialogType.aiVideoGeneration) && + !prevDialogs.has(DialogType.aiVideoGeneration); + + // 更新上一次的状态 + prevOpenDialogsRef.current = new Set(currentDialogs); // 防止多次并发处理 if (isProcessingRef.current) { - // console.log('Already processing content, skipping...'); return; } - // 更新上一次的状态 - prevOpenDialogTypeRef.current = appState.openDialogType; - - if (appState.openDialogType === DialogType.aiImageGeneration) { + if (isImageDialogNewlyOpened) { const processSelection = async () => { isProcessingRef.current = true; @@ -266,7 +269,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => } // 处理 AI 视频生成的选中内容 - if (appState.openDialogType === DialogType.aiVideoGeneration) { + if (isVideoDialogNewlyOpened) { const processVideoSelection = async () => { isProcessingRef.current = true; @@ -365,15 +368,15 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => processVideoSelection(); } - }, [appState.openDialogType]); // Remove board dependency to prevent recursive updates + }, [appState.openDialogTypes]); // Remove board dependency to prevent recursive updates - // 清理处理状态当弹窗关闭时 + // 清理处理状态当所有弹窗关闭时 useEffect(() => { - if (!appState.openDialogType) { + if (appState.openDialogTypes.size === 0) { isProcessingRef.current = false; - prevOpenDialogTypeRef.current = null; + prevOpenDialogsRef.current = new Set(); } - }, [appState.openDialogType]); + }, [appState.openDialogTypes]); // WinBox 关闭回调 const handleImageDialogClose = useCallback(() => { @@ -388,12 +391,8 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => console.warn('Failed to update cache timestamp:', error); } } - // console.log('Image dialog closing - selection should be preserved'); - setAppState(prev => ({ - ...prev, - openDialogType: null, - })); - }, [setAppState]); + closeDialog(DialogType.aiImageGeneration); + }, [closeDialog]); const handleVideoDialogClose = useCallback(() => { // 在关闭前保存AI视频生成的缓存 @@ -407,22 +406,19 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => console.warn('Failed to update cache timestamp:', error); } } - // console.log('Video dialog closing - selection should be preserved'); - setAppState(prev => ({ - ...prev, - openDialogType: null, - })); - }, [setAppState]); + closeDialog(DialogType.aiVideoGeneration); + }, [closeDialog]); return ( <> { - setAppState({ - ...appState, - openDialogType: open ? DialogType.mermaidToDrawnix : null, - }); + if (open) { + openDialog(DialogType.mermaidToDrawnix); + } else { + closeDialog(DialogType.mermaidToDrawnix); + } }} > @@ -430,12 +426,13 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => { - setAppState({ - ...appState, - openDialogType: open ? DialogType.markdownToDrawnix : null, - }); + if (open) { + openDialog(DialogType.markdownToDrawnix); + } else { + closeDialog(DialogType.markdownToDrawnix); + } }} > @@ -444,7 +441,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => {/* AI 图片生成窗口 - 使用 WinBox */} onClose={handleImageDialogClose} width="80%" height="60%" - minWidth={1024} + minWidth={800} minHeight={500} x="center" y="center" @@ -478,7 +475,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => container={container} autoMaximize={imageDialogAutoMaximize} > - {appState.openDialogType === DialogType.aiImageGeneration && ( + {appState.openDialogTypes.has(DialogType.aiImageGeneration) && ( imageGenerationMode === 'batch' ? ( {language === 'zh' ? '加载中...' : 'Loading...'}}> handleImageModeChange('single')} /> @@ -499,7 +496,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) => {/* AI 视频生成窗口 - 使用 WinBox */} className="winbox-ai-generation winbox-ai-video-generation" container={container} > - {appState.openDialogType === DialogType.aiVideoGeneration && ( + {appState.openDialogTypes.has(DialogType.aiVideoGeneration) && ( void; /** 是否自动最大化 */ autoMaximize?: boolean; + /** 允许窗口移出视口时,至少保留在屏幕内的像素数(默认 50) */ + minVisiblePixels?: number; } /** @@ -149,6 +151,7 @@ export const WinBoxWindow: React.FC = ({ onMove, onResize, autoMaximize = false, + minVisiblePixels = 50, }) => { const winboxRef = useRef(null); const winboxElementRef = useRef(null); // WinBox 窗口的 DOM 元素 @@ -157,11 +160,127 @@ export const WinBoxWindow: React.FC = ({ const [isReady, setIsReady] = useState(false); const [winboxLoaded, setWinboxLoaded] = useState(!!WinBoxConstructor); + // 跟踪分屏状态 (使用 state 以便触发 hook 重新计算) + const [splitSide, _setSplitSide] = useState<'left' | 'right' | null>(null); + const splitSideRef = useRef<'left' | 'right' | null>(null); + // 保存原始 minWidth 以便分屏后恢复 + const originalMinWidthRef = useRef(null); + + const setSplitSide = useCallback((side: 'left' | 'right' | null) => { + _setSplitSide(side); + splitSideRef.current = side; + + // 退出分屏时恢复原始 minWidth + if (side === null && originalMinWidthRef.current !== null && winboxRef.current) { + winboxRef.current.minwidth = originalMinWidthRef.current; + originalMinWidthRef.current = null; + } + }, []); + + // 静态跟踪全局分屏占用情况 + const getOccupiedSides = useCallback(() => { + const sides = { left: false, right: false }; + if (typeof window === 'undefined' || !(window as any).WinBox) return sides; + + // 直接从 WinBox 栈中获取实例,避免受 CSS transform 影响 + const WinBox = (window as any).WinBox; + const allBoxes = WinBox.stack(); + const viewportWidth = window.innerWidth; + + allBoxes.forEach((wb: any) => { + // 忽略当前正在操作的窗口 + if (wb === winboxRef.current) return; + + const isHalfWidth = Math.abs(wb.width - viewportWidth / 2) < 20; + if (isHalfWidth) { + if (wb.x < 20) sides.left = true; + if (Math.abs((wb.x + wb.width) - viewportWidth) < 20) sides.right = true; + } + }); + return sides; + }, []); + + const handleSplit = useCallback(() => { + const wb = winboxRef.current; + // 安全检查:确保 WinBox 实例和其 DOM 元素都存在 + if (!wb || !wb.window) return; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const occupied = getOccupiedSides(); + + let targetSide: 'left' | 'right'; + + // 逻辑:如果已经分屏,切换到另一边 + if (splitSide === 'left') targetSide = 'right'; + else if (splitSide === 'right') targetSide = 'left'; + else { + // 第一次分屏逻辑 + const centerX = wb.x + wb.width / 2; + const screenMid = viewportWidth / 2; + + if (occupied.left && !occupied.right) { + targetSide = 'right'; + } else if (!occupied.left && occupied.right) { + targetSide = 'left'; + } else { + // 根据位置判断:靠近哪边去哪边,中间默认去右边 + if (centerX < screenMid) { + targetSide = 'left'; + } else { + targetSide = 'right'; + } + } + } + + // 保存原始 minWidth,并临时设置为较小的值以允许半屏 + if (originalMinWidthRef.current === null) { + originalMinWidthRef.current = wb.minwidth; + } + + const halfWidth = Math.floor(viewportWidth / 2); + + // 分屏时强制设置 minwidth 为一个足够小的值,确保可以缩小到半屏 + wb.minwidth = Math.min(200, halfWidth); + + // 执行分屏:先重置最大化状态 + if (wb.max) { + wb.restore(); + // restore 后需要等待一帧让 WinBox 完成状态更新 + requestAnimationFrame(() => { + if (!wb || !wb.window) return; + performSplit(wb, targetSide, halfWidth, viewportWidth, viewportHeight); + }); + } else { + performSplit(wb, targetSide, halfWidth, viewportWidth, viewportHeight); + } + }, [getOccupiedSides, splitSide, setSplitSide]); + + // 执行实际的分屏操作 + const performSplit = useCallback((wb: any, targetSide: 'left' | 'right', halfWidth: number, viewportWidth: number, viewportHeight: number) => { + if (!wb || !wb.window) return; + + // 关键:分屏前必须重置边界,否则 resize 会加上负边界的宽度 + wb.left = 0; + wb.right = 0; + wb.top = 0; + wb.bottom = 0; + + if (targetSide === 'left') { + wb.resize(halfWidth, viewportHeight).move(0, 0); + setSplitSide('left'); + } else { + // 使用 viewportWidth - halfWidth 确保精准靠右 + wb.resize(halfWidth, viewportHeight).move(viewportWidth - halfWidth, 0); + setSplitSide('right'); + } + }, [setSplitSide]); + // 应用 viewport scale 以确保缩放时窗口位置和大小不变 - // 注意:WinBox 使用 fixed 定位,所以不需要 enablePositionTracking + // 注意:分屏或最大化时禁用缩放补偿,防止超出屏幕 const refreshViewportScale = useViewportScale(winboxElementRef, { - enablePositionTracking: false, // WinBox 使用 fixed 定位 - enableScaleCompensation: true, // 启用反向缩放保持大小不变 + enablePositionTracking: false, + enableScaleCompensation: !splitSide && !winboxRef.current?.max, }); // 加载 WinBox @@ -171,6 +290,8 @@ export const WinBoxWindow: React.FC = ({ } }, [winboxLoaded]); + const isMovingRef = useRef(false); + // 处理窗口关闭 const handleClose = useCallback(() => { onClose?.(); @@ -224,6 +345,7 @@ export const WinBoxWindow: React.FC = ({ modal, background, border, + overflow: true, // 允许内容移出视口 class: classList, root: container || document.body, onclose: handleClose, @@ -231,12 +353,60 @@ export const WinBoxWindow: React.FC = ({ onrestore: onRestore, onfocus: onFocus, onblur: onBlur, - onmove: onMove, + onmove: function (this: any, x: number, y: number) { + if (this.max || this.min || isMovingRef.current) return; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const wbWidth = this.width; + const wbHeight = this.height; + const minVisible = minVisiblePixels; + + // 如果是手动移动(不是由 handleSplit 触发的移动),且位移较大,则退出分屏状态 + if (splitSideRef.current) { + const expectedX = splitSideRef.current === 'left' ? 0 : viewportWidth - wbWidth; + if (Math.abs(x - expectedX) > 50) { + setSplitSide(null); + } + } + + let newX = x; + let newY = y; + + // X 轴约束:左侧至少留 50px,右侧至少留 50px + if (x < minVisible - wbWidth) newX = minVisible - wbWidth; + if (x > viewportWidth - minVisible) newX = viewportWidth - minVisible; + + // Y 轴约束:顶部不能出 (确保标题栏可见),底部至少留 50px + if (y < 0) newY = 0; + if (y > viewportHeight - minVisible) newY = viewportHeight - minVisible; + + if (newX !== x || newY !== y) { + isMovingRef.current = true; + this.move(newX, newY); + isMovingRef.current = false; + } + + onMove?.(newX, newY); + }, onresize: onResize, }); winboxRef.current = wb; + // 添加分屏按钮 + wb.addControl({ + index: 0, // 在最大化按钮左边(WinBox 默认按钮索引从右往左是 0:close, 1:full, 2:max, 3:min) + // 但 WinBox 的 addControl 是往左添加,所以 index 影响排序 + class: 'wb-split', + image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjMiIHk9IjMiIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PGxpbmUgeDE9IjEyIiB5MT0iMyIgeDI9IjEyIiB5Mj0iMjEiPjwvbGluZT48L3N2Zz4=', + click: (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + handleSplit(); + } + }); + // 保存 WinBox 窗口的 DOM 元素引用,用于应用 viewport scale if (wb.window) { winboxElementRef.current = wb.window as HTMLDivElement; @@ -273,6 +443,7 @@ export const WinBoxWindow: React.FC = ({ // 使用 setTimeout 确保窗口完全创建后再最大化 setTimeout(() => { if (winboxRef.current) { + setStrictBoundaries(); winboxRef.current.maximize(); } }, 100); diff --git a/packages/drawnix/src/components/winbox/winbox-custom.scss b/packages/drawnix/src/components/winbox/winbox-custom.scss index 54e7ca0e..9ee937fd 100644 --- a/packages/drawnix/src/components/winbox/winbox-custom.scss +++ b/packages/drawnix/src/components/winbox/winbox-custom.scss @@ -4,8 +4,16 @@ @import '../../styles/z-index.scss'; // WinBox 容器全局 z-index +// 使用 !important 确保层级高于工具栏和 AI 输入框 +// 通过 .focus 类区分激活/非激活窗口 .winbox { - z-index: $z-dialog-ai-image !important; // Layer 5: Modals, 确保在 unified-toolbar 之上 + // 非激活窗口的 z-index + z-index: $z-dialog-ai-image !important; + + // 激活窗口的 z-index 更高 + &.focus { + z-index: ($z-dialog-ai-image + 100) !important; + } } .winbox-react { @@ -82,6 +90,7 @@ .wb-min, .wb-max, .wb-full, + .wb-split, .wb-close { width: 32px; height: 32px; @@ -99,6 +108,11 @@ } } + // 分屏按钮特殊大小(如果需要微调) + .wb-split { + background-size: 16px; + } + // 关闭按钮特殊样式 .wb-close:hover { background-color: rgba(255, 59, 48, 0.9); @@ -194,7 +208,7 @@ // AI 生成弹窗通用样式 .winbox-ai-generation { - z-index: $z-dialog-ai-image !important; // Layer 5: AI 图片/视频生成弹窗 + // 不使用 !important,让 WinBox 可以管理聚焦窗口的 z-index // 白底灰黑色 header 样式 .wb-header { @@ -212,6 +226,7 @@ .wb-min, .wb-max, .wb-full, + .wb-split, .wb-close { filter: invert(1) brightness(0.3); @@ -410,6 +425,7 @@ .wb-min, .wb-max, .wb-full, + .wb-split, .wb-close { width: 28px; height: 28px; diff --git a/packages/drawnix/src/drawnix.tsx b/packages/drawnix/src/drawnix.tsx index 409ad792..72426f98 100644 --- a/packages/drawnix/src/drawnix.tsx +++ b/packages/drawnix/src/drawnix.tsx @@ -131,7 +131,7 @@ export const Drawnix: React.FC = ({ pointer: PlaitPointerType.hand, isMobile: md.mobile() !== null, isPencilMode: false, - openDialogType: null, + openDialogTypes: new Set(), dialogInitialData: null, openCleanConfirm: false, openSettings: false, @@ -186,9 +186,13 @@ export const Drawnix: React.FC = ({ setMediaLibraryOpen(true); }, [closeAllDrawers]); - // 使用 useCallback 稳定 setAppState 函数引用 - const stableSetAppState = useCallback((newAppState: DrawnixState) => { - setAppState(newAppState); + // 使用 useCallback 稳定 setAppState 函数引用,支持函数式更新 + const stableSetAppState = useCallback((newAppState: DrawnixState | ((prev: DrawnixState) => DrawnixState)) => { + if (typeof newAppState === 'function') { + setAppState(newAppState); + } else { + setAppState(newAppState); + } }, []); const updateAppState = useCallback((newAppState: Partial) => { @@ -893,7 +897,7 @@ const DrawnixContent: React.FC = ({ - {appState.openDialogType && ( + {appState.openDialogTypes.size > 0 && ( diff --git a/packages/drawnix/src/hooks/use-drawnix.tsx b/packages/drawnix/src/hooks/use-drawnix.tsx index 42d2414a..680caefb 100644 --- a/packages/drawnix/src/hooks/use-drawnix.tsx +++ b/packages/drawnix/src/hooks/use-drawnix.tsx @@ -51,7 +51,10 @@ export type DrawnixState = { pointer: DrawnixPointerType; isMobile: boolean; isPencilMode: boolean; - openDialogType: DialogType | null; + /** @deprecated 使用 openDialogTypes 代替,保留用于向后兼容 */ + openDialogType?: DialogType | null; + /** 当前打开的弹窗类型集合,支持同时打开多个弹窗 */ + openDialogTypes: Set; dialogInitialData?: DialogInitialData | null; openCleanConfirm: boolean; openSettings: boolean; @@ -61,15 +64,16 @@ export type DrawnixState = { export const DrawnixContext = createContext<{ appState: DrawnixState; - setAppState: (appState: DrawnixState) => void; + setAppState: (appState: DrawnixState | ((prev: DrawnixState) => DrawnixState)) => void; board: DrawnixBoard | null; } | null>(null); export const useDrawnix = (): { appState: DrawnixState; - setAppState: (appState: DrawnixState) => void; + setAppState: (appState: DrawnixState | ((prev: DrawnixState) => DrawnixState)) => void; board: DrawnixBoard | null; openDialog: (dialogType: DialogType, initialData?: DialogInitialData) => void; + closeDialog: (dialogType: DialogType) => void; } => { const context = useContext(DrawnixContext); @@ -80,14 +84,31 @@ export const useDrawnix = (): { } const openDialog = (dialogType: DialogType, initialData?: DialogInitialData) => { - context.setAppState({ - ...context.appState, - openDialogType: dialogType, - dialogInitialData: initialData || null + // 使用函数式更新,确保始终使用最新的状态 + context.setAppState((prevState) => { + const newOpenDialogTypes = new Set(prevState.openDialogTypes); + newOpenDialogTypes.add(dialogType); + return { + ...prevState, + openDialogTypes: newOpenDialogTypes, + dialogInitialData: initialData || null + }; }); }; - return { ...context, openDialog }; + const closeDialog = (dialogType: DialogType) => { + // 使用函数式更新,确保始终使用最新的状态 + context.setAppState((prevState) => { + const newOpenDialogTypes = new Set(prevState.openDialogTypes); + newOpenDialogTypes.delete(dialogType); + return { + ...prevState, + openDialogTypes: newOpenDialogTypes, + }; + }); + }; + + return { ...context, openDialog, closeDialog }; }; export const useSetPointer = () => {