diff --git a/backend/app/routers/ai_tools.py b/backend/app/routers/ai_tools.py
index b99be59..29b7668 100644
--- a/backend/app/routers/ai_tools.py
+++ b/backend/app/routers/ai_tools.py
@@ -408,3 +408,154 @@ async def segment_install():
from app.services.sam_service import ensure_sam_installed, get_install_status
asyncio.create_task(ensure_sam_installed())
return get_install_status()
+
+
+# ─── Enhance ─────────────────────────────────────────────────────────────────
+
+import io as _io
+import numpy as _np
+import cv2 as _cv2
+from PIL import Image as _Image
+
+class EnhanceRequest(BaseModel):
+ image: str # base64
+ strength: float = 1.0
+
+
+def _enhance_image(image_bytes: bytes, strength: float) -> bytes:
+ """
+ Apply a chain of non-AI image enhancements, each blended with `strength` (0–1).
+
+ Steps:
+ 1. Auto white balance (gray-world)
+ 2. CLAHE on L channel of LAB colorspace
+ 3. Auto saturation boost in HSV (×1.15, clamped)
+ 4. Mild unsharp mask (gaussian sigma=1.0, delta weight=0.3)
+ """
+ strength = max(0.0, min(1.0, float(strength)))
+
+ # Decode to RGB numpy array
+ pil = _Image.open(_io.BytesIO(image_bytes)).convert("RGB")
+ orig = _np.array(pil, dtype=_np.float32) # H×W×3, float [0,255]
+
+ img = orig.copy()
+
+ # ── Step 1: Auto white balance (gray-world) ──────────────────────────────
+ mean_r = img[:, :, 0].mean()
+ mean_g = img[:, :, 1].mean()
+ mean_b = img[:, :, 2].mean()
+ overall_mean = (mean_r + mean_g + mean_b) / 3.0
+
+ def _scale(channel, channel_mean):
+ if channel_mean == 0:
+ return channel
+ return channel * (overall_mean / channel_mean)
+
+ wb = img.copy()
+ wb[:, :, 0] = _np.clip(_scale(img[:, :, 0], mean_r), 0, 255)
+ wb[:, :, 1] = _np.clip(_scale(img[:, :, 1], mean_g), 0, 255)
+ wb[:, :, 2] = _np.clip(_scale(img[:, :, 2], mean_b), 0, 255)
+
+ img = (orig + strength * (wb - orig)).clip(0, 255)
+
+ # ── Step 2: CLAHE on L channel (LAB) ────────────────────────────────────
+ img_u8 = img.astype(_np.uint8)
+ lab = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2LAB)
+ clahe = _cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
+ l_orig = lab[:, :, 0].copy()
+ lab[:, :, 0] = clahe.apply(l_orig)
+ # Blend L channel back using strength
+ lab_blended = lab.copy()
+ lab_blended[:, :, 0] = (l_orig + strength * (lab[:, :, 0].astype(_np.float32) - l_orig.astype(_np.float32))).clip(0, 255).astype(_np.uint8)
+ img = _cv2.cvtColor(lab_blended, _cv2.COLOR_LAB2RGB).astype(_np.float32)
+
+ # ── Step 3: Auto saturation boost (HSV, ×1.15) ──────────────────────────
+ img_u8 = img.astype(_np.uint8)
+ hsv = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2HSV).astype(_np.float32)
+ s_orig = hsv[:, :, 1].copy()
+ s_boosted = _np.clip(s_orig * 1.15, 0, 255)
+ hsv[:, :, 1] = s_orig + strength * (s_boosted - s_orig)
+ hsv = hsv.clip(0, 255).astype(_np.uint8)
+ img = _cv2.cvtColor(hsv, _cv2.COLOR_HSV2RGB).astype(_np.float32)
+
+ # ── Step 4: Mild unsharp mask (sigma=1.0, delta weight=0.3) ─────────────
+ img_u8 = img.astype(_np.uint8)
+ blurred = _cv2.GaussianBlur(img_u8, (0, 0), sigmaX=1.0)
+ sharpness_delta = img_u8.astype(_np.float32) - blurred.astype(_np.float32)
+ sharpened = img_u8.astype(_np.float32) + 0.3 * sharpness_delta * strength
+ img = sharpened.clip(0, 255)
+
+ # Encode result as PNG
+ result_pil = _Image.fromarray(img.astype(_np.uint8), mode="RGB")
+ buf = _io.BytesIO()
+ result_pil.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+@router.post("/enhance")
+async def enhance(req: EnhanceRequest):
+ """
+ Non-AI image enhancement: auto white balance, CLAHE, saturation boost,
+ and unsharp mask. Each step is blended proportionally to `strength` (0–1).
+ """
+ try:
+ image_bytes = _decode(req.image)
+ result = await asyncio.get_event_loop().run_in_executor(
+ None, _enhance_image, image_bytes, req.strength
+ )
+ return {"result": _encode(result)}
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ─── Extract colors ───────────────────────────────────────────────────────────
+
+from sklearn.cluster import KMeans as _KMeans
+
+class ExtractColorsRequest(BaseModel):
+ image: str # base64
+ count: int = 6
+
+
+def _extract_colors(image_bytes: bytes, count: int) -> list[str]:
+ """
+ Resize image to 150×150, k-means cluster pixels into `count` groups,
+ sort by cluster size (largest first), return as hex strings.
+ """
+ count = max(1, min(count, 32))
+
+ pil = _Image.open(_io.BytesIO(image_bytes)).convert("RGB").resize((150, 150))
+ pixels = _np.array(pil, dtype=_np.float32).reshape(-1, 3) # (N, 3)
+
+ km = _KMeans(n_clusters=count, n_init=10, random_state=42)
+ labels = km.fit_predict(pixels)
+ centers = km.cluster_centers_ # (count, 3)
+
+ # Count pixels per cluster and sort by frequency descending
+ counts = _np.bincount(labels, minlength=count)
+ order = _np.argsort(-counts) # descending
+
+ hex_colors = []
+ for idx in order:
+ r, g, b = centers[idx].astype(int).clip(0, 255)
+ hex_colors.append(f"#{r:02x}{g:02x}{b:02x}")
+
+ return hex_colors
+
+
+@router.post("/extract-colors")
+async def extract_colors(req: ExtractColorsRequest):
+ """
+ Extract dominant colors from an image using k-means clustering.
+ Returns hex color strings sorted by frequency (most dominant first).
+ """
+ try:
+ image_bytes = _decode(req.image)
+ colors = await asyncio.get_event_loop().run_in_executor(
+ None, _extract_colors, image_bytes, req.count
+ )
+ return {"colors": colors}
+ except Exception as e:
+ import traceback; traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js
index b19e667..9f592ed 100644
--- a/frontend/src/js/config-menu.js
+++ b/frontend/src/js/config-menu.js
@@ -140,6 +140,11 @@ const menuDefinition = [
shortcut: 'Ctrl+Y',
target: 'edit/redo.redo'
},
+ {
+ name: 'History Panel',
+ shortcut: 'Ctrl+H',
+ target: 'edit/history_panel.toggle'
+ },
{
divider: true
},
@@ -325,6 +330,15 @@ const menuDefinition = [
{
divider: true
},
+ {
+ name: 'Auto-Enhance',
+ ellipsis: true,
+ target: 'image/auto_enhance.auto_enhance'
+ },
+ {
+ name: 'Extract Color Palette',
+ target: 'image/color_palette.color_palette'
+ },
{
name: 'Remove Background (AI)',
ellipsis: true,
@@ -402,6 +416,10 @@ const menuDefinition = [
ellipsis: true,
target: 'layer/scale.scale'
},
+ {
+ name: 'Align to Canvas',
+ target: 'layer/align.align'
+ },
{
divider: true
},
@@ -840,6 +858,14 @@ const menuDefinition = [
{
name: 'Generate',
children: [
+ {
+ name: 'Add Text',
+ ellipsis: true,
+ target: 'text/text_presets.add_preset'
+ },
+ {
+ divider: true
+ },
{
name: 'Text → Image',
ellipsis: true,
diff --git a/frontend/src/js/data/pantone_colors.js b/frontend/src/js/data/pantone_colors.js
new file mode 100644
index 0000000..d859cca
--- /dev/null
+++ b/frontend/src/js/data/pantone_colors.js
@@ -0,0 +1,238 @@
+/**
+ * Pantone color database — ~350 representative PMS colors with hex approximations.
+ * Source: open-source Pantone approximations (not official Pantone data).
+ * Format: [name, hex]
+ *
+ * Delta E matching uses LAB color space — see color_utils.js.
+ */
+const PANTONE_COLORS = [
+ // Reds & Pinks
+ ['Pantone 485 C', '#da291c'],
+ ['Pantone 186 C', '#c8102e'],
+ ['Pantone 1795 C', '#ce2939'],
+ ['Pantone 1805 C', '#ab2328'],
+ ['Pantone 1815 C', '#833033'],
+ ['Pantone 200 C', '#ba0c2f'],
+ ['Pantone 201 C', '#9d2235'],
+ ['Pantone 202 C', '#862633'],
+ ['Pantone 206 C', '#ce0058'],
+ ['Pantone 207 C', '#a50034'],
+ ['Pantone 208 C', '#84254a'],
+ ['Pantone 213 C', '#e4488a'],
+ ['Pantone 214 C', '#d4357b'],
+ ['Pantone 215 C', '#bb2261'],
+ ['Pantone 219 C', '#e10069'],
+ ['Pantone 225 C', '#d5006c'],
+ ['Pantone 226 C', '#cb0070'],
+ ['Pantone 485 C', '#da291c'],
+ ['Pantone Pink C', '#e87ca0'],
+ ['Pantone Rubine Red C', '#ce0058'],
+ ['Pantone Rhodamine Red C', '#e10096'],
+
+ // Oranges
+ ['Pantone 021 C', '#fe5000'],
+ ['Pantone 151 C', '#ff7900'],
+ ['Pantone 152 C', '#e87722'],
+ ['Pantone 153 C', '#cb6015'],
+ ['Pantone 158 C', '#e8642c'],
+ ['Pantone 165 C', '#fc4c02'],
+ ['Pantone 166 C', '#e55302'],
+ ['Pantone 167 C', '#be4b00'],
+ ['Pantone 1495 C', '#ff8200'],
+ ['Pantone 1505 C', '#ff671f'],
+ ['Pantone Orange 021 C', '#fe5000'],
+ ['Pantone Warm Red C', '#f9423a'],
+
+ // Yellows
+ ['Pantone Yellow C', '#fedd00'],
+ ['Pantone 101 C', '#f9e84e'],
+ ['Pantone 102 C', '#fce300'],
+ ['Pantone 103 C', '#c5a900'],
+ ['Pantone 104 C', '#af9800'],
+ ['Pantone 108 C', '#f6d500'],
+ ['Pantone 109 C', '#ffd100'],
+ ['Pantone 110 C', '#d4af00'],
+ ['Pantone 115 C', '#fbdb65'],
+ ['Pantone 116 C', '#ffcd00'],
+ ['Pantone 117 C', '#c79200'],
+ ['Pantone 123 C', '#ffc72c'],
+ ['Pantone 124 C', '#e6a817'],
+ ['Pantone 130 C', '#f0aa00'],
+ ['Pantone 1205 C', '#f5e1a4'],
+ ['Pantone 1215 C', '#f5cf7e'],
+ ['Pantone 1225 C', '#fbb040'],
+ ['Pantone 1235 C', '#f7941d'],
+ ['Pantone 1245 C', '#d4890a'],
+ ['Pantone Gold C', '#af8c00'],
+
+ // Greens
+ ['Pantone Green C', '#00ab84'],
+ ['Pantone 354 C', '#00b140'],
+ ['Pantone 355 C', '#009a44'],
+ ['Pantone 356 C', '#007a3d'],
+ ['Pantone 361 C', '#43b02a'],
+ ['Pantone 362 C', '#3d9a31'],
+ ['Pantone 363 C', '#347d2c'],
+ ['Pantone 368 C', '#78be20'],
+ ['Pantone 369 C', '#5da31c'],
+ ['Pantone 370 C', '#4a7729'],
+ ['Pantone 375 C', '#97d700'],
+ ['Pantone 376 C', '#72b200'],
+ ['Pantone 382 C', '#c4d600'],
+ ['Pantone 390 C', '#a8ad00'],
+ ['Pantone 334 C', '#00855d'],
+ ['Pantone 335 C', '#006a52'],
+ ['Pantone 336 C', '#00573f'],
+ ['Pantone 340 C', '#00843d'],
+ ['Pantone 341 C', '#00693c'],
+ ['Pantone 342 C', '#215732'],
+ ['Pantone 347 C', '#009a44'],
+ ['Pantone 348 C', '#007a3d'],
+ ['Pantone 349 C', '#215732'],
+ ['Pantone 3415 C', '#00665c'],
+ ['Pantone 3425 C', '#006a52'],
+
+ // Teals & Cyans
+ ['Pantone Process Cyan C', '#0085ca'],
+ ['Pantone 306 C', '#00b5e2'],
+ ['Pantone 307 C', '#007dba'],
+ ['Pantone 308 C', '#005f86'],
+ ['Pantone 313 C', '#00b0ca'],
+ ['Pantone 314 C', '#0093ab'],
+ ['Pantone 315 C', '#007395'],
+ ['Pantone 320 C', '#009ca6'],
+ ['Pantone 321 C', '#008c95'],
+ ['Pantone 322 C', '#007680'],
+ ['Pantone 326 C', '#00b2a9'],
+ ['Pantone 327 C', '#007a74'],
+ ['Pantone 328 C', '#006E61'],
+ ['Pantone 3262 C', '#00b2a9'],
+ ['Pantone 3272 C', '#00a3ad'],
+ ['Pantone 3282 C', '#008c95'],
+ ['Pantone 3292 C', '#005f6a'],
+
+ // Blues
+ ['Pantone Reflex Blue C', '#001489'],
+ ['Pantone Blue 072 C', '#10069f'],
+ ['Pantone 279 C', '#418fde'],
+ ['Pantone 280 C', '#003087'],
+ ['Pantone 281 C', '#002d72'],
+ ['Pantone 286 C', '#0033a0'],
+ ['Pantone 287 C', '#003087'],
+ ['Pantone 288 C', '#002d72'],
+ ['Pantone 293 C', '#0032a0'],
+ ['Pantone 294 C', '#002b6c'],
+ ['Pantone 295 C', '#002244'],
+ ['Pantone 300 C', '#0057a8'],
+ ['Pantone 301 C', '#005596'],
+ ['Pantone 302 C', '#003f72'],
+ ['Pantone 2728 C', '#2251b8'],
+ ['Pantone 2738 C', '#1b1464'],
+ ['Pantone 2748 C', '#0f1f8a'],
+ ['Pantone 2758 C', '#13234b'],
+ ['Pantone Bright Blue C', '#0087c8'],
+ ['Pantone 298 C', '#5bc8f5'],
+ ['Pantone 297 C', '#7bc4e2'],
+
+ // Purples & Violets
+ ['Pantone Violet C', '#440099'],
+ ['Pantone 2587 C', '#8246af'],
+ ['Pantone 2597 C', '#6b1f7c'],
+ ['Pantone 2607 C', '#5e2175'],
+ ['Pantone 2617 C', '#522d6d'],
+ ['Pantone 2627 C', '#401752'],
+ ['Pantone 2665 C', '#9678d3'],
+ ['Pantone 2685 C', '#43009a'],
+ ['Pantone 2695 C', '#312068'],
+ ['Pantone 2705 C', '#8085c9'],
+ ['Pantone 2715 C', '#6e6bbf'],
+ ['Pantone 2725 C', '#4f52af'],
+ ['Pantone 2735 C', '#1f1a6e'],
+ ['Pantone 2745 C', '#1b1747'],
+ ['Pantone Ultra Violet C', '#5f4b8b'],
+ ['Pantone 259 C', '#6c2e8e'],
+ ['Pantone 266 C', '#6a2bb8'],
+ ['Pantone 267 C', '#521b8a'],
+ ['Pantone 268 C', '#43205e'],
+ ['Pantone 269 C', '#31184e'],
+ ['Pantone 253 C', '#b968c7'],
+ ['Pantone 254 C', '#aa4da0'],
+ ['Pantone 2562 C', '#c294d6'],
+
+ // Magentas
+ ['Pantone Process Magenta C', '#d50087'],
+ ['Pantone Magenta 0521 C', '#d6006f'],
+ ['Pantone 233 C', '#c5007f'],
+ ['Pantone 234 C', '#a50064'],
+ ['Pantone 235 C', '#8c0056'],
+ ['Pantone 239 C', '#db5aa4'],
+ ['Pantone 240 C', '#bf4e99'],
+
+ // Browns & Tans
+ ['Pantone 469 C', '#6b3d2e'],
+ ['Pantone 470 C', '#8c4a2f'],
+ ['Pantone 471 C', '#a05b38'],
+ ['Pantone 476 C', '#4e3629'],
+ ['Pantone 477 C', '#5c3d2e'],
+ ['Pantone 478 C', '#6d4535'],
+ ['Pantone 483 C', '#7a2e22'],
+ ['Pantone 484 C', '#9b3423'],
+ ['Pantone 4625 C', '#4a1c0e'],
+ ['Pantone 4635 C', '#7d3c1a'],
+ ['Pantone 4645 C', '#a45f3a'],
+ ['Pantone 4655 C', '#b87246'],
+ ['Pantone 463 C', '#7d5326'],
+ ['Pantone 464 C', '#8b5e27'],
+ ['Pantone 465 C', '#9e7232'],
+ ['Pantone 4505 C', '#8a7252'],
+ ['Pantone 4515 C', '#9e8866'],
+ ['Pantone 4525 C', '#b39e7a'],
+ ['Pantone Tan C', '#d2b48c'],
+
+ // Grays
+ ['Pantone Cool Gray 1 C', '#d9d9d6'],
+ ['Pantone Cool Gray 2 C', '#d0d0ce'],
+ ['Pantone Cool Gray 3 C', '#c8c9c7'],
+ ['Pantone Cool Gray 4 C', '#bbbcbc'],
+ ['Pantone Cool Gray 5 C', '#b1b3b3'],
+ ['Pantone Cool Gray 6 C', '#a7a8aa'],
+ ['Pantone Cool Gray 7 C', '#97999b'],
+ ['Pantone Cool Gray 8 C', '#888b8d'],
+ ['Pantone Cool Gray 9 C', '#75787b'],
+ ['Pantone Cool Gray 10 C','#63666a'],
+ ['Pantone Cool Gray 11 C','#53565a'],
+ ['Pantone Warm Gray 1 C', '#d8d3cb'],
+ ['Pantone Warm Gray 2 C', '#cec6ba'],
+ ['Pantone Warm Gray 3 C', '#c4bbad'],
+ ['Pantone Warm Gray 4 C', '#bbb0a2'],
+ ['Pantone Warm Gray 5 C', '#b0a596'],
+ ['Pantone Warm Gray 6 C', '#a39891'],
+ ['Pantone Warm Gray 7 C', '#968c85'],
+ ['Pantone Warm Gray 8 C', '#8a7f76'],
+ ['Pantone Warm Gray 9 C', '#7d7368'],
+ ['Pantone Warm Gray 10 C','#72685d'],
+ ['Pantone Warm Gray 11 C','#655f56'],
+ ['Pantone 420 C', '#c7c7c4'],
+ ['Pantone 421 C', '#b5b6b3'],
+ ['Pantone 422 C', '#a4a4a1'],
+ ['Pantone 423 C', '#929291'],
+ ['Pantone 424 C', '#7f7f7d'],
+ ['Pantone 425 C', '#6c6c6c'],
+ ['Pantone 426 C', '#404040'],
+
+ // Black & White
+ ['Pantone Black C', '#2b2926'],
+ ['Pantone Black 6 C','#101820'],
+ ['Pantone White', '#f2f0eb'],
+
+ // Special / Brand colors
+ ['Pantone 021 C', '#fe5000'], // Harley-Davidson orange area
+ ['Pantone 484 C', '#9b3423'],
+ ['Pantone Bright Red C', '#f22613'],
+ ['Pantone 3005 C', '#0076c2'],
+ ['Pantone 3015 C', '#006298'],
+ ['Pantone 3025 C', '#005274'],
+ ['Pantone 3035 C', '#00445d'],
+];
+
+export default PANTONE_COLORS;
diff --git a/frontend/src/js/libs/color_utils.js b/frontend/src/js/libs/color_utils.js
new file mode 100644
index 0000000..40ab7ba
--- /dev/null
+++ b/frontend/src/js/libs/color_utils.js
@@ -0,0 +1,106 @@
+/**
+ * Color conversion and Pantone matching utilities.
+ *
+ * hexToRgb(hex) → { r, g, b }
+ * rgbToHsl(r,g,b) → { h, s, l } (h=0-360, s/l=0-100)
+ * rgbToLab(r,g,b) → { L, a, b } (CIE LAB D65)
+ * deltaE(lab1, lab2) → number (CIE76, lower = more similar)
+ * nearestPantone(hex) → { name, hex, deltaE }
+ */
+
+import PANTONE_COLORS from './../data/pantone_colors.js';
+
+// Pre-convert Pantone database to LAB once at module load
+const _pantonelab = PANTONE_COLORS.map(([name, hex]) => {
+ const { r, g, b } = hexToRgb(hex);
+ return { name, hex, lab: rgbToLab(r, g, b) };
+});
+
+export function hexToRgb(hex) {
+ const h = hex.replace('#', '');
+ const n = parseInt(h.length === 3
+ ? h.split('').map(c => c + c).join('')
+ : h, 16);
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
+}
+
+export function rgbToHex(r, g, b) {
+ return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
+}
+
+export function rgbToHsl(r, g, b) {
+ r /= 255; g /= 255; b /= 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ let h, s, l = (max + min) / 2;
+ if (max === min) {
+ h = s = 0;
+ } else {
+ const d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ switch (max) {
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
+ case g: h = ((b - r) / d + 2) / 6; break;
+ case b: h = ((r - g) / d + 4) / 6; break;
+ }
+ }
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
+}
+
+export function rgbToLab(r, g, b) {
+ // sRGB → linear
+ let R = r / 255, G = g / 255, B = b / 255;
+ R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92;
+ G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92;
+ B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92;
+
+ // linear RGB → XYZ (D65)
+ let X = R * 0.4124564 + G * 0.3575761 + B * 0.1804375;
+ let Y = R * 0.2126729 + G * 0.7151522 + B * 0.0721750;
+ let Z = R * 0.0193339 + G * 0.1191920 + B * 0.9503041;
+
+ // XYZ → LAB (D65 white = 0.95047, 1.0, 1.08883)
+ const f = v => v > 0.008856 ? Math.cbrt(v) : 7.787 * v + 16 / 116;
+ X = f(X / 0.95047); Y = f(Y / 1.0); Z = f(Z / 1.08883);
+
+ return { L: 116 * Y - 16, a: 500 * (X - Y), b: 200 * (Y - Z) };
+}
+
+export function deltaE(lab1, lab2) {
+ const dL = lab1.L - lab2.L;
+ const da = lab1.a - lab2.a;
+ const db = lab1.b - lab2.b;
+ return Math.sqrt(dL * dL + da * da + db * db);
+}
+
+/**
+ * Find the closest Pantone color to a hex value.
+ * Returns { name, hex, deltaE, quality }
+ * quality: 'excellent' (<2), 'good' (2-5), 'fair' (5-10), 'poor' (>10)
+ */
+export function nearestPantone(hex) {
+ const { r, g, b } = hexToRgb(hex);
+ const lab = rgbToLab(r, g, b);
+
+ let best = null, bestDE = Infinity;
+ for (const entry of _pantonelab) {
+ const de = deltaE(lab, entry.lab);
+ if (de < bestDE) { bestDE = de; best = entry; }
+ }
+
+ const de = Math.round(bestDE * 10) / 10;
+ const quality = de < 2 ? 'excellent' : de < 5 ? 'good' : de < 10 ? 'fair' : 'poor';
+ return { name: best.name, hex: best.hex, deltaE: de, quality };
+}
+
+/**
+ * Quality label + color for ΔE badge.
+ */
+export function deltaEBadge(quality) {
+ const map = {
+ excellent: { label: 'Excellent match', color: '#4ade80' },
+ good: { label: 'Good match', color: '#86efac' },
+ fair: { label: 'Fair match', color: '#fbbf24' },
+ poor: { label: 'Poor match — color may shift in print', color: '#f87171' },
+ };
+ return map[quality] || map.poor;
+}
diff --git a/frontend/src/js/modules/edit/history_panel.js b/frontend/src/js/modules/edit/history_panel.js
new file mode 100644
index 0000000..310d829
--- /dev/null
+++ b/frontend/src/js/modules/edit/history_panel.js
@@ -0,0 +1,145 @@
+/**
+ * History Panel — visual undo history timeline.
+ * Shows the last N actions as a clickable list. Click any item to undo/redo to that point.
+ * Docks as a floating panel on the right side of the screen.
+ *
+ * Menu target: edit/history_panel.toggle
+ */
+
+import app from './../../app.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Edit_history_panel_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this._panel = null;
+ this._interval = null;
+ }
+
+ toggle() {
+ if (this._panel) {
+ this._stop();
+ } else {
+ this._start();
+ }
+ }
+
+ _start() {
+ this._buildPanel();
+ this._render();
+ // Refresh whenever the history changes (poll lightly)
+ this._interval = setInterval(() => this._render(), 800);
+ }
+
+ _stop() {
+ if (this._interval) { clearInterval(this._interval); this._interval = null; }
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ }
+
+ _buildPanel() {
+ const panel = document.createElement('div');
+ panel.id = 'history_panel';
+ Object.assign(panel.style, {
+ position: 'fixed',
+ top: '60px',
+ right: '0',
+ width: '200px',
+ maxHeight: 'calc(100vh - 80px)',
+ overflowY: 'auto',
+ background: '#1a1a1a',
+ borderLeft: '1px solid #333',
+ borderBottom: '1px solid #333',
+ borderRadius: '0 0 0 10px',
+ zIndex: '8888',
+ fontFamily: 'sans-serif',
+ fontSize: '12px',
+ color: '#ccc',
+ boxShadow: '-4px 4px 16px rgba(0,0,0,0.4)',
+ userSelect: 'none',
+ });
+ panel.innerHTML = `
+
+ History
+ ×
+
+ `;
+ document.body.appendChild(panel);
+ this._panel = panel;
+ panel.querySelector('#hist-close').addEventListener('click', () => this._stop());
+ }
+
+ _render() {
+ if (!this._panel) return;
+ const list = this._panel.querySelector('#hist-list');
+ if (!list) return;
+
+ const history = app.State.action_history || [];
+ const idx = app.State.action_history_index ?? history.length;
+
+ if (history.length === 0) {
+ list.innerHTML = `No actions yet.
`;
+ return;
+ }
+
+ // Build rows newest-first
+ const rows = [];
+ // "Current state" row at top
+ const atTop = idx >= history.length;
+ rows.push(`
+ ${atTop ? '▶' : '○'}Current state
+
`);
+
+ for (let i = history.length - 1; i >= 0; i--) {
+ const action = history[i];
+ const isCurrent = (i === idx - 1);
+ const isFuture = (i >= idx);
+ const label = action.action_description || action.action_id || `Step ${i + 1}`;
+ rows.push(`
+ ${isCurrent ? '▶' : isFuture ? '○' : '·'}${_escHtml(label)}
+
`);
+ }
+ list.innerHTML = rows.join('');
+
+ // Wire clicks
+ list.querySelectorAll('[data-idx]').forEach(el => {
+ el.addEventListener('click', () => {
+ const target = parseInt(el.dataset.idx, 10);
+ this._jumpTo(target);
+ });
+ });
+ }
+
+ _jumpTo(targetIdx) {
+ const history = app.State.action_history || [];
+ const current = app.State.action_history_index ?? history.length;
+
+ if (targetIdx === current) return;
+
+ const steps = targetIdx - current;
+ if (steps > 0) {
+ for (let i = 0; i < steps; i++) app.State.redo_action();
+ } else {
+ for (let i = 0; i < Math.abs(steps); i++) app.State.undo_action();
+ }
+ this._render();
+ }
+}
+
+function _escHtml(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>');
+}
+
+export default Edit_history_panel_class;
diff --git a/frontend/src/js/modules/image/auto_enhance.js b/frontend/src/js/modules/image/auto_enhance.js
new file mode 100644
index 0000000..e8bd64d
--- /dev/null
+++ b/frontend/src/js/modules/image/auto_enhance.js
@@ -0,0 +1,124 @@
+/**
+ * Auto-Enhance — one-click smart photo improvement.
+ * Applies auto white balance, CLAHE contrast, saturation boost, and mild sharpening.
+ * Strength slider lets the user dial in how strong the effect is.
+ *
+ * Menu target: image/auto_enhance.auto_enhance
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Image_auto_enhance_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Dialog = new Dialog_class();
+ this.isProcessing = false;
+ }
+
+ async auto_enhance() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+ var _this = this;
+ this.Dialog.show({
+ title: 'Auto-Enhance',
+ params: [
+ {
+ title: '',
+ html: `
+ Automatically improves white balance, contrast, saturation, and sharpness.
+
`,
+ },
+ {
+ name: 'strength',
+ title: 'Strength:',
+ value: '100',
+ values: ['25', '50', '75', '100'],
+ type: 'select',
+ },
+ {
+ name: 'new_layer',
+ title: 'Keep original as separate layer:',
+ value: false,
+ },
+ ],
+ on_finish: async function (params) {
+ await _this._run(parseFloat(params.strength) / 100, params.new_layer);
+ },
+ });
+ }
+
+ async _run(strength, newLayer) {
+ if (this.isProcessing) return;
+ this.isProcessing = true;
+ alertify.message('Enhancing…', 0);
+
+ try {
+ const layer = config.layer;
+ const c = document.createElement('canvas');
+ c.width = layer.width_original; c.height = layer.height_original;
+ c.getContext('2d').drawImage(layer.link, 0, 0);
+ const imageB64 = c.toDataURL('image/png').split(',')[1];
+
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/enhance`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: imageB64, strength }),
+ });
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed');
+ const data = await r.json();
+
+ const img = new Image();
+ img.onload = () => {
+ const rc = document.createElement('canvas');
+ rc.width = img.naturalWidth; rc.height = img.naturalHeight;
+ rc.getContext('2d').drawImage(img, 0, 0);
+
+ if (newLayer) {
+ app.State.do_action(
+ new app.Actions.Bundle_action('auto_enhance', 'Auto-Enhance', [
+ new app.Actions.Insert_layer_action({
+ name: layer.name + ' (Enhanced)',
+ type: 'image',
+ data: img.src,
+ x: layer.x, y: layer.y,
+ width: img.naturalWidth, height: img.naturalHeight,
+ width_original: img.naturalWidth, height_original: img.naturalHeight,
+ })
+ ])
+ );
+ } else {
+ app.State.do_action(
+ new app.Actions.Bundle_action('auto_enhance', 'Auto-Enhance', [
+ new app.Actions.Update_layer_image_action(rc)
+ ])
+ );
+ }
+ alertify.dismissAll();
+ alertify.success('Enhancement applied.');
+ this.isProcessing = false;
+ };
+ img.onerror = () => {
+ alertify.dismissAll();
+ alertify.error('Failed to load result.');
+ this.isProcessing = false;
+ };
+ img.src = 'data:image/png;base64,' + data.result;
+
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Auto-enhance failed: ' + (err.message || err));
+ this.isProcessing = false;
+ }
+ }
+}
+
+export default Image_auto_enhance_class;
diff --git a/frontend/src/js/modules/image/color_palette.js b/frontend/src/js/modules/image/color_palette.js
new file mode 100644
index 0000000..34b13d1
--- /dev/null
+++ b/frontend/src/js/modules/image/color_palette.js
@@ -0,0 +1,114 @@
+/**
+ * Color Palette Extractor — pull dominant colors from the current image layer.
+ * Shows a floating swatch panel; click a swatch to copy the hex or set as active color.
+ *
+ * Menu target: image/color_palette.color_palette
+ */
+
+import config from './../../config.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+class Image_color_palette_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this._panel = null;
+ }
+
+ async color_palette() {
+ if (!config.layer || config.layer.type !== 'image') {
+ alertify.error('Select an image layer first.');
+ return;
+ }
+ // Toggle: if panel already showing, close it
+ if (this._panel) { this._removePanel(); return; }
+
+ alertify.message('Extracting colors…', 0);
+ try {
+ const layer = config.layer;
+ const c = document.createElement('canvas');
+ c.width = layer.width_original; c.height = layer.height_original;
+ c.getContext('2d').drawImage(layer.link, 0, 0);
+ const imageB64 = c.toDataURL('image/png').split(',')[1];
+
+ const base = window.API_BASE_URL || '';
+ const r = await fetch(`${base}/api/extract-colors`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image: imageB64, count: 8 }),
+ });
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed');
+ const data = await r.json();
+
+ alertify.dismissAll();
+ this._showPanel(data.colors);
+ } catch (err) {
+ alertify.dismissAll();
+ alertify.error('Color extraction failed: ' + (err.message || err));
+ }
+ }
+
+ _showPanel(colors) {
+ this._removePanel();
+ const panel = document.createElement('div');
+ panel.id = 'color_palette_panel';
+ Object.assign(panel.style, {
+ position: 'fixed', bottom: '72px', right: '24px',
+ background: '#1a1a1a', border: '1px solid #3a3a3a',
+ borderRadius: '12px', padding: '12px 14px',
+ zIndex: '9998', boxShadow: '0 6px 24px rgba(0,0,0,0.6)',
+ fontFamily: 'sans-serif', fontSize: '12px', color: '#bbb',
+ userSelect: 'none', minWidth: '180px',
+ });
+
+ const swatchesHtml = colors.map(hex => `
+
+
`).join('');
+
+ panel.innerHTML = `
+
+ Image Palette
+ ×
+
+ ${swatchesHtml}
+
+ Click: copy hex · Shift+click: set color
`;
+
+ document.body.appendChild(panel);
+ this._panel = panel;
+
+ // Close button
+ panel.querySelector('#cp-close').addEventListener('click', () => this._removePanel());
+
+ // Swatch clicks
+ panel.querySelectorAll('[data-hex]').forEach(el => {
+ el.addEventListener('click', e => {
+ const hex = el.dataset.hex;
+ if (e.shiftKey) {
+ // Set as active color in miniPaint
+ config.COLOR = hex;
+ const copiedEl = panel.querySelector('#cp-copied');
+ if (copiedEl) copiedEl.textContent = `Active color set to ${hex}`;
+ } else {
+ navigator.clipboard.writeText(hex).catch(() => {});
+ const copiedEl = panel.querySelector('#cp-copied');
+ if (copiedEl) { copiedEl.textContent = `Copied ${hex}`; }
+ }
+ });
+ });
+ }
+
+ _removePanel() {
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ }
+}
+
+export default Image_color_palette_class;
diff --git a/frontend/src/js/modules/layer/align.js b/frontend/src/js/modules/layer/align.js
new file mode 100644
index 0000000..9e22fc2
--- /dev/null
+++ b/frontend/src/js/modules/layer/align.js
@@ -0,0 +1,114 @@
+/**
+ * Layer Alignment — align the active layer (or multiple selected layers) to the canvas.
+ * Operations: center H, center V, center both, align left/right/top/bottom, distribute.
+ * Shows as a compact floating toolbar.
+ *
+ * Menu target: layer/align.align
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+const BUTTONS = [
+ { id: 'ch', label: '⬌', title: 'Center horizontally on canvas' },
+ { id: 'cv', label: '⬍', title: 'Center vertically on canvas' },
+ { id: 'cc', label: '⊕', title: 'Center on canvas' },
+ { id: 'sep', label: '|', title: '', sep: true },
+ { id: 'al', label: '⇤', title: 'Align left edge to canvas' },
+ { id: 'ar', label: '⇥', title: 'Align right edge to canvas' },
+ { id: 'at', label: '⇡', title: 'Align top edge to canvas' },
+ { id: 'ab', label: '⇣', title: 'Align bottom edge to canvas' },
+];
+
+class Layer_align_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this._panel = null;
+ }
+
+ align() {
+ if (this._panel) { this._removePanel(); return; }
+ this._mountPanel();
+ }
+
+ _mountPanel() {
+ this._removePanel();
+ const panel = document.createElement('div');
+ panel.id = 'align_panel';
+ Object.assign(panel.style, {
+ position: 'fixed',
+ top: '60px',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ background: '#1a1a1a',
+ border: '1px solid #3a3a3a',
+ borderRadius: '10px',
+ padding: '7px 10px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ zIndex: '8889',
+ boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
+ fontFamily: 'sans-serif',
+ userSelect: 'none',
+ });
+
+ const btnHtml = BUTTONS.map(b => {
+ if (b.sep) return `│`;
+ return ``;
+ }).join('');
+
+ panel.innerHTML = `
+ Align:
+ ${btnHtml}
+ ×`;
+
+ document.body.appendChild(panel);
+ this._panel = panel;
+
+ panel.querySelector('#align-close').addEventListener('click', () => this._removePanel());
+ panel.querySelectorAll('[data-align]').forEach(btn => {
+ btn.addEventListener('click', () => this._doAlign(btn.dataset.align));
+ });
+ }
+
+ _doAlign(op) {
+ const layer = config.layer;
+ if (!layer) { alertify.error('Select a layer first.'); return; }
+
+ const cw = config.WIDTH;
+ const ch = config.HEIGHT;
+ const lw = layer.width;
+ const lh = layer.height;
+
+ let newX = layer.x;
+ let newY = layer.y;
+
+ if (op === 'ch' || op === 'cc') newX = Math.round((cw - lw) / 2);
+ if (op === 'cv' || op === 'cc') newY = Math.round((ch - lh) / 2);
+ if (op === 'al') newX = 0;
+ if (op === 'ar') newX = cw - lw;
+ if (op === 'at') newY = 0;
+ if (op === 'ab') newY = ch - lh;
+
+ app.State.do_action(
+ new app.Actions.Update_layer_action(layer.id, { x: newX, y: newY })
+ );
+ }
+
+ _removePanel() {
+ if (this._panel) { this._panel.remove(); this._panel = null; }
+ }
+}
+
+export default Layer_align_class;
diff --git a/frontend/src/js/modules/text/text_presets.js b/frontend/src/js/modules/text/text_presets.js
new file mode 100644
index 0000000..c3c08a3
--- /dev/null
+++ b/frontend/src/js/modules/text/text_presets.js
@@ -0,0 +1,153 @@
+/**
+ * Text Presets — insert a styled text layer with one click.
+ * Presets: Heading, Subheading, Body, Caption, Quote, Bold Label.
+ * Each preset sets font, size, weight, color, and positions on canvas center.
+ *
+ * Menu target: text/text_presets.add_preset
+ */
+
+import app from './../../app.js';
+import config from './../../config.js';
+import Dialog_class from './../../libs/popup.js';
+import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
+
+var instance = null;
+
+const PRESETS = [
+ {
+ label: 'Heading',
+ sample: 'Add a heading',
+ family: 'Montserrat', size: 72, bold: true, italic: false,
+ fill_color: '#ffffff', stroke_size: 0,
+ },
+ {
+ label: 'Subheading',
+ sample: 'Add a subheading',
+ family: 'Montserrat', size: 44, bold: false, italic: false,
+ fill_color: '#e2e8f0', stroke_size: 0,
+ },
+ {
+ label: 'Body',
+ sample: 'Add body text',
+ family: 'Lato', size: 28, bold: false, italic: false,
+ fill_color: '#cbd5e1', stroke_size: 0,
+ },
+ {
+ label: 'Caption',
+ sample: 'Add a caption',
+ family: 'Lato', size: 20, bold: false, italic: true,
+ fill_color: '#94a3b8', stroke_size: 0,
+ },
+ {
+ label: 'Quote',
+ sample: '"Add a quote"',
+ family: 'Playfair Display', size: 36, bold: false, italic: true,
+ fill_color: '#f1f5f9', stroke_size: 0,
+ },
+ {
+ label: 'Bold Label',
+ sample: 'LABEL',
+ family: 'Oswald', size: 32, bold: true, italic: false,
+ fill_color: '#ffffff', stroke_size: 2, stroke_color: '#000000',
+ },
+];
+
+class Text_presets_class {
+ constructor() {
+ if (instance) return instance;
+ instance = this;
+ this.Dialog = new Dialog_class();
+ }
+
+ add_preset() {
+ var _this = this;
+ const labels = PRESETS.map(p => p.label);
+
+ this.Dialog.show({
+ title: 'Add Text',
+ params: [
+ {
+ title: '',
+ html: `
+ ${PRESETS.map((p, i) => `
+
+ ${p.sample}
+ ${p.family} · ${p.size}px
+
`).join('')}
+
`,
+ },
+ {
+ name: 'custom_text',
+ title: 'Custom text (optional):',
+ value: '',
+ },
+ ],
+ on_finish: async function (params) {
+ // Detect which preset was last hovered/clicked — use dialog value instead
+ const label = params.preset || labels[0];
+ // Because we can't easily get the clicked row from the html block,
+ // use the first preset as default. The user can also type a custom text.
+ // A nicer approach: wire click handlers after dialog renders.
+ _this._applyPreset(PRESETS[0], params.custom_text || '');
+ },
+ });
+
+ // Wire preset row clicks after the dialog is in DOM
+ requestAnimationFrame(() => {
+ document.querySelectorAll('[data-preset-idx]').forEach(el => {
+ el.addEventListener('click', () => {
+ const idx = parseInt(el.dataset.presetIdx, 10);
+ const customInput = document.querySelector('input[name="custom_text"]') ||
+ document.querySelector('#custom_text');
+ const text = customInput ? customInput.value.trim() : '';
+ _this._applyPreset(PRESETS[idx], text);
+ // Close dialog
+ const closeBtn = document.querySelector('.dialog_close') ||
+ document.querySelector('[data-dialog-close]');
+ if (closeBtn) closeBtn.click();
+ });
+ });
+ });
+ }
+
+ _applyPreset(preset, customText) {
+ const text = customText || preset.sample;
+ const cw = config.WIDTH || 800;
+ const ch = config.HEIGHT || 600;
+
+ // Build a text layer. miniPaint text layers use type='text' with params.
+ app.State.do_action(
+ new app.Actions.Insert_layer_action({
+ type: 'text',
+ name: preset.label,
+ x: Math.round(cw * 0.1),
+ y: Math.round(ch * 0.4),
+ width: Math.round(cw * 0.8),
+ height: preset.size + 20,
+ width_original: Math.round(cw * 0.8),
+ height_original: preset.size + 20,
+ params: {
+ text: text,
+ family: preset.family,
+ size: preset.size,
+ bold: preset.bold,
+ italic: preset.italic,
+ fill_color: preset.fill_color,
+ stroke_size: preset.stroke_size || 0,
+ stroke_color: preset.stroke_color || '#000000',
+ kerning: 0,
+ leading: 0,
+ },
+ })
+ );
+ alertify.success(`"${preset.label}" text added — double-click to edit.`);
+ }
+}
+
+export default Text_presets_class;
diff --git a/frontend/src/js/tools/pick_color.js b/frontend/src/js/tools/pick_color.js
index ce92efb..f8c8752 100644
--- a/frontend/src/js/tools/pick_color.js
+++ b/frontend/src/js/tools/pick_color.js
@@ -1,111 +1,211 @@
+/**
+ * Pick Color (Eyedropper) — enhanced with live color tooltip.
+ *
+ * Hover: floating tooltip shows hex, RGB, HSL, and nearest Pantone match with ΔE.
+ * Click: sets as active color AND copies hex to clipboard.
+ * Drag: continuously samples color while dragging.
+ *
+ * ΔE (Delta E) is the color difference between the sampled color and the
+ * nearest Pantone ink. Lower is better:
+ * < 2 = Excellent — nearly identical in print
+ * 2–5 = Good — slight difference, acceptable for most print work
+ * 5–10 = Fair — noticeable difference; specify Pantone manually if color accuracy matters
+ * > 10 = Poor — this color cannot be faithfully reproduced as a single Pantone ink
+ */
+
import config from './../config.js';
import Base_tools_class from './../core/base-tools.js';
import Base_layers_class from './../core/base-layers.js';
import Helper_class from './../libs/helpers.js';
import Base_gui_class from './../core/base-gui.js';
+import { hexToRgb, rgbToHsl, rgbToLab, nearestPantone, deltaEBadge } from './../libs/color_utils.js';
class Pick_color_class extends Base_tools_class {
- constructor(ctx) {
- super();
- this.Base_layers = new Base_layers_class();
- this.Helper = new Helper_class();
- this.Base_gui = new Base_gui_class();
- this.ctx = ctx;
- this.name = 'pick_color';
- }
+ constructor(ctx) {
+ super();
+ this.Base_layers = new Base_layers_class();
+ this.Helper = new Helper_class();
+ this.Base_gui = new Base_gui_class();
+ this.ctx = ctx;
+ this.name = 'pick_color';
+ this._tooltip = null;
+ this._lastHex = null;
+ }
- dragStart(event) {
- var _this = this;
- if (config.TOOL.name != _this.name)
- return;
- _this.mousedown(event);
- }
+ dragStart(event) {
+ if (config.TOOL.name !== this.name) return;
+ this.mousedown(event);
+ }
- dragMove(event) {
- var _this = this;
- if (config.TOOL.name != _this.name)
- return;
- _this.mousemove(event);
- }
+ dragMove(event) {
+ if (config.TOOL.name !== this.name) return;
+ this.mousemove(event);
+ }
- load() {
- var _this = this;
+ load() {
+ var _this = this;
- //mouse events
- document.addEventListener('mousedown', function (event) {
- _this.dragStart(event);
- });
- document.addEventListener('mousemove', function (event) {
- _this.dragMove(event);
- });
- document.addEventListener('mouseup', function (event) {
- var mouse = _this.get_mouse_info(event);
- if (config.TOOL.name != _this.name || mouse.click_valid == false)
- return;
- _this.copy_color_to_clipboard();
- });
+ document.addEventListener('mousedown', e => _this.dragStart(e));
+ document.addEventListener('mousemove', e => {
+ if (config.TOOL.name !== _this.name) { _this._hideTooltip(); return; }
+ _this.dragMove(e);
+ });
+ document.addEventListener('mouseup', e => {
+ if (config.TOOL.name !== _this.name) return;
+ var mouse = _this.get_mouse_info(e);
+ if (mouse.click_valid) _this.copy_color_to_clipboard();
+ });
+ document.addEventListener('touchstart', e => _this.dragStart(e));
+ document.addEventListener('touchmove', e => _this.dragMove(e));
+ document.addEventListener('mouseleave', () => _this._hideTooltip());
+ }
- // collect touch events
- document.addEventListener('touchstart', function (event) {
- _this.dragStart(event);
- });
- document.addEventListener('touchmove', function (event) {
- _this.dragMove(event);
- });
- }
+ mousedown(e) {
+ var mouse = this.get_mouse_info(e);
+ if (!mouse.click_valid) return;
+ this.pick_color(mouse);
+ }
- mousedown(e) {
- var mouse = this.get_mouse_info(e);
- if (mouse.click_valid == false) {
- return;
- }
+ mousemove(e) {
+ var mouse = this.get_mouse_info(e);
+ // Show tooltip on hover (even without drag)
+ this._sampleAndTooltip(mouse, e.clientX, e.clientY);
+ if (!mouse.is_drag || !mouse.click_valid) return;
+ this.pick_color(mouse);
+ }
- this.pick_color(mouse);
- }
+ pick_color(mouse) {
+ var params = this.getParams();
+ var canvas, ctx;
+ if (!params.global) {
+ canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, null, false);
+ ctx = canvas.getContext('2d');
+ } else {
+ canvas = document.createElement('canvas');
+ ctx = canvas.getContext('2d');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ this.Base_layers.convert_layers_to_canvas(ctx, null, false);
+ }
- mousemove(e) {
- var mouse = this.get_mouse_info(e);
- if (mouse.is_drag == false || mouse.click_valid == false) {
- return;
- }
+ var c = ctx.getImageData(mouse.x, mouse.y, 1, 1).data;
+ var hex = this.Helper.rgbToHex(c[0], c[1], c[2]);
- this.pick_color(mouse);
- }
+ const def = { hex };
+ if (c[3] > 0) def.a = c[3];
+ this.Base_gui.GUI_colors.set_color(def);
+ this._lastHex = hex;
+ }
- pick_color(mouse) {
- var params = this.getParams();
+ copy_color_to_clipboard() {
+ navigator.clipboard.writeText(config.COLOR).catch(() => {});
+ }
- //get canvas from layer
- if (params.global == false) {
- //active layer
- var canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, null, false);
- var ctx = canvas.getContext("2d");
- }
- else {
- //global
- var canvas = document.createElement('canvas');
- var ctx = canvas.getContext("2d");
- canvas.width = config.WIDTH;
- canvas.height = config.HEIGHT;
- this.Base_layers.convert_layers_to_canvas(ctx, null, false);
- }
- //find color
- var c = ctx.getImageData(mouse.x, mouse.y, 1, 1).data;
- var hex = this.Helper.rgbToHex(c[0], c[1], c[2]);
+ // ── Tooltip ────────────────────────────────────────────────────────────────
- const newColorDefinition = { hex };
- if (c[3] > 0) {
- //set alpha
- newColorDefinition.a = c[3];
- }
- this.Base_gui.GUI_colors.set_color(newColorDefinition);
- }
+ _sampleAndTooltip(mouse, clientX, clientY) {
+ if (!config.layer || !mouse.click_valid) { this._hideTooltip(); return; }
- copy_color_to_clipboard() {
- navigator.clipboard.writeText(config.COLOR);
- }
+ var params = this.getParams();
+ var canvas, ctx;
+ try {
+ if (!params.global) {
+ canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, null, false);
+ ctx = canvas.getContext('2d');
+ } else {
+ canvas = document.createElement('canvas');
+ ctx = canvas.getContext('2d');
+ canvas.width = config.WIDTH;
+ canvas.height = config.HEIGHT;
+ this.Base_layers.convert_layers_to_canvas(ctx, null, false);
+ }
+ } catch { this._hideTooltip(); return; }
+ var c = ctx.getImageData(mouse.x, mouse.y, 1, 1).data;
+ var r = c[0], g = c[1], b = c[2], a = c[3];
+ if (a === 0) { this._hideTooltip(); return; }
+
+ var hex = this.Helper.rgbToHex(r, g, b);
+ this._showTooltip(hex, r, g, b, clientX, clientY);
+ }
+
+ _showTooltip(hex, r, g, b, cx, cy) {
+ const hsl = rgbToHsl(r, g, b);
+ const pantone = nearestPantone(hex);
+ const badge = deltaEBadge(pantone.quality);
+
+ // Perceived text color for swatch
+ const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
+ const swatchText = brightness > 140 ? '#1a1a1a' : '#ffffff';
+
+ if (!this._tooltip) {
+ const t = document.createElement('div');
+ t.id = 'pick_color_tooltip';
+ Object.assign(t.style, {
+ position: 'fixed',
+ zIndex: '99999',
+ background: '#1a1a1a',
+ border: '1px solid #3a3a3a',
+ borderRadius: '10px',
+ padding: '10px 13px',
+ fontFamily: 'monospace, sans-serif',
+ fontSize: '12px',
+ color: '#ddd',
+ pointerEvents:'none',
+ boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
+ minWidth: '210px',
+ lineHeight: '1.6',
+ });
+ document.body.appendChild(t);
+ this._tooltip = t;
+ }
+
+ const t = this._tooltip;
+
+ t.innerHTML = `
+
+
+
+
+
${hex.toUpperCase()}
+
rgb(${r}, ${g}, ${b})
+
hsl(${hsl.h}°, ${hsl.s}%, ${hsl.l}%)
+
+
+
+
+
+ ΔE ${pantone.deltaE}
+ ● ${badge.label}
+
+ ${pantone.quality === 'poor'
+ ? `
+ Tip: this color may shift significantly in print.
+
`
+ : ''}
+
+ Click to copy hex & set active color
`;
+
+ // Position tooltip near cursor, keep on screen
+ const tw = 230, th = 160;
+ let tx = cx + 16, ty = cy + 16;
+ if (tx + tw > window.innerWidth - 8) tx = cx - tw - 8;
+ if (ty + th > window.innerHeight - 8) ty = cy - th - 8;
+ t.style.left = tx + 'px';
+ t.style.top = ty + 'px';
+ t.style.display = 'block';
+ }
+
+ _hideTooltip() {
+ if (this._tooltip) this._tooltip.style.display = 'none';
+ }
}
export default Pick_color_class;