9 files changed
+439
-33
No files matched your search
@@ -2320,6 +2320,52 @@ trimTransparentBorders(imageData: ImageData, strict: boolean = true)
|
||||
|
||||
---
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 生产代码禁止保留调试日志
|
||||
|
||||
**场景**: 开发调试时添加的 `console.log` 语句未在提交前清理
|
||||
|
||||
❌ **错误示例**:
|
||||
```typescript
|
||||
// 调试日志遗留在生产代码中
|
||||
const handleZoomPercentClick = useCallback(() => {
|
||||
console.log('[ViewNavigation] Zoom percent clicked, current state:', zoomMenuOpen);
|
||||
setZoomMenuOpen((prev) => !prev);
|
||||
}, [zoomMenuOpen]);
|
||||
|
||||
// Popover 中的调试日志
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
console.log('[Popover] onOpenChange:', open);
|
||||
setZoomMenuOpen(open);
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
✅ **正确示例**:
|
||||
```typescript
|
||||
// 清理调试日志,保持代码简洁
|
||||
const handleZoomPercentClick = useCallback(() => {
|
||||
setZoomMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// 直接传递 setter 函数
|
||||
<Popover onOpenChange={setZoomMenuOpen}>
|
||||
```
|
||||
|
||||
**原因**:
|
||||
1. 调试日志会污染用户控制台,影响体验
|
||||
2. 暴露内部实现细节,存在安全隐患
|
||||
3. 增加打包体积和运行时开销
|
||||
4. 代码 Review 时容易被忽略,形成技术债
|
||||
|
||||
**例外情况**:
|
||||
- `console.error` / `console.warn` 用于记录真正的错误/警告是允许的
|
||||
- 带有 `[DEBUG]` 前缀且通过环境变量控制的日志可以保留
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `/docs/CODING_STANDARDS.md` - 完整编码规范
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Drawnix,
|
||||
WorkspaceService,
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '@drawnix/drawnix';
|
||||
import { PlaitBoard, PlaitElement, PlaitTheme, Viewport } from '@plait/core';
|
||||
|
||||
// 节流保存 viewport 的间隔(毫秒)
|
||||
const VIEWPORT_SAVE_DEBOUNCE = 500;
|
||||
|
||||
// Global flag to prevent duplicate initialization in StrictMode
|
||||
let appInitialized = false;
|
||||
|
||||
@@ -21,12 +24,19 @@ export function App() {
|
||||
theme?: PlaitTheme;
|
||||
}>({ children: [] });
|
||||
|
||||
// 存储最新的 viewport,用于页面关闭前保存
|
||||
const latestViewportRef = useRef<Viewport | undefined>();
|
||||
// 防抖定时器
|
||||
const viewportSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Initialize workspace and handle migration
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
// Prevent duplicate initialization in StrictMode
|
||||
if (appInitialized) {
|
||||
// 等待 workspaceService 完全初始化
|
||||
const workspaceService = WorkspaceService.getInstance();
|
||||
await workspaceService.waitForInitialization();
|
||||
const currentBoard = workspaceService.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
setValue({
|
||||
@@ -68,7 +78,7 @@ export function App() {
|
||||
|
||||
if (currentBoard) {
|
||||
const elements = currentBoard.elements || [];
|
||||
|
||||
|
||||
// 先设置原始元素,让页面先渲染
|
||||
setValue({
|
||||
children: elements,
|
||||
@@ -118,6 +128,8 @@ export function App() {
|
||||
const handleBoardChange = useCallback(
|
||||
(data: BoardChangeData) => {
|
||||
setValue(data);
|
||||
// 同步更新最新 viewport
|
||||
latestViewportRef.current = data.viewport;
|
||||
|
||||
// Save to current board
|
||||
const workspaceService = WorkspaceService.getInstance();
|
||||
@@ -128,6 +140,88 @@ export function App() {
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle viewport changes (pan/zoom) - 单独保存 viewport
|
||||
const handleViewportChange = useCallback(
|
||||
(viewport: Viewport) => {
|
||||
// 更新最新 viewport
|
||||
latestViewportRef.current = viewport;
|
||||
|
||||
// 防抖保存
|
||||
if (viewportSaveTimerRef.current) {
|
||||
clearTimeout(viewportSaveTimerRef.current);
|
||||
}
|
||||
viewportSaveTimerRef.current = setTimeout(() => {
|
||||
const workspaceService = WorkspaceService.getInstance();
|
||||
const currentBoard = workspaceService.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
// 只保存 viewport,不影响其他数据
|
||||
workspaceService.saveCurrentBoard({
|
||||
children: currentBoard.elements,
|
||||
viewport: viewport,
|
||||
theme: currentBoard.theme,
|
||||
}).catch((err: Error) => {
|
||||
console.error('[App] Failed to save viewport:', err);
|
||||
});
|
||||
}
|
||||
}, VIEWPORT_SAVE_DEBOUNCE);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 页面关闭/隐藏前保存 viewport
|
||||
useEffect(() => {
|
||||
// 立即保存 viewport 的函数
|
||||
const saveViewportImmediately = () => {
|
||||
// 清除防抖定时器
|
||||
if (viewportSaveTimerRef.current) {
|
||||
clearTimeout(viewportSaveTimerRef.current);
|
||||
viewportSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 同步保存最新的 viewport
|
||||
const viewport = latestViewportRef.current;
|
||||
if (viewport) {
|
||||
const workspaceService = WorkspaceService.getInstance();
|
||||
const currentBoard = workspaceService.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
// 直接更新内存中的 board 数据
|
||||
currentBoard.viewport = viewport;
|
||||
// 尝试保存
|
||||
workspaceService.saveCurrentBoard({
|
||||
children: currentBoard.elements,
|
||||
viewport: viewport,
|
||||
theme: currentBoard.theme,
|
||||
}).catch(() => {
|
||||
// 忽略错误
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
saveViewportImmediately();
|
||||
};
|
||||
|
||||
// 页面隐藏时也保存(处理移动端和标签页切换)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
saveViewportImmediately();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
// 清理定时器
|
||||
if (viewportSaveTimerRef.current) {
|
||||
clearTimeout(viewportSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -150,6 +244,7 @@ export function App() {
|
||||
viewport={value.viewport}
|
||||
theme={value.theme}
|
||||
onChange={handleBoardChange}
|
||||
onViewportChange={handleViewportChange}
|
||||
onBoardSwitch={handleBoardSwitch}
|
||||
isDataReady={isDataReady}
|
||||
afterInit={(board) => {
|
||||
|
||||
@@ -7,19 +7,25 @@
|
||||
|
||||
import { Dialog, DialogContent } from '../dialog/dialog';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Checkbox, MessagePlugin, Progress } from 'tdesign-react';
|
||||
import { Checkbox, MessagePlugin, Progress, DialogPlugin } from 'tdesign-react';
|
||||
import { UploadIcon } from 'tdesign-icons-react';
|
||||
import {
|
||||
backupRestoreService,
|
||||
BackupOptions,
|
||||
ImportResult,
|
||||
BackupWorkspaceState,
|
||||
} from '../../services/backup-restore-service';
|
||||
import { workspaceService } from '../../services/workspace-service';
|
||||
import './backup-restore-dialog.scss';
|
||||
|
||||
export interface BackupRestoreDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
container: HTMLElement | null;
|
||||
/** 切换画板的回调(用于恢复备份时的画布状态) */
|
||||
onSwitchBoard?: (boardId: string, viewport?: BackupWorkspaceState['viewport']) => void;
|
||||
/** 导入前的回调(用于保存当前画板数据到 IndexedDB) */
|
||||
onBeforeImport?: () => Promise<void>;
|
||||
}
|
||||
|
||||
type TabType = 'backup' | 'restore';
|
||||
@@ -28,6 +34,8 @@ export const BackupRestoreDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
container,
|
||||
onSwitchBoard,
|
||||
onBeforeImport,
|
||||
}: BackupRestoreDialogProps) => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('backup');
|
||||
const [backupOptions, setBackupOptions] = useState<BackupOptions>({
|
||||
@@ -39,10 +47,64 @@ export const BackupRestoreDialog = ({
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
// 待恢复的工作区状态(用于延迟处理画布切换)
|
||||
const [pendingWorkspaceState, setPendingWorkspaceState] = useState<BackupWorkspaceState | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
/**
|
||||
* 处理画布切换逻辑
|
||||
* - 如果当前是空白画布,自动切换到备份时的画布
|
||||
* - 如果当前不是空白画布,弹窗询问用户
|
||||
*/
|
||||
const handleWorkspaceRestore = useCallback(async (workspaceState: BackupWorkspaceState) => {
|
||||
if (!workspaceState.currentBoardId || !onSwitchBoard) return;
|
||||
|
||||
// 检查备份的画板是否存在
|
||||
const targetBoard = workspaceService.getBoard(workspaceState.currentBoardId);
|
||||
if (!targetBoard) {
|
||||
// 画板不存在,可能是增量导入时没有导入该画板
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前画板
|
||||
const currentBoard = workspaceService.getCurrentBoard();
|
||||
|
||||
// 检查当前画布是否为空白(没有元素或只有默认元素)
|
||||
const isCurrentBoardEmpty = !currentBoard ||
|
||||
!currentBoard.elements ||
|
||||
currentBoard.elements.length === 0;
|
||||
|
||||
if (isCurrentBoardEmpty) {
|
||||
// 当前是空白画布,自动切换
|
||||
await onSwitchBoard(workspaceState.currentBoardId, workspaceState.viewport);
|
||||
MessagePlugin.success(`已切换到画板「${targetBoard.name}」`);
|
||||
} else {
|
||||
// 当前不是空白画布,弹窗询问
|
||||
const dialogInstance = DialogPlugin.confirm({
|
||||
header: '恢复画布状态',
|
||||
body: `备份时正在编辑画板「${workspaceState.currentBoardName || targetBoard.name}」,是否切换到该画板?`,
|
||||
confirmBtn: '切换',
|
||||
cancelBtn: '取消',
|
||||
onConfirm: async () => {
|
||||
await onSwitchBoard(workspaceState.currentBoardId!, workspaceState.viewport);
|
||||
MessagePlugin.success(`已切换到画板「${targetBoard.name}」`);
|
||||
dialogInstance.destroy();
|
||||
},
|
||||
onCancel: () => {
|
||||
dialogInstance.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [onSwitchBoard]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (!isProcessing) {
|
||||
// 如果有待恢复的工作区状态,处理画布切换(需要等待完成)
|
||||
if (pendingWorkspaceState) {
|
||||
await handleWorkspaceRestore(pendingWorkspaceState);
|
||||
setPendingWorkspaceState(null);
|
||||
}
|
||||
|
||||
// 如果有导入结果且导入了任务数据,刷新页面以确保任务队列生效
|
||||
if (importResult && importResult.tasks && importResult.tasks.imported > 0) {
|
||||
window.location.reload();
|
||||
@@ -55,7 +117,7 @@ export const BackupRestoreDialog = ({
|
||||
setProgressMessage('');
|
||||
setImportResult(null);
|
||||
}
|
||||
}, [isProcessing, onOpenChange, importResult]);
|
||||
}, [isProcessing, onOpenChange, importResult, pendingWorkspaceState, handleWorkspaceRestore]);
|
||||
|
||||
const handleBackup = useCallback(async () => {
|
||||
// 检查是否至少选择了一项
|
||||
@@ -104,10 +166,16 @@ export const BackupRestoreDialog = ({
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
setProgressMessage('正在读取文件...');
|
||||
setProgressMessage('正在保存当前画板...');
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
// 导入前先保存当前画板数据,确保合并时使用最新数据
|
||||
if (onBeforeImport) {
|
||||
await onBeforeImport();
|
||||
}
|
||||
|
||||
setProgressMessage('正在读取文件...');
|
||||
const result = await backupRestoreService.importFromZip(
|
||||
file,
|
||||
(p, msg) => {
|
||||
@@ -118,6 +186,11 @@ export const BackupRestoreDialog = ({
|
||||
|
||||
setImportResult(result);
|
||||
|
||||
// 保存工作区状态,关闭对话框时处理
|
||||
if (result.workspaceState?.currentBoardId) {
|
||||
setPendingWorkspaceState(result.workspaceState);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
MessagePlugin.success('导入成功!');
|
||||
} else if (result.errors.length > 0) {
|
||||
@@ -133,7 +206,7 @@ export const BackupRestoreDialog = ({
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [onBeforeImport]);
|
||||
|
||||
const handleOptionChange = useCallback((key: keyof BackupOptions, checked: boolean) => {
|
||||
setBackupOptions((prev) => ({ ...prev, [key]: checked }));
|
||||
@@ -272,9 +345,10 @@ export const BackupRestoreDialog = ({
|
||||
提示词:导入 {importResult.prompts.imported} 条,跳过 {importResult.prompts.skipped} 条
|
||||
</li>
|
||||
)}
|
||||
{(importResult.projects.folders > 0 || importResult.projects.boards > 0) && (
|
||||
{(importResult.projects.folders > 0 || importResult.projects.boards > 0 || importResult.projects.merged > 0) && (
|
||||
<li>
|
||||
项目:导入 {importResult.projects.folders} 个文件夹,{importResult.projects.boards} 个画板
|
||||
{importResult.projects.merged > 0 && `,合并 ${importResult.projects.merged} 个画板`}
|
||||
</li>
|
||||
)}
|
||||
{(importResult.assets.imported > 0 || importResult.assets.skipped > 0) && (
|
||||
|
||||
@@ -583,17 +583,48 @@ export const ProjectDrawer: React.FC<ProjectDrawerProps> = ({
|
||||
setDeleteTarget(null);
|
||||
}, [deleteTarget, deleteFolderWithContents]);
|
||||
|
||||
// Helper function to get the first available board from tree (excluding a specific id)
|
||||
const getFirstBoardFromTree = useCallback((nodes: TreeNode[], excludeId?: string): Board | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'board' && node.data.id !== excludeId) {
|
||||
return node.data;
|
||||
}
|
||||
if (node.type === 'folder' && node.children) {
|
||||
const board = getFirstBoardFromTree(node.children, excludeId);
|
||||
if (board) return board;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Handle delete (board)
|
||||
const handleDeleteBoard = useCallback(async () => {
|
||||
if (!deleteTarget || deleteTarget.type !== 'board') return;
|
||||
|
||||
const deletingCurrentBoard = deleteTarget.id === currentBoard?.id;
|
||||
const success = await deleteBoard(deleteTarget.id);
|
||||
|
||||
if (success) {
|
||||
MessagePlugin.success('删除成功');
|
||||
|
||||
// If we deleted the current board, switch to the first available board
|
||||
if (deletingCurrentBoard) {
|
||||
const firstBoard = getFirstBoardFromTree(tree, deleteTarget.id);
|
||||
if (firstBoard) {
|
||||
// Save before switching (though current board is deleted, this ensures clean state)
|
||||
if (onBeforeSwitch) {
|
||||
await onBeforeSwitch();
|
||||
}
|
||||
const switched = await switchBoard(firstBoard.id);
|
||||
if (switched && onBoardSwitch) {
|
||||
onBoardSwitch(switched);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTarget(null);
|
||||
}, [deleteTarget, deleteBoard]);
|
||||
}, [deleteTarget, deleteBoard, currentBoard, tree, getFirstBoardFromTree, onBeforeSwitch, switchBoard, onBoardSwitch]);
|
||||
|
||||
// Handle copy board
|
||||
const handleCopyBoard = useCallback(async (id: string) => {
|
||||
|
||||
@@ -36,7 +36,8 @@ export const ViewNavigation: React.FC<ViewNavigationProps> = ({
|
||||
const { t } = useI18n();
|
||||
const { isDrawerOpen, drawerWidth } = useChatDrawerControl();
|
||||
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||||
const [minimapExpanded, setMinimapExpanded] = useState(false);
|
||||
// 首次进入页面时默认展示 minimap
|
||||
const [minimapExpanded, setMinimapExpanded] = useState(true);
|
||||
const [manuallyExpanded, setManuallyExpanded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -176,9 +177,8 @@ export const ViewNavigation: React.FC<ViewNavigationProps> = ({
|
||||
|
||||
// 处理百分比按钮点击
|
||||
const handleZoomPercentClick = useCallback(() => {
|
||||
console.log('[ViewNavigation] Zoom percent clicked, current state:', zoomMenuOpen);
|
||||
setZoomMenuOpen((prev) => !prev);
|
||||
}, [zoomMenuOpen]);
|
||||
}, []);
|
||||
|
||||
// 当前缩放百分比
|
||||
const zoomPercentage = Math.round((board?.viewport?.zoom || 1) * 100);
|
||||
@@ -207,10 +207,7 @@ export const ViewNavigation: React.FC<ViewNavigationProps> = ({
|
||||
<Popover
|
||||
sideOffset={8}
|
||||
open={zoomMenuOpen}
|
||||
onOpenChange={(open) => {
|
||||
console.log('[ViewNavigation] Popover onOpenChange:', open);
|
||||
setZoomMenuOpen(open);
|
||||
}}
|
||||
onOpenChange={setZoomMenuOpen}
|
||||
placement="bottom"
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -59,6 +59,7 @@ import { ChatDrawerProvider, useChatDrawer } from './contexts/ChatDrawerContext'
|
||||
import { fontManagerService } from './services/font-manager-service';
|
||||
import { WorkflowProvider } from './contexts/WorkflowContext';
|
||||
import { useWorkspace } from './hooks/useWorkspace';
|
||||
import { workspaceService } from './services/workspace-service';
|
||||
import { Board as WorkspaceBoard } from './types/workspace.types';
|
||||
import { toolTestHelper } from './utils/tool-test-helper';
|
||||
import { ViewNavigation } from './components/view-navigation';
|
||||
@@ -874,6 +875,27 @@ const DrawnixContent: React.FC<DrawnixContentProps> = ({
|
||||
open={backupRestoreOpen}
|
||||
onOpenChange={setBackupRestoreOpen}
|
||||
container={containerRef.current}
|
||||
onBeforeImport={async () => {
|
||||
// 导入前先保存当前画板数据到 IndexedDB
|
||||
if (handleBeforeSwitch) {
|
||||
await handleBeforeSwitch();
|
||||
}
|
||||
}}
|
||||
onSwitchBoard={async (boardId, viewport) => {
|
||||
// 注意:这里不调用 handleBeforeSwitch
|
||||
// 因为在备份恢复时,onBeforeImport 已经保存了当前画板
|
||||
// 如果在这里再保存,会用旧的内存数据覆盖 IndexedDB 中刚合并的新数据
|
||||
|
||||
// 切换到目标画板(使用已导入的 workspaceService 单例,确保数据一致性)
|
||||
const board = await workspaceService.switchBoard(boardId);
|
||||
if (board && onBoardSwitch) {
|
||||
// 如果有 viewport,合并到 board 中
|
||||
if (viewport) {
|
||||
board.viewport = viewport;
|
||||
}
|
||||
onBoardSwitch(board);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { swTaskQueueService } from './sw-task-queue-service';
|
||||
import { TaskType, TaskStatus, Task } from '../types/task.types';
|
||||
import type { Folder, Board } from '../types/workspace.types';
|
||||
import type { StoredAsset } from '../types/asset.types';
|
||||
import type { PlaitTheme, PlaitElement as CorePlaitElement } from '@plait/core';
|
||||
import { LS_KEYS_TO_MIGRATE } from '../constants/storage-keys';
|
||||
import { DrawnixExportedType } from '../data/types';
|
||||
import { VERSIONS } from '../constants';
|
||||
@@ -71,6 +72,21 @@ export interface BackupOptions {
|
||||
includeAssets: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份时的工作区状态
|
||||
*/
|
||||
export interface BackupWorkspaceState {
|
||||
/** 当前画板ID */
|
||||
currentBoardId: string | null;
|
||||
/** 当前画板名称(用于显示) */
|
||||
currentBoardName?: string;
|
||||
/** 当前视图状态(使用 origination 格式,与 @plait/core 保持一致) */
|
||||
viewport?: {
|
||||
zoom: number;
|
||||
origination?: [number, number];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份清单(manifest.json)
|
||||
*/
|
||||
@@ -92,6 +108,8 @@ interface BackupManifest {
|
||||
assetCount: number;
|
||||
taskCount: number;
|
||||
};
|
||||
/** 备份时的工作区状态 */
|
||||
workspaceState?: BackupWorkspaceState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +131,7 @@ interface DrawnixFileData {
|
||||
source: string;
|
||||
elements: PlaitElement[];
|
||||
viewport: Viewport;
|
||||
theme?: string;
|
||||
theme?: PlaitTheme;
|
||||
boardMeta?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -126,8 +144,7 @@ interface DrawnixFileData {
|
||||
|
||||
interface Viewport {
|
||||
zoom: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
origination?: [number, number];
|
||||
}
|
||||
|
||||
interface PlaitElement {
|
||||
@@ -140,6 +157,19 @@ interface PlaitElement {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保所有元素都有 id(为没有 id 的元素生成一个)
|
||||
* 返回类型兼容 @plait/core 的 PlaitElement[]
|
||||
*/
|
||||
function ensureElementIds(elements: PlaitElement[]): CorePlaitElement[] {
|
||||
return elements.map(el => {
|
||||
if (!el.id) {
|
||||
return { ...el, id: `imported-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } as CorePlaitElement;
|
||||
}
|
||||
return el as CorePlaitElement;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入结果
|
||||
*/
|
||||
@@ -152,6 +182,8 @@ export interface ImportResult {
|
||||
projects: {
|
||||
folders: number;
|
||||
boards: number;
|
||||
/** 合并的画板数量(相同ID的画板) */
|
||||
merged: number;
|
||||
skipped: number;
|
||||
};
|
||||
assets: {
|
||||
@@ -163,6 +195,8 @@ export interface ImportResult {
|
||||
skipped: number;
|
||||
};
|
||||
errors: string[];
|
||||
/** 备份时的工作区状态(用于恢复画布位置) */
|
||||
workspaceState?: BackupWorkspaceState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +249,14 @@ class BackupRestoreService {
|
||||
|
||||
onProgress?.(5, '正在准备数据...');
|
||||
|
||||
// 获取当前工作区状态
|
||||
const currentBoard = workspaceService.getCurrentBoard();
|
||||
const workspaceState: BackupWorkspaceState = {
|
||||
currentBoardId: currentBoard?.id || null,
|
||||
currentBoardName: currentBoard?.name,
|
||||
viewport: currentBoard?.viewport,
|
||||
};
|
||||
|
||||
const manifest: BackupManifest = {
|
||||
signature: BACKUP_SIGNATURE,
|
||||
version: BACKUP_VERSION,
|
||||
@@ -233,6 +275,7 @@ class BackupRestoreService {
|
||||
assetCount: 0,
|
||||
taskCount: 0,
|
||||
},
|
||||
workspaceState,
|
||||
};
|
||||
|
||||
// 导出提示词
|
||||
@@ -534,7 +577,7 @@ class BackupRestoreService {
|
||||
const result: ImportResult = {
|
||||
success: false,
|
||||
prompts: { imported: 0, skipped: 0 },
|
||||
projects: { folders: 0, boards: 0, skipped: 0 },
|
||||
projects: { folders: 0, boards: 0, merged: 0, skipped: 0 },
|
||||
assets: { imported: 0, skipped: 0 },
|
||||
tasks: { imported: 0, skipped: 0 },
|
||||
errors: [],
|
||||
@@ -591,6 +634,11 @@ class BackupRestoreService {
|
||||
await workspaceService.reload();
|
||||
}
|
||||
|
||||
// 返回备份时的工作区状态(用于恢复画布位置)
|
||||
if (manifest.workspaceState) {
|
||||
result.workspaceState = manifest.workspaceState;
|
||||
}
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
onProgress?.(100, '导入完成');
|
||||
} catch (error: unknown) {
|
||||
@@ -827,9 +875,10 @@ class BackupRestoreService {
|
||||
private async importProjects(
|
||||
zip: JSZip,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<{ folders: number; boards: number; skipped: number }> {
|
||||
): Promise<{ folders: number; boards: number; merged: number; skipped: number }> {
|
||||
let foldersImported = 0;
|
||||
let boardsImported = 0;
|
||||
let boardsMerged = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// 获取现有数据
|
||||
@@ -846,7 +895,7 @@ class BackupRestoreService {
|
||||
);
|
||||
|
||||
if (drawnixFiles.length === 0) {
|
||||
return { folders: 0, boards: 0, skipped: 0 };
|
||||
return { folders: 0, boards: 0, merged: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
// 从目录结构推断文件夹
|
||||
@@ -918,12 +967,6 @@ class BackupRestoreService {
|
||||
|
||||
const boardMeta = drawnixData.boardMeta;
|
||||
|
||||
// 检查是否已存在
|
||||
if (boardMeta?.id && existingBoardIds.has(boardMeta.id)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 推断文件夹 ID
|
||||
const relativePath = filePath.replace(/^projects\//, '');
|
||||
const parts = relativePath.split('/');
|
||||
@@ -936,12 +979,58 @@ class BackupRestoreService {
|
||||
const fileName = parts[parts.length - 1] || 'unnamed.drawnix';
|
||||
const boardName = boardMeta?.name || fileName.replace('.drawnix', '');
|
||||
|
||||
// 检查是否已存在相同 ID 的画板
|
||||
if (boardMeta?.id && existingBoardIds.has(boardMeta.id)) {
|
||||
// 找到现有画板进行合并
|
||||
const existingBoard = existingBoards.find(b => b.id === boardMeta.id);
|
||||
if (existingBoard) {
|
||||
// 合并元素:保留现有元素,添加备份中新增的元素(基于ID去重)
|
||||
const existingElementIds = new Set(
|
||||
(existingBoard.elements || [])
|
||||
.map(el => el.id)
|
||||
.filter((id): id is string => !!id)
|
||||
);
|
||||
|
||||
const backupElements = drawnixData.elements || [];
|
||||
// 过滤出备份中的新元素:
|
||||
// - 有 ID 的元素:只保留现有画板中不存在的
|
||||
// - 无 ID 的元素:全部保留(不会与现有元素冲突)
|
||||
const newElements = backupElements.filter(el =>
|
||||
el.id ? !existingElementIds.has(el.id) : true
|
||||
);
|
||||
|
||||
// 合并后的元素:现有元素 + 备份中新增的元素
|
||||
// 使用 ensureElementIds 确保新元素都有 id
|
||||
const mergedElements = [
|
||||
...(existingBoard.elements || []),
|
||||
...ensureElementIds(newElements),
|
||||
];
|
||||
|
||||
// 合并画板数据
|
||||
const mergedBoard: Board = {
|
||||
...existingBoard,
|
||||
// 合并后的元素(图形可以多,但不能少)
|
||||
elements: mergedElements,
|
||||
// 使用备份的 viewport(恢复视图位置)
|
||||
viewport: drawnixData.viewport || existingBoard.viewport,
|
||||
// 使用备份的 theme
|
||||
theme: drawnixData.theme || existingBoard.theme,
|
||||
// 更新时间
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await workspaceStorageService.saveBoard(mergedBoard);
|
||||
boardsMerged++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const board: Board = {
|
||||
id: boardMeta?.id || this.generateId(),
|
||||
name: boardName,
|
||||
folderId,
|
||||
order: boardMeta?.order ?? i,
|
||||
elements: drawnixData.elements || [],
|
||||
elements: ensureElementIds(drawnixData.elements || []),
|
||||
viewport: drawnixData.viewport,
|
||||
theme: drawnixData.theme,
|
||||
createdAt: boardMeta?.createdAt || Date.now(),
|
||||
@@ -967,7 +1056,7 @@ class BackupRestoreService {
|
||||
}
|
||||
}
|
||||
|
||||
return { folders: foldersImported, boards: boardsImported, skipped };
|
||||
return { folders: foldersImported, boards: boardsImported, merged: boardsMerged, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,7 @@ class WorkspaceService {
|
||||
private state: WorkspaceState;
|
||||
private events$: Subject<WorkspaceEvent> = new Subject();
|
||||
private initialized: boolean = false;
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.state = {
|
||||
@@ -64,7 +65,17 @@ class WorkspaceService {
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// 如果正在初始化,等待完成
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
this.initializationPromise = this.doInitialize();
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
try {
|
||||
await workspaceStorageService.initialize();
|
||||
|
||||
@@ -95,10 +106,23 @@ class WorkspaceService {
|
||||
// });
|
||||
} catch (error) {
|
||||
console.error('[WorkspaceService] Failed to initialize:', error);
|
||||
this.initializationPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for initialization to complete
|
||||
*/
|
||||
async waitForInitialization(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
// 如果还没开始初始化,启动初始化
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
// ========== Folder Operations ==========
|
||||
|
||||
async createFolder(options: CreateFolderOptions): Promise<Folder> {
|
||||
|
||||
@@ -128,7 +128,7 @@ export const Wrapper: React.FC<WrapperProps> = ({
|
||||
board,
|
||||
listRender,
|
||||
}));
|
||||
}, [board, onChange, onSelectionChange, onValueChange]);
|
||||
}, [board, onChange, onSelectionChange, onValueChange, onViewportChange, onThemeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
BOARD_TO_ON_CHANGE.set(board, () => {
|
||||
@@ -185,6 +185,30 @@ export const Wrapper: React.FC<WrapperProps> = ({
|
||||
}, [board]);
|
||||
|
||||
const isFirstRender = useRef(true);
|
||||
const prevViewportRef = useRef<Viewport | undefined>(viewport);
|
||||
|
||||
// 处理 viewport prop 变化(用于恢复保存的视图状态)
|
||||
useEffect(() => {
|
||||
const prevViewport = prevViewportRef.current;
|
||||
prevViewportRef.current = viewport;
|
||||
|
||||
// 如果外部传入了有效的 viewport,且与当前不同,则应用它
|
||||
if (viewport && !FLUSHING.get(board)) {
|
||||
// 检查是否是从 undefined 变为有值,或者值发生了变化
|
||||
const isNewViewport = !prevViewport;
|
||||
const isDifferent = prevViewport && (
|
||||
prevViewport.zoom !== viewport.zoom ||
|
||||
prevViewport.offsetX !== viewport.offsetX ||
|
||||
prevViewport.offsetY !== viewport.offsetY
|
||||
);
|
||||
|
||||
if (isNewViewport || isDifferent) {
|
||||
board.viewport = viewport;
|
||||
initializeViewBox(board);
|
||||
updateViewportOffset(board);
|
||||
}
|
||||
}
|
||||
}, [viewport, board]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
@@ -199,9 +223,13 @@ export const Wrapper: React.FC<WrapperProps> = ({
|
||||
parent: board,
|
||||
parentG: PlaitBoard.getElementHost(board),
|
||||
});
|
||||
BoardTransforms.fitViewport(board);
|
||||
// 只有当没有传入 viewport 时才自动适配视图
|
||||
// 如果传入了 viewport,说明是恢复保存的视图,不应该重置
|
||||
if (!viewport) {
|
||||
BoardTransforms.fitViewport(board);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
}, [value, viewport]);
|
||||
|
||||
return (
|
||||
<BoardContext.Provider value={context}>{children}</BoardContext.Provider>
|
||||
|
||||
Reference in new issue
Block a user