Merge branch 'develop' of https://github.com/ljquan/aitu into develop
This commit is contained in:
commit
3264ce8539
8 files changed
+341
-76
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in new issue
Block a user