@solhun/feedback-kit-web 0.1.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/LICENSE +21 -0
- package/dist/index.cjs +1414 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +229 -0
- package/dist/index.d.ts +229 -0
- package/dist/index.js +1374 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
- package/src/element-info.ts +138 -0
- package/src/feedback-kit.test.ts +227 -0
- package/src/feedback-kit.tsx +778 -0
- package/src/index.ts +97 -0
- package/src/marker-store.ts +153 -0
- package/src/picking.test.ts +249 -0
- package/src/picking.ts +370 -0
- package/src/providers.ts +43 -0
- package/src/screen-map.test.ts +455 -0
- package/src/screenshot.test.ts +47 -0
- package/src/screenshot.ts +90 -0
- package/src/storage.test.ts +65 -0
- package/src/storage.ts +69 -0
- package/src/widget.ts +70 -0
package/src/picking.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// 요소 지목 모드 (웹 전용).
|
|
2
|
+
//
|
|
3
|
+
// 켜져 있는 동안 페이지의 아무 요소나 클릭하면 그 자리에 주석을 남긴다. 리포트 모달과 달리
|
|
4
|
+
// "열고 → 쓰고 → 닫는" 절차가 없다 — 한 번 켜 두고 여러 곳을 연달아 지목하는 게 이 모드의 전부다.
|
|
5
|
+
// 그래서 주석을 저장해도 모드는 꺼지지 않고, 페이지를 옮겨도 살아남는다.
|
|
6
|
+
//
|
|
7
|
+
// 페이지의 원래 클릭(링크 이동·버튼 동작)은 막아야 한다. 지목하려고 누른 링크가 실제로 이동해
|
|
8
|
+
// 버리면 주석을 달 화면이 사라진다.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
COMMENT_MAX_CHARS,
|
|
12
|
+
normalizePin,
|
|
13
|
+
type ElementInfo,
|
|
14
|
+
type FeedbackPin,
|
|
15
|
+
type FeedbackReport,
|
|
16
|
+
type ReportParts,
|
|
17
|
+
type QueueStatus,
|
|
18
|
+
type SubmitOutcome,
|
|
19
|
+
} from "@solhun/feedback-kit-core";
|
|
20
|
+
import { describeElement } from "./element-info.js";
|
|
21
|
+
import { MarkerStore, type StoredMarker } from "./marker-store.js";
|
|
22
|
+
|
|
23
|
+
/** 위젯 자신의 UI 를 표시하는 속성. 이 안쪽 클릭은 가로채지 않는다. */
|
|
24
|
+
export const OWN_UI_ATTR = "data-feedback-kit";
|
|
25
|
+
|
|
26
|
+
/** 지목 컨트롤러가 큐에게 요구하는 최소 계약. */
|
|
27
|
+
export interface PickingQueueLike {
|
|
28
|
+
submit(report: FeedbackReport): Promise<SubmitOutcome>;
|
|
29
|
+
subscribe?(listener: (status: QueueStatus) => void): () => void;
|
|
30
|
+
getOutcome?(clientSubmissionId: string): Pick<SubmitOutcome, "delivered" | "id"> | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AnnotationPopupState {
|
|
34
|
+
element: ElementInfo | null;
|
|
35
|
+
/** 클릭한 지점의 뷰포트 상대 좌표(0~1). */
|
|
36
|
+
point: FeedbackPin;
|
|
37
|
+
comment: string;
|
|
38
|
+
canSave: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PickingState {
|
|
42
|
+
active: boolean;
|
|
43
|
+
/** 지금 마우스가 올라가 있는 요소. 하이라이트를 그리는 근거. */
|
|
44
|
+
hovered: ElementInfo | null;
|
|
45
|
+
popup: AnnotationPopupState | null;
|
|
46
|
+
/** 현재 경로에 남아 있는 마커들. */
|
|
47
|
+
markers: readonly StoredMarker[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PickingListener = (state: PickingState) => void;
|
|
51
|
+
|
|
52
|
+
export interface ElementPickingOpts {
|
|
53
|
+
queue: PickingQueueLike;
|
|
54
|
+
createReport: (parts: ReportParts) => Promise<FeedbackReport>;
|
|
55
|
+
store?: MarkerStore;
|
|
56
|
+
/** 현재 경로. 마커는 경로별로 나눠 저장한다. */
|
|
57
|
+
getPathname?: () => string;
|
|
58
|
+
/** 이벤트를 들을 문서. 기본은 전역 `document`. */
|
|
59
|
+
doc?: Document | null;
|
|
60
|
+
/** 좌표를 정규화할 기준 크기. 기본은 현재 뷰포트. */
|
|
61
|
+
getViewport?: () => { width: number; height: number };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultDocument(): Document | null {
|
|
65
|
+
return (globalThis as { document?: Document }).document ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultPathname(): string {
|
|
69
|
+
const loc = (globalThis as { location?: { pathname?: string } }).location;
|
|
70
|
+
return loc?.pathname ?? "/";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function defaultViewport(): { width: number; height: number } {
|
|
74
|
+
const w = globalThis as { innerWidth?: number; innerHeight?: number };
|
|
75
|
+
return { width: w.innerWidth ?? 0, height: w.innerHeight ?? 0 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 위젯 자신의 UI 안에서 일어난 일인가. */
|
|
79
|
+
function isOwnUi(target: EventTarget | null): boolean {
|
|
80
|
+
const el = target as Element | null;
|
|
81
|
+
if (!el || typeof el.closest !== "function") return false;
|
|
82
|
+
return el.closest(`[${OWN_UI_ATTR}]`) !== null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ElementPickingController {
|
|
86
|
+
private readonly queue: PickingQueueLike;
|
|
87
|
+
private readonly createReport: (parts: ReportParts) => Promise<FeedbackReport>;
|
|
88
|
+
private readonly store: MarkerStore;
|
|
89
|
+
private readonly getPathname: () => string;
|
|
90
|
+
private readonly doc: Document | null;
|
|
91
|
+
private readonly getViewport: () => { width: number; height: number };
|
|
92
|
+
private readonly unsubscribeQueue: () => void;
|
|
93
|
+
|
|
94
|
+
private readonly listeners = new Set<PickingListener>();
|
|
95
|
+
|
|
96
|
+
private active = false;
|
|
97
|
+
private attached = false;
|
|
98
|
+
private hovered: ElementInfo | null = null;
|
|
99
|
+
private popup: AnnotationPopupState | null = null;
|
|
100
|
+
private markers: StoredMarker[] = [];
|
|
101
|
+
private pathWatch: ReturnType<typeof setInterval> | null = null;
|
|
102
|
+
private lastPathname: string;
|
|
103
|
+
private saving = false;
|
|
104
|
+
|
|
105
|
+
private readonly onClick = (event: Event) => this.handleClick(event);
|
|
106
|
+
private readonly onMouseOver = (event: Event) => this.handleMouseOver(event);
|
|
107
|
+
|
|
108
|
+
constructor(opts: ElementPickingOpts) {
|
|
109
|
+
this.queue = opts.queue;
|
|
110
|
+
this.createReport = opts.createReport;
|
|
111
|
+
this.store = opts.store ?? new MarkerStore();
|
|
112
|
+
this.getPathname = opts.getPathname ?? defaultPathname;
|
|
113
|
+
this.doc = opts.doc === undefined ? defaultDocument() : opts.doc;
|
|
114
|
+
this.getViewport = opts.getViewport ?? defaultViewport;
|
|
115
|
+
this.lastPathname = this.getPathname();
|
|
116
|
+
this.markers = this.store.list(this.lastPathname);
|
|
117
|
+
this.unsubscribeQueue =
|
|
118
|
+
this.queue.subscribe?.(() => {
|
|
119
|
+
if (this.reconcileMarkerOutcomes()) this.emit();
|
|
120
|
+
}) ?? (() => undefined);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 조회 ──────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
getState(): PickingState {
|
|
126
|
+
return {
|
|
127
|
+
active: this.active,
|
|
128
|
+
hovered: this.hovered,
|
|
129
|
+
popup: this.popup,
|
|
130
|
+
markers: this.markers,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get isActive(): boolean {
|
|
135
|
+
return this.active;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
subscribe(listener: PickingListener): () => void {
|
|
139
|
+
this.listeners.add(listener);
|
|
140
|
+
listener(this.getState());
|
|
141
|
+
return () => {
|
|
142
|
+
this.listeners.delete(listener);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── 모드 on/off ───────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
start(): void {
|
|
149
|
+
if (this.active) return;
|
|
150
|
+
this.active = true;
|
|
151
|
+
this.store.setPickingActive(true);
|
|
152
|
+
this.attach();
|
|
153
|
+
this.startPathWatch();
|
|
154
|
+
this.markers = this.store.list(this.getPathname());
|
|
155
|
+
this.emit();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** [지목 종료]. 저장된 플래그까지 지워서 새로고침해도 다시 켜지지 않게 한다. */
|
|
159
|
+
stop(): void {
|
|
160
|
+
this.active = false;
|
|
161
|
+
this.store.setPickingActive(false);
|
|
162
|
+
this.detach();
|
|
163
|
+
this.stopPathWatch();
|
|
164
|
+
this.hovered = null;
|
|
165
|
+
this.popup = null;
|
|
166
|
+
this.emit();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 저장돼 있던 모드를 되살린다. 새로고침·페이지 이동 직후에 한 번 부른다.
|
|
171
|
+
* @returns 되살아났으면 true.
|
|
172
|
+
*/
|
|
173
|
+
restore(): boolean {
|
|
174
|
+
if (!this.store.isPickingActive()) {
|
|
175
|
+
this.markers = this.store.list(this.getPathname());
|
|
176
|
+
this.emit();
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
this.start();
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 경로가 바뀌었을 때 그 경로의 마커로 갈아 끼운다. */
|
|
184
|
+
syncPath(): readonly StoredMarker[] {
|
|
185
|
+
this.lastPathname = this.getPathname();
|
|
186
|
+
this.markers = this.store.list(this.lastPathname);
|
|
187
|
+
this.reconcileMarkerOutcomes();
|
|
188
|
+
this.popup = null;
|
|
189
|
+
this.hovered = null;
|
|
190
|
+
this.emit();
|
|
191
|
+
return this.markers;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
dispose(): void {
|
|
195
|
+
this.detach();
|
|
196
|
+
this.stopPathWatch();
|
|
197
|
+
this.unsubscribeQueue();
|
|
198
|
+
this.listeners.clear();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── DOM 이벤트 ────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
private attach(): void {
|
|
204
|
+
if (this.attached || !this.doc) return;
|
|
205
|
+
// 캡처 단계에서 잡아야 페이지의 핸들러보다 먼저 막을 수 있다.
|
|
206
|
+
this.doc.addEventListener("click", this.onClick, true);
|
|
207
|
+
this.doc.addEventListener("mouseover", this.onMouseOver, true);
|
|
208
|
+
this.attached = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private detach(): void {
|
|
212
|
+
if (!this.attached || !this.doc) return;
|
|
213
|
+
this.doc.removeEventListener("click", this.onClick, true);
|
|
214
|
+
this.doc.removeEventListener("mouseover", this.onMouseOver, true);
|
|
215
|
+
this.attached = false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private handleMouseOver(event: Event): void {
|
|
219
|
+
if (!this.active || isOwnUi(event.target)) return;
|
|
220
|
+
this.hovered = describeElement(event.target as Element | null);
|
|
221
|
+
this.emit();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private handleClick(event: Event): void {
|
|
225
|
+
if (!this.active) return;
|
|
226
|
+
if (isOwnUi(event.target)) return;
|
|
227
|
+
|
|
228
|
+
// 페이지가 이 클릭을 보지 못하게 한다 — 링크 이동·버튼 동작을 전부 막는다.
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
if (typeof event.stopImmediatePropagation === "function") {
|
|
232
|
+
event.stopImmediatePropagation();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const mouse = event as MouseEvent;
|
|
236
|
+
this.openAnnotation(event.target as Element | null, {
|
|
237
|
+
x: mouse.clientX ?? 0,
|
|
238
|
+
y: mouse.clientY ?? 0,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── 주석 팝업 ─────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/** 클릭 지점 기준으로 주석 팝업을 연다. 좌표는 해상도 무관한 상대값으로 접어 둔다. */
|
|
245
|
+
openAnnotation(element: Element | null, clientPoint: { x: number; y: number }): void {
|
|
246
|
+
const point = normalizePin(clientPoint, this.getViewport());
|
|
247
|
+
this.popup = {
|
|
248
|
+
element: describeElement(element),
|
|
249
|
+
point,
|
|
250
|
+
comment: "",
|
|
251
|
+
canSave: false,
|
|
252
|
+
};
|
|
253
|
+
this.emit();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
setAnnotationComment(value: string): void {
|
|
257
|
+
if (!this.popup) return;
|
|
258
|
+
this.popup = {
|
|
259
|
+
...this.popup,
|
|
260
|
+
comment: value,
|
|
261
|
+
canSave: value.trim().length > 0 && value.length <= COMMENT_MAX_CHARS,
|
|
262
|
+
};
|
|
263
|
+
this.emit();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 취소 — 마커도 제보도 남기지 않는다. 모드는 켜진 채로 둔다. */
|
|
267
|
+
cancelAnnotation(): void {
|
|
268
|
+
this.popup = null;
|
|
269
|
+
this.emit();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 주석 저장. 마커를 먼저 "전송 중"으로 찍고 나서 보낸다 — 사용자는 결과를 기다리지 않고
|
|
274
|
+
* 다음 요소로 넘어갈 수 있어야 한다.
|
|
275
|
+
*
|
|
276
|
+
* @returns 검증에 걸려 아무것도 하지 않았으면 `null`.
|
|
277
|
+
*/
|
|
278
|
+
async saveAnnotation(): Promise<SubmitOutcome | null> {
|
|
279
|
+
const popup = this.popup;
|
|
280
|
+
if (!popup || !popup.canSave || this.saving) return null;
|
|
281
|
+
this.saving = true;
|
|
282
|
+
|
|
283
|
+
const pathname = this.getPathname();
|
|
284
|
+
const parts: ReportParts = {
|
|
285
|
+
kind: "annotation",
|
|
286
|
+
comment: popup.comment,
|
|
287
|
+
priority: "unset",
|
|
288
|
+
screenshot: null,
|
|
289
|
+
pin: popup.point,
|
|
290
|
+
element: popup.element,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
let report: FeedbackReport;
|
|
294
|
+
try {
|
|
295
|
+
report = await this.createReport(parts);
|
|
296
|
+
} catch {
|
|
297
|
+
// 조립이 실패하면 남길 마커의 id 도 없다. 팝업만 닫고 조용히 물러난다.
|
|
298
|
+
this.popup = null;
|
|
299
|
+
this.emit();
|
|
300
|
+
this.saving = false;
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const marker: StoredMarker = {
|
|
305
|
+
id: report.clientSubmissionId,
|
|
306
|
+
x: popup.point.x,
|
|
307
|
+
y: popup.point.y,
|
|
308
|
+
selector: popup.element?.selector ?? null,
|
|
309
|
+
comment: popup.comment,
|
|
310
|
+
status: "sending",
|
|
311
|
+
at: report.createdAt,
|
|
312
|
+
};
|
|
313
|
+
this.markers = this.store.upsert(pathname, marker);
|
|
314
|
+
this.popup = null; // 모드는 그대로 — 다음 요소를 바로 지목할 수 있다.
|
|
315
|
+
this.emit();
|
|
316
|
+
|
|
317
|
+
let outcome: SubmitOutcome;
|
|
318
|
+
try {
|
|
319
|
+
outcome = await this.queue.submit(report);
|
|
320
|
+
} catch {
|
|
321
|
+
outcome = { delivered: false, id: null, queued: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const next = this.store.updateStatus(
|
|
325
|
+
pathname,
|
|
326
|
+
marker.id,
|
|
327
|
+
outcome.delivered ? "done" : "pending",
|
|
328
|
+
);
|
|
329
|
+
// 저장 중에 경로가 바뀌었으면 화면에 보이는 목록은 건드리지 않는다.
|
|
330
|
+
if (pathname === this.getPathname()) this.markers = next;
|
|
331
|
+
this.emit();
|
|
332
|
+
|
|
333
|
+
this.saving = false;
|
|
334
|
+
return outcome;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private startPathWatch(): void {
|
|
338
|
+
if (this.pathWatch !== null || typeof setInterval !== "function") return;
|
|
339
|
+
this.lastPathname = this.getPathname();
|
|
340
|
+
this.pathWatch = setInterval(() => {
|
|
341
|
+
const pathname = this.getPathname();
|
|
342
|
+
if (pathname !== this.lastPathname) this.syncPath();
|
|
343
|
+
}, 200);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private stopPathWatch(): void {
|
|
347
|
+
if (this.pathWatch === null) return;
|
|
348
|
+
clearInterval(this.pathWatch);
|
|
349
|
+
this.pathWatch = null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 전역 pending 수가 아니라 마커와 같은 clientSubmissionId의 확정 성공만 완료로 바꾼다. */
|
|
353
|
+
private reconcileMarkerOutcomes(): boolean {
|
|
354
|
+
if (!this.queue.getOutcome) return false;
|
|
355
|
+
let changed = false;
|
|
356
|
+
for (const marker of this.markers) {
|
|
357
|
+
const outcome = this.queue.getOutcome(marker.id);
|
|
358
|
+
if (marker.status !== "done" && outcome?.delivered) {
|
|
359
|
+
this.markers = this.store.updateStatus(this.lastPathname, marker.id, "done");
|
|
360
|
+
changed = true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return changed;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private emit(): void {
|
|
367
|
+
const state = this.getState();
|
|
368
|
+
for (const listener of this.listeners) listener(state);
|
|
369
|
+
}
|
|
370
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 웹 기본 컨텍스트 제공자.
|
|
2
|
+
//
|
|
3
|
+
// 왜 패키지가 주나: `getUrl` 을 호스트가 깜빡하면 제보의 url 이 빈 문자열로 나가고,
|
|
4
|
+
// 수집 서버는 그걸 400 으로 거절한다. 400 은 재시도 불가라 **제보가 조용히 버려진다.**
|
|
5
|
+
// 실제로 e-fm 첫 배선에서 이 일이 났다 — 화면에는 "보내지 못했습니다"만 뜨고,
|
|
6
|
+
// 원인(빈 url)은 네트워크를 까 봐야 알 수 있었다.
|
|
7
|
+
//
|
|
8
|
+
// 기본값을 패키지가 쥐고 있으면 붙이는 쪽이 잊을 수 없다. 필요하면 덮어쓰면 된다.
|
|
9
|
+
|
|
10
|
+
import type { ContextProviders, WebContextInput } from "@solhun/feedback-kit-core";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 브라우저 전역에서 뽑을 수 있는 기본 제공자들.
|
|
14
|
+
*
|
|
15
|
+
* SSR·비브라우저에서 불려도 던지지 않는다(`null` 을 돌려준다) — 위젯이 마운트되기 전
|
|
16
|
+
* 서버 렌더 단계에서 실행될 수 있기 때문이다.
|
|
17
|
+
*/
|
|
18
|
+
export function webContextProviders(): ContextProviders {
|
|
19
|
+
return {
|
|
20
|
+
getUrl: () => {
|
|
21
|
+
const loc = (globalThis as { location?: { href?: string } }).location;
|
|
22
|
+
return typeof loc?.href === "string" && loc.href.length > 0 ? loc.href : null;
|
|
23
|
+
},
|
|
24
|
+
getWebContext: (): WebContextInput | null => {
|
|
25
|
+
const g = globalThis as {
|
|
26
|
+
location?: unknown;
|
|
27
|
+
navigator?: { userAgent?: string };
|
|
28
|
+
innerWidth?: number;
|
|
29
|
+
innerHeight?: number;
|
|
30
|
+
devicePixelRatio?: number;
|
|
31
|
+
};
|
|
32
|
+
if (!g.location) return null;
|
|
33
|
+
return {
|
|
34
|
+
userAgent: g.navigator?.userAgent ?? null,
|
|
35
|
+
viewport: {
|
|
36
|
+
width: typeof g.innerWidth === "number" ? g.innerWidth : null,
|
|
37
|
+
height: typeof g.innerHeight === "number" ? g.innerHeight : null,
|
|
38
|
+
devicePixelRatio: typeof g.devicePixelRatio === "number" ? g.devicePixelRatio : null,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|