@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.
@@ -0,0 +1,778 @@
1
+ import {
2
+ FLOATING_BUTTON_ID,
3
+ MODAL_ACTION_CANCEL,
4
+ MODAL_ACTION_PICK,
5
+ MODAL_ACTION_REMOVE_SCREENSHOT,
6
+ MODAL_ACTION_RETRY,
7
+ MODAL_ACTION_SEND,
8
+ MODAL_FIELD_COMMENT,
9
+ MODAL_FIELD_PRIORITY,
10
+ type FeedbackPriority,
11
+ type FeedbackScreenshot,
12
+ type WidgetState,
13
+ } from "@solhun/feedback-kit-core";
14
+ import {
15
+ type ChangeEvent,
16
+ type CSSProperties,
17
+ type FormEvent,
18
+ type KeyboardEvent,
19
+ type MouseEvent,
20
+ useEffect,
21
+ useRef,
22
+ useState,
23
+ } from "react";
24
+
25
+ import type { PickingState } from "./picking.js";
26
+ import { OWN_UI_ATTR } from "./picking.js";
27
+ import { createWebWidget, type WebWidget, type WebWidgetOpts } from "./widget.js";
28
+
29
+ /**
30
+ * 패키지를 붙인 제품의 디자인 시스템을 침범하지 않는 중립 토큰이다.
31
+ * 모든 값은 인라인 스타일로만 쓰여 호스트의 전역 CSS와 충돌하지 않는다.
32
+ */
33
+ const TOKENS = {
34
+ ink: "#17202a",
35
+ muted: "#667085",
36
+ surface: "#ffffff",
37
+ subtle: "#f4f6f8",
38
+ line: "#d7dce2",
39
+ accent: "#315d73",
40
+ accentHover: "#274b5d",
41
+ danger: "#b42318",
42
+ warning: "#8a5a00",
43
+ success: "#18794e",
44
+ shadow: "0 18px 50px rgba(23, 32, 42, 0.2)",
45
+ radius: "14px",
46
+ } as const;
47
+
48
+ const OWN_UI_PROPS = { [OWN_UI_ATTR]: "" } as Record<string, string>;
49
+ const FONT_STACK =
50
+ "ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
51
+
52
+ export type FeedbackKitProps = WebWidgetOpts;
53
+
54
+ const INITIAL_PICKING_STATE: PickingState = {
55
+ active: false,
56
+ hovered: null,
57
+ popup: null,
58
+ markers: [],
59
+ };
60
+
61
+ function screenshotSource(screenshot: FeedbackScreenshot): string {
62
+ return `data:${screenshot.contentType};base64,${screenshot.base64}`;
63
+ }
64
+
65
+ function readScreenshotFile(file: File): Promise<FeedbackScreenshot | null> {
66
+ if (file.type !== "image/png" && file.type !== "image/jpeg") {
67
+ return Promise.resolve(null);
68
+ }
69
+ const contentType: FeedbackScreenshot["contentType"] = file.type;
70
+
71
+ return new Promise((resolve) => {
72
+ const reader = new FileReader();
73
+ reader.onerror = () => resolve(null);
74
+ reader.onload = () => {
75
+ const result = typeof reader.result === "string" ? reader.result : "";
76
+ const comma = result.indexOf(",");
77
+ if (comma < 0) {
78
+ resolve(null);
79
+ return;
80
+ }
81
+ resolve({
82
+ base64: result.slice(comma + 1),
83
+ contentType,
84
+ });
85
+ };
86
+ reader.readAsDataURL(file);
87
+ });
88
+ }
89
+
90
+ function nextPaint(): Promise<void> {
91
+ return new Promise((resolve) => {
92
+ if (typeof requestAnimationFrame === "function") {
93
+ requestAnimationFrame(() => resolve());
94
+ } else {
95
+ setTimeout(resolve, 0);
96
+ }
97
+ });
98
+ }
99
+
100
+ function focusableElements(container: HTMLElement): HTMLElement[] {
101
+ return Array.from(
102
+ container.querySelectorAll<HTMLElement>(
103
+ "button:not([disabled]), textarea:not([disabled]), select:not([disabled]), input:not([disabled]):not([type='hidden']), [tabindex]:not([tabindex='-1'])",
104
+ ),
105
+ ).filter((node) => node.getAttribute("aria-hidden") !== "true");
106
+ }
107
+
108
+ function trapTab(event: KeyboardEvent<HTMLElement>, container: HTMLElement): void {
109
+ if (event.key !== "Tab") return;
110
+ const items = focusableElements(container);
111
+ if (items.length === 0) {
112
+ event.preventDefault();
113
+ return;
114
+ }
115
+
116
+ const first = items[0]!;
117
+ const last = items[items.length - 1]!;
118
+ const active = document.activeElement;
119
+ if (event.shiftKey && (active === first || !container.contains(active))) {
120
+ event.preventDefault();
121
+ last.focus();
122
+ } else if (!event.shiftKey && (active === last || !container.contains(active))) {
123
+ event.preventDefault();
124
+ first.focus();
125
+ }
126
+ }
127
+
128
+ const baseButton: CSSProperties = {
129
+ minHeight: 38,
130
+ border: `1px solid ${TOKENS.line}`,
131
+ borderRadius: 9,
132
+ background: TOKENS.surface,
133
+ color: TOKENS.ink,
134
+ padding: "8px 12px",
135
+ font: "inherit",
136
+ fontWeight: 650,
137
+ cursor: "pointer",
138
+ };
139
+
140
+ const primaryButton: CSSProperties = {
141
+ ...baseButton,
142
+ borderColor: TOKENS.accent,
143
+ background: TOKENS.accent,
144
+ color: TOKENS.surface,
145
+ };
146
+
147
+ const inputStyle: CSSProperties = {
148
+ width: "100%",
149
+ boxSizing: "border-box",
150
+ border: `1px solid ${TOKENS.line}`,
151
+ borderRadius: 9,
152
+ background: TOKENS.surface,
153
+ color: TOKENS.ink,
154
+ padding: "10px 11px",
155
+ font: "inherit",
156
+ };
157
+
158
+ function MarkerStatus({ status }: { status: "sending" | "done" | "pending" }) {
159
+ const presentation =
160
+ status === "sending"
161
+ ? { icon: "↻", text: "전송 중", color: TOKENS.accent }
162
+ : status === "done"
163
+ ? { icon: "✓", text: "완료", color: TOKENS.success }
164
+ : { icon: "◷", text: "대기", color: TOKENS.warning };
165
+
166
+ return (
167
+ <span style={{ display: "inline-flex", gap: 4, alignItems: "center", color: presentation.color }}>
168
+ <span
169
+ aria-hidden="true"
170
+ style={
171
+ status === "sending"
172
+ ? { display: "inline-block", animation: "feedback-kit-spin 900ms linear infinite" }
173
+ : undefined
174
+ }
175
+ >
176
+ {presentation.icon}
177
+ </span>
178
+ <span>{presentation.text}</span>
179
+ </span>
180
+ );
181
+ }
182
+
183
+ /**
184
+ * `createWebWidget`의 헤드리스 상태를 실제 DOM으로 그리는 공개 React 진입점.
185
+ * 큐·제보 조립·스크린샷 캡처는 모두 props로 주입된 기존 계약을 그대로 사용한다.
186
+ */
187
+ export function FeedbackKit(props: FeedbackKitProps) {
188
+ const rootRef = useRef<HTMLDivElement | null>(null);
189
+ const floatingButtonRef = useRef<HTMLButtonElement | null>(null);
190
+ const modalRef = useRef<HTMLDivElement | null>(null);
191
+ const confirmRef = useRef<HTMLDivElement | null>(null);
192
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
193
+ const previousScreenRef = useRef<WidgetState["screen"]>("button");
194
+ const previousModalOpenRef = useRef(false);
195
+ const previousConfirmVisibleRef = useRef(false);
196
+ const [kit, setKit] = useState<WebWidget | null>(null);
197
+ const [widgetState, setWidgetState] = useState<WidgetState | null>(null);
198
+ const [pickingState, setPickingState] = useState<PickingState>(INITIAL_PICKING_STATE);
199
+ const [announcement, setAnnouncement] = useState<string | null>(null);
200
+
201
+ const {
202
+ queue,
203
+ createReport,
204
+ capture,
205
+ reencode,
206
+ screenshotLimitBytes,
207
+ store,
208
+ getPathname,
209
+ doc,
210
+ getViewport,
211
+ } = props;
212
+
213
+ useEffect(() => {
214
+ const created = createWebWidget({
215
+ queue,
216
+ createReport,
217
+ capture,
218
+ reencode,
219
+ screenshotLimitBytes,
220
+ store,
221
+ getPathname,
222
+ doc,
223
+ getViewport,
224
+ });
225
+ setKit(created);
226
+ const unsubscribeWidget = created.widget.subscribe(setWidgetState);
227
+ const unsubscribePicking = created.picking.subscribe(setPickingState);
228
+
229
+ return () => {
230
+ unsubscribeWidget();
231
+ unsubscribePicking();
232
+ created.dispose();
233
+ };
234
+ }, [
235
+ queue,
236
+ createReport,
237
+ capture,
238
+ reencode,
239
+ screenshotLimitBytes,
240
+ store,
241
+ getPathname,
242
+ doc,
243
+ getViewport,
244
+ ]);
245
+
246
+ useEffect(() => {
247
+ if (!widgetState) return;
248
+ const { modal, screen } = widgetState;
249
+ if (modal.submitMessage) setAnnouncement(modal.submitMessage);
250
+
251
+ if (modal.open && !previousModalOpenRef.current && modal.focused) {
252
+ document.querySelector<HTMLElement>(`[data-fk-focus-id="${modal.focused}"]`)?.focus();
253
+ }
254
+ if (modal.closeConfirmVisible && !previousConfirmVisibleRef.current) confirmRef.current?.focus();
255
+
256
+ const wasModal = previousScreenRef.current === "modal";
257
+ if (wasModal && screen === "button" && modal.restoreFocusTo === FLOATING_BUTTON_ID) {
258
+ void nextPaint().then(() => floatingButtonRef.current?.focus());
259
+ }
260
+ previousScreenRef.current = screen;
261
+ previousModalOpenRef.current = modal.open;
262
+ previousConfirmVisibleRef.current = modal.closeConfirmVisible;
263
+ }, [widgetState]);
264
+
265
+ if (!kit || !widgetState) {
266
+ return <div {...OWN_UI_PROPS} data-feedback-kit-loading="true" />;
267
+ }
268
+
269
+ const activeKit = kit;
270
+ const { modal, screen } = widgetState;
271
+ const draftLocked = modal.submitStatus === "sending" || modal.submitStatus === "pending";
272
+
273
+ async function withOwnUiHidden(action: () => Promise<void>): Promise<void> {
274
+ const root = rootRef.current;
275
+ const previousVisibility = root?.style.visibility ?? "";
276
+ if (root) root.style.visibility = "hidden";
277
+ try {
278
+ await nextPaint();
279
+ await action();
280
+ } finally {
281
+ if (root) root.style.visibility = previousVisibility;
282
+ }
283
+ }
284
+
285
+ async function openReport(): Promise<void> {
286
+ setAnnouncement(null);
287
+ await withOwnUiHidden(() => activeKit.widget.openReport());
288
+ }
289
+
290
+ async function recapture(): Promise<void> {
291
+ await withOwnUiHidden(() => activeKit.widget.modal.recapture());
292
+ }
293
+
294
+ async function attachFile(event: ChangeEvent<HTMLInputElement>): Promise<void> {
295
+ const input = event.currentTarget;
296
+ const file = input.files?.[0];
297
+ if (file) {
298
+ const screenshot = await readScreenshotFile(file);
299
+ if (screenshot) await activeKit.widget.modal.attachFile(screenshot);
300
+ }
301
+ input.value = "";
302
+ }
303
+
304
+ function closeReport(): void {
305
+ activeKit.widget.closeReport();
306
+ }
307
+
308
+ function handleModalKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
309
+ if (event.key === "Escape") {
310
+ event.preventDefault();
311
+ if (modal.closeConfirmVisible) activeKit.widget.cancelCloseReport();
312
+ else closeReport();
313
+ return;
314
+ }
315
+ trapTab(event, modal.closeConfirmVisible ? confirmRef.current ?? event.currentTarget : event.currentTarget);
316
+ }
317
+
318
+ function handleBackdrop(event: MouseEvent<HTMLDivElement>): void {
319
+ if (event.target === event.currentTarget) closeReport();
320
+ }
321
+
322
+ function submitReport(event: FormEvent<HTMLFormElement>): void {
323
+ event.preventDefault();
324
+ void activeKit.widget.submitReport();
325
+ }
326
+
327
+ const floating = screen === "button" || screen === "picking";
328
+ const hoverBox = pickingState.hovered?.boundingBox;
329
+ const popup = pickingState.popup;
330
+
331
+ return (
332
+ <div
333
+ ref={rootRef}
334
+ {...OWN_UI_PROPS}
335
+ style={{ fontFamily: FONT_STACK, color: TOKENS.ink, fontSize: 14, lineHeight: 1.45 }}
336
+ >
337
+ <style>{`@keyframes feedback-kit-spin{to{transform:rotate(360deg)}}`}</style>
338
+
339
+ {floating ? (
340
+ <button
341
+ ref={floatingButtonRef}
342
+ id={FLOATING_BUTTON_ID}
343
+ type="button"
344
+ disabled={screen === "picking"}
345
+ aria-label={screen === "picking" ? "요소 지목 중" : "피드백 보내기"}
346
+ onClick={() => void openReport()}
347
+ style={{
348
+ ...primaryButton,
349
+ position: "fixed",
350
+ right: 24,
351
+ bottom: 24,
352
+ zIndex: 2147483600,
353
+ minWidth: 112,
354
+ minHeight: 46,
355
+ borderRadius: 24,
356
+ boxShadow: TOKENS.shadow,
357
+ opacity: screen === "picking" ? 0.76 : 1,
358
+ cursor: screen === "picking" ? "default" : "pointer",
359
+ }}
360
+ >
361
+ {screen === "picking" ? "지목 중" : "피드백"}
362
+ </button>
363
+ ) : null}
364
+
365
+ {announcement && !modal.open ? (
366
+ <div
367
+ role="status"
368
+ aria-live="polite"
369
+ style={{
370
+ position: "fixed",
371
+ right: 24,
372
+ bottom: 82,
373
+ zIndex: 2147483601,
374
+ padding: "10px 14px",
375
+ borderRadius: 10,
376
+ background: TOKENS.ink,
377
+ color: TOKENS.surface,
378
+ boxShadow: TOKENS.shadow,
379
+ }}
380
+ >
381
+ {announcement}
382
+ </div>
383
+ ) : null}
384
+
385
+ {screen === "modal" && modal.open ? (
386
+ <div
387
+ onMouseDown={handleBackdrop}
388
+ style={{
389
+ position: "fixed",
390
+ inset: 0,
391
+ zIndex: 2147483602,
392
+ display: "grid",
393
+ placeItems: "center",
394
+ padding: 20,
395
+ background: "rgba(23, 32, 42, 0.48)",
396
+ }}
397
+ >
398
+ <div
399
+ ref={modalRef}
400
+ role="dialog"
401
+ aria-modal="true"
402
+ aria-labelledby="feedback-kit-dialog-title"
403
+ onKeyDown={handleModalKeyDown}
404
+ style={{
405
+ width: "min(520px, 100%)",
406
+ maxHeight: "min(760px, calc(100vh - 40px))",
407
+ overflowY: "auto",
408
+ boxSizing: "border-box",
409
+ border: `1px solid ${TOKENS.line}`,
410
+ borderRadius: TOKENS.radius,
411
+ background: TOKENS.surface,
412
+ padding: 22,
413
+ boxShadow: TOKENS.shadow,
414
+ }}
415
+ >
416
+ <form onSubmit={submitReport}>
417
+ <h2 id="feedback-kit-dialog-title" style={{ margin: "0 0 4px", fontSize: 20 }}>
418
+ 피드백 보내기
419
+ </h2>
420
+ <p style={{ margin: "0 0 18px", color: TOKENS.muted }}>
421
+ 현재 화면의 문제나 의견을 남겨주세요.
422
+ </p>
423
+
424
+ <section aria-labelledby="feedback-kit-screenshot-label" style={{ marginBottom: 16 }}>
425
+ <strong id="feedback-kit-screenshot-label">스크린샷</strong>
426
+ <div
427
+ style={{
428
+ display: "grid",
429
+ placeItems: "center",
430
+ minHeight: 150,
431
+ marginTop: 7,
432
+ overflow: "hidden",
433
+ border: `1px solid ${TOKENS.line}`,
434
+ borderRadius: 10,
435
+ background: TOKENS.subtle,
436
+ }}
437
+ >
438
+ {modal.screenshotStatus === "capturing" ? (
439
+ <span role="status">화면 캡처 중…</span>
440
+ ) : modal.screenshot ? (
441
+ <img
442
+ src={screenshotSource(modal.screenshot)}
443
+ alt="자동 캡처된 현재 화면 미리보기"
444
+ style={{ display: "block", maxWidth: "100%", maxHeight: 260, objectFit: "contain" }}
445
+ />
446
+ ) : (
447
+ <span role="status" style={{ padding: 18, color: TOKENS.muted, textAlign: "center" }}>
448
+ {modal.screenshotMessage ?? "스크린샷 없음"}
449
+ </span>
450
+ )}
451
+ </div>
452
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
453
+ {modal.screenshot ? (
454
+ <button
455
+ type="button"
456
+ data-fk-focus-id={MODAL_ACTION_REMOVE_SCREENSHOT}
457
+ disabled={draftLocked}
458
+ onClick={() => kit.widget.modal.removeScreenshot()}
459
+ style={baseButton}
460
+ >
461
+ 제거
462
+ </button>
463
+ ) : null}
464
+ <button
465
+ type="button"
466
+ disabled={draftLocked || modal.screenshotStatus === "capturing"}
467
+ onClick={() => void recapture()}
468
+ style={baseButton}
469
+ >
470
+ 다시 찍기
471
+ </button>
472
+ <button
473
+ type="button"
474
+ disabled={draftLocked}
475
+ onClick={() => fileInputRef.current?.click()}
476
+ style={baseButton}
477
+ >
478
+ {modal.screenshot ? "파일로 교체" : "파일로 첨부"}
479
+ </button>
480
+ <input
481
+ ref={fileInputRef}
482
+ type="file"
483
+ accept="image/png,image/jpeg"
484
+ tabIndex={-1}
485
+ aria-hidden="true"
486
+ onChange={(event) => void attachFile(event)}
487
+ style={{ display: "none" }}
488
+ />
489
+ </div>
490
+ </section>
491
+
492
+ <label htmlFor="feedback-kit-comment" style={{ display: "block", fontWeight: 700 }}>
493
+ 코멘트 <span aria-hidden="true">*</span>
494
+ </label>
495
+ <textarea
496
+ id="feedback-kit-comment"
497
+ data-fk-focus-id={MODAL_FIELD_COMMENT}
498
+ value={modal.comment}
499
+ disabled={draftLocked}
500
+ aria-required="true"
501
+ aria-invalid={modal.commentError ? "true" : undefined}
502
+ aria-describedby={modal.commentError ? "feedback-kit-comment-error" : undefined}
503
+ onChange={(event) => kit.widget.modal.setComment(event.currentTarget.value)}
504
+ rows={5}
505
+ placeholder="무엇이 불편했는지 알려주세요"
506
+ style={{ ...inputStyle, marginTop: 7, resize: "vertical" }}
507
+ />
508
+ <div style={{ minHeight: 21, marginTop: 3 }}>
509
+ {modal.commentError ? (
510
+ <span id="feedback-kit-comment-error" role="alert" style={{ color: TOKENS.danger }}>
511
+ {modal.commentError}
512
+ </span>
513
+ ) : (
514
+ <span style={{ color: TOKENS.muted }}>{modal.comment.length.toLocaleString()} / 4,000</span>
515
+ )}
516
+ </div>
517
+
518
+ <label htmlFor="feedback-kit-priority" style={{ display: "block", marginTop: 10, fontWeight: 700 }}>
519
+ 우선순위
520
+ </label>
521
+ <select
522
+ id="feedback-kit-priority"
523
+ data-fk-focus-id={MODAL_FIELD_PRIORITY}
524
+ value={modal.priority}
525
+ disabled={draftLocked}
526
+ onChange={(event) =>
527
+ kit.widget.modal.setPriority(event.currentTarget.value as FeedbackPriority)
528
+ }
529
+ style={{ ...inputStyle, marginTop: 7 }}
530
+ >
531
+ <option value="unset">선택 안 함</option>
532
+ <option value="normal">보통</option>
533
+ <option value="high">높음</option>
534
+ <option value="urgent">긴급</option>
535
+ </select>
536
+
537
+ <button
538
+ type="button"
539
+ role="switch"
540
+ aria-checked="false"
541
+ disabled={draftLocked}
542
+ data-fk-focus-id={MODAL_ACTION_PICK}
543
+ onClick={() => kit.widget.startPicking()}
544
+ style={{ ...baseButton, width: "100%", marginTop: 16 }}
545
+ >
546
+ 요소 지목
547
+ </button>
548
+
549
+ {modal.submitStatus !== "idle" || modal.pending > 0 ? (
550
+ <div
551
+ role="status"
552
+ aria-live="polite"
553
+ style={{
554
+ marginTop: 14,
555
+ padding: 11,
556
+ borderRadius: 9,
557
+ background: TOKENS.subtle,
558
+ color: modal.submitStatus === "pending" ? TOKENS.warning : TOKENS.ink,
559
+ }}
560
+ >
561
+ {modal.submitStatus === "sending" ? "전송 중…" : modal.submitMessage}
562
+ {modal.pending > 0 ? ` · 대기 큐 ${modal.pending}건` : ""}
563
+ {modal.showRetry ? (
564
+ <button
565
+ type="button"
566
+ data-fk-focus-id={MODAL_ACTION_RETRY}
567
+ onClick={() => void kit.widget.retryReport()}
568
+ style={{ ...baseButton, marginLeft: 10 }}
569
+ >
570
+ 다시 보내기
571
+ </button>
572
+ ) : null}
573
+ </div>
574
+ ) : null}
575
+
576
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
577
+ <button
578
+ type="button"
579
+ data-fk-focus-id={MODAL_ACTION_CANCEL}
580
+ onClick={closeReport}
581
+ style={baseButton}
582
+ >
583
+ 취소
584
+ </button>
585
+ <button
586
+ type="submit"
587
+ data-fk-focus-id={MODAL_ACTION_SEND}
588
+ disabled={!modal.canSubmit}
589
+ style={{
590
+ ...primaryButton,
591
+ opacity: modal.canSubmit ? 1 : 0.5,
592
+ cursor: modal.canSubmit ? "pointer" : "not-allowed",
593
+ }}
594
+ >
595
+ {modal.submitStatus === "sending" ? "전송 중…" : "보내기"}
596
+ </button>
597
+ </div>
598
+ </form>
599
+
600
+ {modal.closeConfirmVisible ? (
601
+ <div
602
+ style={{
603
+ position: "fixed",
604
+ inset: 0,
605
+ zIndex: 2147483604,
606
+ display: "grid",
607
+ placeItems: "center",
608
+ padding: 20,
609
+ background: "rgba(23, 32, 42, 0.58)",
610
+ }}
611
+ >
612
+ <div
613
+ ref={confirmRef}
614
+ role="alertdialog"
615
+ aria-modal="true"
616
+ aria-labelledby="feedback-kit-close-title"
617
+ tabIndex={-1}
618
+ style={{
619
+ width: "min(360px, 100%)",
620
+ boxSizing: "border-box",
621
+ borderRadius: 12,
622
+ background: TOKENS.surface,
623
+ padding: 20,
624
+ boxShadow: TOKENS.shadow,
625
+ }}
626
+ >
627
+ <h3 id="feedback-kit-close-title" style={{ margin: "0 0 6px", fontSize: 17 }}>
628
+ 작성 중인 내용을 닫을까요?
629
+ </h3>
630
+ <p style={{ margin: "0 0 16px", color: TOKENS.muted }}>
631
+ 아직 보내지 않은 코멘트가 있습니다.
632
+ </p>
633
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
634
+ <button type="button" onClick={() => kit.widget.cancelCloseReport()} style={baseButton}>
635
+ 계속 쓰기
636
+ </button>
637
+ <button type="button" onClick={() => kit.widget.confirmCloseReport()} style={primaryButton}>
638
+ 닫기
639
+ </button>
640
+ </div>
641
+ </div>
642
+ </div>
643
+ ) : null}
644
+ </div>
645
+ </div>
646
+ ) : null}
647
+
648
+ {screen === "picking" && pickingState.active ? (
649
+ <div
650
+ aria-label="요소 지목 오버레이"
651
+ style={{ position: "fixed", inset: 0, zIndex: 2147483599, pointerEvents: "none" }}
652
+ >
653
+ {hoverBox ? (
654
+ <div
655
+ aria-hidden="true"
656
+ style={{
657
+ position: "fixed",
658
+ left: hoverBox.x,
659
+ top: hoverBox.y,
660
+ width: hoverBox.width,
661
+ height: hoverBox.height,
662
+ boxSizing: "border-box",
663
+ border: `2px solid ${TOKENS.accent}`,
664
+ background: "rgba(49, 93, 115, 0.12)",
665
+ }}
666
+ />
667
+ ) : null}
668
+
669
+ <button
670
+ type="button"
671
+ onClick={() => kit.widget.stopPicking()}
672
+ style={{
673
+ ...primaryButton,
674
+ position: "fixed",
675
+ top: 18,
676
+ right: 18,
677
+ pointerEvents: "auto",
678
+ boxShadow: TOKENS.shadow,
679
+ }}
680
+ >
681
+ 지목 종료
682
+ </button>
683
+
684
+ {pickingState.markers.map((marker, index) => (
685
+ <div
686
+ key={marker.id}
687
+ role="status"
688
+ aria-label={`주석 ${index + 1}: ${marker.comment}`}
689
+ style={{
690
+ position: "fixed",
691
+ left: `${marker.x * 100}%`,
692
+ top: `${marker.y * 100}%`,
693
+ transform: "translate(-13px, -13px)",
694
+ display: "grid",
695
+ placeItems: "center",
696
+ minWidth: 26,
697
+ height: 26,
698
+ padding: "0 7px",
699
+ border: `2px solid ${TOKENS.surface}`,
700
+ borderRadius: 16,
701
+ background: TOKENS.ink,
702
+ color: TOKENS.surface,
703
+ boxShadow: "0 3px 10px rgba(23, 32, 42, 0.3)",
704
+ pointerEvents: "none",
705
+ fontSize: 11,
706
+ fontWeight: 700,
707
+ }}
708
+ >
709
+ <MarkerStatus status={marker.status} />
710
+ </div>
711
+ ))}
712
+
713
+ {popup ? (
714
+ <form
715
+ aria-label="요소 주석"
716
+ onSubmit={(event) => {
717
+ event.preventDefault();
718
+ void kit.picking.saveAnnotation();
719
+ }}
720
+ style={{
721
+ position: "fixed",
722
+ left: `min(${popup.point.x * 100}vw, calc(100vw - 336px))`,
723
+ top: `min(${popup.point.y * 100}vh, calc(100vh - 230px))`,
724
+ width: "min(320px, calc(100vw - 32px))",
725
+ boxSizing: "border-box",
726
+ border: `1px solid ${TOKENS.line}`,
727
+ borderRadius: 12,
728
+ background: TOKENS.surface,
729
+ padding: 14,
730
+ boxShadow: TOKENS.shadow,
731
+ pointerEvents: "auto",
732
+ }}
733
+ >
734
+ <label htmlFor="feedback-kit-annotation" style={{ display: "block", fontWeight: 700 }}>
735
+ 이 요소에 주석 남기기
736
+ </label>
737
+ {popup.element?.text ? (
738
+ <p style={{ margin: "4px 0 9px", color: TOKENS.muted }}>
739
+ {popup.element.text}
740
+ </p>
741
+ ) : null}
742
+ <textarea
743
+ id="feedback-kit-annotation"
744
+ autoFocus
745
+ value={popup.comment}
746
+ onChange={(event) => kit.picking.setAnnotationComment(event.currentTarget.value)}
747
+ rows={4}
748
+ placeholder="의견을 입력해주세요"
749
+ style={{ ...inputStyle, resize: "vertical" }}
750
+ />
751
+ {popup.comment.length > 4000 ? (
752
+ <div role="alert" style={{ marginTop: 4, color: TOKENS.danger }}>
753
+ 4,000자 이하로 입력해주세요
754
+ </div>
755
+ ) : null}
756
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 10 }}>
757
+ <button type="button" onClick={() => kit.picking.cancelAnnotation()} style={baseButton}>
758
+ 취소
759
+ </button>
760
+ <button
761
+ type="submit"
762
+ disabled={!popup.canSave}
763
+ style={{
764
+ ...primaryButton,
765
+ opacity: popup.canSave ? 1 : 0.5,
766
+ cursor: popup.canSave ? "pointer" : "not-allowed",
767
+ }}
768
+ >
769
+ 저장
770
+ </button>
771
+ </div>
772
+ </form>
773
+ ) : null}
774
+ </div>
775
+ ) : null}
776
+ </div>
777
+ );
778
+ }