@solhun/feedback-kit-web 0.1.0 → 0.2.1
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.cjs +122 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -7
- package/dist/index.d.ts +26 -7
- package/dist/index.js +120 -2
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/element-info.ts +0 -138
- package/src/feedback-kit.test.ts +0 -227
- package/src/feedback-kit.tsx +0 -778
- package/src/index.ts +0 -97
- package/src/marker-store.ts +0 -153
- package/src/picking.test.ts +0 -249
- package/src/picking.ts +0 -370
- package/src/providers.ts +0 -43
- package/src/screen-map.test.ts +0 -455
- package/src/screenshot.test.ts +0 -47
- package/src/screenshot.ts +0 -90
- package/src/storage.test.ts +0 -65
- package/src/storage.ts +0 -69
- package/src/widget.ts +0 -70
package/dist/index.d.ts
CHANGED
|
@@ -105,10 +105,19 @@ interface ElementPickingOpts {
|
|
|
105
105
|
width: number;
|
|
106
106
|
height: number;
|
|
107
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* 요소를 고르면 호출된다. 주면 **인라인 팝업 대신 이걸 부른다** —
|
|
110
|
+
* 호스트가 리포트 모달을 열어 스크린샷·우선순위까지 받게 하기 위해서다.
|
|
111
|
+
*
|
|
112
|
+
* 모드는 켜진 채로 둔다. 한 번 켜고 여러 요소를 연달아 지목하는 게 이 모드의 목적이라,
|
|
113
|
+
* 요소 하나 고를 때마다 모드가 꺼지면 매번 다시 켜야 한다.
|
|
114
|
+
*/
|
|
115
|
+
onPick?: (element: ElementInfo | null, point: FeedbackPin) => void;
|
|
108
116
|
}
|
|
109
117
|
declare class ElementPickingController {
|
|
110
118
|
private readonly queue;
|
|
111
119
|
private readonly createReport;
|
|
120
|
+
private readonly onPick;
|
|
112
121
|
private readonly store;
|
|
113
122
|
private readonly getPathname;
|
|
114
123
|
private readonly doc;
|
|
@@ -152,6 +161,12 @@ declare class ElementPickingController {
|
|
|
152
161
|
setAnnotationComment(value: string): void;
|
|
153
162
|
/** 취소 — 마커도 제보도 남기지 않는다. 모드는 켜진 채로 둔다. */
|
|
154
163
|
cancelAnnotation(): void;
|
|
164
|
+
/**
|
|
165
|
+
* 제출된 제보를 마커로 남긴다. 모달 경로에서 쓴다 — 마커 생성이 팝업 저장 안에만
|
|
166
|
+
* 있으면, 리포트 모달로 보낸 지목 제보는 화면에 흔적이 남지 않는다("보냈는지 알 수
|
|
167
|
+
* 없다"가 이 모드가 없애려던 불편이다).
|
|
168
|
+
*/
|
|
169
|
+
noteSubmitted(report: FeedbackReport, delivered: boolean): void;
|
|
155
170
|
/**
|
|
156
171
|
* 주석 저장. 마커를 먼저 "전송 중"으로 찍고 나서 보낸다 — 사용자는 결과를 기다리지 않고
|
|
157
172
|
* 다음 요소로 넘어갈 수 있어야 한다.
|
|
@@ -168,16 +183,20 @@ declare class ElementPickingController {
|
|
|
168
183
|
|
|
169
184
|
/** 캡처에서 빼야 할 노드인가(위젯 자신의 UI). 결과에 자기 모달이 찍히면 안 된다. */
|
|
170
185
|
declare function isOwnUi(node: Element): boolean;
|
|
171
|
-
/**
|
|
172
|
-
* 웹 기본 캡처 — 현재 viewport 를 JPEG 로.
|
|
173
|
-
*
|
|
174
|
-
* 실패하면 **null 을 돌려준다**(던지지 않는다). 코어가 "캡처 실패" 상태로 바꾸고
|
|
175
|
-
* 사용자는 파일로 첨부할 수 있다 — 그림 때문에 제보 자체를 잃지 않는다.
|
|
176
|
-
*/
|
|
177
186
|
declare const captureWebScreenshot: ScreenshotCapture;
|
|
178
187
|
/** 8 MiB 정책이 요구할 때 기존 이미지를 JPEG 품질 단계로 다시 인코딩한다. */
|
|
179
188
|
declare const reencodeWebScreenshot: ScreenshotReencode;
|
|
180
189
|
|
|
190
|
+
/**
|
|
191
|
+
* 바깥 → 안쪽 순서의 컴포넌트 이름 목록.
|
|
192
|
+
* 예: `["App", "LoginPage", "LoginForm", "SubmitButton"]`
|
|
193
|
+
*
|
|
194
|
+
* @param limit 너무 깊은 트리는 자른다. 앱 루트까지 20 겹이면 이미 충분한 단서다.
|
|
195
|
+
*/
|
|
196
|
+
declare function reactComponentPath(node: Element, limit?: number): string[];
|
|
197
|
+
/** 제보 본문에 실을 한 줄. 컴포넌트를 못 찾으면 null(빈 줄을 만들지 않는다). */
|
|
198
|
+
declare function reactComponentSummary(node: Element): string | null;
|
|
199
|
+
|
|
181
200
|
interface WebWidgetOpts extends Omit<WidgetControllerOpts, "platform" | "initialPicking" | "onPickingChange"> {
|
|
182
201
|
/** 지목 모드 플래그와 마커를 담아 둘 저장소. 기본은 `localStorage`. */
|
|
183
202
|
store?: MarkerStore;
|
|
@@ -226,4 +245,4 @@ declare function createWebStorage(backing?: SyncStorage): FeedbackStorage;
|
|
|
226
245
|
*/
|
|
227
246
|
declare function webContextProviders(): ContextProviders;
|
|
228
247
|
|
|
229
|
-
export { type AnnotationPopupState, ELEMENT_TEXT_MAX_CHARS, ElementPickingController, type ElementPickingOpts, FeedbackKit, type FeedbackKitProps, MARKER_KEY_PREFIX, MAX_MARKERS_PER_PATH, type MarkerStatus, MarkerStore, OWN_UI_ATTR, PICKING_MODE_KEY, type PickingListener, type PickingQueueLike, type PickingState, SELECTOR_MAX_DEPTH, type StorageLike, type StoredMarker, type WebWidget, type WebWidgetOpts, captureWebScreenshot, createWebStorage, createWebWidget, cssSelectorPath, describeElement, isOwnUi, reencodeWebScreenshot, visibleText, webContextProviders };
|
|
248
|
+
export { type AnnotationPopupState, ELEMENT_TEXT_MAX_CHARS, ElementPickingController, type ElementPickingOpts, FeedbackKit, type FeedbackKitProps, MARKER_KEY_PREFIX, MAX_MARKERS_PER_PATH, type MarkerStatus, MarkerStore, OWN_UI_ATTR, PICKING_MODE_KEY, type PickingListener, type PickingQueueLike, type PickingState, SELECTOR_MAX_DEPTH, type StorageLike, type StoredMarker, type WebWidget, type WebWidgetOpts, captureWebScreenshot, createWebStorage, createWebWidget, cssSelectorPath, describeElement, isOwnUi, reactComponentPath, reactComponentSummary, reencodeWebScreenshot, visibleText, webContextProviders };
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,48 @@ import {
|
|
|
16
16
|
WidgetController as WidgetController2
|
|
17
17
|
} from "@solhun/feedback-kit-core";
|
|
18
18
|
|
|
19
|
+
// src/react-tree.ts
|
|
20
|
+
function componentName(type) {
|
|
21
|
+
if (typeof type === "function") {
|
|
22
|
+
const fn = type;
|
|
23
|
+
return fn.displayName || fn.name || null;
|
|
24
|
+
}
|
|
25
|
+
if (type !== null && typeof type === "object") {
|
|
26
|
+
const wrapped = type;
|
|
27
|
+
if (wrapped.displayName) return wrapped.displayName;
|
|
28
|
+
if (wrapped.render) return componentName(wrapped.render);
|
|
29
|
+
if (wrapped.type) return componentName(wrapped.type);
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function fiberOf(node) {
|
|
34
|
+
for (const key of Object.getOwnPropertyNames(node)) {
|
|
35
|
+
if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
|
|
36
|
+
const value = node[key];
|
|
37
|
+
if (value !== null && typeof value === "object") return value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function reactComponentPath(node, limit = 20) {
|
|
43
|
+
try {
|
|
44
|
+
let fiber = fiberOf(node);
|
|
45
|
+
const names = [];
|
|
46
|
+
while (fiber && names.length < limit) {
|
|
47
|
+
const name = componentName(fiber.elementType ?? fiber.type);
|
|
48
|
+
if (name && names[0] !== name) names.unshift(name);
|
|
49
|
+
fiber = fiber.return ?? null;
|
|
50
|
+
}
|
|
51
|
+
return names;
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function reactComponentSummary(node) {
|
|
57
|
+
const path = reactComponentPath(node);
|
|
58
|
+
return path.length > 0 ? path.join(" > ") : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
19
61
|
// src/element-info.ts
|
|
20
62
|
var ELEMENT_TEXT_MAX_CHARS = 200;
|
|
21
63
|
var SELECTOR_MAX_DEPTH = 8;
|
|
@@ -105,13 +147,20 @@ function describeElement(el) {
|
|
|
105
147
|
} catch {
|
|
106
148
|
boundingBox = null;
|
|
107
149
|
}
|
|
150
|
+
const react = reactComponentSummary(el);
|
|
108
151
|
return {
|
|
109
152
|
tag: el.tagName.toLowerCase(),
|
|
110
153
|
id: el.getAttribute("id"),
|
|
111
154
|
className: el.getAttribute("class"),
|
|
112
155
|
text: visibleText(el),
|
|
113
156
|
boundingBox,
|
|
114
|
-
attributes
|
|
157
|
+
attributes: {
|
|
158
|
+
...attributes,
|
|
159
|
+
// 지목한 요소가 어느 컴포넌트에 속하는지. `div.flex.items-center` 만 오면
|
|
160
|
+
// 고치는 사람이 코드에서 다시 찾아야 하지만, `LoginForm > SubmitButton` 이
|
|
161
|
+
// 오면 바로 파일이 열린다. React 가 아니면 이 키 자체가 없다.
|
|
162
|
+
...react ? { "react-components": react } : {}
|
|
163
|
+
},
|
|
115
164
|
selector: cssSelectorPath(el)
|
|
116
165
|
};
|
|
117
166
|
}
|
|
@@ -239,6 +288,7 @@ var ElementPickingController = class {
|
|
|
239
288
|
this.queue = opts.queue;
|
|
240
289
|
this.createReport = opts.createReport;
|
|
241
290
|
this.store = opts.store ?? new MarkerStore();
|
|
291
|
+
this.onPick = opts.onPick ?? null;
|
|
242
292
|
this.getPathname = opts.getPathname ?? defaultPathname;
|
|
243
293
|
this.doc = opts.doc === void 0 ? defaultDocument() : opts.doc;
|
|
244
294
|
this.getViewport = opts.getViewport ?? defaultViewport;
|
|
@@ -352,6 +402,10 @@ var ElementPickingController = class {
|
|
|
352
402
|
/** 클릭 지점 기준으로 주석 팝업을 연다. 좌표는 해상도 무관한 상대값으로 접어 둔다. */
|
|
353
403
|
openAnnotation(element, clientPoint) {
|
|
354
404
|
const point = normalizePin(clientPoint, this.getViewport());
|
|
405
|
+
if (this.onPick) {
|
|
406
|
+
this.onPick(describeElement(element), point);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
355
409
|
this.popup = {
|
|
356
410
|
element: describeElement(element),
|
|
357
411
|
point,
|
|
@@ -374,6 +428,26 @@ var ElementPickingController = class {
|
|
|
374
428
|
this.popup = null;
|
|
375
429
|
this.emit();
|
|
376
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* 제출된 제보를 마커로 남긴다. 모달 경로에서 쓴다 — 마커 생성이 팝업 저장 안에만
|
|
433
|
+
* 있으면, 리포트 모달로 보낸 지목 제보는 화면에 흔적이 남지 않는다("보냈는지 알 수
|
|
434
|
+
* 없다"가 이 모드가 없애려던 불편이다).
|
|
435
|
+
*/
|
|
436
|
+
noteSubmitted(report, delivered) {
|
|
437
|
+
if (!report.element && !report.pin) return;
|
|
438
|
+
const pathname = this.getPathname();
|
|
439
|
+
const marker = {
|
|
440
|
+
id: report.clientSubmissionId,
|
|
441
|
+
x: report.pin?.x ?? 0,
|
|
442
|
+
y: report.pin?.y ?? 0,
|
|
443
|
+
selector: report.element?.selector ?? null,
|
|
444
|
+
comment: report.comment,
|
|
445
|
+
status: delivered ? "done" : "pending",
|
|
446
|
+
at: report.createdAt
|
|
447
|
+
};
|
|
448
|
+
this.markers = this.store.upsert(pathname, marker);
|
|
449
|
+
this.emit();
|
|
450
|
+
}
|
|
377
451
|
/**
|
|
378
452
|
* 주석 저장. 마커를 먼저 "전송 중"으로 찍고 나서 보낸다 — 사용자는 결과를 기다리지 않고
|
|
379
453
|
* 다음 요소로 넘어갈 수 있어야 한다.
|
|
@@ -471,6 +545,30 @@ function payload(dataUrl) {
|
|
|
471
545
|
function isOwnUi2(node) {
|
|
472
546
|
return node.hasAttribute?.(OWN_UI_ATTR) === true || node.closest?.(`[${OWN_UI_ATTR}]`) !== null;
|
|
473
547
|
}
|
|
548
|
+
function freezeCanvases(source, clone) {
|
|
549
|
+
const originals = source.querySelectorAll("canvas");
|
|
550
|
+
const clones = clone.querySelectorAll("canvas");
|
|
551
|
+
for (let index = 0; index < originals.length && index < clones.length; index++) {
|
|
552
|
+
const from = originals[index];
|
|
553
|
+
const to = clones[index];
|
|
554
|
+
let dataUrl;
|
|
555
|
+
try {
|
|
556
|
+
dataUrl = from.toDataURL("image/png");
|
|
557
|
+
} catch {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (!dataUrl.startsWith("data:image") || dataUrl.length < 64) continue;
|
|
561
|
+
const box = from.getBoundingClientRect();
|
|
562
|
+
if (box.width < 1 || box.height < 1) continue;
|
|
563
|
+
const image = clone.createElement("img");
|
|
564
|
+
image.src = dataUrl;
|
|
565
|
+
image.setAttribute("style", to.getAttribute("style") ?? "");
|
|
566
|
+
image.style.width = `${box.width}px`;
|
|
567
|
+
image.style.height = `${box.height}px`;
|
|
568
|
+
image.className = to.className;
|
|
569
|
+
to.replaceWith(image);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
474
572
|
var captureWebScreenshot = async () => {
|
|
475
573
|
const doc = globalThis.document;
|
|
476
574
|
const view = doc?.defaultView;
|
|
@@ -488,6 +586,8 @@ var captureWebScreenshot = async () => {
|
|
|
488
586
|
scale: Math.min(2, Math.max(1, view.devicePixelRatio || 1)),
|
|
489
587
|
// 위젯 자신의 UI 는 결과에서 뺀다.
|
|
490
588
|
ignoreElements: isOwnUi2,
|
|
589
|
+
// 캔버스(Rive·차트 등)는 이미지로 바꿔 넘긴다 — freezeCanvases 주석 참고.
|
|
590
|
+
onclone: (cloned) => freezeCanvases(doc, cloned),
|
|
491
591
|
// 외부 이미지가 CORS 를 안 열어두면 그것 때문에 전체가 실패할 수 있다.
|
|
492
592
|
// 못 가져오는 리소스는 건너뛰고 나머지를 그린다.
|
|
493
593
|
useCORS: true,
|
|
@@ -528,13 +628,21 @@ var reencodeWebScreenshot = async (shot, quality) => {
|
|
|
528
628
|
import { WidgetController } from "@solhun/feedback-kit-core";
|
|
529
629
|
function createWebWidget(opts) {
|
|
530
630
|
const store = opts.store ?? new MarkerStore();
|
|
631
|
+
let widgetRef = null;
|
|
531
632
|
const picking = new ElementPickingController({
|
|
532
633
|
queue: opts.queue,
|
|
533
634
|
createReport: opts.createReport,
|
|
534
635
|
store,
|
|
535
636
|
getPathname: opts.getPathname,
|
|
536
637
|
doc: opts.doc,
|
|
537
|
-
getViewport: opts.getViewport
|
|
638
|
+
getViewport: opts.getViewport,
|
|
639
|
+
// 요소를 고르면 인라인 팝업이 아니라 **리포트 모달**을 연다.
|
|
640
|
+
// 코멘트만 받던 팝업과 달리 스크린샷·우선순위까지 같은 화면에서 받는다.
|
|
641
|
+
// 모드는 켜진 채로 둬서 한 번 켜고 여러 요소를 연달아 지목할 수 있다.
|
|
642
|
+
onPick: (element, point) => {
|
|
643
|
+
void widgetRef?.openReport({ element });
|
|
644
|
+
widgetRef?.modal.setPin(point);
|
|
645
|
+
}
|
|
538
646
|
});
|
|
539
647
|
const widget = new WidgetController({
|
|
540
648
|
...opts,
|
|
@@ -548,6 +656,14 @@ function createWebWidget(opts) {
|
|
|
548
656
|
else picking.stop();
|
|
549
657
|
}
|
|
550
658
|
});
|
|
659
|
+
widgetRef = widget;
|
|
660
|
+
const originalSubmit = widget.submitReport.bind(widget);
|
|
661
|
+
widget.submitReport = async () => {
|
|
662
|
+
const outcome = await originalSubmit();
|
|
663
|
+
const report = widget.modal.getLastReport();
|
|
664
|
+
if (report?.element) picking.noteSubmitted(report, outcome?.delivered === true);
|
|
665
|
+
return outcome;
|
|
666
|
+
};
|
|
551
667
|
picking.restore();
|
|
552
668
|
return {
|
|
553
669
|
widget,
|
|
@@ -1364,6 +1480,8 @@ export {
|
|
|
1364
1480
|
isOwnUi2 as isOwnUi,
|
|
1365
1481
|
normalizePin2 as normalizePin,
|
|
1366
1482
|
parseSourceAttr,
|
|
1483
|
+
reactComponentPath,
|
|
1484
|
+
reactComponentSummary,
|
|
1367
1485
|
reencodeWebScreenshot,
|
|
1368
1486
|
resolveConfig,
|
|
1369
1487
|
shouldShowWidget,
|