@yogiswara/honcho-editor-ui 2.5.9 → 2.6.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.
Files changed (50) hide show
  1. package/dist/components/editor/HBulkPreset.js +12 -2
  2. package/dist/hooks/demo/HonchoEditorBulkDemo.d.ts +3 -0
  3. package/dist/hooks/demo/HonchoEditorBulkDemo.js +228 -0
  4. package/dist/hooks/demo/HonchoEditorSingleCleanDemo.d.ts +3 -0
  5. package/dist/hooks/demo/HonchoEditorSingleCleanDemo.js +354 -0
  6. package/dist/hooks/demo/index.d.ts +2 -0
  7. package/dist/hooks/demo/index.js +2 -0
  8. package/dist/hooks/editor/type.d.ts +71 -0
  9. package/dist/hooks/editor/useHonchoEditorBulk.d.ts +12 -12
  10. package/dist/hooks/editor/useHonchoEditorBulk.js +155 -42
  11. package/dist/hooks/editor/useHonchoEditorSingle.d.ts +43 -0
  12. package/dist/hooks/editor/useHonchoEditorSingle.js +158 -0
  13. package/dist/hooks/useAdjustmentHistory.d.ts +9 -5
  14. package/dist/hooks/useAdjustmentHistory.js +187 -31
  15. package/dist/hooks/useAdjustmentHistoryBatch.d.ts +18 -1
  16. package/dist/hooks/useAdjustmentHistoryBatch.js +627 -201
  17. package/dist/hooks/useGallerySwipe.d.ts +1 -1
  18. package/dist/hooks/usePaging.d.ts +89 -0
  19. package/dist/hooks/usePaging.js +211 -0
  20. package/dist/hooks/usePreset.d.ts +1 -1
  21. package/dist/hooks/usePreset.js +35 -35
  22. package/dist/index.d.ts +4 -3
  23. package/dist/index.js +3 -1
  24. package/dist/lib/context/EditorContext.d.ts +10 -0
  25. package/dist/lib/context/EditorContext.js +4 -2
  26. package/dist/lib/hooks/useEditorHeadless.d.ts +18 -2
  27. package/dist/lib/hooks/useEditorHeadless.js +142 -63
  28. package/dist/utils/adjustment.d.ts +2 -1
  29. package/dist/utils/adjustment.js +16 -0
  30. package/dist/utils/imageLoader.d.ts +11 -0
  31. package/dist/utils/imageLoader.js +53 -0
  32. package/package.json +1 -1
  33. package/dist/components/editor/GalleryAlbum/SimplifiedAlbumGallery.d.ts +0 -17
  34. package/dist/components/editor/GalleryAlbum/SimplifiedAlbumGallery.js +0 -14
  35. package/dist/components/editor/GalleryAlbum/SimplifiedImageItem.d.ts +0 -8
  36. package/dist/components/editor/GalleryAlbum/SimplifiedImageItem.js +0 -30
  37. package/dist/components/editor/HImageEditorPage.d.ts +0 -1
  38. package/dist/components/editor/HImageEditorPage.js +0 -187
  39. package/dist/hooks/__tests__/useGallerySwipe.test.d.ts +0 -0
  40. package/dist/hooks/__tests__/useGallerySwipe.test.js +0 -619
  41. package/dist/hooks/editor/useHonchoEditor.d.ts +0 -203
  42. package/dist/hooks/editor/useHonchoEditor.js +0 -716
  43. package/dist/hooks/useAdjustmentHistory.demo.d.ts +0 -8
  44. package/dist/hooks/useAdjustmentHistory.demo.js +0 -106
  45. package/dist/hooks/useAdjustmentHistory.example.d.ts +0 -38
  46. package/dist/hooks/useAdjustmentHistory.example.js +0 -182
  47. package/dist/hooks/useAdjustmentHistory.syncDemo.d.ts +0 -8
  48. package/dist/hooks/useAdjustmentHistory.syncDemo.js +0 -180
  49. package/dist/hooks/useGallerySwipe.example.d.ts +0 -24
  50. package/dist/hooks/useGallerySwipe.example.js +0 -184
@@ -1,4 +1,5 @@
1
1
  import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
2
+ import { mapAdjustmentStateToColorAdjustment, mapColorAdjustmentToAdjustmentState } from '../utils/adjustment';
2
3
  /**
3
4
  * Create default adjustment state
4
5
  */
@@ -37,6 +38,23 @@ const createEmptyBatchState = () => ({
37
38
  allImages: {},
38
39
  initialStates: {}
39
40
  });
41
+ /**
42
+ * Generate unique ID for history entries using UUID format
43
+ */
44
+ const generateEntryId = () => {
45
+ // Simple UUID v4 implementation
46
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
47
+ const r = Math.random() * 16 | 0;
48
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
49
+ return v.toString(16);
50
+ });
51
+ };
52
+ /**
53
+ * Generate unique task ID for backend operations (same as entry ID)
54
+ */
55
+ const generateTaskId = () => {
56
+ return generateEntryId();
57
+ };
40
58
  /**
41
59
  * Advanced hook for managing batch AdjustmentState history with selective undo/redo functionality.
42
60
  *
@@ -85,192 +103,429 @@ export function useAdjustmentHistoryBatch(options = {}) {
85
103
  const internalOptions = useMemo(() => ({
86
104
  maxSize: options.maxSize ?? 'unlimited',
87
105
  devWarnings: options.devWarnings ?? false,
88
- defaultAdjustmentState: options.defaultAdjustmentState ?? {}
106
+ defaultAdjustmentState: options.defaultAdjustmentState ?? {},
107
+ controller: options.controller,
108
+ firebaseUid: options.firebaseUid,
109
+ eventId: options.eventId
89
110
  }), [
90
111
  options.maxSize,
91
112
  options.devWarnings,
92
- options.defaultAdjustmentState
113
+ options.defaultAdjustmentState,
114
+ options.controller,
115
+ options.firebaseUid,
116
+ options.eventId
93
117
  ]);
94
- // Core state management - start empty for plain mode
118
+ // Core state management - using per-image history instead of batch history
95
119
  const [allImageIds, setAllImageIds] = useState([]);
96
120
  const [selectedIds, setSelectedIds] = useState([]);
97
- const [history, setHistory] = useState([createEmptyBatchState()]);
98
- const [currentIndex, setCurrentIndex] = useState(0);
121
+ const [imageHistories, setImageHistories] = useState([]);
99
122
  const [currentBatch, setCurrentBatch] = useState(createEmptyBatchState());
100
123
  // Configuration refs
101
124
  const maxSizeRef = useRef(internalOptions.maxSize);
102
125
  const devWarningsRef = useRef(internalOptions.devWarnings);
103
- // Sync currentBatch with history
126
+ // Helper function to rebuild currentBatch from imageHistories
127
+ const rebuildCurrentBatch = useCallback(() => {
128
+ const newBatch = createEmptyBatchState();
129
+ imageHistories.forEach(imageHistory => {
130
+ // Find current adjustment using currentHistoryEntryId
131
+ const currentEntry = imageHistory.history.find(entry => entry.id === imageHistory.currentHistoryEntryId);
132
+ if (currentEntry) {
133
+ newBatch.allImages[imageHistory.imageId] = currentEntry.adjustment;
134
+ if (selectedIds.includes(imageHistory.imageId)) {
135
+ newBatch.currentSelection[imageHistory.imageId] = currentEntry.adjustment;
136
+ }
137
+ }
138
+ });
139
+ return newBatch;
140
+ }, [imageHistories, selectedIds]);
141
+ // Sync currentBatch with imageHistories
104
142
  useEffect(() => {
105
- setCurrentBatch(history[currentIndex]);
106
- }, [history, currentIndex]);
143
+ setCurrentBatch(rebuildCurrentBatch());
144
+ }, [rebuildCurrentBatch]);
107
145
  // Memory usage calculation
108
146
  const getMemoryUsage = useCallback(() => {
109
147
  try {
110
- const historyString = JSON.stringify(history);
111
- return historyString.length * 2; // Rough estimate: 2 bytes per character
148
+ const historiesString = JSON.stringify(imageHistories);
149
+ return historiesString.length * 2; // Rough estimate: 2 bytes per character
112
150
  }
113
151
  catch (error) {
114
152
  console.warn('Failed to estimate memory usage:', error);
115
- return history.length * allImageIds.length * 1000; // Fallback estimate
153
+ return imageHistories.length * 100 * 1000; // Fallback estimate
116
154
  }
117
- }, [history, allImageIds.length]);
118
- // Trim history to specified size
119
- const trimHistoryToSize = useCallback((size) => {
155
+ }, [imageHistories]);
156
+ // Trim individual image histories to specified size
157
+ const trimImageHistoriesToSize = useCallback((size) => {
120
158
  if (size <= 0)
121
159
  return;
122
- setHistory(prevHistory => {
123
- if (prevHistory.length <= size)
124
- return prevHistory;
125
- const startIndex = Math.max(0, prevHistory.length - size);
126
- const trimmedHistory = prevHistory.slice(startIndex);
127
- // Adjust current index
128
- setCurrentIndex(prevIndex => {
129
- const adjustedIndex = prevIndex - startIndex;
130
- return Math.max(0, Math.min(adjustedIndex, trimmedHistory.length - 1));
131
- });
132
- return trimmedHistory;
133
- });
160
+ setImageHistories(prevHistories => prevHistories.map(imageHistory => ({
161
+ ...imageHistory,
162
+ history: imageHistory.history.length <= size
163
+ ? imageHistory.history
164
+ : imageHistory.history.slice(-size) // Keep last 'size' entries
165
+ })));
134
166
  }, []);
135
167
  // Apply max size limit
136
168
  const enforceMaxSize = useCallback(() => {
137
169
  if (maxSizeRef.current === 'unlimited')
138
170
  return;
139
171
  const maxSize = maxSizeRef.current;
140
- if (history.length > maxSize) {
141
- trimHistoryToSize(maxSize);
172
+ if (typeof maxSize === 'number' && imageHistories.length > 0) {
173
+ const totalHistorySize = imageHistories.reduce((sum, h) => sum + h.history.length, 0);
174
+ if (totalHistorySize > maxSize * imageHistories.length) {
175
+ trimImageHistoriesToSize(maxSize);
176
+ }
142
177
  }
143
- }, [history.length, trimHistoryToSize]);
144
- // Push new batch state to history
145
- const pushBatchState = useCallback((newBatch) => {
146
- // Skip if batch hasn't changed
147
- if (compareBatchStates(newBatch, currentBatch)) {
178
+ }, [imageHistories, trimImageHistoriesToSize]);
179
+ // Apply adjustment deltas to selected images - with entry-based history and backend sync
180
+ const adjustSelected = useCallback(async (delta) => {
181
+ if (selectedIds.length === 0) {
182
+ if (devWarningsRef.current) {
183
+ console.warn('[useAdjustmentHistoryBatch] adjustSelected called with no selection');
184
+ }
148
185
  return;
149
186
  }
150
- // Update currentBatch immediately for smooth UI
151
- setCurrentBatch(newBatch);
152
- // Update history
153
- setHistory(prevHistory => {
154
- const truncatedHistory = prevHistory.slice(0, currentIndex + 1);
155
- const newHistory = [...truncatedHistory, newBatch];
156
- setCurrentIndex(newHistory.length - 1);
157
- return newHistory;
187
+ // Store backend operations to perform after state update
188
+ const backendOperations = [];
189
+ setImageHistories(prevHistories => {
190
+ return prevHistories.map(imageHistory => {
191
+ if (!selectedIds.includes(imageHistory.imageId)) {
192
+ return imageHistory; // No change for unselected images
193
+ }
194
+ // Get current adjustment from current entry
195
+ const currentEntry = imageHistory.history.find(entry => entry.id === imageHistory.currentHistoryEntryId);
196
+ const currentAdjustment = currentEntry?.adjustment || createDefaultAdjustmentState(internalOptions.defaultAdjustmentState);
197
+ // Apply deltas with clamping
198
+ const newAdjustment = { ...currentAdjustment };
199
+ Object.keys(delta).forEach(key => {
200
+ const deltaValue = delta[key];
201
+ if (typeof deltaValue === 'number') {
202
+ const currentValue = newAdjustment[key];
203
+ newAdjustment[key] = Math.max(-100, Math.min(100, currentValue + deltaValue));
204
+ }
205
+ });
206
+ // Check if user is in the middle of history (not at latest state)
207
+ const currentEntryIndex = imageHistory.history.findIndex(entry => entry.id === imageHistory.currentHistoryEntryId);
208
+ const isInMiddleOfHistory = currentEntryIndex < imageHistory.history.length - 1;
209
+ let replaceFromTaskId;
210
+ if (isInMiddleOfHistory) {
211
+ // If user is in middle of history, get the task ID of current position
212
+ replaceFromTaskId = currentEntry?.id;
213
+ }
214
+ // Generate new task ID for backend (same as entry ID)
215
+ const taskId = generateTaskId();
216
+ // Create new entry with task ID
217
+ const newEntryId = taskId; // Use the same ID for both entry and task
218
+ const newEntry = {
219
+ id: newEntryId,
220
+ adjustment: newAdjustment
221
+ };
222
+ // Prepare backend operation
223
+ if (internalOptions.controller && internalOptions.firebaseUid) {
224
+ backendOperations.push({
225
+ imageId: imageHistory.imageId,
226
+ taskId,
227
+ adjustment: newAdjustment,
228
+ replaceFromTaskId
229
+ });
230
+ }
231
+ // Build new history
232
+ let newHistory;
233
+ if (isInMiddleOfHistory) {
234
+ // If in middle of history, truncate from current position and add new entry
235
+ newHistory = [...imageHistory.history.slice(0, currentEntryIndex + 1), newEntry];
236
+ }
237
+ else {
238
+ // If at end of history, just add new entry
239
+ newHistory = [...imageHistory.history, newEntry];
240
+ }
241
+ // Trim if needed
242
+ const maxSize = maxSizeRef.current;
243
+ const trimmedHistory = typeof maxSize === 'number' && newHistory.length > maxSize
244
+ ? newHistory.slice(-maxSize)
245
+ : newHistory;
246
+ return {
247
+ ...imageHistory,
248
+ history: trimmedHistory,
249
+ currentHistoryEntryId: newEntryId // Update current pointer
250
+ };
251
+ });
158
252
  });
159
- }, [currentBatch, currentIndex]);
160
- // Apply adjustment deltas to selected images
161
- const adjustSelected = useCallback((delta) => {
162
- if (selectedIds.length === 0)
253
+ // Perform backend operations asynchronously
254
+ if (backendOperations.length > 0 && internalOptions.controller && internalOptions.firebaseUid) {
255
+ try {
256
+ const promises = backendOperations.map(async (operation) => {
257
+ await internalOptions.controller.createEditorConfig(internalOptions.firebaseUid, {
258
+ gallery_id: operation.imageId,
259
+ task_id: operation.taskId,
260
+ color_adjustment: mapAdjustmentStateToColorAdjustment(operation.adjustment),
261
+ replace_from: operation.replaceFromTaskId
262
+ });
263
+ });
264
+ await Promise.all(promises);
265
+ if (devWarningsRef.current) {
266
+ console.log(`[useAdjustmentHistoryBatch] Synced ${backendOperations.length} adjustments to backend`);
267
+ }
268
+ }
269
+ catch (error) {
270
+ console.error('[useAdjustmentHistoryBatch] Failed to sync adjustments to backend:', error);
271
+ }
272
+ }
273
+ }, [selectedIds, internalOptions]);
274
+ // Apply preset values directly to selected images - preserves history for each image individually
275
+ const adjustSelectedWithPreset = useCallback(async (presetAdjustments) => {
276
+ if (selectedIds.length === 0) {
277
+ if (devWarningsRef.current) {
278
+ console.warn('[useAdjustmentHistoryBatch] adjustSelectedWithPreset called with no selection');
279
+ }
163
280
  return;
164
- const newBatch = {
165
- currentSelection: { ...currentBatch.currentSelection },
166
- allImages: { ...currentBatch.allImages },
167
- initialStates: { ...currentBatch.initialStates }
168
- };
169
- // Apply adjustments to selected images in both currentSelection and allImages
170
- for (const imageId of selectedIds) {
171
- if (newBatch.currentSelection[imageId]) {
172
- // Update current selection
173
- newBatch.currentSelection[imageId] = {
174
- ...newBatch.currentSelection[imageId],
175
- ...Object.fromEntries(Object.entries(delta).map(([key, value]) => [
176
- key,
177
- newBatch.currentSelection[imageId][key] + value
178
- ]))
281
+ }
282
+ // Store backend operations to perform after state update
283
+ const backendOperations = [];
284
+ setImageHistories(prevHistories => {
285
+ return prevHistories.map(imageHistory => {
286
+ if (!selectedIds.includes(imageHistory.imageId)) {
287
+ return imageHistory; // No change for unselected images
288
+ }
289
+ // Get current adjustment from current entry
290
+ const currentEntry = imageHistory.history.find(entry => entry.id === imageHistory.currentHistoryEntryId);
291
+ const currentAdjustment = currentEntry?.adjustment || createDefaultAdjustmentState(internalOptions.defaultAdjustmentState);
292
+ // Apply preset values with clamping (same as adjustSelected logic)
293
+ const newAdjustment = { ...presetAdjustments };
294
+ Object.keys(newAdjustment).forEach(key => {
295
+ const presetValue = newAdjustment[key];
296
+ newAdjustment[key] = Math.max(-100, Math.min(100, presetValue));
297
+ });
298
+ // Check if user is in the middle of history (not at latest state)
299
+ const currentEntryIndex = imageHistory.history.findIndex(entry => entry.id === imageHistory.currentHistoryEntryId);
300
+ const isInMiddleOfHistory = currentEntryIndex < imageHistory.history.length - 1;
301
+ let replaceFromTaskId;
302
+ if (isInMiddleOfHistory) {
303
+ // If user is in middle of history, get the task ID of current position
304
+ replaceFromTaskId = currentEntry?.id;
305
+ }
306
+ // Generate new task ID for backend (same as entry ID)
307
+ const taskId = generateTaskId();
308
+ // Create new entry with task ID
309
+ const newEntryId = taskId; // Use the same ID for both entry and task
310
+ const newEntry = {
311
+ id: newEntryId,
312
+ adjustment: newAdjustment
179
313
  };
180
- // Also update in allImages to persist the changes
181
- newBatch.allImages[imageId] = { ...newBatch.currentSelection[imageId] };
314
+ // Prepare backend operation
315
+ if (internalOptions.controller && internalOptions.firebaseUid) {
316
+ backendOperations.push({
317
+ imageId: imageHistory.imageId,
318
+ taskId,
319
+ adjustment: newAdjustment,
320
+ replaceFromTaskId
321
+ });
322
+ }
323
+ // Build new history
324
+ let newHistory;
325
+ if (isInMiddleOfHistory) {
326
+ // If in middle of history, truncate from current position and add new entry
327
+ newHistory = [...imageHistory.history.slice(0, currentEntryIndex + 1), newEntry];
328
+ }
329
+ else {
330
+ // If at end of history, just add new entry
331
+ newHistory = [...imageHistory.history, newEntry];
332
+ }
333
+ // Trim if needed
334
+ const maxSize = maxSizeRef.current;
335
+ const trimmedHistory = typeof maxSize === 'number' && newHistory.length > maxSize
336
+ ? newHistory.slice(-maxSize)
337
+ : newHistory;
338
+ return {
339
+ ...imageHistory,
340
+ history: trimmedHistory,
341
+ currentHistoryEntryId: newEntryId // Update current pointer
342
+ };
343
+ });
344
+ });
345
+ // Perform backend operations asynchronously
346
+ if (backendOperations.length > 0 && internalOptions.controller && internalOptions.firebaseUid) {
347
+ try {
348
+ const promises = backendOperations.map(async (operation) => {
349
+ await internalOptions.controller.createEditorConfig(internalOptions.firebaseUid, {
350
+ gallery_id: operation.imageId,
351
+ task_id: operation.taskId,
352
+ color_adjustment: mapAdjustmentStateToColorAdjustment(operation.adjustment),
353
+ replace_from: operation.replaceFromTaskId
354
+ });
355
+ });
356
+ await Promise.all(promises);
357
+ if (devWarningsRef.current) {
358
+ console.log(`[useAdjustmentHistoryBatch] Synced ${backendOperations.length} preset adjustments to backend`);
359
+ }
360
+ }
361
+ catch (error) {
362
+ console.error('[useAdjustmentHistoryBatch] Failed to sync preset adjustments to backend:', error);
182
363
  }
183
364
  }
184
- pushBatchState(newBatch);
185
- }, [selectedIds, currentBatch, pushBatchState]);
365
+ }, [selectedIds, internalOptions]);
186
366
  // Set specific adjustment states for specified images (removed since not needed)
187
- // Undo last changes to selected images
188
- const undo = useCallback(() => {
189
- if (currentIndex > 0 && selectedIds.length > 0) {
190
- const previousBatch = history[currentIndex - 1];
191
- const newBatch = {
192
- currentSelection: { ...currentBatch.currentSelection },
193
- allImages: { ...currentBatch.allImages },
194
- initialStates: { ...currentBatch.initialStates }
195
- };
196
- // Only restore adjustments for currently selected images
197
- for (const imageId of selectedIds) {
198
- if (previousBatch.allImages[imageId] && newBatch.currentSelection[imageId]) {
199
- // Restore from previous allImages state (not currentSelection)
200
- newBatch.currentSelection[imageId] = { ...previousBatch.allImages[imageId] };
201
- newBatch.allImages[imageId] = { ...previousBatch.allImages[imageId] };
202
- }
367
+ // Undo last changes to selected images - entry-based history version with backend sync
368
+ const undo = useCallback(async () => {
369
+ if (selectedIds.length === 0) {
370
+ if (devWarningsRef.current) {
371
+ console.warn('[useAdjustmentHistoryBatch] Cannot undo - no images selected');
203
372
  }
204
- // Update current batch and move index back
205
- setCurrentBatch(newBatch);
206
- setCurrentIndex(currentIndex - 1);
207
- // Update the current history entry with the new mixed state
208
- setHistory(prevHistory => {
209
- const newHistory = [...prevHistory];
210
- newHistory[currentIndex] = newBatch;
211
- return newHistory;
373
+ return;
374
+ }
375
+ let anyChanges = false;
376
+ const backendOperations = [];
377
+ setImageHistories(prevHistories => {
378
+ return prevHistories.map(imageHistory => {
379
+ if (!selectedIds.includes(imageHistory.imageId)) {
380
+ return imageHistory; // No change for unselected images
381
+ }
382
+ // Find current entry index
383
+ const currentEntryIndex = imageHistory.history.findIndex(entry => entry.id === imageHistory.currentHistoryEntryId);
384
+ if (currentEntryIndex <= 0) {
385
+ return imageHistory; // Cannot undo if at first entry or entry not found
386
+ }
387
+ // Move to previous entry
388
+ const previousEntry = imageHistory.history[currentEntryIndex - 1];
389
+ anyChanges = true;
390
+ // Prepare backend sync operation
391
+ if (previousEntry.id && internalOptions.controller && internalOptions.firebaseUid) {
392
+ backendOperations.push({
393
+ imageId: imageHistory.imageId,
394
+ taskId: previousEntry.id
395
+ });
396
+ }
397
+ return {
398
+ ...imageHistory,
399
+ currentHistoryEntryId: previousEntry.id
400
+ };
212
401
  });
213
- if (internalOptions.devWarnings) {
214
- console.log('useAdjustmentHistoryBatch: Undo completed for selected images only', {
215
- selectedImages: selectedIds,
216
- currentIndex: currentIndex - 1
402
+ });
403
+ // Sync with backend
404
+ if (backendOperations.length > 0 && internalOptions.controller && internalOptions.firebaseUid) {
405
+ try {
406
+ const promises = backendOperations.map(async (operation) => {
407
+ await internalOptions.controller.setHistoryIndex(internalOptions.firebaseUid, operation.imageId, operation.taskId);
217
408
  });
409
+ await Promise.all(promises);
410
+ if (devWarningsRef.current) {
411
+ console.log(`[useAdjustmentHistoryBatch] Synced ${backendOperations.length} undo operations to backend`);
412
+ }
413
+ }
414
+ catch (error) {
415
+ console.error('[useAdjustmentHistoryBatch] Failed to sync undo to backend:', error);
218
416
  }
219
417
  }
220
- }, [currentIndex, selectedIds, history, currentBatch, internalOptions.devWarnings]);
221
- // Redo next changes to selected images
222
- const redo = useCallback(() => {
223
- if (currentIndex < history.length - 1 && selectedIds.length > 0) {
224
- const nextBatch = history[currentIndex + 1];
225
- const newBatch = {
226
- currentSelection: { ...currentBatch.currentSelection },
227
- allImages: { ...currentBatch.allImages },
228
- initialStates: { ...currentBatch.initialStates }
229
- };
230
- // Only restore adjustments for currently selected images
231
- for (const imageId of selectedIds) {
232
- if (nextBatch.allImages[imageId] && newBatch.currentSelection[imageId]) {
233
- // Restore from next allImages state (not currentSelection)
234
- newBatch.currentSelection[imageId] = { ...nextBatch.allImages[imageId] };
235
- newBatch.allImages[imageId] = { ...nextBatch.allImages[imageId] };
236
- }
418
+ if (!anyChanges && devWarningsRef.current) {
419
+ console.warn('[useAdjustmentHistoryBatch] Undo skipped - no changes to undo for selected images');
420
+ }
421
+ }, [selectedIds, internalOptions]);
422
+ // Redo next changes to selected images - entry-based history version with backend sync
423
+ const redo = useCallback(async () => {
424
+ if (selectedIds.length === 0) {
425
+ if (devWarningsRef.current) {
426
+ console.warn('[useAdjustmentHistoryBatch] Cannot redo - no images selected');
237
427
  }
238
- // Update current batch and move index forward
239
- setCurrentBatch(newBatch);
240
- setCurrentIndex(currentIndex + 1);
241
- // Update the current history entry with the new mixed state
242
- setHistory(prevHistory => {
243
- const newHistory = [...prevHistory];
244
- newHistory[currentIndex + 1] = newBatch;
245
- return newHistory;
428
+ return;
429
+ }
430
+ let anyChanges = false;
431
+ const backendOperations = [];
432
+ setImageHistories(prevHistories => {
433
+ return prevHistories.map(imageHistory => {
434
+ if (!selectedIds.includes(imageHistory.imageId)) {
435
+ return imageHistory; // No change for unselected images
436
+ }
437
+ // Find current entry index
438
+ const currentEntryIndex = imageHistory.history.findIndex(entry => entry.id === imageHistory.currentHistoryEntryId);
439
+ if (currentEntryIndex >= imageHistory.history.length - 1 || currentEntryIndex === -1) {
440
+ return imageHistory; // Cannot redo if at last entry or entry not found
441
+ }
442
+ // Move to next entry
443
+ const nextEntry = imageHistory.history[currentEntryIndex + 1];
444
+ anyChanges = true;
445
+ // Prepare backend sync operation
446
+ if (nextEntry.id && internalOptions.controller && internalOptions.firebaseUid) {
447
+ backendOperations.push({
448
+ imageId: imageHistory.imageId,
449
+ taskId: nextEntry.id
450
+ });
451
+ }
452
+ return {
453
+ ...imageHistory,
454
+ currentHistoryEntryId: nextEntry.id
455
+ };
246
456
  });
247
- if (internalOptions.devWarnings) {
248
- console.log('useAdjustmentHistoryBatch: Redo completed for selected images only', {
249
- selectedImages: selectedIds,
250
- currentIndex: currentIndex + 1
457
+ });
458
+ // Sync with backend
459
+ if (backendOperations.length > 0 && internalOptions.controller && internalOptions.firebaseUid) {
460
+ try {
461
+ const promises = backendOperations.map(async (operation) => {
462
+ await internalOptions.controller.setHistoryIndex(internalOptions.firebaseUid, operation.imageId, operation.taskId);
251
463
  });
464
+ await Promise.all(promises);
465
+ if (devWarningsRef.current) {
466
+ console.log(`[useAdjustmentHistoryBatch] Synced ${backendOperations.length} redo operations to backend`);
467
+ }
468
+ }
469
+ catch (error) {
470
+ console.error('[useAdjustmentHistoryBatch] Failed to sync redo to backend:', error);
252
471
  }
253
472
  }
254
- }, [currentIndex, selectedIds, history, currentBatch, internalOptions.devWarnings]);
255
- // Reset selected images to default state
473
+ if (!anyChanges && devWarningsRef.current) {
474
+ console.warn('[useAdjustmentHistoryBatch] Redo skipped - no changes to redo for selected images');
475
+ }
476
+ }, [selectedIds, internalOptions]);
477
+ // Check if any selected image can be undone
478
+ const canUndoSelected = useCallback(() => {
479
+ return selectedIds.some(imageId => {
480
+ const imageHistory = imageHistories.find(h => h.imageId === imageId);
481
+ if (!imageHistory)
482
+ return false;
483
+ const currentEntryIndex = imageHistory.history.findIndex(entry => entry.id === imageHistory.currentHistoryEntryId);
484
+ return currentEntryIndex > 0;
485
+ });
486
+ }, [selectedIds, imageHistories]);
487
+ // Check if any selected image can be redone
488
+ const canRedoSelected = useCallback(() => {
489
+ return selectedIds.some(imageId => {
490
+ const imageHistory = imageHistories.find(h => h.imageId === imageId);
491
+ if (!imageHistory)
492
+ return false;
493
+ const currentEntryIndex = imageHistory.history.findIndex(entry => entry.id === imageHistory.currentHistoryEntryId);
494
+ return currentEntryIndex >= 0 && currentEntryIndex < imageHistory.history.length - 1;
495
+ });
496
+ }, [selectedIds, imageHistories]);
497
+ // Reset selected images to default state - entry-based history version
256
498
  const reset = useCallback((imageIds) => {
257
499
  const idsToReset = imageIds || selectedIds;
258
500
  if (idsToReset.length === 0)
259
501
  return;
260
- const newBatch = {
261
- currentSelection: { ...currentBatch.currentSelection },
262
- allImages: { ...currentBatch.allImages },
263
- initialStates: { ...currentBatch.initialStates }
264
- };
265
502
  const defaultState = createDefaultAdjustmentState(internalOptions.defaultAdjustmentState);
266
- for (const imageId of idsToReset) {
267
- if (newBatch.currentSelection[imageId]) {
268
- newBatch.currentSelection[imageId] = { ...defaultState };
269
- newBatch.allImages[imageId] = { ...defaultState };
270
- }
271
- }
272
- pushBatchState(newBatch);
273
- }, [selectedIds, currentBatch, pushBatchState, internalOptions.defaultAdjustmentState]);
503
+ setImageHistories(prevHistories => {
504
+ return prevHistories.map(imageHistory => {
505
+ if (!idsToReset.includes(imageHistory.imageId)) {
506
+ return imageHistory; // No change for images not being reset
507
+ }
508
+ // Create new entry with default state
509
+ const newEntryId = generateEntryId();
510
+ const newEntry = {
511
+ id: newEntryId,
512
+ adjustment: defaultState
513
+ };
514
+ // Add to this image's history
515
+ const newHistory = [...imageHistory.history, newEntry];
516
+ // Trim if needed
517
+ const maxSize = maxSizeRef.current;
518
+ const trimmedHistory = typeof maxSize === 'number' && newHistory.length > maxSize
519
+ ? newHistory.slice(-maxSize)
520
+ : newHistory;
521
+ return {
522
+ ...imageHistory,
523
+ history: trimmedHistory,
524
+ currentHistoryEntryId: newEntryId
525
+ };
526
+ });
527
+ });
528
+ }, [selectedIds, internalOptions.defaultAdjustmentState]);
274
529
  // Selection management with initial adjustments - single state update
275
530
  const setSelection = useCallback((configs) => {
276
531
  const imageIds = configs.map(config => config.imageId);
@@ -323,54 +578,217 @@ export function useAdjustmentHistoryBatch(options = {}) {
323
578
  });
324
579
  }
325
580
  }, [allImageIds, currentBatch, internalOptions.defaultAdjustmentState, internalOptions.devWarnings]);
326
- // Sync adjustments for specific images (clears their history) - single state update
327
- const syncAdjustment = useCallback((configs) => {
581
+ // Sync adjustments for specific images - loads full history from backend
582
+ const syncAdjustment = useCallback(async (configs) => {
328
583
  if (configs.length === 0)
329
584
  return;
330
- // Build new batch state
331
- const newBatch = {
332
- currentSelection: { ...currentBatch.currentSelection },
333
- allImages: { ...currentBatch.allImages },
334
- initialStates: { ...currentBatch.initialStates }
335
- };
336
- // Process each sync config
337
- for (const config of configs) {
338
- const { imageId, adjustment } = config;
339
- if (adjustment) {
340
- const fullAdjustment = {
341
- ...createDefaultAdjustmentState(internalOptions.defaultAdjustmentState),
342
- ...adjustment
343
- };
344
- // Update all states for this image
345
- newBatch.allImages[imageId] = { ...fullAdjustment };
346
- newBatch.initialStates[imageId] = { ...fullAdjustment };
347
- // If image is currently selected, update current selection too
348
- if (newBatch.currentSelection[imageId]) {
349
- newBatch.currentSelection[imageId] = { ...fullAdjustment };
585
+ // If controller is available, load full history from backend
586
+ if (internalOptions.controller && internalOptions.firebaseUid) {
587
+ try {
588
+ const historyPromises = configs.map(async (config) => {
589
+ try {
590
+ const historyResponse = await internalOptions.controller.getEditorHistory(internalOptions.firebaseUid, config.imageId);
591
+ return {
592
+ imageId: config.imageId,
593
+ backendHistory: historyResponse.history || [],
594
+ fallbackAdjustment: config.adjustment
595
+ };
596
+ }
597
+ catch (error) {
598
+ console.warn(`[useAdjustmentHistoryBatch] Failed to load history for image ${config.imageId}:`, error);
599
+ return {
600
+ imageId: config.imageId,
601
+ backendHistory: [],
602
+ fallbackAdjustment: config.adjustment
603
+ };
604
+ }
605
+ });
606
+ const historyResults = await Promise.all(historyPromises);
607
+ setImageHistories(prevHistories => {
608
+ const updatedHistories = [...prevHistories];
609
+ for (const result of historyResults) {
610
+ const { imageId, backendHistory, fallbackAdjustment } = result;
611
+ const existingIndex = updatedHistories.findIndex(h => h.imageId === imageId);
612
+ if (backendHistory.length > 0) {
613
+ // Convert backend history to local history entries
614
+ const historyEntries = backendHistory.map((entry, index) => ({
615
+ id: entry.task_id, // Use backend task_id as our entry id
616
+ adjustment: mapColorAdjustmentToAdjustmentState ?
617
+ mapColorAdjustmentToAdjustmentState(entry.editor_config.color_adjustment) :
618
+ createDefaultAdjustmentState(internalOptions.defaultAdjustmentState)
619
+ }));
620
+ const newImageHistory = {
621
+ imageId,
622
+ currentHistoryEntryId: historyEntries[historyEntries.length - 1].id, // Point to latest entry
623
+ history: historyEntries
624
+ };
625
+ if (existingIndex >= 0) {
626
+ updatedHistories[existingIndex] = newImageHistory;
627
+ }
628
+ else {
629
+ updatedHistories.push(newImageHistory);
630
+ }
631
+ }
632
+ else {
633
+ // No backend history, use fallback adjustment or default
634
+ const adjustment = fallbackAdjustment ? {
635
+ ...createDefaultAdjustmentState(internalOptions.defaultAdjustmentState),
636
+ ...fallbackAdjustment
637
+ } : createDefaultAdjustmentState(internalOptions.defaultAdjustmentState);
638
+ const entryId = generateEntryId();
639
+ const entry = {
640
+ id: entryId,
641
+ adjustment
642
+ };
643
+ const newImageHistory = {
644
+ imageId,
645
+ currentHistoryEntryId: entryId,
646
+ history: [entry]
647
+ };
648
+ if (existingIndex >= 0) {
649
+ updatedHistories[existingIndex] = newImageHistory;
650
+ }
651
+ else {
652
+ updatedHistories.push(newImageHistory);
653
+ }
654
+ }
655
+ }
656
+ return updatedHistories;
657
+ });
658
+ if (internalOptions.devWarnings) {
659
+ const syncedImageIds = configs.map(c => c.imageId);
660
+ const totalHistoryEntries = historyResults.reduce((sum, result) => sum + result.backendHistory.length, 0);
661
+ console.log('[useAdjustmentHistoryBatch] Synced adjustments with backend history', {
662
+ syncedImages: syncedImageIds,
663
+ totalHistoryEntries,
664
+ historyLoaded: true
665
+ });
350
666
  }
351
667
  }
668
+ catch (error) {
669
+ console.error('[useAdjustmentHistoryBatch] Failed to sync with backend, falling back to local only:', error);
670
+ // Fall back to local-only sync
671
+ syncAdjustmentLocal(configs);
672
+ }
352
673
  }
353
- // Clear history and start fresh with synced state
354
- const freshHistory = [newBatch];
355
- setHistory(freshHistory);
356
- setCurrentIndex(0);
357
- setCurrentBatch(newBatch);
358
- if (internalOptions.devWarnings) {
359
- const syncedImageIds = configs.map(c => c.imageId);
360
- console.log('useAdjustmentHistoryBatch: Synced adjustments (history cleared)', {
361
- syncedImages: syncedImageIds,
362
- historyCleared: true
363
- });
674
+ else {
675
+ // No controller available, use local-only sync
676
+ syncAdjustmentLocal(configs);
364
677
  }
365
- }, [currentBatch, internalOptions.defaultAdjustmentState, internalOptions.devWarnings]);
678
+ // Update allImageIds to include any new images
679
+ const newImageIds = configs.map(c => c.imageId);
680
+ setAllImageIds(prev => {
681
+ const combined = Array.from(new Set([...prev, ...newImageIds]));
682
+ return combined;
683
+ });
684
+ }, [internalOptions]);
685
+ // Local-only sync for fallback
686
+ const syncAdjustmentLocal = useCallback((configs) => {
687
+ setImageHistories(prevHistories => {
688
+ const updatedHistories = [...prevHistories];
689
+ for (const config of configs) {
690
+ const { imageId, adjustment } = config;
691
+ const existingIndex = updatedHistories.findIndex(h => h.imageId === imageId);
692
+ if (adjustment) {
693
+ const fullAdjustment = {
694
+ ...createDefaultAdjustmentState(internalOptions.defaultAdjustmentState),
695
+ ...adjustment
696
+ };
697
+ const entryId = generateEntryId();
698
+ const entry = {
699
+ id: entryId,
700
+ adjustment: fullAdjustment
701
+ };
702
+ if (existingIndex >= 0) {
703
+ // Update existing image history, replace with new adjustment as initial state
704
+ updatedHistories[existingIndex] = {
705
+ imageId,
706
+ currentHistoryEntryId: entryId,
707
+ history: [entry] // Reset history with synced state
708
+ };
709
+ }
710
+ else {
711
+ // Add new image history
712
+ updatedHistories.push({
713
+ imageId,
714
+ currentHistoryEntryId: entryId,
715
+ history: [entry]
716
+ });
717
+ }
718
+ }
719
+ else if (existingIndex < 0) {
720
+ // Add new image with default state
721
+ const defaultState = createDefaultAdjustmentState(internalOptions.defaultAdjustmentState);
722
+ const entryId = generateEntryId();
723
+ const entry = {
724
+ id: entryId,
725
+ adjustment: defaultState
726
+ };
727
+ updatedHistories.push({
728
+ imageId,
729
+ currentHistoryEntryId: entryId,
730
+ history: [entry]
731
+ });
732
+ }
733
+ }
734
+ return updatedHistories;
735
+ });
736
+ }, [internalOptions]);
366
737
  const toggleSelection = useCallback((imageId) => {
367
- setSelectedIds(prev => prev.includes(imageId)
368
- ? prev.filter(id => id !== imageId)
369
- : [...prev, imageId]);
370
- }, []);
738
+ setSelectedIds(prev => {
739
+ const isCurrentlySelected = prev.includes(imageId);
740
+ const newSelectedIds = isCurrentlySelected
741
+ ? prev.filter(id => id !== imageId)
742
+ : [...prev, imageId];
743
+ // Update currentSelection in batch state
744
+ const newBatch = {
745
+ currentSelection: { ...currentBatch.currentSelection },
746
+ allImages: { ...currentBatch.allImages },
747
+ initialStates: { ...currentBatch.initialStates }
748
+ };
749
+ if (isCurrentlySelected) {
750
+ // Remove from currentSelection
751
+ delete newBatch.currentSelection[imageId];
752
+ }
753
+ else {
754
+ // Add to currentSelection - use existing state from allImages or create default
755
+ if (currentBatch.allImages[imageId]) {
756
+ newBatch.currentSelection[imageId] = { ...currentBatch.allImages[imageId] };
757
+ }
758
+ else {
759
+ // New image - create default state
760
+ const defaultState = createDefaultAdjustmentState(internalOptions.defaultAdjustmentState);
761
+ newBatch.currentSelection[imageId] = { ...defaultState };
762
+ newBatch.allImages[imageId] = { ...defaultState };
763
+ newBatch.initialStates[imageId] = { ...defaultState };
764
+ }
765
+ }
766
+ setCurrentBatch(newBatch);
767
+ return newSelectedIds;
768
+ });
769
+ }, [currentBatch, internalOptions.defaultAdjustmentState]);
371
770
  const selectAll = useCallback(() => {
372
771
  setSelectedIds([...allImageIds]);
373
- }, [allImageIds]);
772
+ // Update currentSelection to include all images
773
+ const newBatch = {
774
+ currentSelection: {},
775
+ allImages: { ...currentBatch.allImages },
776
+ initialStates: { ...currentBatch.initialStates }
777
+ };
778
+ for (const imageId of allImageIds) {
779
+ if (currentBatch.allImages[imageId]) {
780
+ newBatch.currentSelection[imageId] = { ...currentBatch.allImages[imageId] };
781
+ }
782
+ else {
783
+ // New image - create default state
784
+ const defaultState = createDefaultAdjustmentState(internalOptions.defaultAdjustmentState);
785
+ newBatch.currentSelection[imageId] = { ...defaultState };
786
+ newBatch.allImages[imageId] = { ...defaultState };
787
+ newBatch.initialStates[imageId] = { ...defaultState };
788
+ }
789
+ }
790
+ setCurrentBatch(newBatch);
791
+ }, [allImageIds, currentBatch, internalOptions.defaultAdjustmentState]);
374
792
  const clearSelection = useCallback(() => {
375
793
  setSelectedIds([]);
376
794
  // Clear currentSelection but keep allImages and initialStates
@@ -381,18 +799,16 @@ export function useAdjustmentHistoryBatch(options = {}) {
381
799
  };
382
800
  setCurrentBatch(newBatch);
383
801
  }, [currentBatch]);
384
- // Other history actions
802
+ // Jump to specific index - not applicable in per-image history
385
803
  const jumpToIndex = useCallback((index) => {
386
- if (index >= 0 && index < history.length) {
387
- setCurrentIndex(index);
388
- setCurrentBatch(history[index]);
804
+ if (devWarningsRef.current) {
805
+ console.warn('[useAdjustmentHistoryBatch] jumpToIndex not supported in per-image history mode');
389
806
  }
390
- }, [history]);
807
+ }, []);
808
+ // Clear all history and start fresh
391
809
  const clearHistory = useCallback(() => {
392
- const freshBatch = createEmptyBatchState();
393
- setHistory([freshBatch]);
394
- setCurrentIndex(0);
395
- setCurrentBatch(freshBatch);
810
+ setImageHistories([]);
811
+ setCurrentBatch(createEmptyBatchState());
396
812
  setAllImageIds([]);
397
813
  setSelectedIds([]);
398
814
  }, []);
@@ -403,32 +819,41 @@ export function useAdjustmentHistoryBatch(options = {}) {
403
819
  initialStates: { ...currentBatch.initialStates }
404
820
  };
405
821
  }, [currentBatch]);
822
+ // Sync entire batch state - adapted for entry-based history
406
823
  const syncBatch = useCallback((newBatch, targetIndex) => {
407
824
  // Validate input
408
825
  if (!newBatch || typeof newBatch !== 'object' || !newBatch.currentSelection || !newBatch.allImages || !newBatch.initialStates) {
409
826
  console.warn('syncBatch: newBatch must be a valid BatchAdjustmentState object with currentSelection, allImages, and initialStates');
410
827
  return;
411
828
  }
412
- // Update current state
829
+ // Convert batch state to entry-based histories
830
+ const newImageHistories = [];
831
+ Object.entries(newBatch.allImages).forEach(([imageId, adjustment]) => {
832
+ const entryId = generateEntryId();
833
+ const entry = {
834
+ id: entryId,
835
+ adjustment
836
+ };
837
+ newImageHistories.push({
838
+ imageId,
839
+ currentHistoryEntryId: entryId,
840
+ history: [entry] // Start with current state as single history entry
841
+ });
842
+ });
843
+ // Update state
844
+ setImageHistories(newImageHistories);
413
845
  setCurrentBatch({
414
846
  currentSelection: { ...newBatch.currentSelection },
415
847
  allImages: { ...newBatch.allImages },
416
848
  initialStates: { ...newBatch.initialStates }
417
849
  });
418
- // Replace history with single entry
419
- setHistory([{
420
- currentSelection: { ...newBatch.currentSelection },
421
- allImages: { ...newBatch.allImages },
422
- initialStates: { ...newBatch.initialStates }
423
- }]);
424
- setCurrentIndex(0);
425
- // Update image tracking
850
+ // Update tracking
426
851
  const allImageIds = Object.keys(newBatch.allImages);
427
852
  const selectedImageIds = Object.keys(newBatch.currentSelection);
428
853
  setAllImageIds(allImageIds);
429
854
  setSelectedIds(selectedImageIds);
430
855
  if (devWarningsRef.current) {
431
- console.log('syncBatch: Synchronized batch state', {
856
+ console.log('[useAdjustmentHistoryBatch] Synchronized batch state to entry-based history', {
432
857
  totalImages: allImageIds.length,
433
858
  selectedImages: selectedImageIds.length
434
859
  });
@@ -441,19 +866,20 @@ export function useAdjustmentHistoryBatch(options = {}) {
441
866
  enforceMaxSize();
442
867
  }
443
868
  }, [enforceMaxSize]);
444
- // History info object
869
+ // History info object - updated for per-image history
445
870
  const historyInfo = useMemo(() => ({
446
- canUndo: currentIndex > 0 && selectedIds.length > 0,
447
- canRedo: currentIndex < history.length - 1 && selectedIds.length > 0,
448
- currentIndex,
449
- totalStates: history.length,
871
+ canUndo: canUndoSelected(),
872
+ canRedo: canRedoSelected(),
873
+ currentIndex: 0, // Not applicable in per-image history
874
+ totalStates: imageHistories.reduce((sum, h) => sum + h.history.length, 0),
450
875
  selectedCount: selectedIds.length,
451
876
  totalImages: allImageIds.length,
452
877
  historySize: getMemoryUsage()
453
- }), [currentIndex, history.length, selectedIds.length, allImageIds.length, getMemoryUsage]);
878
+ }), [canUndoSelected, canRedoSelected, imageHistories, selectedIds.length, allImageIds.length, getMemoryUsage]);
454
879
  // Actions object - stabilized with useMemo
455
880
  const actions = useMemo(() => ({
456
881
  adjustSelected,
882
+ adjustSelectedWithPreset,
457
883
  undo,
458
884
  redo,
459
885
  reset,
@@ -467,7 +893,7 @@ export function useAdjustmentHistoryBatch(options = {}) {
467
893
  getCurrentBatch,
468
894
  syncBatch
469
895
  }), [
470
- adjustSelected, undo, redo, reset,
896
+ adjustSelected, adjustSelectedWithPreset, undo, redo, reset,
471
897
  setSelection, syncAdjustment, toggleSelection, selectAll, clearSelection,
472
898
  jumpToIndex, clearHistory, getCurrentBatch, syncBatch
473
899
  ]);