hocmai-feedback-widget 0.2.0 → 0.2.2

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 (57) hide show
  1. package/README.md +132 -97
  2. package/dist/capture/device-detection.d.ts +13 -0
  3. package/dist/capture/fonts.d.ts +4 -0
  4. package/dist/capture/index.d.ts +14 -0
  5. package/dist/capture/mathjax-fonts.d.ts +5 -0
  6. package/dist/capture/types.d.ts +25 -0
  7. package/dist/ckeditor.mjs +113 -0
  8. package/dist/ckeditor.mjs.map +1 -0
  9. package/dist/components/CkEditor.d.ts +14 -0
  10. package/dist/components/FeedbackWidget.d.ts +11 -0
  11. package/dist/context/FeedbackContext.d.ts +5 -0
  12. package/dist/hocmai-feedback-widget.mjs +7544 -0
  13. package/dist/hocmai-feedback-widget.mjs.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/package.json +32 -28
  16. package/src/App.tsx +143 -58
  17. package/src/capture/device-detection.ts +52 -0
  18. package/src/capture/fonts.ts +34 -0
  19. package/src/capture/index.ts +362 -0
  20. package/src/capture/mathjax-fonts.ts +143 -0
  21. package/src/capture/types.ts +34 -0
  22. package/src/ckeditor.d.ts +14 -0
  23. package/src/components/CkEditor.tsx +130 -0
  24. package/src/components/CropOverlay.tsx +29 -47
  25. package/src/components/FeedbackWidget.tsx +96 -90
  26. package/src/context/FeedbackContext.tsx +11 -4
  27. package/src/index.ts +9 -0
  28. package/src/main.tsx +7 -8
  29. package/src/utils/device.ts +14 -14
  30. package/dist/support-feedback-lib.es.js +0 -3867
  31. package/dist/support-feedback-lib.umd.js +0 -84
  32. package/dist/utils/screenshot/background-renderer.d.ts +0 -3
  33. package/dist/utils/screenshot/canvas-renderer.d.ts +0 -9
  34. package/dist/utils/screenshot/cleanup.d.ts +0 -3
  35. package/dist/utils/screenshot/device-detection.d.ts +0 -2
  36. package/dist/utils/screenshot/element-collector.d.ts +0 -10
  37. package/dist/utils/screenshot/image-renderer.d.ts +0 -21
  38. package/dist/utils/screenshot/performance.d.ts +0 -3
  39. package/dist/utils/screenshot/svg-renderer.d.ts +0 -24
  40. package/dist/utils/screenshot/text-renderer.d.ts +0 -23
  41. package/dist/utils/screenshot/types.d.ts +0 -35
  42. package/dist/utils/screenshot.d.ts +0 -9
  43. package/dist/vite.svg +0 -1
  44. package/src/App.css +0 -42
  45. package/src/assets/react.svg +0 -1
  46. package/src/index.css +0 -68
  47. package/src/utils/screenshot/background-renderer.ts +0 -54
  48. package/src/utils/screenshot/canvas-renderer.ts +0 -217
  49. package/src/utils/screenshot/cleanup.ts +0 -32
  50. package/src/utils/screenshot/device-detection.ts +0 -38
  51. package/src/utils/screenshot/element-collector.ts +0 -83
  52. package/src/utils/screenshot/image-renderer.ts +0 -148
  53. package/src/utils/screenshot/performance.ts +0 -57
  54. package/src/utils/screenshot/svg-renderer.ts +0 -126
  55. package/src/utils/screenshot/text-renderer.ts +0 -212
  56. package/src/utils/screenshot/types.ts +0 -40
  57. package/src/utils/screenshot.ts +0 -68
@@ -1,4 +1,5 @@
1
1
  import React, { useState, useEffect } from 'react';
2
+ import { REPORT_UI_ATTR } from '../capture/types';
2
3
 
3
4
  export interface Rect {
4
5
  x: number;
@@ -18,7 +19,6 @@ const CropOverlay: React.FC<CropOverlayProps> = ({ onConfirm, onCancel }) => {
18
19
  const [currentPos, setCurrentPos] = useState({ x: 0, y: 0 });
19
20
  const [preventScroll, setPreventScroll] = useState(false);
20
21
 
21
- // Manage body overflow to prevent scrolling during touch selection
22
22
  useEffect(() => {
23
23
  if (preventScroll) {
24
24
  document.body.style.overflow = 'hidden';
@@ -30,9 +30,6 @@ const CropOverlay: React.FC<CropOverlayProps> = ({ onConfirm, onCancel }) => {
30
30
  };
31
31
  }, [preventScroll]);
32
32
 
33
- // Ref for the selection box to calculate rect without render lag if needed,
34
- // but state is fine for React.
35
-
36
33
  const handleMouseDown = (e: React.MouseEvent) => {
37
34
  setIsDragging(true);
38
35
  setStartPos({ x: e.clientX, y: e.clientY });
@@ -49,14 +46,12 @@ const CropOverlay: React.FC<CropOverlayProps> = ({ onConfirm, onCancel }) => {
49
46
  finalizeSelection();
50
47
  };
51
48
 
52
- // Touch events
53
49
  const handleTouchStart = (e: React.TouchEvent) => {
54
- if (e.touches.length === 1) { // Single touch only
50
+ if (e.touches.length === 1) {
55
51
  setIsDragging(true);
56
52
  const touch = e.touches[0];
57
53
  setStartPos({ x: touch.clientX, y: touch.clientY });
58
54
  setCurrentPos({ x: touch.clientX, y: touch.clientY });
59
- // Prevent scrolling while selecting
60
55
  setPreventScroll(true);
61
56
  }
62
57
  };
@@ -70,39 +65,24 @@ const CropOverlay: React.FC<CropOverlayProps> = ({ onConfirm, onCancel }) => {
70
65
 
71
66
  const handleTouchEnd = () => {
72
67
  finalizeSelection();
73
- // Restore scrolling
74
68
  setPreventScroll(false);
75
69
  };
76
70
 
77
71
  const finalizeSelection = () => {
78
72
  if (isDragging) {
79
73
  setIsDragging(false);
80
-
81
- // Calculate final rect
82
74
  const x = Math.min(startPos.x, currentPos.x);
83
75
  const y = Math.min(startPos.y, currentPos.y);
84
76
  const width = Math.abs(currentPos.x - startPos.x);
85
77
  const height = Math.abs(currentPos.y - startPos.y);
86
78
 
87
- // Only confirm if width/height is significant (e.g. > 10px) to avoid accidental clicks
79
+ // Only confirm meaningful selections (avoid accidental clicks).
88
80
  if (width > 10 && height > 10) {
89
- // We capture viewport coordinates.
90
- // Note: screenshot tool usually captures full page or document body.
91
- // If html-to-image captures document.body, we might need to adjust for scroll.
92
- // HOWEVER, our plan is to capture the Viewport mostly.
93
- // Or if we capture Body, we add scrollX/scrollY.
94
-
95
- onConfirm({
96
- x,
97
- y,
98
- width,
99
- height
100
- });
81
+ onConfirm({ x, y, width, height });
101
82
  }
102
83
  }
103
84
  };
104
85
 
105
- // Close on Escape
106
86
  useEffect(() => {
107
87
  const handleKeyDown = (e: KeyboardEvent) => {
108
88
  if (e.key === 'Escape') onCancel();
@@ -113,12 +93,13 @@ const CropOverlay: React.FC<CropOverlayProps> = ({ onConfirm, onCancel }) => {
113
93
 
114
94
  return (
115
95
  <div
96
+ {...{ [REPORT_UI_ATTR]: 'true' }}
116
97
  style={{
117
98
  position: 'fixed',
118
99
  inset: 0,
119
- zIndex: 99999, // Extremely high z-index
100
+ zIndex: 99999,
120
101
  cursor: 'crosshair',
121
- userSelect: 'none'
102
+ userSelect: 'none',
122
103
  }}
123
104
  onMouseDown={handleMouseDown}
124
105
  onMouseMove={handleMouseMove}
@@ -127,32 +108,33 @@ const CropOverlay: React.FC<CropOverlayProps> = ({ onConfirm, onCancel }) => {
127
108
  onTouchMove={handleTouchMove}
128
109
  onTouchEnd={handleTouchEnd}
129
110
  >
130
- {/* Background Dim - separate div to avoid affecting selection box rendering if simpler */}
131
- <div style={{
132
- position: 'absolute',
133
- inset: 0,
134
- backgroundColor: 'rgba(0, 0, 0, 0.4)'
135
- }} />
111
+ <div
112
+ style={{
113
+ position: 'absolute',
114
+ inset: 0,
115
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
116
+ }}
117
+ />
136
118
 
137
- {/* Helper Text */}
138
119
  {!isDragging && (
139
- <div style={{
140
- position: 'absolute',
141
- top: '20px',
142
- left: '50%',
143
- transform: 'translateX(-50%)',
144
- background: 'rgba(0,0,0,0.8)',
145
- color: 'white',
146
- padding: '8px 16px',
147
- borderRadius: '20px',
148
- pointerEvents: 'none',
149
- fontSize: '14px'
150
- }}>
120
+ <div
121
+ style={{
122
+ position: 'absolute',
123
+ top: '20px',
124
+ left: '50%',
125
+ transform: 'translateX(-50%)',
126
+ background: 'rgba(0,0,0,0.8)',
127
+ color: 'white',
128
+ padding: '8px 16px',
129
+ borderRadius: '20px',
130
+ pointerEvents: 'none',
131
+ fontSize: '14px',
132
+ }}
133
+ >
151
134
  Kéo chuột hoặc chạm để chọn vùng chụp ảnh (ESC để hủy)
152
135
  </div>
153
136
  )}
154
137
 
155
- {/* Selection Box */}
156
138
  {isDragging && (
157
139
  <div
158
140
  style={{
@@ -163,7 +145,7 @@ const CropOverlay: React.FC<CropOverlayProps> = ({ onConfirm, onCancel }) => {
163
145
  height: Math.abs(currentPos.y - startPos.y),
164
146
  border: '2px solid #fff',
165
147
  backgroundColor: 'rgba(255, 255, 255, 0.1)',
166
- boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)' // Clever trick to dim outside
148
+ boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
167
149
  }}
168
150
  />
169
151
  )}
@@ -1,8 +1,11 @@
1
1
  import React, { useState } from 'react';
2
2
  import { getDeviceInfo } from '../utils/device';
3
- import { captureScreenshot, type Rect } from '../utils/screenshot';
3
+ import { captureScreenshot, prewarmCapture, type Rect } from '../capture';
4
4
  import { MessageSquarePlus, Crop, Monitor, X, Hash, AlignLeft, Loader2, Image as ImageIcon } from 'lucide-react';
5
5
  import CropOverlay from './CropOverlay';
6
+ // Type-only import — erased at build time, so the core bundle has zero CKEditor
7
+ // coupling and apps build without the CKEditor deps.
8
+ import type { CkEditorProps } from './CkEditor';
6
9
 
7
10
  export interface FeedbackData {
8
11
  idQuestion: string | number;
@@ -25,8 +28,20 @@ export interface FeedbackProps {
25
28
  onClose?: () => void;
26
29
  isEnableCaptureFull?: boolean;
27
30
  isEnableCaptureElement?: boolean;
31
+ /** Endpoint that accepts { base64 } and returns { file_url }. */
32
+ uploadUrl?: string;
33
+ /** Use the rich-text editor for the description instead of a plain textarea. */
34
+ isCkEditor?: boolean;
35
+ /**
36
+ * Rich-text editor component for the description (used when isCkEditor is true).
37
+ * Pass the ready-made editor from `hocmai-feedback-widget/ckeditor`, or your own.
38
+ * Kept as a prop so the core bundle has no CKEditor dependency.
39
+ */
40
+ descriptionEditor?: React.ComponentType<CkEditorProps>;
28
41
  }
29
42
 
43
+ const DEFAULT_UPLOAD_URL = 'https://lcms.icanwork.vn/api/upload-to-s3/upload-image-error-to-s3';
44
+
30
45
  const FeedbackWidget: React.FC<FeedbackProps> = ({
31
46
  questionId,
32
47
  contextData,
@@ -37,7 +52,10 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
37
52
  onOpen,
38
53
  onClose,
39
54
  isEnableCaptureFull = true,
40
- isEnableCaptureElement = true
55
+ isEnableCaptureElement = true,
56
+ uploadUrl = DEFAULT_UPLOAD_URL,
57
+ isCkEditor = false,
58
+ descriptionEditor: DescriptionEditor,
41
59
  }) => {
42
60
  const [internalIsOpen, setInternalIsOpen] = useState(false);
43
61
  const [description, setDescription] = useState('');
@@ -70,6 +88,14 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
70
88
  }
71
89
  }, [isOpen, questionId, contextData]);
72
90
 
91
+ // Warm the snapshot font/image cache while the user fills the form so the
92
+ // first capture isn't delayed by inlining MathJax web fonts.
93
+ React.useEffect(() => {
94
+ if (isOpen) {
95
+ prewarmCapture().catch(() => { });
96
+ }
97
+ }, [isOpen]);
98
+
73
99
  const handleSelectImage = () => {
74
100
  if (screenshots.length >= 3) {
75
101
  alert('Bạn chỉ có thể chụp tối đa 3 ảnh.');
@@ -101,7 +127,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
101
127
  };
102
128
  reader.readAsDataURL(file);
103
129
 
104
- // Reset input so same file can be selected again if needed
105
130
  event.target.value = '';
106
131
  };
107
132
 
@@ -118,7 +143,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
118
143
  };
119
144
 
120
145
  const startCapture = async (mode: 'full' | 'crop') => {
121
- console.log('click capture', performance.now());
122
146
  if (screenshots.length >= 3) {
123
147
  alert('Bạn chỉ có thể chụp tối đa 3 ảnh.');
124
148
  return;
@@ -126,25 +150,19 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
126
150
  setIsTempHidden(true); // Hide modal temporarily
127
151
 
128
152
  if (mode === 'crop') {
129
- // Add small delay to ensure modal is gone
130
153
  setTimeout(() => setIsSelecting(true), 150);
131
154
  return;
132
155
  }
133
156
 
134
- // Full screen capture
135
157
  setTimeout(async () => {
136
158
  await performCapture();
137
159
  }, 150);
138
160
  };
139
161
 
140
162
  const performCapture = async (cropRect?: Rect) => {
141
- console.log('performCapture start', performance.now());
142
163
  setIsCapturing(true);
143
164
  try {
144
- // If cropRect is provided, it captures that specific area.
145
- // If undefined, it captures the current viewport (default behavior of updated captureScreenshot).
146
165
  const shot = await captureScreenshot(cropRect);
147
- console.log('performCapture end', performance.now());
148
166
  if (shot) {
149
167
  setScreenshots(prev => [...prev, shot as string]);
150
168
  } else {
@@ -152,11 +170,12 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
152
170
  }
153
171
  } catch (err) {
154
172
  console.error(err);
173
+ alert('Không thể chụp ảnh màn hình. Vui lòng thử lại.');
155
174
  } finally {
156
175
  setIsCapturing(false);
157
176
  setIsTempHidden(false); // Re-show modal
158
177
  }
159
- }
178
+ };
160
179
 
161
180
  const handleCropConfirm = (rect: Rect) => {
162
181
  setIsSelecting(false);
@@ -176,7 +195,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
176
195
  if (onClose) onClose();
177
196
  setInternalIsOpen(false);
178
197
 
179
- // Clean up state
180
198
  setScreenshots([]);
181
199
  setIsSelecting(false);
182
200
  setZoomedImage(null);
@@ -210,17 +228,15 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
210
228
  setIsSubmitting(true);
211
229
  try {
212
230
  const deviceInfo = getDeviceInfo();
213
- // send image (data base64) to server and get URL image return
231
+ // Upload each screenshot (base64) and collect the returned URLs.
214
232
  const listImage = await Promise.all(
215
233
  screenshots.map(async (shot) => {
216
- const response = await fetch('https://lcms.icanwork.vn/api/upload-to-s3/upload-image-error-to-s3', {
234
+ const response = await fetch(uploadUrl, {
217
235
  method: 'POST',
218
236
  headers: {
219
237
  'Content-Type': 'application/json'
220
238
  },
221
- body: JSON.stringify({
222
- base64: shot
223
- })
239
+ body: JSON.stringify({ base64: shot })
224
240
  });
225
241
  const uploadData = await response.json();
226
242
  return uploadData.file_url;
@@ -237,7 +253,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
237
253
  };
238
254
 
239
255
  await onSubmit(feedbackData);
240
- handleCloseTrigger(); // Close and reset after success
256
+ handleCloseTrigger();
241
257
  } catch (error) {
242
258
  console.error('Submit feedback failed', error);
243
259
  } finally {
@@ -245,9 +261,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
245
261
  }
246
262
  };
247
263
 
248
- // ... inside component
249
-
250
- // Reduced Styles
251
264
  const styles = `
252
265
  @keyframes fb-fade-in {
253
266
  from { opacity: 0; transform: translateY(10px); }
@@ -268,7 +281,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
268
281
  border-radius: 12px;
269
282
  width: 280px;
270
283
  max-width: 90vw;
271
- max-height: 80vh;
284
+ max-height: 80vh;
272
285
  overflow-y: auto;
273
286
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
274
287
  border: 1px solid #f3f4f6;
@@ -289,7 +302,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
289
302
  bottom: 20px !important;
290
303
  right: 20px !important;
291
304
  }
292
- /* Prevent iOS zoom on inputs */
293
305
  input, select, textarea {
294
306
  font-size: 16px !important;
295
307
  }
@@ -298,23 +310,17 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
298
310
  .fb-btn-crop {
299
311
  display: none !important;
300
312
  }
301
- /* Enforce 16px font on all mobile/tablet inputs to prevent zoom */
302
313
  .fb-widget-modal input,
303
314
  .fb-widget-modal select,
304
315
  .fb-widget-modal textarea {
305
316
  font-size: 16px !important;
306
317
  }
307
318
  }
308
- @media (min-width: 768px) {
309
- .fb-btn-upload {
310
-
311
- }
312
- }
313
319
  `;
314
320
 
315
321
  return (
316
322
  <>
317
- <style id="feedback-widget-styles">{styles}</style>
323
+ <style id="feedback-widget-styles" data-hsa-report-ui="true">{styles}</style>
318
324
 
319
325
  {isSelecting && (
320
326
  <CropOverlay onConfirm={handleCropConfirm} onCancel={handleCropCancel} />
@@ -323,6 +329,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
323
329
  {/* Zoom Modal */}
324
330
  {zoomedImage && (
325
331
  <div
332
+ data-hsa-report-ui="true"
326
333
  style={{
327
334
  position: 'fixed',
328
335
  inset: 0,
@@ -357,13 +364,17 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
357
364
  )}
358
365
 
359
366
  {/* Trigger Button */}
360
- <div onClick={() => {
361
- if (externalIsOpen === undefined) {
362
- handleOpen();
363
- } else if (onOpen) {
364
- onOpen();
365
- }
366
- }} style={{ cursor: 'pointer', display: 'inline-block' }}>
367
+ <div
368
+ data-hsa-report-ui="true"
369
+ onClick={() => {
370
+ if (externalIsOpen === undefined) {
371
+ handleOpen();
372
+ } else if (onOpen) {
373
+ onOpen();
374
+ }
375
+ }}
376
+ style={{ cursor: 'pointer', display: 'inline-block' }}
377
+ >
367
378
  {triggerComponent || (
368
379
  <button
369
380
  className="fb-widget-trigger"
@@ -394,16 +405,16 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
394
405
  )}
395
406
  </div>
396
407
 
397
- {/* ... capturing overlay ... */}
408
+ {/* Capturing indicator */}
398
409
  {isCapturing && (
399
- <div style={{ position: 'fixed', top: 20, right: 20, background: 'rgba(0,0,0,0.8)', color: 'white', padding: '8px 12px', borderRadius: '8px', zIndex: 10000, fontWeight: 500, backdropFilter: 'blur(4px)', fontSize: '0.8rem' }}>
410
+ <div data-hsa-report-ui="true" style={{ position: 'fixed', top: 20, right: 20, background: 'rgba(0,0,0,0.8)', color: 'white', padding: '8px 12px', borderRadius: '8px', zIndex: 10000, fontWeight: 500, backdropFilter: 'blur(4px)', fontSize: '0.8rem' }}>
400
411
  Đang chụp...
401
412
  </div>
402
413
  )}
403
414
 
404
415
  {/* Main Form Popover */}
405
416
  {showWidget && !isSelecting && !zoomedImage && (
406
- <div className="fb-widget-modal">
417
+ <div className="fb-widget-modal" data-hsa-report-ui="true">
407
418
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
408
419
  <h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 700, color: '#111827' }}>
409
420
  Báo lỗi
@@ -429,7 +440,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
429
440
  </button>
430
441
  </div>
431
442
 
432
- {/* Form Fields Container */}
433
443
  <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
434
444
 
435
445
  {/* Question ID Input */}
@@ -437,12 +447,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
437
447
  <label style={{ display: 'block', marginBottom: '2px', fontSize: '0.8rem', fontWeight: 600, color: '#374151' }}>
438
448
  ID Câu hỏi {contextData?.questionIdChild ? 'bài đọc' : ''} <span style={{ color: '#ef4444' }}>*</span>
439
449
  </label>
440
- <div style={{
441
- position: 'relative',
442
- display: 'flex',
443
- alignItems: 'center',
444
- transition: 'all 0.2s'
445
- }}>
450
+ <div style={{ position: 'relative', display: 'flex', alignItems: 'center', transition: 'all 0.2s' }}>
446
451
  <Hash size={12} style={{
447
452
  position: 'absolute',
448
453
  left: '10px',
@@ -487,12 +492,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
487
492
  <label style={{ display: 'block', marginBottom: '2px', fontSize: '0.8rem', fontWeight: 600, color: '#374151' }}>
488
493
  ID Câu hỏi
489
494
  </label>
490
- <div style={{
491
- position: 'relative',
492
- display: 'flex',
493
- alignItems: 'center',
494
- transition: 'all 0.2s'
495
- }}>
495
+ <div style={{ position: 'relative', display: 'flex', alignItems: 'center', transition: 'all 0.2s' }}>
496
496
  <Hash size={12} style={{
497
497
  position: 'absolute',
498
498
  left: '10px',
@@ -575,45 +575,53 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
575
575
  </span>
576
576
  </div>
577
577
  <div style={{ position: 'relative' }}>
578
- <div style={{
579
- position: 'absolute',
580
- left: '10px',
581
- top: '8px',
582
- pointerEvents: 'none'
583
- }}>
578
+ <div style={{ position: 'absolute', left: '10px', top: '8px', pointerEvents: 'none' }}>
584
579
  <AlignLeft size={12} style={{
585
580
  color: focusedField === 'desc' ? themeColor : (errors.description ? '#ef4444' : '#9ca3af'),
586
581
  transition: 'color 0.2s'
587
582
  }} />
588
583
  </div>
589
- <textarea
590
- value={description}
591
- onFocus={() => setFocusedField('desc')}
592
- onBlur={() => setFocusedField(null)}
593
- onChange={(e) => {
594
- setDescription(e.target.value);
595
- if (errors.description) setErrors(prev => ({ ...prev, description: undefined }));
596
- }}
597
- maxLength={1000}
598
- placeholder="Mô tả chi tiết..."
599
- style={{
600
- width: '100%',
601
- minHeight: '80px',
602
- padding: '6px 10px 6px 28px',
603
- borderRadius: '6px',
604
- border: `1px solid ${errors.description ? '#ef4444' : (focusedField === 'desc' ? themeColor : '#e5e7eb')}`,
605
- boxShadow: focusedField === 'desc' ? `0 0 0 2px ${themeColor}20` : 'none',
606
- outline: 'none',
607
- fontSize: '0.85rem',
608
- color: '#1f2937',
609
- resize: 'vertical',
610
- boxSizing: 'border-box',
611
- transition: 'all 0.2s',
612
- backgroundColor: '#f9fafb',
613
- lineHeight: '1.4',
614
- fontFamily: 'inherit'
615
- }}
616
- />
584
+ {isCkEditor && DescriptionEditor ? (
585
+ <DescriptionEditor
586
+ placeholder="Mô tả chi tiết..."
587
+ contentQuestion={description}
588
+ error={!!errors.description}
589
+ uploadUrl={uploadUrl}
590
+ handleContent={(html: string) => {
591
+ setDescription(html);
592
+ if (errors.description) setErrors(prev => ({ ...prev, description: undefined }));
593
+ }}
594
+ />
595
+ ) : (
596
+ <textarea
597
+ value={description}
598
+ onFocus={() => setFocusedField('desc')}
599
+ onBlur={() => setFocusedField(null)}
600
+ onChange={(e) => {
601
+ setDescription(e.target.value);
602
+ if (errors.description) setErrors(prev => ({ ...prev, description: undefined }));
603
+ }}
604
+ maxLength={1000}
605
+ placeholder="Mô tả chi tiết..."
606
+ style={{
607
+ width: '100%',
608
+ minHeight: '80px',
609
+ padding: '6px 10px 6px 28px',
610
+ borderRadius: '6px',
611
+ border: `1px solid ${errors.description ? '#ef4444' : (focusedField === 'desc' ? themeColor : '#e5e7eb')}`,
612
+ boxShadow: focusedField === 'desc' ? `0 0 0 2px ${themeColor}20` : 'none',
613
+ outline: 'none',
614
+ fontSize: '0.85rem',
615
+ color: '#1f2937',
616
+ resize: 'vertical',
617
+ boxSizing: 'border-box',
618
+ transition: 'all 0.2s',
619
+ backgroundColor: '#f9fafb',
620
+ lineHeight: '1.4',
621
+ fontFamily: 'inherit'
622
+ }}
623
+ />
624
+ )}
617
625
  </div>
618
626
  {errors.description && (
619
627
  <span style={{ display: 'block', marginTop: '2px', fontSize: '0.7rem', color: '#ef4444' }}>
@@ -630,7 +638,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
630
638
  </label>
631
639
  </div>
632
640
 
633
- {/* Thumbnails Grid */}
634
641
  {screenshots.length > 0 && (
635
642
  <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '6px', marginBottom: '8px' }}>
636
643
  {screenshots.map((shot, index) => (
@@ -710,7 +717,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
710
717
  alignItems: 'center',
711
718
  gap: '4px',
712
719
  transition: 'all 0.2s',
713
- minWidth: '60px' // Prevent squishing
720
+ minWidth: '60px'
714
721
  }}
715
722
  onMouseOver={(e) => {
716
723
  e.currentTarget.style.borderColor = themeColor;
@@ -844,7 +851,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
844
851
  ) : 'Gửi báo lỗi'}
845
852
  </button>
846
853
  </div>
847
- {/* <p style={{ textAlign: 'center', marginTop: 'auto', color: '#9CA3AF' }}>v0.2.0-rc1</p> */}
848
854
  </div>
849
855
  )}
850
856
  </>
@@ -1,5 +1,6 @@
1
1
  import React, { createContext, useContext, useState, type ReactNode } from 'react';
2
2
  import FeedbackWidget, { type FeedbackData } from '../components/FeedbackWidget';
3
+ import type { CkEditorProps } from '../components/CkEditor';
3
4
 
4
5
  interface FeedbackContextType {
5
6
  openFeedback: (questionId: string | number, contextData?: any) => void;
@@ -22,12 +23,18 @@ interface FeedbackProviderProps {
22
23
  children: ReactNode;
23
24
  onSubmit: (data: FeedbackData) => Promise<void>;
24
25
  themeColor?: string;
26
+ /** Use the rich-text editor for the description instead of a plain textarea. */
27
+ isCkEditor?: boolean;
28
+ /** Editor component for the description (from `hocmai-feedback-widget/ckeditor` or your own). */
29
+ descriptionEditor?: React.ComponentType<CkEditorProps>;
25
30
  }
26
31
 
27
32
  export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
28
33
  children,
29
34
  onSubmit,
30
- themeColor
35
+ themeColor,
36
+ isCkEditor,
37
+ descriptionEditor,
31
38
  }) => {
32
39
  const [isOpen, setIsOpen] = useState(false);
33
40
  const [activeQuestionId, setActiveQuestionId] = useState<string | number | null>(null);
@@ -41,23 +48,23 @@ export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
41
48
 
42
49
  const closeFeedback = () => {
43
50
  setIsOpen(false);
44
- // Optional: Reset active ID after animation, but keeping it ensures state doesn't flicker during close
45
51
  };
46
52
 
47
53
  return (
48
54
  <FeedbackContext.Provider value={{ openFeedback, closeFeedback, isOpen, activeQuestionId }}>
49
55
  {children}
50
56
 
51
- {/* The Single Global Widget */}
57
+ {/* The single global widget */}
52
58
  {activeQuestionId && (
53
59
  <FeedbackWidget
54
60
  questionId={activeQuestionId}
55
61
  contextData={activeContextData}
56
62
  onSubmit={onSubmit}
57
63
  themeColor={themeColor}
64
+ isCkEditor={isCkEditor}
65
+ descriptionEditor={descriptionEditor}
58
66
  isOpen={isOpen}
59
67
  onClose={closeFeedback}
60
- // We don't need triggerComponent here because triggers are distributed
61
68
  triggerComponent={<></>}
62
69
  />
63
70
  )}
package/src/index.ts CHANGED
@@ -1,3 +1,12 @@
1
1
  export { default as FeedbackWidget } from './components/FeedbackWidget';
2
2
  export type { FeedbackData, FeedbackProps } from './components/FeedbackWidget';
3
+ export type { CkEditorProps } from './components/CkEditor';
3
4
  export { FeedbackProvider, useFeedback } from './context/FeedbackContext';
5
+
6
+ // Screenshot utilities (MathJax-CHTML aware, snapdom-based)
7
+ export {
8
+ captureScreenshot,
9
+ captureScreenshotWithOptions,
10
+ prewarmCapture,
11
+ } from './capture';
12
+ export type { Rect, CaptureProgress, CaptureOptions } from './capture/types';
package/src/main.tsx CHANGED
@@ -1,10 +1,9 @@
1
- import { StrictMode } from 'react'
2
- import { createRoot } from 'react-dom/client'
3
- import './index.css'
4
- import App from './App.tsx'
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
5
4
 
6
- createRoot(document.getElementById('root')!).render(
7
- <StrictMode>
5
+ ReactDOM.createRoot(document.getElementById('root')!).render(
6
+ <React.StrictMode>
8
7
  <App />
9
- </StrictMode>,
10
- )
8
+ </React.StrictMode>
9
+ );