@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.
package/src/uuid.ts ADDED
@@ -0,0 +1,54 @@
1
+ // UUID v4 생성기 — 폴리필/의존성 없이 자체 구현.
2
+ //
3
+ // 설계 이유:
4
+ // - crypto.randomUUID는 보안 컨텍스트(https/localhost)에서만 있고, 구형 런타임엔 없다.
5
+ // 표준 API가 없어도 멱등 키(clientSubmissionId)를 만들 수 있어야 한다(TC5).
6
+ // - 따라서 getRandomValues를 우선 쓰고, 그마저 없으면 Math.random으로 폴백한다.
7
+ // - 코어는 globalThis.crypto만 참조(document/window 아님) → 플랫폼 무관 유지.
8
+
9
+ function pickRandomBytes(): (arr: Uint8Array) => Uint8Array {
10
+ const g: typeof globalThis =
11
+ typeof globalThis !== "undefined" ? globalThis : ({} as typeof globalThis);
12
+ // DOM lib에 의존하지 않으려고 Crypto 전역 타입 대신 필요한 모양만 선언한다.
13
+ // 코어는 lib.dom 없이도 타입체크가 통과해야 한다(플랫폼 무관 요구사항).
14
+ const crypto = (g as { crypto?: { getRandomValues?(array: Uint8Array): Uint8Array } }).crypto;
15
+ const getRandomValues = crypto?.getRandomValues;
16
+ if (typeof getRandomValues === "function") {
17
+ // crypto에 바인딩해서 호출한다(구현체가 this를 요구한다).
18
+ return (arr) => getRandomValues.call(crypto, arr);
19
+ }
20
+ // 폴백: Math.random. 멱등 키 용도라 충분한 균등분포.
21
+ return (arr) => {
22
+ for (let i = 0; i < arr.length; i++) {
23
+ arr[i] = Math.floor(Math.random() * 256);
24
+ }
25
+ return arr;
26
+ };
27
+ }
28
+
29
+ /**
30
+ * RFC 4122 v4 UUID 문자열을 만든다(8-4-4-4-16).
31
+ * crypto.getRandomValues가 있으면 그것을, 없으면 Math.random을 쓴다.
32
+ */
33
+ export function uuidv4(): string {
34
+ const fill = pickRandomBytes();
35
+ const b = new Uint8Array(16);
36
+ fill(b);
37
+
38
+ // 버전(4)과 변형(10xx) 비트 고정.
39
+ b[6] = (b[6] & 0x0f) | 0x40;
40
+ b[8] = (b[8] & 0x3f) | 0x80;
41
+
42
+ const h = Array.from(b, (n) => n.toString(16).padStart(2, "0"));
43
+ return (
44
+ h.slice(0, 4).join("") +
45
+ "-" +
46
+ h.slice(4, 6).join("") +
47
+ "-" +
48
+ h.slice(6, 8).join("") +
49
+ "-" +
50
+ h.slice(8, 10).join("") +
51
+ "-" +
52
+ h.slice(10, 16).join("")
53
+ );
54
+ }
@@ -0,0 +1,224 @@
1
+ // 화면 지도.
2
+ //
3
+ // 위젯이 가질 수 있는 화면은 넷뿐이다: 플로팅 버튼 / 리포트 모달 / 핀 지정 / 요소 지목.
4
+ // 어느 화면에서 어느 화면으로 갈 수 있는지를 여기 한 곳에 모아 둔다 — 흩어지면
5
+ // "취소했는데 이전 좌표가 날아간다" 같은 사고가 화면마다 따로 생긴다.
6
+
7
+ import type { FeedbackPin } from "../types.js";
8
+ import type { SubmitOutcome } from "../queue.js";
9
+ import {
10
+ ReportModalController,
11
+ type ReportModalOpts,
12
+ type ReportModalState,
13
+ type WidgetPlatform,
14
+ } from "./modal.js";
15
+ import { PinController, type PixelPoint, type PixelSize } from "./pin.js";
16
+
17
+ /** 위젯이 보여줄 수 있는 화면. */
18
+ export type WidgetScreen = "button" | "modal" | "pin" | "picking";
19
+
20
+ export interface WidgetControllerOpts extends ReportModalOpts {
21
+ /** 저장돼 있던 요소 지목 모드를 복원할 때 쓴다(새로고침·페이지 이동 후). */
22
+ initialPicking?: boolean;
23
+ /** 지목 모드가 켜지고 꺼질 때 호출. 호스트가 저장소에 기록한다. */
24
+ onPickingChange?: (active: boolean) => void;
25
+ }
26
+
27
+ export interface WidgetState {
28
+ screen: WidgetScreen;
29
+ modal: ReportModalState;
30
+ /** 핀 지정 화면에서 만지는 중인 좌표. */
31
+ pinDraft: FeedbackPin | null;
32
+ /** 모달로 돌아간 확정 좌표. */
33
+ pinConfirmed: FeedbackPin | null;
34
+ pickingActive: boolean;
35
+ }
36
+
37
+ export type WidgetListener = (state: WidgetState) => void;
38
+
39
+ export class WidgetController {
40
+ readonly modal: ReportModalController;
41
+ readonly pin: PinController;
42
+
43
+ private readonly platform: WidgetPlatform;
44
+ private readonly onPickingChange: ((active: boolean) => void) | null;
45
+ private readonly listeners = new Set<WidgetListener>();
46
+ private readonly unsubscribeModal: () => void;
47
+
48
+ private screen: WidgetScreen;
49
+ private picking: boolean;
50
+
51
+ constructor(opts: WidgetControllerOpts) {
52
+ this.platform = opts.platform;
53
+ this.onPickingChange = opts.onPickingChange ?? null;
54
+ this.modal = new ReportModalController(opts);
55
+ this.pin = new PinController(null);
56
+ opts.queue.start?.();
57
+ this.picking = opts.initialPicking ?? false;
58
+ this.screen = this.picking ? "picking" : "button";
59
+
60
+ // 모달이 스스로 닫히는 경로(전송 성공, 큐 자동 배출)도 화면 지도에 반영해야 한다.
61
+ this.unsubscribeModal = this.modal.subscribe((state) => {
62
+ // 스크린샷을 제거하거나 모달을 새로 열면 그 이미지에 속했던 확정 핀도 함께 폐기한다.
63
+ // modal.pin만 비우면 PinController의 confirmed 값이 다음 핀 화면에서 되살아난다.
64
+ if (state.pin === null && this.pin.confirmed !== null) this.pin.reset();
65
+ if (!state.open && (this.screen === "modal" || this.screen === "pin")) {
66
+ this.screen = this.picking ? "picking" : "button";
67
+ }
68
+ this.emit();
69
+ });
70
+ }
71
+
72
+ // ── 조회 ──────────────────────────────────────────────────────────────────
73
+
74
+ getScreen(): WidgetScreen {
75
+ return this.screen;
76
+ }
77
+
78
+ getState(): WidgetState {
79
+ return {
80
+ screen: this.screen,
81
+ modal: this.modal.getState(),
82
+ pinDraft: this.pin.draft,
83
+ pinConfirmed: this.pin.confirmed,
84
+ pickingActive: this.picking,
85
+ };
86
+ }
87
+
88
+ get isPicking(): boolean {
89
+ return this.picking;
90
+ }
91
+
92
+ subscribe(listener: WidgetListener): () => void {
93
+ this.listeners.add(listener);
94
+ listener(this.getState());
95
+ return () => {
96
+ this.listeners.delete(listener);
97
+ };
98
+ }
99
+
100
+ dispose(): void {
101
+ this.unsubscribeModal();
102
+ this.modal.dispose();
103
+ this.modal.stopQueue();
104
+ this.listeners.clear();
105
+ }
106
+
107
+ // ── 플로팅 버튼 ↔ 모달 ────────────────────────────────────────────────────
108
+
109
+ async openReport(): Promise<void> {
110
+ this.screen = "modal";
111
+ await this.modal.open();
112
+ this.emit();
113
+ }
114
+
115
+ /** 모달 닫기 요청. 쓰던 내용이 있으면 확인부터 받는다(화면은 그대로 모달). */
116
+ closeReport(): "closed" | "confirm" {
117
+ const result = this.modal.requestClose();
118
+ if (result === "closed") {
119
+ this.pin.reset();
120
+ this.screen = this.picking ? "picking" : "button";
121
+ }
122
+ this.emit();
123
+ return result;
124
+ }
125
+
126
+ confirmCloseReport(): void {
127
+ this.modal.confirmClose();
128
+ this.pin.reset();
129
+ this.screen = this.picking ? "picking" : "button";
130
+ this.emit();
131
+ }
132
+
133
+ cancelCloseReport(): void {
134
+ this.modal.cancelClose();
135
+ this.emit();
136
+ }
137
+
138
+ async submitReport(): Promise<SubmitOutcome | null> {
139
+ const outcome = await this.modal.submit();
140
+ if (outcome?.delivered) this.pin.reset();
141
+ this.emit();
142
+ return outcome;
143
+ }
144
+
145
+ async retryReport(): Promise<void> {
146
+ await this.modal.retry();
147
+ this.emit();
148
+ }
149
+
150
+ // ── 핀 지정 ───────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * 핀 지정 화면 열기. 스크린샷이 없으면 좌표가 가리킬 대상이 없으므로 열지 않는다.
154
+ * @returns 열렸으면 true.
155
+ */
156
+ openPinScreen(): boolean {
157
+ if (!this.modal.getState().canPin) return false;
158
+ this.pin.open();
159
+ this.screen = "pin";
160
+ this.emit();
161
+ return true;
162
+ }
163
+
164
+ /** 핀 지정 화면에서 탭. 기존 핀이 있으면 그 자리로 옮긴다. */
165
+ placePin(point: PixelPoint, size: PixelSize): FeedbackPin | null {
166
+ if (this.screen !== "pin") return null;
167
+ const placed = this.pin.place(point, size);
168
+ this.emit();
169
+ return placed;
170
+ }
171
+
172
+ /** 확정 — 모달로 돌아가고 좌표가 제출에 실린다. */
173
+ confirmPin(): FeedbackPin | null {
174
+ if (this.screen !== "pin") return this.pin.confirmed;
175
+ const confirmed = this.pin.confirm();
176
+ this.modal.setPin(confirmed);
177
+ this.screen = "modal";
178
+ this.emit();
179
+ return confirmed;
180
+ }
181
+
182
+ /** 취소 — 이전 확정 좌표를 그대로 두고 모달로 돌아간다. */
183
+ cancelPin(): FeedbackPin | null {
184
+ if (this.screen !== "pin") return this.pin.confirmed;
185
+ const previous = this.pin.cancel();
186
+ this.modal.setPin(previous);
187
+ this.screen = "modal";
188
+ this.emit();
189
+ return previous;
190
+ }
191
+
192
+ // ── 요소 지목 (웹 전용) ───────────────────────────────────────────────────
193
+
194
+ /**
195
+ * 요소 지목 모드 시작. 모달에서 넘어오는 흐름이라 닫기 확인을 묻지 않는다
196
+ * (사용자가 이미 "지목하러 가겠다"고 밝힌 상태다).
197
+ */
198
+ startPicking(): boolean {
199
+ if (this.platform !== "web") return false;
200
+ this.modal.dismiss();
201
+ this.setPicking(true);
202
+ this.screen = "picking";
203
+ this.emit();
204
+ return true;
205
+ }
206
+
207
+ /** [지목 종료]. 플로팅 버튼 화면으로 돌아가고 저장된 모드 표시도 지운다. */
208
+ stopPicking(): void {
209
+ this.setPicking(false);
210
+ if (this.screen === "picking") this.screen = "button";
211
+ this.emit();
212
+ }
213
+
214
+ private setPicking(active: boolean): void {
215
+ if (this.picking === active) return;
216
+ this.picking = active;
217
+ this.onPickingChange?.(active);
218
+ }
219
+
220
+ private emit(): void {
221
+ const state = this.getState();
222
+ for (const listener of this.listeners) listener(state);
223
+ }
224
+ }
@@ -0,0 +1,66 @@
1
+ // 모달 포커스 순서.
2
+ //
3
+ // DOM 없이도 검증할 수 있도록 "포커스 가능한 것들의 순서"를 문자열 id 목록으로 들고 돈다.
4
+ // 실제 렌더러는 이 id 를 자기 노드에 매달아 두고 focus() 를 걸면 된다.
5
+
6
+ /** 위젯 플로팅 버튼의 고정 id. 모달을 닫으면 여기로 포커스를 되돌린다. */
7
+ export const FLOATING_BUTTON_ID = "feedback-kit-floating-button";
8
+
9
+ /**
10
+ * 순환하는 포커스 링. 끝에서 Tab 하면 처음으로 돌아온다 = 포커스가 모달 밖으로 새지 않는다.
11
+ */
12
+ export class FocusRing {
13
+ private items: string[];
14
+ private index: number;
15
+
16
+ constructor(items: readonly string[] = []) {
17
+ this.items = [...items];
18
+ this.index = this.items.length > 0 ? 0 : -1;
19
+ }
20
+
21
+ get order(): readonly string[] {
22
+ return this.items;
23
+ }
24
+
25
+ get current(): string | null {
26
+ return this.index >= 0 && this.index < this.items.length ? this.items[this.index]! : null;
27
+ }
28
+
29
+ /**
30
+ * 항목 목록을 바꾼다. 지금 포커스된 항목이 새 목록에도 있으면 그 자리를 유지한다
31
+ * (스크린샷을 지웠다고 포커스가 처음으로 튀면 키보드 사용자가 길을 잃는다).
32
+ */
33
+ setItems(items: readonly string[]): void {
34
+ const focused = this.current;
35
+ this.items = [...items];
36
+ if (this.items.length === 0) {
37
+ this.index = -1;
38
+ return;
39
+ }
40
+ const keep = focused === null ? -1 : this.items.indexOf(focused);
41
+ this.index = keep >= 0 ? keep : 0;
42
+ }
43
+
44
+ focus(id: string): boolean {
45
+ const at = this.items.indexOf(id);
46
+ if (at < 0) return false;
47
+ this.index = at;
48
+ return true;
49
+ }
50
+
51
+ next(): string | null {
52
+ if (this.items.length === 0) return null;
53
+ this.index = (this.index + 1) % this.items.length;
54
+ return this.current;
55
+ }
56
+
57
+ prev(): string | null {
58
+ if (this.items.length === 0) return null;
59
+ this.index = (this.index - 1 + this.items.length) % this.items.length;
60
+ return this.current;
61
+ }
62
+
63
+ contains(id: string): boolean {
64
+ return this.items.includes(id);
65
+ }
66
+ }
@@ -0,0 +1,59 @@
1
+ // 위젯 헤드리스 계층 진입점.
2
+ //
3
+ // 여기 있는 것들은 전부 순수 TS다 — DOM 도 React Native API 도 참조하지 않는다.
4
+ // 웹/앱 패키지가 이 상태기계를 자기 렌더러에 얹는다.
5
+
6
+ export {
7
+ captureWithinLimit,
8
+ SCREENSHOT_FAILED_MESSAGE,
9
+ SCREENSHOT_MAX_BASE64_BYTES,
10
+ SCREENSHOT_QUALITY_STEPS,
11
+ screenshotBytes,
12
+ } from "./screenshot.js";
13
+ export type {
14
+ CaptureWithinLimitOpts,
15
+ ScreenshotCapture,
16
+ ScreenshotOutcome,
17
+ ScreenshotReencode,
18
+ } from "./screenshot.js";
19
+
20
+ export { denormalizePin, normalizePin, PinController } from "./pin.js";
21
+ export type { PixelPoint, PixelSize } from "./pin.js";
22
+
23
+ export { FLOATING_BUTTON_ID, FocusRing } from "./focus.js";
24
+
25
+ export {
26
+ COMMENT_MAX_CHARS,
27
+ COMMENT_REQUIRED_MESSAGE,
28
+ COMMENT_TOO_LONG_MESSAGE,
29
+ MODAL_ACTION_ATTACH,
30
+ MODAL_ACTION_CANCEL,
31
+ MODAL_ACTION_PICK,
32
+ MODAL_ACTION_PIN,
33
+ MODAL_ACTION_REMOVE_SCREENSHOT,
34
+ MODAL_ACTION_RETRY,
35
+ MODAL_ACTION_SEND,
36
+ MODAL_FIELD_COMMENT,
37
+ MODAL_FIELD_PRIORITY,
38
+ ReportModalController,
39
+ SUBMIT_DONE_MESSAGE,
40
+ SUBMIT_FAILED_MESSAGE,
41
+ SUBMIT_PENDING_MESSAGE,
42
+ } from "./modal.js";
43
+ export type {
44
+ ModalQueueLike,
45
+ ModalSubmitStatus,
46
+ ReportModalListener,
47
+ ReportModalOpts,
48
+ ReportModalState,
49
+ ScreenshotStatus,
50
+ WidgetPlatform,
51
+ } from "./modal.js";
52
+
53
+ export { WidgetController } from "./controller.js";
54
+ export type {
55
+ WidgetControllerOpts,
56
+ WidgetListener,
57
+ WidgetScreen,
58
+ WidgetState,
59
+ } from "./controller.js";