CatPtain commited on
Commit
4db4b63
·
verified ·
1 Parent(s): 7d93f04

Upload 57 files

Browse files
frontend/src/services/dataSyncService.ts CHANGED
@@ -1,13 +1,13 @@
1
  import api from '@/services'
2
  import { debounce } from 'lodash'
3
- // 添加前端截图支持
4
- import { toPng, toJpeg, toSvg } from 'html-to-image'
5
 
6
  class DataSyncService {
7
  private currentPPTId: string | null = null
8
  private saveTimeout: number | null = null
9
  private isOnline = true
10
- private autoSaveDelay = 300000 // 默认5分钟,可配置
11
  private isInitialized = false
12
  private debouncedSave: any = null
13
 
@@ -15,32 +15,32 @@ class DataSyncService {
15
  this.setupNetworkMonitoring()
16
  }
17
 
18
- // 延迟初始化,在 Pinia 可用后调用
19
  async initialize() {
20
  if (this.isInitialized) return
21
  await this.setupAutoSave()
22
  this.isInitialized = true
23
  }
24
 
25
- // 设置自动保存延迟时间(毫秒)
26
  setAutoSaveDelay(delay: number) {
27
- this.autoSaveDelay = Math.max(500, delay) // 最小500ms
28
  if (this.isInitialized) {
29
- this.setupAutoSave() // 重新设置自动保存
30
  }
31
  }
32
 
33
- // 获取当前自动保存延迟时间
34
  getAutoSaveDelay(): number {
35
  return this.autoSaveDelay
36
  }
37
 
38
- // 设置当前PPT ID
39
  setCurrentPPTId(pptId: string) {
40
  this.currentPPTId = pptId
41
  }
42
 
43
- // 自动保存功能
44
  private async setupAutoSave() {
45
  if (this.debouncedSave) {
46
  this.debouncedSave.cancel()
@@ -48,9 +48,9 @@ class DataSyncService {
48
 
49
  this.debouncedSave = debounce(async () => {
50
  await this.savePPT()
51
- }, this.autoSaveDelay) // 使用可配置的延迟时间
52
 
53
- // 监听slides变化
54
  try {
55
  const { useSlidesStore } = await import('@/store')
56
  const slidesStore = useSlidesStore()
@@ -61,26 +61,26 @@ class DataSyncService {
61
  })
62
  }
63
  catch (error) {
64
- // console.warn('无法设置自动保存,store 未就绪:', error)
65
  }
66
  }
67
 
68
- // 网络状态监控
69
  private setupNetworkMonitoring() {
70
  window.addEventListener('online', () => {
71
  this.isOnline = true
72
- // console.log('网络已连接,恢复自动保存')
73
  })
74
 
75
  window.addEventListener('offline', () => {
76
  this.isOnline = false
77
- // console.log('网络已断开,暂停自动保存')
78
  })
79
  }
80
 
81
- // 保存PPT到后端
82
  async savePPT(force = false): Promise<boolean> {
83
- // 动态导入 store,避免初始化时的依赖问题
84
  const { useAuthStore, useSlidesStore } = await import('@/store')
85
 
86
  try {
@@ -88,34 +88,34 @@ class DataSyncService {
88
  const slidesStore = useSlidesStore()
89
 
90
  if (!authStore.isLoggedIn) {
91
- // console.warn('用户未登录,无法保存')
92
  return false
93
  }
94
 
95
- // 如果没有当前PPT ID且是强制保存,创建新PPT
96
  if (!this.currentPPTId && force) {
97
  try {
98
- const response = await api.createPPT(slidesStore.title || '未命名演示文稿')
99
  this.currentPPTId = response.pptId
100
 
101
- // 更新slides store中的数据以匹配新创建的PPT
102
  if (response.ppt) {
103
  slidesStore.setSlides(response.ppt.slides)
104
  slidesStore.setTitle(response.ppt.title)
105
  slidesStore.setTheme(response.ppt.theme)
106
  }
107
 
108
- // console.log('创建新PPT并保存成功')
109
  return true
110
  }
111
  catch (createError) {
112
- // console.error('创建新PPT失败:', createError)
113
  return false
114
  }
115
  }
116
 
117
  if (!this.currentPPTId && !force) {
118
- // console.warn('没有当前PPT ID')
119
  return false
120
  }
121
 
@@ -124,28 +124,28 @@ class DataSyncService {
124
  title: slidesStore.title,
125
  slides: slidesStore.slides,
126
  theme: slidesStore.theme,
127
- // 添加关键的尺��信息
128
  viewportSize: slidesStore.viewportSize,
129
  viewportRatio: slidesStore.viewportRatio
130
  }
131
 
132
  await api.savePPT(pptData)
133
- // console.log('PPT保存成功')
134
  return true
135
  }
136
  catch (error) {
137
- // console.error('PPT保存失败:', error)
138
  return false
139
  }
140
  }
141
 
142
- // 创建新PPT
143
  async createNewPPT(title: string): Promise<string | null> {
144
  const { useAuthStore } = await import('@/store')
145
  const authStore = useAuthStore()
146
 
147
  if (!authStore.isLoggedIn) {
148
- throw new Error('用户未登录')
149
  }
150
 
151
  try {
@@ -154,85 +154,155 @@ class DataSyncService {
154
  return response.pptId
155
  }
156
  catch (error) {
157
- // console.error('创建PPT失败:', error)
158
  throw error
159
  }
160
  }
161
 
162
- // 加载PPT
163
  async loadPPT(pptId: string): Promise<boolean> {
164
  const { useAuthStore, useSlidesStore } = await import('@/store')
165
  const authStore = useAuthStore()
166
  const slidesStore = useSlidesStore()
167
-
168
  if (!authStore.isLoggedIn) {
169
- throw new Error('用户未登录')
170
  }
171
-
172
  try {
 
173
  const pptData = await api.getPPT(pptId)
174
 
175
- slidesStore.setSlides(pptData.slides)
176
- slidesStore.setTitle(pptData.title)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  if (pptData.theme) {
178
  slidesStore.setTheme(pptData.theme)
 
179
  }
180
 
181
- this.setCurrentPPTId(pptId)
182
- // console.log('PPT加载成功')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  return true
184
  }
185
- catch (error) {
186
- // console.error('PPT加载失败:', error)
187
- throw error
 
 
 
 
 
 
 
 
 
 
 
188
  }
189
  }
190
 
191
- // 获取PPT列表
192
  async getPPTList() {
193
  const { useAuthStore } = await import('@/store')
194
  const authStore = useAuthStore()
195
 
196
  if (!authStore.isLoggedIn) {
197
- throw new Error('用户未登录')
198
  }
199
 
200
  return await api.getPPTList()
201
  }
202
 
203
- // 删除PPT
204
  async deletePPT(pptId: string): Promise<boolean> {
205
  const { useAuthStore } = await import('@/store')
206
  const authStore = useAuthStore()
207
 
208
  if (!authStore.isLoggedIn) {
209
- throw new Error('用户未登录')
210
  }
211
 
212
  try {
213
  await api.deletePPT(pptId)
214
 
215
- // 如果删除的是当前PPT,清除当前PPT ID
216
  if (this.currentPPTId === pptId) {
217
  this.currentPPTId = null
218
  }
219
 
220
- // console.log('PPT删除成功')
221
  return true
222
  }
223
  catch (error) {
224
- // console.error('PPT删除失败:', error)
225
  throw error
226
  }
227
  }
228
 
229
- // 生成分享链接
230
  async generateShareLink(slideIndex = 0) {
231
  const { useAuthStore } = await import('@/store')
232
  const authStore = useAuthStore()
233
 
234
  if (!authStore.isLoggedIn || !this.currentPPTId) {
235
- throw new Error('用户未登录或没有当前PPT')
236
  }
237
 
238
  try {
@@ -244,83 +314,293 @@ class DataSyncService {
244
  return response
245
  }
246
  catch (error) {
247
- // console.error('生成分享链接失败:', error)
248
  throw error
249
  }
250
  }
251
 
252
- // 手动保存
253
  async manualSave(): Promise<boolean> {
254
  return await this.savePPT(true)
255
  }
256
 
257
- // 前端截图功能 - 作为后端截图的备用方案
258
- async generateFrontendScreenshot(elementId = 'slideContainer', options = {}) {
259
- try {
260
- const element = document.getElementById(elementId)
261
- if (!element) {
262
- throw new Error(`Element with id '${elementId}' not found`)
263
- }
264
 
265
- const defaultOptions = {
266
- quality: 0.95,
267
- pixelRatio: 1,
268
- backgroundColor: '#ffffff',
269
- ...options
270
  }
271
 
272
- console.log('🖼️ 开始前端截图生成...')
273
 
274
- // 使用html-to-image生成截图
275
- const imageDataUrl = await toJpeg(element, defaultOptions)
276
 
277
- console.log('✅ 前端截图生成成功')
278
- return imageDataUrl
279
- } catch (error) {
280
- console.error('❌ 前端截图生成失败:', error)
281
- throw error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
283
  }
284
 
285
- // 生成PPT截图链接(优先使用后端,失败时尝试前端)
286
- async generateScreenshotUrl(slideIndex = 0, useFrontend = false) {
287
  const { useAuthStore } = await import('@/store')
288
  const authStore = useAuthStore()
289
 
290
  if (!authStore.isLoggedIn || !this.currentPPTId) {
291
- throw new Error('用户未登录或没有当前PPT')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  }
 
293
 
294
- const baseUrl = window.location.origin
295
- const backendScreenshotUrl = `${baseUrl}/api/public/screenshot/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}`
 
 
 
 
 
 
 
 
296
 
297
- if (useFrontend) {
298
- try {
299
- // 尝试前端截图
300
- const frontendScreenshot = await this.generateFrontendScreenshot()
301
- return {
302
- type: 'frontend',
303
- url: frontendScreenshot,
304
- isDataUrl: true
 
 
 
 
 
 
 
 
 
305
  }
306
- } catch (frontendError) {
307
- console.warn('前端截图失败,回退到后端URL:', frontendError.message)
308
- return {
309
- type: 'backend',
310
- url: backendScreenshotUrl,
311
- isDataUrl: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  }
 
 
 
 
 
 
 
314
  }
 
315
 
316
- return {
317
- type: 'backend',
318
- url: backendScreenshotUrl,
319
- isDataUrl: false
 
320
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  }
322
  }
323
 
324
- // 创建单例实例
325
  export const dataSyncService = new DataSyncService()
326
  export default dataSyncService
 
1
  import api from '@/services'
2
  import { debounce } from 'lodash'
3
+ // Using html2canvas instead of html-to-image for better compatibility
4
+ // import { toJpeg } from 'html-to-image'
5
 
6
  class DataSyncService {
7
  private currentPPTId: string | null = null
8
  private saveTimeout: number | null = null
9
  private isOnline = true
10
+ private autoSaveDelay = 300000 // Default 5 minutes, configurable
11
  private isInitialized = false
12
  private debouncedSave: any = null
13
 
 
15
  this.setupNetworkMonitoring()
16
  }
17
 
18
+ // Delayed initialization, called after Pinia is available
19
  async initialize() {
20
  if (this.isInitialized) return
21
  await this.setupAutoSave()
22
  this.isInitialized = true
23
  }
24
 
25
+ // Set auto-save delay time (milliseconds)
26
  setAutoSaveDelay(delay: number) {
27
+ this.autoSaveDelay = Math.max(500, delay) // Minimum 500ms
28
  if (this.isInitialized) {
29
+ this.setupAutoSave() // Reset auto-save
30
  }
31
  }
32
 
33
+ // Get current auto-save delay time
34
  getAutoSaveDelay(): number {
35
  return this.autoSaveDelay
36
  }
37
 
38
+ // Set current PPT ID
39
  setCurrentPPTId(pptId: string) {
40
  this.currentPPTId = pptId
41
  }
42
 
43
+ // Auto-save functionality
44
  private async setupAutoSave() {
45
  if (this.debouncedSave) {
46
  this.debouncedSave.cancel()
 
48
 
49
  this.debouncedSave = debounce(async () => {
50
  await this.savePPT()
51
+ }, this.autoSaveDelay) // Use configurable delay time
52
 
53
+ // Listen for slides changes
54
  try {
55
  const { useSlidesStore } = await import('@/store')
56
  const slidesStore = useSlidesStore()
 
61
  })
62
  }
63
  catch (error) {
64
+ // console.warn('Unable to set up auto-save, store not ready:', error)
65
  }
66
  }
67
 
68
+ // Network status monitoring
69
  private setupNetworkMonitoring() {
70
  window.addEventListener('online', () => {
71
  this.isOnline = true
72
+ // console.log('Network connected, resuming auto-save')
73
  })
74
 
75
  window.addEventListener('offline', () => {
76
  this.isOnline = false
77
+ // console.log('Network disconnected, pausing auto-save')
78
  })
79
  }
80
 
81
+ // Save PPT to backend
82
  async savePPT(force = false): Promise<boolean> {
83
+ // Dynamically import store to avoid dependency issues during initialization
84
  const { useAuthStore, useSlidesStore } = await import('@/store')
85
 
86
  try {
 
88
  const slidesStore = useSlidesStore()
89
 
90
  if (!authStore.isLoggedIn) {
91
+ // console.warn('User not logged in, unable to save')
92
  return false
93
  }
94
 
95
+ // If no current PPT ID and forced save, create new PPT
96
  if (!this.currentPPTId && force) {
97
  try {
98
+ const response = await api.createPPT(slidesStore.title || 'Untitled Presentation')
99
  this.currentPPTId = response.pptId
100
 
101
+ // Update slides store data to match newly created PPT
102
  if (response.ppt) {
103
  slidesStore.setSlides(response.ppt.slides)
104
  slidesStore.setTitle(response.ppt.title)
105
  slidesStore.setTheme(response.ppt.theme)
106
  }
107
 
108
+ // console.log('Created new PPT and saved successfully')
109
  return true
110
  }
111
  catch (createError) {
112
+ // console.error('Failed to create new PPT:', createError)
113
  return false
114
  }
115
  }
116
 
117
  if (!this.currentPPTId && !force) {
118
+ // console.warn('No current PPT ID')
119
  return false
120
  }
121
 
 
124
  title: slidesStore.title,
125
  slides: slidesStore.slides,
126
  theme: slidesStore.theme,
127
+ // Add critical size information
128
  viewportSize: slidesStore.viewportSize,
129
  viewportRatio: slidesStore.viewportRatio
130
  }
131
 
132
  await api.savePPT(pptData)
133
+ // console.log('PPT saved successfully')
134
  return true
135
  }
136
  catch (error) {
137
+ // console.error('Failed to save PPT:', error)
138
  return false
139
  }
140
  }
141
 
142
+ // Create new PPT
143
  async createNewPPT(title: string): Promise<string | null> {
144
  const { useAuthStore } = await import('@/store')
145
  const authStore = useAuthStore()
146
 
147
  if (!authStore.isLoggedIn) {
148
+ throw new Error('User not logged in')
149
  }
150
 
151
  try {
 
154
  return response.pptId
155
  }
156
  catch (error) {
157
+ // console.error('Failed to create PPT:', error)
158
  throw error
159
  }
160
  }
161
 
162
+ // Load PPT
163
  async loadPPT(pptId: string): Promise<boolean> {
164
  const { useAuthStore, useSlidesStore } = await import('@/store')
165
  const authStore = useAuthStore()
166
  const slidesStore = useSlidesStore()
167
+
168
  if (!authStore.isLoggedIn) {
169
+ throw new Error('User not logged in')
170
  }
171
+
172
  try {
173
+ // console.log(`🔍 Starting to load PPT: ${pptId}`)
174
  const pptData = await api.getPPT(pptId)
175
 
176
+ if (!pptData) {
177
+ throw new Error('PPT data is empty')
178
+ }
179
+
180
+ // console.log('📋 PPT data loaded successfully, updating store:', {...})
181
+
182
+ // Fix: Ensure complete PPT data is loaded and set in correct order
183
+
184
+ // 1. First set viewport information to ensure correct canvas size
185
+ if (pptData.viewportSize && pptData.viewportSize > 0) {
186
+ slidesStore.setViewportSize(pptData.viewportSize)
187
+ // console.log('✅ Viewport size set successfully:', pptData.viewportSize)
188
+ }
189
+ else {
190
+ // console.log('⚠️ Using default viewport size: 1000')
191
+ slidesStore.setViewportSize(1000)
192
+ }
193
+
194
+ if (pptData.viewportRatio && pptData.viewportRatio > 0) {
195
+ slidesStore.setViewportRatio(pptData.viewportRatio)
196
+ // console.log('✅ Viewport ratio set successfully:', pptData.viewportRatio)
197
+ }
198
+ else {
199
+ // console.log('⚠️ Using default viewport ratio: 0.5625')
200
+ slidesStore.setViewportRatio(0.5625)
201
+ }
202
+
203
+ // 2. Set theme
204
  if (pptData.theme) {
205
  slidesStore.setTheme(pptData.theme)
206
+ // console.log('✅ Theme set successfully')
207
  }
208
 
209
+ // 3. Set title
210
+ slidesStore.setTitle(pptData.title || 'Untitled Presentation')
211
+ // console.log('✅ Title set successfully:', pptData.title)
212
+
213
+ // 4. Finally set slides, ensuring it is done after viewport information is set
214
+ if (Array.isArray(pptData.slides) && pptData.slides.length > 0) {
215
+ // Validate slides data integrity
216
+ const validSlides = pptData.slides.filter((slide: any) =>
217
+ slide && typeof slide === 'object' && Array.isArray(slide.elements)
218
+ )
219
+
220
+ if (validSlides.length !== pptData.slides.length) {
221
+ // console.warn(`⚠️ Found ${pptData.slides.length - validSlides.length} invalid slides, filtered out`)
222
+ }
223
+
224
+ slidesStore.setSlides(validSlides)
225
+ // console.log('✅ Slides set successfully:', validSlides.length, 'pages')
226
+ }
227
+ else {
228
+ // console.warn('⚠️ PPT has no valid slides, creating default slide')
229
+ slidesStore.setSlides([{
230
+ id: 'default-slide',
231
+ elements: [],
232
+ background: { type: 'solid', color: '#ffffff' }
233
+ }])
234
+ }
235
+
236
+ // 5. Set current PPT ID
237
+ const actualPptId = pptData.id || pptData.pptId || pptId
238
+ this.setCurrentPPTId(actualPptId)
239
+ // console.log('✅ PPT ID set successfully:', actualPptId)
240
+
241
+ // console.log('🎉 PPT loaded successfully!')
242
  return true
243
  }
244
+ catch (error: any) {
245
+ // console.error('❌ Failed to load PPT:', { pptId, error: error.message, stack: error.stack })
246
+
247
+ // Provide more specific error information
248
+ if (error.message?.includes('404') || error.message?.includes('not found')) {
249
+ throw new Error(`PPT not found (ID: ${pptId})`)
250
+ }
251
+ if (error.message?.includes('403') || error.message?.includes('unauthorized')) {
252
+ throw new Error('No access permission')
253
+ }
254
+ if (error.message?.includes('Network')) {
255
+ throw new Error('Network connection failed, please check network status')
256
+ }
257
+ throw new Error(`Failed to load: ${error.message}`)
258
  }
259
  }
260
 
261
+ // Get PPT list
262
  async getPPTList() {
263
  const { useAuthStore } = await import('@/store')
264
  const authStore = useAuthStore()
265
 
266
  if (!authStore.isLoggedIn) {
267
+ throw new Error('User not logged in')
268
  }
269
 
270
  return await api.getPPTList()
271
  }
272
 
273
+ // Delete PPT
274
  async deletePPT(pptId: string): Promise<boolean> {
275
  const { useAuthStore } = await import('@/store')
276
  const authStore = useAuthStore()
277
 
278
  if (!authStore.isLoggedIn) {
279
+ throw new Error('User not logged in')
280
  }
281
 
282
  try {
283
  await api.deletePPT(pptId)
284
 
285
+ // If the deleted PPT is the current PPT, clear the current PPT ID
286
  if (this.currentPPTId === pptId) {
287
  this.currentPPTId = null
288
  }
289
 
290
+ // console.log('PPT deleted successfully')
291
  return true
292
  }
293
  catch (error) {
294
+ // console.error('Failed to delete PPT:', error)
295
  throw error
296
  }
297
  }
298
 
299
+ // Generate share link
300
  async generateShareLink(slideIndex = 0) {
301
  const { useAuthStore } = await import('@/store')
302
  const authStore = useAuthStore()
303
 
304
  if (!authStore.isLoggedIn || !this.currentPPTId) {
305
+ throw new Error('User not logged in or no current PPT')
306
  }
307
 
308
  try {
 
314
  return response
315
  }
316
  catch (error) {
317
+ // console.error('Failed to generate share link:', error)
318
  throw error
319
  }
320
  }
321
 
322
+ // Manual save
323
  async manualSave(): Promise<boolean> {
324
  return await this.savePPT(true)
325
  }
326
 
327
+ // New screenshot solution: use direct image API
328
+ async generateScreenshotUrl(slideIndex = 0, options: any = {}) {
329
+ const { useAuth = true, format = 'jpeg', quality = 90 } = options;
330
+
331
+ if (useAuth) {
332
+ const { useAuthStore } = await import('@/store')
333
+ const authStore = useAuthStore()
334
 
335
+ if (!authStore.isLoggedIn || !this.currentPPTId) {
336
+ throw new Error('User not logged in or no current PPT')
 
 
 
337
  }
338
 
339
+ const baseUrl = window.location.origin
340
 
341
+ // 🔧 NEW: Use frontend screenshot data API for direct generation
342
+ console.log('🚀 Using new frontend screenshot strategy...')
343
 
344
+ try {
345
+ // Get PPT data for frontend generation
346
+ const dataResponse = await fetch(`${baseUrl}/api/public/screenshot-data/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}`)
347
+
348
+ if (dataResponse.ok) {
349
+ const screenshotData = await dataResponse.json()
350
+
351
+ // Return data for frontend direct generation
352
+ return {
353
+ type: 'frontend-data',
354
+ data: screenshotData,
355
+ isDataUrl: false,
356
+ info: {
357
+ format,
358
+ quality,
359
+ strategy: 'frontend-direct-generation',
360
+ pptTitle: screenshotData.pptData?.title,
361
+ slideIndex: screenshotData.slideIndex,
362
+ dimensions: `${screenshotData.exportConfig?.width}x${screenshotData.exportConfig?.height}`
363
+ }
364
+ }
365
+ }
366
+ } catch (error) {
367
+ console.warn('Frontend data API failed, falling back to export page:', error)
368
+ }
369
+
370
+ // Fallback: Frontend export page (user opens in new tab)
371
+ const frontendExportUrl = `${baseUrl}/api/public/image/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}?format=${format}&quality=${quality}`
372
+
373
+ return {
374
+ type: 'frontend-export-page',
375
+ url: frontendExportUrl,
376
+ isDataUrl: false,
377
+ info: {
378
+ format,
379
+ quality,
380
+ strategy: 'frontend-export-page',
381
+ usage: 'Open this URL in a new tab to generate and download screenshot'
382
+ }
383
+ }
384
+ } else {
385
+ // Public access mode
386
+ const baseUrl = window.location.origin
387
+ return {
388
+ type: 'frontend-export-page-public',
389
+ url: `${baseUrl}/api/public/image/public/public/${slideIndex}?format=${format}&quality=${quality}`,
390
+ isDataUrl: false
391
+ }
392
  }
393
  }
394
 
395
+ // Get PPT screenshot data (for frontend direct rendering)
396
+ async getScreenshotData(slideIndex = 0) {
397
  const { useAuthStore } = await import('@/store')
398
  const authStore = useAuthStore()
399
 
400
  if (!authStore.isLoggedIn || !this.currentPPTId) {
401
+ throw new Error('User not logged in or no current PPT')
402
+ }
403
+
404
+ try {
405
+ const baseUrl = window.location.origin
406
+ const response = await fetch(`${baseUrl}/api/public/screenshot-data/${authStore.currentUser!.id}/${this.currentPPTId}/${slideIndex}`)
407
+
408
+ if (!response.ok) {
409
+ throw new Error(`Failed to get screenshot data: ${response.status}`)
410
+ }
411
+
412
+ const data = await response.json()
413
+ return data
414
+ } catch (error: any) {
415
+ console.error('❌ Failed to get screenshot data:', error)
416
+ throw new Error(`Failed to get screenshot data: ${error.message}`)
417
  }
418
+ }
419
 
420
+ // Generate screenshot directly in current page (best solution)
421
+ async generateDirectScreenshot(slideIndex = 0, options: any = {}) {
422
+ try {
423
+ const { useSlidesStore } = await import('@/store')
424
+ const slidesStore = useSlidesStore()
425
+
426
+ // Check if slide data is available
427
+ if (!slidesStore.slides || slideIndex >= slidesStore.slides.length) {
428
+ throw new Error('Invalid slide index or no slides available')
429
+ }
430
 
431
+ const {
432
+ format = 'jpeg',
433
+ quality = 90,
434
+ width = slidesStore.viewportSize || 1000,
435
+ height = Math.ceil((slidesStore.viewportSize || 1000) * (slidesStore.viewportRatio || 0.562)),
436
+ elementSelector = '.canvas-main, .slide-container, .editor-main'
437
+ } = options
438
+
439
+ // Find screenshot target element
440
+ let targetElement: HTMLElement | null = null
441
+ const selectors = elementSelector.split(',').map((s: string) => s.trim())
442
+
443
+ for (const selector of selectors) {
444
+ targetElement = document.querySelector(selector) as HTMLElement
445
+ if (targetElement) {
446
+ console.log(`✅ Found screenshot target element: ${selector}`)
447
+ break
448
  }
449
+ }
450
+
451
+ if (!targetElement) {
452
+ throw new Error(`Screenshot target element not found. Tried selectors: ${selectors.join(', ')}`)
453
+ }
454
+
455
+ // Dynamically load html2canvas
456
+ const html2canvas = await this.loadHtml2Canvas()
457
+
458
+ console.log('🖼️ Starting to generate direct screenshot...', { width, height, quality })
459
+
460
+ // Wait for fonts and images to load
461
+ await this.ensureResourcesReady()
462
+
463
+ // Generate screenshot
464
+ const canvas = await html2canvas(targetElement, {
465
+ width,
466
+ height,
467
+ scale: 2, // High resolution
468
+ useCORS: true,
469
+ allowTaint: true,
470
+ backgroundColor: slidesStore.theme?.backgroundColor || '#ffffff',
471
+ logging: false,
472
+ onclone: function(clonedDoc) {
473
+ // Ensure correct fonts in cloned document
474
+ const clonedElements = clonedDoc.querySelectorAll('*')
475
+ clonedElements.forEach((el: any) => {
476
+ if (el.style) {
477
+ 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'
478
+ }
479
+ })
480
  }
481
+ })
482
+
483
+ // Convert to specified format
484
+ const imageDataUrl = canvas.toDataURL(`image/${format}`, quality / 100)
485
+
486
+ console.log('✅ Direct screenshot generated successfully, size:', imageDataUrl.length)
487
+
488
+ return {
489
+ type: 'direct-frontend',
490
+ url: imageDataUrl,
491
+ isDataUrl: true,
492
+ width: canvas.width,
493
+ height: canvas.height,
494
+ format,
495
+ quality
496
  }
497
+
498
+ } catch (error: any) {
499
+ console.error('❌ Failed to generate direct screenshot:', error)
500
+
501
+ // Fallback to frontend export page solution
502
+ console.log('🔄 Falling back to frontend export page solution...')
503
+ return await this.generateScreenshotUrl(slideIndex, options)
504
  }
505
+ }
506
 
507
+ // Dynamically load html2canvas library
508
+ private async loadHtml2Canvas(): Promise<any> {
509
+ // Check if already loaded
510
+ if ((window as any).html2canvas) {
511
+ return (window as any).html2canvas
512
  }
513
+
514
+ return new Promise((resolve, reject) => {
515
+ const script = document.createElement('script')
516
+ script.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/html2canvas.min.js'
517
+ script.onload = () => {
518
+ resolve((window as any).html2canvas)
519
+ }
520
+ script.onerror = (error) => {
521
+ reject(new Error('Failed to load html2canvas library'))
522
+ }
523
+ document.head.appendChild(script)
524
+ })
525
+ }
526
+
527
+ // Wait for resources to be ready
528
+ private async ensureResourcesReady(): Promise<void> {
529
+ // Wait for font loading
530
+ if (document.fonts && document.fonts.ready) {
531
+ await document.fonts.ready
532
+ }
533
+
534
+ // Wait for image loading
535
+ const images = document.querySelectorAll('img')
536
+ const imagePromises = Array.from(images).map(img => {
537
+ if (img.complete) return Promise.resolve()
538
+
539
+ return new Promise((resolve) => {
540
+ img.onload = () => resolve(void 0)
541
+ img.onerror = () => resolve(void 0) // Continue even if failed
542
+ setTimeout(() => resolve(void 0), 2000) // 2 second timeout
543
+ })
544
+ })
545
+
546
+ await Promise.all(imagePromises)
547
+
548
+ // Additional wait to ensure rendering is complete
549
+ await new Promise(resolve => setTimeout(resolve, 300))
550
+ }
551
+
552
+ // Simplified frontend screenshot generation (for compatibility)
553
+ async generateFrontendScreenshot(elementId = 'slideContainer', options: any = {}) {
554
+ console.warn('⚠️ generateFrontendScreenshot is deprecated, use generateDirectScreenshot instead')
555
+ return await this.generateDirectScreenshot(0, { ...options, elementSelector: `#${elementId}` })
556
+ }
557
+
558
+ // Ensure element is fully rendered (for compatibility)
559
+ ensureElementReady(element: HTMLElement) {
560
+ console.warn('⚠️ ensureElementReady is deprecated, now using ensureResourcesReady')
561
+ return this.ensureResourcesReady()
562
+ }
563
+
564
+ // Generate frontend fallback screenshot (for compatibility)
565
+ generateFallbackScreenshot(width = 1000, height = 562) {
566
+ const canvas = document.createElement('canvas')
567
+ canvas.width = width
568
+ canvas.height = height
569
+ const ctx = canvas.getContext('2d')
570
+
571
+ if (!ctx) return ''
572
+
573
+ // Draw background
574
+ const gradient = ctx.createLinearGradient(0, 0, width, height)
575
+ gradient.addColorStop(0, '#f8f9fa')
576
+ gradient.addColorStop(1, '#e9ecef')
577
+ ctx.fillStyle = gradient
578
+ ctx.fillRect(0, 0, width, height)
579
+
580
+ // Draw border
581
+ ctx.strokeStyle = '#dee2e6'
582
+ ctx.lineWidth = 3
583
+ ctx.setLineDash([15, 10])
584
+ ctx.strokeRect(20, 20, width - 40, height - 40)
585
+
586
+ // Optimized font rendering
587
+ ctx.fillStyle = '#495057'
588
+ 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'
589
+ ctx.textAlign = 'center'
590
+ ctx.fillText('PPT Preview', width / 2, height * 0.35)
591
+
592
+ ctx.fillStyle = '#6c757d'
593
+ 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'
594
+ ctx.fillText('Frontend Screenshot Service', width / 2, height * 0.5)
595
+
596
+ ctx.fillStyle = '#adb5bd'
597
+ 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'
598
+ ctx.fillText(`Size: ${width} × ${height}`, width / 2, height * 0.6)
599
+
600
+ return canvas.toDataURL('image/jpeg', 0.9)
601
  }
602
  }
603
 
604
+ // Create singleton instance
605
  export const dataSyncService = new DataSyncService()
606
  export default dataSyncService
frontend/src/store/auth.ts CHANGED
@@ -1,85 +1,85 @@
1
- import { defineStore } from 'pinia'
2
- import api from '@/services'
3
-
4
- interface User {
5
- id: string
6
- username: string
7
- role: string
8
- }
9
-
10
- interface AuthState {
11
- user: User | null
12
- token: string | null
13
- isAuthenticated: boolean
14
- }
15
-
16
- export const useAuthStore = defineStore('auth', {
17
- state: (): AuthState => ({
18
- user: null,
19
- token: localStorage.getItem('pptist_token'),
20
- isAuthenticated: false
21
- }),
22
-
23
- getters: {
24
- currentUser: (state) => state.user,
25
- isLoggedIn: (state) => state.isAuthenticated && !!state.token,
26
- userRole: (state) => state.user?.role || 'guest'
27
- },
28
-
29
- actions: {
30
- async login(username: string, password: string) {
31
- try {
32
- const response = await api.login(username, password)
33
- this.token = response.token
34
- this.user = response.user
35
- this.isAuthenticated = true
36
-
37
- localStorage.setItem('pptist_token', response.token)
38
- localStorage.setItem('pptist_user', JSON.stringify(response.user))
39
-
40
- return response
41
- } catch (error) {
42
- this.logout()
43
- throw error
44
- }
45
- },
46
-
47
- async verifyToken() {
48
- if (!this.token) {
49
- this.logout()
50
- return false
51
- }
52
-
53
- try {
54
- const response = await api.verifyToken()
55
- this.user = response.user
56
- this.isAuthenticated = true
57
- return true
58
- } catch (error) {
59
- this.logout()
60
- return false
61
- }
62
- },
63
-
64
- logout() {
65
- this.user = null
66
- this.token = null
67
- this.isAuthenticated = false
68
-
69
- localStorage.removeItem('pptist_token')
70
- localStorage.removeItem('pptist_user')
71
- },
72
-
73
- async initAuth() {
74
- const savedUser = localStorage.getItem('pptist_user')
75
- if (savedUser && this.token) {
76
- try {
77
- this.user = JSON.parse(savedUser)
78
- await this.verifyToken()
79
- } catch (error) {
80
- this.logout()
81
- }
82
- }
83
- }
84
- }
85
  })
 
1
+ import { defineStore } from 'pinia'
2
+ import api from '@/services'
3
+
4
+ interface User {
5
+ id: string
6
+ username: string
7
+ role: string
8
+ }
9
+
10
+ interface AuthState {
11
+ user: User | null
12
+ token: string | null
13
+ isAuthenticated: boolean
14
+ }
15
+
16
+ export const useAuthStore = defineStore('auth', {
17
+ state: (): AuthState => ({
18
+ user: null,
19
+ token: localStorage.getItem('pptist_token'),
20
+ isAuthenticated: false
21
+ }),
22
+
23
+ getters: {
24
+ currentUser: (state) => state.user,
25
+ isLoggedIn: (state) => state.isAuthenticated && !!state.token,
26
+ userRole: (state) => state.user?.role || 'guest'
27
+ },
28
+
29
+ actions: {
30
+ async login(username: string, password: string) {
31
+ try {
32
+ const response = await api.login(username, password)
33
+ this.token = response.token
34
+ this.user = response.user
35
+ this.isAuthenticated = true
36
+
37
+ localStorage.setItem('pptist_token', response.token)
38
+ localStorage.setItem('pptist_user', JSON.stringify(response.user))
39
+
40
+ return response
41
+ } catch (error) {
42
+ this.logout()
43
+ throw error
44
+ }
45
+ },
46
+
47
+ async verifyToken() {
48
+ if (!this.token) {
49
+ this.logout()
50
+ return false
51
+ }
52
+
53
+ try {
54
+ const response = await api.verifyToken()
55
+ this.user = response.user
56
+ this.isAuthenticated = true
57
+ return true
58
+ } catch (error) {
59
+ this.logout()
60
+ return false
61
+ }
62
+ },
63
+
64
+ logout() {
65
+ this.user = null
66
+ this.token = null
67
+ this.isAuthenticated = false
68
+
69
+ localStorage.removeItem('pptist_token')
70
+ localStorage.removeItem('pptist_user')
71
+ },
72
+
73
+ async initAuth() {
74
+ const savedUser = localStorage.getItem('pptist_user')
75
+ if (savedUser && this.token) {
76
+ try {
77
+ this.user = JSON.parse(savedUser)
78
+ await this.verifyToken()
79
+ } catch (error) {
80
+ this.logout()
81
+ }
82
+ }
83
+ }
84
+ }
85
  })
frontend/src/store/slides.ts CHANGED
@@ -118,11 +118,26 @@ export const useSlidesStore = defineStore('slides', {
118
  },
119
 
120
  setViewportSize(size: number) {
121
- this.viewportSize = size
 
 
 
 
 
 
 
122
  },
123
 
124
  setViewportRatio(viewportRatio: number) {
125
- this.viewportRatio = viewportRatio
 
 
 
 
 
 
 
 
126
  },
127
 
128
  setSlides(slides: Slide[]) {
 
118
  },
119
 
120
  setViewportSize(size: number) {
121
+ // 🔧 修复:确保视口尺寸的合理性和精度
122
+ if (typeof size !== 'number' || size <= 0 || size > 2000) {
123
+ console.warn(`⚠️ Invalid viewportSize: ${size}, using default 1000`)
124
+ this.viewportSize = 1000
125
+ } else {
126
+ // 保持整数精度,避免浮点误差
127
+ this.viewportSize = Math.round(size)
128
+ }
129
  },
130
 
131
  setViewportRatio(viewportRatio: number) {
132
+ // 🔧 修复:确保视口比例的合理性和高精度
133
+ if (typeof viewportRatio !== 'number' || viewportRatio <= 0 || viewportRatio > 2) {
134
+ console.warn(`⚠️ Invalid viewportRatio: ${viewportRatio}, using default 0.5625`)
135
+ this.viewportRatio = 0.5625
136
+ } else {
137
+ // 🔧 关键修复:保持极高精度,防止累积误差
138
+ // 使用8位小数精度确保视口比例的准确性
139
+ this.viewportRatio = Math.round(viewportRatio * 100000000) / 100000000
140
+ }
141
  },
142
 
143
  setSlides(slides: Slide[]) {
frontend/src/types/edit.ts CHANGED
@@ -1,126 +1,126 @@
1
- import type { ShapePoolItem } from '@/configs/shapes'
2
- import type { LinePoolItem } from '@/configs/lines'
3
- import type { ImageClipDataRange, PPTElementOutline, PPTElementShadow, Gradient } from './slides'
4
-
5
- export enum ElementOrderCommands {
6
- UP = 'up',
7
- DOWN = 'down',
8
- TOP = 'top',
9
- BOTTOM = 'bottom',
10
- }
11
-
12
- export enum ElementAlignCommands {
13
- TOP = 'top',
14
- BOTTOM = 'bottom',
15
- LEFT = 'left',
16
- RIGHT = 'right',
17
- VERTICAL = 'vertical',
18
- HORIZONTAL = 'horizontal',
19
- CENTER = 'center',
20
- }
21
-
22
- export const enum OperateBorderLines {
23
- T = 'top',
24
- B = 'bottom',
25
- L = 'left',
26
- R = 'right',
27
- }
28
-
29
- export const enum OperateResizeHandlers {
30
- LEFT_TOP = 'left-top',
31
- TOP = 'top',
32
- RIGHT_TOP = 'right-top',
33
- LEFT = 'left',
34
- RIGHT = 'right',
35
- LEFT_BOTTOM = 'left-bottom',
36
- BOTTOM = 'bottom',
37
- RIGHT_BOTTOM = 'right-bottom',
38
- }
39
-
40
- export const enum OperateLineHandlers {
41
- START = 'start',
42
- END = 'end',
43
- C = 'ctrl',
44
- C1 = 'ctrl1',
45
- C2 = 'ctrl2',
46
- }
47
-
48
- export interface AlignmentLineAxis {
49
- x: number
50
- y: number
51
- }
52
-
53
- export interface AlignmentLineProps {
54
- type: 'vertical' | 'horizontal'
55
- axis: AlignmentLineAxis
56
- length: number
57
- }
58
-
59
- export interface MultiSelectRange {
60
- minX: number
61
- maxX: number
62
- minY: number
63
- maxY: number
64
- }
65
-
66
- export interface ImageClipedEmitData {
67
- range: ImageClipDataRange
68
- position: {
69
- left: number
70
- top: number
71
- width: number
72
- height: number
73
- }
74
- }
75
-
76
- export interface CreateElementSelectionData {
77
- start: [number, number]
78
- end: [number, number]
79
- }
80
-
81
- export interface CreateCustomShapeData {
82
- start: [number, number]
83
- end: [number, number]
84
- path: string
85
- viewBox: [number, number]
86
- fill?: string
87
- outline?: PPTElementOutline
88
- }
89
-
90
- export interface CreatingTextElement {
91
- type: 'text'
92
- vertical?: boolean
93
- }
94
- export interface CreatingShapeElement {
95
- type: 'shape'
96
- data: ShapePoolItem
97
- }
98
- export interface CreatingLineElement {
99
- type: 'line'
100
- data: LinePoolItem
101
- }
102
- export type CreatingElement = CreatingTextElement | CreatingShapeElement | CreatingLineElement
103
-
104
- export type TextFormatPainterKeys = 'bold' | 'em' | 'underline' | 'strikethrough' | 'color' | 'backcolor' | 'fontsize' | 'fontname' | 'align'
105
-
106
- export interface TextFormatPainter {
107
- keep: boolean
108
- bold?: boolean
109
- em?: boolean
110
- underline?: boolean
111
- strikethrough?: boolean
112
- color?: string
113
- backcolor?: string
114
- fontsize?: string
115
- fontname?: string
116
- align?: 'left' | 'right' | 'center'
117
- }
118
-
119
- export interface ShapeFormatPainter {
120
- keep: boolean
121
- fill?: string
122
- gradient?: Gradient
123
- outline?: PPTElementOutline
124
- opacity?: number
125
- shadow?: PPTElementShadow
126
  }
 
1
+ import type { ShapePoolItem } from '@/configs/shapes'
2
+ import type { LinePoolItem } from '@/configs/lines'
3
+ import type { ImageClipDataRange, PPTElementOutline, PPTElementShadow, Gradient } from './slides'
4
+
5
+ export enum ElementOrderCommands {
6
+ UP = 'up',
7
+ DOWN = 'down',
8
+ TOP = 'top',
9
+ BOTTOM = 'bottom',
10
+ }
11
+
12
+ export enum ElementAlignCommands {
13
+ TOP = 'top',
14
+ BOTTOM = 'bottom',
15
+ LEFT = 'left',
16
+ RIGHT = 'right',
17
+ VERTICAL = 'vertical',
18
+ HORIZONTAL = 'horizontal',
19
+ CENTER = 'center',
20
+ }
21
+
22
+ export const enum OperateBorderLines {
23
+ T = 'top',
24
+ B = 'bottom',
25
+ L = 'left',
26
+ R = 'right',
27
+ }
28
+
29
+ export const enum OperateResizeHandlers {
30
+ LEFT_TOP = 'left-top',
31
+ TOP = 'top',
32
+ RIGHT_TOP = 'right-top',
33
+ LEFT = 'left',
34
+ RIGHT = 'right',
35
+ LEFT_BOTTOM = 'left-bottom',
36
+ BOTTOM = 'bottom',
37
+ RIGHT_BOTTOM = 'right-bottom',
38
+ }
39
+
40
+ export const enum OperateLineHandlers {
41
+ START = 'start',
42
+ END = 'end',
43
+ C = 'ctrl',
44
+ C1 = 'ctrl1',
45
+ C2 = 'ctrl2',
46
+ }
47
+
48
+ export interface AlignmentLineAxis {
49
+ x: number
50
+ y: number
51
+ }
52
+
53
+ export interface AlignmentLineProps {
54
+ type: 'vertical' | 'horizontal'
55
+ axis: AlignmentLineAxis
56
+ length: number
57
+ }
58
+
59
+ export interface MultiSelectRange {
60
+ minX: number
61
+ maxX: number
62
+ minY: number
63
+ maxY: number
64
+ }
65
+
66
+ export interface ImageClipedEmitData {
67
+ range: ImageClipDataRange
68
+ position: {
69
+ left: number
70
+ top: number
71
+ width: number
72
+ height: number
73
+ }
74
+ }
75
+
76
+ export interface CreateElementSelectionData {
77
+ start: [number, number]
78
+ end: [number, number]
79
+ }
80
+
81
+ export interface CreateCustomShapeData {
82
+ start: [number, number]
83
+ end: [number, number]
84
+ path: string
85
+ viewBox: [number, number]
86
+ fill?: string
87
+ outline?: PPTElementOutline
88
+ }
89
+
90
+ export interface CreatingTextElement {
91
+ type: 'text'
92
+ vertical?: boolean
93
+ }
94
+ export interface CreatingShapeElement {
95
+ type: 'shape'
96
+ data: ShapePoolItem
97
+ }
98
+ export interface CreatingLineElement {
99
+ type: 'line'
100
+ data: LinePoolItem
101
+ }
102
+ export type CreatingElement = CreatingTextElement | CreatingShapeElement | CreatingLineElement
103
+
104
+ export type TextFormatPainterKeys = 'bold' | 'em' | 'underline' | 'strikethrough' | 'color' | 'backcolor' | 'fontsize' | 'fontname' | 'align'
105
+
106
+ export interface TextFormatPainter {
107
+ keep: boolean
108
+ bold?: boolean
109
+ em?: boolean
110
+ underline?: boolean
111
+ strikethrough?: boolean
112
+ color?: string
113
+ backcolor?: string
114
+ fontsize?: string
115
+ fontname?: string
116
+ align?: 'left' | 'right' | 'center'
117
+ }
118
+
119
+ export interface ShapeFormatPainter {
120
+ keep: boolean
121
+ fill?: string
122
+ gradient?: Gradient
123
+ outline?: PPTElementOutline
124
+ opacity?: number
125
+ shadow?: PPTElementShadow
126
  }
frontend/src/types/export.ts CHANGED
@@ -1 +1 @@
1
- export type DialogForExportTypes = 'image' | 'pdf' | 'json' | 'pptx' | 'pptist' | ''
 
1
+ export type DialogForExportTypes = 'image' | 'pdf' | 'json' | 'pptx' | 'pptist' | 'html' | ''
frontend/src/utils/element.ts CHANGED
@@ -1,259 +1,259 @@
1
- import tinycolor from 'tinycolor2'
2
- import { nanoid } from 'nanoid'
3
- import type { PPTElement, PPTLineElement, Slide } from '@/types/slides'
4
-
5
- interface RotatedElementData {
6
- left: number
7
- top: number
8
- width: number
9
- height: number
10
- rotate: number
11
- }
12
-
13
- interface IdMap {
14
- [id: string]: string
15
- }
16
-
17
- /**
18
- * 计算元素在画布中的矩形范围旋转后的新位置范围
19
- * @param element 元素的位置大小和旋转角度信息
20
- */
21
- export const getRectRotatedRange = (element: RotatedElementData) => {
22
- const { left, top, width, height, rotate = 0 } = element
23
-
24
- const radius = Math.sqrt( Math.pow(width, 2) + Math.pow(height, 2) ) / 2
25
- const auxiliaryAngle = Math.atan(height / width) * 180 / Math.PI
26
-
27
- const tlbraRadian = (180 - rotate - auxiliaryAngle) * Math.PI / 180
28
- const trblaRadian = (auxiliaryAngle - rotate) * Math.PI / 180
29
-
30
- const middleLeft = left + width / 2
31
- const middleTop = top + height / 2
32
-
33
- const xAxis = [
34
- middleLeft + radius * Math.cos(tlbraRadian),
35
- middleLeft + radius * Math.cos(trblaRadian),
36
- middleLeft - radius * Math.cos(tlbraRadian),
37
- middleLeft - radius * Math.cos(trblaRadian),
38
- ]
39
- const yAxis = [
40
- middleTop - radius * Math.sin(tlbraRadian),
41
- middleTop - radius * Math.sin(trblaRadian),
42
- middleTop + radius * Math.sin(tlbraRadian),
43
- middleTop + radius * Math.sin(trblaRadian),
44
- ]
45
-
46
- return {
47
- xRange: [Math.min(...xAxis), Math.max(...xAxis)],
48
- yRange: [Math.min(...yAxis), Math.max(...yAxis)],
49
- }
50
- }
51
-
52
- /**
53
- * 计算元素在画布中的矩形范围旋转后的新位置与旋转之前位置的偏离距离
54
- * @param element 元素的位置大小和旋转角度信息
55
- */
56
- export const getRectRotatedOffset = (element: RotatedElementData) => {
57
- const { xRange: originXRange, yRange: originYRange } = getRectRotatedRange({
58
- left: element.left,
59
- top: element.top,
60
- width: element.width,
61
- height: element.height,
62
- rotate: 0,
63
- })
64
- const { xRange: rotatedXRange, yRange: rotatedYRange } = getRectRotatedRange({
65
- left: element.left,
66
- top: element.top,
67
- width: element.width,
68
- height: element.height,
69
- rotate: element.rotate,
70
- })
71
- return {
72
- offsetX: rotatedXRange[0] - originXRange[0],
73
- offsetY: rotatedYRange[0] - originYRange[0],
74
- }
75
- }
76
-
77
- /**
78
- * 计算元素在画布中的位置范围
79
- * @param element 元素信息
80
- */
81
- export const getElementRange = (element: PPTElement) => {
82
- let minX, maxX, minY, maxY
83
-
84
- if (element.type === 'line') {
85
- minX = element.left
86
- maxX = element.left + Math.max(element.start[0], element.end[0])
87
- minY = element.top
88
- maxY = element.top + Math.max(element.start[1], element.end[1])
89
- }
90
- else if ('rotate' in element && element.rotate) {
91
- const { left, top, width, height, rotate } = element
92
- const { xRange, yRange } = getRectRotatedRange({ left, top, width, height, rotate })
93
- minX = xRange[0]
94
- maxX = xRange[1]
95
- minY = yRange[0]
96
- maxY = yRange[1]
97
- }
98
- else {
99
- minX = element.left
100
- maxX = element.left + element.width
101
- minY = element.top
102
- maxY = element.top + element.height
103
- }
104
- return { minX, maxX, minY, maxY }
105
- }
106
-
107
- /**
108
- * 计算一组元素在画布中的位置范围
109
- * @param elementList 一组元素信息
110
- */
111
- export const getElementListRange = (elementList: PPTElement[]) => {
112
- const leftValues: number[] = []
113
- const topValues: number[] = []
114
- const rightValues: number[] = []
115
- const bottomValues: number[] = []
116
-
117
- elementList.forEach(element => {
118
- const { minX, maxX, minY, maxY } = getElementRange(element)
119
- leftValues.push(minX)
120
- topValues.push(minY)
121
- rightValues.push(maxX)
122
- bottomValues.push(maxY)
123
- })
124
-
125
- const minX = Math.min(...leftValues)
126
- const maxX = Math.max(...rightValues)
127
- const minY = Math.min(...topValues)
128
- const maxY = Math.max(...bottomValues)
129
-
130
- return { minX, maxX, minY, maxY }
131
- }
132
-
133
- /**
134
- * 计算线条元素的长度
135
- * @param element 线条元素
136
- */
137
- export const getLineElementLength = (element: PPTLineElement) => {
138
- const deltaX = element.end[0] - element.start[0]
139
- const deltaY = element.end[1] - element.start[1]
140
- const len = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
141
- return len
142
- }
143
-
144
- export interface AlignLine {
145
- value: number
146
- range: [number, number]
147
- }
148
-
149
- /**
150
- * 将一组对齐吸附线进行去重:同位置的的多条对齐吸附线仅留下一条,取该位置所有对齐吸附线的最大值和最小值为新的范围
151
- * @param lines 一组对齐吸附线信息
152
- */
153
- export const uniqAlignLines = (lines: AlignLine[]) => {
154
- const uniqLines: AlignLine[] = []
155
- lines.forEach(line => {
156
- const index = uniqLines.findIndex(_line => _line.value === line.value)
157
- if (index === -1) uniqLines.push(line)
158
- else {
159
- const uniqLine = uniqLines[index]
160
- const rangeMin = Math.min(uniqLine.range[0], line.range[0])
161
- const rangeMax = Math.max(uniqLine.range[1], line.range[1])
162
- const range: [number, number] = [rangeMin, rangeMax]
163
- const _line = { value: line.value, range }
164
- uniqLines[index] = _line
165
- }
166
- })
167
- return uniqLines
168
- }
169
-
170
- /**
171
- * 以页面列表为基础,为每一个页面生成新的ID,并关联到旧ID形成一个字典
172
- * 主要用于页面元素时,维持数据中各处页面ID原有的关系
173
- * @param slides 页面列表
174
- */
175
- export const createSlideIdMap = (slides: Slide[]) => {
176
- const slideIdMap: IdMap = {}
177
- for (const slide of slides) {
178
- slideIdMap[slide.id] = nanoid(10)
179
- }
180
- return slideIdMap
181
- }
182
-
183
- /**
184
- * 以元素列表为基础,为每一个元素生成新的ID,并关联到旧ID形成一个字典
185
- * 主要用于复制元素时,维持数据中各处元素ID原有的关系
186
- * 例如:原本两个组合的元素拥有相同的groupId,复制后依然会拥有另一个相同的groupId
187
- * @param elements 元素列表数据
188
- */
189
- export const createElementIdMap = (elements: PPTElement[]) => {
190
- const groupIdMap: IdMap = {}
191
- const elIdMap: IdMap = {}
192
- for (const element of elements) {
193
- const groupId = element.groupId
194
- if (groupId && !groupIdMap[groupId]) {
195
- groupIdMap[groupId] = nanoid(10)
196
- }
197
- elIdMap[element.id] = nanoid(10)
198
- }
199
- return {
200
- groupIdMap,
201
- elIdMap,
202
- }
203
- }
204
-
205
- /**
206
- * 根据表格的主题色,获取对应用于配色的子颜色
207
- * @param themeColor 主题色
208
- */
209
- export const getTableSubThemeColor = (themeColor: string) => {
210
- const rgba = tinycolor(themeColor)
211
- return [
212
- rgba.setAlpha(0.3).toRgbString(),
213
- rgba.setAlpha(0.1).toRgbString(),
214
- ]
215
- }
216
-
217
- /**
218
- * 获取线条元素路径字符串
219
- * @param element 线条元素
220
- */
221
- export const getLineElementPath = (element: PPTLineElement) => {
222
- const start = element.start.join(',')
223
- const end = element.end.join(',')
224
- if (element.broken) {
225
- const mid = element.broken.join(',')
226
- return `M${start} L${mid} L${end}`
227
- }
228
- else if (element.broken2) {
229
- const { minX, maxX, minY, maxY } = getElementRange(element)
230
- if (maxX - minX >= maxY - minY) return `M${start} L${element.broken2[0]},${element.start[1]} L${element.broken2[0]},${element.end[1]} ${end}`
231
- return `M${start} L${element.start[0]},${element.broken2[1]} L${element.end[0]},${element.broken2[1]} ${end}`
232
- }
233
- else if (element.curve) {
234
- const mid = element.curve.join(',')
235
- return `M${start} Q${mid} ${end}`
236
- }
237
- else if (element.cubic) {
238
- const [c1, c2] = element.cubic
239
- const p1 = c1.join(',')
240
- const p2 = c2.join(',')
241
- return `M${start} C${p1} ${p2} ${end}`
242
- }
243
- return `M${start} L${end}`
244
- }
245
-
246
- /**
247
- * 判断一个元素是否在可视范围内
248
- * @param element 元素
249
- * @param parent 父元素
250
- */
251
- export const isElementInViewport = (element: HTMLElement, parent: HTMLElement): boolean => {
252
- const elementRect = element.getBoundingClientRect()
253
- const parentRect = parent.getBoundingClientRect()
254
-
255
- return (
256
- elementRect.top >= parentRect.top &&
257
- elementRect.bottom <= parentRect.bottom
258
- )
259
  }
 
1
+ import tinycolor from 'tinycolor2'
2
+ import { nanoid } from 'nanoid'
3
+ import type { PPTElement, PPTLineElement, Slide } from '@/types/slides'
4
+
5
+ interface RotatedElementData {
6
+ left: number
7
+ top: number
8
+ width: number
9
+ height: number
10
+ rotate: number
11
+ }
12
+
13
+ interface IdMap {
14
+ [id: string]: string
15
+ }
16
+
17
+ /**
18
+ * 计算元素在画布中的矩形范围旋转后的新位置范围
19
+ * @param element 元素的位置大小和旋转角度信息
20
+ */
21
+ export const getRectRotatedRange = (element: RotatedElementData) => {
22
+ const { left, top, width, height, rotate = 0 } = element
23
+
24
+ const radius = Math.sqrt( Math.pow(width, 2) + Math.pow(height, 2) ) / 2
25
+ const auxiliaryAngle = Math.atan(height / width) * 180 / Math.PI
26
+
27
+ const tlbraRadian = (180 - rotate - auxiliaryAngle) * Math.PI / 180
28
+ const trblaRadian = (auxiliaryAngle - rotate) * Math.PI / 180
29
+
30
+ const middleLeft = left + width / 2
31
+ const middleTop = top + height / 2
32
+
33
+ const xAxis = [
34
+ middleLeft + radius * Math.cos(tlbraRadian),
35
+ middleLeft + radius * Math.cos(trblaRadian),
36
+ middleLeft - radius * Math.cos(tlbraRadian),
37
+ middleLeft - radius * Math.cos(trblaRadian),
38
+ ]
39
+ const yAxis = [
40
+ middleTop - radius * Math.sin(tlbraRadian),
41
+ middleTop - radius * Math.sin(trblaRadian),
42
+ middleTop + radius * Math.sin(tlbraRadian),
43
+ middleTop + radius * Math.sin(trblaRadian),
44
+ ]
45
+
46
+ return {
47
+ xRange: [Math.min(...xAxis), Math.max(...xAxis)],
48
+ yRange: [Math.min(...yAxis), Math.max(...yAxis)],
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 计算元素在画布中的矩形范围旋转后的新位置与旋转之前位置的偏离距离
54
+ * @param element 元素的位置大小和旋转角度信息
55
+ */
56
+ export const getRectRotatedOffset = (element: RotatedElementData) => {
57
+ const { xRange: originXRange, yRange: originYRange } = getRectRotatedRange({
58
+ left: element.left,
59
+ top: element.top,
60
+ width: element.width,
61
+ height: element.height,
62
+ rotate: 0,
63
+ })
64
+ const { xRange: rotatedXRange, yRange: rotatedYRange } = getRectRotatedRange({
65
+ left: element.left,
66
+ top: element.top,
67
+ width: element.width,
68
+ height: element.height,
69
+ rotate: element.rotate,
70
+ })
71
+ return {
72
+ offsetX: rotatedXRange[0] - originXRange[0],
73
+ offsetY: rotatedYRange[0] - originYRange[0],
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 计算元素在画布中的位置范围
79
+ * @param element 元素信息
80
+ */
81
+ export const getElementRange = (element: PPTElement) => {
82
+ let minX, maxX, minY, maxY
83
+
84
+ if (element.type === 'line') {
85
+ minX = element.left
86
+ maxX = element.left + Math.max(element.start[0], element.end[0])
87
+ minY = element.top
88
+ maxY = element.top + Math.max(element.start[1], element.end[1])
89
+ }
90
+ else if ('rotate' in element && element.rotate) {
91
+ const { left, top, width, height, rotate } = element
92
+ const { xRange, yRange } = getRectRotatedRange({ left, top, width, height, rotate })
93
+ minX = xRange[0]
94
+ maxX = xRange[1]
95
+ minY = yRange[0]
96
+ maxY = yRange[1]
97
+ }
98
+ else {
99
+ minX = element.left
100
+ maxX = element.left + element.width
101
+ minY = element.top
102
+ maxY = element.top + element.height
103
+ }
104
+ return { minX, maxX, minY, maxY }
105
+ }
106
+
107
+ /**
108
+ * 计算一组元素在画布中的位置范围
109
+ * @param elementList 一组元素信息
110
+ */
111
+ export const getElementListRange = (elementList: PPTElement[]) => {
112
+ const leftValues: number[] = []
113
+ const topValues: number[] = []
114
+ const rightValues: number[] = []
115
+ const bottomValues: number[] = []
116
+
117
+ elementList.forEach(element => {
118
+ const { minX, maxX, minY, maxY } = getElementRange(element)
119
+ leftValues.push(minX)
120
+ topValues.push(minY)
121
+ rightValues.push(maxX)
122
+ bottomValues.push(maxY)
123
+ })
124
+
125
+ const minX = Math.min(...leftValues)
126
+ const maxX = Math.max(...rightValues)
127
+ const minY = Math.min(...topValues)
128
+ const maxY = Math.max(...bottomValues)
129
+
130
+ return { minX, maxX, minY, maxY }
131
+ }
132
+
133
+ /**
134
+ * 计算线条元素的长度
135
+ * @param element 线条元素
136
+ */
137
+ export const getLineElementLength = (element: PPTLineElement) => {
138
+ const deltaX = element.end[0] - element.start[0]
139
+ const deltaY = element.end[1] - element.start[1]
140
+ const len = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
141
+ return len
142
+ }
143
+
144
+ export interface AlignLine {
145
+ value: number
146
+ range: [number, number]
147
+ }
148
+
149
+ /**
150
+ * 将一组对齐吸附线进行去重:同位置的的多条对齐吸附线仅留下一条,取该位置所有对齐吸附线的最大值和最小值为新的范围
151
+ * @param lines 一组对齐吸附线信息
152
+ */
153
+ export const uniqAlignLines = (lines: AlignLine[]) => {
154
+ const uniqLines: AlignLine[] = []
155
+ lines.forEach(line => {
156
+ const index = uniqLines.findIndex(_line => _line.value === line.value)
157
+ if (index === -1) uniqLines.push(line)
158
+ else {
159
+ const uniqLine = uniqLines[index]
160
+ const rangeMin = Math.min(uniqLine.range[0], line.range[0])
161
+ const rangeMax = Math.max(uniqLine.range[1], line.range[1])
162
+ const range: [number, number] = [rangeMin, rangeMax]
163
+ const _line = { value: line.value, range }
164
+ uniqLines[index] = _line
165
+ }
166
+ })
167
+ return uniqLines
168
+ }
169
+
170
+ /**
171
+ * 以页面列表为基础,为每一个页面生成新的ID,并关联到旧ID形成一个字典
172
+ * 主要用于页面元素时,维持数据中各处页面ID原有的关系
173
+ * @param slides 页面列表
174
+ */
175
+ export const createSlideIdMap = (slides: Slide[]) => {
176
+ const slideIdMap: IdMap = {}
177
+ for (const slide of slides) {
178
+ slideIdMap[slide.id] = nanoid(10)
179
+ }
180
+ return slideIdMap
181
+ }
182
+
183
+ /**
184
+ * 以元素列表为基础,为每一个元素生成新的ID,并关联到旧ID形成一个字典
185
+ * 主要用于复制元素时,维持数据中各处元素ID原有的关系
186
+ * 例如:原本两个组合的元素拥有相同的groupId,复制后依然会拥有另一个相同的groupId
187
+ * @param elements 元素列表数据
188
+ */
189
+ export const createElementIdMap = (elements: PPTElement[]) => {
190
+ const groupIdMap: IdMap = {}
191
+ const elIdMap: IdMap = {}
192
+ for (const element of elements) {
193
+ const groupId = element.groupId
194
+ if (groupId && !groupIdMap[groupId]) {
195
+ groupIdMap[groupId] = nanoid(10)
196
+ }
197
+ elIdMap[element.id] = nanoid(10)
198
+ }
199
+ return {
200
+ groupIdMap,
201
+ elIdMap,
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 根据表格的主题色,获取对应用于配色的子颜色
207
+ * @param themeColor 主题色
208
+ */
209
+ export const getTableSubThemeColor = (themeColor: string) => {
210
+ const rgba = tinycolor(themeColor)
211
+ return [
212
+ rgba.setAlpha(0.3).toRgbString(),
213
+ rgba.setAlpha(0.1).toRgbString(),
214
+ ]
215
+ }
216
+
217
+ /**
218
+ * 获取线条元素路径字符串
219
+ * @param element 线条元素
220
+ */
221
+ export const getLineElementPath = (element: PPTLineElement) => {
222
+ const start = element.start.join(',')
223
+ const end = element.end.join(',')
224
+ if (element.broken) {
225
+ const mid = element.broken.join(',')
226
+ return `M${start} L${mid} L${end}`
227
+ }
228
+ else if (element.broken2) {
229
+ const { minX, maxX, minY, maxY } = getElementRange(element)
230
+ if (maxX - minX >= maxY - minY) return `M${start} L${element.broken2[0]},${element.start[1]} L${element.broken2[0]},${element.end[1]} ${end}`
231
+ return `M${start} L${element.start[0]},${element.broken2[1]} L${element.end[0]},${element.broken2[1]} ${end}`
232
+ }
233
+ else if (element.curve) {
234
+ const mid = element.curve.join(',')
235
+ return `M${start} Q${mid} ${end}`
236
+ }
237
+ else if (element.cubic) {
238
+ const [c1, c2] = element.cubic
239
+ const p1 = c1.join(',')
240
+ const p2 = c2.join(',')
241
+ return `M${start} C${p1} ${p2} ${end}`
242
+ }
243
+ return `M${start} L${end}`
244
+ }
245
+
246
+ /**
247
+ * 判断一个元素是否在可视范围内
248
+ * @param element 元素
249
+ * @param parent 父元素
250
+ */
251
+ export const isElementInViewport = (element: HTMLElement, parent: HTMLElement): boolean => {
252
+ const elementRect = element.getBoundingClientRect()
253
+ const parentRect = parent.getBoundingClientRect()
254
+
255
+ return (
256
+ elementRect.top >= parentRect.top &&
257
+ elementRect.bottom <= parentRect.bottom
258
+ )
259
  }
frontend/src/utils/textParser.ts CHANGED
@@ -1,13 +1,13 @@
1
- /**
2
- * 将普通文本转为带段落信息的HTML字符串
3
- * @param text 文本
4
- */
5
- export const parseText2Paragraphs = (text: string) => {
6
- const htmlText = text.replace(/[\n\r]+/g, '<br>')
7
- const paragraphs = htmlText.split('<br>')
8
- let string = ''
9
- for (const paragraph of paragraphs) {
10
- if (paragraph) string += `<div>${paragraph}</div>`
11
- }
12
- return string
13
  }
 
1
+ /**
2
+ * 将普通文本转为带段落信息的HTML字符串
3
+ * @param text 文本
4
+ */
5
+ export const parseText2Paragraphs = (text: string) => {
6
+ const htmlText = text.replace(/[\n\r]+/g, '<br>')
7
+ const paragraphs = htmlText.split('<br>')
8
+ let string = ''
9
+ for (const paragraph of paragraphs) {
10
+ if (paragraph) string += `<div>${paragraph}</div>`
11
+ }
12
+ return string
13
  }