Add Auto-Enhance, Color Palette, History Panel, Align, Text Presets
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
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;
|
||||
padding:8px 10px;border-bottom:1px solid #333;position:sticky;top:0;
|
||||
background:#1a1a1a;z-index:1;">
|
||||
<span style="font-size:12px;color:#888;font-weight:600;">History</span>
|
||||
<span id="hist-close" style="cursor:pointer;color:#555;font-size:16px;">×</span>
|
||||
</div>
|
||||
<div id="hist-list"></div>`;
|
||||
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 = `<div style="padding:12px 10px;color:#555;">No actions yet.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build rows newest-first
|
||||
const rows = [];
|
||||
// "Current state" row at top
|
||||
const atTop = idx >= history.length;
|
||||
rows.push(`<div data-idx="${history.length}"
|
||||
style="padding:6px 10px;cursor:pointer;border-bottom:1px solid #222;
|
||||
background:${atTop ? '#1e3a5f' : 'transparent'};
|
||||
color:${atTop ? '#93c5fd' : '#666'};"
|
||||
>
|
||||
<span style="margin-right:6px;font-size:10px;">${atTop ? '▶' : '○'}</span>Current state
|
||||
</div>`);
|
||||
|
||||
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(`<div data-idx="${i}"
|
||||
style="padding:6px 10px;cursor:pointer;border-bottom:1px solid #1e1e1e;
|
||||
background:${isCurrent ? '#1e3a5f' : 'transparent'};
|
||||
color:${isFuture ? '#444' : isCurrent ? '#93c5fd' : '#ccc'};"
|
||||
>
|
||||
<span style="margin-right:6px;font-size:10px;">${isCurrent ? '▶' : isFuture ? '○' : '·'}</span>${_escHtml(label)}
|
||||
</div>`);
|
||||
}
|
||||
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,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
export default Edit_history_panel_class;
|
||||
@@ -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: `<div style="font-size:11px;color:#888;margin-bottom:8px;">
|
||||
Automatically improves white balance, contrast, saturation, and sharpness.
|
||||
</div>`,
|
||||
},
|
||||
{
|
||||
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;
|
||||
@@ -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 => `
|
||||
<div title="Click to copy • Shift+click to set active color"
|
||||
data-hex="${hex}"
|
||||
style="display:inline-block;width:32px;height:32px;border-radius:6px;
|
||||
background:${hex};cursor:pointer;border:2px solid transparent;
|
||||
transition:border-color .12s;margin:2px;"
|
||||
onmouseover="this.style.borderColor='#fff'"
|
||||
onmouseout="this.style.borderColor='transparent'">
|
||||
</div>`).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<span style="font-size:12px;color:#888;">Image Palette</span>
|
||||
<span id="cp-close" style="cursor:pointer;color:#666;font-size:16px;line-height:1;">×</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:2px;">${swatchesHtml}</div>
|
||||
<div id="cp-copied" style="font-size:11px;color:#4ade80;margin-top:6px;min-height:14px;"></div>
|
||||
<div style="font-size:10px;color:#555;margin-top:4px;">Click: copy hex · Shift+click: set color</div>`;
|
||||
|
||||
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;
|
||||
@@ -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 `<span style="color:#444;padding:0 2px;">│</span>`;
|
||||
return `<button data-align="${b.id}" title="${b.title}"
|
||||
style="width:30px;height:30px;border-radius:6px;border:1px solid #444;
|
||||
background:#252525;color:#ccc;cursor:pointer;font-size:16px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
transition:background .12s;"
|
||||
onmouseover="this.style.background='#333'"
|
||||
onmouseout="this.style.background='#252525'">${b.label}</button>`;
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<span style="font-size:11px;color:#555;margin-right:4px;">Align:</span>
|
||||
${btnHtml}
|
||||
<span id="align-close" style="margin-left:6px;cursor:pointer;color:#555;font-size:18px;">×</span>`;
|
||||
|
||||
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;
|
||||
@@ -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: `<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:4px;">
|
||||
${PRESETS.map((p, i) => `
|
||||
<div data-preset-idx="${i}" style="padding:8px 12px;border-radius:8px;
|
||||
border:1px solid #333;cursor:pointer;transition:background .12s;"
|
||||
onmouseover="this.style.background='#2a2a2a'"
|
||||
onmouseout="this.style.background='transparent'">
|
||||
<span style="font-family:${p.family},sans-serif;font-size:${Math.min(p.size * 0.4, 22)}px;
|
||||
font-weight:${p.bold ? 'bold' : 'normal'};
|
||||
font-style:${p.italic ? 'italic' : 'normal'};
|
||||
color:${p.fill_color};">${p.sample}</span>
|
||||
<span style="float:right;font-size:10px;color:#555;">${p.family} · ${p.size}px</span>
|
||||
</div>`).join('')}
|
||||
</div>`,
|
||||
},
|
||||
{
|
||||
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;
|
||||
Reference in New Issue
Block a user