@jhits/plugin-images 0.0.13 → 0.0.15

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 (111) hide show
  1. package/dist/api/list/index.d.ts +18 -0
  2. package/dist/api/list/index.d.ts.map +1 -1
  3. package/dist/api/list/index.js +121 -20
  4. package/dist/api/router.d.ts.map +1 -1
  5. package/dist/api/router.js +7 -0
  6. package/dist/api/usage/route.d.ts +23 -0
  7. package/dist/api/usage/route.d.ts.map +1 -0
  8. package/dist/api/usage/route.js +238 -0
  9. package/dist/components/BackgroundImage.d.ts.map +1 -1
  10. package/dist/components/BackgroundImage.js +5 -17
  11. package/dist/components/GlobalImageEditor.d.ts.map +1 -1
  12. package/dist/components/GlobalImageEditor.js +9 -4
  13. package/dist/components/Image.d.ts +3 -6
  14. package/dist/components/Image.d.ts.map +1 -1
  15. package/dist/components/Image.js +103 -206
  16. package/dist/components/ImageEditor.d.ts.map +1 -1
  17. package/dist/components/ImageEditor.js +21 -125
  18. package/dist/components/ImagePicker.d.ts.map +1 -1
  19. package/dist/components/ImagePicker.js +6 -59
  20. package/dist/utils/fallback.d.ts +9 -4
  21. package/dist/utils/fallback.d.ts.map +1 -1
  22. package/dist/utils/fallback.js +40 -12
  23. package/dist/utils/transforms.d.ts.map +1 -1
  24. package/dist/utils/transforms.js +7 -10
  25. package/dist/views/ImageManager/components/CleanupLibraryModal.d.ts +12 -0
  26. package/dist/views/ImageManager/components/CleanupLibraryModal.d.ts.map +1 -0
  27. package/dist/views/ImageManager/components/CleanupLibraryModal.js +7 -0
  28. package/dist/views/ImageManager/components/DeleteImageModal.d.ts +15 -0
  29. package/dist/views/ImageManager/components/DeleteImageModal.d.ts.map +1 -0
  30. package/dist/views/ImageManager/components/DeleteImageModal.js +8 -0
  31. package/dist/views/ImageManager/components/ImageGrid.d.ts +12 -0
  32. package/dist/views/ImageManager/components/ImageGrid.d.ts.map +1 -0
  33. package/dist/views/ImageManager/components/ImageGrid.js +15 -0
  34. package/dist/views/ImageManager/components/ImageManagerHeader.d.ts +11 -0
  35. package/dist/views/ImageManager/components/ImageManagerHeader.d.ts.map +1 -0
  36. package/dist/views/ImageManager/components/ImageManagerHeader.js +6 -0
  37. package/dist/views/ImageManager/components/ImageManagerStats.d.ts +8 -0
  38. package/dist/views/ImageManager/components/ImageManagerStats.d.ts.map +1 -0
  39. package/dist/views/ImageManager/components/ImageManagerStats.js +6 -0
  40. package/dist/views/ImageManager/components/ImageManagerToolbar.d.ts +9 -0
  41. package/dist/views/ImageManager/components/ImageManagerToolbar.d.ts.map +1 -0
  42. package/dist/views/ImageManager/components/ImageManagerToolbar.js +10 -0
  43. package/dist/views/ImageManager/components/ImageTable.d.ts +13 -0
  44. package/dist/views/ImageManager/components/ImageTable.d.ts.map +1 -0
  45. package/dist/views/ImageManager/components/ImageTable.js +13 -0
  46. package/dist/views/ImageManager/types.d.ts +26 -0
  47. package/dist/views/ImageManager/types.d.ts.map +1 -0
  48. package/dist/views/ImageManager/types.js +1 -0
  49. package/dist/views/ImageManager.d.ts +1 -1
  50. package/dist/views/ImageManager.d.ts.map +1 -1
  51. package/dist/views/ImageManager.js +206 -2
  52. package/package.json +10 -9
  53. package/src/api/list/index.ts +147 -22
  54. package/src/api/router.ts +8 -0
  55. package/src/api/usage/route.ts +294 -0
  56. package/src/components/BackgroundImage.tsx +5 -15
  57. package/src/components/GlobalImageEditor.tsx +9 -4
  58. package/src/components/Image.tsx +128 -268
  59. package/src/components/ImageEditor.tsx +31 -193
  60. package/src/components/ImagePicker.tsx +22 -107
  61. package/src/utils/fallback.ts +46 -13
  62. package/src/utils/transforms.ts +9 -12
  63. package/src/views/ImageManager/components/CleanupLibraryModal.tsx +96 -0
  64. package/src/views/ImageManager/components/DeleteImageModal.tsx +144 -0
  65. package/src/views/ImageManager/components/ImageGrid.tsx +119 -0
  66. package/src/views/ImageManager/components/ImageManagerHeader.tsx +72 -0
  67. package/src/views/ImageManager/components/ImageManagerStats.tsx +60 -0
  68. package/src/views/ImageManager/components/ImageManagerToolbar.tsx +60 -0
  69. package/src/views/ImageManager/components/ImageTable.tsx +120 -0
  70. package/src/views/ImageManager/types.ts +27 -0
  71. package/src/views/ImageManager.tsx +307 -12
  72. package/src/components/BackgroundImage.d.ts +0 -11
  73. package/src/components/BackgroundImage.d.ts.map +0 -1
  74. package/src/components/GlobalImageEditor/config.d.ts +0 -9
  75. package/src/components/GlobalImageEditor/config.d.ts.map +0 -1
  76. package/src/components/GlobalImageEditor/eventHandlers.d.ts +0 -20
  77. package/src/components/GlobalImageEditor/eventHandlers.d.ts.map +0 -1
  78. package/src/components/GlobalImageEditor/imageDetection.d.ts +0 -16
  79. package/src/components/GlobalImageEditor/imageDetection.d.ts.map +0 -1
  80. package/src/components/GlobalImageEditor/imageSetup.d.ts +0 -9
  81. package/src/components/GlobalImageEditor/imageSetup.d.ts.map +0 -1
  82. package/src/components/GlobalImageEditor/saveLogic.d.ts +0 -26
  83. package/src/components/GlobalImageEditor/saveLogic.d.ts.map +0 -1
  84. package/src/components/GlobalImageEditor/stylingDetection.d.ts +0 -9
  85. package/src/components/GlobalImageEditor/stylingDetection.d.ts.map +0 -1
  86. package/src/components/GlobalImageEditor/transformParsing.d.ts +0 -16
  87. package/src/components/GlobalImageEditor/transformParsing.d.ts.map +0 -1
  88. package/src/components/GlobalImageEditor/types.d.ts +0 -36
  89. package/src/components/GlobalImageEditor/types.d.ts.map +0 -1
  90. package/src/components/GlobalImageEditor.d.ts +0 -8
  91. package/src/components/GlobalImageEditor.d.ts.map +0 -1
  92. package/src/components/Image.d.ts +0 -22
  93. package/src/components/Image.d.ts.map +0 -1
  94. package/src/components/ImageBrowserModal.d.ts +0 -13
  95. package/src/components/ImageBrowserModal.d.ts.map +0 -1
  96. package/src/components/ImageEditor.d.ts +0 -27
  97. package/src/components/ImageEditor.d.ts.map +0 -1
  98. package/src/components/ImagePicker.d.ts +0 -3
  99. package/src/components/ImagePicker.d.ts.map +0 -1
  100. package/src/components/ImagesPluginInit.d.ts +0 -24
  101. package/src/components/ImagesPluginInit.d.ts.map +0 -1
  102. package/src/hooks/useImagePicker.d.ts +0 -20
  103. package/src/hooks/useImagePicker.d.ts.map +0 -1
  104. package/src/types/index.d.ts +0 -80
  105. package/src/types/index.d.ts.map +0 -1
  106. package/src/utils/fallback.d.ts +0 -27
  107. package/src/utils/fallback.d.ts.map +0 -1
  108. package/src/utils/transforms.d.ts +0 -26
  109. package/src/utils/transforms.d.ts.map +0 -1
  110. package/src/views/ImageManager.d.ts +0 -10
  111. package/src/views/ImageManager.d.ts.map +0 -1
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo } from 'react';
4
- import { ZoomIn, RotateCcw, Move, Maximize2, Sun, Droplets } from 'lucide-react';
5
- import { getImageTransform, getImageFilter } from '../utils/transforms';
4
+ import { ZoomIn, RotateCcw, Maximize2, Sun, Droplets } from 'lucide-react';
5
+ import { getImageFilter } from '../utils/transforms';
6
6
 
7
7
  export interface ImageEditorProps {
8
8
  imageUrl: string;
@@ -40,153 +40,51 @@ export const ImageEditor = forwardRef<ImageEditorHandle, ImageEditorProps>(({
40
40
  imageId,
41
41
  }, ref) => {
42
42
  const containerRef = useRef<HTMLDivElement>(null);
43
- const imageRef = useRef<HTMLImageElement>(null);
44
43
  const [isDragging, setIsDragging] = useState(false);
45
44
  const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
46
45
  const [dragStartPosition, setDragStartPosition] = useState({ x: 0, y: 0 });
47
- const [baseScale, setBaseScale] = useState(0);
48
46
 
49
- // Local state for brightness and blur (only applied on save)
50
47
  const [localBrightness, setLocalBrightness] = useState(brightness);
51
48
  const [localBlur, setLocalBlur] = useState(blur);
52
49
 
53
- // Keep internal values in a ref to avoid stale closure issues during the final save
54
- const currentValues = useRef({ scale, positionX, positionY, brightness, blur });
55
- useEffect(() => {
56
- currentValues.current = { scale, positionX, positionY, brightness: localBrightness, blur: localBlur };
57
- }, [scale, positionX, positionY, localBrightness, localBlur]);
58
-
59
- // Reset local values when props change (when editor opens with new values)
60
50
  useEffect(() => {
61
51
  setLocalBrightness(brightness);
62
52
  setLocalBlur(blur);
63
53
  }, [brightness, blur]);
64
54
 
65
- // EXPOSE TO PARENT: Return current values so Parent can handle the DB Save
66
55
  useImperativeHandle(ref, () => ({
67
56
  flushSave: async () => {
68
- // Read the latest values directly from props/state (they're in the dependency array, so they're current)
69
- // The ref is kept in sync via useEffect, but read directly from props to avoid any timing issues
70
- const latestScale = scale;
71
- const latestPositionX = positionX;
72
- const latestPositionY = positionY;
73
- const latestBrightness = localBrightness;
74
- const latestBlur = localBlur;
75
-
76
- // Update the ref to ensure it's in sync (for any other code that might read it)
77
- currentValues.current = {
78
- scale: latestScale,
79
- positionX: latestPositionX,
80
- positionY: latestPositionY,
81
- brightness: latestBrightness,
82
- blur: latestBlur
83
- };
84
-
85
- // DON'T call onBrightnessChange/onBlurChange here - they trigger saves
86
- // The parent (via onEditorSave) will handle saving all values together
87
- // This prevents duplicate saves
88
-
89
57
  return {
90
- scale: Math.max(0.1, Math.min(5.0, latestScale)),
91
- positionX: latestPositionX,
92
- positionY: latestPositionY,
93
- brightness: latestBrightness,
94
- blur: latestBlur
58
+ scale: Math.max(0.1, Math.min(5.0, scale)),
59
+ positionX: positionX,
60
+ positionY: positionY,
61
+ brightness: localBrightness,
62
+ blur: localBlur
95
63
  };
96
64
  }
97
65
  }), [scale, positionX, positionY, localBrightness, localBlur]);
98
66
 
99
- const getClampedPosition = useCallback((x: number, y: number, currentScale: number) => {
100
- if (!containerRef.current || !imageRef.current || baseScale === 0) return { x, y };
101
- const totalScale = baseScale * currentScale;
102
- const containerWidth = containerRef.current.offsetWidth;
103
- const containerHeight = containerRef.current.offsetHeight;
104
-
105
- // With minWidth/minHeight, the image maintains its natural aspect ratio
106
- // but is scaled to cover the container. The scaled dimensions are based on natural size.
107
- const scaledWidth = imageRef.current.naturalWidth * totalScale;
108
- const scaledHeight = imageRef.current.naturalHeight * totalScale;
109
- const overflowX = Math.max(0, (scaledWidth - containerWidth) / 2);
110
- const overflowY = Math.max(0, (scaledHeight - containerHeight) / 2);
111
-
112
- // Convert overflow limits to percentage of CONTAINER (not natural size)
113
- // Position values are container-relative, so limits must be too
114
- const limitX = containerWidth > 0 ? (overflowX / containerWidth) * 100 : 0;
115
- const limitY = containerHeight > 0 ? (overflowY / containerHeight) * 100 : 0;
116
-
117
- return { x: Math.max(-limitX, Math.min(limitX, x)), y: Math.max(-limitY, Math.min(limitY, y)) };
118
- }, [baseScale]);
119
-
120
67
  const handleScaleChange = (newScale: number) => {
121
- const s = Math.max(0.1, Math.min(5.0, newScale));
122
- onScaleChange(s);
123
- const clamped = getClampedPosition(positionX, positionY, s);
124
- onPositionChange(clamped.x, clamped.y);
68
+ onScaleChange(Math.max(0.1, Math.min(5.0, newScale)));
125
69
  };
126
70
 
127
- const calculateBaseScale = useCallback(() => {
128
- if (!imageRef.current || !containerRef.current) return;
129
- const img = imageRef.current;
130
- const container = containerRef.current;
131
- const containerWidth = container.offsetWidth;
132
- const containerHeight = container.offsetHeight;
133
-
134
- // Ensure container has dimensions before calculating
135
- if (containerWidth === 0 || containerHeight === 0) {
136
- // Try to get dimensions from computed styles or parent
137
- const parentWidth = container.parentElement?.clientWidth;
138
- const parentHeight = container.parentElement?.clientHeight;
139
-
140
- if (parentWidth && parentHeight && img.naturalWidth > 0) {
141
- const widthRatio = parentWidth / img.naturalWidth;
142
- const heightRatio = parentHeight / img.naturalHeight;
143
- const calculatedBaseScale = Math.max(widthRatio, heightRatio);
144
- setBaseScale(calculatedBaseScale);
145
- }
146
- return;
147
- }
148
-
149
- if (img.naturalWidth === 0 || img.naturalHeight === 0) return;
150
- setBaseScale(Math.max(containerWidth / img.naturalWidth, containerHeight / img.naturalHeight));
151
- }, []);
152
-
153
- useEffect(() => {
154
- if (!containerRef.current) return;
155
- const obs = new ResizeObserver(calculateBaseScale);
156
- obs.observe(containerRef.current);
157
- return () => obs.disconnect();
158
- }, [calculateBaseScale]);
159
-
160
71
  useEffect(() => {
161
72
  if (!isDragging) return;
162
73
  const move = (e: MouseEvent) => {
163
- if (!containerRef.current || !imageRef.current || baseScale === 0) return;
164
- const rect = containerRef.current.getBoundingClientRect();
165
-
166
- // Calculate movement in screen pixels
74
+ if (!containerRef.current) return;
167
75
  const mouseDeltaX = e.clientX - dragStart.x;
168
76
  const mouseDeltaY = e.clientY - dragStart.y;
169
77
 
170
- // Convert screen pixel movement to percentage of CONTAINER
171
- // Since the image is now width: 100%; height: 100%, percentages are container-relative
172
- // This makes position values consistent across different container sizes
173
- const dx = containerRef.current && containerRef.current.offsetWidth > 0
174
- ? (mouseDeltaX / containerRef.current.offsetWidth) * 100
175
- : 0;
176
- const dy = containerRef.current && containerRef.current.offsetHeight > 0
177
- ? (mouseDeltaY / containerRef.current.offsetHeight) * 100
178
- : 0;
78
+ const dx = (mouseDeltaX / containerRef.current.offsetWidth) * 100;
79
+ const dy = (mouseDeltaY / containerRef.current.offsetHeight) * 100;
179
80
 
180
- const newPositionX = dragStartPosition.x + dx;
181
- const newPositionY = dragStartPosition.y + dy;
182
- const clamped = getClampedPosition(newPositionX, newPositionY, scale);
183
- onPositionChange(clamped.x, clamped.y);
81
+ onPositionChange(dragStartPosition.x + dx, dragStartPosition.y + dy);
184
82
  };
185
83
  const up = () => setIsDragging(false);
186
84
  window.addEventListener('mousemove', move);
187
85
  window.addEventListener('mouseup', up);
188
86
  return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
189
- }, [isDragging, dragStart, dragStartPosition, scale, baseScale, getClampedPosition, onPositionChange]);
87
+ }, [isDragging, dragStart, dragStartPosition, onPositionChange]);
190
88
 
191
89
  const aspectValue = useMemo(() => {
192
90
  if (aspectRatio === 'auto') return undefined;
@@ -200,31 +98,26 @@ export const ImageEditor = forwardRef<ImageEditorHandle, ImageEditorProps>(({
200
98
  <div
201
99
  ref={containerRef}
202
100
  className={`relative shadow-2xl overflow-hidden ${borderRadius} bg-neutral-100 dark:bg-neutral-900 w-full max-w-full border border-neutral-200 dark:border-neutral-800`}
203
- style={{ aspectRatio: aspectValue, height: aspectValue && aspectValue < 1 ? '400px' : 'auto', width: aspectValue && aspectValue < 1 ? 'auto' : '100%', cursor: isDragging ? 'grabbing' : 'grab' }}
101
+ style={{
102
+ aspectRatio: aspectValue,
103
+ cursor: isDragging ? 'grabbing' : 'grab'
104
+ }}
204
105
  onMouseDown={(e) => {
205
106
  setIsDragging(true);
206
107
  setDragStart({ x: e.clientX, y: e.clientY });
207
- // Convert stored position to visual percentage for dragging
208
- // stored = visual, so we can use positionX directly as visual percentage
209
108
  setDragStartPosition({ x: positionX, y: positionY });
210
109
  e.preventDefault();
211
110
  }}
212
111
  >
213
112
  <img
214
- ref={imageRef} src={imageUrl} alt="Editor" onLoad={calculateBaseScale}
215
- className="absolute max-w-none select-none"
113
+ src={imageUrl}
114
+ alt="Editor"
115
+ className="w-full h-full object-cover select-none pointer-events-none"
216
116
  style={{
217
- top: '50%',
218
- left: '50%',
219
- width: 'auto', // Allow image to maintain natural aspect ratio
220
- height: 'auto', // Allow image to maintain natural aspect ratio
221
- minWidth: '100%', // Ensure image covers container width
222
- minHeight: '100%', // Ensure image covers container height
223
117
  filter: getImageFilter(localBrightness, localBlur),
224
- transform: baseScale > 0 ? getImageTransform({ scale, positionX, positionY, baseScale }, true) : 'translate(-50%, -50%)',
118
+ transform: `translate(${positionX}%, ${positionY}%) scale(${scale})`,
225
119
  transformOrigin: 'center center'
226
120
  }}
227
- draggable={false}
228
121
  />
229
122
  </div>
230
123
  </div>
@@ -233,16 +126,10 @@ export const ImageEditor = forwardRef<ImageEditorHandle, ImageEditorProps>(({
233
126
  <div className="p-6 bg-white dark:bg-neutral-900 rounded-3xl border border-neutral-200 dark:border-neutral-800 shadow-lg dark:shadow-neutral-950/50 space-y-6">
234
127
  <div>
235
128
  <div className="flex justify-between items-center mb-4">
236
- <label className="text-[10px] font-black uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 flex items-center gap-2"><ZoomIn size={14} className="text-neutral-600 dark:text-neutral-300" /> Zoom Level</label>
129
+ <label className="text-[10px] font-black uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 flex items-center gap-2">Zoom Level</label>
237
130
  <div className="flex items-center gap-2">
238
- <span className="text-xs font-mono font-bold text-primary dark:text-primary">{Math.round(scale * 100)}%</span>
239
- <button
240
- onClick={() => handleScaleChange(1)}
241
- className="p-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
242
- title="Reset zoom"
243
- >
244
- <RotateCcw size={12} className="text-neutral-500 dark:text-neutral-400" />
245
- </button>
131
+ <span className="text-xs font-mono font-bold text-primary">{Math.round(scale * 100)}%</span>
132
+ <button onClick={() => handleScaleChange(1)} className="p-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg"><RotateCcw size={12} /></button>
246
133
  </div>
247
134
  </div>
248
135
  <input type="range" min="0.1" max="5" step="0.01" value={scale} onChange={(e) => handleScaleChange(parseFloat(e.target.value))} className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-primary" />
@@ -250,74 +137,25 @@ export const ImageEditor = forwardRef<ImageEditorHandle, ImageEditorProps>(({
250
137
 
251
138
  {onBrightnessChange && (
252
139
  <div>
253
- <div className="flex justify-between items-center mb-4">
254
- <label className="text-[10px] font-black uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 flex items-center gap-2"><Sun size={14} className="text-neutral-600 dark:text-neutral-300" /> Brightness</label>
255
- <div className="flex items-center gap-2">
256
- <span className="text-xs font-mono font-bold text-primary dark:text-primary">{Math.round(localBrightness)}%</span>
257
- <button
258
- onClick={() => setLocalBrightness(100)}
259
- className="p-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
260
- title="Reset brightness"
261
- >
262
- <RotateCcw size={12} className="text-neutral-500 dark:text-neutral-400" />
263
- </button>
264
- </div>
265
- </div>
266
- <input
267
- type="range"
268
- min="0"
269
- max="200"
270
- step="1"
271
- value={localBrightness}
272
- onChange={(e) => setLocalBrightness(parseInt(e.target.value))}
273
- className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-primary"
274
- />
140
+ <label className="text-[10px] font-black uppercase tracking-[0.2em] text-neutral-500 mb-4 block">Brightness</label>
141
+ <input type="range" min="0" max="200" step="1" value={localBrightness} onChange={(e) => setLocalBrightness(parseInt(e.target.value))} className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-primary" />
275
142
  </div>
276
143
  )}
277
144
 
278
145
  {onBlurChange && (
279
146
  <div>
280
- <div className="flex justify-between items-center mb-4">
281
- <label className="text-[10px] font-black uppercase tracking-[0.2em] text-neutral-500 dark:text-neutral-400 flex items-center gap-2"><Droplets size={14} className="text-neutral-600 dark:text-neutral-300" /> Blur</label>
282
- <div className="flex items-center gap-2">
283
- <span className="text-xs font-mono font-bold text-primary dark:text-primary">{localBlur}px</span>
284
- <button
285
- onClick={() => setLocalBlur(0)}
286
- className="p-1.5 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
287
- title="Reset blur"
288
- >
289
- <RotateCcw size={12} className="text-neutral-500 dark:text-neutral-400" />
290
- </button>
291
- </div>
292
- </div>
293
- <input
294
- type="range"
295
- min="0"
296
- max="20"
297
- step="0.5"
298
- value={localBlur}
299
- onChange={(e) => setLocalBlur(parseFloat(e.target.value))}
300
- className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-primary"
301
- />
147
+ <label className="text-[10px] font-black uppercase tracking-[0.2em] text-neutral-500 mb-4 block">Blur</label>
148
+ <input type="range" min="0" max="20" step="0.5" value={localBlur} onChange={(e) => setLocalBlur(parseFloat(e.target.value))} className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-primary" />
302
149
  </div>
303
150
  )}
304
151
 
305
152
  <div className="grid grid-cols-2 gap-3">
306
- <button onClick={() => onPositionChange(0, 0)} className="flex flex-col items-center gap-2 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-2xl hover:bg-primary/10 dark:hover:bg-primary/20 transition-all border border-transparent hover:border-primary/20 dark:hover:border-primary/30">
307
- <Maximize2 size={16} className="text-neutral-700 dark:text-neutral-300" /><span className="text-[9px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">Center</span>
308
- </button>
309
- <button onClick={() => {
310
- handleScaleChange(1);
311
- if (onBrightnessChange) setLocalBrightness(100);
312
- if (onBlurChange) setLocalBlur(0);
313
- onPositionChange(0, 0);
314
- }} className="flex flex-col items-center gap-2 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-2xl hover:bg-primary/10 dark:hover:bg-primary/20 transition-all border border-transparent hover:border-primary/20 dark:hover:border-primary/30">
315
- <RotateCcw size={16} className="text-neutral-700 dark:text-neutral-300" /><span className="text-[9px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400">Reset All</span>
316
- </button>
153
+ <button onClick={() => onPositionChange(0, 0)} className="flex flex-col items-center gap-2 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-2xl border border-transparent hover:border-primary/20"><Maximize2 size={16} /><span className="text-[9px] font-black uppercase tracking-widest">Center</span></button>
154
+ <button onClick={() => { handleScaleChange(1); setLocalBrightness(100); setLocalBlur(0); onPositionChange(0, 0); }} className="flex flex-col items-center gap-2 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-2xl border border-transparent hover:border-primary/20"><RotateCcw size={16} /><span className="text-[9px] font-black uppercase tracking-widest">Reset</span></button>
317
155
  </div>
318
156
  </div>
319
157
  </div>
320
158
  </div>
321
159
  );
322
160
  });
323
- ImageEditor.displayName = 'ImageEditor';
161
+ ImageEditor.displayName = 'ImageEditor';
@@ -1,14 +1,13 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
3
+ import React, { useState, useEffect, useMemo, useRef } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { Search, Image as ImageIcon, Settings } from 'lucide-react';
6
6
  import type { ImagePickerProps } from '../types';
7
- import type { ImageMetadata } from '../types';
8
7
  import { ImageEditor, type ImageEditorHandle } from './ImageEditor';
9
8
  import { ImageBrowserModal } from './ImageBrowserModal';
10
9
  import { useImagePicker } from '../hooks/useImagePicker';
11
- import { getImageTransform, getImageFilter } from '../utils/transforms';
10
+ import { getImageFilter } from '../utils/transforms';
12
11
  import { getFallbackImageUrl } from '../utils/fallback';
13
12
 
14
13
  export function ImagePicker({
@@ -20,29 +19,21 @@ export function ImagePicker({
20
19
  const [transforms, setTransforms] = useState({ scale: scale >= 0.1 ? scale : 1.0, positionX, positionY });
21
20
  const [isEditorOpen, setIsEditorOpen] = useState(false);
22
21
  const [isBrowserOpen, setIsBrowserOpen] = useState(false);
23
- const [previewBaseScale, setPreviewBaseScale] = useState<number | null>(null);
24
22
  const [previewImageError, setPreviewImageError] = useState(false);
25
23
  const [mounted, setMounted] = useState(false);
26
24
  const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
27
- const previewImageRef = useRef<HTMLImageElement>(null);
28
25
  const previewContainerRef = useRef<HTMLDivElement>(null);
29
26
  const editorRef = useRef<ImageEditorHandle | null>(null);
30
27
 
31
28
  const { selectedImage, setSelectedImage } = useImagePicker({ value, images: [] });
32
29
 
33
- // Reset preview error when selected image changes
34
30
  useEffect(() => {
35
31
  setPreviewImageError(false);
36
32
  }, [selectedImage?.id, selectedImage?.url]);
37
33
 
38
- // Handle SSR & portal target - ensure we only render portal on client
39
34
  useEffect(() => {
40
35
  setMounted(true);
41
-
42
36
  if (typeof document === 'undefined') return;
43
-
44
- // If we're inside the GlobalImageEditor overlay, portal into that instead of document.body
45
- // This ensures the browser/editor modals share the same stacking context
46
37
  const editorContainer = previewContainerRef.current?.closest('[data-image-editor="true"]') as HTMLElement | null;
47
38
  if (editorContainer) {
48
39
  setPortalTarget(editorContainer);
@@ -51,19 +42,15 @@ export function ImagePicker({
51
42
  }
52
43
  }, []);
53
44
 
54
- // Auto-open editor if requested (e.g., when opening from edit button)
55
- // Only auto-open once when the prop first becomes true, not on every render
56
45
  const hasAutoOpenedRef = useRef(false);
57
46
  useEffect(() => {
58
47
  if (autoOpenEditor && selectedImage && !isEditorOpen && !hasAutoOpenedRef.current) {
59
- // Small delay to ensure ImagePicker is fully rendered
60
48
  const timer = setTimeout(() => {
61
49
  setIsEditorOpen(true);
62
50
  hasAutoOpenedRef.current = true;
63
51
  }, 100);
64
52
  return () => clearTimeout(timer);
65
53
  }
66
- // Reset the flag when autoOpenEditor becomes false
67
54
  if (!autoOpenEditor) {
68
55
  hasAutoOpenedRef.current = false;
69
56
  }
@@ -73,55 +60,26 @@ export function ImagePicker({
73
60
  if (!isEditorOpen) setTransforms({ scale: scale >= 0.1 ? scale : 1.0, positionX, positionY });
74
61
  }, [scale, positionX, positionY, isEditorOpen]);
75
62
 
76
- const calculatePreviewBaseScale = useCallback(() => {
77
- if (!previewImageRef.current || !previewContainerRef.current) return;
78
- const fit = Math.max(previewContainerRef.current.offsetWidth / previewImageRef.current.naturalWidth,
79
- previewContainerRef.current.offsetHeight / previewImageRef.current.naturalHeight);
80
- setPreviewBaseScale(fit);
81
- }, []);
82
-
83
- // Handle editor save - delegate to parent callbacks instead of saving directly
84
- // This ensures all saves go through a single location (GlobalImageEditor.saveImageTransform)
85
63
  const handleEditorSave = async () => {
86
64
  if (!editorRef.current || !selectedImage) return;
87
-
88
65
  try {
89
- // 1. Get current values from Editor UI (this will also call onBrightnessChange and onBlurChange)
90
66
  const final = await editorRef.current.flushSave();
91
-
92
- // 2. Normalize position values - if they're -50% (centering value), treat as 0
93
67
  const normalizedPositionX = final.positionX === -50 ? 0 : final.positionX;
94
68
  const normalizedPositionY = final.positionY === -50 ? 0 : final.positionY;
95
69
 
96
- // 3. If onEditorSave is provided, use it exclusively to prevent duplicate saves
97
- // Otherwise, update parent state through individual callbacks
98
- console.log('[ImagePicker] handleEditorSave - final values:', final, 'has onEditorSave:', !!onEditorSave);
99
70
  if (onEditorSave) {
100
- // onEditorSave handles everything - don't call individual handlers to avoid duplicates
101
- console.log('[ImagePicker] Calling onEditorSave with:', { scale: final.scale, positionX: normalizedPositionX, positionY: normalizedPositionY, brightness: final.brightness, blur: final.blur });
102
71
  onEditorSave(final.scale, normalizedPositionX, normalizedPositionY, final.brightness, final.blur);
103
72
  } else {
104
- // Fallback: update parent state through individual callbacks
105
- // Since onEditorSave is not provided, call all callbacks and they should handle saving
106
- // The last one (onPositionYChange) will trigger the final save
107
- console.log('[ImagePicker] No onEditorSave, using individual callbacks');
108
- // Update scale first
109
73
  onScaleChange?.(final.scale);
110
- // Update positions - these will trigger saves
111
74
  onPositionXChange?.(normalizedPositionX);
112
- // Last callback - this should trigger the final save with all values
113
- // We need to ensure this saves immediately with all final values including brightness/blur
114
75
  onPositionYChange?.(normalizedPositionY);
115
76
  }
116
-
117
- // 5. Close the editor
118
77
  setIsEditorOpen(false);
119
78
  } catch (error) {
120
79
  console.error('[ImagePicker] Failed to get editor values:', error);
121
80
  }
122
81
  };
123
82
 
124
-
125
83
  const aspectValue = useMemo(() => {
126
84
  if (aspectRatio === 'auto') return undefined;
127
85
  const [w, h] = aspectRatio.split('/').map(Number);
@@ -134,38 +92,18 @@ export function ImagePicker({
134
92
  <div className="relative group max-w-md mx-auto">
135
93
  <div className={`relative ${borderRadius} overflow-hidden border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 shadow-lg transition-all duration-300 hover:shadow-xl`} style={{ aspectRatio: aspectValue, width: '100%' }}>
136
94
  <div ref={previewContainerRef} className="relative w-full h-full overflow-hidden">
137
- {previewImageError ? (
138
- <img
139
- src={getFallbackImageUrl()}
140
- alt={selectedImage.filename || 'Image not found'}
141
- className="absolute max-w-none"
142
- style={{
143
- top: '50%', left: '50%', width: 'auto', height: 'auto',
144
- minWidth: '100%', minHeight: '100%',
145
- transform: 'translate(-50%, -50%)',
146
- transformOrigin: 'center center',
147
- objectFit: 'cover',
148
- }}
149
- />
150
- ) : (
151
- <img
152
- ref={previewImageRef}
153
- src={selectedImage.url}
154
- alt={selectedImage.filename}
155
- className="absolute max-w-none"
156
- onLoad={calculatePreviewBaseScale}
157
- onError={() => setPreviewImageError(true)}
158
- style={{
159
- top: '50%', left: '50%', width: 'auto', height: 'auto',
160
- minWidth: '100%', minHeight: '100%',
161
- filter: getImageFilter(brightness, blur),
162
- transform: previewBaseScale ? getImageTransform({ scale: transforms.scale, positionX: transforms.positionX, positionY: transforms.positionY, baseScale: previewBaseScale }, true) : 'translate(-50%, -50%)',
163
- transformOrigin: 'center center',
164
- }}
165
- />
166
- )}
95
+ <img
96
+ src={previewImageError ? getFallbackImageUrl() : selectedImage.url}
97
+ alt={selectedImage.filename}
98
+ onError={() => setPreviewImageError(true)}
99
+ className="w-full h-full object-cover"
100
+ style={{
101
+ filter: getImageFilter(brightness, blur),
102
+ transform: `translate(${transforms.positionX}%, ${transforms.positionY}%) scale(${transforms.scale})`,
103
+ transformOrigin: 'center center',
104
+ }}
105
+ />
167
106
  </div>
168
- {/* Overlay on hover */}
169
107
  <div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors duration-300 pointer-events-none" />
170
108
  </div>
171
109
  </div>
@@ -186,22 +124,20 @@ export function ImagePicker({
186
124
  onClick={() => setIsBrowserOpen(true)}
187
125
  className="flex items-center justify-center gap-2 px-5 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl text-sm font-semibold text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-750 hover:border-neutral-300 dark:hover:border-neutral-600 transition-all duration-200 shadow-sm hover:shadow-md active:scale-[0.98]"
188
126
  >
189
- <Search size={16} />
190
- Browse
127
+ <Search size={16} /> Browse
191
128
  </button>
192
129
  <button
193
130
  onClick={() => setIsEditorOpen(true)}
194
131
  disabled={!selectedImage}
195
- className="flex items-center justify-center gap-2 px-5 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl text-sm font-semibold text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-750 hover:border-neutral-300 dark:hover:border-neutral-600 transition-all duration-200 shadow-sm hover:shadow-md active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:shadow-sm disabled:hover:scale-100"
132
+ className="flex items-center justify-center gap-2 px-5 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl text-sm font-semibold text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-750 hover:border-neutral-300 dark:hover:border-neutral-600 transition-all duration-200 shadow-sm hover:shadow-md active:scale-[0.98] disabled:opacity-40"
196
133
  >
197
- <Settings size={16} />
198
- Edit
134
+ <Settings size={16} /> Edit
199
135
  </button>
200
136
  </div>
201
137
 
202
138
  {isEditorOpen && selectedImage && mounted && portalTarget && createPortal(
203
139
  <div className="fixed inset-0 z-[200] flex items-center justify-center bg-neutral-950/80 dark:bg-neutral-950/90 backdrop-blur-md p-4 animate-in fade-in duration-200">
204
- <div className="w-full max-w-4xl bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden shadow-2xl dark:shadow-neutral-950/50 flex flex-col max-h-[85vh] border border-neutral-200 dark:border-neutral-800 animate-in zoom-in-95 duration-300">
140
+ <div className="w-full max-w-4xl bg-white dark:bg-neutral-900 rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh] border border-neutral-200 dark:border-neutral-800 animate-in zoom-in-95 duration-300">
205
141
  <div className="flex-1 overflow-hidden p-4 lg:p-6">
206
142
  <ImageEditor
207
143
  ref={editorRef}
@@ -212,11 +148,7 @@ export function ImagePicker({
212
148
  brightness={brightness}
213
149
  blur={blur}
214
150
  onScaleChange={(s) => setTransforms(t => ({ ...t, scale: s }))}
215
- onPositionChange={(x, y) => {
216
- // Only update local state during drag - don't trigger saves
217
- // Saves will happen when editor closes via onEditorSave
218
- setTransforms(t => ({ ...t, positionX: x, positionY: y }));
219
- }}
151
+ onPositionChange={(x, y) => setTransforms(t => ({ ...t, positionX: x, positionY: y }))}
220
152
  onBrightnessChange={onBrightnessChange}
221
153
  onBlurChange={onBlurChange}
222
154
  aspectRatio={aspectRatio}
@@ -224,18 +156,8 @@ export function ImagePicker({
224
156
  />
225
157
  </div>
226
158
  <div className="px-6 py-5 border-t border-neutral-200 dark:border-neutral-800 bg-neutral-50/80 dark:bg-neutral-900/80 backdrop-blur-sm flex justify-end gap-3">
227
- <button
228
- onClick={() => setIsEditorOpen(false)}
229
- className="px-6 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl font-semibold text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-all duration-200 shadow-sm hover:shadow-md active:scale-[0.98]"
230
- >
231
- Cancel
232
- </button>
233
- <button
234
- onClick={handleEditorSave}
235
- className="px-8 py-2.5 bg-primary text-white rounded-xl font-semibold shadow-md hover:shadow-lg hover:bg-primary/90 transition-all duration-200 active:scale-[0.98]"
236
- >
237
- Done
238
- </button>
159
+ <button onClick={() => setIsEditorOpen(false)} className="px-6 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl font-semibold text-neutral-700 dark:text-neutral-200">Cancel</button>
160
+ <button onClick={handleEditorSave} className="px-8 py-2.5 bg-primary text-white rounded-xl font-semibold shadow-md">Done</button>
239
161
  </div>
240
162
  </div>
241
163
  </div>,
@@ -250,16 +172,9 @@ export function ImagePicker({
250
172
  onChange?.(image);
251
173
  setIsBrowserOpen(false);
252
174
  }}
253
- selectedImageId={(() => {
254
- // Use resolved image's filename/URL if available, otherwise fall back to value
255
- // This ensures semantic IDs are resolved to actual filenames for matching
256
- if (selectedImage) {
257
- return selectedImage.filename || selectedImage.url || selectedImage.id || value;
258
- }
259
- return value;
260
- })()}
175
+ selectedImageId={selectedImage?.filename || selectedImage?.url || selectedImage?.id || value}
261
176
  darkMode={false}
262
177
  />
263
178
  </div>
264
179
  );
265
- }
180
+ }