@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.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { ElementInfo, FeedbackPin, FeedbackScreenshot, FeedbackHint, FeedbackReport, SubmitOutcome, QueueStatus, ReportParts, ScreenshotCapture, ScreenshotReencode, WidgetControllerOpts, HintProvider, WidgetController, FeedbackStorage, ContextProviders } from '@solhun/feedback-kit-core';
1
+ import { ElementInfo, FeedbackPin, FeedbackScreenshot, FeedbackHint, FeedbackReport, SubmitOutcome, QueueStatus, ReportParts, ScreenshotCapture, ScreenshotReencode, RouteWatcher, WidgetControllerOpts, HintProvider, WidgetController, FeedbackStorage, ContextProviders, DiagnosticsInstallOpts, RouteTrailStore } from '@solhun/feedback-kit-core';
2
2
  export { COMMENT_MAX_CHARS, COMMENT_REQUIRED_MESSAGE, COMMENT_TOO_LONG_MESSAGE, ElementInfo, ElementLike, FLOATING_BUTTON_ID, FeedbackAdapter, FeedbackConfig, FeedbackContext, FeedbackHint, FeedbackPin, FeedbackReport, FeedbackStorage, FeedbackUser, HintCatalog, HintElementRule, HintPlatform, HintProvider, HintRankInput, HintScope, MODAL_ACTION_HINT_TOGGLE, MODAL_ACTION_PICK, ModalSubmitStatus, ReportModalController, ReportModalState, ResolvedConfig, SCREENSHOT_FAILED_MESSAGE, SOURCE_ATTR, SUBMIT_DONE_MESSAGE, SUBMIT_PENDING_MESSAGE, ScreenshotStatus, SubmitResult, Visibility, VisibilityEnv, VisibilityFn, WidgetController, WidgetPosition, WidgetScreen, WidgetState, denormalizePin, normalizePin, parseSourceAttr, rankHints, resolveConfig, shouldShowWidget, sourceFromElement } from '@solhun/feedback-kit-core';
3
3
  import * as react from 'react';
4
4
 
@@ -163,6 +163,12 @@ interface ElementPickingOpts {
163
163
  * `rankHints` 한 결과를 돌려준다. 없으면(힌트 기능이 꺼져 있으면) 팝업에 칩이 안 뜬다.
164
164
  */
165
165
  rankHintsFor?: ((element: ElementInfo | null) => readonly FeedbackHint[]) | null;
166
+ /**
167
+ * 화면 이동 감시자. 기본은 웹 공용 감시자(`webRouteWatcher`) — **진단의 화면 이동 궤적과
168
+ * 같은 것**이다. 여기만 따로 구현하면 한쪽이 이동을 놓쳤을 때 원인을 가릴 수 없다.
169
+ * `null` 을 주면 감시하지 않는다(테스트에서 `syncPath()` 를 직접 부를 때).
170
+ */
171
+ watchRoutes?: RouteWatcher | null;
166
172
  }
167
173
  declare class ElementPickingController {
168
174
  private readonly queue;
@@ -186,10 +192,14 @@ declare class ElementPickingController {
186
192
  */
187
193
  private paused;
188
194
  private attached;
195
+ /** 같은 요소 위 mousemove 마다 React 경로·선택자를 다시 만들지 않기 위한 캐시. */
196
+ private hoveredTarget;
189
197
  private hovered;
190
198
  private popup;
191
199
  private markers;
192
- private pathWatch;
200
+ private readonly watchRoutes;
201
+ /** 경로 감시 해제. 감시 중이 아니면 null. */
202
+ private unwatchRoutes;
193
203
  private lastPathname;
194
204
  private saving;
195
205
  /** 늦게 끝난 캡처가 다음 주석의 그림을 덮지 못하게 하는 세대 번호. */
@@ -203,6 +213,7 @@ declare class ElementPickingController {
203
213
  private userToggledHints;
204
214
  private readonly onClick;
205
215
  private readonly onMouseOver;
216
+ private readonly onMouseMove;
206
217
  constructor(opts: ElementPickingOpts);
207
218
  getState(): PickingState;
208
219
  get isActive(): boolean;
@@ -228,6 +239,17 @@ declare class ElementPickingController {
228
239
  * @returns 되살아났으면 true.
229
240
  */
230
241
  restore(): boolean;
242
+ /**
243
+ * 이 화면에 쌓인 마커 표시를 지운다.
244
+ *
245
+ * 지워지는 건 **화면 표시뿐**이다. 제보는 이미 큐를 거쳐 수집처로 갔으므로 없어지지 않는다.
246
+ * 마커는 `localStorage` 에 남아 새로고침에도 살아남는데(그게 원래 목적이다) 정작 치울
247
+ * 수단이 없어서, 한 번 보낸 [완료] 배지가 그 화면을 볼 때마다 계속 따라다녔다.
248
+ *
249
+ * 화면에 보이는 목록의 기준은 `lastPathname` 이다(`reconcileMarkerOutcomes` 와 같다).
250
+ * 이동 직후 아직 `syncPath` 가 안 돈 순간에도 "지금 눈에 보이는 것"이 지워져야 한다.
251
+ */
252
+ clearMarkers(): void;
231
253
  /** 경로가 바뀌었을 때 그 경로의 마커로 갈아 끼운다. */
232
254
  syncPath(): readonly StoredMarker[];
233
255
  dispose(): void;
@@ -375,5 +397,58 @@ declare function createWebStorage(backing?: SyncStorage): FeedbackStorage;
375
397
  * 서버 렌더 단계에서 실행될 수 있기 때문이다.
376
398
  */
377
399
  declare function webContextProviders(): ContextProviders;
400
+ /**
401
+ * 웹 진단 수집의 기본 배선.
402
+ *
403
+ * `sharedDiagnostics.install(webDiagnosticsOptions({ ... }))` 로 쓴다. 넘긴 값이 기본값을
404
+ * 덮으므로 감시자·저장소를 직접 갈아 끼울 수도 있다.
405
+ *
406
+ * 왜 패키지가 주나: 감시자를 호스트가 깜빡하면 **궤적만 조용히 빈다.** 네트워크·로그는
407
+ * 그대로 모이니 화면상 아무 문제가 없어 보이고, "이 제보엔 이동이 없었나 보다"로 읽힌다 —
408
+ * 빠진 것과 없는 것이 구분되지 않는 실패다.
409
+ *
410
+ * 진단을 끄면(`captureDiagnostics: false`) install 자체가 안 불리므로 궤적도 같이 꺼진다.
411
+ * 궤적만 따로 켜고 끄는 스위치는 만들지 않는다.
412
+ */
413
+ declare function webDiagnosticsOptions(opts?: DiagnosticsInstallOpts): DiagnosticsInstallOpts;
414
+
415
+ /** 감시자가 들여다보는 전역. 테스트에서는 가짜를 넣는다. */
416
+ interface RouteWatchTarget {
417
+ location?: {
418
+ pathname?: string;
419
+ } | null;
420
+ history?: {
421
+ pushState?: (...args: unknown[]) => unknown;
422
+ replaceState?: (...args: unknown[]) => unknown;
423
+ } | null;
424
+ addEventListener?: (type: string, listener: () => void) => void;
425
+ removeEventListener?: (type: string, listener: () => void) => void;
426
+ }
427
+ /**
428
+ * 경로 감시자 하나를 만든다.
429
+ *
430
+ * 돌려주는 것은 **구독 함수**다. 구독하면 현재 경로를 한 번 흘리고(마운트 시점의 화면이
431
+ * 궤적의 첫 줄이 된다), 이후 이동마다 새 경로를 흘린다. 해제 함수를 부르면 구독이 끊긴다.
432
+ *
433
+ * `history` 패치는 **첫 구독자가 생길 때 걸고 마지막이 나가면 푼다.** 구독자가 없는데 패치가
434
+ * 남아 있으면, 위젯을 떼어낸 뒤에도 호스트의 모든 화면 이동이 우리 코드를 거친다.
435
+ */
436
+ declare function createRouteWatcher(target?: RouteWatchTarget | null): RouteWatcher;
437
+ /**
438
+ * 웹 기본 감시자. **모듈 스코프에 하나**라서 궤적과 지목이 같은 인스턴스를 물려 쓴다.
439
+ * 각자 `createRouteWatcher()` 를 부르면 `history` 가 두 겹으로 패치돼, 해제 순서가 엇갈릴 때
440
+ * 원본이 복구되지 않는다.
441
+ */
442
+ declare const webRouteWatcher: RouteWatcher;
443
+ /**
444
+ * 궤적을 남길 탭 단위 저장소(`sessionStorage`).
445
+ *
446
+ * `localStorage` 가 아닌 이유: 궤적은 **이 탭에서 지금 지나온 길**이다. 탭을 나눠 쓰면
447
+ * 다른 탭의 이동이 섞여 재현 절차가 거짓이 된다.
448
+ *
449
+ * 프라이빗 모드·차단 설정에서는 접근 자체가 던진다. 그때는 `null` 을 돌려주고 궤적은
450
+ * 메모리로 떨어진다 — 저장을 못 한다고 제보를 막지 않는다.
451
+ */
452
+ declare function webRouteStorage(): RouteTrailStore | null;
378
453
 
379
- 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 };
454
+ 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, type RouteWatchTarget, SELECTOR_MAX_DEPTH, type StorageLike, type StoredMarker, type WebWidget, type WebWidgetOpts, captureWebScreenshot, createRouteWatcher, createWebStorage, createWebWidget, cssSelectorPath, describeElement, isOwnUi, reactComponentPath, reactComponentSummary, reencodeWebScreenshot, visibleText, webContextProviders, webDiagnosticsOptions, webRouteStorage, webRouteWatcher };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ElementInfo, FeedbackPin, FeedbackScreenshot, FeedbackHint, FeedbackReport, SubmitOutcome, QueueStatus, ReportParts, ScreenshotCapture, ScreenshotReencode, WidgetControllerOpts, HintProvider, WidgetController, FeedbackStorage, ContextProviders } from '@solhun/feedback-kit-core';
1
+ import { ElementInfo, FeedbackPin, FeedbackScreenshot, FeedbackHint, FeedbackReport, SubmitOutcome, QueueStatus, ReportParts, ScreenshotCapture, ScreenshotReencode, RouteWatcher, WidgetControllerOpts, HintProvider, WidgetController, FeedbackStorage, ContextProviders, DiagnosticsInstallOpts, RouteTrailStore } from '@solhun/feedback-kit-core';
2
2
  export { COMMENT_MAX_CHARS, COMMENT_REQUIRED_MESSAGE, COMMENT_TOO_LONG_MESSAGE, ElementInfo, ElementLike, FLOATING_BUTTON_ID, FeedbackAdapter, FeedbackConfig, FeedbackContext, FeedbackHint, FeedbackPin, FeedbackReport, FeedbackStorage, FeedbackUser, HintCatalog, HintElementRule, HintPlatform, HintProvider, HintRankInput, HintScope, MODAL_ACTION_HINT_TOGGLE, MODAL_ACTION_PICK, ModalSubmitStatus, ReportModalController, ReportModalState, ResolvedConfig, SCREENSHOT_FAILED_MESSAGE, SOURCE_ATTR, SUBMIT_DONE_MESSAGE, SUBMIT_PENDING_MESSAGE, ScreenshotStatus, SubmitResult, Visibility, VisibilityEnv, VisibilityFn, WidgetController, WidgetPosition, WidgetScreen, WidgetState, denormalizePin, normalizePin, parseSourceAttr, rankHints, resolveConfig, shouldShowWidget, sourceFromElement } from '@solhun/feedback-kit-core';
3
3
  import * as react from 'react';
4
4
 
@@ -163,6 +163,12 @@ interface ElementPickingOpts {
163
163
  * `rankHints` 한 결과를 돌려준다. 없으면(힌트 기능이 꺼져 있으면) 팝업에 칩이 안 뜬다.
164
164
  */
165
165
  rankHintsFor?: ((element: ElementInfo | null) => readonly FeedbackHint[]) | null;
166
+ /**
167
+ * 화면 이동 감시자. 기본은 웹 공용 감시자(`webRouteWatcher`) — **진단의 화면 이동 궤적과
168
+ * 같은 것**이다. 여기만 따로 구현하면 한쪽이 이동을 놓쳤을 때 원인을 가릴 수 없다.
169
+ * `null` 을 주면 감시하지 않는다(테스트에서 `syncPath()` 를 직접 부를 때).
170
+ */
171
+ watchRoutes?: RouteWatcher | null;
166
172
  }
167
173
  declare class ElementPickingController {
168
174
  private readonly queue;
@@ -186,10 +192,14 @@ declare class ElementPickingController {
186
192
  */
187
193
  private paused;
188
194
  private attached;
195
+ /** 같은 요소 위 mousemove 마다 React 경로·선택자를 다시 만들지 않기 위한 캐시. */
196
+ private hoveredTarget;
189
197
  private hovered;
190
198
  private popup;
191
199
  private markers;
192
- private pathWatch;
200
+ private readonly watchRoutes;
201
+ /** 경로 감시 해제. 감시 중이 아니면 null. */
202
+ private unwatchRoutes;
193
203
  private lastPathname;
194
204
  private saving;
195
205
  /** 늦게 끝난 캡처가 다음 주석의 그림을 덮지 못하게 하는 세대 번호. */
@@ -203,6 +213,7 @@ declare class ElementPickingController {
203
213
  private userToggledHints;
204
214
  private readonly onClick;
205
215
  private readonly onMouseOver;
216
+ private readonly onMouseMove;
206
217
  constructor(opts: ElementPickingOpts);
207
218
  getState(): PickingState;
208
219
  get isActive(): boolean;
@@ -228,6 +239,17 @@ declare class ElementPickingController {
228
239
  * @returns 되살아났으면 true.
229
240
  */
230
241
  restore(): boolean;
242
+ /**
243
+ * 이 화면에 쌓인 마커 표시를 지운다.
244
+ *
245
+ * 지워지는 건 **화면 표시뿐**이다. 제보는 이미 큐를 거쳐 수집처로 갔으므로 없어지지 않는다.
246
+ * 마커는 `localStorage` 에 남아 새로고침에도 살아남는데(그게 원래 목적이다) 정작 치울
247
+ * 수단이 없어서, 한 번 보낸 [완료] 배지가 그 화면을 볼 때마다 계속 따라다녔다.
248
+ *
249
+ * 화면에 보이는 목록의 기준은 `lastPathname` 이다(`reconcileMarkerOutcomes` 와 같다).
250
+ * 이동 직후 아직 `syncPath` 가 안 돈 순간에도 "지금 눈에 보이는 것"이 지워져야 한다.
251
+ */
252
+ clearMarkers(): void;
231
253
  /** 경로가 바뀌었을 때 그 경로의 마커로 갈아 끼운다. */
232
254
  syncPath(): readonly StoredMarker[];
233
255
  dispose(): void;
@@ -375,5 +397,58 @@ declare function createWebStorage(backing?: SyncStorage): FeedbackStorage;
375
397
  * 서버 렌더 단계에서 실행될 수 있기 때문이다.
376
398
  */
377
399
  declare function webContextProviders(): ContextProviders;
400
+ /**
401
+ * 웹 진단 수집의 기본 배선.
402
+ *
403
+ * `sharedDiagnostics.install(webDiagnosticsOptions({ ... }))` 로 쓴다. 넘긴 값이 기본값을
404
+ * 덮으므로 감시자·저장소를 직접 갈아 끼울 수도 있다.
405
+ *
406
+ * 왜 패키지가 주나: 감시자를 호스트가 깜빡하면 **궤적만 조용히 빈다.** 네트워크·로그는
407
+ * 그대로 모이니 화면상 아무 문제가 없어 보이고, "이 제보엔 이동이 없었나 보다"로 읽힌다 —
408
+ * 빠진 것과 없는 것이 구분되지 않는 실패다.
409
+ *
410
+ * 진단을 끄면(`captureDiagnostics: false`) install 자체가 안 불리므로 궤적도 같이 꺼진다.
411
+ * 궤적만 따로 켜고 끄는 스위치는 만들지 않는다.
412
+ */
413
+ declare function webDiagnosticsOptions(opts?: DiagnosticsInstallOpts): DiagnosticsInstallOpts;
414
+
415
+ /** 감시자가 들여다보는 전역. 테스트에서는 가짜를 넣는다. */
416
+ interface RouteWatchTarget {
417
+ location?: {
418
+ pathname?: string;
419
+ } | null;
420
+ history?: {
421
+ pushState?: (...args: unknown[]) => unknown;
422
+ replaceState?: (...args: unknown[]) => unknown;
423
+ } | null;
424
+ addEventListener?: (type: string, listener: () => void) => void;
425
+ removeEventListener?: (type: string, listener: () => void) => void;
426
+ }
427
+ /**
428
+ * 경로 감시자 하나를 만든다.
429
+ *
430
+ * 돌려주는 것은 **구독 함수**다. 구독하면 현재 경로를 한 번 흘리고(마운트 시점의 화면이
431
+ * 궤적의 첫 줄이 된다), 이후 이동마다 새 경로를 흘린다. 해제 함수를 부르면 구독이 끊긴다.
432
+ *
433
+ * `history` 패치는 **첫 구독자가 생길 때 걸고 마지막이 나가면 푼다.** 구독자가 없는데 패치가
434
+ * 남아 있으면, 위젯을 떼어낸 뒤에도 호스트의 모든 화면 이동이 우리 코드를 거친다.
435
+ */
436
+ declare function createRouteWatcher(target?: RouteWatchTarget | null): RouteWatcher;
437
+ /**
438
+ * 웹 기본 감시자. **모듈 스코프에 하나**라서 궤적과 지목이 같은 인스턴스를 물려 쓴다.
439
+ * 각자 `createRouteWatcher()` 를 부르면 `history` 가 두 겹으로 패치돼, 해제 순서가 엇갈릴 때
440
+ * 원본이 복구되지 않는다.
441
+ */
442
+ declare const webRouteWatcher: RouteWatcher;
443
+ /**
444
+ * 궤적을 남길 탭 단위 저장소(`sessionStorage`).
445
+ *
446
+ * `localStorage` 가 아닌 이유: 궤적은 **이 탭에서 지금 지나온 길**이다. 탭을 나눠 쓰면
447
+ * 다른 탭의 이동이 섞여 재현 절차가 거짓이 된다.
448
+ *
449
+ * 프라이빗 모드·차단 설정에서는 접근 자체가 던진다. 그때는 `null` 을 돌려주고 궤적은
450
+ * 메모리로 떨어진다 — 저장을 못 한다고 제보를 막지 않는다.
451
+ */
452
+ declare function webRouteStorage(): RouteTrailStore | null;
378
453
 
379
- 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 };
454
+ 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, type RouteWatchTarget, SELECTOR_MAX_DEPTH, type StorageLike, type StoredMarker, type WebWidget, type WebWidgetOpts, captureWebScreenshot, createRouteWatcher, createWebStorage, createWebWidget, cssSelectorPath, describeElement, isOwnUi, reactComponentPath, reactComponentSummary, reencodeWebScreenshot, visibleText, webContextProviders, webDiagnosticsOptions, webRouteStorage, webRouteWatcher };
package/dist/index.js CHANGED
@@ -103,6 +103,7 @@ function reactComponentSummary(node) {
103
103
  // src/element-info.ts
104
104
  var ELEMENT_TEXT_MAX_CHARS = 200;
105
105
  var SELECTOR_MAX_DEPTH = 8;
106
+ var PICK_TARGET_SNAP_PX = 24;
106
107
  var KEPT_ATTRS = [
107
108
  "id",
108
109
  "class",
@@ -175,6 +176,55 @@ function visibleText(el) {
175
176
  if (collapsed === "") return null;
176
177
  return collapsed.length > ELEMENT_TEXT_MAX_CHARS ? collapsed.slice(0, ELEMENT_TEXT_MAX_CHARS) : collapsed;
177
178
  }
179
+ function elementRect(element) {
180
+ try {
181
+ const rect = element.getBoundingClientRect();
182
+ const left = rect.left;
183
+ const top = rect.top;
184
+ const width = rect.width;
185
+ const height = rect.height;
186
+ if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) {
187
+ return null;
188
+ }
189
+ return {
190
+ element,
191
+ left,
192
+ top,
193
+ right: left + width,
194
+ bottom: top + height,
195
+ width,
196
+ height
197
+ };
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+ function distanceFromPoint(rect, point) {
203
+ const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
204
+ const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
205
+ return Math.hypot(dx, dy);
206
+ }
207
+ function resolvePickTarget(target, point) {
208
+ if (!target || typeof target.tagName !== "string") return null;
209
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return target;
210
+ let current = target;
211
+ while (true) {
212
+ const currentRect = elementRect(current);
213
+ const children = Array.from(current.children).map(elementRect).filter((rect) => rect !== null).map((rect) => ({ rect, distance: distanceFromPoint(rect, point) }));
214
+ if (children.length === 0) return current;
215
+ const containing = children.filter(({ distance }) => distance === 0).sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
216
+ if (containing[0]) {
217
+ current = containing[0].rect.element;
218
+ continue;
219
+ }
220
+ const nearest = children.sort((a, b) => a.distance - b.distance)[0];
221
+ const isMorePrecise = currentRect ? nearest.rect.width * nearest.rect.height < currentRect.width * currentRect.height : true;
222
+ if (nearest.distance <= PICK_TARGET_SNAP_PX && isMorePrecise) {
223
+ return nearest.rect.element;
224
+ }
225
+ return current;
226
+ }
227
+ }
178
228
  function describeElement(el) {
179
229
  if (!el || typeof el.tagName !== "string") return null;
180
230
  const attributes = {};
@@ -299,6 +349,84 @@ import {
299
349
  captureWithinLimit,
300
350
  normalizePin
301
351
  } from "@solhun/feedback-kit-core";
352
+
353
+ // src/route-watcher.ts
354
+ function defaultTarget() {
355
+ const g = globalThis;
356
+ return g.location ? g : null;
357
+ }
358
+ function createRouteWatcher(target = defaultTarget()) {
359
+ const listeners = /* @__PURE__ */ new Set();
360
+ let detach = null;
361
+ const currentPath = () => {
362
+ const p = target?.location?.pathname;
363
+ return typeof p === "string" && p.length > 0 ? p : "/";
364
+ };
365
+ const emit = () => {
366
+ const path = currentPath();
367
+ for (const listener of [...listeners]) {
368
+ try {
369
+ listener(path);
370
+ } catch {
371
+ }
372
+ }
373
+ };
374
+ const attach = () => {
375
+ if (detach !== null || !target) return;
376
+ const onPop = () => emit();
377
+ target.addEventListener?.("popstate", onPop);
378
+ const history = target.history;
379
+ const originalPush = typeof history?.pushState === "function" ? history.pushState : null;
380
+ const originalReplace = typeof history?.replaceState === "function" ? history.replaceState : null;
381
+ if (history && originalPush) {
382
+ history.pushState = function patched(...args) {
383
+ const out = originalPush.apply(this, args);
384
+ emit();
385
+ return out;
386
+ };
387
+ }
388
+ if (history && originalReplace) {
389
+ history.replaceState = function patched(...args) {
390
+ const out = originalReplace.apply(this, args);
391
+ emit();
392
+ return out;
393
+ };
394
+ }
395
+ detach = () => {
396
+ target.removeEventListener?.("popstate", onPop);
397
+ if (history && originalPush) history.pushState = originalPush;
398
+ if (history && originalReplace) history.replaceState = originalReplace;
399
+ };
400
+ };
401
+ return (onPath) => {
402
+ listeners.add(onPath);
403
+ if (listeners.size === 1) attach();
404
+ try {
405
+ onPath(currentPath());
406
+ } catch {
407
+ }
408
+ return () => {
409
+ listeners.delete(onPath);
410
+ if (listeners.size === 0 && detach) {
411
+ detach();
412
+ detach = null;
413
+ }
414
+ };
415
+ };
416
+ }
417
+ var webRouteWatcher = createRouteWatcher();
418
+ function webRouteStorage() {
419
+ try {
420
+ const s = globalThis.sessionStorage;
421
+ if (!s || typeof s.getItem !== "function" || typeof s.setItem !== "function") return null;
422
+ s.getItem("feedback-kit:probe");
423
+ return s;
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+
429
+ // src/picking.ts
302
430
  var OWN_UI_ATTR = "data-feedback-kit";
303
431
  function defaultDocument() {
304
432
  return globalThis.document ?? null;
@@ -327,10 +455,13 @@ var ElementPickingController = class {
327
455
  */
328
456
  this.paused = false;
329
457
  this.attached = false;
458
+ /** 같은 요소 위 mousemove 마다 React 경로·선택자를 다시 만들지 않기 위한 캐시. */
459
+ this.hoveredTarget = null;
330
460
  this.hovered = null;
331
461
  this.popup = null;
332
462
  this.markers = [];
333
- this.pathWatch = null;
463
+ /** 경로 감시 해제. 감시 중이 아니면 null. */
464
+ this.unwatchRoutes = null;
334
465
  this.saving = false;
335
466
  /** 늦게 끝난 캡처가 다음 주석의 그림을 덮지 못하게 하는 세대 번호. */
336
467
  this.shotGeneration = 0;
@@ -343,6 +474,7 @@ var ElementPickingController = class {
343
474
  this.userToggledHints = false;
344
475
  this.onClick = (event) => this.handleClick(event);
345
476
  this.onMouseOver = (event) => this.handleMouseOver(event);
477
+ this.onMouseMove = (event) => this.handleMouseOver(event);
346
478
  this.queue = opts.queue;
347
479
  this.createReport = opts.createReport;
348
480
  this.store = opts.store ?? new MarkerStore();
@@ -354,6 +486,7 @@ var ElementPickingController = class {
354
486
  this.reencode = opts.reencode ?? null;
355
487
  this.screenshotLimitBytes = opts.screenshotLimitBytes;
356
488
  this.rankHintsFor = opts.rankHintsFor ?? null;
489
+ this.watchRoutes = opts.watchRoutes === void 0 ? webRouteWatcher : opts.watchRoutes;
357
490
  this.lastPathname = this.getPathname();
358
491
  this.markers = this.store.list(this.lastPathname);
359
492
  this.unsubscribeQueue = this.queue.subscribe?.(() => {
@@ -402,6 +535,7 @@ var ElementPickingController = class {
402
535
  this.store.setPickingActive(false);
403
536
  this.detach();
404
537
  this.stopPathWatch();
538
+ this.hoveredTarget = null;
405
539
  this.hovered = null;
406
540
  this.popup = null;
407
541
  this.emit();
@@ -417,6 +551,7 @@ var ElementPickingController = class {
417
551
  if (!this.active || this.paused) return;
418
552
  this.paused = true;
419
553
  this.detach();
554
+ this.hoveredTarget = null;
420
555
  this.hovered = null;
421
556
  this.emit();
422
557
  }
@@ -444,6 +579,22 @@ var ElementPickingController = class {
444
579
  this.start();
445
580
  return true;
446
581
  }
582
+ /**
583
+ * 이 화면에 쌓인 마커 표시를 지운다.
584
+ *
585
+ * 지워지는 건 **화면 표시뿐**이다. 제보는 이미 큐를 거쳐 수집처로 갔으므로 없어지지 않는다.
586
+ * 마커는 `localStorage` 에 남아 새로고침에도 살아남는데(그게 원래 목적이다) 정작 치울
587
+ * 수단이 없어서, 한 번 보낸 [완료] 배지가 그 화면을 볼 때마다 계속 따라다녔다.
588
+ *
589
+ * 화면에 보이는 목록의 기준은 `lastPathname` 이다(`reconcileMarkerOutcomes` 와 같다).
590
+ * 이동 직후 아직 `syncPath` 가 안 돈 순간에도 "지금 눈에 보이는 것"이 지워져야 한다.
591
+ */
592
+ clearMarkers() {
593
+ if (this.markers.length === 0) return;
594
+ this.store.clear(this.lastPathname);
595
+ this.markers = [];
596
+ this.emit();
597
+ }
447
598
  /** 경로가 바뀌었을 때 그 경로의 마커로 갈아 끼운다. */
448
599
  syncPath() {
449
600
  if (this.paused) this.resume();
@@ -451,6 +602,7 @@ var ElementPickingController = class {
451
602
  this.markers = this.store.list(this.lastPathname);
452
603
  this.reconcileMarkerOutcomes();
453
604
  this.popup = null;
605
+ this.hoveredTarget = null;
454
606
  this.hovered = null;
455
607
  this.emit();
456
608
  return this.markers;
@@ -466,17 +618,26 @@ var ElementPickingController = class {
466
618
  if (this.attached || !this.doc) return;
467
619
  this.doc.addEventListener("click", this.onClick, true);
468
620
  this.doc.addEventListener("mouseover", this.onMouseOver, true);
621
+ this.doc.addEventListener("mousemove", this.onMouseMove, true);
469
622
  this.attached = true;
470
623
  }
471
624
  detach() {
472
625
  if (!this.attached || !this.doc) return;
473
626
  this.doc.removeEventListener("click", this.onClick, true);
474
627
  this.doc.removeEventListener("mouseover", this.onMouseOver, true);
628
+ this.doc.removeEventListener("mousemove", this.onMouseMove, true);
475
629
  this.attached = false;
476
630
  }
477
631
  handleMouseOver(event) {
478
632
  if (!this.active || this.paused || isOwnUi(event.target)) return;
479
- this.hovered = describeElement(event.target);
633
+ const mouse = event;
634
+ const target = resolvePickTarget(event.target, {
635
+ x: mouse.clientX ?? 0,
636
+ y: mouse.clientY ?? 0
637
+ });
638
+ if (target === this.hoveredTarget) return;
639
+ this.hoveredTarget = target;
640
+ this.hovered = describeElement(target);
480
641
  this.emit();
481
642
  }
482
643
  handleClick(event) {
@@ -497,11 +658,12 @@ var ElementPickingController = class {
497
658
  /** 클릭 지점 기준으로 주석 팝업을 연다. 좌표는 해상도 무관한 상대값으로 접어 둔다. */
498
659
  openAnnotation(element, clientPoint) {
499
660
  const point = normalizePin(clientPoint, this.getViewport());
661
+ const target = resolvePickTarget(element, clientPoint);
500
662
  if (this.onPick) {
501
- this.onPick(describeElement(element), point);
663
+ this.onPick(describeElement(target), point);
502
664
  return;
503
665
  }
504
- const info = describeElement(element);
666
+ const info = describeElement(target);
505
667
  this.userToggledHints = false;
506
668
  const hints = this.rankHintsFor ? this.rankHintsFor(info) : [];
507
669
  this.popup = {
@@ -716,17 +878,17 @@ ${hint.draft}`;
716
878
  return outcome;
717
879
  }
718
880
  startPathWatch() {
719
- if (this.pathWatch !== null || typeof setInterval !== "function") return;
881
+ if (this.unwatchRoutes !== null || !this.watchRoutes) return;
720
882
  this.lastPathname = this.getPathname();
721
- this.pathWatch = setInterval(() => {
883
+ this.unwatchRoutes = this.watchRoutes(() => {
722
884
  const pathname = this.getPathname();
723
885
  if (pathname !== this.lastPathname) this.syncPath();
724
- }, 200);
886
+ });
725
887
  }
726
888
  stopPathWatch() {
727
- if (this.pathWatch === null) return;
728
- clearInterval(this.pathWatch);
729
- this.pathWatch = null;
889
+ if (this.unwatchRoutes === null) return;
890
+ this.unwatchRoutes();
891
+ this.unwatchRoutes = null;
730
892
  }
731
893
  /** 전역 pending 수가 아니라 마커와 같은 clientSubmissionId의 확정 성공만 완료로 바꾼다. */
732
894
  reconcileMarkerOutcomes() {
@@ -1012,7 +1174,7 @@ import {
1012
1174
  } from "react";
1013
1175
 
1014
1176
  // src/version.ts
1015
- var VERSION = true ? "0.7.0" : "dev";
1177
+ var VERSION = true ? "0.8.0" : "dev";
1016
1178
 
1017
1179
  // src/feedback-kit.tsx
1018
1180
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -1278,8 +1440,10 @@ function PickToggle({
1278
1440
  }
1279
1441
  function PickBar({
1280
1442
  paused,
1443
+ markerCount,
1281
1444
  onToggle,
1282
- onTogglePause
1445
+ onTogglePause,
1446
+ onClearMarkers
1283
1447
  }) {
1284
1448
  return /* @__PURE__ */ jsxs(
1285
1449
  "div",
@@ -1287,10 +1451,20 @@ function PickBar({
1287
1451
  style: {
1288
1452
  position: "fixed",
1289
1453
  top: 16,
1290
- left: "50%",
1291
- transform: "translateX(-50%)",
1454
+ // `left:50% + translateX(-50%)` 로 가운데를 잡으면 **쓸 수 있는 폭이 화면의 절반**이
1455
+ // 된다(transform 은 레이아웃 계산에 안 들어간다). 버튼이 셋이 되면서 그 절반에
1456
+ // 안 맞아 좁은 화면에서 버튼이 한 줄에 하나씩 세로로 쌓였다(390px 에서 3줄).
1457
+ // 양끝을 물리고 auto 마진으로 가운데를 잡으면 화면 전체 폭을 쓴다 — 같은 화면이 2줄.
1458
+ left: 0,
1459
+ right: 0,
1460
+ marginInline: "auto",
1461
+ width: "fit-content",
1462
+ maxWidth: "calc(100% - 32px)",
1292
1463
  display: "flex",
1293
1464
  alignItems: "stretch",
1465
+ justifyContent: "center",
1466
+ // 셋이 한 줄에 안 들어가는 폭에서는 줄을 바꾼다 — 안 그러면 화면 밖으로 나간다.
1467
+ flexWrap: "wrap",
1294
1468
  gap: 8,
1295
1469
  // 오버레이 자체는 클릭을 통과시킨다(pointerEvents:none) — 이 막대만 되살린다.
1296
1470
  pointerEvents: "auto",
@@ -1323,7 +1497,30 @@ function PickBar({
1323
1497
  paused ? "\uC9C0\uBAA9 \uC7AC\uAC1C" : "\uC7A0\uC2DC \uBA48\uCDA4"
1324
1498
  ]
1325
1499
  }
1326
- )
1500
+ ),
1501
+ markerCount > 0 ? /* @__PURE__ */ jsxs(
1502
+ "button",
1503
+ {
1504
+ type: "button",
1505
+ "data-fk-pick-clear": markerCount,
1506
+ title: "\uD654\uBA74\uC758 \uD45C\uC2DC\uB9CC \uC9C0\uC6C1\uB2C8\uB2E4. \uBCF4\uB0B8 \uC81C\uBCF4\uB294 \uADF8\uB300\uB85C \uB0A8\uC2B5\uB2C8\uB2E4.",
1507
+ "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)`,
1508
+ onClick: onClearMarkers,
1509
+ style: {
1510
+ ...baseButton,
1511
+ display: "inline-flex",
1512
+ alignItems: "center",
1513
+ gap: 6,
1514
+ whiteSpace: "nowrap",
1515
+ color: TOKENS.muted
1516
+ },
1517
+ children: [
1518
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\u2715" }),
1519
+ "\uD45C\uC2DC \uC9C0\uC6B0\uAE30 ",
1520
+ markerCount
1521
+ ]
1522
+ }
1523
+ ) : null
1327
1524
  ]
1328
1525
  }
1329
1526
  );
@@ -1988,8 +2185,10 @@ function FeedbackKit(props) {
1988
2185
  PickBar,
1989
2186
  {
1990
2187
  paused: pickingState.paused,
2188
+ markerCount: pickingState.markers.length,
1991
2189
  onToggle: togglePicking,
1992
- onTogglePause: () => activeKit.picking.togglePause()
2190
+ onTogglePause: () => activeKit.picking.togglePause(),
2191
+ onClearMarkers: () => activeKit.picking.clearMarkers()
1993
2192
  }
1994
2193
  ),
1995
2194
  pickingState.markers.map((marker, index) => /* @__PURE__ */ jsx(
@@ -2215,6 +2414,13 @@ function webContextProviders() {
2215
2414
  }
2216
2415
  };
2217
2416
  }
2417
+ function webDiagnosticsOptions(opts = {}) {
2418
+ return {
2419
+ watchRoutes: webRouteWatcher,
2420
+ routeStorage: webRouteStorage(),
2421
+ ...opts
2422
+ };
2423
+ }
2218
2424
  export {
2219
2425
  COMMENT_MAX_CHARS3 as COMMENT_MAX_CHARS,
2220
2426
  COMMENT_REQUIRED_MESSAGE,
@@ -2239,6 +2445,7 @@ export {
2239
2445
  SUBMIT_PENDING_MESSAGE,
2240
2446
  WidgetController2 as WidgetController,
2241
2447
  captureWebScreenshot,
2448
+ createRouteWatcher,
2242
2449
  createWebStorage,
2243
2450
  createWebWidget,
2244
2451
  cssSelectorPath,
@@ -2255,6 +2462,9 @@ export {
2255
2462
  shouldShowWidget,
2256
2463
  sourceFromElement,
2257
2464
  visibleText,
2258
- webContextProviders
2465
+ webContextProviders,
2466
+ webDiagnosticsOptions,
2467
+ webRouteStorage,
2468
+ webRouteWatcher
2259
2469
  };
2260
2470
  //# sourceMappingURL=index.js.map