From d2273017fd624e973594cc45f2e4f729bb39ea30 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 01:41:20 +0000 Subject: [PATCH 1/2] Add Auto-Enhance, Color Palette, History Panel, Align, Text Presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-Enhance (Image menu): POST /api/enhance — gray-world white balance, CLAHE contrast on L channel, saturation boost ×1.15 in HSV, unsharp mask; all blended by strength slider Frontend: strength selector (25/50/75/100%), keep-original option Extract Color Palette (Image menu): POST /api/extract-colors — k-means on 150×150 thumbnail, returns N dominant colors sorted by cluster size. Frontend: floating swatch panel, click=copy hex, shift+click=set as active color, toggle on/off. History Panel (Edit menu, Ctrl+H): Pure frontend — reads app.State.action_history and action_history_index, renders clickable list of past actions (newest first), click any step to undo/redo to that point. Auto-refreshes every 800ms while open. Align to Canvas (Layer menu): Floating toolbar with 7 alignment buttons: center H, center V, center both, align left/right/top/bottom edges. Uses Update_layer_action for undo support. Add Text (Generate menu): 6 styled presets (Heading, Subheading, Body, Caption, Quote, Bold Label) shown as live-rendered previews in the dialog. Click a preset to insert a text layer with the correct font/size/weight/color pre-applied. https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN --- backend/app/routers/ai_tools.py | 151 +++++++++++++++++ frontend/src/js/config-menu.js | 26 +++ frontend/src/js/modules/edit/history_panel.js | 145 +++++++++++++++++ frontend/src/js/modules/image/auto_enhance.js | 124 ++++++++++++++ .../src/js/modules/image/color_palette.js | 114 +++++++++++++ frontend/src/js/modules/layer/align.js | 114 +++++++++++++ frontend/src/js/modules/text/text_presets.js | 153 ++++++++++++++++++ 7 files changed, 827 insertions(+) create mode 100644 frontend/src/js/modules/edit/history_panel.js create mode 100644 frontend/src/js/modules/image/auto_enhance.js create mode 100644 frontend/src/js/modules/image/color_palette.js create mode 100644 frontend/src/js/modules/layer/align.js create mode 100644 frontend/src/js/modules/text/text_presets.js 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/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; From 54be280b5b28862f96d1bab3014a97e6c1f89dee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 01:52:23 +0000 Subject: [PATCH 2/2] Enhance eyedropper with live Pantone matching tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pick_color.js: on hover shows a floating tooltip with: - 40px color swatch of the sampled pixel - Hex (#RRGGBB), RGB, and HSL values - Nearest Pantone name and swatch from the database - ΔE value (CIE76 color distance in LAB space) - Quality badge: Excellent (<2) / Good (2-5) / Fair (5-10) / Poor (>10) - Warning note when ΔE > 10 (color cannot be faithfully printed as Pantone) Click copies hex to clipboard and sets as active color (existing behavior). color_utils.js: hexToRgb, rgbToHsl, sRGB→XYZ→CIE LAB conversion (D65), CIE76 deltaE, nearestPantone() (scans all ~350 entries, returns best match). Pantone database is pre-converted to LAB at module load for fast scanning. pantone_colors.js: ~350 representative PMS colors across reds, oranges, yellows, greens, teals, blues, purples, magentas, browns, grays, black/white. Hex approximations from open-source Pantone datasets (not official Pantone data). https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN --- frontend/src/js/data/pantone_colors.js | 238 ++++++++++++++++++++++ frontend/src/js/libs/color_utils.js | 106 ++++++++++ frontend/src/js/tools/pick_color.js | 272 +++++++++++++++++-------- 3 files changed, 530 insertions(+), 86 deletions(-) create mode 100644 frontend/src/js/data/pantone_colors.js create mode 100644 frontend/src/js/libs/color_utils.js 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/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}%)
+
+
+
+
+
+ ${pantone.name} +
+
+ Δ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;