@solhun/feedback-kit-web 0.7.0 → 0.8.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.cjs CHANGED
@@ -53,6 +53,7 @@ __export(index_exports, {
53
53
  SUBMIT_PENDING_MESSAGE: () => import_feedback_kit_core6.SUBMIT_PENDING_MESSAGE,
54
54
  WidgetController: () => import_feedback_kit_core6.WidgetController,
55
55
  captureWebScreenshot: () => captureWebScreenshot,
56
+ createRouteWatcher: () => createRouteWatcher,
56
57
  createWebStorage: () => createWebStorage,
57
58
  createWebWidget: () => createWebWidget,
58
59
  cssSelectorPath: () => cssSelectorPath,
@@ -69,7 +70,10 @@ __export(index_exports, {
69
70
  shouldShowWidget: () => import_feedback_kit_core4.shouldShowWidget,
70
71
  sourceFromElement: () => import_feedback_kit_core5.sourceFromElement,
71
72
  visibleText: () => visibleText,
72
- webContextProviders: () => webContextProviders
73
+ webContextProviders: () => webContextProviders,
74
+ webDiagnosticsOptions: () => webDiagnosticsOptions,
75
+ webRouteStorage: () => webRouteStorage,
76
+ webRouteWatcher: () => webRouteWatcher
73
77
  });
74
78
  module.exports = __toCommonJS(index_exports);
75
79
  var import_feedback_kit_core4 = require("@solhun/feedback-kit-core");
@@ -162,6 +166,7 @@ function reactComponentSummary(node) {
162
166
  // src/element-info.ts
163
167
  var ELEMENT_TEXT_MAX_CHARS = 200;
164
168
  var SELECTOR_MAX_DEPTH = 8;
169
+ var PICK_TARGET_SNAP_PX = 24;
165
170
  var KEPT_ATTRS = [
166
171
  "id",
167
172
  "class",
@@ -234,6 +239,55 @@ function visibleText(el) {
234
239
  if (collapsed === "") return null;
235
240
  return collapsed.length > ELEMENT_TEXT_MAX_CHARS ? collapsed.slice(0, ELEMENT_TEXT_MAX_CHARS) : collapsed;
236
241
  }
242
+ function elementRect(element) {
243
+ try {
244
+ const rect = element.getBoundingClientRect();
245
+ const left = rect.left;
246
+ const top = rect.top;
247
+ const width = rect.width;
248
+ const height = rect.height;
249
+ if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
250
+ return null;
251
+ }
252
+ return {
253
+ element,
254
+ left,
255
+ top,
256
+ right: left + width,
257
+ bottom: top + height,
258
+ width,
259
+ height
260
+ };
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+ function distanceFromPoint(rect, point) {
266
+ const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
267
+ const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
268
+ return Math.hypot(dx, dy);
269
+ }
270
+ function resolvePickTarget(target, point) {
271
+ if (!target || typeof target.tagName !== "string") return null;
272
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return target;
273
+ let current = target;
274
+ while (true) {
275
+ const currentRect = elementRect(current);
276
+ const children = Array.from(current.children).map(elementRect).filter((rect) => rect !== null).map((rect) => ({ rect, distance: distanceFromPoint(rect, point) }));
277
+ if (children.length === 0) return current;
278
+ const containing = children.filter(({ distance }) => distance === 0).sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
279
+ if (containing[0]) {
280
+ current = containing[0].rect.element;
281
+ continue;
282
+ }
283
+ const nearest = children.sort((a, b) => a.distance - b.distance)[0];
284
+ const isMorePrecise = currentRect ? nearest.rect.width * nearest.rect.height < currentRect.width * currentRect.height : true;
285
+ if (nearest.distance <= PICK_TARGET_SNAP_PX && isMorePrecise) {
286
+ return nearest.rect.element;
287
+ }
288
+ return current;
289
+ }
290
+ }
237
291
  function describeElement(el) {
238
292
  if (!el || typeof el.tagName !== "string") return null;
239
293
  const attributes = {};
@@ -354,6 +408,84 @@ var MarkerStore = class {
354
408
 
355
409
  // src/picking.ts
356
410
  var import_feedback_kit_core = require("@solhun/feedback-kit-core");
411
+
412
+ // src/route-watcher.ts
413
+ function defaultTarget() {
414
+ const g = globalThis;
415
+ return g.location ? g : null;
416
+ }
417
+ function createRouteWatcher(target = defaultTarget()) {
418
+ const listeners = /* @__PURE__ */ new Set();
419
+ let detach = null;
420
+ const currentPath = () => {
421
+ const p = target?.location?.pathname;
422
+ return typeof p === "string" && p.length > 0 ? p : "/";
423
+ };
424
+ const emit = () => {
425
+ const path = currentPath();
426
+ for (const listener of [...listeners]) {
427
+ try {
428
+ listener(path);
429
+ } catch {
430
+ }
431
+ }
432
+ };
433
+ const attach = () => {
434
+ if (detach !== null || !target) return;
435
+ const onPop = () => emit();
436
+ target.addEventListener?.("popstate", onPop);
437
+ const history = target.history;
438
+ const originalPush = typeof history?.pushState === "function" ? history.pushState : null;
439
+ const originalReplace = typeof history?.replaceState === "function" ? history.replaceState : null;
440
+ if (history && originalPush) {
441
+ history.pushState = function patched(...args) {
442
+ const out = originalPush.apply(this, args);
443
+ emit();
444
+ return out;
445
+ };
446
+ }
447
+ if (history && originalReplace) {
448
+ history.replaceState = function patched(...args) {
449
+ const out = originalReplace.apply(this, args);
450
+ emit();
451
+ return out;
452
+ };
453
+ }
454
+ detach = () => {
455
+ target.removeEventListener?.("popstate", onPop);
456
+ if (history && originalPush) history.pushState = originalPush;
457
+ if (history && originalReplace) history.replaceState = originalReplace;
458
+ };
459
+ };
460
+ return (onPath) => {
461
+ listeners.add(onPath);
462
+ if (listeners.size === 1) attach();
463
+ try {
464
+ onPath(currentPath());
465
+ } catch {
466
+ }
467
+ return () => {
468
+ listeners.delete(onPath);
469
+ if (listeners.size === 0 && detach) {
470
+ detach();
471
+ detach = null;
472
+ }
473
+ };
474
+ };
475
+ }
476
+ var webRouteWatcher = createRouteWatcher();
477
+ function webRouteStorage() {
478
+ try {
479
+ const s = globalThis.sessionStorage;
480
+ if (!s || typeof s.getItem !== "function" || typeof s.setItem !== "function") return null;
481
+ s.getItem("feedback-kit:probe");
482
+ return s;
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
487
+
488
+ // src/picking.ts
357
489
  var OWN_UI_ATTR = "data-feedback-kit";
358
490
  function defaultDocument() {
359
491
  return globalThis.document ?? null;
@@ -382,10 +514,13 @@ var ElementPickingController = class {
382
514
  */
383
515
  this.paused = false;
384
516
  this.attached = false;
517
+ /** 같은 요소 위 mousemove 마다 React 경로·선택자를 다시 만들지 않기 위한 캐시. */
518
+ this.hoveredTarget = null;
385
519
  this.hovered = null;
386
520
  this.popup = null;
387
521
  this.markers = [];
388
- this.pathWatch = null;
522
+ /** 경로 감시 해제. 감시 중이 아니면 null. */
523
+ this.unwatchRoutes = null;
389
524
  this.saving = false;
390
525
  /** 늦게 끝난 캡처가 다음 주석의 그림을 덮지 못하게 하는 세대 번호. */
391
526
  this.shotGeneration = 0;
@@ -398,6 +533,7 @@ var ElementPickingController = class {
398
533
  this.userToggledHints = false;
399
534
  this.onClick = (event) => this.handleClick(event);
400
535
  this.onMouseOver = (event) => this.handleMouseOver(event);
536
+ this.onMouseMove = (event) => this.handleMouseOver(event);
401
537
  this.queue = opts.queue;
402
538
  this.createReport = opts.createReport;
403
539
  this.store = opts.store ?? new MarkerStore();
@@ -409,6 +545,7 @@ var ElementPickingController = class {
409
545
  this.reencode = opts.reencode ?? null;
410
546
  this.screenshotLimitBytes = opts.screenshotLimitBytes;
411
547
  this.rankHintsFor = opts.rankHintsFor ?? null;
548
+ this.watchRoutes = opts.watchRoutes === void 0 ? webRouteWatcher : opts.watchRoutes;
412
549
  this.lastPathname = this.getPathname();
413
550
  this.markers = this.store.list(this.lastPathname);
414
551
  this.unsubscribeQueue = this.queue.subscribe?.(() => {
@@ -457,6 +594,7 @@ var ElementPickingController = class {
457
594
  this.store.setPickingActive(false);
458
595
  this.detach();
459
596
  this.stopPathWatch();
597
+ this.hoveredTarget = null;
460
598
  this.hovered = null;
461
599
  this.popup = null;
462
600
  this.emit();
@@ -472,6 +610,7 @@ var ElementPickingController = class {
472
610
  if (!this.active || this.paused) return;
473
611
  this.paused = true;
474
612
  this.detach();
613
+ this.hoveredTarget = null;
475
614
  this.hovered = null;
476
615
  this.emit();
477
616
  }
@@ -499,6 +638,22 @@ var ElementPickingController = class {
499
638
  this.start();
500
639
  return true;
501
640
  }
641
+ /**
642
+ * 이 화면에 쌓인 마커 표시를 지운다.
643
+ *
644
+ * 지워지는 건 **화면 표시뿐**이다. 제보는 이미 큐를 거쳐 수집처로 갔으므로 없어지지 않는다.
645
+ * 마커는 `localStorage` 에 남아 새로고침에도 살아남는데(그게 원래 목적이다) 정작 치울
646
+ * 수단이 없어서, 한 번 보낸 [완료] 배지가 그 화면을 볼 때마다 계속 따라다녔다.
647
+ *
648
+ * 화면에 보이는 목록의 기준은 `lastPathname` 이다(`reconcileMarkerOutcomes` 와 같다).
649
+ * 이동 직후 아직 `syncPath` 가 안 돈 순간에도 "지금 눈에 보이는 것"이 지워져야 한다.
650
+ */
651
+ clearMarkers() {
652
+ if (this.markers.length === 0) return;
653
+ this.store.clear(this.lastPathname);
654
+ this.markers = [];
655
+ this.emit();
656
+ }
502
657
  /** 경로가 바뀌었을 때 그 경로의 마커로 갈아 끼운다. */
503
658
  syncPath() {
504
659
  if (this.paused) this.resume();
@@ -506,6 +661,7 @@ var ElementPickingController = class {
506
661
  this.markers = this.store.list(this.lastPathname);
507
662
  this.reconcileMarkerOutcomes();
508
663
  this.popup = null;
664
+ this.hoveredTarget = null;
509
665
  this.hovered = null;
510
666
  this.emit();
511
667
  return this.markers;
@@ -521,17 +677,26 @@ var ElementPickingController = class {
521
677
  if (this.attached || !this.doc) return;
522
678
  this.doc.addEventListener("click", this.onClick, true);
523
679
  this.doc.addEventListener("mouseover", this.onMouseOver, true);
680
+ this.doc.addEventListener("mousemove", this.onMouseMove, true);
524
681
  this.attached = true;
525
682
  }
526
683
  detach() {
527
684
  if (!this.attached || !this.doc) return;
528
685
  this.doc.removeEventListener("click", this.onClick, true);
529
686
  this.doc.removeEventListener("mouseover", this.onMouseOver, true);
687
+ this.doc.removeEventListener("mousemove", this.onMouseMove, true);
530
688
  this.attached = false;
531
689
  }
532
690
  handleMouseOver(event) {
533
691
  if (!this.active || this.paused || isOwnUi(event.target)) return;
534
- this.hovered = describeElement(event.target);
692
+ const mouse = event;
693
+ const target = resolvePickTarget(event.target, {
694
+ x: mouse.clientX ?? 0,
695
+ y: mouse.clientY ?? 0
696
+ });
697
+ if (target === this.hoveredTarget) return;
698
+ this.hoveredTarget = target;
699
+ this.hovered = describeElement(target);
535
700
  this.emit();
536
701
  }
537
702
  handleClick(event) {
@@ -552,11 +717,12 @@ var ElementPickingController = class {
552
717
  /** 클릭 지점 기준으로 주석 팝업을 연다. 좌표는 해상도 무관한 상대값으로 접어 둔다. */
553
718
  openAnnotation(element, clientPoint) {
554
719
  const point = (0, import_feedback_kit_core.normalizePin)(clientPoint, this.getViewport());
720
+ const target = resolvePickTarget(element, clientPoint);
555
721
  if (this.onPick) {
556
- this.onPick(describeElement(element), point);
722
+ this.onPick(describeElement(target), point);
557
723
  return;
558
724
  }
559
- const info = describeElement(element);
725
+ const info = describeElement(target);
560
726
  this.userToggledHints = false;
561
727
  const hints = this.rankHintsFor ? this.rankHintsFor(info) : [];
562
728
  this.popup = {
@@ -771,17 +937,17 @@ ${hint.draft}`;
771
937
  return outcome;
772
938
  }
773
939
  startPathWatch() {
774
- if (this.pathWatch !== null || typeof setInterval !== "function") return;
940
+ if (this.unwatchRoutes !== null || !this.watchRoutes) return;
775
941
  this.lastPathname = this.getPathname();
776
- this.pathWatch = setInterval(() => {
942
+ this.unwatchRoutes = this.watchRoutes(() => {
777
943
  const pathname = this.getPathname();
778
944
  if (pathname !== this.lastPathname) this.syncPath();
779
- }, 200);
945
+ });
780
946
  }
781
947
  stopPathWatch() {
782
- if (this.pathWatch === null) return;
783
- clearInterval(this.pathWatch);
784
- this.pathWatch = null;
948
+ if (this.unwatchRoutes === null) return;
949
+ this.unwatchRoutes();
950
+ this.unwatchRoutes = null;
785
951
  }
786
952
  /** 전역 pending 수가 아니라 마커와 같은 clientSubmissionId의 확정 성공만 완료로 바꾼다. */
787
953
  reconcileMarkerOutcomes() {
@@ -1048,7 +1214,7 @@ var import_feedback_kit_core3 = require("@solhun/feedback-kit-core");
1048
1214
  var import_react = require("react");
1049
1215
 
1050
1216
  // src/version.ts
1051
- var VERSION = true ? "0.7.0" : "dev";
1217
+ var VERSION = true ? "0.8.0" : "dev";
1052
1218
 
1053
1219
  // src/feedback-kit.tsx
1054
1220
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -1314,8 +1480,10 @@ function PickToggle({
1314
1480
  }
1315
1481
  function PickBar({
1316
1482
  paused,
1483
+ markerCount,
1317
1484
  onToggle,
1318
- onTogglePause
1485
+ onTogglePause,
1486
+ onClearMarkers
1319
1487
  }) {
1320
1488
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1321
1489
  "div",
@@ -1323,10 +1491,20 @@ function PickBar({
1323
1491
  style: {
1324
1492
  position: "fixed",
1325
1493
  top: 16,
1326
- left: "50%",
1327
- transform: "translateX(-50%)",
1494
+ // `left:50% + translateX(-50%)` 로 가운데를 잡으면 **쓸 수 있는 폭이 화면의 절반**이
1495
+ // 된다(transform 은 레이아웃 계산에 안 들어간다). 버튼이 셋이 되면서 그 절반에
1496
+ // 안 맞아 좁은 화면에서 버튼이 한 줄에 하나씩 세로로 쌓였다(390px 에서 3줄).
1497
+ // 양끝을 물리고 auto 마진으로 가운데를 잡으면 화면 전체 폭을 쓴다 — 같은 화면이 2줄.
1498
+ left: 0,
1499
+ right: 0,
1500
+ marginInline: "auto",
1501
+ width: "fit-content",
1502
+ maxWidth: "calc(100% - 32px)",
1328
1503
  display: "flex",
1329
1504
  alignItems: "stretch",
1505
+ justifyContent: "center",
1506
+ // 셋이 한 줄에 안 들어가는 폭에서는 줄을 바꾼다 — 안 그러면 화면 밖으로 나간다.
1507
+ flexWrap: "wrap",
1330
1508
  gap: 8,
1331
1509
  // 오버레이 자체는 클릭을 통과시킨다(pointerEvents:none) — 이 막대만 되살린다.
1332
1510
  pointerEvents: "auto",
@@ -1359,7 +1537,30 @@ function PickBar({
1359
1537
  paused ? "\uC9C0\uBAA9 \uC7AC\uAC1C" : "\uC7A0\uC2DC \uBA48\uCDA4"
1360
1538
  ]
1361
1539
  }
1362
- )
1540
+ ),
1541
+ markerCount > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1542
+ "button",
1543
+ {
1544
+ type: "button",
1545
+ "data-fk-pick-clear": markerCount,
1546
+ title: "\uD654\uBA74\uC758 \uD45C\uC2DC\uB9CC \uC9C0\uC6C1\uB2C8\uB2E4. \uBCF4\uB0B8 \uC81C\uBCF4\uB294 \uADF8\uB300\uB85C \uB0A8\uC2B5\uB2C8\uB2E4.",
1547
+ "aria-label": `\uC774 \uD654\uBA74\uC758 \uC8FC\uC11D \uD45C\uC2DC ${markerCount}\uAC1C \uC9C0\uC6B0\uAE30 (\uBCF4\uB0B8 \uC81C\uBCF4\uB294 \uADF8\uB300\uB85C \uB0A8\uC2B5\uB2C8\uB2E4)`,
1548
+ onClick: onClearMarkers,
1549
+ style: {
1550
+ ...baseButton,
1551
+ display: "inline-flex",
1552
+ alignItems: "center",
1553
+ gap: 6,
1554
+ whiteSpace: "nowrap",
1555
+ color: TOKENS.muted
1556
+ },
1557
+ children: [
1558
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: "\u2715" }),
1559
+ "\uD45C\uC2DC \uC9C0\uC6B0\uAE30 ",
1560
+ markerCount
1561
+ ]
1562
+ }
1563
+ ) : null
1363
1564
  ]
1364
1565
  }
1365
1566
  );
@@ -2024,8 +2225,10 @@ function FeedbackKit(props) {
2024
2225
  PickBar,
2025
2226
  {
2026
2227
  paused: pickingState.paused,
2228
+ markerCount: pickingState.markers.length,
2027
2229
  onToggle: togglePicking,
2028
- onTogglePause: () => activeKit.picking.togglePause()
2230
+ onTogglePause: () => activeKit.picking.togglePause(),
2231
+ onClearMarkers: () => activeKit.picking.clearMarkers()
2029
2232
  }
2030
2233
  ),
2031
2234
  pickingState.markers.map((marker, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -2251,6 +2454,13 @@ function webContextProviders() {
2251
2454
  }
2252
2455
  };
2253
2456
  }
2457
+ function webDiagnosticsOptions(opts = {}) {
2458
+ return {
2459
+ watchRoutes: webRouteWatcher,
2460
+ routeStorage: webRouteStorage(),
2461
+ ...opts
2462
+ };
2463
+ }
2254
2464
  // Annotate the CommonJS export names for ESM import in node:
2255
2465
  0 && (module.exports = {
2256
2466
  COMMENT_MAX_CHARS,
@@ -2276,6 +2486,7 @@ function webContextProviders() {
2276
2486
  SUBMIT_PENDING_MESSAGE,
2277
2487
  WidgetController,
2278
2488
  captureWebScreenshot,
2489
+ createRouteWatcher,
2279
2490
  createWebStorage,
2280
2491
  createWebWidget,
2281
2492
  cssSelectorPath,
@@ -2292,6 +2503,9 @@ function webContextProviders() {
2292
2503
  shouldShowWidget,
2293
2504
  sourceFromElement,
2294
2505
  visibleText,
2295
- webContextProviders
2506
+ webContextProviders,
2507
+ webDiagnosticsOptions,
2508
+ webRouteStorage,
2509
+ webRouteWatcher
2296
2510
  });
2297
2511
  //# sourceMappingURL=index.cjs.map