@solhun/feedback-kit-web 0.3.0 → 0.4.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.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { resolveConfig, shouldShowWidget } from "@solhun/feedback-kit-core";
3
3
  import { parseSourceAttr, SOURCE_ATTR, sourceFromElement } from "@solhun/feedback-kit-core";
4
4
  import {
5
- COMMENT_MAX_CHARS as COMMENT_MAX_CHARS2,
5
+ COMMENT_MAX_CHARS as COMMENT_MAX_CHARS3,
6
6
  COMMENT_REQUIRED_MESSAGE,
7
7
  COMMENT_TOO_LONG_MESSAGE,
8
8
  denormalizePin,
@@ -294,6 +294,7 @@ var MarkerStore = class {
294
294
  // src/picking.ts
295
295
  import {
296
296
  COMMENT_MAX_CHARS,
297
+ captureWithinLimit,
297
298
  normalizePin
298
299
  } from "@solhun/feedback-kit-core";
299
300
  var OWN_UI_ATTR = "data-feedback-kit";
@@ -323,6 +324,10 @@ var ElementPickingController = class {
323
324
  this.markers = [];
324
325
  this.pathWatch = null;
325
326
  this.saving = false;
327
+ /** 늦게 끝난 캡처가 다음 주석의 그림을 덮지 못하게 하는 세대 번호. */
328
+ this.shotGeneration = 0;
329
+ /** 지금 도는 자동 캡처. Enter 가 캡처보다 빨랐을 때 기다릴 대상. */
330
+ this.capturing = null;
326
331
  this.onClick = (event) => this.handleClick(event);
327
332
  this.onMouseOver = (event) => this.handleMouseOver(event);
328
333
  this.queue = opts.queue;
@@ -332,6 +337,9 @@ var ElementPickingController = class {
332
337
  this.getPathname = opts.getPathname ?? defaultPathname;
333
338
  this.doc = opts.doc === void 0 ? defaultDocument() : opts.doc;
334
339
  this.getViewport = opts.getViewport ?? defaultViewport;
340
+ this.capture = opts.capture ?? null;
341
+ this.reencode = opts.reencode ?? null;
342
+ this.screenshotLimitBytes = opts.screenshotLimitBytes;
335
343
  this.lastPathname = this.getPathname();
336
344
  this.markers = this.store.list(this.lastPathname);
337
345
  this.unsubscribeQueue = this.queue.subscribe?.(() => {
@@ -450,24 +458,84 @@ var ElementPickingController = class {
450
458
  element: describeElement(element),
451
459
  point,
452
460
  comment: "",
453
- canSave: false
461
+ canSave: false,
462
+ screenshot: null,
463
+ screenshotStatus: this.capture ? "capturing" : "none",
464
+ saving: false
454
465
  };
455
466
  this.emit();
467
+ if (this.capture) this.capturing = this.runCapture();
456
468
  }
457
469
  setAnnotationComment(value) {
458
470
  if (!this.popup) return;
459
471
  this.popup = {
460
472
  ...this.popup,
461
473
  comment: value,
462
- canSave: value.trim().length > 0 && value.length <= COMMENT_MAX_CHARS
474
+ canSave: this.isSavable(value)
463
475
  };
464
476
  this.emit();
465
477
  }
478
+ /**
479
+ * 사용자가 붙여넣기(Cmd+V)나 드래그로 넣은 그림. 자동 캡처 결과를 덮는다.
480
+ *
481
+ * 자동 캡처보다 이게 우선인 이유: 사용자가 굳이 그림을 붙였다면 그건 "지금 화면"이
482
+ * 아니라 **보여주고 싶은 다른 것**이다(잘라낸 부분, 다른 탭, 기대하는 디자인).
483
+ */
484
+ async attachAnnotationImage(shot) {
485
+ if (!this.popup || this.popup.saving) return;
486
+ const generation = ++this.shotGeneration;
487
+ const outcome = await captureWithinLimit({
488
+ capture: () => shot,
489
+ reencode: this.reencode,
490
+ limitBytes: this.screenshotLimitBytes
491
+ });
492
+ if (generation !== this.shotGeneration || !this.popup) return;
493
+ this.popup = {
494
+ ...this.popup,
495
+ screenshot: outcome.failed ? null : outcome.screenshot,
496
+ screenshotStatus: outcome.failed || !outcome.screenshot ? "failed" : "ready"
497
+ };
498
+ this.emit();
499
+ }
500
+ /** 그림만 뗀다. 코멘트는 그대로 — 그림이 없어도 주석은 성립한다. */
501
+ removeAnnotationImage() {
502
+ if (!this.popup || this.popup.saving) return;
503
+ this.shotGeneration += 1;
504
+ this.popup = { ...this.popup, screenshot: null, screenshotStatus: "none" };
505
+ this.emit();
506
+ }
466
507
  /** 취소 — 마커도 제보도 남기지 않는다. 모드는 켜진 채로 둔다. */
467
508
  cancelAnnotation() {
509
+ this.shotGeneration += 1;
468
510
  this.popup = null;
469
511
  this.emit();
470
512
  }
513
+ isSavable(comment) {
514
+ return comment.trim().length > 0 && comment.length <= COMMENT_MAX_CHARS;
515
+ }
516
+ /** 도는 캡처가 있으면 끝나기를 기다린다. 캡처가 던져도 저장을 막지 않는다. */
517
+ async settleCapture() {
518
+ try {
519
+ await this.capturing;
520
+ } catch {
521
+ }
522
+ }
523
+ async runCapture() {
524
+ if (!this.capture) return;
525
+ const generation = ++this.shotGeneration;
526
+ const outcome = await captureWithinLimit({
527
+ capture: this.capture,
528
+ reencode: this.reencode,
529
+ limitBytes: this.screenshotLimitBytes
530
+ });
531
+ if (generation !== this.shotGeneration || !this.popup) return;
532
+ this.popup = {
533
+ ...this.popup,
534
+ screenshot: outcome.failed ? null : outcome.screenshot,
535
+ screenshotStatus: outcome.failed || !outcome.screenshot ? "failed" : "ready"
536
+ };
537
+ this.emit();
538
+ }
471
539
  /**
472
540
  * 제출된 제보를 마커로 남긴다. 모달 경로에서 쓴다 — 마커 생성이 팝업 저장 안에만
473
541
  * 있으면, 리포트 모달로 보낸 지목 제보는 화면에 흔적이 남지 않는다("보냈는지 알 수
@@ -498,14 +566,22 @@ var ElementPickingController = class {
498
566
  const popup = this.popup;
499
567
  if (!popup || !popup.canSave || this.saving) return null;
500
568
  this.saving = true;
569
+ if (popup.screenshotStatus === "capturing") await this.settleCapture();
570
+ const settled = this.popup;
571
+ if (!settled) {
572
+ this.saving = false;
573
+ return null;
574
+ }
575
+ this.popup = { ...settled, saving: true };
576
+ this.emit();
501
577
  const pathname = this.getPathname();
502
578
  const parts = {
503
579
  kind: "annotation",
504
- comment: popup.comment,
580
+ comment: settled.comment,
505
581
  priority: "unset",
506
- screenshot: null,
507
- pin: popup.point,
508
- element: popup.element
582
+ screenshot: settled.screenshot,
583
+ pin: settled.point,
584
+ element: settled.element
509
585
  };
510
586
  let report;
511
587
  try {
@@ -518,10 +594,10 @@ var ElementPickingController = class {
518
594
  }
519
595
  const marker = {
520
596
  id: report.clientSubmissionId,
521
- x: popup.point.x,
522
- y: popup.point.y,
523
- selector: popup.element?.selector ?? null,
524
- comment: popup.comment,
597
+ x: settled.point.x,
598
+ y: settled.point.y,
599
+ selector: settled.element?.selector ?? null,
600
+ comment: settled.comment,
525
601
  status: "sending",
526
602
  at: report.createdAt
527
603
  };
@@ -669,6 +745,8 @@ import { WidgetController } from "@solhun/feedback-kit-core";
669
745
  function createWebWidget(opts) {
670
746
  const store = opts.store ?? new MarkerStore();
671
747
  let widgetRef = null;
748
+ const capture = opts.capture === void 0 ? captureWebScreenshot : opts.capture;
749
+ const reencode = opts.reencode === void 0 ? reencodeWebScreenshot : opts.reencode;
672
750
  const picking = new ElementPickingController({
673
751
  queue: opts.queue,
674
752
  createReport: opts.createReport,
@@ -676,18 +754,20 @@ function createWebWidget(opts) {
676
754
  getPathname: opts.getPathname,
677
755
  doc: opts.doc,
678
756
  getViewport: opts.getViewport,
679
- // 요소를 고르면 인라인 팝업이 아니라 **리포트 모달**을 연다.
680
- // 코멘트만 받던 팝업과 달리 스크린샷·우선순위까지 같은 화면에서 받는다.
681
- // 모드는 켜진 채로 둬서 한 번 켜고 여러 요소를 연달아 지목할 수 있다.
682
- onPick: (element, point) => {
683
- void widgetRef?.openReport({ element });
684
- widgetRef?.modal.setPin(point);
685
- }
757
+ // 주석도 같은 캡처를 쓴다. 캡처가 위젯 자신의 UI 를 빼주므로 팝업이 찍히지 않는다.
758
+ capture,
759
+ reencode,
760
+ screenshotLimitBytes: opts.screenshotLimitBytes,
761
+ // 기본은 클릭 자리의 퀵 입력창이라 onPick 을 주지 않는다(주면 팝업 대신 그걸 부른다).
762
+ // 모드는 어느 쪽이든 켜진 채로 둬서 한 번 켜고 여러 요소를 연달아 지목할 수 있다.
763
+ onPick: opts.pickTarget === "modal" ? (element, point) => {
764
+ void widgetRef?.openReport({ element, pin: point });
765
+ } : void 0
686
766
  });
687
767
  const widget = new WidgetController({
688
768
  ...opts,
689
- capture: opts.capture === void 0 ? captureWebScreenshot : opts.capture,
690
- reencode: opts.reencode === void 0 ? reencodeWebScreenshot : opts.reencode,
769
+ capture,
770
+ reencode,
691
771
  platform: "web",
692
772
  // 새로고침·페이지 이동 직후에도 켜져 있던 모드를 그대로 이어받는다.
693
773
  initialPicking: store.isPickingActive(),
@@ -718,6 +798,7 @@ function createWebWidget(opts) {
718
798
 
719
799
  // src/feedback-kit.tsx
720
800
  import {
801
+ COMMENT_MAX_CHARS as COMMENT_MAX_CHARS2,
721
802
  FLOATING_BUTTON_ID,
722
803
  MODAL_ACTION_CANCEL,
723
804
  MODAL_ACTION_PICK,
@@ -734,10 +815,10 @@ import {
734
815
  } from "react";
735
816
 
736
817
  // src/version.ts
737
- var VERSION = true ? "0.3.0" : "dev";
818
+ var VERSION = true ? "0.4.0" : "dev";
738
819
 
739
820
  // src/feedback-kit.tsx
740
- import { jsx, jsxs } from "react/jsx-runtime";
821
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
741
822
  var TOKENS = {
742
823
  ink: "#17202a",
743
824
  muted: "#667085",
@@ -763,29 +844,63 @@ var INITIAL_PICKING_STATE = {
763
844
  function screenshotSource(screenshot) {
764
845
  return `data:${screenshot.contentType};base64,${screenshot.base64}`;
765
846
  }
766
- function readScreenshotFile(file) {
767
- if (file.type !== "image/png" && file.type !== "image/jpeg") {
768
- return Promise.resolve(null);
769
- }
770
- const contentType = file.type;
847
+ function readDataUrl(blob) {
771
848
  return new Promise((resolve) => {
772
849
  const reader = new FileReader();
773
850
  reader.onerror = () => resolve(null);
774
- reader.onload = () => {
775
- const result = typeof reader.result === "string" ? reader.result : "";
776
- const comma = result.indexOf(",");
777
- if (comma < 0) {
778
- resolve(null);
779
- return;
780
- }
781
- resolve({
782
- base64: result.slice(comma + 1),
783
- contentType
784
- });
785
- };
786
- reader.readAsDataURL(file);
851
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
852
+ reader.readAsDataURL(blob);
787
853
  });
788
854
  }
855
+ function base64Of(dataUrl) {
856
+ const comma = dataUrl.indexOf(",");
857
+ return comma >= 0 ? dataUrl.slice(comma + 1) : null;
858
+ }
859
+ async function transcodeToJpeg(dataUrl) {
860
+ const image = await new Promise((resolve) => {
861
+ const img = new Image();
862
+ img.onload = () => resolve(img);
863
+ img.onerror = () => resolve(null);
864
+ img.src = dataUrl;
865
+ });
866
+ if (!image) return null;
867
+ const canvas = document.createElement("canvas");
868
+ canvas.width = image.naturalWidth || image.width;
869
+ canvas.height = image.naturalHeight || image.height;
870
+ if (canvas.width < 1 || canvas.height < 1) return null;
871
+ const context = canvas.getContext("2d");
872
+ if (!context) return null;
873
+ context.drawImage(image, 0, 0);
874
+ try {
875
+ const base64 = base64Of(canvas.toDataURL("image/jpeg", 0.9));
876
+ return base64 ? { base64, contentType: "image/jpeg" } : null;
877
+ } catch {
878
+ return null;
879
+ }
880
+ }
881
+ async function readImageFile(file) {
882
+ if (!file.type.startsWith("image/")) return null;
883
+ const dataUrl = await readDataUrl(file);
884
+ if (!dataUrl) return null;
885
+ if (file.type === "image/png" || file.type === "image/jpeg") {
886
+ const base64 = base64Of(dataUrl);
887
+ return base64 ? { base64, contentType: file.type } : null;
888
+ }
889
+ return transcodeToJpeg(dataUrl);
890
+ }
891
+ function firstImageOf(transfer) {
892
+ if (!transfer) return null;
893
+ for (const item of Array.from(transfer.items ?? [])) {
894
+ if (item.kind === "file" && item.type.startsWith("image/")) {
895
+ const file = item.getAsFile();
896
+ if (file) return file;
897
+ }
898
+ }
899
+ for (const file of Array.from(transfer.files ?? [])) {
900
+ if (file.type.startsWith("image/")) return file;
901
+ }
902
+ return null;
903
+ }
789
904
  function nextPaint() {
790
905
  return new Promise((resolve) => {
791
906
  if (typeof requestAnimationFrame === "function") {
@@ -837,6 +952,17 @@ var primaryButton = {
837
952
  background: TOKENS.accent,
838
953
  color: TOKENS.surface
839
954
  };
955
+ var visuallyHidden = {
956
+ position: "absolute",
957
+ width: 1,
958
+ height: 1,
959
+ margin: -1,
960
+ padding: 0,
961
+ overflow: "hidden",
962
+ clip: "rect(0 0 0 0)",
963
+ whiteSpace: "nowrap",
964
+ border: 0
965
+ };
840
966
  var inputStyle = {
841
967
  width: "100%",
842
968
  boxSizing: "border-box",
@@ -935,20 +1061,22 @@ function FeedbackKit(props) {
935
1061
  useEffect(() => {
936
1062
  if (!kit || !widgetState) return;
937
1063
  const { screen: screen2, modal: modal2 } = widgetState;
938
- if (screen2 !== "modal" && screen2 !== "pin") return;
1064
+ const quickOpen = screen2 === "picking" && pickingState.popup !== null;
1065
+ if (screen2 !== "modal" && screen2 !== "pin" && !quickOpen) return;
939
1066
  const widget = kit.widget;
940
1067
  function onKeyDownCapture(event) {
941
1068
  if (event.key !== "Escape") return;
942
1069
  if (event.isComposing) return;
943
1070
  event.stopPropagation();
944
1071
  event.preventDefault();
945
- if (screen2 === "pin") widget.cancelPin();
1072
+ if (quickOpen) kit.picking.cancelAnnotation();
1073
+ else if (screen2 === "pin") widget.cancelPin();
946
1074
  else if (modal2.closeConfirmVisible) widget.cancelCloseReport();
947
1075
  else widget.closeReport();
948
1076
  }
949
1077
  document.addEventListener("keydown", onKeyDownCapture, true);
950
1078
  return () => document.removeEventListener("keydown", onKeyDownCapture, true);
951
- }, [kit, widgetState]);
1079
+ }, [kit, widgetState, pickingState.popup]);
952
1080
  if (!kit || !widgetState) {
953
1081
  return /* @__PURE__ */ jsx("div", { ...OWN_UI_PROPS, "data-feedback-kit-loading": "true" });
954
1082
  }
@@ -977,11 +1105,19 @@ function FeedbackKit(props) {
977
1105
  const input = event.currentTarget;
978
1106
  const file = input.files?.[0];
979
1107
  if (file) {
980
- const screenshot = await readScreenshotFile(file);
1108
+ const screenshot = await readImageFile(file);
981
1109
  if (screenshot) await activeKit.widget.modal.attachFile(screenshot);
982
1110
  }
983
1111
  input.value = "";
984
1112
  }
1113
+ async function attachToPopup(transfer) {
1114
+ const file = firstImageOf(transfer);
1115
+ if (!file) return false;
1116
+ const screenshot = await readImageFile(file);
1117
+ if (!screenshot) return false;
1118
+ await activeKit.picking.attachAnnotationImage(screenshot);
1119
+ return true;
1120
+ }
985
1121
  function closeReport() {
986
1122
  activeKit.widget.closeReport();
987
1123
  }
@@ -1419,48 +1555,147 @@ function FeedbackKit(props) {
1419
1555
  event.preventDefault();
1420
1556
  void kit.picking.saveAnnotation();
1421
1557
  },
1558
+ onPaste: (event) => {
1559
+ if (!firstImageOf(event.clipboardData)) return;
1560
+ event.preventDefault();
1561
+ void attachToPopup(event.clipboardData);
1562
+ },
1563
+ onDragOver: (event) => {
1564
+ if (firstImageOf(event.dataTransfer)) event.preventDefault();
1565
+ },
1566
+ onDrop: (event) => {
1567
+ if (!firstImageOf(event.dataTransfer)) return;
1568
+ event.preventDefault();
1569
+ void attachToPopup(event.dataTransfer);
1570
+ },
1422
1571
  style: {
1423
1572
  position: "fixed",
1424
- left: `min(${popup.point.x * 100}vw, calc(100vw - 336px))`,
1425
- top: `min(${popup.point.y * 100}vh, calc(100vh - 230px))`,
1426
- width: "min(320px, calc(100vw - 32px))",
1573
+ // 클릭한 지점 바로 옆에 띄운다. 화면 밖으로 나가지 않게 양쪽을 물린다.
1574
+ left: `clamp(8px, calc(${popup.point.x * 100}vw + 14px), calc(100vw - 340px))`,
1575
+ top: `clamp(8px, calc(${popup.point.y * 100}vh + 14px), calc(100vh - 200px))`,
1576
+ width: "min(332px, calc(100vw - 16px))",
1427
1577
  boxSizing: "border-box",
1428
1578
  border: `1px solid ${TOKENS.line}`,
1429
1579
  borderRadius: 12,
1430
1580
  background: TOKENS.surface,
1431
- padding: 14,
1581
+ padding: 12,
1432
1582
  boxShadow: TOKENS.shadow,
1433
1583
  pointerEvents: "auto"
1434
1584
  },
1435
1585
  children: [
1436
- /* @__PURE__ */ jsx("label", { htmlFor: "feedback-kit-annotation", style: { display: "block", fontWeight: 700 }, children: "\uC774 \uC694\uC18C\uC5D0 \uC8FC\uC11D \uB0A8\uAE30\uAE30" }),
1437
- popup.element?.text ? /* @__PURE__ */ jsx("p", { style: { margin: "4px 0 9px", color: TOKENS.muted }, children: popup.element.text }) : null,
1586
+ /* @__PURE__ */ jsxs(
1587
+ "div",
1588
+ {
1589
+ style: {
1590
+ display: "flex",
1591
+ alignItems: "center",
1592
+ gap: 6,
1593
+ marginBottom: 7,
1594
+ color: TOKENS.muted,
1595
+ fontSize: 12
1596
+ },
1597
+ children: [
1598
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\u25CE" }),
1599
+ /* @__PURE__ */ jsx(
1600
+ "span",
1601
+ {
1602
+ style: {
1603
+ overflow: "hidden",
1604
+ textOverflow: "ellipsis",
1605
+ whiteSpace: "nowrap"
1606
+ },
1607
+ children: popup.element?.text?.trim() || popup.element?.selector || "\uC774 \uC9C0\uC810"
1608
+ }
1609
+ )
1610
+ ]
1611
+ }
1612
+ ),
1613
+ /* @__PURE__ */ jsx("label", { htmlFor: "feedback-kit-annotation", style: visuallyHidden, children: "\uC774 \uC694\uC18C\uC5D0 \uB0A8\uAE38 \uC758\uACAC" }),
1438
1614
  /* @__PURE__ */ jsx(
1439
1615
  "textarea",
1440
1616
  {
1441
1617
  id: "feedback-kit-annotation",
1442
1618
  autoFocus: true,
1443
1619
  value: popup.comment,
1620
+ disabled: popup.saving,
1444
1621
  onChange: (event) => kit.picking.setAnnotationComment(event.currentTarget.value),
1445
- rows: 4,
1446
- placeholder: "\uC758\uACAC\uC744 \uC785\uB825\uD574\uC8FC\uC138\uC694",
1447
- style: { ...inputStyle, resize: "vertical" }
1622
+ onKeyDown: (event) => {
1623
+ if (event.key !== "Enter" || event.shiftKey) return;
1624
+ const native = event.nativeEvent;
1625
+ if (native.isComposing) return;
1626
+ event.preventDefault();
1627
+ void kit.picking.saveAnnotation();
1628
+ },
1629
+ rows: 2,
1630
+ placeholder: "\uBB34\uC5C7\uC774 \uBB38\uC81C\uC778\uAC00\uC694? (Enter \uB85C \uBCF4\uB0B4\uAE30)",
1631
+ style: { ...inputStyle, resize: "vertical", minHeight: 58 }
1632
+ }
1633
+ ),
1634
+ popup.comment.length > COMMENT_MAX_CHARS2 ? /* @__PURE__ */ jsx("div", { role: "alert", style: { marginTop: 4, color: TOKENS.danger }, children: "4,000\uC790 \uC774\uD558\uB85C \uC785\uB825\uD574\uC8FC\uC138\uC694" }) : null,
1635
+ /* @__PURE__ */ jsx(
1636
+ "div",
1637
+ {
1638
+ style: {
1639
+ display: "flex",
1640
+ alignItems: "center",
1641
+ gap: 8,
1642
+ marginTop: 8,
1643
+ color: TOKENS.muted,
1644
+ fontSize: 12
1645
+ },
1646
+ children: popup.screenshot ? /* @__PURE__ */ jsxs(Fragment, { children: [
1647
+ /* @__PURE__ */ jsx(
1648
+ "img",
1649
+ {
1650
+ src: screenshotSource(popup.screenshot),
1651
+ alt: "\uC774 \uC8FC\uC11D\uC5D0 \uCCA8\uBD80\uB41C \uD654\uBA74",
1652
+ style: {
1653
+ width: 40,
1654
+ height: 26,
1655
+ objectFit: "cover",
1656
+ borderRadius: 4,
1657
+ border: `1px solid ${TOKENS.line}`
1658
+ }
1659
+ }
1660
+ ),
1661
+ /* @__PURE__ */ jsx("span", { style: { flex: 1 }, children: "\uD654\uBA74 \uCCA8\uBD80\uB428 \xB7 \uBD99\uC5EC\uB123\uAE30\uB85C \uAD50\uCCB4" }),
1662
+ /* @__PURE__ */ jsx(
1663
+ "button",
1664
+ {
1665
+ type: "button",
1666
+ disabled: popup.saving,
1667
+ onClick: () => kit.picking.removeAnnotationImage(),
1668
+ style: { ...baseButton, minHeight: 26, padding: "2px 8px", fontSize: 12 },
1669
+ children: "\uC81C\uAC70"
1670
+ }
1671
+ )
1672
+ ] }) : /* @__PURE__ */ jsx("span", { role: "status", style: { flex: 1 }, children: popup.screenshotStatus === "capturing" ? "\uD654\uBA74 \uCEA1\uCC98 \uC911\u2026" : popup.screenshotStatus === "failed" ? "\uCEA1\uCC98 \uC2E4\uD328 \u2014 \uC774\uBBF8\uC9C0\uB97C \uBD99\uC5EC\uB123\uC5B4 \uC8FC\uC138\uC694" : "\uC774\uBBF8\uC9C0\uB97C \uBD99\uC5EC\uB123\uAC70\uB098 \uB04C\uC5B4\uB2E4 \uB193\uC744 \uC218 \uC788\uC5B4\uC694" })
1448
1673
  }
1449
1674
  ),
1450
- popup.comment.length > 4e3 ? /* @__PURE__ */ jsx("div", { role: "alert", style: { marginTop: 4, color: TOKENS.danger }, children: "4,000\uC790 \uC774\uD558\uB85C \uC785\uB825\uD574\uC8FC\uC138\uC694" }) : null,
1451
1675
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 10 }, children: [
1452
- /* @__PURE__ */ jsx("button", { type: "button", onClick: () => kit.picking.cancelAnnotation(), style: baseButton, children: "\uCDE8\uC18C" }),
1676
+ /* @__PURE__ */ jsx(
1677
+ "button",
1678
+ {
1679
+ type: "button",
1680
+ disabled: popup.saving,
1681
+ onClick: () => kit.picking.cancelAnnotation(),
1682
+ style: { ...baseButton, minHeight: 32, padding: "5px 10px" },
1683
+ children: "\uCDE8\uC18C"
1684
+ }
1685
+ ),
1453
1686
  /* @__PURE__ */ jsx(
1454
1687
  "button",
1455
1688
  {
1456
1689
  type: "submit",
1457
- disabled: !popup.canSave,
1690
+ disabled: !popup.canSave || popup.saving,
1458
1691
  style: {
1459
1692
  ...primaryButton,
1460
- opacity: popup.canSave ? 1 : 0.5,
1461
- cursor: popup.canSave ? "pointer" : "not-allowed"
1693
+ minHeight: 32,
1694
+ padding: "5px 12px",
1695
+ opacity: popup.canSave && !popup.saving ? 1 : 0.5,
1696
+ cursor: popup.canSave && !popup.saving ? "pointer" : "not-allowed"
1462
1697
  },
1463
- children: "\uC800\uC7A5"
1698
+ children: popup.saving ? "\uBCF4\uB0B4\uB294 \uC911\u2026" : "\uBCF4\uB0B4\uAE30"
1464
1699
  }
1465
1700
  )
1466
1701
  ] })
@@ -1540,7 +1775,7 @@ function webContextProviders() {
1540
1775
  };
1541
1776
  }
1542
1777
  export {
1543
- COMMENT_MAX_CHARS2 as COMMENT_MAX_CHARS,
1778
+ COMMENT_MAX_CHARS3 as COMMENT_MAX_CHARS,
1544
1779
  COMMENT_REQUIRED_MESSAGE,
1545
1780
  COMMENT_TOO_LONG_MESSAGE,
1546
1781
  ELEMENT_TEXT_MAX_CHARS,