@solhun/feedback-kit-web 0.1.0 → 0.2.0

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
@@ -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,