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/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.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": { "optional": true },
45
- "hocmai-ckeditor-custom": { "optional": true },
46
- "ckeditor5-classic-with-mathtype": { "optional": true }
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 onSubmit={handleSubmit}>
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 />
@@ -185,23 +185,17 @@ function warmOnce(): Promise<void> {
185
185
  await ensureReadyForCapture();
186
186
  await inlineMathJaxFonts();
187
187
 
188
- // In the real app snapdom still needs one throwaway toCanvas before it
189
- // renders CHTML glyphs (even with fonts inlined). Do it here on form
190
- // OPEN, overlapping the user typing — so the click-to-capture is a single
191
- // fast pass. SVG math doesn't need it.
192
- const hasChtmlMath = Array.from(document.querySelectorAll('mjx-container')).some(
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 (!hasChtmlMath) return;
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(document.body, {
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
- caps.maxCanvasSize
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
- onSubmit: (data: FeedbackData) => Promise<void>;
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
- await onSubmit(feedbackData);
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.2s ease-out',
343
- backdropFilter: 'blur(4px)'
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, backdropFilter: 'blur(4px)', fontSize: '0.8rem' }}>
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
- openFeedback: (questionId: string | number, contextData?: any) => void;
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
- onSubmit: (data: FeedbackData) => Promise<void>;
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 = (questionId: string | number, contextData?: any) => {
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={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
- ReactDOM.createRoot(document.getElementById('root')!).render(
6
- <React.StrictMode>
7
- <App />
8
- </React.StrictMode>
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 />);