@solhun/feedback-kit-web 0.6.0 → 0.7.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.cjs CHANGED
@@ -375,6 +375,12 @@ var ElementPickingController = class {
375
375
  constructor(opts) {
376
376
  this.listeners = /* @__PURE__ */ new Set();
377
377
  this.active = false;
378
+ /**
379
+ * 일시중지. **저장하지 않는다** — 멈추는 목적이 대개 "링크를 눌러 다른 화면으로 가는 것"
380
+ * 이라, 그 이동이 끝나면 지목이 돌아와 있는 게 목적에 맞다. 같은 이유로 경로가 바뀌면
381
+ * 스스로 풀린다(`syncPath`) — 그래야 SPA 이동과 새로고침이 같게 동작한다.
382
+ */
383
+ this.paused = false;
378
384
  this.attached = false;
379
385
  this.hovered = null;
380
386
  this.popup = null;
@@ -386,7 +392,7 @@ var ElementPickingController = class {
386
392
  /** 지금 도는 자동 캡처. Enter 가 캡처보다 빨랐을 때 기다릴 대상. */
387
393
  this.capturing = null;
388
394
  /**
389
- * 사용자가 [제안 ▾] 를 직접 건드렸는가. 한 번이라도 건드리면 이후 타이핑에 의한 자동
395
+ * 사용자가 [이런 건가요? ▾] 를 직접 건드렸는가. 한 번이라도 건드리면 이후 타이핑에 의한 자동
390
396
  * 접힘/펼침이 멈춘다 — 모달의 `userToggledHints` 와 같은 규칙. 팝업을 새로 열 때마다 리셋된다.
391
397
  */
392
398
  this.userToggledHints = false;
@@ -413,6 +419,7 @@ var ElementPickingController = class {
413
419
  getState() {
414
420
  return {
415
421
  active: this.active,
422
+ paused: this.paused,
416
423
  hovered: this.hovered,
417
424
  popup: this.popup,
418
425
  markers: this.markers
@@ -421,6 +428,10 @@ var ElementPickingController = class {
421
428
  get isActive() {
422
429
  return this.active;
423
430
  }
431
+ /** 켜져 있지만 클릭을 안 먹는 상태인가. */
432
+ get isPaused() {
433
+ return this.paused;
434
+ }
424
435
  subscribe(listener) {
425
436
  this.listeners.add(listener);
426
437
  listener(this.getState());
@@ -432,6 +443,7 @@ var ElementPickingController = class {
432
443
  start() {
433
444
  if (this.active) return;
434
445
  this.active = true;
446
+ this.paused = false;
435
447
  this.store.setPickingActive(true);
436
448
  this.attach();
437
449
  this.startPathWatch();
@@ -441,6 +453,7 @@ var ElementPickingController = class {
441
453
  /** [지목 종료]. 저장된 플래그까지 지워서 새로고침해도 다시 켜지지 않게 한다. */
442
454
  stop() {
443
455
  this.active = false;
456
+ this.paused = false;
444
457
  this.store.setPickingActive(false);
445
458
  this.detach();
446
459
  this.stopPathWatch();
@@ -448,6 +461,31 @@ var ElementPickingController = class {
448
461
  this.popup = null;
449
462
  this.emit();
450
463
  }
464
+ /**
465
+ * 잠시 멈춘다 — 페이지 클릭이 원래대로 동작하고, 모드는 켜진 채로 남는다.
466
+ *
467
+ * 팝업이 열려 있으면 **그대로 둔다.** 쓰던 한 줄을 여기서 지우면 「지목 켜면 모달 초안이
468
+ * 날아간다」와 같은 사고를 다른 자리에 만드는 셈이다. 팝업은 위젯 자신의 UI 라
469
+ * 리스너를 떼도 계속 눌린다.
470
+ */
471
+ pause() {
472
+ if (!this.active || this.paused) return;
473
+ this.paused = true;
474
+ this.detach();
475
+ this.hovered = null;
476
+ this.emit();
477
+ }
478
+ /** 다시 지목을 받는다. */
479
+ resume() {
480
+ if (!this.active || !this.paused) return;
481
+ this.paused = false;
482
+ this.attach();
483
+ this.emit();
484
+ }
485
+ togglePause() {
486
+ if (this.paused) this.resume();
487
+ else this.pause();
488
+ }
451
489
  /**
452
490
  * 저장돼 있던 모드를 되살린다. 새로고침·페이지 이동 직후에 한 번 부른다.
453
491
  * @returns 되살아났으면 true.
@@ -463,6 +501,7 @@ var ElementPickingController = class {
463
501
  }
464
502
  /** 경로가 바뀌었을 때 그 경로의 마커로 갈아 끼운다. */
465
503
  syncPath() {
504
+ if (this.paused) this.resume();
466
505
  this.lastPathname = this.getPathname();
467
506
  this.markers = this.store.list(this.lastPathname);
468
507
  this.reconcileMarkerOutcomes();
@@ -491,12 +530,12 @@ var ElementPickingController = class {
491
530
  this.attached = false;
492
531
  }
493
532
  handleMouseOver(event) {
494
- if (!this.active || isOwnUi(event.target)) return;
533
+ if (!this.active || this.paused || isOwnUi(event.target)) return;
495
534
  this.hovered = describeElement(event.target);
496
535
  this.emit();
497
536
  }
498
537
  handleClick(event) {
499
- if (!this.active) return;
538
+ if (!this.active || this.paused) return;
500
539
  if (isOwnUi(event.target)) return;
501
540
  event.preventDefault();
502
541
  event.stopPropagation();
@@ -577,7 +616,7 @@ ${hint.draft}`;
577
616
  };
578
617
  this.emit();
579
618
  }
580
- /** [제안 ▾] 토글. 방향과 무관하게 이후 자동 접힘/펼침을 멈춘다. */
619
+ /** [이런 건가요? ▾] 토글. 방향과 무관하게 이후 자동 접힘/펼침을 멈춘다. */
581
620
  toggleHints() {
582
621
  if (!this.popup) return;
583
622
  this.userToggledHints = true;
@@ -1009,7 +1048,7 @@ var import_feedback_kit_core3 = require("@solhun/feedback-kit-core");
1009
1048
  var import_react = require("react");
1010
1049
 
1011
1050
  // src/version.ts
1012
- var VERSION = true ? "0.6.0" : "dev";
1051
+ var VERSION = true ? "0.7.0" : "dev";
1013
1052
 
1014
1053
  // src/feedback-kit.tsx
1015
1054
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -1031,6 +1070,7 @@ var OWN_UI_PROPS = { [OWN_UI_ATTR]: "" };
1031
1070
  var FONT_STACK = "ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
1032
1071
  var INITIAL_PICKING_STATE = {
1033
1072
  active: false,
1073
+ paused: false,
1034
1074
  hovered: null,
1035
1075
  popup: null,
1036
1076
  markers: []
@@ -1072,26 +1112,41 @@ async function transcodeToJpeg(dataUrl) {
1072
1112
  return null;
1073
1113
  }
1074
1114
  }
1115
+ function looksLikeImageName(name) {
1116
+ return /\.(png|jpe?g|gif|webp|bmp|avif|heic|heif)$/i.test(name);
1117
+ }
1118
+ function isImageFile(file) {
1119
+ if (file.type.startsWith("image/")) return true;
1120
+ return (file.type === "" || file.type === "application/octet-stream") && looksLikeImageName(file.name);
1121
+ }
1122
+ function contentTypeOf(file) {
1123
+ if (file.type !== "") return file.type;
1124
+ const name = file instanceof File ? file.name : "";
1125
+ if (/\.png$/i.test(name)) return "image/png";
1126
+ if (/\.jpe?g$/i.test(name)) return "image/jpeg";
1127
+ return "";
1128
+ }
1075
1129
  async function readImageFile(file) {
1076
- if (!file.type.startsWith("image/")) return null;
1130
+ const named = file instanceof File ? file : null;
1131
+ if (!file.type.startsWith("image/") && !(named !== null && isImageFile(named))) return null;
1077
1132
  const dataUrl = await readDataUrl(file);
1078
1133
  if (!dataUrl) return null;
1079
- if (file.type === "image/png" || file.type === "image/jpeg") {
1134
+ const type = contentTypeOf(file);
1135
+ if (type === "image/png" || type === "image/jpeg") {
1080
1136
  const base64 = base64Of(dataUrl);
1081
- return base64 ? { base64, contentType: file.type } : null;
1137
+ return base64 ? { base64, contentType: type } : null;
1082
1138
  }
1083
1139
  return transcodeToJpeg(dataUrl);
1084
1140
  }
1085
1141
  function firstImageOf(transfer) {
1086
1142
  if (!transfer) return null;
1087
1143
  for (const item of Array.from(transfer.items ?? [])) {
1088
- if (item.kind === "file" && item.type.startsWith("image/")) {
1089
- const file = item.getAsFile();
1090
- if (file) return file;
1091
- }
1144
+ if (item.kind !== "file") continue;
1145
+ const file = item.getAsFile();
1146
+ if (file && isImageFile(file)) return file;
1092
1147
  }
1093
1148
  for (const file of Array.from(transfer.files ?? [])) {
1094
- if (file.type.startsWith("image/")) return file;
1149
+ if (isImageFile(file)) return file;
1095
1150
  }
1096
1151
  return null;
1097
1152
  }
@@ -1169,6 +1224,7 @@ var inputStyle = {
1169
1224
  };
1170
1225
  function PickToggle({
1171
1226
  active,
1227
+ paused,
1172
1228
  disabled,
1173
1229
  variant,
1174
1230
  focusId,
@@ -1196,14 +1252,9 @@ function PickToggle({
1196
1252
  // 항상 값을 준다. 조건부로 빼면 리렌더 때 shorthand(border)와 충돌한다고 React 가 경고한다.
1197
1253
  borderColor: active ? TOKENS.accent : TOKENS.line,
1198
1254
  ...floating ? {
1199
- position: "fixed",
1200
- top: 16,
1201
- left: "50%",
1202
- transform: "translateX(-50%)",
1203
- // 오버레이 자체는 클릭을 통과시킨다(pointerEvents:none) — 이 토글만 되살린다.
1204
- pointerEvents: "auto",
1205
- background: TOKENS.surface,
1206
- boxShadow: TOKENS.shadow
1255
+ // 위치는 감싸는 `PickBar` 가 잡는다 — 옆에 [잠시 멈춤] 이 함께 서야 하는데
1256
+ // 토글이 스스로 fixed 면 둘을 나란히 둘 수 없다.
1257
+ background: TOKENS.surface
1207
1258
  } : {
1208
1259
  width: "100%",
1209
1260
  justifyContent: "space-between",
@@ -1220,8 +1271,12 @@ function PickToggle({
1220
1271
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1221
1272
  "span",
1222
1273
  {
1223
- style: { fontSize: 12, fontWeight: 600, color: active ? TOKENS.accent : TOKENS.muted },
1224
- children: active ? "\uCF1C\uC9D0" : "\uAEBC\uC9D0"
1274
+ style: {
1275
+ fontSize: 12,
1276
+ fontWeight: 600,
1277
+ color: active && !paused ? TOKENS.accent : TOKENS.muted
1278
+ },
1279
+ children: active ? paused ? "\uC77C\uC2DC\uC911\uC9C0" : "\uCF1C\uC9D0" : "\uAEBC\uC9D0"
1225
1280
  }
1226
1281
  ),
1227
1282
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -1233,7 +1288,7 @@ function PickToggle({
1233
1288
  width: 38,
1234
1289
  height: 22,
1235
1290
  borderRadius: 11,
1236
- background: active ? TOKENS.accent : TOKENS.line
1291
+ background: active ? paused ? TOKENS.muted : TOKENS.accent : TOKENS.line
1237
1292
  },
1238
1293
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1239
1294
  "span",
@@ -1257,6 +1312,58 @@ function PickToggle({
1257
1312
  }
1258
1313
  );
1259
1314
  }
1315
+ function PickBar({
1316
+ paused,
1317
+ onToggle,
1318
+ onTogglePause
1319
+ }) {
1320
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1321
+ "div",
1322
+ {
1323
+ style: {
1324
+ position: "fixed",
1325
+ top: 16,
1326
+ left: "50%",
1327
+ transform: "translateX(-50%)",
1328
+ display: "flex",
1329
+ alignItems: "stretch",
1330
+ gap: 8,
1331
+ // 오버레이 자체는 클릭을 통과시킨다(pointerEvents:none) — 이 막대만 되살린다.
1332
+ pointerEvents: "auto",
1333
+ padding: 6,
1334
+ borderRadius: 12,
1335
+ background: TOKENS.surface,
1336
+ boxShadow: TOKENS.shadow
1337
+ },
1338
+ children: [
1339
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PickToggle, { variant: "floating", active: true, paused, onToggle }),
1340
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1341
+ "button",
1342
+ {
1343
+ type: "button",
1344
+ "aria-pressed": paused,
1345
+ "data-fk-pick-pause": paused ? "paused" : "running",
1346
+ onClick: onTogglePause,
1347
+ style: {
1348
+ ...baseButton,
1349
+ display: "inline-flex",
1350
+ alignItems: "center",
1351
+ gap: 6,
1352
+ whiteSpace: "nowrap",
1353
+ borderColor: paused ? TOKENS.accent : TOKENS.line,
1354
+ color: paused ? TOKENS.accent : TOKENS.ink,
1355
+ fontWeight: 600
1356
+ },
1357
+ children: [
1358
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: paused ? "\u25B6" : "\u23F8" }),
1359
+ paused ? "\uC9C0\uBAA9 \uC7AC\uAC1C" : "\uC7A0\uC2DC \uBA48\uCDA4"
1360
+ ]
1361
+ }
1362
+ )
1363
+ ]
1364
+ }
1365
+ );
1366
+ }
1260
1367
  function MarkerStatus({ status }) {
1261
1368
  const presentation = status === "sending" ? { icon: "\u21BB", text: "\uC804\uC1A1 \uC911", color: TOKENS.accent } : status === "done" ? { icon: "\u2713", text: "\uC644\uB8CC", color: TOKENS.success } : { icon: "\u25F7", text: "\uB300\uAE30", color: TOKENS.warning };
1262
1369
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "inline-flex", gap: 4, alignItems: "center", color: presentation.color }, children: [
@@ -1280,47 +1387,7 @@ function HintChips({
1280
1387
  onToggle
1281
1388
  }) {
1282
1389
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginBottom: 16 }, children: [
1283
- hintsExpanded ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1284
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { margin: "0 0 7px", color: TOKENS.muted, fontSize: 12 }, children: "\uC774\uB7F0 \uAC74\uAC00\uC694?" }),
1285
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1286
- "div",
1287
- {
1288
- role: "group",
1289
- "aria-label": "\uC790\uC8FC \uB098\uC624\uB294 \uC81C\uBCF4 \uC81C\uC548",
1290
- style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 7 },
1291
- children: hints.map((hint) => {
1292
- const used = usedHintIds.includes(hint.id);
1293
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1294
- "button",
1295
- {
1296
- type: "button",
1297
- "data-fk-focus-id": `hint:${hint.id}`,
1298
- disabled: used || disabled,
1299
- "aria-pressed": used,
1300
- onClick: () => onApply(hint.id),
1301
- style: {
1302
- ...baseButton,
1303
- minHeight: 30,
1304
- padding: "5px 10px",
1305
- fontSize: 12,
1306
- fontWeight: 600,
1307
- borderRadius: 999,
1308
- ...used ? {
1309
- background: TOKENS.subtle,
1310
- color: TOKENS.muted,
1311
- borderColor: TOKENS.line,
1312
- cursor: "default"
1313
- } : {}
1314
- },
1315
- children: hint.label
1316
- },
1317
- hint.id
1318
- );
1319
- })
1320
- }
1321
- )
1322
- ] }) : null,
1323
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1390
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1324
1391
  "button",
1325
1392
  {
1326
1393
  type: "button",
@@ -1328,10 +1395,61 @@ function HintChips({
1328
1395
  "aria-expanded": hintsExpanded,
1329
1396
  disabled,
1330
1397
  onClick: onToggle,
1331
- style: { ...baseButton, minHeight: 28, padding: "4px 10px", fontSize: 12 },
1332
- children: hintsExpanded ? "\uC81C\uC548 \u25B4" : "\uC81C\uC548 \u25BE"
1398
+ style: {
1399
+ display: "block",
1400
+ margin: "0 0 7px",
1401
+ padding: 0,
1402
+ border: "none",
1403
+ background: "none",
1404
+ font: "inherit",
1405
+ fontSize: 12,
1406
+ color: TOKENS.muted,
1407
+ cursor: disabled ? "default" : "pointer",
1408
+ textAlign: "left"
1409
+ },
1410
+ children: [
1411
+ "\uC774\uB7F0 \uAC74\uAC00\uC694? ",
1412
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: hintsExpanded ? "\u25B4" : "\u25BE" })
1413
+ ]
1333
1414
  }
1334
- )
1415
+ ),
1416
+ hintsExpanded ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1417
+ "div",
1418
+ {
1419
+ role: "group",
1420
+ "aria-label": "\uC790\uC8FC \uB098\uC624\uB294 \uC81C\uBCF4 \uC81C\uC548",
1421
+ style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 7 },
1422
+ children: hints.map((hint) => {
1423
+ const used = usedHintIds.includes(hint.id);
1424
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1425
+ "button",
1426
+ {
1427
+ type: "button",
1428
+ "data-fk-focus-id": `hint:${hint.id}`,
1429
+ disabled: used || disabled,
1430
+ "aria-pressed": used,
1431
+ onClick: () => onApply(hint.id),
1432
+ style: {
1433
+ ...baseButton,
1434
+ minHeight: 30,
1435
+ padding: "5px 10px",
1436
+ fontSize: 12,
1437
+ fontWeight: 600,
1438
+ borderRadius: 999,
1439
+ ...used ? {
1440
+ background: TOKENS.subtle,
1441
+ color: TOKENS.muted,
1442
+ borderColor: TOKENS.line,
1443
+ cursor: "default"
1444
+ } : {}
1445
+ },
1446
+ children: hint.label
1447
+ },
1448
+ hint.id
1449
+ );
1450
+ })
1451
+ }
1452
+ ) }) : null
1335
1453
  ] });
1336
1454
  }
1337
1455
  function FeedbackKit(props) {
@@ -1433,6 +1551,23 @@ function FeedbackKit(props) {
1433
1551
  document.addEventListener("keydown", onKeyDownCapture, true);
1434
1552
  return () => document.removeEventListener("keydown", onKeyDownCapture, true);
1435
1553
  }, [kit, widgetState, pickingState.popup]);
1554
+ (0, import_react.useEffect)(() => {
1555
+ if (!kit || !widgetState?.modal.open) return;
1556
+ if (pickingState.popup) return;
1557
+ const locked = widgetState.modal.submitStatus === "sending" || widgetState.modal.submitStatus === "pending";
1558
+ if (locked) return;
1559
+ const controller = kit;
1560
+ async function onPaste(event) {
1561
+ const file = firstImageOf(event.clipboardData);
1562
+ if (!file) return;
1563
+ event.preventDefault();
1564
+ const shot = await readImageFile(file);
1565
+ if (shot) await controller.widget.modal.attachFile(shot);
1566
+ }
1567
+ const handler = (event) => void onPaste(event);
1568
+ document.addEventListener("paste", handler);
1569
+ return () => document.removeEventListener("paste", handler);
1570
+ }, [kit, widgetState, pickingState.popup]);
1436
1571
  if (!kit || !widgetState) {
1437
1572
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ...OWN_UI_PROPS, "data-feedback-kit-loading": "true" });
1438
1573
  }
@@ -1470,6 +1605,14 @@ function FeedbackKit(props) {
1470
1605
  }
1471
1606
  input.value = "";
1472
1607
  }
1608
+ async function attachToModal(transfer) {
1609
+ const file = firstImageOf(transfer);
1610
+ if (!file) return false;
1611
+ const screenshot = await readImageFile(file);
1612
+ if (!screenshot) return false;
1613
+ await activeKit.widget.modal.attachFile(screenshot);
1614
+ return true;
1615
+ }
1473
1616
  async function attachToPopup(transfer) {
1474
1617
  const file = firstImageOf(transfer);
1475
1618
  if (!file) return false;
@@ -1570,6 +1713,14 @@ function FeedbackKit(props) {
1570
1713
  "aria-modal": "true",
1571
1714
  "aria-labelledby": "feedback-kit-dialog-title",
1572
1715
  onKeyDown: handleModalKeyDown,
1716
+ onDragOver: (event) => {
1717
+ if (!draftLocked && firstImageOf(event.dataTransfer)) event.preventDefault();
1718
+ },
1719
+ onDrop: (event) => {
1720
+ if (draftLocked || !firstImageOf(event.dataTransfer)) return;
1721
+ event.preventDefault();
1722
+ void attachToModal(event.dataTransfer);
1723
+ },
1573
1724
  style: {
1574
1725
  width: "min(520px, 100%)",
1575
1726
  maxHeight: "min(760px, calc(100vh - 40px))",
@@ -1617,6 +1768,7 @@ function FeedbackKit(props) {
1617
1768
  {
1618
1769
  variant: "inline",
1619
1770
  active: pickingActive,
1771
+ paused: pickingState.paused,
1620
1772
  disabled: draftLocked,
1621
1773
  focusId: import_feedback_kit_core3.MODAL_ACTION_PICK,
1622
1774
  onToggle: togglePicking
@@ -1868,7 +2020,14 @@ function FeedbackKit(props) {
1868
2020
  }
1869
2021
  }
1870
2022
  ) : null,
1871
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PickToggle, { variant: "floating", active: true, onToggle: togglePicking }),
2023
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
2024
+ PickBar,
2025
+ {
2026
+ paused: pickingState.paused,
2027
+ onToggle: togglePicking,
2028
+ onTogglePause: () => activeKit.picking.togglePause()
2029
+ }
2030
+ ),
1872
2031
  pickingState.markers.map((marker, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1873
2032
  "div",
1874
2033
  {