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.
- package/README.md +132 -97
- package/dist/capture/device-detection.d.ts +13 -0
- package/dist/capture/fonts.d.ts +4 -0
- package/dist/capture/index.d.ts +14 -0
- package/dist/capture/mathjax-fonts.d.ts +5 -0
- package/dist/capture/types.d.ts +25 -0
- package/dist/ckeditor.mjs +113 -0
- package/dist/ckeditor.mjs.map +1 -0
- package/dist/components/CkEditor.d.ts +14 -0
- package/dist/components/FeedbackWidget.d.ts +11 -0
- package/dist/context/FeedbackContext.d.ts +5 -0
- package/dist/hocmai-feedback-widget.mjs +7544 -0
- package/dist/hocmai-feedback-widget.mjs.map +1 -0
- package/dist/index.d.ts +3 -0
- package/package.json +32 -28
- package/src/App.tsx +143 -58
- package/src/capture/device-detection.ts +52 -0
- package/src/capture/fonts.ts +34 -0
- package/src/capture/index.ts +362 -0
- package/src/capture/mathjax-fonts.ts +143 -0
- package/src/capture/types.ts +34 -0
- package/src/ckeditor.d.ts +14 -0
- package/src/components/CkEditor.tsx +130 -0
- package/src/components/CropOverlay.tsx +29 -47
- package/src/components/FeedbackWidget.tsx +96 -90
- package/src/context/FeedbackContext.tsx +11 -4
- package/src/index.ts +9 -0
- package/src/main.tsx +7 -8
- package/src/utils/device.ts +14 -14
- package/dist/support-feedback-lib.es.js +0 -3867
- package/dist/support-feedback-lib.umd.js +0 -84
- package/dist/utils/screenshot/background-renderer.d.ts +0 -3
- package/dist/utils/screenshot/canvas-renderer.d.ts +0 -9
- package/dist/utils/screenshot/cleanup.d.ts +0 -3
- package/dist/utils/screenshot/device-detection.d.ts +0 -2
- package/dist/utils/screenshot/element-collector.d.ts +0 -10
- package/dist/utils/screenshot/image-renderer.d.ts +0 -21
- package/dist/utils/screenshot/performance.d.ts +0 -3
- package/dist/utils/screenshot/svg-renderer.d.ts +0 -24
- package/dist/utils/screenshot/text-renderer.d.ts +0 -23
- package/dist/utils/screenshot/types.d.ts +0 -35
- package/dist/utils/screenshot.d.ts +0 -9
- package/dist/vite.svg +0 -1
- package/src/App.css +0 -42
- package/src/assets/react.svg +0 -1
- package/src/index.css +0 -68
- package/src/utils/screenshot/background-renderer.ts +0 -54
- package/src/utils/screenshot/canvas-renderer.ts +0 -217
- package/src/utils/screenshot/cleanup.ts +0 -32
- package/src/utils/screenshot/device-detection.ts +0 -38
- package/src/utils/screenshot/element-collector.ts +0 -83
- package/src/utils/screenshot/image-renderer.ts +0 -148
- package/src/utils/screenshot/performance.ts +0 -57
- package/src/utils/screenshot/svg-renderer.ts +0 -126
- package/src/utils/screenshot/text-renderer.ts +0 -212
- package/src/utils/screenshot/types.ts +0 -40
- 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) {
|
|
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
|
|
79
|
+
// Only confirm meaningful selections (avoid accidental clicks).
|
|
88
80
|
if (width > 10 && height > 10) {
|
|
89
|
-
|
|
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,
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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)'
|
|
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 '../
|
|
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
|
-
//
|
|
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(
|
|
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();
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
{/*
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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'
|
|
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
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import './
|
|
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
|
+
);
|