@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.
- package/dist/api/list/index.d.ts +18 -0
- package/dist/api/list/index.d.ts.map +1 -1
- package/dist/api/list/index.js +121 -20
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +7 -0
- package/dist/api/usage/route.d.ts +23 -0
- package/dist/api/usage/route.d.ts.map +1 -0
- package/dist/api/usage/route.js +238 -0
- package/dist/components/BackgroundImage.d.ts.map +1 -1
- package/dist/components/BackgroundImage.js +5 -17
- package/dist/components/GlobalImageEditor.d.ts.map +1 -1
- package/dist/components/GlobalImageEditor.js +9 -4
- package/dist/components/Image.d.ts +3 -6
- package/dist/components/Image.d.ts.map +1 -1
- package/dist/components/Image.js +103 -206
- package/dist/components/ImageEditor.d.ts.map +1 -1
- package/dist/components/ImageEditor.js +21 -125
- package/dist/components/ImagePicker.d.ts.map +1 -1
- package/dist/components/ImagePicker.js +6 -59
- package/dist/utils/fallback.d.ts +9 -4
- package/dist/utils/fallback.d.ts.map +1 -1
- package/dist/utils/fallback.js +40 -12
- package/dist/utils/transforms.d.ts.map +1 -1
- package/dist/utils/transforms.js +7 -10
- package/dist/views/ImageManager/components/CleanupLibraryModal.d.ts +12 -0
- package/dist/views/ImageManager/components/CleanupLibraryModal.d.ts.map +1 -0
- package/dist/views/ImageManager/components/CleanupLibraryModal.js +7 -0
- package/dist/views/ImageManager/components/DeleteImageModal.d.ts +15 -0
- package/dist/views/ImageManager/components/DeleteImageModal.d.ts.map +1 -0
- package/dist/views/ImageManager/components/DeleteImageModal.js +8 -0
- package/dist/views/ImageManager/components/ImageGrid.d.ts +12 -0
- package/dist/views/ImageManager/components/ImageGrid.d.ts.map +1 -0
- package/dist/views/ImageManager/components/ImageGrid.js +15 -0
- package/dist/views/ImageManager/components/ImageManagerHeader.d.ts +11 -0
- package/dist/views/ImageManager/components/ImageManagerHeader.d.ts.map +1 -0
- package/dist/views/ImageManager/components/ImageManagerHeader.js +6 -0
- package/dist/views/ImageManager/components/ImageManagerStats.d.ts +8 -0
- package/dist/views/ImageManager/components/ImageManagerStats.d.ts.map +1 -0
- package/dist/views/ImageManager/components/ImageManagerStats.js +6 -0
- package/dist/views/ImageManager/components/ImageManagerToolbar.d.ts +9 -0
- package/dist/views/ImageManager/components/ImageManagerToolbar.d.ts.map +1 -0
- package/dist/views/ImageManager/components/ImageManagerToolbar.js +10 -0
- package/dist/views/ImageManager/components/ImageTable.d.ts +13 -0
- package/dist/views/ImageManager/components/ImageTable.d.ts.map +1 -0
- package/dist/views/ImageManager/components/ImageTable.js +13 -0
- package/dist/views/ImageManager/types.d.ts +26 -0
- package/dist/views/ImageManager/types.d.ts.map +1 -0
- package/dist/views/ImageManager/types.js +1 -0
- package/dist/views/ImageManager.d.ts +1 -1
- package/dist/views/ImageManager.d.ts.map +1 -1
- package/dist/views/ImageManager.js +206 -2
- package/package.json +10 -9
- package/src/api/list/index.ts +147 -22
- package/src/api/router.ts +8 -0
- package/src/api/usage/route.ts +294 -0
- package/src/components/BackgroundImage.tsx +5 -15
- package/src/components/GlobalImageEditor.tsx +9 -4
- package/src/components/Image.tsx +128 -268
- package/src/components/ImageEditor.tsx +31 -193
- package/src/components/ImagePicker.tsx +22 -107
- package/src/utils/fallback.ts +46 -13
- package/src/utils/transforms.ts +9 -12
- package/src/views/ImageManager/components/CleanupLibraryModal.tsx +96 -0
- package/src/views/ImageManager/components/DeleteImageModal.tsx +144 -0
- package/src/views/ImageManager/components/ImageGrid.tsx +119 -0
- package/src/views/ImageManager/components/ImageManagerHeader.tsx +72 -0
- package/src/views/ImageManager/components/ImageManagerStats.tsx +60 -0
- package/src/views/ImageManager/components/ImageManagerToolbar.tsx +60 -0
- package/src/views/ImageManager/components/ImageTable.tsx +120 -0
- package/src/views/ImageManager/types.ts +27 -0
- package/src/views/ImageManager.tsx +307 -12
- package/src/components/BackgroundImage.d.ts +0 -11
- package/src/components/BackgroundImage.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/config.d.ts +0 -9
- package/src/components/GlobalImageEditor/config.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/eventHandlers.d.ts +0 -20
- package/src/components/GlobalImageEditor/eventHandlers.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/imageDetection.d.ts +0 -16
- package/src/components/GlobalImageEditor/imageDetection.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/imageSetup.d.ts +0 -9
- package/src/components/GlobalImageEditor/imageSetup.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/saveLogic.d.ts +0 -26
- package/src/components/GlobalImageEditor/saveLogic.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/stylingDetection.d.ts +0 -9
- package/src/components/GlobalImageEditor/stylingDetection.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/transformParsing.d.ts +0 -16
- package/src/components/GlobalImageEditor/transformParsing.d.ts.map +0 -1
- package/src/components/GlobalImageEditor/types.d.ts +0 -36
- package/src/components/GlobalImageEditor/types.d.ts.map +0 -1
- package/src/components/GlobalImageEditor.d.ts +0 -8
- package/src/components/GlobalImageEditor.d.ts.map +0 -1
- package/src/components/Image.d.ts +0 -22
- package/src/components/Image.d.ts.map +0 -1
- package/src/components/ImageBrowserModal.d.ts +0 -13
- package/src/components/ImageBrowserModal.d.ts.map +0 -1
- package/src/components/ImageEditor.d.ts +0 -27
- package/src/components/ImageEditor.d.ts.map +0 -1
- package/src/components/ImagePicker.d.ts +0 -3
- package/src/components/ImagePicker.d.ts.map +0 -1
- package/src/components/ImagesPluginInit.d.ts +0 -24
- package/src/components/ImagesPluginInit.d.ts.map +0 -1
- package/src/hooks/useImagePicker.d.ts +0 -20
- package/src/hooks/useImagePicker.d.ts.map +0 -1
- package/src/types/index.d.ts +0 -80
- package/src/types/index.d.ts.map +0 -1
- package/src/utils/fallback.d.ts +0 -27
- package/src/utils/fallback.d.ts.map +0 -1
- package/src/utils/transforms.d.ts +0 -26
- package/src/utils/transforms.d.ts.map +0 -1
- package/src/views/ImageManager.d.ts +0 -10
- 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,
|
|
5
|
-
import {
|
|
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,
|
|
91
|
-
positionX:
|
|
92
|
-
positionY:
|
|
93
|
-
brightness:
|
|
94
|
-
blur:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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,
|
|
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={{
|
|
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
|
-
|
|
215
|
-
|
|
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:
|
|
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"
|
|
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
|
|
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
|
-
<
|
|
254
|
-
|
|
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
|
-
<
|
|
281
|
-
|
|
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
|
|
307
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|