Download frontend/src/services/dataSyncService.ts from CatPtain/web_ppt: direct link, hf CLI and curl.
- Browser
- Download file 23.6 kB
-
https://huggingface.co/spaces/CatPtain/web_ppt/resolve/main/frontend/src/services/dataSyncService.ts
- Command line
-
hf download hf://spaces/CatPtain/web_ppt/frontend/src/services/dataSyncService.ts
-
curl -L -o dataSyncService.ts https://huggingface.co/spaces/CatPtain/web_ppt/resolve/main/frontend/src/services/dataSyncService.ts
23.6 kB
| import api from '@/services' | |
| import { debounce } from 'lodash' | |
| // Using html2canvas instead of html-to-image for better compatibility | |
| // import { toJpeg } from 'html-to-image' | |
| class DataSyncService { | |
| private currentPPTId: string | null = null | |
| private saveTimeout: number | null = null | |
| private isOnline = true | |
| private autoSaveDelay = 300000 // Default 5 minutes, configurable | |
| private isInitialized = false | |
| private debouncedSave: any = null | |
| constructor() { | |
| this.setupNetworkMonitoring() | |
| } | |
| // Delayed initialization, called after Pinia is available | |
| async initialize() { | |
| if (this.isInitialized) return | |
| await this.setupAutoSave() | |
| this.isInitialized = true | |
| } | |
| // Set auto-save delay time (milliseconds) | |
| setAutoSaveDelay(delay: number) { | |
| this.autoSaveDelay = Math.max(500, delay) // Minimum 500ms | |
| if (this.isInitialized) { | |
| this.setupAutoSave() // Reset auto-save | |
| } | |
| } | |
| // Get current auto-save delay time | |
| getAutoSaveDelay(): number { | |
| return this.autoSaveDelay | |
| } | |
| // Set current PPT ID | |
| setCurrentPPTId(pptId: string) { | |
| this.currentPPTId = pptId | |
| } | |
| // Auto-save functionality | |
| private async setupAutoSave() { | |
| if (this.debouncedSave) { | |
| this.debouncedSave.cancel() | |
| } | |
| this.debouncedSave = debounce(async () => { | |
| await this.savePPT() | |
| }, this.autoSaveDelay) // Use configurable delay time | |
| // Listen for slides changes | |
| try { | |
| const { useSlidesStore } = await import('@/store') | |
| const slidesStore = useSlidesStore() | |
| slidesStore.$subscribe(() => { | |
| if (this.isOnline && this.currentPPTId) { | |
| this.debouncedSave() | |
| } | |
| }) | |
| } | |
| catch (error) { | |
| // console.warn('Unable to set up auto-save, store not ready:', error) | |
| } | |
| } | |
| // Network status monitoring | |
| private setupNetworkMonitoring() { | |
| window.addEventListener('online', () => { | |
| this.isOnline = true | |
| // console.log('Network connected, resuming auto-save') | |
| }) | |
| window.addEventListener('offline', () => { | |
| this.isOnline = false | |
| // console.log('Network disconnected, pausing auto-save') | |
| }) | |
| } | |
| // Save PPT to backend | |
| async savePPT(force = false): Promise<boolean> { | |
| // Dynamically import store to avoid dependency issues during initialization | |
| const { useAuthStore, useSlidesStore } = await import('@/store') | |
| try { | |
| const authStore = useAuthStore() | |
| const slidesStore = useSlidesStore() | |
| if (!authStore.isLoggedIn) { | |
| // console.warn('User not logged in, unable to save') | |
| return false | |
| } | |
| // If no current PPT ID and forced save, create new PPT | |
| if (!this.currentPPTId && force) { | |
| try { | |
| const response = await api.createPPT(slidesStore.title || 'Untitled Presentation') | |
| this.currentPPTId = response.pptId | |
| // Update slides store data to match newly created PPT | |
| if (response.ppt) { | |
| slidesStore.setSlides(response.ppt.slides) | |
| slidesStore.setTitle(response.ppt.title) | |
| slidesStore.setTheme(response.ppt.theme) | |
| } | |
| // console.log('Created new PPT and saved successfully') | |
| return true | |
| } | |
| catch (createError) { | |
| // console.error('Failed to create new PPT:', createError) | |
| return false | |
| } | |
| } | |
| if (!this.currentPPTId && !force) { | |
| // console.warn('No current PPT ID') | |
| return false | |
| } | |
| const pptData = { | |
| pptId: this.currentPPTId, | |
| title: slidesStore.title, | |
| slides: slidesStore.slides, | |
| theme: slidesStore.theme, | |
| // Add critical size information | |
| viewportSize: slidesStore.viewportSize, | |
| viewportRatio: slidesStore.viewportRatio | |
| } | |
| await api.savePPT(pptData) | |
| // console.log('PPT saved successfully') | |
| return true | |
| } | |
| catch (error) { | |
| // console.error('Failed to save PPT:', error) | |
| return false | |
| } | |
| } | |
| // Create new PPT | |
| async createNewPPT(title: string): Promise<string | null> { | |
| const { useAuthStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| if (!authStore.isLoggedIn) { | |
| throw new Error('User not logged in') | |
| } | |
| try { | |
| const response = await api.createPPT(title) | |
| this.setCurrentPPTId(response.pptId) | |
| return response.pptId | |
| } | |
| catch (error) { | |
| // console.error('Failed to create PPT:', error) | |
| throw error | |
| } | |
| } | |
| // Load PPT | |
| async loadPPT(pptId: string): Promise<boolean> { | |
| const { useAuthStore, useSlidesStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| const slidesStore = useSlidesStore() | |
| if (!authStore.isLoggedIn) { | |
| throw new Error('User not logged in') | |
| } | |
| try { | |
| // console.log(`π Starting to load PPT: ${pptId}`) | |
| const pptData = await api.getPPT(pptId) | |
| if (!pptData) { | |
| throw new Error('PPT data is empty') | |
| } | |
| // console.log('π PPT data loaded successfully, updating store:', {...}) | |
| // Fix: Ensure complete PPT data is loaded and set in correct order | |
| // 1. First set viewport information to ensure correct canvas size | |
| if (pptData.viewportSize && pptData.viewportSize > 0) { | |
| slidesStore.setViewportSize(pptData.viewportSize) | |
| // console.log('β Viewport size set successfully:', pptData.viewportSize) | |
| } | |
| else { | |
| // console.log('β οΈ Using default viewport size: 1000') | |
| slidesStore.setViewportSize(1000) | |
| } | |
| if (pptData.viewportRatio && pptData.viewportRatio > 0) { | |
| slidesStore.setViewportRatio(pptData.viewportRatio) | |
| // console.log('β Viewport ratio set successfully:', pptData.viewportRatio) | |
| } | |
| else { | |
| // console.log('β οΈ Using default viewport ratio: 0.5625') | |
| slidesStore.setViewportRatio(0.5625) | |
| } | |
| // 2. Set theme | |
| if (pptData.theme) { | |
| slidesStore.setTheme(pptData.theme) | |
| // console.log('β Theme set successfully') | |
| } | |
| // 3. Set title | |
| slidesStore.setTitle(pptData.title || 'Untitled Presentation') | |
| // console.log('β Title set successfully:', pptData.title) | |
| // 4. Finally set slides, ensuring it is done after viewport information is set | |
| if (Array.isArray(pptData.slides) && pptData.slides.length > 0) { | |
| // Validate slides data integrity | |
| const validSlides = pptData.slides.filter((slide: any) => | |
| slide && typeof slide === 'object' && Array.isArray(slide.elements) | |
| ) | |
| if (validSlides.length !== pptData.slides.length) { | |
| // console.warn(`β οΈ Found ${pptData.slides.length - validSlides.length} invalid slides, filtered out`) | |
| } | |
| slidesStore.setSlides(validSlides) | |
| // console.log('β Slides set successfully:', validSlides.length, 'pages') | |
| } | |
| else { | |
| // console.warn('β οΈ PPT has no valid slides, creating default slide') | |
| slidesStore.setSlides([{ | |
| id: 'default-slide', | |
| elements: [], | |
| background: { type: 'solid', color: '#ffffff' } | |
| }]) | |
| } | |
| // 5. Set current PPT ID | |
| const actualPptId = pptData.id || pptData.pptId || pptId | |
| this.setCurrentPPTId(actualPptId) | |
| // console.log('β PPT ID set successfully:', actualPptId) | |
| // console.log('π PPT loaded successfully!') | |
| return true | |
| } | |
| catch (error: any) { | |
| // console.error('β Failed to load PPT:', { pptId, error: error.message, stack: error.stack }) | |
| // Provide more specific error information | |
| if (error.message?.includes('404') || error.message?.includes('not found')) { | |
| throw new Error(`PPT not found (ID: ${pptId})`) | |
| } | |
| if (error.message?.includes('403') || error.message?.includes('unauthorized')) { | |
| throw new Error('No access permission') | |
| } | |
| if (error.message?.includes('Network')) { | |
| throw new Error('Network connection failed, please check network status') | |
| } | |
| throw new Error(`Failed to load: ${error.message}`) | |
| } | |
| } | |
| // Get PPT list | |
| async getPPTList() { | |
| const { useAuthStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| if (!authStore.isLoggedIn) { | |
| throw new Error('User not logged in') | |
| } | |
| return await api.getPPTList() | |
| } | |
| // Delete PPT | |
| async deletePPT(pptId: string): Promise<boolean> { | |
| const { useAuthStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| if (!authStore.isLoggedIn) { | |
| throw new Error('User not logged in') | |
| } | |
| try { | |
| await api.deletePPT(pptId) | |
| // If the deleted PPT is the current PPT, clear the current PPT ID | |
| if (this.currentPPTId === pptId) { | |
| this.currentPPTId = null | |
| } | |
| // console.log('PPT deleted successfully') | |
| return true | |
| } | |
| catch (error) { | |
| // console.error('Failed to delete PPT:', error) | |
| throw error | |
| } | |
| } | |
| // Generate share link | |
| async generateShareLink(slideIndex = 0) { | |
| const { useAuthStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| if (!authStore.isLoggedIn || !this.currentPPTId) { | |
| throw new Error('User not logged in or no current PPT') | |
| } | |
| try { | |
| const response = await api.generateShareLink( | |
| authStore.currentUser!.id, | |
| this.currentPPTId, | |
| slideIndex | |
| ) | |
| return response | |
| } | |
| catch (error) { | |
| // console.error('Failed to generate share link:', error) | |
| throw error | |
| } | |
| } | |
| // Manual save | |
| async manualSave(): Promise<boolean> { | |
| return await this.savePPT(true) | |
| } | |
| // New screenshot solution: use direct image API | |
| async generateScreenshotUrl(slideIndex = 0, options: any = {}) { | |
| const { useAuth = true, format = 'jpeg', quality = 90 } = options; | |
| if (useAuth) { | |
| const { useAuthStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| if (!authStore.isLoggedIn || !this.currentPPTId) { | |
| throw new Error('User not logged in or no current PPT') | |
| } | |
| const baseUrl = window.location.origin | |
| // π§ NEW: Use frontend screenshot data API for direct generation | |
| console.log('π Using new frontend screenshot strategy...') | |
| try { | |
| // Get PPT data for frontend generation | |
| const dataResponse = await fetch(`${baseUrl}/api/public/screenshot-data/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}`) | |
| if (dataResponse.ok) { | |
| const screenshotData = await dataResponse.json() | |
| // Return data for frontend direct generation | |
| return { | |
| type: 'frontend-data', | |
| data: screenshotData, | |
| isDataUrl: false, | |
| info: { | |
| format, | |
| quality, | |
| strategy: 'frontend-direct-generation', | |
| pptTitle: screenshotData.pptData?.title, | |
| slideIndex: screenshotData.slideIndex, | |
| dimensions: `${screenshotData.exportConfig?.width}x${screenshotData.exportConfig?.height}` | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.warn('Frontend data API failed, falling back to export page:', error) | |
| } | |
| // Fallback: Frontend export page (user opens in new tab) | |
| const frontendExportUrl = `${baseUrl}/api/public/image/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}?format=${format}&quality=${quality}` | |
| return { | |
| type: 'frontend-export-page', | |
| url: frontendExportUrl, | |
| isDataUrl: false, | |
| info: { | |
| format, | |
| quality, | |
| strategy: 'frontend-export-page', | |
| usage: 'Open this URL in a new tab to generate and download screenshot' | |
| } | |
| } | |
| } else { | |
| // Public access mode | |
| const baseUrl = window.location.origin | |
| return { | |
| type: 'frontend-export-page-public', | |
| url: `${baseUrl}/api/public/image/public/public/${slideIndex}?format=${format}&quality=${quality}`, | |
| isDataUrl: false | |
| } | |
| } | |
| } | |
| // Generate image and upload to backend for external link access | |
| async generateAndUploadImage(slideIndex = 0, options: any = {}) { | |
| const { format = 'jpeg', quality = 90, elementSelector = '.slide-viewport, .canvas-viewport, .viewport-wrapper' } = options; | |
| try { | |
| const { useAuthStore, useSlidesStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| const slidesStore = useSlidesStore() | |
| if (!authStore.isLoggedIn || !this.currentPPTId) { | |
| throw new Error('User not logged in or no current PPT') | |
| } | |
| // Find target element for screenshot | |
| let targetElement: HTMLElement | null = null | |
| const selectors = elementSelector.split(',').map((s: string) => s.trim()) | |
| for (const selector of selectors) { | |
| targetElement = document.querySelector(selector) as HTMLElement | |
| if (targetElement) { | |
| console.log(`β Found screenshot target element: ${selector}`) | |
| break | |
| } | |
| } | |
| if (!targetElement) { | |
| throw new Error(`Screenshot target element not found. Tried selectors: ${selectors.join(', ')}`) | |
| } | |
| // Dynamically load html2canvas | |
| const html2canvas = await this.loadHtml2Canvas() | |
| console.log('πΌοΈ Starting to generate image for upload...', { format, quality }) | |
| // Wait for fonts and images to load | |
| await this.ensureResourcesReady() | |
| // Generate screenshot | |
| const canvas = await html2canvas(targetElement, { | |
| scale: 2, // High resolution | |
| useCORS: true, | |
| allowTaint: true, | |
| backgroundColor: slidesStore.theme?.backgroundColor || '#ffffff', | |
| logging: false, | |
| onclone: function(clonedDoc) { | |
| // Ensure correct fonts in cloned document | |
| const clonedElements = clonedDoc.querySelectorAll('*') | |
| clonedElements.forEach((el: any) => { | |
| if (el.style) { | |
| el.style.fontFamily = 'Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, Helvetica, sans-serif' | |
| } | |
| }) | |
| } | |
| }) | |
| // Convert to specified format | |
| const imageDataUrl = canvas.toDataURL(`image/${format}`, quality / 100) | |
| console.log('β Image generated, uploading to backend...') | |
| // Upload to backend | |
| const baseUrl = window.location.origin | |
| const uploadResponse = await fetch(`${baseUrl}/api/public/save-image/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| imageData: imageDataUrl, | |
| format, | |
| quality | |
| }) | |
| }) | |
| if (!uploadResponse.ok) { | |
| throw new Error(`Upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`) | |
| } | |
| const uploadResult = await uploadResponse.json() | |
| console.log('β Image uploaded successfully, external URL:', uploadResult.imageUrl) | |
| return { | |
| success: true, | |
| imageUrl: uploadResult.imageUrl, | |
| imageId: uploadResult.imageId, | |
| expiresAt: uploadResult.expiresAt, | |
| format, | |
| quality, | |
| width: canvas.width, | |
| height: canvas.height | |
| } | |
| } catch (error: any) { | |
| console.error('β Failed to generate and upload image:', error) | |
| throw error | |
| } | |
| } | |
| // Get PPT screenshot data (for frontend direct rendering) | |
| async getScreenshotData(slideIndex = 0) { | |
| const { useAuthStore } = await import('@/store') | |
| const authStore = useAuthStore() | |
| if (!authStore.isLoggedIn || !this.currentPPTId) { | |
| throw new Error('User not logged in or no current PPT') | |
| } | |
| try { | |
| const baseUrl = window.location.origin | |
| const response = await fetch(`${baseUrl}/api/public/screenshot-data/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}`) | |
| if (!response.ok) { | |
| throw new Error(`Failed to get screenshot data: ${response.status}`) | |
| } | |
| const data = await response.json() | |
| return data | |
| } catch (error: any) { | |
| console.error('β Failed to get screenshot data:', error) | |
| throw new Error(`Failed to get screenshot data: ${error.message}`) | |
| } | |
| } | |
| // Generate screenshot directly in current page (best solution) | |
| async generateDirectScreenshot(slideIndex = 0, options: any = {}) { | |
| try { | |
| const { useSlidesStore } = await import('@/store') | |
| const slidesStore = useSlidesStore() | |
| // Check if slide data is available | |
| if (!slidesStore.slides || slideIndex >= slidesStore.slides.length) { | |
| throw new Error('Invalid slide index or no slides available') | |
| } | |
| const { | |
| format = 'jpeg', | |
| quality = 90, | |
| width = slidesStore.viewportSize || 1000, | |
| height = Math.ceil((slidesStore.viewportSize || 1000) * (slidesStore.viewportRatio || 0.562)), | |
| elementSelector = '.canvas-main, .slide-container, .editor-main' | |
| } = options | |
| // Find screenshot target element | |
| let targetElement: HTMLElement | null = null | |
| const selectors = elementSelector.split(',').map((s: string) => s.trim()) | |
| for (const selector of selectors) { | |
| targetElement = document.querySelector(selector) as HTMLElement | |
| if (targetElement) { | |
| console.log(`β Found screenshot target element: ${selector}`) | |
| break | |
| } | |
| } | |
| if (!targetElement) { | |
| throw new Error(`Screenshot target element not found. Tried selectors: ${selectors.join(', ')}`) | |
| } | |
| // Dynamically load html2canvas | |
| const html2canvas = await this.loadHtml2Canvas() | |
| console.log('πΌοΈ Starting to generate direct screenshot...', { width, height, quality }) | |
| // Wait for fonts and images to load | |
| await this.ensureResourcesReady() | |
| // Generate screenshot | |
| const canvas = await html2canvas(targetElement, { | |
| width, | |
| height, | |
| scale: 2, // High resolution | |
| useCORS: true, | |
| allowTaint: true, | |
| backgroundColor: slidesStore.theme?.backgroundColor || '#ffffff', | |
| logging: false, | |
| onclone: function(clonedDoc) { | |
| // Ensure correct fonts in cloned document | |
| const clonedElements = clonedDoc.querySelectorAll('*') | |
| clonedElements.forEach((el: any) => { | |
| if (el.style) { | |
| el.style.fontFamily = 'Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, Helvetica, sans-serif' | |
| } | |
| }) | |
| } | |
| }) | |
| // Convert to specified format | |
| const imageDataUrl = canvas.toDataURL(`image/${format}`, quality / 100) | |
| console.log('β Direct screenshot generated successfully, size:', imageDataUrl.length) | |
| return { | |
| type: 'direct-frontend', | |
| url: imageDataUrl, | |
| isDataUrl: true, | |
| width: canvas.width, | |
| height: canvas.height, | |
| format, | |
| quality | |
| } | |
| } catch (error: any) { | |
| console.error('β Failed to generate direct screenshot:', error) | |
| // Fallback to frontend export page solution | |
| console.log('π Falling back to frontend export page solution...') | |
| return await this.generateScreenshotUrl(slideIndex, options) | |
| } | |
| } | |
| // Dynamically load html2canvas library | |
| private async loadHtml2Canvas(): Promise<any> { | |
| // Check if already loaded | |
| if ((window as any).html2canvas) { | |
| return (window as any).html2canvas | |
| } | |
| return new Promise((resolve, reject) => { | |
| const script = document.createElement('script') | |
| script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js' | |
| script.onload = () => { | |
| resolve((window as any).html2canvas) | |
| } | |
| script.onerror = (error) => { | |
| reject(new Error('Failed to load html2canvas library')) | |
| } | |
| document.head.appendChild(script) | |
| }) | |
| } | |
| // Wait for resources to be ready | |
| private async ensureResourcesReady(): Promise<void> { | |
| // Wait for font loading | |
| if (document.fonts && document.fonts.ready) { | |
| await document.fonts.ready | |
| } | |
| // Wait for image loading | |
| const images = document.querySelectorAll('img') | |
| const imagePromises = Array.from(images).map(img => { | |
| if (img.complete) return Promise.resolve() | |
| return new Promise((resolve) => { | |
| img.onload = () => resolve(void 0) | |
| img.onerror = () => resolve(void 0) // Continue even if failed | |
| setTimeout(() => resolve(void 0), 2000) // 2 second timeout | |
| }) | |
| }) | |
| await Promise.all(imagePromises) | |
| // Additional wait to ensure rendering is complete | |
| await new Promise(resolve => setTimeout(resolve, 300)) | |
| } | |
| // Simplified frontend screenshot generation (for compatibility) | |
| async generateFrontendScreenshot(elementId = 'slideContainer', options: any = {}) { | |
| console.warn('β οΈ generateFrontendScreenshot is deprecated, use generateDirectScreenshot instead') | |
| return await this.generateDirectScreenshot(0, { ...options, elementSelector: `#${elementId}` }) | |
| } | |
| // Ensure element is fully rendered (for compatibility) | |
| ensureElementReady(element: HTMLElement) { | |
| console.warn('β οΈ ensureElementReady is deprecated, now using ensureResourcesReady') | |
| return this.ensureResourcesReady() | |
| } | |
| // Generate frontend fallback screenshot (for compatibility) | |
| generateFallbackScreenshot(width = 1000, height = 562) { | |
| const canvas = document.createElement('canvas') | |
| canvas.width = width | |
| canvas.height = height | |
| const ctx = canvas.getContext('2d') | |
| if (!ctx) return '' | |
| // Draw background | |
| const gradient = ctx.createLinearGradient(0, 0, width, height) | |
| gradient.addColorStop(0, '#f8f9fa') | |
| gradient.addColorStop(1, '#e9ecef') | |
| ctx.fillStyle = gradient | |
| ctx.fillRect(0, 0, width, height) | |
| // Draw border | |
| ctx.strokeStyle = '#dee2e6' | |
| ctx.lineWidth = 3 | |
| ctx.setLineDash([15, 10]) | |
| ctx.strokeRect(20, 20, width - 40, height - 40) | |
| // Optimized font rendering | |
| ctx.fillStyle = '#495057' | |
| ctx.font = 'bold 32px "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans SC", "WenQuanYi Micro Hei", "SimHei", "SimSun", Arial, sans-serif' | |
| ctx.textAlign = 'center' | |
| ctx.fillText('PPT Preview', width / 2, height * 0.35) | |
| ctx.fillStyle = '#6c757d' | |
| ctx.font = '18px "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans SC", "WenQuanYi Micro Hei", "SimHei", "SimSun", Arial, sans-serif' | |
| ctx.fillText('Frontend Screenshot Service', width / 2, height * 0.5) | |
| ctx.fillStyle = '#adb5bd' | |
| ctx.font = '16px "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans SC", "WenQuanYi Micro Hei", "SimHei", "SimSun", Arial, sans-serif' | |
| ctx.fillText(`Size: ${width} Γ ${height}`, width / 2, height * 0.6) | |
| return canvas.toDataURL('image/jpeg', 0.9) | |
| } | |
| } | |
| // Create singleton instance | |
| export const dataSyncService = new DataSyncService() | |
| export default dataSyncService |