@hed-hog/lms 0.0.357 → 0.0.361
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/course/course-operations-integration.service.d.ts +31 -0
- package/dist/course/course-operations-integration.service.d.ts.map +1 -1
- package/dist/course/course-operations-integration.service.js +286 -22
- package/dist/course/course-operations-integration.service.js.map +1 -1
- package/dist/course/course-operations.controller.d.ts +10 -0
- package/dist/course/course-operations.controller.d.ts.map +1 -0
- package/dist/course/course-operations.controller.js +67 -0
- package/dist/course/course-operations.controller.js.map +1 -0
- package/dist/course/course-structure.controller.d.ts +3 -1
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.service.d.ts +3 -1
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +13 -6
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +15 -2
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/dto/update-course-operations-config.dto.d.ts +6 -0
- package/dist/course/dto/update-course-operations-config.dto.d.ts.map +1 -0
- package/dist/course/dto/update-course-operations-config.dto.js +33 -0
- package/dist/course/dto/update-course-operations-config.dto.js.map +1 -0
- package/dist/course/lms-bulk-upload.controller.d.ts +37 -0
- package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -0
- package/dist/course/lms-bulk-upload.controller.js +60 -0
- package/dist/course/lms-bulk-upload.controller.js.map +1 -0
- package/dist/course/lms-bulk-upload.service.d.ts +42 -0
- package/dist/course/lms-bulk-upload.service.d.ts.map +1 -0
- package/dist/course/lms-bulk-upload.service.js +169 -0
- package/dist/course/lms-bulk-upload.service.js.map +1 -0
- package/dist/course/lms-operations-task.subscriber.d.ts +13 -0
- package/dist/course/lms-operations-task.subscriber.d.ts.map +1 -0
- package/dist/course/lms-operations-task.subscriber.js +57 -0
- package/dist/course/lms-operations-task.subscriber.js.map +1 -0
- package/dist/course/lms-setting.controller.d.ts +3 -0
- package/dist/course/lms-setting.controller.d.ts.map +1 -1
- package/dist/course/lms-setting.controller.js +9 -1
- package/dist/course/lms-setting.controller.js.map +1 -1
- package/dist/enterprise/enterprise.service.js +1 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/instructor/instructor.service.d.ts.map +1 -1
- package/dist/instructor/instructor.service.js +12 -3
- package/dist/instructor/instructor.service.js.map +1 -1
- package/dist/platforma/platforma.controller.d.ts +9 -9
- package/hedhog/data/role.yaml +8 -0
- package/hedhog/data/route.yaml +62 -0
- package/hedhog/data/setting_group.yaml +33 -0
- package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +26 -4
- package/hedhog/frontend/app/_lib/editor/types.ts.ejs +6 -0
- package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +447 -31
- package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +59 -47
- package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +201 -35
- package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +36 -5
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-operations-tab.tsx.ejs +382 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +31 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +91 -20
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +11 -3
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -2
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +2 -2
- package/hedhog/frontend/app/courses/page.tsx.ejs +21 -88
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +18 -6
- package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +5 -4
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +16 -10
- package/package.json +7 -7
- package/src/course/course-operations-integration.service.ts +460 -22
- package/src/course/course-operations.controller.ts +45 -0
- package/src/course/course-structure.service.ts +5 -1
- package/src/course/course.module.ts +15 -2
- package/src/course/dto/update-course-operations-config.dto.ts +16 -0
- package/src/course/lms-bulk-upload.controller.ts +27 -0
- package/src/course/lms-bulk-upload.service.ts +204 -0
- package/src/course/lms-operations-task.subscriber.ts +44 -0
- package/src/course/lms-setting.controller.ts +12 -1
- package/src/enterprise/enterprise.service.ts +1 -1
- package/src/instructor/instructor.service.ts +12 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useSidebar } from '@/components/ui/sidebar';
|
|
4
|
+
import { useApp } from '@hed-hog/next-app-provider';
|
|
5
|
+
import { ImageIcon, QrCode } from 'lucide-react';
|
|
4
6
|
import { useEffect, useRef, useState } from 'react';
|
|
5
7
|
import {
|
|
6
8
|
getCanvasAPI,
|
|
@@ -29,6 +31,90 @@ const MARGIN_INSET = 60; // px from canvas edge
|
|
|
29
31
|
const MIN_ZOOM = 0.2;
|
|
30
32
|
const MAX_ZOOM = 3.0;
|
|
31
33
|
const FIT_PADDING = 24;
|
|
34
|
+
type AppRequest = ReturnType<typeof useApp>['request'];
|
|
35
|
+
type LucideNode = [string, Record<string, string | number>];
|
|
36
|
+
const IMAGE_ICON_NODE_FALLBACK: LucideNode[] = [
|
|
37
|
+
['rect', { width: '18', height: '18', x: '3', y: '3', rx: '2', ry: '2' }],
|
|
38
|
+
['circle', { cx: '9', cy: '9', r: '2' }],
|
|
39
|
+
['path', { d: 'm21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21' }],
|
|
40
|
+
];
|
|
41
|
+
const QRCODE_ICON_NODE_FALLBACK: LucideNode[] = [
|
|
42
|
+
['rect', { width: '5', height: '5', x: '3', y: '3', rx: '1' }],
|
|
43
|
+
['rect', { width: '5', height: '5', x: '16', y: '3', rx: '1' }],
|
|
44
|
+
['rect', { width: '5', height: '5', x: '3', y: '16', rx: '1' }],
|
|
45
|
+
['path', { d: 'M21 16h-3a2 2 0 0 0-2 2v3' }],
|
|
46
|
+
['path', { d: 'M21 21v.01' }],
|
|
47
|
+
['path', { d: 'M12 7v3a2 2 0 0 1-2 2H7' }],
|
|
48
|
+
['path', { d: 'M3 12h.01' }],
|
|
49
|
+
['path', { d: 'M12 3h.01' }],
|
|
50
|
+
['path', { d: 'M12 16v.01' }],
|
|
51
|
+
['path', { d: 'M16 12h1' }],
|
|
52
|
+
['path', { d: 'M21 12v.01' }],
|
|
53
|
+
['path', { d: 'M12 21v-1' }],
|
|
54
|
+
];
|
|
55
|
+
const QRCODE_ICON_NODE = (QrCode as unknown as { iconNode?: LucideNode[] })
|
|
56
|
+
.iconNode;
|
|
57
|
+
const IMAGE_ICON_NODE = (ImageIcon as unknown as { iconNode?: LucideNode[] })
|
|
58
|
+
.iconNode;
|
|
59
|
+
const RESOLVED_QRCODE_ICON_NODE = QRCODE_ICON_NODE?.length
|
|
60
|
+
? QRCODE_ICON_NODE
|
|
61
|
+
: QRCODE_ICON_NODE_FALLBACK;
|
|
62
|
+
const RESOLVED_IMAGE_ICON_NODE = IMAGE_ICON_NODE?.length
|
|
63
|
+
? IMAGE_ICON_NODE
|
|
64
|
+
: IMAGE_ICON_NODE_FALLBACK;
|
|
65
|
+
|
|
66
|
+
async function resolveProtectedOpenSrc(
|
|
67
|
+
request: AppRequest,
|
|
68
|
+
fileId: number
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
try {
|
|
71
|
+
const response = await request<{ url?: string }>({
|
|
72
|
+
url: `/file/open/${fileId}`,
|
|
73
|
+
method: 'PUT',
|
|
74
|
+
});
|
|
75
|
+
return response?.data?.url || `/file/open/${fileId}`;
|
|
76
|
+
} catch {
|
|
77
|
+
return `/file/open/${fileId}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadImageElementForObject(canvas: any, obj: any, src: string) {
|
|
82
|
+
const image = new Image();
|
|
83
|
+
image.crossOrigin = 'anonymous';
|
|
84
|
+
image.onload = () => {
|
|
85
|
+
obj._tplResolvedImageElement = image;
|
|
86
|
+
obj.dirty = true;
|
|
87
|
+
canvas.requestRenderAll();
|
|
88
|
+
};
|
|
89
|
+
image.onerror = () => {
|
|
90
|
+
obj._tplResolvedImageElement = null;
|
|
91
|
+
obj.dirty = true;
|
|
92
|
+
canvas.requestRenderAll();
|
|
93
|
+
};
|
|
94
|
+
image.src = src;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function ensureObjectImageLoaded(
|
|
98
|
+
canvas: any,
|
|
99
|
+
obj: any,
|
|
100
|
+
request: AppRequest
|
|
101
|
+
) {
|
|
102
|
+
if (!obj || obj._tplType !== 'image') {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fileId = Number(obj._tplImageFileId ?? 0);
|
|
107
|
+
if (!Number.isFinite(fileId) || fileId <= 0) {
|
|
108
|
+
obj._tplResolvedImageElement = null;
|
|
109
|
+
obj.dirty = true;
|
|
110
|
+
canvas.requestRenderAll();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const src = await resolveProtectedOpenSrc(request, fileId);
|
|
115
|
+
obj._tplImageSrc = src;
|
|
116
|
+
loadImageElementForObject(canvas, obj, src);
|
|
117
|
+
}
|
|
32
118
|
|
|
33
119
|
function applyCanvasZoom(canvas: any, appliedZoom: number) {
|
|
34
120
|
canvas.setZoom(appliedZoom);
|
|
@@ -39,11 +125,246 @@ function applyCanvasZoom(canvas: any, appliedZoom: number) {
|
|
|
39
125
|
canvas.requestRenderAll();
|
|
40
126
|
}
|
|
41
127
|
|
|
128
|
+
function renderLucideIcon(
|
|
129
|
+
ctx: CanvasRenderingContext2D,
|
|
130
|
+
iconNode: LucideNode[] | undefined,
|
|
131
|
+
size: number,
|
|
132
|
+
color = '#334155'
|
|
133
|
+
) {
|
|
134
|
+
if (!iconNode?.length) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const safeSize = Math.max(10, size);
|
|
139
|
+
const scale = safeSize / 24;
|
|
140
|
+
|
|
141
|
+
ctx.save();
|
|
142
|
+
ctx.translate(-safeSize / 2, -safeSize / 2);
|
|
143
|
+
ctx.scale(scale, scale);
|
|
144
|
+
ctx.strokeStyle = color;
|
|
145
|
+
ctx.lineWidth = 2;
|
|
146
|
+
ctx.lineCap = 'round';
|
|
147
|
+
ctx.lineJoin = 'round';
|
|
148
|
+
|
|
149
|
+
for (const [tag, attrs] of iconNode) {
|
|
150
|
+
if (tag === 'path') {
|
|
151
|
+
const d = attrs.d;
|
|
152
|
+
if (typeof d === 'string') {
|
|
153
|
+
const path = new Path2D(d);
|
|
154
|
+
ctx.stroke(path);
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (tag === 'rect') {
|
|
160
|
+
const x = Number(attrs.x ?? 0);
|
|
161
|
+
const y = Number(attrs.y ?? 0);
|
|
162
|
+
const w = Number(attrs.width ?? 0);
|
|
163
|
+
const h = Number(attrs.height ?? 0);
|
|
164
|
+
const rx = Number(attrs.rx ?? 0);
|
|
165
|
+
|
|
166
|
+
if (typeof (ctx as any).roundRect === 'function' && rx > 0) {
|
|
167
|
+
ctx.beginPath();
|
|
168
|
+
(ctx as any).roundRect(x, y, w, h, rx);
|
|
169
|
+
ctx.stroke();
|
|
170
|
+
} else {
|
|
171
|
+
ctx.strokeRect(x, y, w, h);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (tag === 'circle') {
|
|
176
|
+
const cx = Number(attrs.cx ?? 0);
|
|
177
|
+
const cy = Number(attrs.cy ?? 0);
|
|
178
|
+
const r = Number(attrs.r ?? 0);
|
|
179
|
+
ctx.beginPath();
|
|
180
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
181
|
+
ctx.stroke();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
ctx.restore();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function drawImageOnObject(
|
|
189
|
+
ctx: CanvasRenderingContext2D,
|
|
190
|
+
obj: any,
|
|
191
|
+
image: HTMLImageElement
|
|
192
|
+
) {
|
|
193
|
+
const width = Number(obj.width ?? 0);
|
|
194
|
+
const height = Number(obj.height ?? 0);
|
|
195
|
+
const scaleX = Number(obj.scaleX ?? 1);
|
|
196
|
+
const scaleY = Number(obj.scaleY ?? 1);
|
|
197
|
+
const imageWidth = image.naturalWidth || image.width || 1;
|
|
198
|
+
const imageHeight = image.naturalHeight || image.height || 1;
|
|
199
|
+
if (width <= 0 || height <= 0 || imageWidth <= 0 || imageHeight <= 0) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const resizeMode =
|
|
204
|
+
obj._tplImageResizeMode === 'cover' ||
|
|
205
|
+
obj._tplImageResizeMode === 'stretch' ||
|
|
206
|
+
obj._tplImageResizeMode === 'center'
|
|
207
|
+
? obj._tplImageResizeMode
|
|
208
|
+
: 'contain';
|
|
209
|
+
|
|
210
|
+
let renderWidth = width;
|
|
211
|
+
let renderHeight = height;
|
|
212
|
+
|
|
213
|
+
ctx.save();
|
|
214
|
+
if (
|
|
215
|
+
resizeMode !== 'stretch' &&
|
|
216
|
+
Number.isFinite(scaleX) &&
|
|
217
|
+
Number.isFinite(scaleY) &&
|
|
218
|
+
scaleX !== 0 &&
|
|
219
|
+
scaleY !== 0
|
|
220
|
+
) {
|
|
221
|
+
// Neutralize object non-uniform transform so contain/cover/center keep image ratio.
|
|
222
|
+
ctx.scale(1 / scaleX, 1 / scaleY);
|
|
223
|
+
renderWidth = width * scaleX;
|
|
224
|
+
renderHeight = height * scaleY;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const x = -renderWidth / 2;
|
|
228
|
+
const y = -renderHeight / 2;
|
|
229
|
+
|
|
230
|
+
if (typeof (ctx as any).roundRect === 'function') {
|
|
231
|
+
ctx.beginPath();
|
|
232
|
+
(ctx as any).roundRect(x, y, renderWidth, renderHeight, 4);
|
|
233
|
+
ctx.clip();
|
|
234
|
+
} else {
|
|
235
|
+
ctx.beginPath();
|
|
236
|
+
ctx.rect(x, y, renderWidth, renderHeight);
|
|
237
|
+
ctx.clip();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (resizeMode === 'stretch') {
|
|
241
|
+
ctx.drawImage(image, x, y, renderWidth, renderHeight);
|
|
242
|
+
ctx.restore();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (resizeMode === 'center') {
|
|
247
|
+
const dx = x + (renderWidth - imageWidth) / 2;
|
|
248
|
+
const dy = y + (renderHeight - imageHeight) / 2;
|
|
249
|
+
ctx.drawImage(image, dx, dy, imageWidth, imageHeight);
|
|
250
|
+
ctx.restore();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const scale =
|
|
255
|
+
resizeMode === 'cover'
|
|
256
|
+
? Math.max(renderWidth / imageWidth, renderHeight / imageHeight)
|
|
257
|
+
: Math.min(renderWidth / imageWidth, renderHeight / imageHeight);
|
|
258
|
+
const dw = imageWidth * scale;
|
|
259
|
+
const dh = imageHeight * scale;
|
|
260
|
+
const dx = x + (renderWidth - dw) / 2;
|
|
261
|
+
const dy = y + (renderHeight - dh) / 2;
|
|
262
|
+
|
|
263
|
+
ctx.drawImage(image, dx, dy, dw, dh);
|
|
264
|
+
ctx.restore();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function attachPlaceholderOverlay(obj: any) {
|
|
268
|
+
if (!obj || obj._tplOverlayAttached) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (obj._tplType !== 'field' && obj._tplType !== 'image') {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const originalRender = obj._render?.bind(obj);
|
|
277
|
+
if (!originalRender) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
obj._render = function (ctx: CanvasRenderingContext2D) {
|
|
282
|
+
originalRender(ctx);
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
this._tplType === 'image' &&
|
|
286
|
+
this._tplImageFileId != null &&
|
|
287
|
+
this._tplResolvedImageElement
|
|
288
|
+
) {
|
|
289
|
+
drawImageOnObject(ctx, this, this._tplResolvedImageElement);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const isQrPlaceholder =
|
|
293
|
+
this._tplType === 'field' &&
|
|
294
|
+
this._tplKey === 'qrCode' &&
|
|
295
|
+
this._tplQrPlaceholder === true;
|
|
296
|
+
const isLogoPlaceholder =
|
|
297
|
+
this._tplType === 'image' &&
|
|
298
|
+
this._tplImagePlaceholder &&
|
|
299
|
+
this._tplKey === 'courseLogo';
|
|
300
|
+
const isBannerPlaceholder =
|
|
301
|
+
this._tplType === 'image' &&
|
|
302
|
+
this._tplImagePlaceholder &&
|
|
303
|
+
this._tplKey === 'courseBanner';
|
|
304
|
+
const isGenericImagePlaceholder =
|
|
305
|
+
this._tplType === 'image' &&
|
|
306
|
+
!this._tplImagePlaceholder &&
|
|
307
|
+
this._tplImageFileId == null;
|
|
308
|
+
|
|
309
|
+
if (
|
|
310
|
+
!isQrPlaceholder &&
|
|
311
|
+
!isLogoPlaceholder &&
|
|
312
|
+
!isBannerPlaceholder &&
|
|
313
|
+
!isGenericImagePlaceholder
|
|
314
|
+
) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const width = Number(this.width ?? 0);
|
|
319
|
+
const height = Number(this.height ?? 0);
|
|
320
|
+
|
|
321
|
+
if (width <= 0 || height <= 0) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
ctx.save();
|
|
326
|
+
|
|
327
|
+
if (isQrPlaceholder) {
|
|
328
|
+
const iconSize = Math.min(width, height) * 0.72;
|
|
329
|
+
renderLucideIcon(ctx, RESOLVED_QRCODE_ICON_NODE, iconSize, '#475569');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (isGenericImagePlaceholder) {
|
|
333
|
+
const iconSize = Math.min(width, height) * 0.58;
|
|
334
|
+
renderLucideIcon(ctx, RESOLVED_IMAGE_ICON_NODE, iconSize, '#64748b');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (isLogoPlaceholder || isBannerPlaceholder) {
|
|
338
|
+
const label = isLogoPlaceholder ? 'LOGO' : 'BANNER';
|
|
339
|
+
const fontPx = Math.max(12, Math.min(26, Math.round(height * 0.28)));
|
|
340
|
+
ctx.fillStyle = '#475569';
|
|
341
|
+
ctx.font = `700 ${fontPx}px Inter, sans-serif`;
|
|
342
|
+
ctx.textAlign = 'center';
|
|
343
|
+
ctx.textBaseline = 'middle';
|
|
344
|
+
ctx.fillText(label, 0, 0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
ctx.restore();
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
obj._tplOverlayAttached = true;
|
|
351
|
+
obj.dirty = true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function attachOverlaysForCanvas(canvas: any) {
|
|
355
|
+
const objects = canvas.getObjects().filter((o: any) => !o._isGuide);
|
|
356
|
+
objects.forEach((obj: any) => {
|
|
357
|
+
attachPlaceholderOverlay(obj);
|
|
358
|
+
applyFixedAspectResizeBehavior(obj);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
42
362
|
type CanvasStageProps = {
|
|
43
363
|
isLoading?: boolean;
|
|
44
364
|
};
|
|
45
365
|
|
|
46
366
|
export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
367
|
+
const { request } = useApp();
|
|
47
368
|
const { state: sidebarState } = useSidebar();
|
|
48
369
|
const canvasElRef = useRef<HTMLCanvasElement>(null);
|
|
49
370
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
@@ -94,8 +415,8 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
94
415
|
const el = canvasElRef.current;
|
|
95
416
|
if (!el) return;
|
|
96
417
|
|
|
97
|
-
const { zoom: manualZoom
|
|
98
|
-
const z =
|
|
418
|
+
const { zoom: manualZoom } = useTemplateStore.getState();
|
|
419
|
+
const z = manualZoom;
|
|
99
420
|
|
|
100
421
|
canvas = new Canvas(el, {
|
|
101
422
|
width: CANVAS_W * z,
|
|
@@ -110,6 +431,13 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
110
431
|
/* load existing template objects */
|
|
111
432
|
const template = useTemplateStore.getState().template;
|
|
112
433
|
templateJSONToFabric(canvas, template, fabricMod);
|
|
434
|
+
attachOverlaysForCanvas(canvas);
|
|
435
|
+
const imageObjects = canvas
|
|
436
|
+
.getObjects()
|
|
437
|
+
.filter((o: any) => o._tplType === 'image');
|
|
438
|
+
imageObjects.forEach((obj: any) => {
|
|
439
|
+
void ensureObjectImageLoaded(canvas, obj, request);
|
|
440
|
+
});
|
|
113
441
|
|
|
114
442
|
if (template.background.src) {
|
|
115
443
|
await applyBg(canvas, fabricMod, template.background.src);
|
|
@@ -129,7 +457,10 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
129
457
|
if (snapRef.current) applySnapping(canvas, e.target);
|
|
130
458
|
onModified(e);
|
|
131
459
|
});
|
|
132
|
-
canvas.on('object:scaling',
|
|
460
|
+
canvas.on('object:scaling', (e: any) => {
|
|
461
|
+
enforceUniformScaling(e.target);
|
|
462
|
+
onModified(e);
|
|
463
|
+
});
|
|
133
464
|
canvas.on('object:rotating', onModified);
|
|
134
465
|
canvas.on('text:changed', onModified);
|
|
135
466
|
|
|
@@ -143,7 +474,7 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
143
474
|
const delta = -e.deltaY;
|
|
144
475
|
const currentManualZoom = useTemplateStore.getState().zoom;
|
|
145
476
|
const newManualZoom = clampZoom(currentManualZoom * (1 + delta / 500));
|
|
146
|
-
const appliedZoom =
|
|
477
|
+
const appliedZoom = newManualZoom;
|
|
147
478
|
|
|
148
479
|
const point =
|
|
149
480
|
typeof canvas.getScenePoint === 'function'
|
|
@@ -195,7 +526,7 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
195
526
|
});
|
|
196
527
|
|
|
197
528
|
/* ── register API for other panels ── */
|
|
198
|
-
registerCanvasAPI(buildAPI(canvas, fabricMod));
|
|
529
|
+
registerCanvasAPI(buildAPI(canvas, fabricMod, request));
|
|
199
530
|
}
|
|
200
531
|
|
|
201
532
|
init();
|
|
@@ -254,7 +585,7 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
254
585
|
/* ── sync effective zoom to fit + manual zoom ── */
|
|
255
586
|
useEffect(() => {
|
|
256
587
|
const c = fabricRef.current;
|
|
257
|
-
const appliedZoom =
|
|
588
|
+
const appliedZoom = zoom;
|
|
258
589
|
|
|
259
590
|
setEffectiveZoom(appliedZoom);
|
|
260
591
|
|
|
@@ -591,6 +922,17 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
591
922
|
} else {
|
|
592
923
|
api.addObject(data.type, { key: data.key, shape: data.shape });
|
|
593
924
|
}
|
|
925
|
+
|
|
926
|
+
if (data.type === 'image' && !data.key) {
|
|
927
|
+
const selectedObjectId = useTemplateStore.getState().selectedObjectId;
|
|
928
|
+
if (selectedObjectId) {
|
|
929
|
+
window.dispatchEvent(
|
|
930
|
+
new CustomEvent('lms-certificate:auto-open-image-upload', {
|
|
931
|
+
detail: { objectId: selectedObjectId },
|
|
932
|
+
})
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
594
936
|
} catch {
|
|
595
937
|
/* ignore bad data */
|
|
596
938
|
}
|
|
@@ -599,7 +941,7 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
599
941
|
return (
|
|
600
942
|
<div
|
|
601
943
|
ref={wrapperRef}
|
|
602
|
-
className={`relative
|
|
944
|
+
className={`relative h-full min-h-0 min-w-0 w-full overflow-hidden bg-muted/60 transition-colors ${
|
|
603
945
|
isDragOver ? 'ring-2 ring-inset ring-primary/40 bg-primary/5' : ''
|
|
604
946
|
}`}
|
|
605
947
|
onDragOver={handleDragOver}
|
|
@@ -620,8 +962,12 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
620
962
|
</div>
|
|
621
963
|
</div>
|
|
622
964
|
)}
|
|
623
|
-
<div className="
|
|
624
|
-
<
|
|
965
|
+
<div className="absolute inset-0 overflow-auto overscroll-contain">
|
|
966
|
+
<div className="flex min-h-full min-w-full items-center justify-center p-6">
|
|
967
|
+
<div className="shadow-lg" style={{ lineHeight: 0 }}>
|
|
968
|
+
<canvas ref={canvasElRef} />
|
|
969
|
+
</div>
|
|
970
|
+
</div>
|
|
625
971
|
</div>
|
|
626
972
|
</div>
|
|
627
973
|
);
|
|
@@ -632,17 +978,61 @@ export default function CanvasStage({ isLoading = false }: CanvasStageProps) {
|
|
|
632
978
|
function onSelect(e: any) {
|
|
633
979
|
const obj = e.selected?.[0];
|
|
634
980
|
if (obj?._tplId) {
|
|
981
|
+
applyFixedAspectResizeBehavior(obj);
|
|
635
982
|
useTemplateStore.getState().selectObject(obj._tplId);
|
|
636
983
|
}
|
|
637
984
|
}
|
|
638
985
|
|
|
986
|
+
function shouldKeepUniformScaling(obj: any) {
|
|
987
|
+
const isQrCode = obj?._tplType === 'field' && obj?._tplKey === 'qrCode';
|
|
988
|
+
const isCourseLogo =
|
|
989
|
+
obj?._tplType === 'image' && obj?._tplKey === 'courseLogo';
|
|
990
|
+
return isQrCode || isCourseLogo;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function applyFixedAspectResizeBehavior(obj: any) {
|
|
994
|
+
if (!obj || !shouldKeepUniformScaling(obj)) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
obj.setControlsVisibility({
|
|
999
|
+
mt: false,
|
|
1000
|
+
mb: false,
|
|
1001
|
+
ml: false,
|
|
1002
|
+
mr: false,
|
|
1003
|
+
});
|
|
1004
|
+
obj.set({ lockUniScaling: true });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function enforceUniformScaling(obj: any) {
|
|
1008
|
+
if (!obj || !shouldKeepUniformScaling(obj)) {
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const sx = Number(obj.scaleX ?? 1);
|
|
1013
|
+
const sy = Number(obj.scaleY ?? 1);
|
|
1014
|
+
const useX = Math.abs(sx - 1) >= Math.abs(sy - 1);
|
|
1015
|
+
const uniform = useX ? sx : sy;
|
|
1016
|
+
|
|
1017
|
+
obj.set({ scaleX: uniform, scaleY: uniform });
|
|
1018
|
+
obj.setCoords?.();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
639
1021
|
function onModified(e: any) {
|
|
640
1022
|
const obj = e.target ?? e;
|
|
641
1023
|
if (!obj?._tplId) return;
|
|
1024
|
+
enforceUniformScaling(obj);
|
|
642
1025
|
const partial = fabricObjToTemplatePartial(obj);
|
|
643
1026
|
|
|
644
1027
|
const extra: Partial<TemplateObject> = {};
|
|
645
|
-
|
|
1028
|
+
const isQrPlaceholder =
|
|
1029
|
+
obj._tplType === 'field' &&
|
|
1030
|
+
obj._tplKey === 'qrCode' &&
|
|
1031
|
+
obj._tplQrPlaceholder === true;
|
|
1032
|
+
if (
|
|
1033
|
+
(obj._tplType === 'field' || obj._tplType === 'staticText') &&
|
|
1034
|
+
!isQrPlaceholder
|
|
1035
|
+
) {
|
|
646
1036
|
extra.text = obj.text;
|
|
647
1037
|
}
|
|
648
1038
|
|
|
@@ -677,7 +1067,7 @@ async function applyBg(canvas: any, fabricMod: any, src: string) {
|
|
|
677
1067
|
|
|
678
1068
|
/* ────────────── canvas API (for panels) ────────────── */
|
|
679
1069
|
|
|
680
|
-
function buildAPI(canvas: any, fabricMod: any): CanvasAPI {
|
|
1070
|
+
function buildAPI(canvas: any, fabricMod: any, request: any): CanvasAPI {
|
|
681
1071
|
function findObj(id: string) {
|
|
682
1072
|
return canvas.getObjects().find((o: any) => o._tplId === id);
|
|
683
1073
|
}
|
|
@@ -695,23 +1085,30 @@ function buildAPI(canvas: any, fabricMod: any): CanvasAPI {
|
|
|
695
1085
|
|
|
696
1086
|
if (type === 'field') {
|
|
697
1087
|
const isQrCode = key === 'qrCode';
|
|
698
|
-
fObj = new fabricMod.Textbox(`{{${key}}}`, {
|
|
699
|
-
left: cx - (isQrCode ? 90 : 120),
|
|
700
|
-
top: cy - (isQrCode ? 45 : 18),
|
|
701
|
-
width: isQrCode ? 180 : 240,
|
|
702
|
-
height: isQrCode ? 90 : undefined,
|
|
703
|
-
fontSize: isQrCode ? 20 : 28,
|
|
704
|
-
fontFamily: 'Inter',
|
|
705
|
-
fontWeight: '400',
|
|
706
|
-
fill: '#1e293b',
|
|
707
|
-
textAlign: 'center',
|
|
708
|
-
});
|
|
709
1088
|
if (isQrCode) {
|
|
710
|
-
fObj.
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1089
|
+
fObj = new fabricMod.Rect({
|
|
1090
|
+
left: cx - 90,
|
|
1091
|
+
top: cy - 90,
|
|
1092
|
+
width: 180,
|
|
1093
|
+
height: 180,
|
|
1094
|
+
fill: '#f8fafc',
|
|
1095
|
+
stroke: '#64748b',
|
|
1096
|
+
strokeWidth: 1.5,
|
|
1097
|
+
strokeDashArray: [6, 4],
|
|
1098
|
+
rx: 4,
|
|
1099
|
+
ry: 4,
|
|
1100
|
+
});
|
|
1101
|
+
fObj._tplQrPlaceholder = true;
|
|
1102
|
+
} else {
|
|
1103
|
+
fObj = new fabricMod.Textbox(`{{${key}}}`, {
|
|
1104
|
+
left: cx - 120,
|
|
1105
|
+
top: cy - 18,
|
|
1106
|
+
width: 240,
|
|
1107
|
+
fontSize: 28,
|
|
1108
|
+
fontFamily: 'Inter',
|
|
1109
|
+
fontWeight: '400',
|
|
1110
|
+
fill: '#1e293b',
|
|
1111
|
+
textAlign: 'center',
|
|
715
1112
|
});
|
|
716
1113
|
}
|
|
717
1114
|
fObj._tplKey = key;
|
|
@@ -745,8 +1142,8 @@ function buildAPI(canvas: any, fabricMod: any): CanvasAPI {
|
|
|
745
1142
|
} else if (type === 'image') {
|
|
746
1143
|
const isCourseLogo = key === 'courseLogo';
|
|
747
1144
|
const isCourseBanner = key === 'courseBanner';
|
|
748
|
-
const boxWidth = isCourseBanner ? 420 : isCourseLogo ?
|
|
749
|
-
const boxHeight = isCourseBanner ? 140 : isCourseLogo ?
|
|
1145
|
+
const boxWidth = isCourseBanner ? 420 : isCourseLogo ? 180 : 150;
|
|
1146
|
+
const boxHeight = isCourseBanner ? 140 : isCourseLogo ? 180 : 150;
|
|
750
1147
|
fObj = new fabricMod.Rect({
|
|
751
1148
|
left: cx - boxWidth / 2,
|
|
752
1149
|
top: cy - boxHeight / 2,
|
|
@@ -773,6 +1170,8 @@ function buildAPI(canvas: any, fabricMod: any): CanvasAPI {
|
|
|
773
1170
|
fObj._tplType = type;
|
|
774
1171
|
fObj._tplKey = key;
|
|
775
1172
|
fObj._tplShape = shape;
|
|
1173
|
+
applyFixedAspectResizeBehavior(fObj);
|
|
1174
|
+
attachPlaceholderOverlay(fObj);
|
|
776
1175
|
|
|
777
1176
|
canvas.add(fObj);
|
|
778
1177
|
canvas.setActiveObject(fObj);
|
|
@@ -812,6 +1211,8 @@ function buildAPI(canvas: any, fabricMod: any): CanvasAPI {
|
|
|
812
1211
|
if (!cloneData) return;
|
|
813
1212
|
const fObj = createFabricFromTemplate(cloneData, fabricMod);
|
|
814
1213
|
if (fObj) {
|
|
1214
|
+
applyFixedAspectResizeBehavior(fObj);
|
|
1215
|
+
attachPlaceholderOverlay(fObj);
|
|
815
1216
|
canvas.add(fObj);
|
|
816
1217
|
canvas.setActiveObject(fObj);
|
|
817
1218
|
canvas.requestRenderAll();
|
|
@@ -834,6 +1235,14 @@ function buildAPI(canvas: any, fabricMod: any): CanvasAPI {
|
|
|
834
1235
|
Object.entries(fabricProps).forEach(([k, v]) => {
|
|
835
1236
|
obj.set(k as any, v);
|
|
836
1237
|
});
|
|
1238
|
+
enforceUniformScaling(obj);
|
|
1239
|
+
if (
|
|
1240
|
+
'_tplImageFileId' in fabricProps ||
|
|
1241
|
+
'_tplImageSrc' in fabricProps ||
|
|
1242
|
+
'_tplImageResizeMode' in fabricProps
|
|
1243
|
+
) {
|
|
1244
|
+
void ensureObjectImageLoaded(canvas, obj, request);
|
|
1245
|
+
}
|
|
837
1246
|
obj.setCoords?.();
|
|
838
1247
|
canvas.requestRenderAll();
|
|
839
1248
|
|
|
@@ -879,11 +1288,18 @@ function buildAPI(canvas: any, fabricMod: any): CanvasAPI {
|
|
|
879
1288
|
|
|
880
1289
|
loadTemplate(template) {
|
|
881
1290
|
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
|
882
|
-
const { zoom: manualZoom
|
|
883
|
-
const z =
|
|
1291
|
+
const { zoom: manualZoom } = useTemplateStore.getState();
|
|
1292
|
+
const z = manualZoom;
|
|
884
1293
|
applyCanvasZoom(canvas, z);
|
|
885
1294
|
|
|
886
1295
|
templateJSONToFabric(canvas, template, fabricMod);
|
|
1296
|
+
attachOverlaysForCanvas(canvas);
|
|
1297
|
+
const imageObjects = canvas
|
|
1298
|
+
.getObjects()
|
|
1299
|
+
.filter((o: any) => o._tplType === 'image');
|
|
1300
|
+
imageObjects.forEach((obj: any) => {
|
|
1301
|
+
void ensureObjectImageLoaded(canvas, obj, request);
|
|
1302
|
+
});
|
|
887
1303
|
if (template.background.src) {
|
|
888
1304
|
applyBg(canvas, fabricMod, template.background.src);
|
|
889
1305
|
}
|