@alpaca-editor/core 1.0.4042 → 1.0.4044
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/components/ui/button.d.ts +1 -1
- package/dist/editor/ContentTree.js +5 -1
- package/dist/editor/ContentTree.js.map +1 -1
- package/dist/editor/ScrollingContentTree.js +4 -1
- package/dist/editor/ScrollingContentTree.js.map +1 -1
- package/dist/editor/ai/AiTerminal.js +1 -46
- package/dist/editor/ai/AiTerminal.js.map +1 -1
- package/dist/editor/client/EditorClient.js +127 -37
- package/dist/editor/client/EditorClient.js.map +1 -1
- package/dist/editor/client/editContext.d.ts +1 -0
- package/dist/editor/client/editContext.js.map +1 -1
- package/dist/editor/client/operations.d.ts +3 -0
- package/dist/editor/client/operations.js +30 -1
- package/dist/editor/client/operations.js.map +1 -1
- package/dist/editor/commands/componentCommands.js +6 -0
- package/dist/editor/commands/componentCommands.js.map +1 -1
- package/dist/editor/media-selector/Thumbnails.js +1 -1
- package/dist/editor/media-selector/Thumbnails.js.map +1 -1
- package/dist/editor/page-editor-chrome/FrameMenu.js +1 -1
- package/dist/editor/page-editor-chrome/FrameMenu.js.map +1 -1
- package/dist/editor/page-editor-chrome/InlineEditor.js +30 -17
- package/dist/editor/page-editor-chrome/InlineEditor.js.map +1 -1
- package/dist/editor/reviews/SuggestedEdit.js +18 -2
- package/dist/editor/reviews/SuggestedEdit.js.map +1 -1
- package/dist/editor/reviews/SuggestionDisplayPopover.js +18 -3
- package/dist/editor/reviews/SuggestionDisplayPopover.js.map +1 -1
- package/dist/editor/services/agentService.js +1 -40
- package/dist/editor/services/agentService.js.map +1 -1
- package/dist/editor/ui/PerfectTree.js +2 -2
- package/dist/editor/ui/PerfectTree.js.map +1 -1
- package/dist/revision.d.ts +2 -2
- package/dist/revision.js +2 -2
- package/dist/styles.css +2 -5
- package/package.json +1 -1
- package/src/editor/ContentTree.tsx +5 -1
- package/src/editor/ScrollingContentTree.tsx +5 -1
- package/src/editor/ai/AiTerminal.tsx +2 -96
- package/src/editor/client/EditorClient.tsx +159 -52
- package/src/editor/client/editContext.ts +2 -6
- package/src/editor/client/operations.ts +46 -2
- package/src/editor/commands/componentCommands.tsx +4 -0
- package/src/editor/media-selector/Thumbnails.tsx +4 -1
- package/src/editor/page-editor-chrome/FrameMenu.tsx +1 -0
- package/src/editor/page-editor-chrome/InlineEditor.tsx +41 -19
- package/src/editor/reviews/SuggestedEdit.tsx +23 -2
- package/src/editor/reviews/SuggestionDisplayPopover.tsx +18 -3
- package/src/editor/services/agentService.ts +1 -76
- package/src/editor/ui/PerfectTree.tsx +11 -9
- package/src/revision.ts +2 -2
|
@@ -26,8 +26,6 @@ import {
|
|
|
26
26
|
import { fieldModificationStore } from "./fieldModificationStore";
|
|
27
27
|
|
|
28
28
|
import type { OpenDialog } from "./editContext";
|
|
29
|
-
import type { AiTerminalOptions } from "../ai/AiTerminal";
|
|
30
|
-
|
|
31
29
|
import { EditorConfiguration, MenuItem } from "../../config/types";
|
|
32
30
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
|
33
31
|
import { findComponent, getComponentById } from "../componentTreeHelper";
|
|
@@ -256,7 +254,16 @@ export function EditorClient({
|
|
|
256
254
|
});
|
|
257
255
|
}, []);
|
|
258
256
|
|
|
259
|
-
const [activeSessions, setActiveSessions] = useState<EditSession[]>(
|
|
257
|
+
const [activeSessions, setActiveSessions] = useState<EditSession[]>(() => {
|
|
258
|
+
try {
|
|
259
|
+
const existing = (globalThis as any).__alpacaActiveSessions as
|
|
260
|
+
| EditSession[]
|
|
261
|
+
| undefined;
|
|
262
|
+
return Array.isArray(existing) ? existing : [];
|
|
263
|
+
} catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
});
|
|
260
267
|
|
|
261
268
|
if (typeof window !== "undefined")
|
|
262
269
|
sessionStorage?.setItem("sessionId", sessionId);
|
|
@@ -413,6 +420,7 @@ export function EditorClient({
|
|
|
413
420
|
});
|
|
414
421
|
|
|
415
422
|
const socketMessageListeners = useRef<Set<(data: any) => void>>(new Set());
|
|
423
|
+
const socketInstanceRef = useRef<WebSocket | null>(null);
|
|
416
424
|
|
|
417
425
|
const addSocketMessageListener = useCallback(
|
|
418
426
|
(callback: (message: { type: string; payload: any }) => void) => {
|
|
@@ -531,14 +539,15 @@ export function EditorClient({
|
|
|
531
539
|
}
|
|
532
540
|
|
|
533
541
|
if (message.type === "active-sessions") {
|
|
534
|
-
setActiveSessions(() => {
|
|
542
|
+
setActiveSessions((prev) => {
|
|
535
543
|
// Ensure payload is an array and contains valid session objects
|
|
536
544
|
if (!Array.isArray(message.payload)) {
|
|
537
545
|
console.warn(
|
|
538
546
|
"❌ Active sessions payload is not an array:",
|
|
539
547
|
message.payload,
|
|
540
548
|
);
|
|
541
|
-
|
|
549
|
+
(globalThis as any).__alpacaActiveSessions = prev;
|
|
550
|
+
return prev; // keep previous instead of clearing to avoid losing user during HMR blips
|
|
542
551
|
}
|
|
543
552
|
|
|
544
553
|
// Filter out any invalid session objects
|
|
@@ -560,8 +569,41 @@ export function EditorClient({
|
|
|
560
569
|
);
|
|
561
570
|
}
|
|
562
571
|
|
|
572
|
+
// If server reports empty list, keep previous to avoid transient blanking during HMR
|
|
573
|
+
if (validSessions.length === 0 && prev.length > 0) {
|
|
574
|
+
console.warn(
|
|
575
|
+
"⚠️ Received empty active sessions; preserving previous list to avoid losing state",
|
|
576
|
+
);
|
|
577
|
+
(globalThis as any).__alpacaActiveSessions = prev;
|
|
578
|
+
return prev;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
(globalThis as any).__alpacaActiveSessions = validSessions;
|
|
563
582
|
return validSessions;
|
|
564
583
|
});
|
|
584
|
+
|
|
585
|
+
// Detect if the current session is missing from the list and self-heal
|
|
586
|
+
try {
|
|
587
|
+
const payload = Array.isArray(message.payload)
|
|
588
|
+
? (message.payload as any[])
|
|
589
|
+
: [];
|
|
590
|
+
const hasMySession = payload.some(
|
|
591
|
+
(s) => s && s.sessionId === sessionId,
|
|
592
|
+
);
|
|
593
|
+
if (!hasMySession) {
|
|
594
|
+
console.warn(
|
|
595
|
+
"⚠️ Current session missing from active sessions. Re-sending client-info to recover...",
|
|
596
|
+
);
|
|
597
|
+
setTimeout(() => {
|
|
598
|
+
sendClientInfo();
|
|
599
|
+
}, 300);
|
|
600
|
+
}
|
|
601
|
+
} catch (e) {
|
|
602
|
+
console.warn(
|
|
603
|
+
"Failed to verify active sessions for current session!",
|
|
604
|
+
e,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
565
607
|
}
|
|
566
608
|
|
|
567
609
|
if (message.type === "item-deleted") {
|
|
@@ -761,10 +803,61 @@ export function EditorClient({
|
|
|
761
803
|
);
|
|
762
804
|
|
|
763
805
|
const user = useMemo(
|
|
764
|
-
() =>
|
|
765
|
-
|
|
806
|
+
() =>
|
|
807
|
+
activeSessions.find((x) => x.sessionId === sessionId)?.user ||
|
|
808
|
+
userInfo.user,
|
|
809
|
+
[activeSessions, sessionId, userInfo.user],
|
|
766
810
|
);
|
|
767
811
|
|
|
812
|
+
// Self-heal if our session disappears (e.g., after HMR)
|
|
813
|
+
const missingSessionRecoveryTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
814
|
+
const missingSessionRecoveryAttemptsRef = useRef<number>(0);
|
|
815
|
+
useEffect(() => {
|
|
816
|
+
const hasMySession = activeSessions.some((s) => s.sessionId === sessionId);
|
|
817
|
+
if (hasMySession) {
|
|
818
|
+
// Reset recovery state when we see ourselves again
|
|
819
|
+
if (missingSessionRecoveryTimerRef.current) {
|
|
820
|
+
clearTimeout(missingSessionRecoveryTimerRef.current);
|
|
821
|
+
missingSessionRecoveryTimerRef.current = null;
|
|
822
|
+
}
|
|
823
|
+
missingSessionRecoveryAttemptsRef.current = 0;
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Only run one recovery timer at a time
|
|
828
|
+
if (missingSessionRecoveryTimerRef.current) return;
|
|
829
|
+
|
|
830
|
+
const attempt = missingSessionRecoveryAttemptsRef.current + 1;
|
|
831
|
+
const delay = Math.min(250 * Math.pow(2, attempt - 1), 2000);
|
|
832
|
+
|
|
833
|
+
console.warn(
|
|
834
|
+
`⚠️ Current session not present in active sessions. Recovery attempt ${attempt} in ${delay}ms...`,
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
missingSessionRecoveryTimerRef.current = setTimeout(() => {
|
|
838
|
+
missingSessionRecoveryTimerRef.current = null;
|
|
839
|
+
missingSessionRecoveryAttemptsRef.current = attempt;
|
|
840
|
+
|
|
841
|
+
if (attempt <= 3) {
|
|
842
|
+
sendClientInfo();
|
|
843
|
+
} else {
|
|
844
|
+
// Force a reconnect to refresh presence after several failed nudges
|
|
845
|
+
try {
|
|
846
|
+
socketInstanceRef.current?.close(4000, "recover-presence");
|
|
847
|
+
} catch {
|
|
848
|
+
// Ignore errors while attempting to recover presence
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}, delay);
|
|
852
|
+
|
|
853
|
+
return () => {
|
|
854
|
+
if (missingSessionRecoveryTimerRef.current) {
|
|
855
|
+
clearTimeout(missingSessionRecoveryTimerRef.current);
|
|
856
|
+
missingSessionRecoveryTimerRef.current = null;
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
}, [activeSessions, sessionId]);
|
|
860
|
+
|
|
768
861
|
useEffect(() => {
|
|
769
862
|
if (typeof window === "undefined") return;
|
|
770
863
|
|
|
@@ -919,57 +1012,64 @@ export function EditorClient({
|
|
|
919
1012
|
let reconnectAttempts = 0;
|
|
920
1013
|
|
|
921
1014
|
const connectWebSocket = () => {
|
|
922
|
-
let socket: WebSocket = (globalThis as any).editorSocket;
|
|
1015
|
+
let socket: WebSocket | undefined = (globalThis as any).editorSocket;
|
|
1016
|
+
|
|
1017
|
+
const needsNewSocket =
|
|
1018
|
+
!socket ||
|
|
1019
|
+
socket.readyState === WebSocket.CLOSING ||
|
|
1020
|
+
socket.readyState === WebSocket.CLOSED;
|
|
1021
|
+
|
|
1022
|
+
if (needsNewSocket) {
|
|
1023
|
+
socket = connectSocket(sessionId);
|
|
1024
|
+
|
|
1025
|
+
// Connection opened
|
|
1026
|
+
socket.addEventListener("open", () => {
|
|
1027
|
+
console.log("Connected!");
|
|
1028
|
+
reconnectAttempts = 0; // Reset attempts on successful connection
|
|
1029
|
+
sendClientInfo();
|
|
1030
|
+
requestQuota();
|
|
1031
|
+
//TODO: Load clients
|
|
1032
|
+
});
|
|
923
1033
|
|
|
924
|
-
|
|
925
|
-
socket
|
|
926
|
-
|
|
927
|
-
socket.readyState === WebSocket.CONNECTING)
|
|
928
|
-
)
|
|
929
|
-
return;
|
|
1034
|
+
// Handle connection close
|
|
1035
|
+
socket.addEventListener("close", (event) => {
|
|
1036
|
+
// WebSocket connection closed
|
|
930
1037
|
|
|
931
|
-
|
|
1038
|
+
// Only attempt to reconnect if it wasn't a clean close
|
|
1039
|
+
if (event.code !== 1000) {
|
|
1040
|
+
// Start with 1 second, increase exponentially up to 30 seconds
|
|
1041
|
+
const delay = Math.min(
|
|
1042
|
+
1000 * Math.pow(2, Math.min(reconnectAttempts, 5)),
|
|
1043
|
+
30000,
|
|
1044
|
+
);
|
|
1045
|
+
// Attempting to reconnect with backoff
|
|
932
1046
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
//TODO: Load clients
|
|
940
|
-
});
|
|
1047
|
+
reconnectTimeout = setTimeout(() => {
|
|
1048
|
+
reconnectAttempts++;
|
|
1049
|
+
connectWebSocket();
|
|
1050
|
+
}, delay);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
941
1053
|
|
|
942
|
-
|
|
943
|
-
|
|
1054
|
+
// Handle connection errors
|
|
1055
|
+
socket.addEventListener("error", (error) => {
|
|
1056
|
+
console.error("WebSocket error:", error);
|
|
1057
|
+
});
|
|
944
1058
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
console.log("WebSocket connection closed:", event.code, event.reason);
|
|
1059
|
+
(globalThis as any).editorSocket = socket;
|
|
1060
|
+
}
|
|
948
1061
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
1000 * Math.pow(2, Math.min(reconnectAttempts, 5)),
|
|
954
|
-
30000,
|
|
955
|
-
);
|
|
956
|
-
console.log(
|
|
957
|
-
`Attempting to reconnect in ${delay}ms... (attempt ${reconnectAttempts + 1})`,
|
|
958
|
-
);
|
|
1062
|
+
// Always ensure this instance is listening to messages
|
|
1063
|
+
if (socket) {
|
|
1064
|
+
socket.addEventListener("message", messageHandler);
|
|
1065
|
+
socketInstanceRef.current = socket;
|
|
959
1066
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1067
|
+
// If we attached to an already-open socket, resend client info to refresh presence
|
|
1068
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
1069
|
+
sendClientInfo();
|
|
1070
|
+
requestQuota();
|
|
964
1071
|
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// Handle connection errors
|
|
968
|
-
socket.addEventListener("error", (error) => {
|
|
969
|
-
console.error("WebSocket error:", error);
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
(globalThis as any).editorSocket = socket;
|
|
1072
|
+
}
|
|
973
1073
|
};
|
|
974
1074
|
|
|
975
1075
|
connectWebSocket();
|
|
@@ -979,6 +1079,13 @@ export function EditorClient({
|
|
|
979
1079
|
if (reconnectTimeout) {
|
|
980
1080
|
clearTimeout(reconnectTimeout);
|
|
981
1081
|
}
|
|
1082
|
+
if (socketInstanceRef.current) {
|
|
1083
|
+
socketInstanceRef.current.removeEventListener(
|
|
1084
|
+
"message",
|
|
1085
|
+
messageHandler,
|
|
1086
|
+
);
|
|
1087
|
+
socketInstanceRef.current = null;
|
|
1088
|
+
}
|
|
982
1089
|
};
|
|
983
1090
|
}, []);
|
|
984
1091
|
|
|
@@ -1904,7 +2011,7 @@ export function EditorClient({
|
|
|
1904
2011
|
viewName,
|
|
1905
2012
|
]);
|
|
1906
2013
|
|
|
1907
|
-
const editContext = useMemo(() => {
|
|
2014
|
+
const editContext = useMemo<EditContextType>(() => {
|
|
1908
2015
|
// console.log('🔄 EditContext useMemo is being recalculated');
|
|
1909
2016
|
const context = {
|
|
1910
2017
|
operations: operationsContext.ops,
|
|
@@ -2330,7 +2437,7 @@ export function EditorClient({
|
|
|
2330
2437
|
loadFavorites,
|
|
2331
2438
|
};
|
|
2332
2439
|
|
|
2333
|
-
return context;
|
|
2440
|
+
return context as unknown as EditContextType;
|
|
2334
2441
|
}, [
|
|
2335
2442
|
operations,
|
|
2336
2443
|
itemsRepository,
|
|
@@ -52,12 +52,7 @@ import {
|
|
|
52
52
|
import { ItemsRepository } from "./itemsRepository";
|
|
53
53
|
import { MediaSelectorMode } from "../media-selector/MediaSelector";
|
|
54
54
|
import { ComponentCommand } from "../commands/componentCommands";
|
|
55
|
-
|
|
56
|
-
import {
|
|
57
|
-
Wizard,
|
|
58
|
-
WizardData,
|
|
59
|
-
WizardPageModel,
|
|
60
|
-
} from "../../page-wizard/PageWizard";
|
|
55
|
+
|
|
61
56
|
export type DragObject = {
|
|
62
57
|
type: "template" | "component" | "link-component" | "items";
|
|
63
58
|
typeId: string;
|
|
@@ -140,6 +135,7 @@ export type EditContextType = {
|
|
|
140
135
|
};
|
|
141
136
|
comments: Comment[];
|
|
142
137
|
suggestedEdits: SuggestedEdit[];
|
|
138
|
+
setSuggestedEdits: React.Dispatch<React.SetStateAction<SuggestedEdit[]>>;
|
|
143
139
|
loadComments: () => void;
|
|
144
140
|
setComments: React.Dispatch<React.SetStateAction<Comment[]>>;
|
|
145
141
|
selectedComment: Comment | undefined;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
SuggestedEdit,
|
|
10
10
|
User,
|
|
11
11
|
} from "../../types";
|
|
12
|
+
|
|
12
13
|
import {
|
|
13
14
|
FieldDescriptor,
|
|
14
15
|
FullItem,
|
|
@@ -45,6 +46,23 @@ import { EditorMode } from "./editContext";
|
|
|
45
46
|
// Track pending suggested edit save requests to prevent race conditions
|
|
46
47
|
const pendingSuggestedEditSaves = new Map<string, Promise<any>>();
|
|
47
48
|
|
|
49
|
+
// Track pending suggested edits to prevent creating duplicates before server response
|
|
50
|
+
const pendingSuggestedEdits = new Map<string, SuggestedEdit>();
|
|
51
|
+
|
|
52
|
+
// Helper function to clean up pending operations for a deleted suggested edit
|
|
53
|
+
export function cleanupPendingSuggestedEditOperations(
|
|
54
|
+
suggestedEdit: SuggestedEdit,
|
|
55
|
+
user?: { name: string },
|
|
56
|
+
) {
|
|
57
|
+
const userKey = user?.name || "unknown";
|
|
58
|
+
const pendingFieldKey = `${suggestedEdit.itemId}:${suggestedEdit.mainItemLanguage}:${suggestedEdit.mainItemVersion}:${suggestedEdit.fieldId}:${userKey}`;
|
|
59
|
+
const saveKey = `${suggestedEdit.itemId}:${suggestedEdit.mainItemLanguage}:${suggestedEdit.mainItemVersion}:${suggestedEdit.fieldId}`;
|
|
60
|
+
|
|
61
|
+
// Remove from pending operations
|
|
62
|
+
pendingSuggestedEdits.delete(pendingFieldKey);
|
|
63
|
+
pendingSuggestedEditSaves.delete(saveKey);
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
export function getOperationsContext(
|
|
49
67
|
state: {
|
|
50
68
|
page?: Page;
|
|
@@ -91,6 +109,12 @@ export function getOperationsContext(
|
|
|
91
109
|
// Extract values to avoid state object reference issues
|
|
92
110
|
const itemsRepository = state.itemsRepository;
|
|
93
111
|
|
|
112
|
+
// Debounced version of createOrUpdateSuggestedEdit to prevent excessive API calls
|
|
113
|
+
const debouncedCreateOrUpdateSuggestedEdit = useDebouncedCallback(
|
|
114
|
+
createOrUpdateSuggestedEdit,
|
|
115
|
+
500, // 500ms delay
|
|
116
|
+
);
|
|
117
|
+
|
|
94
118
|
const executeOp = useCallback(
|
|
95
119
|
async (
|
|
96
120
|
op: EditOperation,
|
|
@@ -187,7 +211,7 @@ export function getOperationsContext(
|
|
|
187
211
|
if (executedOp.focus) {
|
|
188
212
|
state.setSelection([executedOp.focus]);
|
|
189
213
|
state.setFocusFieldComponentId(executedOp.focus);
|
|
190
|
-
|
|
214
|
+
|
|
191
215
|
// Clear insert mode immediately after selecting the newly added component
|
|
192
216
|
if (executedOp.type === "add-component") {
|
|
193
217
|
state.setInsertMode(false);
|
|
@@ -336,7 +360,7 @@ export function getOperationsContext(
|
|
|
336
360
|
|
|
337
361
|
if (op) {
|
|
338
362
|
// Create and track the save promise
|
|
339
|
-
const savePromise =
|
|
363
|
+
const savePromise = debouncedCreateOrUpdateSuggestedEdit(op)!
|
|
340
364
|
.then((result) => {
|
|
341
365
|
// Update the suggested edits state with the result from the server
|
|
342
366
|
if (result.type === "success" && result.data) {
|
|
@@ -351,6 +375,9 @@ export function getOperationsContext(
|
|
|
351
375
|
.finally(() => {
|
|
352
376
|
// Remove from pending saves when complete
|
|
353
377
|
pendingSuggestedEditSaves.delete(fieldKey);
|
|
378
|
+
// Also remove from pending edits since it's now persisted
|
|
379
|
+
const pendingFieldKey = `${field.item.id}:${field.item.language}:${field.item.version}:${field.fieldId}:${state.user?.name || "unknown"}`;
|
|
380
|
+
pendingSuggestedEdits.delete(pendingFieldKey);
|
|
354
381
|
});
|
|
355
382
|
|
|
356
383
|
pendingSuggestedEditSaves.set(fieldKey, savePromise);
|
|
@@ -807,6 +834,19 @@ async function getOrMergeSuggestedEditOp(
|
|
|
807
834
|
}
|
|
808
835
|
const page = state.page;
|
|
809
836
|
|
|
837
|
+
// Create a unique key for this field to check for pending edits
|
|
838
|
+
const fieldKey = `${field.item.id}:${field.item.language}:${field.item.version}:${field.fieldId}:${state.user?.name || "unknown"}`;
|
|
839
|
+
|
|
840
|
+
// First check if there's a pending suggested edit for this field
|
|
841
|
+
const pendingEdit = pendingSuggestedEdits.get(fieldKey);
|
|
842
|
+
if (pendingEdit) {
|
|
843
|
+
// Update the pending edit instead of creating a new one
|
|
844
|
+
pendingEdit.newValue = newVal || "";
|
|
845
|
+
pendingEdit.updated = new Date().toISOString();
|
|
846
|
+
pendingEdit.updatedBy = state.user?.name || "unknown";
|
|
847
|
+
return pendingEdit;
|
|
848
|
+
}
|
|
849
|
+
|
|
810
850
|
// Attempt to find an existing suggested edit by the same user for this field.
|
|
811
851
|
const existing = state.suggestedEdits.find(
|
|
812
852
|
(edit) =>
|
|
@@ -819,6 +859,7 @@ async function getOrMergeSuggestedEditOp(
|
|
|
819
859
|
edit.status === "pending", // or any status that indicates it's open for further editing.
|
|
820
860
|
);
|
|
821
861
|
|
|
862
|
+
|
|
822
863
|
if (existing) {
|
|
823
864
|
// Update the existing suggestion.
|
|
824
865
|
existing.newValue = newVal || "";
|
|
@@ -846,6 +887,9 @@ async function getOrMergeSuggestedEditOp(
|
|
|
846
887
|
type: "FieldValue",
|
|
847
888
|
};
|
|
848
889
|
|
|
890
|
+
// Add to pending edits to prevent race conditions
|
|
891
|
+
pendingSuggestedEdits.set(fieldKey, newEdit);
|
|
892
|
+
|
|
849
893
|
state.setSuggestedEdits([...state.suggestedEdits, newEdit]);
|
|
850
894
|
return newEdit;
|
|
851
895
|
}
|
|
@@ -95,6 +95,7 @@ function getInsertCommand(
|
|
|
95
95
|
const item = components[0];
|
|
96
96
|
if (!item) return null;
|
|
97
97
|
if (!item.placeholders || item.placeholders.length === 0) return null;
|
|
98
|
+
if (editContext.mode === "suggestions") return null;
|
|
98
99
|
return {
|
|
99
100
|
id: "insert",
|
|
100
101
|
icon: <Plus size={defaultIconSize} />,
|
|
@@ -112,6 +113,7 @@ function getDuplicateCommand(
|
|
|
112
113
|
editContext: EditContextType,
|
|
113
114
|
): ComponentCommand | null {
|
|
114
115
|
if (components.length !== 1) return null;
|
|
116
|
+
if (editContext.mode === "suggestions") return null;
|
|
115
117
|
|
|
116
118
|
return {
|
|
117
119
|
id: "duplicate",
|
|
@@ -295,6 +297,8 @@ function getDeleteCommand(
|
|
|
295
297
|
components: Component[],
|
|
296
298
|
editContext: EditContextType,
|
|
297
299
|
): ComponentCommand | null {
|
|
300
|
+
if (editContext.mode === "suggestions") return null;
|
|
301
|
+
|
|
298
302
|
const applicableComponents = components.filter(
|
|
299
303
|
(c) =>
|
|
300
304
|
((!isPlaceholder(c) && !c.layoutId) ||
|
|
@@ -16,7 +16,10 @@ export function Thumbnails({
|
|
|
16
16
|
onHide: () => void;
|
|
17
17
|
}) {
|
|
18
18
|
return (
|
|
19
|
-
<div
|
|
19
|
+
<div
|
|
20
|
+
data-testid="media-selector-thumbnails-container"
|
|
21
|
+
className="absolute inset-0 flex flex-col overflow-auto p-2"
|
|
22
|
+
>
|
|
20
23
|
<div className="flex flex-wrap content-start items-start gap-4">
|
|
21
24
|
{images?.map((t) => (
|
|
22
25
|
<div
|
|
@@ -612,7 +612,7 @@ export function InlineEditor({
|
|
|
612
612
|
// Otherwise, gather all suggestions for this field.
|
|
613
613
|
const fieldSuggestions = context.suggestedEdits.filter(
|
|
614
614
|
(s: any) =>
|
|
615
|
-
s.status === "
|
|
615
|
+
s.status === "pending" &&
|
|
616
616
|
s.fieldId === fieldId &&
|
|
617
617
|
s.itemId === itemId &&
|
|
618
618
|
s.mainItemLanguage === language &&
|
|
@@ -699,7 +699,7 @@ export function InlineEditor({
|
|
|
699
699
|
]);
|
|
700
700
|
|
|
701
701
|
useEffect(() => {
|
|
702
|
-
if (!context || compareView
|
|
702
|
+
if (!context || compareView) return;
|
|
703
703
|
|
|
704
704
|
const iframeDoc = pageViewContext.editorIframe?.contentWindow?.document;
|
|
705
705
|
if (!iframeDoc) return;
|
|
@@ -712,11 +712,35 @@ export function InlineEditor({
|
|
|
712
712
|
);
|
|
713
713
|
|
|
714
714
|
allFieldEls.forEach(async (el) => {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
715
|
+
// don't stomp on the one that's live‑editing (only in non-preview modes)
|
|
716
|
+
if (
|
|
717
|
+
context.mode !== "preview" &&
|
|
718
|
+
el === context.inlineEditingFieldElement
|
|
719
|
+
)
|
|
720
|
+
return;
|
|
718
721
|
|
|
719
722
|
const fieldId = el.getAttribute("data-fieldid")!;
|
|
723
|
+
const itemId = el.getAttribute("data-itemid")!;
|
|
724
|
+
const language = el.getAttribute("data-language")!;
|
|
725
|
+
const version = parseInt(el.getAttribute("data-version")!, 10);
|
|
726
|
+
|
|
727
|
+
// In suggestions mode (but not preview), only reset fields that don't have any suggested edits
|
|
728
|
+
if (
|
|
729
|
+
context.mode === "suggestions" ||
|
|
730
|
+
(context.mode !== "preview" && context.showSuggestedEdits)
|
|
731
|
+
) {
|
|
732
|
+
const hasActiveSuggestions = context.suggestedEdits.some(
|
|
733
|
+
(edit) =>
|
|
734
|
+
edit.status === "pending" &&
|
|
735
|
+
edit.fieldId === fieldId &&
|
|
736
|
+
edit.itemId === itemId &&
|
|
737
|
+
edit.mainItemLanguage === language &&
|
|
738
|
+
edit.mainItemVersion === version,
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
// If field has active suggestions, don't reset it (let the other hook handle it)
|
|
742
|
+
if (hasActiveSuggestions) return;
|
|
743
|
+
}
|
|
720
744
|
|
|
721
745
|
const realField = await context.itemsRepository.getItem({
|
|
722
746
|
id: fieldId,
|
|
@@ -729,10 +753,6 @@ export function InlineEditor({
|
|
|
729
753
|
return;
|
|
730
754
|
}
|
|
731
755
|
|
|
732
|
-
const itemId = el.getAttribute("data-itemid")!;
|
|
733
|
-
const language = el.getAttribute("data-language")!;
|
|
734
|
-
const version = parseInt(el.getAttribute("data-version")!, 10);
|
|
735
|
-
|
|
736
756
|
// lookup baseline
|
|
737
757
|
const loaded = await context.itemsRepository.getItem({
|
|
738
758
|
id: itemId,
|
|
@@ -742,21 +762,23 @@ export function InlineEditor({
|
|
|
742
762
|
const repoF = loaded?.fields.find((f) => f.id === fieldId);
|
|
743
763
|
let value = repoF?.rawValue ?? "";
|
|
744
764
|
|
|
745
|
-
// override with any local edit
|
|
746
|
-
|
|
747
|
-
(
|
|
748
|
-
m
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
765
|
+
// override with any local edit (only in non-preview modes)
|
|
766
|
+
if (context.mode !== "preview") {
|
|
767
|
+
const mod = modifiedFieldsContext?.modifiedFields.find(
|
|
768
|
+
(m) =>
|
|
769
|
+
m.fieldId === fieldId &&
|
|
770
|
+
m.item.id === itemId &&
|
|
771
|
+
m.item.language === language &&
|
|
772
|
+
m.item.version === version,
|
|
773
|
+
);
|
|
753
774
|
|
|
754
|
-
|
|
775
|
+
if (mod) value = mod.value as string;
|
|
776
|
+
}
|
|
755
777
|
|
|
756
778
|
// write it in
|
|
757
779
|
el.innerHTML = value;
|
|
758
780
|
});
|
|
759
|
-
}, [context.mode, context.showSuggestedEdits]);
|
|
781
|
+
}, [context.mode, context.showSuggestedEdits, context.suggestedEdits]);
|
|
760
782
|
|
|
761
783
|
return null;
|
|
762
784
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
deleteSuggestedEdit,
|
|
6
6
|
createOrUpdateSuggestedEdit,
|
|
7
7
|
} from "../services/suggestedEditsService";
|
|
8
|
+
import { cleanupPendingSuggestedEditOperations } from "../client/operations";
|
|
8
9
|
import { Button } from "../../components/ui/button";
|
|
9
10
|
import { formatDate } from "../utils";
|
|
10
11
|
import { SimpleIconButton } from "../ui/SimpleIconButton";
|
|
@@ -171,8 +172,28 @@ export function SuggestedEditComponent({ edit }: { edit: SuggestedEditType }) {
|
|
|
171
172
|
<Button
|
|
172
173
|
variant="outline"
|
|
173
174
|
onClick={async () => {
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
try {
|
|
176
|
+
await deleteSuggestedEdit(edit);
|
|
177
|
+
// Clean up pending operations to prevent race conditions
|
|
178
|
+
cleanupPendingSuggestedEditOperations(
|
|
179
|
+
edit,
|
|
180
|
+
editContext?.user,
|
|
181
|
+
);
|
|
182
|
+
// Immediately update local state to reflect deletion
|
|
183
|
+
if (
|
|
184
|
+
editContext?.setSuggestedEdits &&
|
|
185
|
+
editContext?.suggestedEdits
|
|
186
|
+
) {
|
|
187
|
+
const updatedEdits = editContext.suggestedEdits.filter(
|
|
188
|
+
(e) => e.id !== edit.id,
|
|
189
|
+
);
|
|
190
|
+
editContext.setSuggestedEdits(updatedEdits);
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error("Failed to delete suggested edit:", error);
|
|
194
|
+
} finally {
|
|
195
|
+
setDeletePopoverOpen(false);
|
|
196
|
+
}
|
|
176
197
|
}}
|
|
177
198
|
>
|
|
178
199
|
Delete
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
deleteSuggestedEdit,
|
|
16
16
|
createOrUpdateSuggestedEdit,
|
|
17
17
|
} from "../services/suggestedEditsService";
|
|
18
|
+
import { cleanupPendingSuggestedEditOperations } from "../client/operations";
|
|
18
19
|
import {
|
|
19
20
|
Check,
|
|
20
21
|
Trash2,
|
|
@@ -108,9 +109,23 @@ export function SuggestionDisplayPopover({
|
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
try {
|
|
113
|
+
await deleteSuggestedEdit(suggestion);
|
|
114
|
+
// Clean up pending operations to prevent race conditions
|
|
115
|
+
cleanupPendingSuggestedEditOperations(suggestion, editContext?.user);
|
|
116
|
+
// Immediately update local state to reflect deletion
|
|
117
|
+
if (editContext?.setSuggestedEdits && editContext?.suggestedEdits) {
|
|
118
|
+
const updatedEdits = editContext.suggestedEdits.filter(
|
|
119
|
+
(e) => e.id !== suggestion.id,
|
|
120
|
+
);
|
|
121
|
+
editContext.setSuggestedEdits(updatedEdits);
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error("Failed to delete suggested edit:", error);
|
|
125
|
+
} finally {
|
|
126
|
+
setIsOpen(false);
|
|
127
|
+
onSuggestionUpdated?.();
|
|
128
|
+
}
|
|
114
129
|
};
|
|
115
130
|
|
|
116
131
|
const handleApplyPatch = async () => {
|