Merge pull request #69 from ljquan/backup

Backup
This commit is contained in:
ljquan authored and GitHub committed 2026-01-16 15:42:46 +08:00
commit aeb8a39e43
9 files changed
+439 -33

No files matched your search

+46
View File
@@ -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` - 完整编码规范
+97 -2
View File
@@ -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>
+22
View File
@@ -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> {
+31 -3
View File
@@ -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>