Merge branch 'develop' of https://github.com/ljquan/aitu into develop

This commit is contained in:
liquidliang committed 2026-01-20 21:00:04 +08:00
commit 3264ce8539
8 files changed
+341 -76

No files matched your search

+56
View File
@@ -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 变量
@@ -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<MarkdownToDrawnixLibProps>({
@@ -178,7 +178,7 @@ const MarkdownToDrawnix = () => {
});
}
setAppState({ ...appState, openDialogType: null });
closeDialog(DialogType.markdownToDrawnix);
};
return (
@@ -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<MermaidToDrawnixLibProps>({
@@ -141,7 +141,7 @@ const MermaidToDrawnix = () => {
});
}
setAppState({ ...appState, openDialogType: null });
closeDialog(DialogType.mermaidToDrawnix);
};
return (
@@ -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<typeof appState.openDialogType>(null);
// 使用 useRef 来跟踪上一次打开的弹窗类型,避免不必要的处理
const prevOpenDialogsRef = useRef<Set<DialogType>>(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 (
<>
<Dialog
open={appState.openDialogType === DialogType.mermaidToDrawnix}
open={appState.openDialogTypes.has(DialogType.mermaidToDrawnix)}
onOpenChange={(open) => {
setAppState({
...appState,
openDialogType: open ? DialogType.mermaidToDrawnix : null,
});
if (open) {
openDialog(DialogType.mermaidToDrawnix);
} else {
closeDialog(DialogType.mermaidToDrawnix);
}
}}
>
<DialogContent className="Dialog ttd-dialog" container={container}>
@@ -430,12 +426,13 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
</DialogContent>
</Dialog>
<Dialog
open={appState.openDialogType === DialogType.markdownToDrawnix}
open={appState.openDialogTypes.has(DialogType.markdownToDrawnix)}
onOpenChange={(open) => {
setAppState({
...appState,
openDialogType: open ? DialogType.markdownToDrawnix : null,
});
if (open) {
openDialog(DialogType.markdownToDrawnix);
} else {
closeDialog(DialogType.markdownToDrawnix);
}
}}
>
<DialogContent className="Dialog ttd-dialog" container={container}>
@@ -444,7 +441,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
</Dialog>
{/* AI 图片生成窗口 - 使用 WinBox */}
<WinBoxWindow
visible={appState.openDialogType === DialogType.aiImageGeneration}
visible={appState.openDialogTypes.has(DialogType.aiImageGeneration)}
title={imageGenerationMode === 'batch'
? (language === 'zh' ? '批量出图' : 'Batch Generation')
: (language === 'zh' ? 'AI 图片生成' : 'AI Image Generation')
@@ -468,7 +465,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
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' ? (
<Suspense fallback={<div className="loading-fallback">{language === 'zh' ? '加载中...' : 'Loading...'}</div>}>
<BatchImageGeneration onSwitchToSingle={() => handleImageModeChange('single')} />
@@ -499,7 +496,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
</WinBoxWindow>
{/* AI 视频生成窗口 - 使用 WinBox */}
<WinBoxWindow
visible={appState.openDialogType === DialogType.aiVideoGeneration}
visible={appState.openDialogTypes.has(DialogType.aiVideoGeneration)}
title={language === 'zh' ? 'AI 视频生成' : 'AI Video Generation'}
onClose={handleVideoDialogClose}
width="70%"
@@ -513,7 +510,7 @@ const TTDDialogComponent = ({ container }: { container: HTMLElement | null }) =>
className="winbox-ai-generation winbox-ai-video-generation"
container={container}
>
{appState.openDialogType === DialogType.aiVideoGeneration && (
{appState.openDialogTypes.has(DialogType.aiVideoGeneration) && (
<AIVideoGeneration
initialPrompt={aiVideoData.initialPrompt}
initialImage={aiVideoData.initialImage}
@@ -111,6 +111,8 @@ export interface WinBoxWindowProps {
onResize?: (width: number, height: number) => void;
/** 是否自动最大化 */
autoMaximize?: boolean;
/** 允许窗口移出视口时,至少保留在屏幕内的像素数(默认 50) */
minVisiblePixels?: number;
}
/**
@@ -149,6 +151,7 @@ export const WinBoxWindow: React.FC<WinBoxWindowProps> = ({
onMove,
onResize,
autoMaximize = false,
minVisiblePixels = 50,
}) => {
const winboxRef = useRef<any>(null);
const winboxElementRef = useRef<HTMLDivElement | null>(null); // WinBox 窗口的 DOM 元素
@@ -157,11 +160,127 @@ export const WinBoxWindow: React.FC<WinBoxWindowProps> = ({
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<number | null>(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<WinBoxWindowProps> = ({
}
}, [winboxLoaded]);
const isMovingRef = useRef(false);
// 处理窗口关闭
const handleClose = useCallback(() => {
onClose?.();
@@ -224,6 +345,7 @@ export const WinBoxWindow: React.FC<WinBoxWindowProps> = ({
modal,
background,
border,
overflow: true, // 允许内容移出视口
class: classList,
root: container || document.body,
onclose: handleClose,
@@ -231,12 +353,60 @@ export const WinBoxWindow: React.FC<WinBoxWindowProps> = ({
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<WinBoxWindowProps> = ({
// 使用 setTimeout 确保窗口完全创建后再最大化
setTimeout(() => {
if (winboxRef.current) {
setStrictBoundaries();
winboxRef.current.maximize();
}
}, 100);
@@ -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;
+9 -5
View File
@@ -131,7 +131,7 @@ export const Drawnix: React.FC<DrawnixProps> = ({
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<DrawnixProps> = ({
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<DrawnixState>) => {
@@ -893,7 +897,7 @@ const DrawnixContent: React.FC<DrawnixContentProps> = ({
<PencilSettingsToolbar></PencilSettingsToolbar>
<PenSettingsToolbar></PenSettingsToolbar>
<EraserSettingsToolbar></EraserSettingsToolbar>
{appState.openDialogType && (
{appState.openDialogTypes.size > 0 && (
<Suspense fallback={null}>
<TTDDialog container={containerRef.current}></TTDDialog>
</Suspense>
+29 -8
View File
@@ -51,7 +51,10 @@ export type DrawnixState = {
pointer: DrawnixPointerType;
isMobile: boolean;
isPencilMode: boolean;
openDialogType: DialogType | null;
/** @deprecated 使用 openDialogTypes 代替,保留用于向后兼容 */
openDialogType?: DialogType | null;
/** 当前打开的弹窗类型集合,支持同时打开多个弹窗 */
openDialogTypes: Set<DialogType>;
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 = () => {