@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.
Files changed (75) hide show
  1. package/dist/course/course-operations-integration.service.d.ts +31 -0
  2. package/dist/course/course-operations-integration.service.d.ts.map +1 -1
  3. package/dist/course/course-operations-integration.service.js +286 -22
  4. package/dist/course/course-operations-integration.service.js.map +1 -1
  5. package/dist/course/course-operations.controller.d.ts +10 -0
  6. package/dist/course/course-operations.controller.d.ts.map +1 -0
  7. package/dist/course/course-operations.controller.js +67 -0
  8. package/dist/course/course-operations.controller.js.map +1 -0
  9. package/dist/course/course-structure.controller.d.ts +3 -1
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.service.d.ts +3 -1
  12. package/dist/course/course-structure.service.d.ts.map +1 -1
  13. package/dist/course/course-structure.service.js +13 -6
  14. package/dist/course/course-structure.service.js.map +1 -1
  15. package/dist/course/course.module.d.ts.map +1 -1
  16. package/dist/course/course.module.js +15 -2
  17. package/dist/course/course.module.js.map +1 -1
  18. package/dist/course/dto/update-course-operations-config.dto.d.ts +6 -0
  19. package/dist/course/dto/update-course-operations-config.dto.d.ts.map +1 -0
  20. package/dist/course/dto/update-course-operations-config.dto.js +33 -0
  21. package/dist/course/dto/update-course-operations-config.dto.js.map +1 -0
  22. package/dist/course/lms-bulk-upload.controller.d.ts +37 -0
  23. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -0
  24. package/dist/course/lms-bulk-upload.controller.js +60 -0
  25. package/dist/course/lms-bulk-upload.controller.js.map +1 -0
  26. package/dist/course/lms-bulk-upload.service.d.ts +42 -0
  27. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -0
  28. package/dist/course/lms-bulk-upload.service.js +169 -0
  29. package/dist/course/lms-bulk-upload.service.js.map +1 -0
  30. package/dist/course/lms-operations-task.subscriber.d.ts +13 -0
  31. package/dist/course/lms-operations-task.subscriber.d.ts.map +1 -0
  32. package/dist/course/lms-operations-task.subscriber.js +57 -0
  33. package/dist/course/lms-operations-task.subscriber.js.map +1 -0
  34. package/dist/course/lms-setting.controller.d.ts +3 -0
  35. package/dist/course/lms-setting.controller.d.ts.map +1 -1
  36. package/dist/course/lms-setting.controller.js +9 -1
  37. package/dist/course/lms-setting.controller.js.map +1 -1
  38. package/dist/enterprise/enterprise.service.js +1 -1
  39. package/dist/enterprise/enterprise.service.js.map +1 -1
  40. package/dist/instructor/instructor.service.d.ts.map +1 -1
  41. package/dist/instructor/instructor.service.js +12 -3
  42. package/dist/instructor/instructor.service.js.map +1 -1
  43. package/dist/platforma/platforma.controller.d.ts +9 -9
  44. package/hedhog/data/role.yaml +8 -0
  45. package/hedhog/data/route.yaml +62 -0
  46. package/hedhog/data/setting_group.yaml +33 -0
  47. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +26 -4
  48. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +6 -0
  49. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +447 -31
  50. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +59 -47
  51. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +201 -35
  52. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +36 -5
  53. package/hedhog/frontend/app/courses/[id]/structure/_components/course-operations-tab.tsx.ejs +382 -0
  54. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +31 -1
  55. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +91 -20
  56. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -0
  57. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +11 -3
  58. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -2
  59. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +2 -2
  60. package/hedhog/frontend/app/courses/page.tsx.ejs +21 -88
  61. package/hedhog/frontend/app/enterprise/page.tsx.ejs +18 -6
  62. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +5 -4
  63. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +16 -10
  64. package/package.json +7 -7
  65. package/src/course/course-operations-integration.service.ts +460 -22
  66. package/src/course/course-operations.controller.ts +45 -0
  67. package/src/course/course-structure.service.ts +5 -1
  68. package/src/course/course.module.ts +15 -2
  69. package/src/course/dto/update-course-operations-config.dto.ts +16 -0
  70. package/src/course/lms-bulk-upload.controller.ts +27 -0
  71. package/src/course/lms-bulk-upload.service.ts +204 -0
  72. package/src/course/lms-operations-task.subscriber.ts +44 -0
  73. package/src/course/lms-setting.controller.ts +12 -1
  74. package/src/enterprise/enterprise.service.ts +1 -1
  75. 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, effectiveZoom } = useTemplateStore.getState();
98
- const z = Math.min(manualZoom, effectiveZoom);
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', onModified);
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 = Math.min(newManualZoom, autoFitZoomRef.current);
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 = Math.min(zoom, autoFitZoom);
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 flex flex-1 items-center justify-center overflow-auto bg-muted/60 p-6 transition-colors ${
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="shadow-lg" style={{ lineHeight: 0 }}>
624
- <canvas ref={canvasElRef} />
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
- if (obj._tplType === 'field' || obj._tplType === 'staticText') {
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.set({
711
- text: '[QR Code]',
712
- stroke: '#94a3b8',
713
- strokeWidth: 1,
714
- backgroundColor: '#f8fafc',
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 ? 220 : 150;
749
- const boxHeight = isCourseBanner ? 140 : isCourseLogo ? 120 : 150;
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, effectiveZoom } = useTemplateStore.getState();
883
- const z = Math.min(manualZoom, effectiveZoom);
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
  }