Add image → pixelize (Phase D) and move it into the pixel editor

A new "From image…" button in the pixel editor toolbar opens a file
picker and then a pixelize modal. The modal previews source and
pixelized output side by side, with live updates as the user changes:

- W / H (4–128, matching the editor's resize cap; default = the
  editor's current dims so Apply lands as a drop-in replacement)
- Colors (2–64; index 0 reserved as transparent)
- Fit mode — Cover / Fit / Stretch

Algorithm:

- _pixelDownsampleImage draws the image onto an off-screen canvas
  with imageSmoothingEnabled=false; fit-mode math computes source
  and dest rects so Cover crops edges, Fit letterboxes with
  transparent, Stretch ignores aspect.
- _pixelMedianCut quantizes the downscaled pixels: collect opaque
  samples, recursively split the bucket with the widest single-
  channel range until N buckets, average each bucket → palette
  entry. Pixels with alpha < 128 become transparent (index 0).
- _pixelNearestPaletteIdx maps each opaque pixel to the nearest
  palette entry by squared RGB distance.

Apply replaces the editor's current pixel art — frames, palette,
and dims all get swapped to match the pixelized output. One
pixelSnapshot before mutating, so Ctrl+Z restores everything.
Standalone mode commits immediately.

Drive-bys in this commit:

- Move the From image entry out of the vector-canvas ▦ popup and
  into the pixel editor toolbar (per request — the editor is where
  the workflow lives).
- Fix the modal show bug: .modal CSS rule sets display:none, so the
  modal must be opened with style.display = 'flex' (matching the
  publish-confirm modal). The previous '' didn't override the rule.
- Drop the long hint paragraph under the colour picker — the SV
  square + hue strip + Add button are self-explanatory and the
  eyedropper has its own tooltip.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-26 10:33:44 -04:00
1 parent 130e0218d8
commit a13ef22b92
1 file changed
+326 -1
+326 -1
View File
@@ -2209,6 +2209,30 @@
border-radius: 6px; cursor: pointer; font-family: inherit;
}
.pixelToolMenu button:hover { background: rgba(127,127,127,.14); }
.pixelizePreviewRow { display: flex; gap: 12px; margin: 0 0 10px; }
.pixelizePreviewBox { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.pixelizePreviewLabel { font-size: .72rem; color: #64748b; font-weight: 600; }
.pixelizePreviewArea {
width: 100%; aspect-ratio: 1;
background: repeating-conic-gradient(#e2e8f0 0% 25%, #f8fafc 0% 50%) 0 0 / 14px 14px;
border: 1px solid var(--line); border-radius: 6px;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.pixelizePreviewArea img, .pixelizePreviewArea canvas {
max-width: 100%; max-height: 100%; display: block;
image-rendering: pixelated; image-rendering: crisp-edges;
}
.pixelizeRow { display: flex; gap: 10px; align-items: center; margin: 0 0 8px; }
.pixelizeField {
display: flex; align-items: center; gap: 6px;
font-size: .82rem; flex: 1;
}
.pixelizeField input {
width: 60px; padding: 4px 6px;
border: 1px solid var(--line); border-radius: 6px;
font-family: inherit;
}
.toolOptions {
grid-area: options;
display: flex;
@@ -3542,6 +3566,8 @@
<button type="button" id="pixelResizeApplyBtn" class="pixelBarBtn primary">Apply</button>
</div>
</div>
<button type="button" id="pixelEditorImageBtn" class="pixelBarBtn" title="Pixelize an image into this design">From image…</button>
<input id="pixelEditorImageFile" type="file" accept="image/*" hidden />
<div class="pixelExportWrap">
<button type="button" id="pixelExportPngBtn" class="pixelBarBtn" aria-haspopup="true" aria-expanded="false">Export PNG ▾</button>
<div id="pixelExportMenu" class="pixelExportMenu">
@@ -3611,7 +3637,6 @@
<button type="button" id="pixelPickFromScreenBtn" class="pixelBarBtn" style="flex:1">🎯 Pick from screen</button>
<button type="button" id="pixelPickStopBtn" class="pixelBarBtn" style="display:none" title="Stop sharing the screen with Artstr">Stop</button>
</div>
<p class="hint" style="font-size:.72rem;margin:8px 0 0"><b>Drag on the gradient square + rainbow strip above</b> to pick a colour, then click <b>+ Add</b> to push it into the palette. The ⊙ eyedropper tool in the toolbar samples colours already on your sprite.</p>
</div>
</div>
</div>
@@ -4022,6 +4047,42 @@
</div>
</div>
<div class="modal" id="pixelizeModal" aria-hidden="true" style="display:none">
<div class="modalInner" style="max-width:560px;position:relative">
<button type="button" id="pixelizeCloseBtn" class="welcomeCloseBtn" aria-label="Close">×</button>
<h2 style="margin:0 0 4px;font-size:1.05rem;font-family:inherit">Pixelize image</h2>
<p class="hint" style="margin:0 0 12px">Downscale a picture to a pixel-art grid. Tweak the controls — the preview updates live.</p>
<div class="pixelizePreviewRow">
<div class="pixelizePreviewBox">
<div class="pixelizePreviewLabel">Source</div>
<div class="pixelizePreviewArea" id="pixelizeSourceWrap"></div>
</div>
<div class="pixelizePreviewBox">
<div class="pixelizePreviewLabel">Pixelized</div>
<div class="pixelizePreviewArea" id="pixelizeOutWrap"></div>
</div>
</div>
<div class="pixelizeRow">
<label class="pixelizeField">W <input type="number" id="pixelizeW" min="4" max="128" step="1" value="32" /></label>
<label class="pixelizeField">H <input type="number" id="pixelizeH" min="4" max="128" step="1" value="32" /></label>
<label class="pixelizeField">Colors <input type="number" id="pixelizeColors" min="2" max="64" step="1" value="16" /></label>
</div>
<div class="pixelizeRow">
<span class="pixelExportLabel" style="flex:0 0 auto">Fit</span>
<div class="pixelExportScales" id="pixelizeFitMode" style="grid-template-columns:repeat(3,1fr);flex:1">
<button type="button" data-fit="cover" class="pixelExportScaleBtn active">Cover</button>
<button type="button" data-fit="contain" class="pixelExportScaleBtn">Fit</button>
<button type="button" data-fit="stretch" class="pixelExportScaleBtn">Stretch</button>
</div>
</div>
<p class="hint" style="margin:8px 0 0;font-size:.72rem">Cover = fill the grid (crops edges). Fit = letterbox with transparent. Stretch = ignore aspect.</p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px">
<button type="button" id="pixelizeCancelBtn">Cancel</button>
<button type="button" id="pixelizeAddBtn" class="primary">Add as layer</button>
</div>
</div>
</div>
<div class="modal" id="publishConfirmModal" aria-hidden="true" style="display:none">
<div class="modalInner" style="max-width:580px;position:relative">
<button type="button" id="publishConfirmCloseBtn" class="welcomeCloseBtn" aria-label="Close">×</button>
@@ -9609,6 +9670,237 @@
openPixelEditor(layer.id);
}
// ----------------------------------------------------------------
// Image → pixel-art conversion. Downscale an arbitrary image to a
// target grid, quantize its colours, and build a pixelArt blob ready
// for a new pixelart layer.
//
// Quantization uses median-cut: collect pixels, recursively split the
// bucket with the widest channel range until we have N buckets, then
// average each bucket. Good visual match for photos and vector art
// alike; classic and predictable.
// ----------------------------------------------------------------
// Median-cut palette quantization. `pixels` is a flat Uint8 buffer of
// RGBA samples; opaque pixels (alpha ≥ 128) feed the quantizer, anything
// below that becomes transparent in the output. Returns an array of
// #rrggbbff hex strings with index 0 reserved as transparent.
function _pixelMedianCut(rgbaPixels, n) {
const opaque = [];
for (let i = 0; i < rgbaPixels.length; i += 4) {
if (rgbaPixels[i + 3] >= 128) {
opaque.push([rgbaPixels[i], rgbaPixels[i + 1], rgbaPixels[i + 2]]);
}
}
const wantColours = Math.max(1, (n | 0) - 1); // reserve slot 0 for transparent
if (!opaque.length) return ['#00000000'];
let buckets = [opaque];
while (buckets.length < wantColours) {
// Pick the bucket with the widest single-channel range.
let bestIdx = -1, bestRange = -1, bestChan = 0;
for (let bi = 0; bi < buckets.length; bi++) {
const b = buckets[bi];
if (b.length < 2) continue;
for (let c = 0; c < 3; c++) {
let mn = 255, mx = 0;
for (const p of b) { if (p[c] < mn) mn = p[c]; if (p[c] > mx) mx = p[c]; }
const range = mx - mn;
if (range > bestRange) { bestRange = range; bestIdx = bi; bestChan = c; }
}
}
if (bestIdx < 0) break;
const b = buckets[bestIdx];
b.sort((a, b) => a[bestChan] - b[bestChan]);
const mid = b.length >> 1;
buckets.splice(bestIdx, 1, b.slice(0, mid), b.slice(mid));
}
const palette = ['#00000000'];
for (const b of buckets) {
let r = 0, g = 0, bb = 0;
for (const p of b) { r += p[0]; g += p[1]; bb += p[2]; }
const n = b.length || 1;
const toHex = (v) => Math.round(v / n).toString(16).padStart(2, '0');
palette.push('#' + toHex(r) + toHex(g) + toHex(bb) + 'ff');
}
return palette;
}
// Squared distance in RGB. Cheap enough — for a 128×128 grid with a
// 64-colour palette that's ~1M comparisons, which runs in tens of ms.
function _pixelNearestPaletteIdx(r, g, b, palette) {
let best = 1, bestD = Infinity;
// Skip palette[0] (transparent) — opaque pixels never map there.
for (let i = 1; i < palette.length; i++) {
const hex = palette[i];
const pr = parseInt(hex.slice(1, 3), 16);
const pg = parseInt(hex.slice(3, 5), 16);
const pb = parseInt(hex.slice(5, 7), 16);
const dr = r - pr, dg = g - pg, db = b - pb;
const d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
// Build the downsampled RGBA buffer at the target grid, handling the
// three fit modes: cover (fill, crop edges), contain (letterbox, pad
// with transparent), stretch (ignore aspect). Uses an off-screen canvas
// and the browser's built-in nearest-neighbour scaler.
function _pixelDownsampleImage(img, w, h, fit) {
const out = document.createElement('canvas');
out.width = w; out.height = h;
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, w, h);
const iw = img.naturalWidth || img.width;
const ih = img.naturalHeight || img.height;
if (!iw || !ih) return ctx.getImageData(0, 0, w, h);
let dx = 0, dy = 0, dw = w, dh = h;
let sx = 0, sy = 0, sw = iw, sh = ih;
if (fit === 'contain') {
const ratio = Math.min(w / iw, h / ih);
dw = Math.max(1, Math.round(iw * ratio));
dh = Math.max(1, Math.round(ih * ratio));
dx = ((w - dw) / 2) | 0;
dy = ((h - dh) / 2) | 0;
} else if (fit === 'cover') {
const ratio = Math.max(w / iw, h / ih);
const scaledW = iw * ratio;
const scaledH = ih * ratio;
sw = Math.round(w / ratio);
sh = Math.round(h / ratio);
sx = ((iw - sw) / 2) | 0;
sy = ((ih - sh) / 2) | 0;
}
// 'stretch' uses defaults: sx=0,sy=0,sw=iw,sh=ih,dx=0,dy=0,dw=w,dh=h
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
return ctx.getImageData(0, 0, w, h);
}
// Image → pixelArt blob. Downscale, quantize palette, map pixels.
function pixelizeImage(img, opts = {}) {
const w = Math.max(4, Math.min(128, opts.width | 0 || 32));
const h = Math.max(4, Math.min(128, opts.height | 0 || 32));
const nColours = Math.max(2, Math.min(64, opts.colors | 0 || 16));
const fit = (['cover', 'contain', 'stretch'].includes(opts.fit) ? opts.fit : 'cover');
const data = _pixelDownsampleImage(img, w, h, fit).data;
const palette = _pixelMedianCut(data, nColours);
const indices = new Uint8Array(w * h);
for (let i = 0, p = 0; p < data.length; i++, p += 4) {
if (data[p + 3] < 128) { indices[i] = 0; continue; }
indices[i] = _pixelNearestPaletteIdx(data[p], data[p + 1], data[p + 2], palette);
}
return {
width: w, height: h, palette,
frames: [{ id: 'f1', pixels: encodePixelRLE(indices) }],
};
}
// Render a pixelArt blob into a canvas suitable for the modal preview.
function _pixelizeRenderPreview(targetEl, pa) {
targetEl.innerHTML = '';
const cv = renderPixelGridToCanvas(pa);
// The preview area is square; the canvas's natural aspect is w/h, so
// we constrain to fit within the box without distorting non-square
// grids.
cv.style.cssText = 'max-width:100%;max-height:100%;display:block;image-rendering:pixelated;image-rendering:crisp-edges';
targetEl.appendChild(cv);
}
// Modal state: a live source image + the user's chosen options.
let _pixelizeState = null;
function openPixelizeModal(img) {
// Default the grid to the editor's current dims so "Apply" lands as a
// drop-in replacement; users can still tweak the dim inputs.
const defW = _pixelEdit ? _pixelEdit.w : 32;
const defH = _pixelEdit ? _pixelEdit.h : 32;
_pixelizeState = {
img,
opts: { width: defW, height: defH, colors: 16, fit: 'cover' },
debounce: 0,
};
const modal = $('pixelizeModal');
if (!modal) return;
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
// Show the source image.
const src = $('pixelizeSourceWrap');
if (src) { src.innerHTML = ''; src.appendChild(img.cloneNode()); }
$('pixelizeW').value = _pixelizeState.opts.width;
$('pixelizeH').value = _pixelizeState.opts.height;
$('pixelizeColors').value = _pixelizeState.opts.colors;
document.querySelectorAll('#pixelizeFitMode .pixelExportScaleBtn').forEach(b => {
b.classList.toggle('active', b.dataset.fit === _pixelizeState.opts.fit);
});
pixelizeUpdatePreview();
}
function closePixelizeModal() {
const modal = $('pixelizeModal');
if (modal) { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); }
_pixelizeState = null;
}
function pixelizeUpdatePreview() {
if (!_pixelizeState) return;
// Debounce to keep typing responsive — quantization can take ~50–100ms
// at the upper end (128×128 × 64 colours).
clearTimeout(_pixelizeState.debounce);
_pixelizeState.debounce = setTimeout(() => {
if (!_pixelizeState) return;
try {
const pa = pixelizeImage(_pixelizeState.img, _pixelizeState.opts);
_pixelizeState.previewPa = pa;
_pixelizeRenderPreview($('pixelizeOutWrap'), pa);
} catch (e) {
console.warn('pixelize preview:', e);
}
}, 60);
}
// Replace the open pixel editor's state (grid dims + palette + frames)
// with the pixelized image. One undo entry restores everything.
function pixelizeCommit() {
if (!_pixelizeState || !_pixelEdit) { closePixelizeModal(); return; }
const pa = pixelizeImage(_pixelizeState.img, _pixelizeState.opts);
pixelSnapshot();
_pixelEdit.w = pa.width;
_pixelEdit.h = pa.height;
_pixelEdit.palette = pa.palette.slice();
_pixelEdit.frames = decodePixelArtFrames(pa, pa.width, pa.height);
_pixelEdit.currentFrame = 0;
_pixelEdit.indices = _pixelEdit.frames[0].indices;
_pixelEdit.current = Math.min(1, _pixelEdit.palette.length - 1);
const dims = $('pixelEditorDims');
if (dims) dims.textContent = `${pa.width} × ${pa.height} px`;
renderPixelPalette();
renderPixelGrid();
renderPixelTimeline();
pixelCommitIfStandalone();
closePixelizeModal();
toastSuccess('Image pixelized.');
}
function pixelImportFromImage() {
$('pixelImportImage')?.click();
}
function handlePixelImageFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => openPixelizeModal(img);
img.onerror = () => toastError('Could not read that image.');
img.src = reader.result;
};
reader.onerror = () => toastError('Could not read that file.');
reader.readAsDataURL(file);
}
// Sanitize an arbitrary `pixelArt` blob (from file or community payload)
// into a safe, clamped shape we can drop into a new layer.
function sanitizePixelArtBlob(pa) {
@@ -20003,6 +20295,39 @@
pixelImportFromFile();
});
$('pixelImportFile')?.addEventListener('change', handlePixelImportFile);
// Pixel-editor toolbar "From image…" — opens the picker, then the
// pixelize modal. Replaces the editor's current grid + palette + frames.
$('pixelEditorImageBtn')?.addEventListener('click', () => {
$('pixelEditorImageFile')?.click();
});
$('pixelEditorImageFile')?.addEventListener('change', handlePixelImageFile);
// ---- Pixelize modal wiring ----
const _onPixelizeOpt = (key, max = 128, min = 4) => (e) => {
if (!_pixelizeState) return;
const v = Math.max(min, Math.min(max, Number(e.target.value) | 0 || min));
_pixelizeState.opts[key] = v;
pixelizeUpdatePreview();
};
$('pixelizeW')?.addEventListener('input', _onPixelizeOpt('width', 128, 4));
$('pixelizeH')?.addEventListener('input', _onPixelizeOpt('height', 128, 4));
$('pixelizeColors')?.addEventListener('input', _onPixelizeOpt('colors', 64, 2));
$('pixelizeFitMode')?.addEventListener('click', (e) => {
const btn = e.target.closest('.pixelExportScaleBtn');
if (!btn || !_pixelizeState) return;
const fit = btn.dataset.fit || 'cover';
_pixelizeState.opts.fit = fit;
document.querySelectorAll('#pixelizeFitMode .pixelExportScaleBtn').forEach(b => {
b.classList.toggle('active', b.dataset.fit === fit);
});
pixelizeUpdatePreview();
});
$('pixelizeCancelBtn')?.addEventListener('click', closePixelizeModal);
$('pixelizeCloseBtn')?.addEventListener('click', closePixelizeModal);
$('pixelizeAddBtn')?.addEventListener('click', pixelizeCommit);
// Click outside the inner modal closes.
$('pixelizeModal')?.addEventListener('click', (e) => {
if (e.target === $('pixelizeModal')) closePixelizeModal();
});
// Outside-click and Escape dismiss the menu — mirrors the export-PNG
// menu pattern.
const _pixelToolMenuEl = $('pixelToolMenu');