/**
* Per-tool stroke-modifier sliders (Opacity / Flow / Softness) for
* Eraser, Brush, and Clone. The three sections share identical UX:
*
* - Opacity slider: writes to state, updates label, fades the
* preview swatch opacity.
* - Flow slider: writes to state, updates label, fades the swatch
* opacity AND swaps its border style (dashed at low flow → dotted
* at high flow) so the user sees the "denseness" change.
* - Softness slider: writes to state, updates label, tweens the
* radial-gradient inner stop on the swatch so it visually fades
* from hard disk to soft falloff.
*
* The whole block was three near-identical 30-LOC copies before; now
* it's one helper that takes the tool's prefix + a state-field bag.
*
* Usage: just call wireStrokeToolSliders() — the DOM IDs are wired
* statically from #ge-{eraser,brush,clone}-{opacity,flow,softness}
* + their labels + preview swatches.
*/
import { state } from './state.js';
import {
applyBrushPreset,
captureBrushPreset,
loadBrushPresets,
saveCustomBrushPresets,
} from './brush-presets.js';
/** Wire the three sliders for one stroke tool. */
function wireToolSliders(prefix, fields) {
const opPrev = document.getElementById(`ge-${prefix}-preview-opacity`);
const flPrev = document.getElementById(`ge-${prefix}-preview-flow`);
const softPrev = document.getElementById(`ge-${prefix}-preview-softness`);
document.getElementById(`ge-${prefix}-opacity`)?.addEventListener('input', (e) => {
state[fields.opacity] = parseInt(e.target.value);
document.getElementById(`ge-${prefix}-opacity-label`).textContent = state[fields.opacity] + '%';
if (opPrev) opPrev.style.opacity = (state[fields.opacity] / 100).toFixed(2);
});
document.getElementById(`ge-${prefix}-flow`)?.addEventListener('input', (e) => {
state[fields.flow] = parseInt(e.target.value);
document.getElementById(`ge-${prefix}-flow-label`).textContent = state[fields.flow] + '%';
// Lower flow → fewer / sparser dots. Cycle dot densities by
// swapping the dashed/dotted border style and fading opacity.
if (flPrev) {
const denseness = Math.max(1, Math.round(state[fields.flow] / 20));
flPrev.style.borderStyle = denseness <= 2 ? 'dashed' : 'dotted';
flPrev.style.opacity = (0.3 + (state[fields.flow] / 100) * 0.6).toFixed(2);
}
});
document.getElementById(`ge-${prefix}-softness`)?.addEventListener('input', (e) => {
state[fields.softness] = parseInt(e.target.value);
document.getElementById(`ge-${prefix}-softness-label`).textContent = state[fields.softness] + '%';
// Preview tweens from a hard disk into a soft radial gradient as
// softness rises (the CSS already sets the radial gradient — we
// just tween the inner solid radius to communicate the falloff).
if (softPrev) {
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
}
});
}
export function wireStrokeToolSliders({ onPresetApplied } = {}) {
wireToolSliders('eraser', { opacity: 'eraserOpacity', flow: 'eraserFlow', softness: 'eraserSoftness' });
wireToolSliders('brush', { opacity: 'brushOpacity', flow: 'brushFlow', softness: 'brushSoftness' });
wireToolSliders('clone', { opacity: 'cloneOpacity', flow: 'cloneFlow', softness: 'cloneSoftness' });
const smudgeStrength = document.getElementById('ge-smudge-strength');
smudgeStrength?.addEventListener('input', (e) => {
state.smudgeStrength = parseInt(e.target.value, 10);
document.getElementById('ge-smudge-strength-label').textContent = `${state.smudgeStrength}%`;
const preview = document.getElementById('ge-smudge-preview-strength');
if (preview) preview.style.opacity = (state.smudgeStrength / 100).toFixed(2);
});
const dodgeBurnIntensity = document.getElementById('ge-dodgeburn-intensity');
dodgeBurnIntensity?.addEventListener('input', (e) => {
state.dodgeBurnIntensity = parseInt(e.target.value, 10);
document.getElementById('ge-dodgeburn-intensity-label').textContent = `${state.dodgeBurnIntensity}%`;
const preview = document.getElementById('ge-dodgeburn-preview');
if (preview) preview.style.opacity = (0.2 + state.dodgeBurnIntensity / 125).toFixed(2);
});
const spacing = document.getElementById('ge-brush-spacing');
const smoothing = document.getElementById('ge-brush-smoothing');
const blend = document.getElementById('ge-brush-blend');
const spacingPrev = document.getElementById('ge-brush-preview-spacing');
const smoothingPrev = document.getElementById('ge-brush-preview-smoothing');
// Spacing preview: split the single dot into two dots whose gap grows with
// the spacing value, so the swatch reads as "dots spaced apart".
const paintSpacingPreview = () => {
if (!spacingPrev) return;
const gap = Math.min(9, 1 + state.brushSpacing * 0.08);
const c1 = (7 - gap / 2).toFixed(2);
const c2 = (7 + gap / 2).toFixed(2);
spacingPrev.style.opacity = '';
spacingPrev.style.background = `radial-gradient(circle 3.4px at ${c1}px 50%, var(--fg) 98%, transparent 100%), radial-gradient(circle 3.4px at ${c2}px 50%, var(--fg) 98%, transparent 100%)`;
};
spacing?.addEventListener('input', (e) => {
state.brushSpacing = parseInt(e.target.value, 10);
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
paintSpacingPreview();
});
smoothing?.addEventListener('input', (e) => {
state.brushSmoothing = parseInt(e.target.value, 10);
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
// More smoothing → softer edge; widen the radial falloff.
if (smoothingPrev) {
const innerStop = Math.max(0, 70 - state.brushSmoothing * 0.7);
smoothingPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
}
});
blend?.addEventListener('change', (e) => { state.brushBlendMode = e.target.value; });
document.getElementById('ge-pressure-size')?.addEventListener('change', e => { state.pressureSize = e.target.checked; });
document.getElementById('ge-pressure-opacity')?.addEventListener('change', e => { state.pressureOpacity = e.target.checked; });
document.getElementById('ge-pressure-flow')?.addEventListener('change', e => { state.pressureFlow = e.target.checked; });
document.getElementById('ge-eyedropper-sample')?.addEventListener('change', e => { state.eyedropperSample = e.target.value; });
document.getElementById('ge-eyedropper-sample-size')?.addEventListener('change', e => {
const size = Math.max(1, Math.round(Number(e.target.value) || 1));
state.eyedropperSampleSize = size;
});
let presets = loadBrushPresets();
const presetSelect = document.getElementById('ge-brush-preset');
const presetSaveBtn = document.getElementById('ge-brush-preset-save');
const presetDeleteBtn = document.getElementById('ge-brush-preset-delete');
// Save and Delete only apply to custom brushes: they are hidden while a
// fixed built-in preset is selected. Save is available on the unsaved
// "Custom" slot (to create one) and on a saved custom (to save over it);
// Delete is available only for a saved (non built-in) custom preset.
const syncPresetButtons = () => {
const id = presetSelect?.value || '';
const preset = presets.find(item => item.id === id);
const isBuiltIn = !!preset && !!preset.builtIn;
const isUserCustom = !!preset && !preset.builtIn;
if (presetSaveBtn) presetSaveBtn.hidden = isBuiltIn;
if (presetDeleteBtn) presetDeleteBtn.hidden = !isUserCustom;
};
const renderPresets = (selectedId = '') => {
if (!presetSelect) return;
// Pixel is its own tool, so it is not offered as a brush preset here.
const list = presets.filter(preset => !preset.pixel);
presetSelect.innerHTML = '' + list.map(preset =>
``).join('');
presetSelect.value = selectedId;
syncPresetButtons();
};
const syncControls = () => {
if (spacing) spacing.value = String(state.brushSpacing);
if (smoothing) smoothing.value = String(state.brushSmoothing);
if (blend) blend.value = state.brushBlendMode;
if (smudgeStrength) smudgeStrength.value = String(state.smudgeStrength);
const smudgeLabel = document.getElementById('ge-smudge-strength-label');
if (smudgeLabel) smudgeLabel.textContent = `${state.smudgeStrength}%`;
document.getElementById('ge-brush-spacing-label').textContent = `${state.brushSpacing}%`;
document.getElementById('ge-brush-smoothing-label').textContent = `${state.brushSmoothing}%`;
paintSpacingPreview();
onPresetApplied?.();
};
// Restore the last preset the user picked instead of snapping back to the
// unsaved "Custom" slot on every editor open.
let savedPresetId = '';
try { savedPresetId = localStorage.getItem('ge-brush-preset') || ''; } catch (error) { /* storage unavailable */ }
renderPresets(presets.some(item => item.id === savedPresetId && !item.pixel) ? savedPresetId : '');
presetSelect?.addEventListener('change', () => {
try { localStorage.setItem('ge-brush-preset', presetSelect.value || ''); } catch (error) { /* ignore */ }
const preset = presets.find(item => item.id === presetSelect.value);
if (!preset) {
// "Custom" (no preset) — leave the current brush alone but drop pixel
// mode so the user can get back out of the 1px Pixel preset.
state.brushPixel = false;
onPresetApplied?.();
syncPresetButtons();
return;
}
applyBrushPreset(state, preset);
syncControls();
syncPresetButtons();
});
document.getElementById('ge-brush-preset-save')?.addEventListener('click', () => {
const name = window.prompt('Brush preset name');
if (!name?.trim()) return;
const preset = captureBrushPreset(state, name);
presets.push(preset);
saveCustomBrushPresets(presets);
try { localStorage.setItem('ge-brush-preset', preset.id); } catch (error) { /* ignore */ }
renderPresets(preset.id);
});
document.getElementById('ge-brush-preset-delete')?.addEventListener('click', () => {
const preset = presets.find(item => item.id === presetSelect?.value);
if (!preset || preset.builtIn) return;
presets = presets.filter(item => item.id !== preset.id);
saveCustomBrushPresets(presets);
try { localStorage.setItem('ge-brush-preset', ''); } catch (error) { /* ignore */ }
renderPresets();
paintSpacingPreview();
});
// The caret ("v") next to the brush row drops down the less-used stroke
// controls (Spacing / Smoothing / Blend / pen pressure) so the options strip
// stays a single tidy line. Same pattern as the wand's caret menu.
const moreBtn = document.getElementById('ge-brush-more');
const moreMenu = document.getElementById('ge-brush-more-menu');
const closeBrushMore = () => {
if (!moreMenu || moreMenu.hidden) return;
moreMenu.hidden = true;
moreBtn?.setAttribute('aria-expanded', 'false');
moreBtn?.classList.remove('active');
};
moreBtn?.addEventListener('click', (event) => {
event.stopPropagation();
const open = moreMenu.hidden;
moreMenu.hidden = !open;
moreBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
moreBtn.classList.toggle('active', open);
});
document.addEventListener('click', (event) => {
if (!moreMenu || moreMenu.hidden) return;
if (moreMenu.contains(event.target) || moreBtn?.contains(event.target)) return;
closeBrushMore();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeBrushMore();
});
}