@jhits/plugin-images 0.0.4 → 0.0.6

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.
@@ -3,5 +3,8 @@
3
3
  */
4
4
 
5
5
  export { ImagePicker } from './ImagePicker';
6
+ export { ImageEditor } from './ImageEditor';
7
+ export { ImageEffectsPanel } from './ImageEffectsPanel';
8
+ export { ImageBrowserModal } from './ImageBrowserModal';
6
9
  export type { ImagePickerProps } from '../types';
7
10
 
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Hook for Image Picker Logic
3
+ */
4
+
5
+ import { useState, useEffect, useRef } from 'react';
6
+ import type { ImageMetadata } from '../types';
7
+
8
+ export interface UseImagePickerOptions {
9
+ value?: string;
10
+ images: ImageMetadata[];
11
+ }
12
+
13
+ export function useImagePicker({ value, images }: UseImagePickerOptions) {
14
+ const [selectedImage, setSelectedImage] = useState<ImageMetadata | null>(null);
15
+ const [uploading, setUploading] = useState(false);
16
+ const fileInputRef = useRef<HTMLInputElement>(null);
17
+ const lastResolvedValueRef = useRef<string | undefined>(undefined);
18
+ const isResolvingRef = useRef(false);
19
+ const lastImagesRef = useRef<string>('');
20
+
21
+ // Find selected image from value (can be ID or URL)
22
+ useEffect(() => {
23
+ // Create a stable reference for images array to detect actual changes
24
+ const imagesKey = JSON.stringify(images.map(img => ({ id: img.id, url: img.url })));
25
+
26
+ // Prevent infinite loops by checking if we're already resolving or if value and images haven't changed
27
+ if (isResolvingRef.current || (lastResolvedValueRef.current === value && lastImagesRef.current === imagesKey)) {
28
+ return;
29
+ }
30
+
31
+ lastImagesRef.current = imagesKey;
32
+
33
+ if (!value) {
34
+ if (selectedImage !== null) {
35
+ setSelectedImage(null);
36
+ }
37
+ lastResolvedValueRef.current = value;
38
+ return;
39
+ }
40
+
41
+ isResolvingRef.current = true;
42
+ const resolveImage = async () => {
43
+ // Normalize the value - extract filename if it's a URL
44
+ const isFullUrl = value.startsWith('http://') || value.startsWith('https://');
45
+ const isRelativeUrl = value.startsWith('/');
46
+ const isUrl = isFullUrl || isRelativeUrl;
47
+
48
+ // Extract filename from URL if it's a URL
49
+ let filenameFromUrl: string | null = null;
50
+ let imageIdToResolve = value;
51
+
52
+ if (isUrl) {
53
+ const urlParts = value.split('/');
54
+ filenameFromUrl = urlParts[urlParts.length - 1]?.split('?')[0] || null;
55
+ // If it's a full URL, try to resolve using the filename instead
56
+ if (isFullUrl && filenameFromUrl) {
57
+ imageIdToResolve = filenameFromUrl;
58
+ }
59
+ }
60
+
61
+ // First, try to find by ID (preferred method)
62
+ let found = images.find(img => img.id === value || img.id === imageIdToResolve);
63
+
64
+ // If not found by ID, try to find by URL
65
+ if (!found) {
66
+ found = images.find(img => img.url === value);
67
+ }
68
+
69
+ // If still not found, try to match by filename extracted from URL
70
+ if (!found && filenameFromUrl) {
71
+ found = images.find(img => img.id === filenameFromUrl || img.filename === filenameFromUrl);
72
+ }
73
+
74
+ if (found) {
75
+ // Only update if the image is actually different
76
+ setSelectedImage(prev => {
77
+ if (prev?.id === found.id && prev?.url === found.url) {
78
+ return prev;
79
+ }
80
+ return found;
81
+ });
82
+ } else {
83
+ // If value is already a full URL, use it directly
84
+ if (isFullUrl) {
85
+ const newImage = {
86
+ id: value,
87
+ filename: filenameFromUrl || value,
88
+ url: value, // Use the full URL as-is
89
+ size: 0,
90
+ mimeType: 'image/jpeg',
91
+ uploadedAt: new Date().toISOString(),
92
+ };
93
+ setSelectedImage(prev => {
94
+ if (prev?.id === newImage.id && prev?.url === newImage.url) {
95
+ return prev;
96
+ }
97
+ return newImage;
98
+ });
99
+ lastResolvedValueRef.current = value;
100
+ isResolvingRef.current = false;
101
+ return;
102
+ }
103
+
104
+ // If value is a relative URL, use it directly
105
+ if (isRelativeUrl) {
106
+ const newImage = {
107
+ id: value,
108
+ filename: filenameFromUrl || value,
109
+ url: value, // Use the relative URL as-is
110
+ size: 0,
111
+ mimeType: 'image/jpeg',
112
+ uploadedAt: new Date().toISOString(),
113
+ };
114
+ setSelectedImage(prev => {
115
+ if (prev?.id === newImage.id && prev?.url === newImage.url) {
116
+ return prev;
117
+ }
118
+ return newImage;
119
+ });
120
+ lastResolvedValueRef.current = value;
121
+ isResolvingRef.current = false;
122
+ return;
123
+ }
124
+
125
+ // Check if value looks like a filename (has extension or starts with digits)
126
+ const isLikelyPath = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(value) || /^\d+-/.test(value);
127
+
128
+ if (isLikelyPath) {
129
+ // It's likely a filename, create image object directly
130
+ const newImage = {
131
+ id: value,
132
+ filename: value,
133
+ url: `/api/uploads/${value}`,
134
+ size: 0,
135
+ mimeType: 'image/jpeg',
136
+ uploadedAt: new Date().toISOString(),
137
+ };
138
+ setSelectedImage(prev => {
139
+ if (prev?.id === newImage.id && prev?.url === newImage.url) {
140
+ return prev;
141
+ }
142
+ return newImage;
143
+ });
144
+ lastResolvedValueRef.current = value;
145
+ isResolvingRef.current = false;
146
+ } else {
147
+ // It might be a semantic ID, try to resolve via API
148
+ // Use the extracted ID (filename) if value was a URL, otherwise use value
149
+ try {
150
+ const response = await fetch(`/api/plugin-images/resolve?id=${encodeURIComponent(imageIdToResolve)}`);
151
+ if (response.ok) {
152
+ const data = await response.json();
153
+ // Normalize the URL - ensure it's a proper URL
154
+ let imageUrl: string;
155
+ if (data.url) {
156
+ // If API returns a URL, use it (but check if it's already a full URL)
157
+ if (data.url.startsWith('http://') || data.url.startsWith('https://')) {
158
+ imageUrl = data.url;
159
+ } else if (data.url.startsWith('/')) {
160
+ imageUrl = data.url;
161
+ } else {
162
+ // Relative path without leading slash
163
+ imageUrl = `/api/uploads/${data.url}`;
164
+ }
165
+ } else if (data.filename) {
166
+ // Use filename to construct URL
167
+ imageUrl = `/api/uploads/${data.filename}`;
168
+ } else {
169
+ // Fallback: if original value was a URL, use it; otherwise construct
170
+ imageUrl = isFullUrl ? value : (isRelativeUrl ? value : `/api/uploads/${value}`);
171
+ }
172
+
173
+ const newImage = {
174
+ id: value,
175
+ filename: data.filename || filenameFromUrl || value,
176
+ url: imageUrl,
177
+ size: 0,
178
+ mimeType: 'image/jpeg',
179
+ uploadedAt: new Date().toISOString(),
180
+ };
181
+ setSelectedImage(prev => {
182
+ if (prev?.id === newImage.id && prev?.url === newImage.url) {
183
+ return prev;
184
+ }
185
+ return newImage;
186
+ });
187
+ lastResolvedValueRef.current = value;
188
+ isResolvingRef.current = false;
189
+ } else {
190
+ // API resolution failed - check if it's a semantic ID or actual filename
191
+ const isLikelyFilename = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(imageIdToResolve) || /^\d+-/.test(imageIdToResolve);
192
+
193
+ if (!isLikelyFilename) {
194
+ // It's a semantic ID that couldn't be resolved - clear selection
195
+ // This prevents showing broken URLs like /api/uploads/blog-featured-...
196
+ setSelectedImage(null);
197
+ lastResolvedValueRef.current = value;
198
+ isResolvingRef.current = false;
199
+ return;
200
+ }
201
+
202
+ // It's a filename, create fallback object
203
+ const isFullUrl = value.startsWith('http://') || value.startsWith('https://');
204
+ const isRelativeUrl = value.startsWith('/');
205
+
206
+ let imageUrl: string;
207
+ if (isFullUrl) {
208
+ // Already a full URL, use as-is
209
+ imageUrl = value;
210
+ } else if (isRelativeUrl) {
211
+ // Already a relative URL starting with /, use as-is
212
+ imageUrl = value;
213
+ } else {
214
+ // It's a filename, construct the URL
215
+ imageUrl = `/api/uploads/${value}`;
216
+ }
217
+
218
+ const urlParts = value.split('/');
219
+ const filename = urlParts[urlParts.length - 1]?.split('?')[0] || value;
220
+
221
+ const newImage = {
222
+ id: value,
223
+ filename: filename,
224
+ url: imageUrl,
225
+ size: 0,
226
+ mimeType: 'image/jpeg',
227
+ uploadedAt: new Date().toISOString(),
228
+ };
229
+ setSelectedImage(prev => {
230
+ if (prev?.id === newImage.id && prev?.url === newImage.url) {
231
+ return prev;
232
+ }
233
+ return newImage;
234
+ });
235
+ lastResolvedValueRef.current = value;
236
+ isResolvingRef.current = false;
237
+ }
238
+ } catch (error) {
239
+ // API call failed - check if it's a semantic ID or actual filename
240
+ const isLikelyFilename = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(imageIdToResolve) || /^\d+-/.test(imageIdToResolve);
241
+
242
+ if (!isLikelyFilename) {
243
+ // It's a semantic ID that couldn't be resolved - clear selection
244
+ // This prevents showing broken URLs like /api/uploads/blog-featured-...
245
+ setSelectedImage(null);
246
+ lastResolvedValueRef.current = value;
247
+ isResolvingRef.current = false;
248
+ return;
249
+ }
250
+
251
+ // It's a filename, create fallback object
252
+ const isFullUrl = value.startsWith('http://') || value.startsWith('https://');
253
+ const isRelativeUrl = value.startsWith('/');
254
+
255
+ let imageUrl: string;
256
+ if (isFullUrl) {
257
+ // Already a full URL, use as-is
258
+ imageUrl = value;
259
+ } else if (isRelativeUrl) {
260
+ // Already a relative URL starting with /, use as-is
261
+ imageUrl = value;
262
+ } else {
263
+ // It's a filename, construct the URL
264
+ imageUrl = `/api/uploads/${value}`;
265
+ }
266
+
267
+ const urlParts = value.split('/');
268
+ const filename = urlParts[urlParts.length - 1]?.split('?')[0] || value;
269
+
270
+ const newImage = {
271
+ id: value,
272
+ filename: filename,
273
+ url: imageUrl,
274
+ size: 0,
275
+ mimeType: 'image/jpeg',
276
+ uploadedAt: new Date().toISOString(),
277
+ };
278
+ setSelectedImage(prev => {
279
+ if (prev?.id === newImage.id && prev?.url === newImage.url) {
280
+ return prev;
281
+ }
282
+ return newImage;
283
+ });
284
+ lastResolvedValueRef.current = value;
285
+ isResolvingRef.current = false;
286
+ }
287
+ }
288
+ }
289
+ };
290
+
291
+ resolveImage().catch(() => {
292
+ // Error already handled in catch block
293
+ isResolvingRef.current = false;
294
+ });
295
+ }, [value, images]);
296
+
297
+ // Handle file upload
298
+ const handleFileSelect = async (e?: React.ChangeEvent<HTMLInputElement> | { target: { files: File[] | null } }) => {
299
+ const file = e?.target?.files?.[0];
300
+ if (!file) return null;
301
+
302
+ setUploading(true);
303
+ try {
304
+ const formData = new FormData();
305
+ formData.append('file', file);
306
+
307
+ const response = await fetch('/api/plugin-images/upload', {
308
+ method: 'POST',
309
+ body: formData,
310
+ });
311
+
312
+ const data = await response.json();
313
+
314
+ if (data.success && data.image) {
315
+ // Select the newly uploaded image
316
+ setSelectedImage(data.image);
317
+ if (fileInputRef.current) {
318
+ fileInputRef.current.value = '';
319
+ }
320
+ return data.image;
321
+ } else {
322
+ alert(data.error || 'Failed to upload image');
323
+ return null;
324
+ }
325
+ } catch (error) {
326
+ console.error('Upload error:', error);
327
+ alert('Failed to upload image');
328
+ return null;
329
+ } finally {
330
+ setUploading(false);
331
+ if (fileInputRef.current) {
332
+ fileInputRef.current.value = '';
333
+ }
334
+ }
335
+ };
336
+
337
+ return {
338
+ selectedImage,
339
+ setSelectedImage,
340
+ uploading,
341
+ fileInputRef,
342
+ handleFileSelect,
343
+ };
344
+ }
@@ -52,9 +52,33 @@ export interface ImagePickerProps {
52
52
  brightness?: number;
53
53
  /** Current blur value (0-20) */
54
54
  blur?: number;
55
+ /** Current scale value (0.1-3.0, 1.0 = normal) */
56
+ scale?: number;
57
+ /** Current X position offset (-100 to 100, 0 = center) */
58
+ positionX?: number;
59
+ /** Current Y position offset (-100 to 100, 0 = center) */
60
+ positionY?: number;
61
+ /** Aspect ratio for preview/editor (e.g., "4/5", "3/4", "16/9", "1/1") */
62
+ aspectRatio?: string;
63
+ /** Border radius class (e.g., "rounded-xl", "rounded-3xl") */
64
+ borderRadius?: string;
65
+ /** Object fit style (e.g., "cover", "contain") */
66
+ objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
67
+ /** Object position (e.g., "center", "top", "bottom") */
68
+ objectPosition?: string;
55
69
  /** Callback when brightness changes */
56
70
  onBrightnessChange?: (brightness: number) => void;
57
71
  /** Callback when blur changes */
58
72
  onBlurChange?: (blur: number) => void;
73
+ /** Callback when scale changes */
74
+ onScaleChange?: (scale: number) => void;
75
+ /** Callback when X position changes */
76
+ onPositionXChange?: (positionX: number) => void;
77
+ /** Callback when Y position changes */
78
+ onPositionYChange?: (positionY: number) => void;
79
+ /** Callback when editor "Done" button is clicked - triggers immediate save */
80
+ onEditorSave?: (scale: number, positionX: number, positionY: number, brightness?: number, blur?: number) => void;
81
+ /** Automatically open the editor when component mounts (useful for modal scenarios) */
82
+ autoOpenEditor?: boolean;
59
83
  }
60
84
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Image Transform Utilities
3
+ */
4
+
5
+ export interface ImageTransformOptions {
6
+ scale: number;
7
+ positionX: number;
8
+ positionY: number;
9
+ baseScale?: number;
10
+ }
11
+
12
+ /**
13
+ * Calculates the CSS transform string
14
+ * Order: Center -> Scale -> Translate (Offset)
15
+ *
16
+ * Position values are stored as percentages of the CONTAINER, not the image.
17
+ * This makes them consistent across different container sizes.
18
+ *
19
+ * IMPORTANT: The image uses width: auto; height: auto with minWidth: 100%; minHeight: 100%
20
+ * This allows the image to maintain its natural aspect ratio while ensuring it covers the container.
21
+ * baseScale handles the "fitting" to ensure the image is large enough to fill the container.
22
+ *
23
+ * Applying SCALE before POSITION makes the position relative to the visual size you see,
24
+ * not the original raw file size. This ensures consistent positioning across different scales.
25
+ */
26
+ export function getImageTransform(
27
+ options: ImageTransformOptions,
28
+ needsCentering: boolean = false,
29
+ caller?: string
30
+ ): string {
31
+ const { scale, positionX, positionY, baseScale = 1 } = options;
32
+ const totalScale = baseScale * scale;
33
+
34
+ // 1. Center the image (if using top:50% left:50%)
35
+ const center = needsCentering ? 'translate(-50%, -50%)' : '';
36
+
37
+ // 2. Apply the scaling (base x zoom) FIRST
38
+ // This ensures the position offset is relative to the scaled visual size
39
+ const zoom = `scale(${totalScale})`;
40
+
41
+ // 3. Apply the offset (positionX/Y) AFTER scaling
42
+ // Position values are stored as percentage of CONTAINER
43
+ // Since scale is applied first, the translate is relative to the scaled visual size
44
+ const offset = `translate(${positionX}%, ${positionY}%)`;
45
+
46
+ // Combining them: Center first, then scale, then move
47
+ // Order matters: scale before translate ensures position is relative to visual size
48
+ return `${center} ${zoom} ${offset}`.trim();
49
+ }
50
+
51
+ export function getImageFilter(brightness: number = 100, blur: number = 0): string | undefined {
52
+ if (brightness === 100 && blur === 0) return undefined;
53
+ return `brightness(${brightness}%) blur(${blur}px)`;
54
+ }