@solhun/feedback-kit-core 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.
@@ -0,0 +1,526 @@
1
+ // 리포트 모달의 상태기계.
2
+ //
3
+ // 여기엔 DOM 도 RN 도 없다. 렌더러는 `getState()` 를 그리고 사용자의 동작을 메서드로 되돌려준다.
4
+ // 전송·재시도·멱등키는 전부 `FeedbackQueue` 에 맡긴다 — 이 모듈이 전송을 다시 구현하지 않는다.
5
+
6
+ import type {
7
+ FeedbackPin,
8
+ FeedbackPriority,
9
+ FeedbackReport,
10
+ FeedbackScreenshot,
11
+ } from "../types.js";
12
+ import type { ReportParts } from "../report.js";
13
+ import type { QueueStatus, SubmitOutcome } from "../queue.js";
14
+ import {
15
+ captureWithinLimit,
16
+ SCREENSHOT_FAILED_MESSAGE,
17
+ type ScreenshotCapture,
18
+ type ScreenshotReencode,
19
+ } from "./screenshot.js";
20
+ import { FLOATING_BUTTON_ID, FocusRing } from "./focus.js";
21
+
22
+ /** 코멘트 상한. 넘으면 자르지 않고 거부한다(사용자가 쓴 글을 임의로 삭제하지 않는다). */
23
+ export const COMMENT_MAX_CHARS = 4000;
24
+
25
+ export const COMMENT_REQUIRED_MESSAGE = "의견을 입력해주세요";
26
+ export const COMMENT_TOO_LONG_MESSAGE = "4,000자 이하로 입력해주세요";
27
+ export const SUBMIT_DONE_MESSAGE = "보냈습니다";
28
+ export const SUBMIT_PENDING_MESSAGE = "대기 중";
29
+ export const SUBMIT_FAILED_MESSAGE = "보내지 못했습니다";
30
+
31
+ export type WidgetPlatform = "web" | "native";
32
+ export type ScreenshotStatus = "none" | "capturing" | "ready" | "failed";
33
+ export type ModalSubmitStatus = "idle" | "sending" | "sent" | "pending" | "failed";
34
+
35
+ /** 모달 안에서 포커스를 받는 요소들의 고정 id. 렌더러가 그대로 매단다. */
36
+ export const MODAL_FIELD_COMMENT = "comment";
37
+ export const MODAL_FIELD_PRIORITY = "priority";
38
+ export const MODAL_ACTION_ATTACH = "attach";
39
+ export const MODAL_ACTION_REMOVE_SCREENSHOT = "remove-screenshot";
40
+ export const MODAL_ACTION_PIN = "pin";
41
+ export const MODAL_ACTION_PICK = "pick";
42
+ export const MODAL_ACTION_RETRY = "retry";
43
+ export const MODAL_ACTION_CANCEL = "cancel";
44
+ export const MODAL_ACTION_SEND = "send";
45
+
46
+ export interface ReportModalState {
47
+ open: boolean;
48
+ comment: string;
49
+ priority: FeedbackPriority;
50
+ screenshot: FeedbackScreenshot | null;
51
+ screenshotStatus: ScreenshotStatus;
52
+ screenshotMessage: string | null;
53
+ pin: FeedbackPin | null;
54
+ submitStatus: ModalSubmitStatus;
55
+ submitMessage: string | null;
56
+ commentError: string | null;
57
+ canSubmit: boolean;
58
+ /** 핀 찍기를 열 수 있는가. 스크린샷이 있어야 좌표가 의미를 갖는다. */
59
+ canPin: boolean;
60
+ showRetry: boolean;
61
+ /** 대기 큐에 남아 있는 건수. */
62
+ pending: number;
63
+ closeConfirmVisible: boolean;
64
+ /** 이 플랫폼에서 노출되는 부가 동작들. 웹은 요소 지목, 앱은 핀 찍기. */
65
+ actions: readonly string[];
66
+ focusOrder: readonly string[];
67
+ focused: string | null;
68
+ /** 모달이 닫힌 뒤 포커스를 돌려줄 곳. */
69
+ restoreFocusTo: string | null;
70
+ }
71
+
72
+ /** 모달이 큐에게 요구하는 최소 계약. `FeedbackQueue` 가 그대로 만족한다. */
73
+ export interface ModalQueueLike {
74
+ submit(report: FeedbackReport): Promise<SubmitOutcome>;
75
+ retryNow(): Promise<void>;
76
+ size(): Promise<number>;
77
+ subscribe(listener: (status: QueueStatus) => void): () => void;
78
+ /** 현재 모달 제보만 다른 대기 항목과 구분하기 위한 제출 단위 결말. */
79
+ getOutcome?(clientSubmissionId: string): Pick<SubmitOutcome, "delivered" | "id"> | null;
80
+ /** 실제 FeedbackQueue의 online/주기 재시도 lifecycle. 테스트 대역은 생략할 수 있다. */
81
+ start?(): void;
82
+ stop?(): void;
83
+ }
84
+
85
+ export interface ReportModalOpts {
86
+ queue: ModalQueueLike;
87
+ /** 제보 조립. 보통 `buildReport` 를 부분 적용해 넘긴다. */
88
+ createReport: (parts: ReportParts) => Promise<FeedbackReport>;
89
+ platform: WidgetPlatform;
90
+ /** 모달을 열 때 자동 캡처. 없으면 캡처 단계를 건너뛴다. */
91
+ capture?: ScreenshotCapture | null;
92
+ reencode?: ScreenshotReencode | null;
93
+ screenshotLimitBytes?: number;
94
+ }
95
+
96
+ export type ReportModalListener = (state: ReportModalState) => void;
97
+
98
+ export class ReportModalController {
99
+ private readonly queue: ModalQueueLike;
100
+ private readonly createReport: (parts: ReportParts) => Promise<FeedbackReport>;
101
+ private readonly platform: WidgetPlatform;
102
+ private readonly capture: ScreenshotCapture | null;
103
+ private readonly reencode: ScreenshotReencode | null;
104
+ private readonly screenshotLimitBytes: number | undefined;
105
+
106
+ private readonly listeners = new Set<ReportModalListener>();
107
+ private readonly ring = new FocusRing();
108
+ private readonly unsubscribeQueue: () => void;
109
+
110
+ /** 사용자가 입력란을 건드렸는가. 열자마자 빨간 문구가 뜨는 걸 막으려고 둔다. */
111
+ private touched = false;
112
+ private submitAttempted = false;
113
+ private lastReport: FeedbackReport | null = null;
114
+ /** 이전 open/recapture가 늦게 끝나 최신 상태를 덮지 못하게 하는 세대 번호. */
115
+ private captureGeneration = 0;
116
+
117
+ private state: ReportModalState = {
118
+ open: false,
119
+ comment: "",
120
+ priority: "unset",
121
+ screenshot: null,
122
+ screenshotStatus: "none",
123
+ screenshotMessage: null,
124
+ pin: null,
125
+ submitStatus: "idle",
126
+ submitMessage: null,
127
+ commentError: null,
128
+ canSubmit: false,
129
+ canPin: false,
130
+ showRetry: false,
131
+ pending: 0,
132
+ closeConfirmVisible: false,
133
+ actions: [],
134
+ focusOrder: [],
135
+ focused: null,
136
+ restoreFocusTo: null,
137
+ };
138
+
139
+ constructor(opts: ReportModalOpts) {
140
+ this.queue = opts.queue;
141
+ this.createReport = opts.createReport;
142
+ this.platform = opts.platform;
143
+ this.capture = opts.capture ?? null;
144
+ this.reencode = opts.reencode ?? null;
145
+ this.screenshotLimitBytes = opts.screenshotLimitBytes;
146
+
147
+ // 현재 제보의 결말만 본다. 큐 전체 길이로 판단하면 다른 제보가 남았을 때 성공을 놓치거나,
148
+ // 현재 제보가 영구 실패로 빠졌을 때 큐가 비었다는 이유로 성공 처리하게 된다.
149
+ this.unsubscribeQueue = this.queue.subscribe((status) => {
150
+ this.patch({ pending: status.pending });
151
+ if (this.state.submitStatus !== "pending" || !this.lastReport) return;
152
+
153
+ const outcome = this.queue.getOutcome?.(this.lastReport.clientSubmissionId) ?? null;
154
+ const delivered = outcome?.delivered === true;
155
+ const rejected = outcome?.delivered === false;
156
+ // 오래된 테스트 대역처럼 제출 단위 API가 없는 경우에만 기존의 단일 큐 가정을 쓴다.
157
+ const legacyDrained = !this.queue.getOutcome && status.pending === 0;
158
+ if (delivered || legacyDrained) {
159
+ this.patch({ submitStatus: "sent", submitMessage: SUBMIT_DONE_MESSAGE });
160
+ this.close({ keepResult: true });
161
+ } else if (rejected) {
162
+ this.patch({ submitStatus: "failed", submitMessage: SUBMIT_FAILED_MESSAGE });
163
+ }
164
+ });
165
+ this.recompute();
166
+ }
167
+
168
+ // ── 조회 ──────────────────────────────────────────────────────────────────
169
+
170
+ getState(): ReportModalState {
171
+ return this.state;
172
+ }
173
+
174
+ /** 마지막으로 조립한 제보. 재시도가 같은 멱등키를 쓰는지 확인할 때 쓴다. */
175
+ getLastReport(): FeedbackReport | null {
176
+ return this.lastReport;
177
+ }
178
+
179
+ subscribe(listener: ReportModalListener): () => void {
180
+ this.listeners.add(listener);
181
+ listener(this.state);
182
+ return () => {
183
+ this.listeners.delete(listener);
184
+ };
185
+ }
186
+
187
+ dispose(): void {
188
+ this.unsubscribeQueue();
189
+ this.listeners.clear();
190
+ }
191
+
192
+ /** WidgetController가 소유한 큐 lifecycle을 함께 정리한다. */
193
+ stopQueue(): void {
194
+ this.queue.stop?.();
195
+ }
196
+
197
+ // ── 열기/닫기 ─────────────────────────────────────────────────────────────
198
+
199
+ async open(): Promise<void> {
200
+ if (this.state.open) return;
201
+ const wasPending = this.state.submitStatus === "pending" && this.lastReport !== null;
202
+ const pendingReport = wasPending ? this.lastReport : null;
203
+ this.touched = false;
204
+ this.submitAttempted = false;
205
+ this.patch({
206
+ open: true,
207
+ comment: pendingReport?.comment ?? "",
208
+ priority: pendingReport?.priority ?? "unset",
209
+ screenshot: pendingReport?.screenshot ?? null,
210
+ screenshotStatus: "none",
211
+ screenshotMessage: null,
212
+ pin: pendingReport?.pin ?? null,
213
+ submitStatus: wasPending ? "pending" : "idle",
214
+ submitMessage: wasPending ? SUBMIT_PENDING_MESSAGE : null,
215
+ closeConfirmVisible: false,
216
+ restoreFocusTo: null,
217
+ });
218
+ this.ring.focus(MODAL_FIELD_COMMENT);
219
+ this.patch({ focused: this.ring.current });
220
+ // 대기 항목을 다시 연 경우에는 최초 입력을 그대로 보여줘야 하므로 새 캡처로 덮지 않는다.
221
+ if (this.capture && !wasPending) await this.runCapture();
222
+ }
223
+
224
+ /**
225
+ * 닫기 요청. 쓰던 내용이 있으면 곧장 닫지 않고 확인을 받는다.
226
+ * @returns 실제로 닫혔으면 `"closed"`, 확인이 필요하면 `"confirm"`.
227
+ */
228
+ requestClose(): "closed" | "confirm" {
229
+ if (this.state.comment.length >= 1) {
230
+ this.patch({ closeConfirmVisible: true });
231
+ return "confirm";
232
+ }
233
+ this.close();
234
+ return "closed";
235
+ }
236
+
237
+ /** 확인 대화에서 "닫기"를 고른 경우. */
238
+ confirmClose(): void {
239
+ this.close();
240
+ }
241
+
242
+ /** 확인 대화에서 "계속 쓰기"를 고른 경우. 입력은 그대로 남는다. */
243
+ cancelClose(): void {
244
+ this.patch({ closeConfirmVisible: false });
245
+ }
246
+
247
+ /** 확인 없이 닫는다(요소 지목 모드로 넘어갈 때처럼 흐름이 이어지는 경우). */
248
+ dismiss(): void {
249
+ this.close();
250
+ }
251
+
252
+ private close(opts?: { keepResult?: boolean }): void {
253
+ this.captureGeneration += 1;
254
+ const keepDraft = opts?.keepResult || this.state.submitStatus === "pending";
255
+ const cleared = keepDraft
256
+ ? {}
257
+ : {
258
+ comment: "",
259
+ priority: "unset" as FeedbackPriority,
260
+ screenshot: null,
261
+ screenshotStatus: "none" as ScreenshotStatus,
262
+ screenshotMessage: null,
263
+ pin: null,
264
+ submitStatus: "idle" as ModalSubmitStatus,
265
+ submitMessage: null,
266
+ };
267
+ this.touched = false;
268
+ this.submitAttempted = false;
269
+ this.patch({
270
+ open: false,
271
+ closeConfirmVisible: false,
272
+ focused: null,
273
+ restoreFocusTo: FLOATING_BUTTON_ID,
274
+ ...cleared,
275
+ });
276
+ }
277
+
278
+ // ── 입력 ──────────────────────────────────────────────────────────────────
279
+
280
+ /** 상한을 넘겨도 값을 자르지 않는다. 거부는 하되 사용자가 쓴 글은 보존한다. */
281
+ setComment(value: string): void {
282
+ if (this.isDraftLocked()) return;
283
+ this.touched = true;
284
+ this.patch({ comment: value });
285
+ }
286
+
287
+ setPriority(priority: FeedbackPriority): void {
288
+ if (this.isDraftLocked()) return;
289
+ this.patch({ priority });
290
+ }
291
+
292
+ setPin(pin: FeedbackPin | null): void {
293
+ if (this.isDraftLocked()) return;
294
+ this.patch({ pin });
295
+ }
296
+
297
+ /** 스크린샷 제거 — 핀은 스크린샷 위 좌표라 함께 사라진다. */
298
+ removeScreenshot(): void {
299
+ if (this.isDraftLocked()) return;
300
+ this.captureGeneration += 1;
301
+ this.patch({
302
+ screenshot: null,
303
+ screenshotStatus: "none",
304
+ screenshotMessage: null,
305
+ pin: null,
306
+ });
307
+ }
308
+
309
+ async recapture(): Promise<void> {
310
+ if (!this.capture || this.isDraftLocked()) return;
311
+ await this.runCapture();
312
+ }
313
+
314
+ /** 캡처가 실패했을 때의 대체 경로 — 사용자가 직접 고른 파일. */
315
+ async attachFile(shot: FeedbackScreenshot): Promise<void> {
316
+ if (this.isDraftLocked()) return;
317
+ const generation = ++this.captureGeneration;
318
+ const outcome = await captureWithinLimit({
319
+ capture: () => shot,
320
+ reencode: this.reencode,
321
+ limitBytes: this.screenshotLimitBytes,
322
+ });
323
+ if (generation === this.captureGeneration && this.state.open && !this.isDraftLocked()) {
324
+ this.applyScreenshot(outcome.screenshot, outcome.failed);
325
+ }
326
+ }
327
+
328
+ private async runCapture(): Promise<void> {
329
+ const generation = ++this.captureGeneration;
330
+ this.patch({ screenshotStatus: "capturing", screenshotMessage: null });
331
+ const outcome = await captureWithinLimit({
332
+ capture: this.capture!,
333
+ reencode: this.reencode,
334
+ limitBytes: this.screenshotLimitBytes,
335
+ });
336
+ if (generation === this.captureGeneration && this.state.open) {
337
+ this.applyScreenshot(outcome.screenshot, outcome.failed);
338
+ }
339
+ }
340
+
341
+ private applyScreenshot(shot: FeedbackScreenshot | null, failed: boolean): void {
342
+ if (shot && !failed) {
343
+ // 핀은 이전 이미지 기준 좌표이므로 이미지가 바뀌면 함께 폐기한다.
344
+ this.patch({ screenshot: shot, screenshotStatus: "ready", screenshotMessage: null, pin: null });
345
+ return;
346
+ }
347
+ this.patch({
348
+ screenshot: null,
349
+ screenshotStatus: "failed",
350
+ screenshotMessage: SCREENSHOT_FAILED_MESSAGE,
351
+ pin: null,
352
+ });
353
+ }
354
+
355
+ // ── 포커스 ────────────────────────────────────────────────────────────────
356
+
357
+ tabNext(): string | null {
358
+ const next = this.ring.next();
359
+ this.patch({ focused: next });
360
+ return next;
361
+ }
362
+
363
+ tabPrev(): string | null {
364
+ const prev = this.ring.prev();
365
+ this.patch({ focused: prev });
366
+ return prev;
367
+ }
368
+
369
+ focus(id: string): boolean {
370
+ const ok = this.ring.focus(id);
371
+ if (ok) this.patch({ focused: this.ring.current });
372
+ return ok;
373
+ }
374
+
375
+ // ── 전송 ──────────────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * 제출. 실패해도 예외를 던지지 않는다 — 대기 큐에 남기고 입력을 보존한다.
379
+ * @returns 검증에 걸려 아무것도 보내지 않았으면 `null`.
380
+ */
381
+ async submit(): Promise<SubmitOutcome | null> {
382
+ this.submitAttempted = true;
383
+ if (this.state.submitStatus === "sending" || this.state.submitStatus === "pending") return null;
384
+ if (!this.isCommentValid()) {
385
+ this.recompute();
386
+ this.emit();
387
+ return null;
388
+ }
389
+
390
+ this.patch({ submitStatus: "sending", submitMessage: null });
391
+
392
+ const parts: ReportParts = {
393
+ kind: "report",
394
+ comment: this.state.comment,
395
+ priority: this.state.priority,
396
+ screenshot: this.state.screenshot,
397
+ pin: this.state.pin,
398
+ element: null,
399
+ };
400
+
401
+ let outcome: SubmitOutcome;
402
+ try {
403
+ const report = await this.createReport(parts);
404
+ this.lastReport = report;
405
+ outcome = await this.queue.submit(report);
406
+ } catch {
407
+ // 조립되지 않은 제보는 큐에 들어간 적이 없다. 대기 중으로 가장하지 않고 재시도 가능한
408
+ // 명시 실패로 남겨 사용자의 입력을 보존한다.
409
+ outcome = { delivered: false, id: null, queued: false };
410
+ }
411
+
412
+ if (outcome.delivered) {
413
+ this.patch({ submitStatus: "sent", submitMessage: SUBMIT_DONE_MESSAGE });
414
+ this.close({ keepResult: true });
415
+ } else if (outcome.queued) {
416
+ this.patch({ submitStatus: "pending", submitMessage: SUBMIT_PENDING_MESSAGE });
417
+ } else {
418
+ this.patch({ submitStatus: "failed", submitMessage: SUBMIT_FAILED_MESSAGE });
419
+ }
420
+ return outcome;
421
+ }
422
+
423
+ /** [다시 보내기]. 큐가 비면 완료로 넘어간다. */
424
+ async retry(): Promise<void> {
425
+ if (this.state.submitStatus !== "pending" && this.state.pending === 0) return;
426
+ const retryingCurrent = this.state.submitStatus === "pending";
427
+ if (retryingCurrent) this.patch({ submitStatus: "sending", submitMessage: null });
428
+ try {
429
+ await this.queue.retryNow();
430
+ } catch {
431
+ // retryNow 는 삼키는 게 정상이지만 방어적으로 한 번 더 막는다.
432
+ }
433
+ const remaining = await this.queue.size();
434
+ if (!retryingCurrent) {
435
+ this.patch({ pending: remaining });
436
+ } else {
437
+ const outcome = this.lastReport
438
+ ? this.queue.getOutcome?.(this.lastReport.clientSubmissionId) ?? null
439
+ : null;
440
+ const delivered = outcome?.delivered === true;
441
+ const rejected = outcome?.delivered === false;
442
+ const legacyDrained = !this.queue.getOutcome && remaining === 0;
443
+ if (delivered || legacyDrained) {
444
+ this.patch({ submitStatus: "sent", submitMessage: SUBMIT_DONE_MESSAGE });
445
+ this.close({ keepResult: true });
446
+ } else if (rejected) {
447
+ this.patch({ submitStatus: "failed", submitMessage: SUBMIT_FAILED_MESSAGE });
448
+ } else {
449
+ this.patch({ submitStatus: "pending", submitMessage: SUBMIT_PENDING_MESSAGE });
450
+ }
451
+ }
452
+ }
453
+
454
+ // ── 내부 ──────────────────────────────────────────────────────────────────
455
+
456
+ private isCommentValid(): boolean {
457
+ const value = this.state.comment;
458
+ return value.trim().length > 0 && value.length <= COMMENT_MAX_CHARS;
459
+ }
460
+
461
+ private isDraftLocked(): boolean {
462
+ return this.state.submitStatus === "sending" || this.state.submitStatus === "pending";
463
+ }
464
+
465
+ private commentErrorFor(): string | null {
466
+ const value = this.state.comment;
467
+ if (value.length > COMMENT_MAX_CHARS) return COMMENT_TOO_LONG_MESSAGE;
468
+ if (value.trim().length === 0) {
469
+ // 아직 아무것도 안 건드린 상태에서 경고를 띄우지 않는다.
470
+ return this.touched || this.submitAttempted ? COMMENT_REQUIRED_MESSAGE : null;
471
+ }
472
+ return null;
473
+ }
474
+
475
+ private actionsFor(): string[] {
476
+ // 웹은 DOM 요소를 직접 지목할 수 있고, 앱은 좌표(핀)로만 가리킨다.
477
+ return this.platform === "web" ? [MODAL_ACTION_PICK] : [MODAL_ACTION_PIN];
478
+ }
479
+
480
+ private focusOrderFor(): string[] {
481
+ const order: string[] = [MODAL_FIELD_COMMENT, MODAL_FIELD_PRIORITY];
482
+ if (this.state.screenshot) order.push(MODAL_ACTION_REMOVE_SCREENSHOT);
483
+ else order.push(MODAL_ACTION_ATTACH);
484
+ order.push(...this.actionsFor());
485
+ if (this.state.submitStatus === "pending") order.push(MODAL_ACTION_RETRY);
486
+ order.push(MODAL_ACTION_CANCEL, MODAL_ACTION_SEND);
487
+ return order;
488
+ }
489
+
490
+ private recompute(): void {
491
+ const commentError = this.commentErrorFor();
492
+ const canSubmit =
493
+ this.isCommentValid() &&
494
+ this.state.screenshotStatus !== "capturing" &&
495
+ (this.state.submitStatus === "idle" || this.state.submitStatus === "failed");
496
+ const canPin =
497
+ this.platform === "native" &&
498
+ this.state.screenshot !== null &&
499
+ this.state.screenshotStatus !== "capturing" &&
500
+ !this.isDraftLocked() &&
501
+ this.state.open;
502
+ const actions = this.actionsFor();
503
+ const focusOrder = this.state.open ? this.focusOrderFor() : [];
504
+ this.ring.setItems(focusOrder);
505
+ this.state = {
506
+ ...this.state,
507
+ commentError,
508
+ canSubmit,
509
+ canPin,
510
+ showRetry: this.state.submitStatus === "pending" || this.state.pending > 0,
511
+ actions,
512
+ focusOrder,
513
+ focused: this.state.open ? this.ring.current : null,
514
+ };
515
+ }
516
+
517
+ private patch(partial: Partial<ReportModalState>): void {
518
+ this.state = { ...this.state, ...partial };
519
+ this.recompute();
520
+ this.emit();
521
+ }
522
+
523
+ private emit(): void {
524
+ for (const listener of this.listeners) listener(this.state);
525
+ }
526
+ }
@@ -0,0 +1,110 @@
1
+ // 핀 지정.
2
+ //
3
+ // 핀은 "한 화면에 하나"다. 그래서 컬렉션이 아니라 단일 값으로 들고, 새로 찍으면 덮어쓴다.
4
+ // 좌표는 항상 0~1 상대값으로 보관한다 — 기기 해상도가 달라도 같은 지점을 가리키게 하려는 것이다.
5
+
6
+ import type { FeedbackPin } from "../types.js";
7
+
8
+ export interface PixelPoint {
9
+ x: number;
10
+ y: number;
11
+ }
12
+
13
+ export interface PixelSize {
14
+ width: number;
15
+ height: number;
16
+ }
17
+
18
+ function clamp01(value: number): number {
19
+ if (!Number.isFinite(value)) return 0;
20
+ if (value < 0) return 0;
21
+ if (value > 1) return 1;
22
+ return value;
23
+ }
24
+
25
+ /**
26
+ * 픽셀 좌표를 스크린샷 기준 0~1 상대 좌표로 바꾼다.
27
+ * 크기가 0 이하이면(아직 레이아웃 전) 나눗셈이 무의미하므로 0 으로 떨어뜨린다.
28
+ */
29
+ export function normalizePin(point: PixelPoint, size: PixelSize): FeedbackPin {
30
+ const x = size.width > 0 ? point.x / size.width : 0;
31
+ const y = size.height > 0 ? point.y / size.height : 0;
32
+ return { x: clamp01(x), y: clamp01(y) };
33
+ }
34
+
35
+ /** 상대 좌표를 다시 픽셀로 되돌린다(마커를 그릴 때 쓴다). */
36
+ export function denormalizePin(pin: FeedbackPin, size: PixelSize): PixelPoint {
37
+ return { x: pin.x * size.width, y: pin.y * size.height };
38
+ }
39
+
40
+ /**
41
+ * 핀 지정 화면의 상태기계.
42
+ *
43
+ * `draft` 는 지정 화면에서 만지는 중인 값, `confirmed` 는 모달로 돌아간 확정값이다.
44
+ * 둘을 나눠 둔 이유는 취소했을 때 이전 확정값이 살아남아야 하기 때문이다.
45
+ */
46
+ export class PinController {
47
+ private confirmedPin: FeedbackPin | null;
48
+ private draftPin: FeedbackPin | null;
49
+ private opened = false;
50
+
51
+ constructor(initial: FeedbackPin | null = null) {
52
+ this.confirmedPin = initial;
53
+ this.draftPin = initial;
54
+ }
55
+
56
+ get isOpen(): boolean {
57
+ return this.opened;
58
+ }
59
+
60
+ get draft(): FeedbackPin | null {
61
+ return this.draftPin;
62
+ }
63
+
64
+ get confirmed(): FeedbackPin | null {
65
+ return this.confirmedPin;
66
+ }
67
+
68
+ /** 화면에 떠 있는 핀 개수. 단일 핀 규칙이라 0 아니면 1이다. */
69
+ get count(): number {
70
+ return this.draftPin ? 1 : 0;
71
+ }
72
+
73
+ open(): void {
74
+ this.draftPin = this.confirmedPin;
75
+ this.opened = true;
76
+ }
77
+
78
+ /** 탭한 자리에 핀을 찍는다. 기존 핀이 있으면 그 자리로 옮긴다(추가가 아니다). */
79
+ place(point: PixelPoint, size: PixelSize): FeedbackPin {
80
+ const pin = normalizePin(point, size);
81
+ this.draftPin = pin;
82
+ return pin;
83
+ }
84
+
85
+ /** 이미 상대 좌표를 갖고 있을 때 쓰는 경로. */
86
+ placeRelative(pin: FeedbackPin): FeedbackPin {
87
+ const next = { x: clamp01(pin.x), y: clamp01(pin.y) };
88
+ this.draftPin = next;
89
+ return next;
90
+ }
91
+
92
+ confirm(): FeedbackPin | null {
93
+ this.confirmedPin = this.draftPin;
94
+ this.opened = false;
95
+ return this.confirmedPin;
96
+ }
97
+
98
+ /** 취소 — 이전에 확정한 좌표로 되돌린다. */
99
+ cancel(): FeedbackPin | null {
100
+ this.draftPin = this.confirmedPin;
101
+ this.opened = false;
102
+ return this.confirmedPin;
103
+ }
104
+
105
+ reset(): void {
106
+ this.confirmedPin = null;
107
+ this.draftPin = null;
108
+ this.opened = false;
109
+ }
110
+ }