Upload 57 files
Browse files- frontend/src/services/dataSyncService.ts +377 -97
- frontend/src/store/auth.ts +84 -84
- frontend/src/store/slides.ts +17 -2
- frontend/src/types/edit.ts +125 -125
- frontend/src/types/export.ts +1 -1
- frontend/src/utils/element.ts +258 -258
- frontend/src/utils/textParser.ts +12 -12
frontend/src/services/dataSyncService.ts
CHANGED
@@ -1,13 +1,13 @@
|
|
1 |
import api from '@/services'
|
2 |
import { debounce } from 'lodash'
|
3 |
-
//
|
4 |
-
import {
|
5 |
|
6 |
class DataSyncService {
|
7 |
private currentPPTId: string | null = null
|
8 |
private saveTimeout: number | null = null
|
9 |
private isOnline = true
|
10 |
-
private autoSaveDelay = 300000 //
|
11 |
private isInitialized = false
|
12 |
private debouncedSave: any = null
|
13 |
|
@@ -15,32 +15,32 @@ class DataSyncService {
|
|
15 |
this.setupNetworkMonitoring()
|
16 |
}
|
17 |
|
18 |
-
//
|
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) //
|
28 |
if (this.isInitialized) {
|
29 |
-
this.setupAutoSave() //
|
30 |
}
|
31 |
}
|
32 |
|
33 |
-
//
|
34 |
getAutoSaveDelay(): number {
|
35 |
return this.autoSaveDelay
|
36 |
}
|
37 |
|
38 |
-
//
|
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 |
-
//
|
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('
|
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 |
-
//
|
82 |
async savePPT(force = false): Promise<boolean> {
|
83 |
-
//
|
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 |
-
//
|
96 |
if (!this.currentPPTId && force) {
|
97 |
try {
|
98 |
-
const response = await api.createPPT(slidesStore.title || '
|
99 |
this.currentPPTId = response.pptId
|
100 |
|
101 |
-
//
|
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('
|
109 |
return true
|
110 |
}
|
111 |
catch (createError) {
|
112 |
-
// console.error('
|
113 |
return false
|
114 |
}
|
115 |
}
|
116 |
|
117 |
if (!this.currentPPTId && !force) {
|
118 |
-
// console.warn('
|
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
|
138 |
return false
|
139 |
}
|
140 |
}
|
141 |
|
142 |
-
//
|
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('
|
158 |
throw error
|
159 |
}
|
160 |
}
|
161 |
|
162 |
-
//
|
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 |
-
|
176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
177 |
if (pptData.theme) {
|
178 |
slidesStore.setTheme(pptData.theme)
|
|
|
179 |
}
|
180 |
|
181 |
-
|
182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
183 |
return true
|
184 |
}
|
185 |
-
catch (error) {
|
186 |
-
// console.error('PPT
|
187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
188 |
}
|
189 |
}
|
190 |
|
191 |
-
//
|
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 |
-
//
|
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 |
-
//
|
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
|
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('
|
236 |
}
|
237 |
|
238 |
try {
|
@@ -244,83 +314,293 @@ class DataSyncService {
|
|
244 |
return response
|
245 |
}
|
246 |
catch (error) {
|
247 |
-
// console.error('
|
248 |
throw error
|
249 |
}
|
250 |
}
|
251 |
|
252 |
-
//
|
253 |
async manualSave(): Promise<boolean> {
|
254 |
return await this.savePPT(true)
|
255 |
}
|
256 |
|
257 |
-
//
|
258 |
-
async
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
|
265 |
-
|
266 |
-
|
267 |
-
pixelRatio: 1,
|
268 |
-
backgroundColor: '#ffffff',
|
269 |
-
...options
|
270 |
}
|
271 |
|
272 |
-
|
273 |
|
274 |
-
//
|
275 |
-
|
276 |
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
282 |
}
|
283 |
}
|
284 |
|
285 |
-
//
|
286 |
-
async
|
287 |
const { useAuthStore } = await import('@/store')
|
288 |
const authStore = useAuthStore()
|
289 |
|
290 |
if (!authStore.isLoggedIn || !this.currentPPTId) {
|
291 |
-
throw new Error('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
292 |
}
|
|
|
293 |
|
294 |
-
|
295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
296 |
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
305 |
}
|
306 |
-
}
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
312 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
313 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
314 |
}
|
|
|
315 |
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
|
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
122 |
},
|
123 |
|
124 |
setViewportRatio(viewportRatio: number) {
|
125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
}
|