hocmai-feedback-widget 0.2.2 → 0.2.4
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/LICENSE +21 -0
- package/README.md +105 -124
- package/dist/components/FeedbackWidget.d.ts +2 -1
- package/dist/context/FeedbackContext.d.ts +15 -2
- package/dist/hocmai-feedback-widget.mjs +972 -959
- package/dist/hocmai-feedback-widget.mjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/package.json +12 -4
- package/src/App.tsx +5 -1
- package/src/capture/index.ts +15 -16
- package/src/components/FeedbackWidget.tsx +16 -9
- package/src/context/FeedbackContext.tsx +50 -5
- package/src/index.ts +1 -0
- package/src/main.tsx +5 -6
package/dist/index.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ export { default as FeedbackWidget } from './components/FeedbackWidget';
|
|
|
2
2
|
export type { FeedbackData, FeedbackProps } from './components/FeedbackWidget';
|
|
3
3
|
export type { CkEditorProps } from './components/CkEditor';
|
|
4
4
|
export { FeedbackProvider, useFeedback } from './context/FeedbackContext';
|
|
5
|
+
export type { FeedbackSubmitHandler } from './context/FeedbackContext';
|
|
5
6
|
export { captureScreenshot, captureScreenshotWithOptions, prewarmCapture, } from './capture';
|
|
6
7
|
export type { Rect, CaptureProgress, CaptureOptions } from './capture/types';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hocmai-feedback-widget",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Feedback/report widget with MathJax-aware screenshot capture (snapdom + embedded fonts).",
|
|
7
7
|
"module": "./dist/hocmai-feedback-widget.mjs",
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"dev": "vite",
|
|
25
|
+
"clean": "rm -rf dist node_modules/.vite",
|
|
26
|
+
"prebuild": "npm run clean",
|
|
25
27
|
"build": "vite build",
|
|
26
28
|
"typecheck": "tsc --noEmit",
|
|
27
29
|
"preview": "vite preview",
|
|
@@ -41,9 +43,15 @@
|
|
|
41
43
|
"ckeditor5-classic-with-mathtype": "*"
|
|
42
44
|
},
|
|
43
45
|
"peerDependenciesMeta": {
|
|
44
|
-
"@ckeditor/ckeditor5-react": {
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
"@ckeditor/ckeditor5-react": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"hocmai-ckeditor-custom": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"ckeditor5-classic-with-mathtype": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
47
55
|
},
|
|
48
56
|
"devDependencies": {
|
|
49
57
|
"@playwright/test": "^1.48.0",
|
package/src/App.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { FeedbackProvider, useFeedback, captureScreenshot, type FeedbackData } from './index';
|
|
3
|
+
import CkEditor from './components/CkEditor';
|
|
3
4
|
|
|
4
5
|
declare global {
|
|
5
6
|
interface Window {
|
|
@@ -123,7 +124,10 @@ const App: React.FC = () => {
|
|
|
123
124
|
}, []);
|
|
124
125
|
|
|
125
126
|
return (
|
|
126
|
-
<FeedbackProvider
|
|
127
|
+
<FeedbackProvider
|
|
128
|
+
isCkEditor={true}
|
|
129
|
+
descriptionEditor={CkEditor}
|
|
130
|
+
onSubmit={handleSubmit}>
|
|
127
131
|
{/* Mirror host app: normal-flow root + fixed overlay watermark + fixed opaque header */}
|
|
128
132
|
<div style={{ width: '100vw', display: 'flex', flexDirection: 'column' }}>
|
|
129
133
|
<Watermark />
|
package/src/capture/index.ts
CHANGED
|
@@ -185,23 +185,17 @@ function warmOnce(): Promise<void> {
|
|
|
185
185
|
await ensureReadyForCapture();
|
|
186
186
|
await inlineMathJaxFonts();
|
|
187
187
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
188
|
+
// snapdom needs one throwaway toCanvas before it renders CHTML glyphs
|
|
189
|
+
// (even with fonts inlined). It only needs to exercise the CHTML/font path
|
|
190
|
+
// ONCE — not the whole page — so warm with a single small formula element.
|
|
191
|
+
// This is far cheaper than a full-body pass (which on a tall page hits the
|
|
192
|
+
// canvas limit and downscales), keeping form-open light.
|
|
193
|
+
const chtml = Array.from(document.querySelectorAll('mjx-container')).find(
|
|
193
194
|
(c) => !c.querySelector('svg')
|
|
194
|
-
);
|
|
195
|
-
if (!
|
|
196
|
-
const vp = viewportSize();
|
|
197
|
-
const keep: ViewportRect = { top: 0, bottom: vp.h, left: 0, right: vp.w };
|
|
195
|
+
) as HTMLElement | undefined;
|
|
196
|
+
if (!chtml) return; // SVG math or no math → no priming needed
|
|
198
197
|
try {
|
|
199
|
-
await snapdom.toCanvas(
|
|
200
|
-
embedFonts: true,
|
|
201
|
-
dpr: 1,
|
|
202
|
-
scale: 1,
|
|
203
|
-
filter: makePruneFilter(keep, [`[${REPORT_UI_ATTR}]`]),
|
|
204
|
-
});
|
|
198
|
+
await snapdom.toCanvas(chtml, { embedFonts: true, dpr: 1, scale: 1 });
|
|
205
199
|
} catch {
|
|
206
200
|
/* warm errors are non-fatal */
|
|
207
201
|
}
|
|
@@ -309,11 +303,16 @@ export async function captureScreenshotWithOptions(
|
|
|
309
303
|
const caps = getCaptureCapabilities();
|
|
310
304
|
const root = document.body;
|
|
311
305
|
const desired = options.scale ?? caps.recommendedScale;
|
|
306
|
+
// Keep ~5% headroom below the hard canvas limit: snapdom measures the element
|
|
307
|
+
// slightly larger than scrollHeight, and hitting the limit triggers a slow
|
|
308
|
+
// internal downscale. Staying under it avoids that and keeps the crop mapping
|
|
309
|
+
// linear.
|
|
310
|
+
const safeMaxCanvas = Math.floor(caps.maxCanvasSize * 0.95);
|
|
312
311
|
const scale = clampScale(
|
|
313
312
|
desired,
|
|
314
313
|
root.scrollWidth || region.w,
|
|
315
314
|
root.scrollHeight || region.h,
|
|
316
|
-
|
|
315
|
+
safeMaxCanvas
|
|
317
316
|
);
|
|
318
317
|
|
|
319
318
|
// Font inlining (idempotent; normally already done from open-time prewarm so
|
|
@@ -4,7 +4,8 @@ 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
6
|
// Type-only import — erased at build time, so the core bundle has zero CKEditor
|
|
7
|
-
// coupling and apps build without the CKEditor deps.
|
|
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.
|
|
8
9
|
import type { CkEditorProps } from './CkEditor';
|
|
9
10
|
|
|
10
11
|
export interface FeedbackData {
|
|
@@ -20,7 +21,8 @@ export interface FeedbackData {
|
|
|
20
21
|
export interface FeedbackProps {
|
|
21
22
|
questionId: string | number;
|
|
22
23
|
contextData?: Record<string, any>;
|
|
23
|
-
|
|
24
|
+
/** Submit handler. Optional — may be provided per call via openFeedback or at the Provider. */
|
|
25
|
+
onSubmit?: (data: FeedbackData) => Promise<void> | void;
|
|
24
26
|
themeColor?: string;
|
|
25
27
|
triggerComponent?: React.ReactNode;
|
|
26
28
|
isOpen?: boolean;
|
|
@@ -252,7 +254,11 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
252
254
|
contextData
|
|
253
255
|
};
|
|
254
256
|
|
|
255
|
-
|
|
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
|
+
}
|
|
256
262
|
handleCloseTrigger();
|
|
257
263
|
} catch (error) {
|
|
258
264
|
console.error('Submit feedback failed', error);
|
|
@@ -339,8 +345,8 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
339
345
|
alignItems: 'center',
|
|
340
346
|
justifyContent: 'center',
|
|
341
347
|
cursor: 'zoom-out',
|
|
342
|
-
animation: 'fb-fade-in 0.
|
|
343
|
-
|
|
348
|
+
animation: 'fb-fade-in 0.15s ease-out',
|
|
349
|
+
willChange: 'opacity'
|
|
344
350
|
}}
|
|
345
351
|
onClick={() => setZoomedImage(null)}
|
|
346
352
|
>
|
|
@@ -348,6 +354,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
348
354
|
src={zoomedImage}
|
|
349
355
|
alt="Zoomed"
|
|
350
356
|
draggable={false}
|
|
357
|
+
decoding="async"
|
|
351
358
|
onContextMenu={(e) => e.preventDefault()}
|
|
352
359
|
style={{
|
|
353
360
|
maxWidth: '90vw',
|
|
@@ -407,7 +414,7 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
407
414
|
|
|
408
415
|
{/* Capturing indicator */}
|
|
409
416
|
{isCapturing && (
|
|
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,
|
|
417
|
+
<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, fontSize: '0.8rem' }}>
|
|
411
418
|
Đang chụp...
|
|
412
419
|
</div>
|
|
413
420
|
)}
|
|
@@ -575,15 +582,15 @@ const FeedbackWidget: React.FC<FeedbackProps> = ({
|
|
|
575
582
|
</span>
|
|
576
583
|
</div>
|
|
577
584
|
<div style={{ position: 'relative' }}>
|
|
578
|
-
<div style={{ position: 'absolute', left: '10px', top: '8px', pointerEvents: 'none' }}>
|
|
585
|
+
{isCkEditor ? null : <div style={{ position: 'absolute', left: '10px', top: '8px', pointerEvents: 'none' }}>
|
|
579
586
|
<AlignLeft size={12} style={{
|
|
580
587
|
color: focusedField === 'desc' ? themeColor : (errors.description ? '#ef4444' : '#9ca3af'),
|
|
581
588
|
transition: 'color 0.2s'
|
|
582
589
|
}} />
|
|
583
|
-
</div>
|
|
590
|
+
</div>}
|
|
584
591
|
{isCkEditor && DescriptionEditor ? (
|
|
585
592
|
<DescriptionEditor
|
|
586
|
-
placeholder="Mô tả chi tiết..."
|
|
593
|
+
placeholder="Mô tả chi tiết lỗi..."
|
|
587
594
|
contentQuestion={description}
|
|
588
595
|
error={!!errors.description}
|
|
589
596
|
uploadUrl={uploadUrl}
|
|
@@ -2,8 +2,19 @@ import React, { createContext, useContext, useState, type ReactNode } from 'reac
|
|
|
2
2
|
import FeedbackWidget, { type FeedbackData } from '../components/FeedbackWidget';
|
|
3
3
|
import type { CkEditorProps } from '../components/CkEditor';
|
|
4
4
|
|
|
5
|
+
export type FeedbackSubmitHandler = (data: FeedbackData) => Promise<void> | void;
|
|
6
|
+
|
|
5
7
|
interface FeedbackContextType {
|
|
6
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Open the report form.
|
|
10
|
+
* @param onSubmit optional per-call handler; overrides the Provider's onSubmit
|
|
11
|
+
* for this submission. Lets you handle submit from anywhere.
|
|
12
|
+
*/
|
|
13
|
+
openFeedback: (
|
|
14
|
+
questionId: string | number,
|
|
15
|
+
contextData?: any,
|
|
16
|
+
onSubmit?: FeedbackSubmitHandler
|
|
17
|
+
) => void;
|
|
7
18
|
closeFeedback: () => void;
|
|
8
19
|
isOpen: boolean;
|
|
9
20
|
activeQuestionId: string | number | null;
|
|
@@ -21,12 +32,19 @@ export const useFeedback = () => {
|
|
|
21
32
|
|
|
22
33
|
interface FeedbackProviderProps {
|
|
23
34
|
children: ReactNode;
|
|
24
|
-
|
|
35
|
+
/** Default submit handler. Optional — you can instead pass one per call via openFeedback. */
|
|
36
|
+
onSubmit?: FeedbackSubmitHandler;
|
|
25
37
|
themeColor?: string;
|
|
26
38
|
/** Use the rich-text editor for the description instead of a plain textarea. */
|
|
27
39
|
isCkEditor?: boolean;
|
|
28
40
|
/** Editor component for the description (from `hocmai-feedback-widget/ckeditor` or your own). */
|
|
29
41
|
descriptionEditor?: React.ComponentType<CkEditorProps>;
|
|
42
|
+
/** Show the "full screen" capture button. Default true. */
|
|
43
|
+
isEnableCaptureFull?: boolean;
|
|
44
|
+
/** Show the "select region" capture button. Default true. */
|
|
45
|
+
isEnableCaptureElement?: boolean;
|
|
46
|
+
/** Endpoint that accepts { base64 } and returns { file_url } for image uploads. */
|
|
47
|
+
uploadUrl?: string;
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
|
|
@@ -35,14 +53,25 @@ export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
|
|
|
35
53
|
themeColor,
|
|
36
54
|
isCkEditor,
|
|
37
55
|
descriptionEditor,
|
|
56
|
+
isEnableCaptureFull,
|
|
57
|
+
isEnableCaptureElement,
|
|
58
|
+
uploadUrl,
|
|
38
59
|
}) => {
|
|
39
60
|
const [isOpen, setIsOpen] = useState(false);
|
|
40
61
|
const [activeQuestionId, setActiveQuestionId] = useState<string | number | null>(null);
|
|
41
62
|
const [activeContextData, setActiveContextData] = useState<any>(undefined);
|
|
63
|
+
// Per-call submit handler set by openFeedback (stored via updater so React
|
|
64
|
+
// doesn't treat the function as a state initializer).
|
|
65
|
+
const [activeOnSubmit, setActiveOnSubmit] = useState<FeedbackSubmitHandler | undefined>(undefined);
|
|
42
66
|
|
|
43
|
-
const openFeedback = (
|
|
67
|
+
const openFeedback = (
|
|
68
|
+
questionId: string | number,
|
|
69
|
+
contextData?: any,
|
|
70
|
+
perCallOnSubmit?: FeedbackSubmitHandler
|
|
71
|
+
) => {
|
|
44
72
|
setActiveQuestionId(questionId);
|
|
45
73
|
setActiveContextData(contextData);
|
|
74
|
+
setActiveOnSubmit(() => perCallOnSubmit);
|
|
46
75
|
setIsOpen(true);
|
|
47
76
|
};
|
|
48
77
|
|
|
@@ -50,6 +79,19 @@ export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
|
|
|
50
79
|
setIsOpen(false);
|
|
51
80
|
};
|
|
52
81
|
|
|
82
|
+
// Resolve handler: per-call → Provider → warn (never crash).
|
|
83
|
+
const effectiveOnSubmit: FeedbackSubmitHandler = async (data) => {
|
|
84
|
+
const handler = activeOnSubmit ?? onSubmit;
|
|
85
|
+
if (!handler) {
|
|
86
|
+
console.warn(
|
|
87
|
+
'[feedback-widget] Bỏ qua gửi báo lỗi: chưa cung cấp onSubmit (ở FeedbackProvider hoặc openFeedback). Dữ liệu:',
|
|
88
|
+
data
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await handler(data);
|
|
93
|
+
};
|
|
94
|
+
|
|
53
95
|
return (
|
|
54
96
|
<FeedbackContext.Provider value={{ openFeedback, closeFeedback, isOpen, activeQuestionId }}>
|
|
55
97
|
{children}
|
|
@@ -59,10 +101,13 @@ export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
|
|
|
59
101
|
<FeedbackWidget
|
|
60
102
|
questionId={activeQuestionId}
|
|
61
103
|
contextData={activeContextData}
|
|
62
|
-
onSubmit={
|
|
104
|
+
onSubmit={effectiveOnSubmit}
|
|
63
105
|
themeColor={themeColor}
|
|
64
106
|
isCkEditor={isCkEditor}
|
|
65
107
|
descriptionEditor={descriptionEditor}
|
|
108
|
+
isEnableCaptureFull={isEnableCaptureFull}
|
|
109
|
+
isEnableCaptureElement={isEnableCaptureElement}
|
|
110
|
+
uploadUrl={uploadUrl}
|
|
66
111
|
isOpen={isOpen}
|
|
67
112
|
onClose={closeFeedback}
|
|
68
113
|
triggerComponent={<></>}
|
|
@@ -70,4 +115,4 @@ export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
|
|
|
70
115
|
)}
|
|
71
116
|
</FeedbackContext.Provider>
|
|
72
117
|
);
|
|
73
|
-
};
|
|
118
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { default as FeedbackWidget } from './components/FeedbackWidget';
|
|
|
2
2
|
export type { FeedbackData, FeedbackProps } from './components/FeedbackWidget';
|
|
3
3
|
export type { CkEditorProps } from './components/CkEditor';
|
|
4
4
|
export { FeedbackProvider, useFeedback } from './context/FeedbackContext';
|
|
5
|
+
export type { FeedbackSubmitHandler } from './context/FeedbackContext';
|
|
5
6
|
|
|
6
7
|
// Screenshot utilities (MathJax-CHTML aware, snapdom-based)
|
|
7
8
|
export {
|
package/src/main.tsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
1
|
import ReactDOM from 'react-dom/client';
|
|
3
2
|
import App from './App';
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
);
|
|
4
|
+
// NOTE: No <React.StrictMode> on purpose. In dev, StrictMode double-mounts
|
|
5
|
+
// components, and @ckeditor/ckeditor5-react creates a second editor instance
|
|
6
|
+
// during the remount (its async destroy hasn't finished) — showing TWO editors.
|
|
7
|
+
// The host app runs without StrictMode, so this matches production behaviour.
|
|
8
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
|