@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.
@@ -7,36 +7,31 @@
7
7
 
8
8
  'use client';
9
9
 
10
- import React, { useState, useEffect, useRef, useMemo } from 'react';
10
+ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
11
11
  import { X, Edit2 } from 'lucide-react';
12
12
  import { ImagePicker } from './ImagePicker';
13
13
  import type { ImageMetadata } from '../types';
14
+ import type { SelectedImage, PendingTransform } from './GlobalImageEditor/types';
15
+ import { getPluginConfig } from './GlobalImageEditor/config';
16
+ import { parseImageData } from './GlobalImageEditor/imageDetection';
17
+ import { setupImageHandlers } from './GlobalImageEditor/imageSetup';
18
+ import { saveTransformToAPI, flushPendingSave, getFilename, normalizePosition } from './GlobalImageEditor/saveLogic';
19
+ import { handleImageChange, handleBrightnessChange, handleBlurChange } from './GlobalImageEditor/eventHandlers';
14
20
 
15
21
  export function GlobalImageEditor() {
16
- // Read config from window global (set by initImagesPlugin)
17
- const config = useMemo(() => {
18
- if (typeof window === 'undefined') {
19
- return { enabled: false };
20
- }
21
-
22
- const pluginProps = (window as any).__JHITS_PLUGIN_PROPS__?.['plugin-images'];
23
- return {
24
- enabled: pluginProps?.enabled !== undefined ? pluginProps.enabled : true,
25
- className: pluginProps?.className,
26
- overlayClassName: pluginProps?.overlayClassName,
27
- };
28
- }, []);
29
- const [selectedImage, setSelectedImage] = useState<{
30
- element: HTMLElement;
31
- originalSrc: string;
32
- brightness: number;
33
- blur: number;
34
- isBackground: boolean;
35
- } | null>(null);
22
+ // Configuration
23
+ const config = useMemo(() => getPluginConfig(), []);
24
+
25
+ // State
26
+ const [selectedImage, setSelectedImage] = useState<SelectedImage | null>(null);
36
27
  const [isOpen, setIsOpen] = useState(false);
37
28
  const [userRole, setUserRole] = useState<string | null>(null);
38
29
  const [isLoading, setIsLoading] = useState(true);
39
30
 
31
+ // Refs for save debouncing
32
+ const saveTransformTimeoutRef = useRef<NodeJS.Timeout | null>(null);
33
+ const pendingTransformRef = useRef<PendingTransform | null>(null);
34
+
40
35
  // Check if user is admin/dev
41
36
  useEffect(() => {
42
37
  const checkUser = async () => {
@@ -63,13 +58,11 @@ export function GlobalImageEditor() {
63
58
  const { id, currentBrightness, currentBlur } = e.detail || {};
64
59
  if (!id) return;
65
60
 
66
- console.log('[GlobalImageEditor] open-image-editor event received for id:', id);
67
61
 
68
62
  // Find the element by data-image-id or data-background-image-id
69
63
  let element = document.querySelector(`[data-image-id="${id}"], [data-background-image-id="${id}"]`) as HTMLElement;
70
64
 
71
65
  if (!element) {
72
- // Try finding by background image component
73
66
  element = document.querySelector(`[data-background-image-component="true"][data-background-image-id="${id}"]`) as HTMLElement;
74
67
  }
75
68
 
@@ -78,82 +71,27 @@ export function GlobalImageEditor() {
78
71
  return;
79
72
  }
80
73
 
81
- // Determine if it's a background image
82
- const isBackground = element.hasAttribute('data-background-image-component') ||
83
- element.hasAttribute('data-background-image-id');
84
-
85
- // Get current image source - handle Next.js Image wrapper
86
- let currentSrc = '';
87
- let actualImgElement: HTMLImageElement | null = null;
88
-
89
- if (isBackground) {
90
- // For background images, look for nested Image component
91
- const imgWrapper = element.querySelector('[data-image-id]');
92
- if (imgWrapper) {
93
- actualImgElement = imgWrapper.querySelector('img') || null;
94
- } else {
95
- // Fallback: look for any img inside
96
- actualImgElement = element.querySelector('img');
97
- }
98
-
99
- if (actualImgElement) {
100
- currentSrc = actualImgElement.src;
101
- } else {
102
- // Try computed style as last resort
103
- const bgImage = window.getComputedStyle(element).backgroundImage;
104
- const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
105
- if (urlMatch && urlMatch[1]) {
106
- currentSrc = urlMatch[1];
107
- }
108
- }
109
- } else {
110
- // For regular images, find the actual img element (Next.js wraps it)
111
- if (element.tagName === 'IMG') {
112
- actualImgElement = element as HTMLImageElement;
113
- currentSrc = actualImgElement.src;
114
- } else {
115
- // Next.js Image wraps img in span/picture
116
- actualImgElement = element.querySelector('img');
117
- if (actualImgElement) {
118
- currentSrc = actualImgElement.src;
119
- }
120
- }
121
- }
122
-
123
- if (!currentSrc) {
124
- console.error('[GlobalImageEditor] Could not determine image source for id:', id);
125
- return;
126
- }
127
-
128
74
  // Extract semantic ID
129
75
  const semanticId = element.getAttribute('data-image-id') ||
130
76
  element.getAttribute('data-background-image-id') ||
131
77
  id;
132
78
 
133
- // Get current brightness and blur from the wrapper element (where filter is applied)
134
- const filterStyle = element.style.filter || window.getComputedStyle(element).filter || '';
135
- const brightness = currentBrightness ??
136
- (parseInt(filterStyle.match(/brightness\((\d+)%\)/)?.[1] || '100'));
137
- const blur = currentBlur ??
138
- (parseInt(filterStyle.match(/blur\((\d+)px\)/)?.[1] || '0'));
139
-
140
- console.log('[GlobalImageEditor] Opening editor for:', { id, semanticId, currentSrc, brightness, blur, isBackground });
141
-
142
- setSelectedImage({
143
- element,
144
- originalSrc: currentSrc,
145
- brightness,
146
- blur,
147
- isBackground,
148
- });
79
+ // Parse image data
80
+ const imageData = parseImageData(element, semanticId, currentBrightness, currentBlur);
81
+
82
+ if (!imageData) {
83
+ console.error('[GlobalImageEditor] Failed to parse image data');
84
+ return;
85
+ }
149
86
 
150
87
  // Store semantic ID on element if not already set
151
- if (isBackground && !element.hasAttribute('data-background-image-id')) {
88
+ if (imageData.isBackground && !element.hasAttribute('data-background-image-id')) {
152
89
  element.setAttribute('data-background-image-id', semanticId);
153
- } else if (!isBackground && !element.hasAttribute('data-image-id')) {
90
+ } else if (!imageData.isBackground && !element.hasAttribute('data-image-id')) {
154
91
  element.setAttribute('data-image-id', semanticId);
155
92
  }
156
93
 
94
+ setSelectedImage(imageData);
157
95
  setIsOpen(true);
158
96
  };
159
97
 
@@ -167,561 +105,128 @@ export function GlobalImageEditor() {
167
105
  // Add click handlers to all images
168
106
  useEffect(() => {
169
107
  if (isLoading || !userRole) {
170
- console.log('[GlobalImageEditor] Skipping image handlers - isLoading:', isLoading, 'userRole:', userRole);
171
108
  return;
172
109
  }
173
110
 
174
- console.log('[GlobalImageEditor] Setting up image handlers for role:', userRole);
175
-
176
- // Declare cleanupFunctions outside setupImages so it's accessible in the cleanup function
177
- const cleanupFunctions: Array<() => void> = [];
178
111
 
179
- // Wait a bit for images to load and DOM to be ready
180
- const setupImages = () => {
181
- const handleImageClick = (e: MouseEvent) => {
182
- const target = e.target as HTMLElement;
183
-
184
- // Only handle clicks on images that use the plugin components
185
- // Check if it's an img element with data-image-id (from plugin Image component)
186
- let img = target.closest('img[data-image-id]') as HTMLImageElement | null;
187
- let isBackground = false;
188
- let element: HTMLElement | null = null;
189
- let currentSrc = '';
190
-
191
- if (img) {
192
- // This is a plugin Image component
193
- element = img;
194
- currentSrc = img.src;
195
- } else {
196
- // Check if it's an element with background-image
197
- // First check for BackgroundImage component
198
- let bgElement = target.closest('[data-background-image-component]') as HTMLElement | null;
199
-
200
- if (bgElement) {
201
- // This is a BackgroundImage component
202
- element = bgElement;
203
- isBackground = true;
204
-
205
- // Try to get image source from nested Image component first
206
- const imgInside = bgElement.querySelector('img[data-image-id]') as HTMLImageElement | null;
207
- if (imgInside) {
208
- currentSrc = imgInside.src;
209
- } else {
210
- // Fallback to background-image CSS
211
- const bgImage = window.getComputedStyle(bgElement).backgroundImage;
212
- const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
213
- if (urlMatch && urlMatch[1]) {
214
- currentSrc = urlMatch[1];
215
- }
216
- }
217
- }
218
- }
219
-
220
- if (!element || !currentSrc) return;
221
-
222
- // Don't handle images in modals or the editor itself
223
- if (element.closest('[data-image-editor]') || element.closest('[role="dialog"]')) {
224
- return;
225
- }
226
-
227
- e.preventDefault();
228
- e.stopPropagation();
229
-
230
- // Extract semantic ID from data attribute
231
- let semanticId = element.getAttribute('data-image-id') || element.getAttribute('data-background-image-id');
232
-
233
- // If not found, try to extract from the src URL
234
- if (!semanticId && currentSrc.includes('/api/uploads/')) {
235
- const urlPart = currentSrc.split('/api/uploads/')[1]?.split('?')[0];
236
- // If it looks like a semantic ID (no extension or timestamp), use it
237
- if (urlPart && !/^\d+-/.test(urlPart) && !/\.(jpg|jpeg|png|webp|gif|svg)$/i.test(urlPart)) {
238
- semanticId = decodeURIComponent(urlPart);
239
- }
240
- }
241
-
242
- // If still not found, generate one
243
- if (!semanticId) {
244
- semanticId = element.closest('[data-image-id]')?.getAttribute('data-image-id') ||
245
- `image-${Date.now()}`;
246
- }
247
-
248
- // Check for existing filter or data attributes
249
- const filterStyle = element.style.filter || window.getComputedStyle(element).filter || '';
250
- const dataBrightness = element.getAttribute('data-brightness');
251
- const dataBlur = element.getAttribute('data-blur');
252
- const currentBrightness = dataBrightness
253
- ? parseInt(dataBrightness)
254
- : parseInt(filterStyle.match(/brightness\((\d+)%\)/)?.[1] || '100');
255
- const currentBlur = dataBlur
256
- ? parseInt(dataBlur)
257
- : parseInt(filterStyle.match(/blur\((\d+)px\)/)?.[1] || '0');
258
-
259
- setSelectedImage({
260
- element,
261
- originalSrc: currentSrc,
262
- brightness: currentBrightness,
263
- blur: currentBlur,
264
- isBackground,
265
- });
266
-
267
- // Store semantic ID on element for later use
268
- if (isBackground) {
269
- element.setAttribute('data-background-image-id', semanticId);
270
- } else {
271
- element.setAttribute('data-image-id', semanticId);
272
- }
112
+ const cleanup = setupImageHandlers((imageData) => {
113
+ setSelectedImage(imageData);
273
114
  setIsOpen(true);
274
- };
275
-
276
- // Add click listeners and hover effects to all images
277
- // Only select images that use the plugin components (have data-image-id or are inside BackgroundImage)
278
- const images = document.querySelectorAll('img[data-image-id]:not([data-no-edit])') as NodeListOf<HTMLImageElement>;
279
-
280
- // Find elements with BackgroundImage components only
281
- // Only BackgroundImage components should be editable, not all elements with background-image
282
- const bgElements: HTMLElement[] = [];
283
- const backgroundImageComponents = document.querySelectorAll('[data-background-image-component]');
284
- backgroundImageComponents.forEach(el => {
285
- bgElements.push(el as HTMLElement);
286
- });
287
-
288
- console.log('[GlobalImageEditor] Found', images.length, 'images and', bgElements.length, 'background images to make editable');
289
-
290
- images.forEach((img, index) => {
291
- console.log(`[GlobalImageEditor] Setting up image ${index + 1}:`, img.src);
292
- // Store original position for cleanup
293
- const originalPosition = img.style.position;
294
-
295
- img.style.cursor = 'pointer';
296
- img.setAttribute('data-editable-image', 'true');
297
- img.addEventListener('click', handleImageClick as EventListener);
298
-
299
- // Find the best container for the indicator
300
- // Next.js Image wraps in span, so we need to find the right parent
301
- let indicatorContainer: HTMLElement = img;
302
- let parent = img.parentElement;
303
-
304
- // Check if parent is a span (Next.js Image wrapper)
305
- if (parent && (parent.tagName === 'SPAN' || parent.tagName === 'DIV')) {
306
- const parentStyle = window.getComputedStyle(parent);
307
- // Use parent if it has relative/absolute positioning or is a block element
308
- if (parentStyle.position !== 'static' ||
309
- (parentStyle.display !== 'inline' && parentStyle.display !== 'inline-block')) {
310
- indicatorContainer = parent as HTMLElement;
311
- }
312
- }
313
-
314
- // Ensure container has relative positioning
315
- const containerStyle = window.getComputedStyle(indicatorContainer);
316
- if (containerStyle.position === 'static') {
317
- indicatorContainer.style.position = 'relative';
318
- }
319
-
320
- // Get actual dimensions
321
- const rect = indicatorContainer.getBoundingClientRect();
322
-
323
- console.log(`[GlobalImageEditor] Using container for image ${index + 1}:`, indicatorContainer.tagName, `(${rect.width}x${rect.height})`);
324
-
325
- const indicator = document.createElement('div');
326
- indicator.className = 'image-edit-indicator';
327
- indicator.setAttribute('data-image-index', index.toString());
328
- indicator.style.cssText = `
329
- position: absolute;
330
- top: 0;
331
- left: 0;
332
- width: 100%;
333
- height: 100%;
334
- background: rgba(0, 0, 0, 0.4);
335
- display: flex;
336
- align-items: center;
337
- justify-content: center;
338
- opacity: 0;
339
- transition: opacity 0.2s ease;
340
- pointer-events: none;
341
- z-index: 10000;
342
- border-radius: inherit;
343
- box-sizing: border-box;
344
- `;
345
-
346
- const label = document.createElement('div');
347
- label.style.cssText = `
348
- background: rgba(0, 0, 0, 0.9);
349
- color: white;
350
- padding: 8px 16px;
351
- border-radius: 8px;
352
- font-size: 12px;
353
- font-weight: bold;
354
- text-transform: uppercase;
355
- letter-spacing: 0.5px;
356
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
357
- `;
358
- label.textContent = 'Click to Edit';
359
- indicator.appendChild(label);
360
-
361
- indicatorContainer.appendChild(indicator);
362
- console.log(`[GlobalImageEditor] Added hover indicator to image ${index + 1}, container:`, indicatorContainer.tagName);
363
-
364
- const addHoverEffect = () => {
365
- console.log(`[GlobalImageEditor] Hover enter on image ${index + 1}`);
366
- indicator.style.opacity = '1';
367
- };
368
-
369
- const removeHoverEffect = () => {
370
- console.log(`[GlobalImageEditor] Hover leave on image ${index + 1}`);
371
- indicator.style.opacity = '0';
372
- };
373
-
374
- // Add hover listeners to both image and container
375
- img.addEventListener('mouseenter', addHoverEffect);
376
- img.addEventListener('mouseleave', removeHoverEffect);
377
- if (indicatorContainer !== img) {
378
- indicatorContainer.addEventListener('mouseenter', addHoverEffect);
379
- indicatorContainer.addEventListener('mouseleave', removeHoverEffect);
380
- }
381
-
382
- cleanupFunctions.push(() => {
383
- img.removeEventListener('click', handleImageClick as EventListener);
384
- img.removeEventListener('mouseenter', addHoverEffect);
385
- img.removeEventListener('mouseleave', removeHoverEffect);
386
- if (indicatorContainer !== img) {
387
- indicatorContainer.removeEventListener('mouseenter', addHoverEffect);
388
- indicatorContainer.removeEventListener('mouseleave', removeHoverEffect);
389
- if (indicatorContainer.style.position === 'relative') {
390
- indicatorContainer.style.position = '';
391
- }
392
- }
393
- img.removeAttribute('data-editable-image');
394
- img.style.cursor = '';
395
- img.style.position = originalPosition;
396
- indicator.remove();
397
- });
398
- });
399
-
400
- // Process background images - show a button instead of hover overlay
401
- bgElements.forEach((bgEl, index) => {
402
- // Skip BackgroundImage components - they already have their own edit button
403
- if (bgEl.hasAttribute('data-background-image-component')) {
404
- console.log(`[GlobalImageEditor] Skipping BackgroundImage component ${index + 1} - it has its own edit button`);
405
- return;
406
- }
407
- const originalPosition = bgEl.style.position;
408
- if (!bgEl.style.position || bgEl.style.position === 'static') {
409
- bgEl.style.position = 'relative';
410
- }
411
-
412
- bgEl.setAttribute('data-editable-background', 'true');
413
- bgEl.setAttribute('data-background-image', 'true');
414
-
415
- // Create a button indicator instead of hover overlay
416
- const buttonContainer = document.createElement('div');
417
- buttonContainer.className = 'background-edit-button-container';
418
- buttonContainer.setAttribute('data-image-index', `bg-${index}`);
419
- buttonContainer.style.cssText = `
420
- position: absolute;
421
- top: 12px;
422
- right: 12px;
423
- z-index: 10000;
424
- pointer-events: auto;
425
- `;
426
-
427
- const editButton = document.createElement('button');
428
- editButton.className = 'background-edit-button';
429
- editButton.setAttribute('type', 'button');
430
- editButton.style.cssText = `
431
- background: rgba(0, 0, 0, 0.8);
432
- color: white;
433
- border: 2px solid rgba(255, 255, 255, 0.3);
434
- padding: 10px 20px;
435
- border-radius: 8px;
436
- font-size: 12px;
437
- font-weight: bold;
438
- text-transform: uppercase;
439
- letter-spacing: 0.5px;
440
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
441
- cursor: pointer;
442
- transition: all 0.2s ease;
443
- display: flex;
444
- align-items: center;
445
- gap: 8px;
446
- `;
447
- editButton.innerHTML = `
448
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
449
- <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
450
- <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
451
- </svg>
452
- Edit Background
453
- `;
454
-
455
- // Add hover effect to button
456
- editButton.addEventListener('mouseenter', () => {
457
- editButton.style.background = 'rgba(0, 0, 0, 0.95)';
458
- editButton.style.borderColor = 'rgba(255, 255, 255, 0.5)';
459
- editButton.style.transform = 'translateY(-2px)';
460
- editButton.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.5)';
461
- });
462
-
463
- editButton.addEventListener('mouseleave', () => {
464
- editButton.style.background = 'rgba(0, 0, 0, 0.8)';
465
- editButton.style.borderColor = 'rgba(255, 255, 255, 0.3)';
466
- editButton.style.transform = 'translateY(0)';
467
- editButton.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.4)';
468
- });
469
-
470
- // Handle button click
471
- editButton.addEventListener('click', (e) => {
472
- e.preventDefault();
473
- e.stopPropagation();
474
- handleImageClick(e as any);
475
- });
476
-
477
- buttonContainer.appendChild(editButton);
478
- bgEl.appendChild(buttonContainer);
479
-
480
- console.log(`[GlobalImageEditor] Added edit button to background image ${index + 1}`);
481
-
482
- cleanupFunctions.push(() => {
483
- bgEl.removeAttribute('data-editable-background');
484
- bgEl.removeAttribute('data-background-image');
485
- bgEl.style.position = originalPosition;
486
- buttonContainer.remove();
487
- });
488
115
  });
489
116
 
490
- }; // End of setupImages function
491
-
492
117
  // Setup immediately and also after a short delay to catch dynamically loaded images
493
- setupImages();
494
- const timeoutId = setTimeout(setupImages, 500);
118
+ const timeoutId = setTimeout(() => {
119
+ const cleanup2 = setupImageHandlers((imageData) => {
120
+ setSelectedImage(imageData);
121
+ setIsOpen(true);
122
+ });
123
+ // Note: This creates a new cleanup, but the first one will handle the initial setup
124
+ }, 500);
495
125
 
496
126
  return () => {
497
127
  clearTimeout(timeoutId);
498
- cleanupFunctions.forEach(cleanup => cleanup());
128
+ cleanup();
499
129
  };
500
130
  }, [isLoading, userRole]);
501
131
 
502
- // Apply image changes
503
- const handleImageChange = async (image: ImageMetadata | null) => {
132
+ // Save image transform (scale/position) to API with debouncing
133
+ const saveImageTransformHandler = useCallback(async (
134
+ scale: number,
135
+ positionX: number,
136
+ positionY: number,
137
+ immediate: boolean = false,
138
+ brightnessOverride?: number,
139
+ blurOverride?: number
140
+ ) => {
504
141
  if (!selectedImage) return;
505
142
 
506
143
  const { element } = selectedImage;
144
+ const semanticId = element.getAttribute('data-image-id') ||
145
+ element.getAttribute('data-background-image-id');
507
146
 
508
- if (image) {
509
- // Get the semantic ID that was stored when the image was clicked
510
- // Check both data-image-id and data-background-image-id
511
- const semanticId = element.getAttribute('data-image-id') ||
512
- element.getAttribute('data-background-image-id');
513
-
514
- if (!semanticId) {
515
- console.error('[GlobalImageEditor] No semantic ID found for image', {
516
- hasDataImageId: element.hasAttribute('data-image-id'),
517
- hasDataBackgroundImageId: element.hasAttribute('data-background-image-id'),
518
- element: element,
519
- });
520
- return;
521
- }
522
-
523
- // Extract filename from image URL or use image.id
524
- const filename = image.url.split('/').pop()?.split('?')[0] || image.id;
525
-
526
- // Save the mapping between semantic ID and filename, including effects
527
- try {
528
- const response = await fetch('/api/plugin-images/resolve', {
529
- method: 'POST',
530
- headers: { 'Content-Type': 'application/json' },
531
- body: JSON.stringify({
532
- id: semanticId,
533
- filename,
534
- brightness: selectedImage.brightness,
535
- blur: selectedImage.blur,
536
- }),
537
- });
538
-
539
- if (response.ok) {
540
- console.log(`[GlobalImageEditor] Saved mapping: ${semanticId} -> ${filename} (brightness: ${selectedImage.brightness}%, blur: ${selectedImage.blur}px)`);
541
- } else {
542
- console.error('[GlobalImageEditor] Failed to save mapping:', await response.text());
543
- }
544
- } catch (error) {
545
- console.error('Failed to save image mapping:', error);
147
+ if (!semanticId) return;
148
+
149
+ const filename = await getFilename(semanticId, selectedImage);
150
+ if (!filename) return;
151
+
152
+ // Store pending transform
153
+ pendingTransformRef.current = {
154
+ scale,
155
+ positionX,
156
+ positionY,
157
+ semanticId,
158
+ filename
159
+ };
160
+
161
+ // Clear existing timeout
162
+ if (saveTransformTimeoutRef.current) {
163
+ clearTimeout(saveTransformTimeoutRef.current);
164
+ saveTransformTimeoutRef.current = null;
165
+ }
166
+
167
+ // If immediate save is requested (e.g., when "Done" is clicked), save right away
168
+ if (immediate) {
169
+ const normalizedPositionX = normalizePosition(positionX);
170
+ const normalizedPositionY = normalizePosition(positionY);
171
+
172
+ // Use override values if provided, otherwise use selectedImage values
173
+ // IMPORTANT: Overrides contain the latest values from the editor
174
+ const finalBrightness = brightnessOverride !== undefined ? brightnessOverride : (selectedImage?.brightness ?? 100);
175
+ const finalBlur = blurOverride !== undefined ? blurOverride : (selectedImage?.blur ?? 0);
176
+
177
+ await saveTransformToAPI(
178
+ semanticId,
179
+ filename,
180
+ scale,
181
+ normalizedPositionX,
182
+ normalizedPositionY,
183
+ finalBrightness,
184
+ finalBlur
185
+ );
186
+ pendingTransformRef.current = null;
187
+ return;
188
+ }
189
+
190
+ // Debounce: save after 300ms of no changes
191
+ saveTransformTimeoutRef.current = setTimeout(async () => {
192
+ const pending = pendingTransformRef.current;
193
+ if (pending && selectedImage) {
194
+ await flushPendingSave(pending, selectedImage);
546
195
  }
547
-
548
- // Update image src immediately - handle background images differently
549
- if (selectedImage.isBackground) {
550
- // For background images, update the nested Image component
551
- const imgWrapper = element.querySelector('[data-image-id]');
552
- if (imgWrapper) {
553
- // Find the actual img element inside the wrapper
554
- const img = imgWrapper.querySelector('img');
555
- if (img) {
556
- img.src = image.url;
557
- img.setAttribute('data-edited-src', image.url);
558
- }
559
- } else {
560
- // Fallback: update background-image style if no nested Image component
561
- const bgImage = window.getComputedStyle(element).backgroundImage;
562
- if (bgImage && bgImage !== 'none') {
563
- element.style.backgroundImage = `url(${image.url})`;
564
- }
565
- }
566
-
567
- // Store attributes on the background container
568
- element.setAttribute('data-background-image-id', semanticId);
569
- element.setAttribute('data-edited-src', image.url);
570
- element.setAttribute('data-brightness', selectedImage.brightness.toString());
571
- element.setAttribute('data-blur', selectedImage.blur.toString());
572
-
573
- // Apply filter to the wrapper element
574
- if (selectedImage.brightness !== 100 || selectedImage.blur !== 0) {
575
- element.style.filter = `brightness(${selectedImage.brightness}%) blur(${selectedImage.blur}px)`;
576
- } else {
577
- element.style.filter = '';
578
- }
579
- } else {
580
- // For regular images, update the img element directly
581
- if (element.tagName === 'IMG') {
582
- (element as HTMLImageElement).src = image.url;
583
- element.setAttribute('data-edited-src', image.url);
584
- element.setAttribute('data-image-id', semanticId);
585
- element.setAttribute('data-brightness', selectedImage.brightness.toString());
586
- element.setAttribute('data-blur', selectedImage.blur.toString());
587
-
588
- // Apply filter
589
- if (selectedImage.brightness !== 100 || selectedImage.blur !== 0) {
590
- element.style.filter = `brightness(${selectedImage.brightness}%) blur(${selectedImage.blur}px)`;
591
- } else {
592
- element.style.filter = '';
593
- }
594
- } else {
595
- // Next.js Image wrapper - find the actual img element
596
- const img = element.querySelector('img');
597
- if (img) {
598
- img.src = image.url;
599
- img.setAttribute('data-edited-src', image.url);
600
- }
601
- element.setAttribute('data-image-id', semanticId);
602
- element.setAttribute('data-edited-src', image.url);
603
- element.setAttribute('data-brightness', selectedImage.brightness.toString());
604
- element.setAttribute('data-blur', selectedImage.blur.toString());
605
-
606
- // Apply filter to wrapper
607
- if (selectedImage.brightness !== 100 || selectedImage.blur !== 0) {
608
- element.style.filter = `brightness(${selectedImage.brightness}%) blur(${selectedImage.blur}px)`;
609
- } else {
610
- element.style.filter = '';
611
- }
612
- }
196
+ }, 300);
197
+ }, [selectedImage]);
198
+
199
+ // Cleanup timeout on unmount
200
+ useEffect(() => {
201
+ return () => {
202
+ if (saveTransformTimeoutRef.current) {
203
+ clearTimeout(saveTransformTimeoutRef.current);
613
204
  }
614
-
615
- // Dispatch a custom event to notify Image components to re-resolve
616
- window.dispatchEvent(new CustomEvent('image-mapping-updated', {
617
- detail: { id: semanticId, filename, brightness: selectedImage.brightness, blur: selectedImage.blur }
618
- }));
619
-
620
- // Close the editor
621
- handleClose();
622
- }
623
- };
205
+ };
206
+ }, []);
624
207
 
625
- const handleBrightnessChange = async (brightness: number) => {
208
+ // Event handlers
209
+ const handleImageChangeWrapper = useCallback(async (image: ImageMetadata | null) => {
626
210
  if (!selectedImage) return;
627
-
628
- const { element } = selectedImage;
629
- const semanticId = element.getAttribute('data-image-id') ||
630
- element.getAttribute('data-background-image-id');
631
- const currentFilter = element.style.filter || '';
632
- const blurMatch = currentFilter.match(/blur\((\d+)px\)/);
633
- const blur = blurMatch ? parseInt(blurMatch[1]) : selectedImage.blur;
634
-
635
- // Update the selectedImage state
636
- setSelectedImage({
637
- ...selectedImage,
638
- brightness,
639
- });
640
-
641
- element.style.filter = `brightness(${brightness}%) blur(${blur}px)`;
642
- element.setAttribute('data-brightness', brightness.toString());
643
-
644
- // Save the effect immediately
645
- if (semanticId) {
646
- try {
647
- let filename = semanticId;
648
- if (selectedImage.isBackground) {
649
- const bgImage = window.getComputedStyle(element).backgroundImage;
650
- const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
651
- if (urlMatch && urlMatch[1]) {
652
- filename = urlMatch[1].split('/api/uploads/')[1]?.split('?')[0] || semanticId;
653
- }
654
- } else {
655
- filename = (element as HTMLImageElement).src.split('/api/uploads/')[1]?.split('?')[0] || semanticId;
656
- }
657
- await fetch('/api/plugin-images/resolve', {
658
- method: 'POST',
659
- headers: { 'Content-Type': 'application/json' },
660
- body: JSON.stringify({
661
- id: semanticId,
662
- filename,
663
- brightness,
664
- blur,
665
- }),
666
- });
667
- } catch (error) {
668
- console.error('Failed to save brightness:', error);
669
- }
670
- }
671
- };
211
+ await handleImageChange(image, selectedImage, handleClose);
212
+ }, [selectedImage]);
672
213
 
673
- const handleBlurChange = async (blur: number) => {
214
+ const handleBrightnessChangeWrapper = useCallback(async (brightness: number) => {
674
215
  if (!selectedImage) return;
675
-
676
- const { element } = selectedImage;
677
- const semanticId = element.getAttribute('data-image-id') ||
678
- element.getAttribute('data-background-image-id');
679
- const currentFilter = element.style.filter || '';
680
- const brightnessMatch = currentFilter.match(/brightness\((\d+)%\)/);
681
- const brightness = brightnessMatch ? parseInt(brightnessMatch[1]) : selectedImage.brightness;
682
-
683
- // Update the selectedImage state
684
- setSelectedImage({
685
- ...selectedImage,
686
- blur,
687
- });
688
-
689
- element.style.filter = `brightness(${brightness}%) blur(${blur}px)`;
690
- element.setAttribute('data-blur', blur.toString());
691
-
692
- // Save the effect immediately
693
- if (semanticId) {
694
- try {
695
- let filename = semanticId;
696
- if (selectedImage.isBackground) {
697
- const bgImage = window.getComputedStyle(element).backgroundImage;
698
- const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
699
- if (urlMatch && urlMatch[1]) {
700
- filename = urlMatch[1].split('/api/uploads/')[1]?.split('?')[0] || semanticId;
701
- }
702
- } else {
703
- filename = (element as HTMLImageElement).src.split('/api/uploads/')[1]?.split('?')[0] || semanticId;
704
- }
705
- await fetch('/api/plugin-images/resolve', {
706
- method: 'POST',
707
- headers: { 'Content-Type': 'application/json' },
708
- body: JSON.stringify({
709
- id: semanticId,
710
- filename,
711
- brightness,
712
- blur,
713
- }),
714
- });
715
- } catch (error) {
716
- console.error('Failed to save blur:', error);
717
- }
718
- }
719
- };
216
+ // Don't save immediately when in editor - will be saved when "Done" is pressed
217
+ await handleBrightnessChange(brightness, selectedImage, setSelectedImage, false);
218
+ }, [selectedImage]);
219
+
220
+ const handleBlurChangeWrapper = useCallback(async (blur: number) => {
221
+ if (!selectedImage) return;
222
+ // Don't save immediately when in editor - will be saved when "Done" is pressed
223
+ await handleBlurChange(blur, selectedImage, setSelectedImage, false);
224
+ }, [selectedImage]);
720
225
 
721
- const handleClose = () => {
226
+ const handleClose = useCallback(() => {
722
227
  setIsOpen(false);
723
228
  setSelectedImage(null);
724
- };
229
+ }, []);
725
230
 
726
231
  // Don't render if disabled or user is not admin/dev
727
232
  if (!config.enabled || isLoading || !userRole) {
@@ -731,48 +236,92 @@ export function GlobalImageEditor() {
731
236
  if (!isOpen || !selectedImage) {
732
237
  return null;
733
238
  }
734
-
239
+
735
240
  return (
736
- <div
737
- data-image-editor
738
- className={config.overlayClassName || "fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"}
739
- onClick={handleClose}
241
+ <div
242
+ className={`fixed inset-0 z-50 flex items-center justify-center ${config.overlayClassName || 'bg-black/50 backdrop-blur-sm'}`}
243
+ data-image-editor="true"
244
+ onClick={(e) => {
245
+ if (e.target === e.currentTarget) {
246
+ handleClose();
247
+ }
248
+ }}
740
249
  >
741
- <div
250
+ <div
251
+ className={`relative bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-4xl w-full mx-4 max-h-[90vh] flex flex-col ${config.className || ''}`}
742
252
  onClick={(e) => e.stopPropagation()}
743
- className={config.className || "w-full max-w-2xl max-h-[90vh] bg-dashboard-card rounded-2xl border border-dashboard-border shadow-2xl m-4 overflow-y-auto"}
744
253
  >
745
254
  {/* Header */}
746
- <div className="p-6 border-b border-dashboard-border flex items-center justify-between sticky top-0 bg-dashboard-card z-10">
747
- <div className="flex items-center gap-3">
748
- <Edit2 size={20} className="text-neutral-600 dark:text-neutral-400" />
749
- <h2 className="text-lg font-black uppercase tracking-tighter text-neutral-950 dark:text-white">
750
- Edit Image
751
- </h2>
752
- </div>
255
+ <div className="flex items-center justify-between p-6 border-b dark:border-neutral-800">
256
+ <h2 className="text-2xl font-bold dark:text-white">Edit Image</h2>
753
257
  <button
754
258
  onClick={handleClose}
755
- className="p-2 hover:bg-dashboard-bg rounded-lg transition-colors"
259
+ className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
260
+ aria-label="Close editor"
756
261
  >
757
- <X size={20} className="text-neutral-500 dark:text-neutral-400" />
262
+ <X className="w-5 h-5" />
758
263
  </button>
759
264
  </div>
760
265
 
761
266
  {/* Content */}
762
267
  <div className="p-6">
763
268
  <ImagePicker
764
- value={selectedImage.originalSrc}
765
- onChange={handleImageChange}
269
+ value={selectedImage.element.getAttribute('data-image-id') || selectedImage.element.getAttribute('data-background-image-id') || selectedImage.originalSrc}
270
+ onChange={handleImageChangeWrapper}
766
271
  brightness={selectedImage.brightness}
767
272
  blur={selectedImage.blur}
768
- onBrightnessChange={handleBrightnessChange}
769
- onBlurChange={handleBlurChange}
273
+ scale={selectedImage.scale}
274
+ positionX={selectedImage.positionX}
275
+ positionY={selectedImage.positionY}
276
+ onBrightnessChange={handleBrightnessChangeWrapper}
277
+ onBlurChange={handleBlurChangeWrapper}
278
+ onScaleChange={async (newScale: number) => {
279
+ if (selectedImage) {
280
+ const updated = { ...selectedImage, scale: newScale };
281
+ setSelectedImage(updated);
282
+ await saveImageTransformHandler(newScale, updated.positionX, updated.positionY);
283
+ }
284
+ }}
285
+ onPositionXChange={async (newPositionX: number) => {
286
+ if (selectedImage) {
287
+ const updated = { ...selectedImage, positionX: newPositionX };
288
+ setSelectedImage(updated);
289
+ await saveImageTransformHandler(updated.scale, newPositionX, updated.positionY);
290
+ }
291
+ }}
292
+ onPositionYChange={async (newPositionY: number) => {
293
+ if (selectedImage) {
294
+ const updated = { ...selectedImage, positionY: newPositionY };
295
+ setSelectedImage(updated);
296
+ await saveImageTransformHandler(updated.scale, updated.positionX, newPositionY);
297
+ }
298
+ }}
299
+ onEditorSave={async (finalScale: number, finalPositionX: number, finalPositionY: number, finalBrightness?: number, finalBlur?: number) => {
300
+ if (selectedImage) {
301
+ // Update selectedImage with final values
302
+ // Use provided brightness/blur if available, otherwise use current values
303
+ const updated = {
304
+ ...selectedImage,
305
+ scale: finalScale,
306
+ positionX: finalPositionX,
307
+ positionY: finalPositionY,
308
+ brightness: finalBrightness !== undefined ? finalBrightness : selectedImage.brightness,
309
+ blur: finalBlur !== undefined ? finalBlur : selectedImage.blur
310
+ };
311
+ setSelectedImage(updated);
312
+ // Save with the final brightness and blur values
313
+ await saveImageTransformHandler(finalScale, finalPositionX, finalPositionY, true, updated.brightness, updated.blur);
314
+ }
315
+ }}
770
316
  showEffects={true}
771
317
  darkMode={false}
318
+ aspectRatio={selectedImage.aspectRatio}
319
+ borderRadius={selectedImage.borderRadius}
320
+ objectFit={selectedImage.objectFit}
321
+ objectPosition={selectedImage.objectPosition}
772
322
  />
773
323
  </div>
774
324
  </div>
775
325
  </div>
776
326
  );
777
327
  }
778
-