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
@@ -0,0 +1,362 @@
1
+ import { snapdom } from '@zumer/snapdom';
2
+ import { domToPng } from 'modern-screenshot';
3
+ import type { Rect, CaptureOptions, CaptureProgress } from './types';
4
+ import { REPORT_UI_ATTR } from './types';
5
+ import { getCaptureCapabilities, clampScale } from './device-detection';
6
+ import { ensureReadyForCapture } from './fonts';
7
+ import { inlineMathJaxFonts } from './mathjax-fonts';
8
+
9
+ export type { Rect, CaptureProgress, CaptureOptions } from './types';
10
+
11
+ const DEFAULT_TIMEOUT = 20000;
12
+ const DEFAULT_BG = '#ffffff';
13
+
14
+ /** Region to keep, expressed in document (CSS) coordinates. */
15
+ interface Region {
16
+ x: number;
17
+ y: number;
18
+ w: number;
19
+ h: number;
20
+ }
21
+
22
+ function scrollOffset() {
23
+ return {
24
+ x: window.scrollX || window.pageXOffset || 0,
25
+ y: window.scrollY || window.pageYOffset || 0,
26
+ };
27
+ }
28
+
29
+ function viewportSize() {
30
+ const w =
31
+ window.visualViewport?.width ||
32
+ document.documentElement.clientWidth ||
33
+ window.innerWidth;
34
+ const h =
35
+ window.visualViewport?.height ||
36
+ document.documentElement.clientHeight ||
37
+ window.innerHeight;
38
+ return { w, h };
39
+ }
40
+
41
+ /** Target rectangle in viewport (client) coordinates — used to prune off-screen nodes. */
42
+ interface ViewportRect {
43
+ top: number;
44
+ bottom: number;
45
+ left: number;
46
+ right: number;
47
+ }
48
+
49
+ function regionToViewportRect(cropRect?: Rect): ViewportRect {
50
+ if (cropRect) {
51
+ return {
52
+ top: cropRect.y,
53
+ bottom: cropRect.y + cropRect.height,
54
+ left: cropRect.x,
55
+ right: cropRect.x + cropRect.width,
56
+ };
57
+ }
58
+ const vp = viewportSize();
59
+ return { top: 0, bottom: vp.h, left: 0, right: vp.w };
60
+ }
61
+
62
+ /**
63
+ * The key to speed on formula-heavy pages: prune any element whose box lies fully
64
+ * outside the target region. snapdom/modern-screenshot skip a filtered node's whole
65
+ * subtree, so cloning ~900 nodes instead of ~26,000 turns a 27s capture into ~1.5s.
66
+ * Also drops the widget's own UI (selectors). getBoundingClientRect is cheap relative
67
+ * to the per-node clone + getComputedStyle cost it avoids.
68
+ */
69
+ function makePruneFilter(keep: ViewportRect, selectors: string[], buffer = 150) {
70
+ return (node: Node): boolean => {
71
+ if (!(node instanceof Element)) return true;
72
+ for (const sel of selectors) {
73
+ if (node.matches?.(sel)) return false;
74
+ }
75
+ const r = node.getBoundingClientRect();
76
+ // Keep zero-size nodes (often layout wrappers / not yet measured).
77
+ if (r.width === 0 && r.height === 0) return true;
78
+ if (r.bottom < keep.top - buffer) return false;
79
+ if (r.top > keep.bottom + buffer) return false;
80
+ if (r.right < keep.left - buffer) return false;
81
+ if (r.left > keep.right + buffer) return false;
82
+ return true;
83
+ };
84
+ }
85
+
86
+ /** A crop rect is in viewport coords; convert it (or the whole viewport) to document coords. */
87
+ function resolveRegion(cropRect?: Rect): Region {
88
+ const scroll = scrollOffset();
89
+ if (cropRect) {
90
+ return {
91
+ x: scroll.x + cropRect.x,
92
+ y: scroll.y + cropRect.y,
93
+ w: cropRect.width,
94
+ h: cropRect.height,
95
+ };
96
+ }
97
+ const vp = viewportSize();
98
+ return { x: scroll.x, y: scroll.y, w: vp.w, h: vp.h };
99
+ }
100
+
101
+ function excludeSelectors(extra?: string[]): string[] {
102
+ return [`[${REPORT_UI_ATTR}]`, ...(extra ?? [])];
103
+ }
104
+
105
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
106
+ return new Promise<T>((resolve, reject) => {
107
+ const timer = setTimeout(
108
+ () => reject(new Error(`${label} timed out after ${ms}ms`)),
109
+ ms
110
+ );
111
+ promise.then(
112
+ (value) => {
113
+ clearTimeout(timer);
114
+ resolve(value);
115
+ },
116
+ (err) => {
117
+ clearTimeout(timer);
118
+ reject(err);
119
+ }
120
+ );
121
+ });
122
+ }
123
+
124
+ function isBlankDataUrl(dataUrl: string | null): boolean {
125
+ return !dataUrl || dataUrl.length < 2000;
126
+ }
127
+
128
+ /**
129
+ * Crop a region (document CSS coords) out of a full-page canvas.
130
+ * Scale factors are derived from the real canvas size vs the captured element's
131
+ * scroll size, so we never have to guess the renderer's internal multiplier.
132
+ */
133
+ function cropFromCanvas(
134
+ source: HTMLCanvasElement,
135
+ root: HTMLElement,
136
+ region: Region,
137
+ backgroundColor: string
138
+ ): string | null {
139
+ const scroll = scrollOffset();
140
+ const rootRect = root.getBoundingClientRect();
141
+ const rootDocLeft = rootRect.left + scroll.x;
142
+ const rootDocTop = rootRect.top + scroll.y;
143
+
144
+ const contentWidth = root.scrollWidth || rootRect.width || 1;
145
+ const contentHeight = root.scrollHeight || rootRect.height || 1;
146
+ const scaleX = source.width / contentWidth;
147
+ const scaleY = source.height / contentHeight;
148
+
149
+ // Region relative to the captured element, in source-canvas pixels.
150
+ const srcX = (region.x - rootDocLeft) * scaleX;
151
+ const srcY = (region.y - rootDocTop) * scaleY;
152
+ const srcW = region.w * scaleX;
153
+ const srcH = region.h * scaleY;
154
+
155
+ const out = document.createElement('canvas');
156
+ out.width = Math.max(1, Math.round(srcW));
157
+ out.height = Math.max(1, Math.round(srcH));
158
+ const ctx = out.getContext('2d', { alpha: false });
159
+ if (!ctx) return null;
160
+
161
+ ctx.fillStyle = backgroundColor;
162
+ ctx.fillRect(0, 0, out.width, out.height);
163
+ ctx.drawImage(source, srcX, srcY, srcW, srcH, 0, 0, out.width, out.height);
164
+
165
+ try {
166
+ return out.toDataURL('image/png');
167
+ } catch (err) {
168
+ console.warn('[report-widget] canvas export failed (possibly tainted):', err);
169
+ return null;
170
+ } finally {
171
+ out.width = 0;
172
+ out.height = 0;
173
+ }
174
+ }
175
+
176
+ // Deterministically inline MathJax CHTML fonts as data-URI @font-face so any
177
+ // capture renders formulas immediately (no async font-fetch race). Idempotent;
178
+ // runs when the report form OPENS (via prewarmCapture), overlapping with the
179
+ // user typing, so the click-to-capture is fast. See mathjax-fonts.ts.
180
+ let warmPromise: Promise<void> | null = null;
181
+ function warmOnce(): Promise<void> {
182
+ if (warmPromise) return warmPromise;
183
+ warmPromise = (async () => {
184
+ try {
185
+ await ensureReadyForCapture();
186
+ await inlineMathJaxFonts();
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(
193
+ (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 };
198
+ try {
199
+ await snapdom.toCanvas(document.body, {
200
+ embedFonts: true,
201
+ dpr: 1,
202
+ scale: 1,
203
+ filter: makePruneFilter(keep, [`[${REPORT_UI_ATTR}]`]),
204
+ });
205
+ } catch {
206
+ /* warm errors are non-fatal */
207
+ }
208
+ } catch {
209
+ // Non-fatal: the real capture still runs (may fall back to embedFonts).
210
+ }
211
+ })();
212
+ return warmPromise;
213
+ }
214
+
215
+ /** Prepare fonts for capture (call when the report form opens). */
216
+ export function prewarmCapture(): Promise<void> {
217
+ return warmOnce();
218
+ }
219
+
220
+ /** Tier 1: snapdom clones only the on-screen subtree (fonts embedded), then we crop. */
221
+ async function captureWithSnapdom(
222
+ region: Region,
223
+ keepRect: ViewportRect,
224
+ scale: number,
225
+ backgroundColor: string,
226
+ selectors: string[]
227
+ ): Promise<string | null> {
228
+ const root = document.body;
229
+ const canvas = await snapdom.toCanvas(root, {
230
+ embedFonts: true,
231
+ dpr: 1,
232
+ scale,
233
+ backgroundColor,
234
+ filter: makePruneFilter(keepRect, selectors),
235
+ });
236
+ return cropFromCanvas(canvas, root, region, backgroundColor);
237
+ }
238
+
239
+ /** Tier 2: modern-screenshot renders the region directly via an offset transform. */
240
+ async function captureWithModernScreenshot(
241
+ region: Region,
242
+ scale: number,
243
+ backgroundColor: string,
244
+ selectors: string[]
245
+ ): Promise<string | null> {
246
+ const root = document.body;
247
+ const rect = root.getBoundingClientRect();
248
+ // Offset of the region within the captured root, in the root's own coords.
249
+ const offsetX = region.x - (rect.left + (window.scrollX || 0));
250
+ const offsetY = region.y - (rect.top + (window.scrollY || 0));
251
+ // modern-screenshot already clips to width/height, so we must NOT prune
252
+ // off-screen nodes (that drops content that ends up inside the region after
253
+ // the transform). Only exclude the widget's own UI.
254
+ const uiFilter = (node: Node): boolean => {
255
+ if (!(node instanceof Element)) return true;
256
+ for (const sel of selectors) {
257
+ if (node.matches?.(sel)) return false;
258
+ }
259
+ return true;
260
+ };
261
+ return domToPng(root, {
262
+ width: region.w,
263
+ height: region.h,
264
+ scale,
265
+ backgroundColor,
266
+ filter: uiFilter,
267
+ style: {
268
+ transform: `translate(${-offsetX}px, ${-offsetY}px)`,
269
+ transformOrigin: 'top left',
270
+ },
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Capture a screenshot of the current viewport, or of `cropRect` (viewport coords).
276
+ *
277
+ * Signature is drop-in compatible with hocmai-feedback-widget's captureScreenshot.
278
+ * Strategy: snapdom (fonts embedded → MathJax CHTML renders correctly, single DOM
279
+ * clone → no O(N) layout thrash) with a modern-screenshot fallback. Always bounded
280
+ * by a timeout so it can never hang the UI.
281
+ */
282
+ export async function captureScreenshot(
283
+ cropRect?: Rect,
284
+ onProgress?: (progress: CaptureProgress) => void
285
+ ): Promise<string | null> {
286
+ const options: CaptureOptions = {};
287
+ return captureScreenshotWithOptions(cropRect, { ...options, onProgress });
288
+ }
289
+
290
+ export async function captureScreenshotWithOptions(
291
+ cropRect: Rect | undefined,
292
+ options: CaptureOptions
293
+ ): Promise<string | null> {
294
+ const {
295
+ backgroundColor = DEFAULT_BG,
296
+ timeoutMs = DEFAULT_TIMEOUT,
297
+ excludeSelectors: extraSelectors,
298
+ onProgress,
299
+ } = options;
300
+
301
+ const report = (progress: CaptureProgress) => onProgress?.(progress);
302
+ const selectors = excludeSelectors(extraSelectors);
303
+
304
+ report({ phase: 'preparing', progress: 10, message: 'Đang chuẩn bị...' });
305
+ await ensureReadyForCapture();
306
+
307
+ const region = resolveRegion(cropRect);
308
+ const keepRect = regionToViewportRect(cropRect);
309
+ const caps = getCaptureCapabilities();
310
+ const root = document.body;
311
+ const desired = options.scale ?? caps.recommendedScale;
312
+ const scale = clampScale(
313
+ desired,
314
+ root.scrollWidth || region.w,
315
+ root.scrollHeight || region.h,
316
+ caps.maxCanvasSize
317
+ );
318
+
319
+ // Font inlining (idempotent; normally already done from open-time prewarm so
320
+ // this is instant) — makes any capture render formulas with no font race.
321
+ await warmOnce();
322
+
323
+ // Tier 1: snapdom + off-screen prune (fast — clones only viewport nodes — and
324
+ // renders CHTML reliably). The warm pass on form-open means this is a single
325
+ // fast pass at click time.
326
+ try {
327
+ report({ phase: 'rendering', progress: 50, message: 'Đang chụp...' });
328
+ const result = await withTimeout(
329
+ captureWithSnapdom(region, keepRect, scale, backgroundColor, selectors),
330
+ timeoutMs,
331
+ 'snapdom capture'
332
+ );
333
+ if (!isBlankDataUrl(result)) {
334
+ report({ phase: 'done', progress: 100, message: 'Hoàn tất' });
335
+ return result;
336
+ }
337
+ console.warn('[report-widget] snapdom returned blank result, falling back');
338
+ } catch (err) {
339
+ console.warn('[report-widget] snapdom capture failed, falling back:', err);
340
+ }
341
+
342
+ // Tier 2: modern-screenshot (no off-screen prune) — viewport-direct, reliable
343
+ // fallback if snapdom returns blank.
344
+ const regionScale = clampScale(desired, region.w, region.h, caps.maxCanvasSize);
345
+ try {
346
+ report({ phase: 'rendering', progress: 80, message: 'Đang chụp (dự phòng)...' });
347
+ const result = await withTimeout(
348
+ captureWithModernScreenshot(region, regionScale, backgroundColor, selectors),
349
+ timeoutMs,
350
+ 'modern-screenshot capture'
351
+ );
352
+ if (!isBlankDataUrl(result)) {
353
+ report({ phase: 'done', progress: 100, message: 'Hoàn tất' });
354
+ return result;
355
+ }
356
+ } catch (err) {
357
+ console.error('[report-widget] modern-screenshot capture failed:', err);
358
+ }
359
+
360
+ console.error('[report-widget] all capture methods failed');
361
+ return null;
362
+ }
@@ -0,0 +1,143 @@
1
+ import { REPORT_UI_ATTR } from './types';
2
+
3
+ /**
4
+ * The blank-first-capture bug: MathJax CHTML glyphs are drawn with MathJax web
5
+ * fonts loaded from a (cross-origin) CDN. snapdom inlines those fonts into its
6
+ * snapshot only after an async fetch, so the first capture is blank and only a
7
+ * later one is complete.
8
+ *
9
+ * Fix: deterministically fetch the MathJax font files ONCE and inject them back
10
+ * into the document as @font-face rules whose src is a data: URI. After that,
11
+ * snapdom finds already-inlined fonts (no fetch needed) and the very first
12
+ * capture renders every formula. Display is unchanged — these rules only add a
13
+ * data-URI source for the same font families MathJax already uses.
14
+ */
15
+
16
+ interface FontFaceInfo {
17
+ family: string;
18
+ weight: string;
19
+ style: string;
20
+ url: string;
21
+ format?: string;
22
+ }
23
+
24
+ const STYLE_ID = 'hsa-report-mathjax-fonts';
25
+
26
+ function bufferToBase64(buf: ArrayBuffer): string {
27
+ let binary = '';
28
+ const bytes = new Uint8Array(buf);
29
+ const chunk = 0x8000;
30
+ for (let i = 0; i < bytes.length; i += chunk) {
31
+ binary += String.fromCharCode.apply(
32
+ null,
33
+ bytes.subarray(i, i + chunk) as unknown as number[]
34
+ );
35
+ }
36
+ return btoa(binary);
37
+ }
38
+
39
+ /** Collect @font-face rules that belong to MathJax from readable stylesheets. */
40
+ function collectMathJaxFontFaces(): FontFaceInfo[] {
41
+ const faces: FontFaceInfo[] = [];
42
+ for (const sheet of Array.from(document.styleSheets)) {
43
+ let rules: CSSRuleList | null = null;
44
+ try {
45
+ rules = sheet.cssRules;
46
+ } catch {
47
+ continue; // cross-origin stylesheet — not readable
48
+ }
49
+ if (!rules) continue;
50
+ for (const rule of Array.from(rules)) {
51
+ if (typeof CSSFontFaceRule !== 'undefined' && rule instanceof CSSFontFaceRule) {
52
+ const family = rule.style
53
+ .getPropertyValue('font-family')
54
+ .replace(/["']/g, '')
55
+ .trim();
56
+ if (!/MJX|MathJax/i.test(family)) continue;
57
+ const srcText = rule.style.getPropertyValue('src');
58
+ const m = srcText.match(
59
+ /url\(\s*["']?([^"')]+)["']?\s*\)(?:\s*format\(\s*["']?([^"')]+)["']?\s*\))?/
60
+ );
61
+ if (!m) continue;
62
+ let url: string;
63
+ try {
64
+ url = new URL(m[1], sheet.href || window.location.href).href;
65
+ } catch {
66
+ continue;
67
+ }
68
+ faces.push({
69
+ family,
70
+ weight: rule.style.getPropertyValue('font-weight') || 'normal',
71
+ style: rule.style.getPropertyValue('font-style') || 'normal',
72
+ url,
73
+ format: m[2],
74
+ });
75
+ }
76
+ }
77
+ }
78
+ return faces;
79
+ }
80
+
81
+ let inlinePromise: Promise<void> | null = null;
82
+
83
+ /**
84
+ * Fetch MathJax fonts and inject them as data-URI @font-face. Idempotent and
85
+ * cached. No-op if there are no MathJax CHTML font faces (e.g. SVG output).
86
+ */
87
+ export function inlineMathJaxFonts(): Promise<void> {
88
+ if (inlinePromise) return inlinePromise;
89
+ inlinePromise = (async () => {
90
+ if (document.getElementById(STYLE_ID)) return;
91
+ const faces = collectMathJaxFontFaces();
92
+ if (faces.length === 0) return; // SVG output or no math → nothing to do
93
+
94
+ // De-duplicate by URL (many faces can share files).
95
+ const byUrl = new Map<string, FontFaceInfo[]>();
96
+ for (const f of faces) {
97
+ const list = byUrl.get(f.url) ?? [];
98
+ list.push(f);
99
+ byUrl.set(f.url, list);
100
+ }
101
+
102
+ const cssBlocks = await Promise.all(
103
+ Array.from(byUrl.entries()).map(async ([url, group]) => {
104
+ try {
105
+ const res = await fetch(url, { mode: 'cors' });
106
+ if (!res.ok) return '';
107
+ const buf = await res.arrayBuffer();
108
+ const b64 = bufferToBase64(buf);
109
+ const fmt = group[0].format || (url.includes('woff2') ? 'woff2' : 'woff');
110
+ const mime = fmt === 'woff2' ? 'font/woff2' : 'font/woff';
111
+ const dataUri = `url(data:${mime};base64,${b64}) format("${fmt}")`;
112
+ return group
113
+ .map(
114
+ (f) =>
115
+ `@font-face{font-family:"${f.family}";font-style:${f.style};font-weight:${f.weight};font-display:block;src:${dataUri};}`
116
+ )
117
+ .join('');
118
+ } catch {
119
+ return '';
120
+ }
121
+ })
122
+ );
123
+
124
+ const css = cssBlocks.join('');
125
+ if (!css) return;
126
+
127
+ const style = document.createElement('style');
128
+ style.id = STYLE_ID;
129
+ // NOT marked with REPORT_UI_ATTR on purpose: the capture must keep these
130
+ // @font-face rules. (We only exclude visible widget UI via that attr.)
131
+ void REPORT_UI_ATTR;
132
+ style.textContent = css;
133
+ document.head.appendChild(style);
134
+
135
+ // Make sure the browser has parsed/loaded them before we capture.
136
+ try {
137
+ await (document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts?.ready;
138
+ } catch {
139
+ /* ignore */
140
+ }
141
+ })();
142
+ return inlinePromise;
143
+ }
@@ -0,0 +1,34 @@
1
+ export interface Rect {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+
8
+ export type CapturePhase =
9
+ | 'preparing'
10
+ | 'rendering'
11
+ | 'cropping'
12
+ | 'exporting'
13
+ | 'done';
14
+
15
+ export interface CaptureProgress {
16
+ phase: CapturePhase;
17
+ progress: number; // 0-100
18
+ message: string;
19
+ }
20
+
21
+ export interface CaptureOptions {
22
+ /** Output pixel multiplier. Defaults to a device-aware value, capped by max canvas size. */
23
+ scale?: number;
24
+ /** Background fill for the exported PNG. Default '#ffffff'. */
25
+ backgroundColor?: string;
26
+ /** Hard timeout so a capture can never hang the UI. Default 20000ms. */
27
+ timeoutMs?: number;
28
+ /** CSS selectors to exclude from the capture (the widget's own UI is always excluded). */
29
+ excludeSelectors?: string[];
30
+ onProgress?: (progress: CaptureProgress) => void;
31
+ }
32
+
33
+ /** Attribute used to tag the widget's own DOM so it never appears in screenshots. */
34
+ export const REPORT_UI_ATTR = 'data-hsa-report-ui';
@@ -0,0 +1,14 @@
1
+ // Ambient declarations for the optional CKEditor peer dependencies, used only by
2
+ // the separate `hocmai-feedback-widget/ckeditor` entry. They are externalized at
3
+ // build time and may not be installed, so we type them loosely here.
4
+ declare module '@ckeditor/ckeditor5-react' {
5
+ import type { ComponentType } from 'react';
6
+ export const CKEditor: ComponentType<any>;
7
+ }
8
+
9
+ declare module 'hocmai-ckeditor-custom' {
10
+ const ClassicEditor: any;
11
+ export default ClassicEditor;
12
+ }
13
+
14
+ declare module 'ckeditor5-classic-with-mathtype/build/translations/vi.js';
@@ -0,0 +1,130 @@
1
+ import { useRef, useState } from 'react';
2
+ import { CKEditor } from '@ckeditor/ckeditor5-react';
3
+ import ClassicEditor from 'hocmai-ckeditor-custom';
4
+ import 'ckeditor5-classic-with-mathtype/build/translations/vi.js';
5
+
6
+ export interface CkEditorProps {
7
+ /** Called with the editor HTML whenever the content changes. */
8
+ handleContent: (data: string) => void;
9
+ placeholder?: string;
10
+ contentQuestion?: string;
11
+ error?: boolean;
12
+ showOnly?: boolean;
13
+ className?: string;
14
+ disableItem?: string[];
15
+ /** Endpoint that accepts { base64 } and returns { file_url } for image uploads. */
16
+ uploadUrl?: string;
17
+ }
18
+
19
+ const DEFAULT_UPLOAD_URL =
20
+ 'https://lcms.icanwork.vn/api/upload-to-s3/upload-image-error-to-s3';
21
+
22
+ // Style overrides for the CKEditor UI, injected via a <style> tag (self-contained;
23
+ // no Next.js-only CSS-module import).
24
+ const CKEDITOR_OVERRIDE_CSS = `
25
+ .hsa-ck-wrap .ck-editor { width: 100%; height: 100%; border: none; border-radius: 10px; outline: none; padding: 0; margin: 0; background-color: transparent; }
26
+ .hsa-ck-wrap .ck-toolbar { background-color: transparent !important; border: 1px solid #b2b2b2 !important; border-bottom: none; }
27
+ .hsa-ck-wrap .ck-content { padding: 8px 10px; margin: 0; border: 1px solid #b2b2b2; border-radius: 0 0 6px 6px; outline: none; resize: none; min-height: 120px; }
28
+ .hsa-ck-wrap .ck.ck-editor__editable:not(.ck-editor__nested-editable).ck-focused { box-shadow: none !important; }
29
+ .hsa-ck-wrap .ck.ck-button:not(.ck-disabled):hover, .hsa-ck-wrap a.ck.ck-button:not(.ck-disabled):hover { border-radius: 4px !important; }
30
+ .hsa-ck-wrap.ckstyle-err .ck-toolbar, .hsa-ck-wrap.ckstyle-err .ck-content { border-color: #ef4444 !important; }
31
+ `;
32
+
33
+ const TOOLBAR_ITEMS = [
34
+ 'MathType', 'ChemType', 'heading', '|',
35
+ 'bold', 'underline', 'italic', 'link', 'subscript', 'superscript',
36
+ 'fontColor', 'fontFamily', 'fontSize', 'bulletedList', 'numberedList', '|',
37
+ 'outdent', 'indent', 'alignment', '|',
38
+ 'imageUpload', 'blockQuote', 'insertTable', 'undo', 'redo', 'removeFormat',
39
+ ];
40
+
41
+ /** Upload an image as base64 and return its URL (fetch — no axios dependency). */
42
+ function makeUploadAdapter(uploadUrl: string) {
43
+ return (loader: { file: Promise<File> }) => ({
44
+ upload: () =>
45
+ new Promise((resolve, reject) => {
46
+ loader.file
47
+ .then((img) => {
48
+ const reader = new FileReader();
49
+ reader.onload = async () => {
50
+ try {
51
+ const res = await fetch(uploadUrl, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json', Accept: '*/*' },
54
+ body: JSON.stringify({ base64: reader.result as string }),
55
+ });
56
+ const data = await res.json();
57
+ resolve({ default: data.file_url });
58
+ } catch (err) {
59
+ reject(err);
60
+ }
61
+ };
62
+ reader.onerror = () => reject(reader.error);
63
+ reader.readAsDataURL(img);
64
+ })
65
+ .catch(reject);
66
+ }),
67
+ });
68
+ }
69
+
70
+ const CkEditor = ({
71
+ handleContent,
72
+ placeholder,
73
+ contentQuestion,
74
+ error,
75
+ showOnly,
76
+ className,
77
+ disableItem,
78
+ uploadUrl = DEFAULT_UPLOAD_URL,
79
+ }: CkEditorProps) => {
80
+ const refCkeditor = useRef<HTMLDivElement>(null);
81
+ const [content, setContent] = useState(contentQuestion ?? '');
82
+
83
+ const items = TOOLBAR_ITEMS.filter((item) =>
84
+ disableItem ? !disableItem.includes(item) : true
85
+ );
86
+
87
+ return (
88
+ <div
89
+ ref={refCkeditor}
90
+ className={`hsa-ck-wrap ${error ? 'ckstyle-err' : ''} ${showOnly ? 'ckstyle' : ''} ${className ?? ''}`}
91
+ style={{ color: '#000' }}
92
+ >
93
+ <style>{CKEDITOR_OVERRIDE_CSS}</style>
94
+ <CKEditor
95
+ editor={ClassicEditor}
96
+ config={{
97
+ toolbar: { items },
98
+ placeholder,
99
+ licenseKey:
100
+ 'MEtLSVRKRXpqd2lSMnNuL3dsYnZHUWozTUpYSjU4NlhjRWtVQ3kzRFR6TlhQVTVBMDJxMWo1V0o3R29LLU1qQXlOREEwTVRNPQ',
101
+ }}
102
+ data={content?.replaceAll('<mfenced>', '<mfenced separators>')}
103
+ onReady={(editor: any) => {
104
+ editor.setData(
105
+ editor
106
+ .getData()
107
+ .replaceAll('<p>&nbsp;</p>', '')
108
+ .replaceAll('<br>&nbsp;', '<br/>')
109
+ );
110
+ if (showOnly) {
111
+ editor.enableReadOnlyMode('hsa-report-widget');
112
+ } else {
113
+ editor.disableReadOnlyMode('hsa-report-widget');
114
+ }
115
+ const fileRepository = editor.plugins.get('FileRepository');
116
+ if (fileRepository) {
117
+ fileRepository.createUploadAdapter = makeUploadAdapter(uploadUrl);
118
+ }
119
+ }}
120
+ onChange={(_event: unknown, editor: any) => {
121
+ const data = editor.getData();
122
+ setContent(data);
123
+ handleContent(data.replaceAll('<p>&nbsp;</p>', '<br/>'));
124
+ }}
125
+ />
126
+ </div>
127
+ );
128
+ };
129
+
130
+ export default CkEditor;