hocmai-feedback-widget 0.2.1 → 0.2.3
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 +78 -113
- 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 +13 -1
- package/dist/context/FeedbackContext.d.ts +14 -2
- package/dist/hocmai-feedback-widget.mjs +7550 -0
- package/dist/hocmai-feedback-widget.mjs.map +1 -0
- package/dist/index.d.ts +4 -2
- package/package.json +39 -29
- package/src/App.tsx +147 -58
- package/src/capture/device-detection.ts +52 -0
- package/src/capture/fonts.ts +34 -0
- package/src/capture/index.ts +361 -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 +105 -93
- package/src/context/FeedbackContext.tsx +49 -9
- package/src/index.ts +9 -3
- package/src/main.tsx +7 -9
- package/src/utils/device.ts +14 -14
- package/dist/support-feedback-lib.es.js +0 -3946
- package/dist/support-feedback-lib.umd.js +0 -84
- package/dist/test-mobile-screenshot.html +0 -211
- package/dist/test-simple-capture.html +0 -107
- 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 -16
- 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 -343
- package/src/utils/screenshot/cleanup.ts +0 -32
- package/src/utils/screenshot/device-detection.ts +0 -90
- package/src/utils/screenshot/element-collector.ts +0 -134
- 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 -149
- package/src/utils/screenshot/text-renderer.ts +0 -212
- package/src/utils/screenshot/types.ts +0 -40
- package/src/utils/screenshot.ts +0 -83
|
@@ -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,12 @@
|
|
|
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. The editor itself is shipped
|
|
8
|
+
// at the subpath `hocmai-feedback-widget/ckeditor` and injected via descriptionEditor.
|
|
9
|
+
import type { CkEditorProps } from './CkEditor';
|
|
6
10
|
|
|
7
11
|
export interface FeedbackData {
|
|
8
12
|
idQuestion: string | number;
|
|
@@ -17,7 +21,8 @@ export interface FeedbackData {
|
|
|
17
21
|
export interface FeedbackProps {
|
|
18
22
|
questionId: string | number;
|
|
19
23
|
contextData?: Record<string, any>;
|
|
20
|
-
|
|
24
|
+
/** Submit handler. Optional — may be provided per call via openFeedback or at the Provider. */
|
|
25
|
+
onSubmit?: (data: FeedbackData) => Promise<void> | void;
|
|
21
26
|
themeColor?: string;
|
|
22
27
|
triggerComponent?: React.ReactNode;
|
|
23
28
|
isOpen?: boolean;
|
|
@@ -25,8 +30,20 @@ export interface FeedbackProps {
|
|
|
25
30
|
onClose?: () => void;
|
|
26
31
|
isEnableCaptureFull?: boolean;
|
|
27
32
|
isEnableCaptureElement?: boolean;
|
|
33
|
+
/** Endpoint that accepts { base64 } and returns { file_url }. */
|
|
34
|
+
uploadUrl?: string;
|
|
35
|
+
/** Use the rich-text editor for the description instead of a plain textarea. */
|
|
36
|
+
isCkEditor?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Rich-text editor component for the description (used when isCkEditor is true).
|
|
39
|
+
* Pass the ready-made editor from `hocmai-feedback-widget/ckeditor`, or your own.
|
|
40
|
+
* Kept as a prop so the core bundle has no CKEditor dependency.
|
|
41
|
+
*/
|
|
42
|
+
descriptionEditor?: React.ComponentType<CkEditorProps>;
|
|
28
43
|
}
|
|
29
44
|
|
|
45
|
+
const DEFAULT_UPLOAD_URL = 'https://lcms.icanwork.vn/api/upload-to-s3/upload-image-error-to-s3';
|
|
46
|
+
|
|
30
47
|
const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
31
48
|
questionId,
|
|
32
49
|
contextData,
|
|
@@ -37,7 +54,10 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
37
54
|
onOpen,
|
|
38
55
|
onClose,
|
|
39
56
|
isEnableCaptureFull = true,
|
|
40
|
-
isEnableCaptureElement = true
|
|
57
|
+
isEnableCaptureElement = true,
|
|
58
|
+
uploadUrl = DEFAULT_UPLOAD_URL,
|
|
59
|
+
isCkEditor = false,
|
|
60
|
+
descriptionEditor: DescriptionEditor,
|
|
41
61
|
}) => {
|
|
42
62
|
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
|
43
63
|
const [description, setDescription] = useState('');
|
|
@@ -70,6 +90,14 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
70
90
|
}
|
|
71
91
|
}, [isOpen, questionId, contextData]);
|
|
72
92
|
|
|
93
|
+
// Warm the snapshot font/image cache while the user fills the form so the
|
|
94
|
+
// first capture isn't delayed by inlining MathJax web fonts.
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
if (isOpen) {
|
|
97
|
+
prewarmCapture().catch(() => { });
|
|
98
|
+
}
|
|
99
|
+
}, [isOpen]);
|
|
100
|
+
|
|
73
101
|
const handleSelectImage = () => {
|
|
74
102
|
if (screenshots.length >= 3) {
|
|
75
103
|
alert('Bạn chỉ có thể chụp tối đa 3 ảnh.');
|
|
@@ -101,7 +129,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
101
129
|
};
|
|
102
130
|
reader.readAsDataURL(file);
|
|
103
131
|
|
|
104
|
-
// Reset input so same file can be selected again if needed
|
|
105
132
|
event.target.value = '';
|
|
106
133
|
};
|
|
107
134
|
|
|
@@ -118,7 +145,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
118
145
|
};
|
|
119
146
|
|
|
120
147
|
const startCapture = async (mode: 'full' | 'crop') => {
|
|
121
|
-
console.log('click capture', performance.now());
|
|
122
148
|
if (screenshots.length >= 3) {
|
|
123
149
|
alert('Bạn chỉ có thể chụp tối đa 3 ảnh.');
|
|
124
150
|
return;
|
|
@@ -126,25 +152,19 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
126
152
|
setIsTempHidden(true); // Hide modal temporarily
|
|
127
153
|
|
|
128
154
|
if (mode === 'crop') {
|
|
129
|
-
// Add small delay to ensure modal is gone
|
|
130
155
|
setTimeout(() => setIsSelecting(true), 150);
|
|
131
156
|
return;
|
|
132
157
|
}
|
|
133
158
|
|
|
134
|
-
// Full screen capture
|
|
135
159
|
setTimeout(async () => {
|
|
136
160
|
await performCapture();
|
|
137
161
|
}, 150);
|
|
138
162
|
};
|
|
139
163
|
|
|
140
164
|
const performCapture = async (cropRect?: Rect) => {
|
|
141
|
-
console.log('performCapture start', performance.now());
|
|
142
165
|
setIsCapturing(true);
|
|
143
166
|
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
167
|
const shot = await captureScreenshot(cropRect);
|
|
147
|
-
console.log('performCapture end', performance.now());
|
|
148
168
|
if (shot) {
|
|
149
169
|
setScreenshots(prev => [...prev, shot as string]);
|
|
150
170
|
} else {
|
|
@@ -152,11 +172,12 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
152
172
|
}
|
|
153
173
|
} catch (err) {
|
|
154
174
|
console.error(err);
|
|
175
|
+
alert('Không thể chụp ảnh màn hình. Vui lòng thử lại.');
|
|
155
176
|
} finally {
|
|
156
177
|
setIsCapturing(false);
|
|
157
178
|
setIsTempHidden(false); // Re-show modal
|
|
158
179
|
}
|
|
159
|
-
}
|
|
180
|
+
};
|
|
160
181
|
|
|
161
182
|
const handleCropConfirm = (rect: Rect) => {
|
|
162
183
|
setIsSelecting(false);
|
|
@@ -176,7 +197,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
176
197
|
if (onClose) onClose();
|
|
177
198
|
setInternalIsOpen(false);
|
|
178
199
|
|
|
179
|
-
// Clean up state
|
|
180
200
|
setScreenshots([]);
|
|
181
201
|
setIsSelecting(false);
|
|
182
202
|
setZoomedImage(null);
|
|
@@ -210,17 +230,15 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
210
230
|
setIsSubmitting(true);
|
|
211
231
|
try {
|
|
212
232
|
const deviceInfo = getDeviceInfo();
|
|
213
|
-
//
|
|
233
|
+
// Upload each screenshot (base64) and collect the returned URLs.
|
|
214
234
|
const listImage = await Promise.all(
|
|
215
235
|
screenshots.map(async (shot) => {
|
|
216
|
-
const response = await fetch(
|
|
236
|
+
const response = await fetch(uploadUrl, {
|
|
217
237
|
method: 'POST',
|
|
218
238
|
headers: {
|
|
219
239
|
'Content-Type': 'application/json'
|
|
220
240
|
},
|
|
221
|
-
body: JSON.stringify({
|
|
222
|
-
base64: shot
|
|
223
|
-
})
|
|
241
|
+
body: JSON.stringify({ base64: shot })
|
|
224
242
|
});
|
|
225
243
|
const uploadData = await response.json();
|
|
226
244
|
return uploadData.file_url;
|
|
@@ -236,8 +254,12 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
236
254
|
contextData
|
|
237
255
|
};
|
|
238
256
|
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
if (onSubmit) {
|
|
258
|
+
await onSubmit(feedbackData);
|
|
259
|
+
} else {
|
|
260
|
+
console.warn('[feedback-widget] Không có onSubmit — bỏ qua gửi. Dữ liệu:', feedbackData);
|
|
261
|
+
}
|
|
262
|
+
handleCloseTrigger();
|
|
241
263
|
} catch (error) {
|
|
242
264
|
console.error('Submit feedback failed', error);
|
|
243
265
|
} finally {
|
|
@@ -245,9 +267,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
245
267
|
}
|
|
246
268
|
};
|
|
247
269
|
|
|
248
|
-
// ... inside component
|
|
249
|
-
|
|
250
|
-
// Reduced Styles
|
|
251
270
|
const styles = `
|
|
252
271
|
@keyframes fb-fade-in {
|
|
253
272
|
from { opacity: 0; transform: translateY(10px); }
|
|
@@ -268,7 +287,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
268
287
|
border-radius: 12px;
|
|
269
288
|
width: 280px;
|
|
270
289
|
max-width: 90vw;
|
|
271
|
-
max-height: 80vh;
|
|
290
|
+
max-height: 80vh;
|
|
272
291
|
overflow-y: auto;
|
|
273
292
|
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
293
|
border: 1px solid #f3f4f6;
|
|
@@ -289,7 +308,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
289
308
|
bottom: 20px !important;
|
|
290
309
|
right: 20px !important;
|
|
291
310
|
}
|
|
292
|
-
/* Prevent iOS zoom on inputs */
|
|
293
311
|
input, select, textarea {
|
|
294
312
|
font-size: 16px !important;
|
|
295
313
|
}
|
|
@@ -298,23 +316,17 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
298
316
|
.fb-btn-crop {
|
|
299
317
|
display: none !important;
|
|
300
318
|
}
|
|
301
|
-
/* Enforce 16px font on all mobile/tablet inputs to prevent zoom */
|
|
302
319
|
.fb-widget-modal input,
|
|
303
320
|
.fb-widget-modal select,
|
|
304
321
|
.fb-widget-modal textarea {
|
|
305
322
|
font-size: 16px !important;
|
|
306
323
|
}
|
|
307
324
|
}
|
|
308
|
-
@media (min-width: 768px) {
|
|
309
|
-
.fb-btn-upload {
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
325
|
`;
|
|
314
326
|
|
|
315
327
|
return (
|
|
316
328
|
<>
|
|
317
|
-
<style id="feedback-widget-styles">{styles}</style>
|
|
329
|
+
<style id="feedback-widget-styles" data-hsa-report-ui="true">{styles}</style>
|
|
318
330
|
|
|
319
331
|
{isSelecting && (
|
|
320
332
|
<CropOverlay onConfirm={handleCropConfirm} onCancel={handleCropCancel} />
|
|
@@ -323,6 +335,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
323
335
|
{/* Zoom Modal */}
|
|
324
336
|
{zoomedImage && (
|
|
325
337
|
<div
|
|
338
|
+
data-hsa-report-ui="true"
|
|
326
339
|
style={{
|
|
327
340
|
position: 'fixed',
|
|
328
341
|
inset: 0,
|
|
@@ -357,13 +370,17 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
357
370
|
)}
|
|
358
371
|
|
|
359
372
|
{/* Trigger Button */}
|
|
360
|
-
<div
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
373
|
+
<div
|
|
374
|
+
data-hsa-report-ui="true"
|
|
375
|
+
onClick={() => {
|
|
376
|
+
if (externalIsOpen === undefined) {
|
|
377
|
+
handleOpen();
|
|
378
|
+
} else if (onOpen) {
|
|
379
|
+
onOpen();
|
|
380
|
+
}
|
|
381
|
+
}}
|
|
382
|
+
style={{ cursor: 'pointer', display: 'inline-block' }}
|
|
383
|
+
>
|
|
367
384
|
{triggerComponent || (
|
|
368
385
|
<button
|
|
369
386
|
className="fb-widget-trigger"
|
|
@@ -394,16 +411,16 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
394
411
|
)}
|
|
395
412
|
</div>
|
|
396
413
|
|
|
397
|
-
{/*
|
|
414
|
+
{/* Capturing indicator */}
|
|
398
415
|
{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' }}>
|
|
416
|
+
<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
417
|
Đang chụp...
|
|
401
418
|
</div>
|
|
402
419
|
)}
|
|
403
420
|
|
|
404
421
|
{/* Main Form Popover */}
|
|
405
422
|
{showWidget && !isSelecting && !zoomedImage && (
|
|
406
|
-
<div className="fb-widget-modal">
|
|
423
|
+
<div className="fb-widget-modal" data-hsa-report-ui="true">
|
|
407
424
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
408
425
|
<h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 700, color: '#111827' }}>
|
|
409
426
|
Báo lỗi
|
|
@@ -429,7 +446,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
429
446
|
</button>
|
|
430
447
|
</div>
|
|
431
448
|
|
|
432
|
-
{/* Form Fields Container */}
|
|
433
449
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
434
450
|
|
|
435
451
|
{/* Question ID Input */}
|
|
@@ -437,12 +453,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
437
453
|
<label style={{ display: 'block', marginBottom: '2px', fontSize: '0.8rem', fontWeight: 600, color: '#374151' }}>
|
|
438
454
|
ID Câu hỏi {contextData?.questionIdChild ? 'bài đọc' : ''} <span style={{ color: '#ef4444' }}>*</span>
|
|
439
455
|
</label>
|
|
440
|
-
<div style={{
|
|
441
|
-
position: 'relative',
|
|
442
|
-
display: 'flex',
|
|
443
|
-
alignItems: 'center',
|
|
444
|
-
transition: 'all 0.2s'
|
|
445
|
-
}}>
|
|
456
|
+
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', transition: 'all 0.2s' }}>
|
|
446
457
|
<Hash size={12} style={{
|
|
447
458
|
position: 'absolute',
|
|
448
459
|
left: '10px',
|
|
@@ -487,12 +498,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
487
498
|
<label style={{ display: 'block', marginBottom: '2px', fontSize: '0.8rem', fontWeight: 600, color: '#374151' }}>
|
|
488
499
|
ID Câu hỏi
|
|
489
500
|
</label>
|
|
490
|
-
<div style={{
|
|
491
|
-
position: 'relative',
|
|
492
|
-
display: 'flex',
|
|
493
|
-
alignItems: 'center',
|
|
494
|
-
transition: 'all 0.2s'
|
|
495
|
-
}}>
|
|
501
|
+
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', transition: 'all 0.2s' }}>
|
|
496
502
|
<Hash size={12} style={{
|
|
497
503
|
position: 'absolute',
|
|
498
504
|
left: '10px',
|
|
@@ -575,45 +581,53 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
575
581
|
</span>
|
|
576
582
|
</div>
|
|
577
583
|
<div style={{ position: 'relative' }}>
|
|
578
|
-
<div style={{
|
|
579
|
-
position: 'absolute',
|
|
580
|
-
left: '10px',
|
|
581
|
-
top: '8px',
|
|
582
|
-
pointerEvents: 'none'
|
|
583
|
-
}}>
|
|
584
|
+
{isCkEditor ? null : <div style={{ position: 'absolute', left: '10px', top: '8px', pointerEvents: 'none' }}>
|
|
584
585
|
<AlignLeft size={12} style={{
|
|
585
586
|
color: focusedField === 'desc' ? themeColor : (errors.description ? '#ef4444' : '#9ca3af'),
|
|
586
587
|
transition: 'color 0.2s'
|
|
587
588
|
}} />
|
|
588
|
-
</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
|
-
|
|
589
|
+
</div>}
|
|
590
|
+
{isCkEditor && DescriptionEditor ? (
|
|
591
|
+
<DescriptionEditor
|
|
592
|
+
placeholder="Mô tả chi tiết lỗi..."
|
|
593
|
+
contentQuestion={description}
|
|
594
|
+
error={!!errors.description}
|
|
595
|
+
uploadUrl={uploadUrl}
|
|
596
|
+
handleContent={(html: string) => {
|
|
597
|
+
setDescription(html);
|
|
598
|
+
if (errors.description) setErrors(prev => ({ ...prev, description: undefined }));
|
|
599
|
+
}}
|
|
600
|
+
/>
|
|
601
|
+
) : (
|
|
602
|
+
<textarea
|
|
603
|
+
value={description}
|
|
604
|
+
onFocus={() => setFocusedField('desc')}
|
|
605
|
+
onBlur={() => setFocusedField(null)}
|
|
606
|
+
onChange={(e) => {
|
|
607
|
+
setDescription(e.target.value);
|
|
608
|
+
if (errors.description) setErrors(prev => ({ ...prev, description: undefined }));
|
|
609
|
+
}}
|
|
610
|
+
maxLength={1000}
|
|
611
|
+
placeholder="Mô tả chi tiết..."
|
|
612
|
+
style={{
|
|
613
|
+
width: '100%',
|
|
614
|
+
minHeight: '80px',
|
|
615
|
+
padding: '6px 10px 6px 28px',
|
|
616
|
+
borderRadius: '6px',
|
|
617
|
+
border: `1px solid ${errors.description ? '#ef4444' : (focusedField === 'desc' ? themeColor : '#e5e7eb')}`,
|
|
618
|
+
boxShadow: focusedField === 'desc' ? `0 0 0 2px ${themeColor}20` : 'none',
|
|
619
|
+
outline: 'none',
|
|
620
|
+
fontSize: '0.85rem',
|
|
621
|
+
color: '#1f2937',
|
|
622
|
+
resize: 'vertical',
|
|
623
|
+
boxSizing: 'border-box',
|
|
624
|
+
transition: 'all 0.2s',
|
|
625
|
+
backgroundColor: '#f9fafb',
|
|
626
|
+
lineHeight: '1.4',
|
|
627
|
+
fontFamily: 'inherit'
|
|
628
|
+
}}
|
|
629
|
+
/>
|
|
630
|
+
)}
|
|
617
631
|
</div>
|
|
618
632
|
{errors.description && (
|
|
619
633
|
<span style={{ display: 'block', marginTop: '2px', fontSize: '0.7rem', color: '#ef4444' }}>
|
|
@@ -630,7 +644,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
630
644
|
</label>
|
|
631
645
|
</div>
|
|
632
646
|
|
|
633
|
-
{/* Thumbnails Grid */}
|
|
634
647
|
{screenshots.length > 0 && (
|
|
635
648
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '6px', marginBottom: '8px' }}>
|
|
636
649
|
{screenshots.map((shot, index) => (
|
|
@@ -710,7 +723,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
710
723
|
alignItems: 'center',
|
|
711
724
|
gap: '4px',
|
|
712
725
|
transition: 'all 0.2s',
|
|
713
|
-
minWidth: '60px'
|
|
726
|
+
minWidth: '60px'
|
|
714
727
|
}}
|
|
715
728
|
onMouseOver={(e) => {
|
|
716
729
|
e.currentTarget.style.borderColor = themeColor;
|
|
@@ -844,7 +857,6 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
844
857
|
) : 'Gửi báo lỗi'}
|
|
845
858
|
</button>
|
|
846
859
|
</div>
|
|
847
|
-
{/* <p style={{ textAlign: 'center', marginTop: 'auto', color: '#9CA3AF' }}>v0.2.0-rc4</p> */}
|
|
848
860
|
</div>
|
|
849
861
|
)}
|
|
850
862
|
</>
|