feedtack 1.0.1 → 1.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.
@@ -1,33 +1,9 @@
1
1
  import {
2
- SCHEMA_VERSION,
3
- getDeviceMeta,
4
- getPageMeta,
5
- getPinCoords,
6
- getTargetMeta,
7
- getViewportMeta,
8
- themeToCSS
9
- } from "../chunk-2A5LLDLP.js";
10
-
11
- // src/ui/colors.ts
12
- var PIN_PALETTE = [
13
- "#ef4444",
14
- // red
15
- "#3b82f6",
16
- // blue
17
- "#22c55e",
18
- // green
19
- "#f59e0b",
20
- // amber
21
- "#a855f7",
22
- // purple
23
- "#ec4899"
24
- // pink
25
- ];
2
+ FeedtackEngine,
3
+ PIN_PALETTE
4
+ } from "../chunk-3INDOI4N.js";
26
5
 
27
6
  // src/react/utils.ts
28
- function generateId() {
29
- return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
30
- }
31
7
  function getAnchoredPosition(x, y) {
32
8
  const FORM_HEIGHT = 220;
33
9
  const EDGE = 300;
@@ -611,822 +587,7 @@ function resolvePin(pin) {
611
587
  }
612
588
 
613
589
  // src/react/useFeedtackState.ts
614
- import { useCallback as useCallback5, useEffect as useEffect6, useState as useState3 } from "react";
615
-
616
- // src/react/useFeedtackActions.ts
617
- import { useCallback as useCallback2 } from "react";
618
- function useFeedtackActions(deps) {
619
- const { adapter, currentUser, onError } = deps;
620
- const updateItem = useCallback2(
621
- (id, fn) => deps.setFeedbackItems(
622
- (prev) => prev.map((i) => i.payload.id === id ? fn(i) : i)
623
- ),
624
- [deps.setFeedbackItems]
625
- );
626
- const handleSubmit = useCallback2(async () => {
627
- const comment = deps.getComment();
628
- if (!comment.trim()) {
629
- deps.setCommentError(true);
630
- return;
631
- }
632
- deps.setSubmitting(true);
633
- const payload = {
634
- schemaVersion: SCHEMA_VERSION,
635
- id: generateId(),
636
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
637
- scope: deps.getScope(),
638
- submittedBy: currentUser,
639
- comment: comment.trim(),
640
- sentiment: deps.getSentiment(),
641
- pins: deps.getPendingPins().map((p, i) => ({ ...p, index: i + 1 })),
642
- page: getPageMeta(),
643
- viewport: getViewportMeta(),
644
- device: getDeviceMeta()
645
- };
646
- try {
647
- await adapter.submit(payload);
648
- deps.setFeedbackItems((prev) => [
649
- ...prev,
650
- { payload, replies: [], resolutions: [], archives: [] }
651
- ]);
652
- deps.deactivatePinMode();
653
- } catch (err) {
654
- onError?.(err);
655
- } finally {
656
- deps.setSubmitting(false);
657
- }
658
- }, [adapter, currentUser, onError, deps]);
659
- const handleReply = useCallback2(
660
- async (feedbackId) => {
661
- const body = deps.getReplyBody().trim();
662
- if (!body) return;
663
- const ts = (/* @__PURE__ */ new Date()).toISOString();
664
- try {
665
- await adapter.reply(feedbackId, {
666
- author: currentUser,
667
- body,
668
- timestamp: ts
669
- });
670
- updateItem(feedbackId, (item) => {
671
- const updated = {
672
- ...item,
673
- replies: [
674
- ...item.replies,
675
- {
676
- id: generateId(),
677
- feedbackId,
678
- author: currentUser,
679
- body,
680
- timestamp: ts
681
- }
682
- ]
683
- };
684
- const rescope = deps.shouldRescope?.(currentUser.role) ?? currentUser.role !== "agent";
685
- if (rescope && updated.resolutions.length === 0 && deps.hasFlush) {
686
- deps.clearFlushed?.(deps.getPathname());
687
- }
688
- return updated;
689
- });
690
- deps.setReplyBody("");
691
- } catch (err) {
692
- onError?.(err);
693
- }
694
- },
695
- [adapter, currentUser, onError, updateItem, deps]
696
- );
697
- const handleResolve = useCallback2(
698
- async (feedbackId) => {
699
- const ts = (/* @__PURE__ */ new Date()).toISOString();
700
- try {
701
- await adapter.resolve(feedbackId, {
702
- resolvedBy: currentUser,
703
- timestamp: ts
704
- });
705
- updateItem(feedbackId, (item) => ({
706
- ...item,
707
- resolutions: [
708
- ...item.resolutions,
709
- { feedbackId, resolvedBy: currentUser, timestamp: ts }
710
- ]
711
- }));
712
- } catch (err) {
713
- onError?.(err);
714
- }
715
- },
716
- [adapter, currentUser, onError, updateItem]
717
- );
718
- const handleArchive = useCallback2(
719
- async (feedbackId) => {
720
- const ts = (/* @__PURE__ */ new Date()).toISOString();
721
- try {
722
- await adapter.archive(feedbackId, currentUser.id);
723
- updateItem(feedbackId, (item) => ({
724
- ...item,
725
- archives: [
726
- ...item.archives,
727
- { feedbackId, archivedBy: currentUser, timestamp: ts }
728
- ]
729
- }));
730
- deps.setOpenThreadId(null);
731
- } catch (err) {
732
- onError?.(err);
733
- }
734
- },
735
- [adapter, currentUser, onError, updateItem, deps]
736
- );
737
- return { handleSubmit, handleReply, handleResolve, handleArchive };
738
- }
739
-
740
- // src/react/useFeedtackDom.ts
741
- import { useEffect as useEffect3, useRef as useRef2 } from "react";
742
-
743
- // src/ui/modalStyles.ts
744
- var FEEDTACK_MODAL_STYLES = `
745
- .feedtack-loading {
746
- position: fixed;
747
- bottom: 70px;
748
- right: 24px;
749
- font-size: 12px;
750
- color: var(--ft-text-muted);
751
- z-index: 2147483640;
752
- }
753
-
754
- .feedtack-modal {
755
- position: fixed;
756
- bottom: 72px;
757
- right: 24px;
758
- width: 360px;
759
- max-height: 70vh;
760
- background: var(--ft-bg);
761
- border: 1px solid var(--ft-border);
762
- border-radius: calc(var(--ft-radius) + 4px);
763
- box-shadow: 0 8px 32px rgba(0,0,0,0.18);
764
- z-index: 2147483643;
765
- display: flex;
766
- flex-direction: column;
767
- overflow: hidden;
768
- }
769
-
770
- .feedtack-modal-header {
771
- display: flex;
772
- align-items: center;
773
- justify-content: space-between;
774
- padding: 14px 16px 0;
775
- }
776
-
777
- .feedtack-modal-title {
778
- font-size: 15px;
779
- font-weight: 600;
780
- color: var(--ft-text);
781
- }
782
-
783
- .feedtack-modal-close {
784
- background: none;
785
- border: none;
786
- font-size: 20px;
787
- cursor: pointer;
788
- color: var(--ft-text-muted);
789
- line-height: 1;
790
- padding: 0 4px;
791
- }
792
-
793
- .feedtack-modal-tabs {
794
- display: flex;
795
- gap: 0;
796
- padding: 12px 16px 0;
797
- border-bottom: 1px solid var(--ft-border);
798
- }
799
-
800
- .feedtack-modal-tab {
801
- flex: 1;
802
- padding: 8px 12px;
803
- border: none;
804
- background: none;
805
- font-size: 13px;
806
- font-weight: 500;
807
- cursor: pointer;
808
- color: var(--ft-text-muted);
809
- border-bottom: 2px solid transparent;
810
- margin-bottom: -1px;
811
- display: flex;
812
- align-items: center;
813
- justify-content: center;
814
- gap: 6px;
815
- }
816
-
817
- .feedtack-modal-tab.active {
818
- color: var(--ft-primary);
819
- border-bottom-color: var(--ft-primary);
820
- }
821
-
822
- .feedtack-tab-count {
823
- font-size: 11px;
824
- background: var(--ft-surface);
825
- color: var(--ft-text-muted);
826
- padding: 1px 6px;
827
- border-radius: 10px;
828
- }
829
-
830
- .feedtack-modal-body {
831
- flex: 1;
832
- overflow-y: auto;
833
- padding: 12px 16px;
834
- display: flex;
835
- flex-direction: column;
836
- gap: 12px;
837
- }
838
-
839
- .feedtack-modal-threads {
840
- display: flex;
841
- flex-direction: column;
842
- gap: 6px;
843
- }
844
-
845
- .feedtack-modal-thread-item {
846
- display: flex;
847
- flex-direction: column;
848
- gap: 2px;
849
- text-align: left;
850
- padding: 10px 12px;
851
- background: var(--ft-surface);
852
- border: 1px solid var(--ft-border);
853
- border-radius: var(--ft-radius);
854
- cursor: pointer;
855
- }
856
-
857
- .feedtack-modal-thread-item:hover {
858
- border-color: var(--ft-primary);
859
- }
860
-
861
- .feedtack-thread-author {
862
- font-size: 12px;
863
- font-weight: 600;
864
- color: var(--ft-text);
865
- }
866
-
867
- .feedtack-thread-comment {
868
- font-size: 13px;
869
- color: var(--ft-text);
870
- overflow: hidden;
871
- text-overflow: ellipsis;
872
- white-space: nowrap;
873
- }
874
-
875
- .feedtack-thread-meta {
876
- font-size: 11px;
877
- color: var(--ft-text-muted);
878
- }
879
-
880
- .feedtack-modal-compose {
881
- display: flex;
882
- flex-direction: column;
883
- gap: 8px;
884
- }
885
-
886
- .feedtack-modal-textarea {
887
- width: 100%;
888
- border: 1.5px solid var(--ft-border);
889
- border-radius: var(--ft-radius);
890
- padding: 8px;
891
- font-size: 13px;
892
- resize: vertical;
893
- min-height: 72px;
894
- outline: none;
895
- background: var(--ft-surface);
896
- color: var(--ft-text);
897
- }
898
-
899
- .feedtack-modal-textarea:focus {
900
- border-color: var(--ft-primary);
901
- }
902
-
903
- .feedtack-modal-textarea.error {
904
- border-color: var(--ft-error);
905
- }
906
-
907
- .feedtack-modal-footer {
908
- padding: 10px 16px 14px;
909
- border-top: 1px solid var(--ft-border);
910
- }
911
-
912
- .feedtack-modal-pin-btn {
913
- width: 100%;
914
- padding: 8px 14px;
915
- border: 1.5px solid var(--ft-border);
916
- border-radius: var(--ft-radius);
917
- background: var(--ft-bg);
918
- color: var(--ft-text);
919
- font-size: 13px;
920
- font-weight: 500;
921
- cursor: pointer;
922
- transition: border-color 0.15s;
923
- }
924
-
925
- .feedtack-modal-pin-btn:hover {
926
- border-color: var(--ft-primary);
927
- color: var(--ft-primary);
928
- }
929
-
930
- .feedtack-modal-thread-view {
931
- display: flex;
932
- flex-direction: column;
933
- gap: 10px;
934
- }
935
-
936
- .feedtack-modal-back {
937
- background: none;
938
- border: none;
939
- font-size: 13px;
940
- color: var(--ft-primary);
941
- cursor: pointer;
942
- padding: 0;
943
- text-align: left;
944
- }
945
-
946
- .feedtack-modal-thread-content {
947
- display: flex;
948
- flex-direction: column;
949
- gap: 4px;
950
- font-size: 13px;
951
- }
952
-
953
- .feedtack-modal-reply {
954
- border-top: 1px solid var(--ft-border);
955
- padding-top: 8px;
956
- font-size: 12px;
957
- }
958
-
959
- .feedtack-reply-author {
960
- font-weight: 600;
961
- }
962
-
963
- .feedtack-modal-actions {
964
- display: flex;
965
- gap: 6px;
966
- flex-wrap: wrap;
967
- }
968
-
969
- @media (max-width: 480px) {
970
- .feedtack-modal {
971
- right: 0;
972
- bottom: 64px;
973
- width: 100vw;
974
- max-height: 85vh;
975
- border-radius: var(--ft-radius) var(--ft-radius) 0 0;
976
- border-left: none;
977
- border-right: none;
978
- border-bottom: none;
979
- }
980
- }
981
- `;
982
-
983
- // src/ui/styles.ts
984
- var FEEDTACK_DEFAULT_TOKENS = `
985
- #feedtack-root, .feedtack-form, .feedtack-thread, .feedtack-modal {
986
- --ft-primary: #2563eb;
987
- --ft-primary-hover: #1d4ed8;
988
- --ft-bg: #ffffff;
989
- --ft-surface: #f9fafb;
990
- --ft-text: #111827;
991
- --ft-text-muted: #6b7280;
992
- --ft-border: #e5e7eb;
993
- --ft-radius: 8px;
994
- --ft-error: #ef4444;
995
- --ft-badge: #f59e0b;
996
- }
997
- `;
998
- var FEEDTACK_STYLES = `
999
- #feedtack-root * {
1000
- box-sizing: border-box;
1001
- margin: 0;
1002
- padding: 0;
1003
- font-family: system-ui, -apple-system, sans-serif;
1004
- line-height: 1.5;
1005
- }
1006
-
1007
- .feedtack-btn {
1008
- position: fixed;
1009
- bottom: 24px;
1010
- right: 24px;
1011
- z-index: 2147483640;
1012
- background: var(--ft-text);
1013
- color: var(--ft-bg);
1014
- border: none;
1015
- border-radius: var(--ft-radius);
1016
- padding: 8px 14px;
1017
- font-size: 13px;
1018
- font-weight: 500;
1019
- cursor: pointer;
1020
- box-shadow: 0 2px 8px rgba(0,0,0,0.25);
1021
- display: flex;
1022
- align-items: center;
1023
- gap: 6px;
1024
- transition: background 0.15s;
1025
- }
1026
-
1027
- .feedtack-btn:hover {
1028
- opacity: 0.85;
1029
- }
1030
-
1031
- .feedtack-btn.active {
1032
- background: var(--ft-primary);
1033
- }
1034
-
1035
- .feedtack-crosshair * {
1036
- cursor: crosshair !important;
1037
- }
1038
-
1039
- .feedtack-pin-marker {
1040
- position: absolute;
1041
- z-index: 2147483641;
1042
- width: 24px;
1043
- height: 24px;
1044
- border-radius: 50% 50% 50% 0;
1045
- transform: translate(-50%, -100%) rotate(-45deg);
1046
- transform-origin: bottom center;
1047
- border: 2px solid rgba(255,255,255,0.8);
1048
- box-shadow: 0 2px 6px rgba(0,0,0,0.3);
1049
- cursor: pointer;
1050
- pointer-events: all;
1051
- }
1052
-
1053
- .feedtack-pin-resolved { opacity: 0.6; }
1054
-
1055
- .feedtack-pin-icon {
1056
- position: absolute;
1057
- inset: 0;
1058
- display: flex;
1059
- align-items: center;
1060
- justify-content: center;
1061
- transform: rotate(45deg);
1062
- font-size: 12px;
1063
- font-weight: 700;
1064
- color: #fff;
1065
- line-height: 1;
1066
- pointer-events: none;
1067
- }
1068
-
1069
- .feedtack-pin-badge {
1070
- position: absolute;
1071
- top: -4px;
1072
- right: -4px;
1073
- width: 10px;
1074
- height: 10px;
1075
- background: var(--ft-badge);
1076
- border-radius: 50%;
1077
- border: 1.5px solid var(--ft-bg);
1078
- }
1079
-
1080
- .feedtack-color-picker {
1081
- display: flex;
1082
- gap: 6px;
1083
- padding: 8px;
1084
- background: var(--ft-bg) !important;
1085
- border-radius: var(--ft-radius);
1086
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
1087
- position: fixed;
1088
- bottom: 72px;
1089
- right: 24px;
1090
- z-index: 2147483641;
1091
- }
1092
-
1093
- .feedtack-color-swatch {
1094
- width: 20px;
1095
- height: 20px;
1096
- border-radius: 50%;
1097
- border: 2px solid transparent;
1098
- cursor: pointer;
1099
- transition: transform 0.1s;
1100
- }
1101
-
1102
- .feedtack-color-swatch.selected {
1103
- border-color: var(--ft-text);
1104
- transform: scale(1.15);
1105
- }
1106
-
1107
- .feedtack-form {
1108
- position: absolute;
1109
- z-index: 2147483642;
1110
- background: var(--ft-bg) !important;
1111
- border-radius: calc(var(--ft-radius) + 2px);
1112
- box-shadow: 0 4px 20px rgba(0,0,0,0.18);
1113
- padding: 16px;
1114
- width: 280px;
1115
- display: flex;
1116
- flex-direction: column;
1117
- gap: 10px;
1118
- }
1119
-
1120
- .feedtack-form textarea {
1121
- width: 100%;
1122
- border: 1.5px solid var(--ft-border);
1123
- border-radius: var(--ft-radius);
1124
- padding: 8px;
1125
- font-size: 13px;
1126
- resize: vertical;
1127
- min-height: 80px;
1128
- outline: none;
1129
- background: var(--ft-surface);
1130
- color: var(--ft-text);
1131
- }
1132
-
1133
- .feedtack-form textarea:focus {
1134
- border-color: var(--ft-primary);
1135
- }
1136
-
1137
- .feedtack-form textarea.error {
1138
- border-color: var(--ft-error);
1139
- }
1140
-
1141
- .feedtack-error-msg {
1142
- font-size: 12px;
1143
- color: var(--ft-error);
1144
- }
1145
-
1146
- .feedtack-sentiment {
1147
- display: flex;
1148
- gap: 8px;
1149
- }
1150
-
1151
- .feedtack-sentiment button {
1152
- flex: 1;
1153
- padding: 6px 10px;
1154
- border: 1.5px solid var(--ft-border);
1155
- border-radius: var(--ft-radius);
1156
- background: var(--ft-bg);
1157
- color: var(--ft-text);
1158
- font-size: 12px;
1159
- cursor: pointer;
1160
- transition: all 0.1s;
1161
- }
1162
-
1163
- .feedtack-sentiment button.selected {
1164
- border-color: var(--ft-primary);
1165
- background: var(--ft-surface);
1166
- color: var(--ft-primary);
1167
- }
1168
-
1169
- .feedtack-form-actions {
1170
- display: flex;
1171
- gap: 8px;
1172
- justify-content: flex-end;
1173
- }
1174
-
1175
- .feedtack-btn-cancel {
1176
- padding: 6px 12px;
1177
- border: 1.5px solid var(--ft-border);
1178
- border-radius: var(--ft-radius);
1179
- background: var(--ft-bg);
1180
- color: var(--ft-text);
1181
- font-size: 13px;
1182
- cursor: pointer;
1183
- }
1184
-
1185
- .feedtack-btn-submit {
1186
- padding: 6px 12px;
1187
- border: none;
1188
- border-radius: var(--ft-radius);
1189
- background: var(--ft-primary);
1190
- color: #fff;
1191
- font-size: 13px;
1192
- font-weight: 500;
1193
- cursor: pointer;
1194
- }
1195
-
1196
- .feedtack-btn-submit:disabled {
1197
- opacity: 0.5;
1198
- cursor: not-allowed;
1199
- }
1200
-
1201
- .feedtack-thread {
1202
- position: absolute;
1203
- z-index: 2147483642;
1204
- background: var(--ft-bg) !important;
1205
- border-radius: calc(var(--ft-radius) + 2px);
1206
- box-shadow: 0 4px 20px rgba(0,0,0,0.18);
1207
- padding: 16px;
1208
- width: 300px;
1209
- max-height: 400px;
1210
- overflow-y: auto;
1211
- display: flex;
1212
- flex-direction: column;
1213
- gap: 10px;
1214
- }
1215
-
1216
- .feedtack-sr-only {
1217
- position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
1218
- overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
1219
- }
1220
-
1221
- ` + FEEDTACK_MODAL_STYLES;
1222
-
1223
- // src/react/useFeedtackDom.ts
1224
- function useFeedtackDom(theme, disabled) {
1225
- const rootRef = useRef2(null);
1226
- useEffect3(() => {
1227
- if (disabled) return;
1228
- if (document.getElementById("feedtack-styles")) return;
1229
- const style = document.createElement("style");
1230
- style.id = "feedtack-styles";
1231
- style.textContent = FEEDTACK_DEFAULT_TOKENS + FEEDTACK_STYLES;
1232
- document.head.appendChild(style);
1233
- return () => {
1234
- style.remove();
1235
- };
1236
- }, [disabled]);
1237
- useEffect3(() => {
1238
- if (disabled) return;
1239
- const root = document.createElement("div");
1240
- root.id = "feedtack-root";
1241
- document.body.appendChild(root);
1242
- rootRef.current = root;
1243
- return () => {
1244
- root.remove();
1245
- };
1246
- }, [disabled]);
1247
- useEffect3(() => {
1248
- if (disabled) return;
1249
- const root = document.getElementById("feedtack-root");
1250
- if (!root || !theme) return;
1251
- const tokens = themeToCSS(theme);
1252
- for (const [k, v] of Object.entries(tokens)) {
1253
- root.style.setProperty(k, v);
1254
- }
1255
- }, [theme, disabled]);
1256
- return rootRef;
1257
- }
1258
-
1259
- // src/react/useFeedtackFlush.ts
1260
- import { useCallback as useCallback3, useEffect as useEffect4, useRef as useRef3 } from "react";
1261
- var DEFAULT_IDLE_MS = 5 * 60 * 1e3;
1262
- function useFeedtackFlush({
1263
- pathname,
1264
- feedbackItems,
1265
- onFlush,
1266
- flushIdleMs = DEFAULT_IDLE_MS,
1267
- disabled
1268
- }) {
1269
- const flushedRef = useRef3(/* @__PURE__ */ new Set());
1270
- const prevPathnameRef = useRef3(pathname);
1271
- const idleTimerRef = useRef3(null);
1272
- const flush = useCallback3(
1273
- (path, items) => {
1274
- if (!onFlush || flushedRef.current.has(path)) return;
1275
- const pageItems = items.filter((i) => i.payload.page.pathname === path);
1276
- if (pageItems.length === 0) return;
1277
- flushedRef.current.add(path);
1278
- onFlush({ pathname: path, items: pageItems });
1279
- },
1280
- [onFlush]
1281
- );
1282
- useEffect4(() => {
1283
- if (disabled || !onFlush) return;
1284
- const prev = prevPathnameRef.current;
1285
- prevPathnameRef.current = pathname;
1286
- if (prev !== pathname) {
1287
- flush(prev, feedbackItems);
1288
- }
1289
- }, [pathname, feedbackItems, flush, onFlush, disabled]);
1290
- useEffect4(() => {
1291
- if (disabled || !onFlush || flushIdleMs <= 0) return;
1292
- const resetTimer = () => {
1293
- if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
1294
- idleTimerRef.current = setTimeout(() => {
1295
- flush(pathname, feedbackItems);
1296
- }, flushIdleMs);
1297
- };
1298
- const events = ["mousemove", "keydown", "scroll", "touchstart"];
1299
- for (const e of events)
1300
- window.addEventListener(e, resetTimer, { passive: true });
1301
- resetTimer();
1302
- return () => {
1303
- if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
1304
- for (const e of events) window.removeEventListener(e, resetTimer);
1305
- };
1306
- }, [pathname, feedbackItems, flush, onFlush, flushIdleMs, disabled]);
1307
- useEffect4(() => {
1308
- if (disabled || !onFlush) return;
1309
- const handleUnload = () => flush(pathname, feedbackItems);
1310
- window.addEventListener("beforeunload", handleUnload);
1311
- return () => window.removeEventListener("beforeunload", handleUnload);
1312
- }, [pathname, feedbackItems, flush, onFlush, disabled]);
1313
- const clearFlushed = useCallback3((path) => {
1314
- flushedRef.current.delete(path);
1315
- }, []);
1316
- return { clearFlushed };
1317
- }
1318
-
1319
- // src/react/usePinMode.ts
1320
- import { useCallback as useCallback4, useEffect as useEffect5, useState as useState2 } from "react";
1321
- function usePinMode({
1322
- hotkey,
1323
- onDeactivate,
1324
- disabled,
1325
- isModalOpen,
1326
- onHotkey
1327
- }) {
1328
- const [isActive, setIsActive] = useState2(false);
1329
- const [pendingPins, setPendingPins] = useState2([]);
1330
- const [selectedColor, setSelectedColor] = useState2(PIN_PALETTE[0]);
1331
- const [showForm, setShowForm] = useState2(false);
1332
- const activate = useCallback4(() => setIsActive(true), []);
1333
- const deactivate = useCallback4(() => {
1334
- setIsActive(false);
1335
- setPendingPins([]);
1336
- setShowForm(false);
1337
- onDeactivate?.();
1338
- }, [onDeactivate]);
1339
- useEffect5(() => {
1340
- if (isActive) {
1341
- document.documentElement.classList.add("feedtack-crosshair");
1342
- } else {
1343
- document.documentElement.classList.remove("feedtack-crosshair");
1344
- }
1345
- return () => document.documentElement.classList.remove("feedtack-crosshair");
1346
- }, [isActive]);
1347
- useEffect5(() => {
1348
- if (disabled) return;
1349
- const handler = (e) => {
1350
- if (e.key === hotkey.toUpperCase() && e.shiftKey) {
1351
- if (onHotkey) {
1352
- onHotkey();
1353
- } else {
1354
- setIsActive((prev) => !prev);
1355
- }
1356
- }
1357
- if (e.key === "Escape") {
1358
- deactivate();
1359
- }
1360
- if (isActive && !isModalOpen && !showForm && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
1361
- e.preventDefault();
1362
- setSelectedColor((prev) => {
1363
- const idx = PIN_PALETTE.indexOf(prev);
1364
- const dir = e.key === "ArrowRight" ? 1 : -1;
1365
- return PIN_PALETTE[(idx + dir + PIN_PALETTE.length) % PIN_PALETTE.length];
1366
- });
1367
- }
1368
- };
1369
- window.addEventListener("keydown", handler);
1370
- return () => window.removeEventListener("keydown", handler);
1371
- }, [hotkey, deactivate, isActive, disabled, isModalOpen, showForm, onHotkey]);
1372
- const placePin = useCallback4(
1373
- (coords, target) => {
1374
- if (target.closest("#feedtack-root, .feedtack-form, .feedtack-color-picker"))
1375
- return;
1376
- setPendingPins((prev) => [
1377
- ...prev,
1378
- {
1379
- color: selectedColor,
1380
- ...getPinCoords(coords),
1381
- target: getTargetMeta(target)
1382
- }
1383
- ]);
1384
- setShowForm(true);
1385
- },
1386
- [selectedColor]
1387
- );
1388
- const handlePageClick = useCallback4(
1389
- (e) => {
1390
- if (!isActive) return;
1391
- e.preventDefault();
1392
- e.stopPropagation();
1393
- placePin(e, e.target);
1394
- },
1395
- [isActive, placePin]
1396
- );
1397
- const handleTouchEnd = useCallback4(
1398
- (e) => {
1399
- if (!isActive) return;
1400
- const touch = e.changedTouches[0];
1401
- if (!touch) return;
1402
- const target = document.elementFromPoint(touch.clientX, touch.clientY);
1403
- if (!target) return;
1404
- e.preventDefault();
1405
- placePin(touch, target);
1406
- },
1407
- [isActive, placePin]
1408
- );
1409
- useEffect5(() => {
1410
- if (disabled) return;
1411
- document.addEventListener("click", handlePageClick, true);
1412
- document.addEventListener("touchend", handleTouchEnd, true);
1413
- return () => {
1414
- document.removeEventListener("click", handlePageClick, true);
1415
- document.removeEventListener("touchend", handleTouchEnd, true);
1416
- };
1417
- }, [handlePageClick, handleTouchEnd, disabled]);
1418
- return {
1419
- isActive,
1420
- activate,
1421
- deactivate,
1422
- pendingPins,
1423
- selectedColor,
1424
- setSelectedColor,
1425
- showForm
1426
- };
1427
- }
1428
-
1429
- // src/react/useFeedtackState.ts
590
+ import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useSyncExternalStore } from "react";
1430
591
  function useFeedtackState({
1431
592
  adapter,
1432
593
  currentUser,
@@ -1438,164 +599,112 @@ function useFeedtackState({
1438
599
  flushIdleMs,
1439
600
  rescopeRoles
1440
601
  }) {
1441
- useFeedtackDom(theme, disabled);
1442
- const [pathname, setPathname] = useState3(
1443
- () => typeof window === "undefined" ? "/" : window.location.pathname
1444
- );
1445
- useEffect6(() => {
1446
- const update = () => setPathname(window.location.pathname);
1447
- const origPush = history.pushState.bind(history);
1448
- const origReplace = history.replaceState.bind(history);
1449
- history.pushState = (...args) => {
1450
- origPush(...args);
1451
- queueMicrotask(update);
1452
- };
1453
- history.replaceState = (...args) => {
1454
- origReplace(...args);
1455
- queueMicrotask(update);
1456
- };
1457
- window.addEventListener("popstate", update);
1458
- return () => {
1459
- window.removeEventListener("popstate", update);
1460
- history.pushState = origPush;
1461
- history.replaceState = origReplace;
1462
- };
1463
- }, []);
1464
- const [comment, setComment] = useState3("");
1465
- const [sentiment, setSentiment] = useState3(null);
1466
- const [commentError, setCommentError] = useState3(false);
1467
- const [submitting, setSubmitting] = useState3(false);
1468
- const [feedbackItems, setFeedbackItems] = useState3([]);
1469
- const [loading, setLoading] = useState3(true);
1470
- const [openThreadId, setOpenThreadId] = useState3(null);
1471
- const [replyBody, setReplyBody] = useState3("");
1472
- const [isModalOpen, setIsModalOpen] = useState3(false);
1473
- const [composeScope, setComposeScope] = useState3("site");
1474
- const [siteFeedback, setSiteFeedback] = useState3([]);
1475
- const [pageFeedback, setPageFeedback] = useState3([]);
1476
- const openModal = useCallback5(() => setIsModalOpen(true), []);
1477
- const closeModal = useCallback5(() => setIsModalOpen(false), []);
1478
- const resetForm = useCallback5(() => {
1479
- setComment("");
1480
- setSentiment(null);
1481
- setCommentError(false);
1482
- }, []);
1483
- const pinMode = usePinMode({
1484
- hotkey,
1485
- disabled,
1486
- isModalOpen: openThreadId !== null || isModalOpen,
1487
- onHotkey: openModal,
1488
- onDeactivate: () => {
1489
- resetForm();
1490
- setOpenThreadId(null);
1491
- }
1492
- });
1493
- const { clearFlushed } = useFeedtackFlush({
1494
- pathname,
1495
- feedbackItems,
1496
- onFlush,
1497
- flushIdleMs,
1498
- disabled
1499
- });
1500
- useEffect6(() => {
1501
- setLoading(true);
1502
- adapter.loadFeedback({ pathname }).then((items) => {
1503
- const elementItems = [];
1504
- const siteItems = [];
1505
- const pageItems = [];
1506
- for (const item of items) {
1507
- if (item.payload.scope === "site") siteItems.push(item);
1508
- else if (item.payload.scope === "page") pageItems.push(item);
1509
- else elementItems.push(item);
1510
- }
1511
- setFeedbackItems(elementItems);
1512
- setSiteFeedback(siteItems);
1513
- setPageFeedback(pageItems);
1514
- }).catch((err) => onError?.(err)).finally(() => setLoading(false));
1515
- }, [adapter, onError, pathname]);
1516
- const getCurrentScope = useCallback5(() => {
1517
- if (pinMode.isActive || pinMode.pendingPins.length > 0) return "element";
1518
- return composeScope;
1519
- }, [pinMode.isActive, pinMode.pendingPins.length, composeScope]);
1520
- const commentRef = () => comment;
1521
- const sentimentRef = () => sentiment;
1522
- const scopeRef = () => getCurrentScope();
1523
- const pinsRef = () => pinMode.pendingPins;
1524
- const replyRef = () => replyBody;
1525
- const pathRef = () => pathname;
1526
- const actions = useFeedtackActions({
1527
- adapter,
1528
- currentUser,
1529
- onError,
1530
- getComment: commentRef,
1531
- getSentiment: sentimentRef,
1532
- getScope: scopeRef,
1533
- getPendingPins: pinsRef,
1534
- getReplyBody: replyRef,
1535
- getPathname: pathRef,
1536
- setCommentError,
1537
- setSubmitting,
1538
- setFeedbackItems,
1539
- setReplyBody,
1540
- setOpenThreadId,
1541
- deactivatePinMode: pinMode.deactivate,
1542
- clearFlushed,
1543
- shouldRescope: rescopeRoles ? (role) => rescopeRoles.includes(role) : void 0,
1544
- hasFlush: !!onFlush
1545
- });
1546
- const handleModalSubmit = useCallback5(async () => {
1547
- if (!comment.trim()) {
1548
- setCommentError(true);
1549
- return;
1550
- }
1551
- await actions.handleSubmit();
1552
- const scope = composeScope;
1553
- setFeedbackItems((prev) => {
1554
- const newItem = prev[prev.length - 1];
1555
- if (newItem && newItem.payload.scope === scope) {
1556
- if (scope === "site") {
1557
- setSiteFeedback((s) => [...s, newItem]);
1558
- } else {
1559
- setPageFeedback((p) => [...p, newItem]);
1560
- }
1561
- return prev.slice(0, -1);
1562
- }
1563
- return prev;
602
+ const engineRef = useRef2(null);
603
+ if (!engineRef.current) {
604
+ engineRef.current = new FeedtackEngine({
605
+ adapter,
606
+ currentUser,
607
+ hotkey,
608
+ theme,
609
+ onError,
610
+ disabled,
611
+ onFlush,
612
+ flushIdleMs,
613
+ rescopeRoles
1564
614
  });
1565
- resetForm();
1566
- }, [actions, composeScope, resetForm, comment]);
1567
- const isArchivedForUser = (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id);
1568
- const hasUnread = (item) => item.replies.length > 0;
1569
- const hasValidPins = (item) => Array.isArray(item.payload?.pins) && item.payload.pins.length > 0;
615
+ }
616
+ const engine = engineRef.current;
617
+ useEffect3(() => {
618
+ engine.mount();
619
+ return () => engine.destroy();
620
+ }, [engine]);
621
+ const subscribe = useCallback2(
622
+ (cb) => engine.subscribe(cb),
623
+ [engine]
624
+ );
625
+ const getSnapshot = useCallback2(() => engine.getState(), [engine]);
626
+ const state = useSyncExternalStore(
627
+ subscribe,
628
+ getSnapshot,
629
+ getSnapshot
630
+ );
631
+ const isArchivedForUser = useCallback2(
632
+ (item) => engine.isArchivedForUser(item),
633
+ [engine]
634
+ );
635
+ const hasUnread = useCallback2(
636
+ (item) => engine.hasUnread(item),
637
+ [engine]
638
+ );
639
+ const hasValidPins = useCallback2(
640
+ (item) => engine.hasValidPins(item),
641
+ [engine]
642
+ );
1570
643
  return {
1571
- ...pinMode,
1572
- isPinModeActive: pinMode.isActive,
1573
- activatePinMode: pinMode.activate,
1574
- deactivatePinMode: pinMode.deactivate,
1575
- comment,
1576
- setComment,
1577
- sentiment,
1578
- setSentiment,
1579
- commentError,
1580
- setCommentError,
1581
- submitting,
1582
- pathname,
1583
- feedbackItems,
1584
- siteFeedback,
1585
- pageFeedback,
1586
- loading,
1587
- openThreadId,
1588
- setOpenThreadId,
1589
- replyBody,
1590
- setReplyBody,
1591
- // Modal state
1592
- isModalOpen,
1593
- openModal,
1594
- closeModal,
1595
- composeScope,
1596
- setComposeScope,
1597
- handleModalSubmit,
1598
- ...actions,
644
+ // Pin mode
645
+ isPinModeActive: state.isPinModeActive,
646
+ isActive: state.isPinModeActive,
647
+ activatePinMode: useCallback2(() => engine.activatePinMode(), [engine]),
648
+ activate: useCallback2(() => engine.activatePinMode(), [engine]),
649
+ deactivatePinMode: useCallback2(() => engine.deactivatePinMode(), [engine]),
650
+ deactivate: useCallback2(() => engine.deactivatePinMode(), [engine]),
651
+ pendingPins: state.pendingPins,
652
+ selectedColor: state.selectedColor,
653
+ setSelectedColor: useCallback2(
654
+ (c) => engine.setSelectedColor(c),
655
+ [engine]
656
+ ),
657
+ showForm: state.showForm,
658
+ // Form
659
+ comment: state.comment,
660
+ setComment: useCallback2((v) => engine.setComment(v), [engine]),
661
+ sentiment: state.sentiment,
662
+ setSentiment: useCallback2(
663
+ (v) => engine.setSentiment(v),
664
+ [engine]
665
+ ),
666
+ commentError: state.commentError,
667
+ setCommentError: useCallback2(
668
+ (v) => engine.setCommentError(v),
669
+ [engine]
670
+ ),
671
+ submitting: state.submitting,
672
+ // Feedback
673
+ feedbackItems: state.feedbackItems,
674
+ siteFeedback: state.siteFeedback,
675
+ pageFeedback: state.pageFeedback,
676
+ loading: state.loading,
677
+ pathname: state.pathname,
678
+ // Thread
679
+ openThreadId: state.openThreadId,
680
+ setOpenThreadId: useCallback2(
681
+ (id) => engine.setOpenThreadId(id),
682
+ [engine]
683
+ ),
684
+ replyBody: state.replyBody,
685
+ setReplyBody: useCallback2((v) => engine.setReplyBody(v), [engine]),
686
+ // Modal
687
+ isModalOpen: state.isModalOpen,
688
+ openModal: useCallback2(() => engine.openModal(), [engine]),
689
+ closeModal: useCallback2(() => engine.closeModal(), [engine]),
690
+ composeScope: state.composeScope,
691
+ setComposeScope: useCallback2(
692
+ (s) => engine.setComposeScope(s),
693
+ [engine]
694
+ ),
695
+ // Actions
696
+ handleSubmit: useCallback2(() => engine.handleSubmit(), [engine]),
697
+ handleModalSubmit: useCallback2(() => engine.handleModalSubmit(), [engine]),
698
+ handleReply: useCallback2((id) => engine.handleReply(id), [engine]),
699
+ handleResolve: useCallback2(
700
+ (id) => engine.handleResolve(id),
701
+ [engine]
702
+ ),
703
+ handleArchive: useCallback2(
704
+ (id) => engine.handleArchive(id),
705
+ [engine]
706
+ ),
707
+ // Derived helpers
1599
708
  isArchivedForUser,
1600
709
  hasUnread,
1601
710
  hasValidPins