Import a published pixel-art design as a layer (Phase B)

The pixel-art tool button (▦) in the tool palette now opens a small
popup with three options, mirroring the disc-slot and deck-slide
patterns:

- Add blank pixel-art layer — original behavior (32×32 default).
- Browse community… — opens the existing Nostr browser filtered
  to nostrType='pixelart'. Selecting a design imports it as a new
  pixelart layer in the current design (rather than loading it as
  the current document).
- Import from file… — file picker for .json designs.

Plumbing:

- sanitizePixelArtBlob clamps dimensions to 4–128 (matches resize
  cap), trims palettes to 64 entries, defaults missing palettes to
  DEFAULT_PIXEL_PALETTE, decodes/re-encodes RLE so corrupt runs
  either coerce to something valid or get rejected.
- importPixelArtAsLayer accepts either a full design payload (with
  .pixelArt) or a raw pixelArt blob; rejected payloads toast an
  error rather than creating a broken layer.
- tryPixelArtImportRow intercepts community-browser selections
  while state._pixelArtImportMode is true — added alongside the
  existing tryDeckImportRow at the four selection sites.
- nostrClose handler clears state._pixelArtImportMode so closing
  the modal without picking doesn't leave the flag stuck on.
- Menu closes on outside click, Escape, and after picking — same
  pattern as the export-PNG popover.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-26 08:26:34 -04:00
1 parent f20a90929d
commit 2a8d0e3707
1 file changed
+173 -3
+173 -3
View File
@@ -2138,6 +2138,23 @@
.toolPaletteBtn:hover { background:rgba(127,127,127,.14); }
.toolPaletteBtn.active { background:var(--accent); color:#fff; }
.toolPaletteDivider { height:1px; background:var(--line); margin:3px 2px; }
.pixelToolWrap { position: relative; }
.pixelToolMenu {
position: absolute; left: calc(100% + 6px); top: 0; z-index: 40;
min-width: 220px; padding: 6px;
background: var(--panel, #fff); color: var(--ink);
border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(15,23,42,.18);
flex-direction: column; gap: 2px;
display: none;
}
.pixelToolMenu.open { display: flex; }
.pixelToolMenu button {
text-align: left; padding: 8px 10px; font-size: .85rem;
border: 0; background: transparent; color: var(--ink);
border-radius: 6px; cursor: pointer; font-family: inherit;
}
.pixelToolMenu button:hover { background: rgba(127,127,127,.14); }
.toolOptions {
grid-area: options;
display: flex;
@@ -3527,7 +3544,15 @@
<button type="button" class="toolPaletteBtn" id="tool-pencil" data-tool="pencil" title="Pencil tool (B)" aria-label="Pencil tool">✎</button>
<button type="button" class="toolPaletteBtn" id="tool-shape" data-tool="shape" title="Shape tool (S)" aria-label="Shape tool">▢</button>
<button type="button" class="toolPaletteBtn" id="tool-text" data-tool="text" title="Text tool (T)" aria-label="Text tool">T</button>
<button type="button" class="toolPaletteBtn" id="tool-pixel" title="Add a pixel-art layer" aria-label="Pixel art layer">▦</button>
<div class="pixelToolWrap">
<button type="button" class="toolPaletteBtn" id="tool-pixel" title="Pixel art layer" aria-haspopup="true" aria-expanded="false" aria-label="Pixel art layer">▦</button>
<div id="pixelToolMenu" class="pixelToolMenu">
<button type="button" id="pixelAddBlankBtn">Add blank pixel-art layer</button>
<button type="button" id="pixelImportNostrBtn">Browse community…</button>
<button type="button" id="pixelImportFileBtn">Import from file…</button>
</div>
<input id="pixelImportFile" type="file" accept="application/json,.json" hidden />
</div>
<button type="button" class="toolPaletteBtn" id="deckPresentBtn" title="Present this deck" aria-label="Present deck">▶</button>
</div>
@@ -9033,6 +9058,110 @@
openPixelEditor(layer.id);
}
// 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) {
if (!pa || typeof pa !== 'object') return null;
const w = Math.max(4, Math.min(128, pa.width | 0));
const h = Math.max(4, Math.min(128, pa.height | 0));
if (!w || !h) return null;
const palette = (Array.isArray(pa.palette) && pa.palette.length)
? pa.palette.slice(0, 64).map(c => (typeof c === 'string' ? c : '#00000000'))
: DEFAULT_PIXEL_PALETTE.slice();
const srcFrame = (Array.isArray(pa.frames) && pa.frames[0]) ? pa.frames[0] : { pixels: [] };
// decode then re-encode so we own the buffer and catch corrupt RLE runs.
const indices = decodePixelRLE(srcFrame, w, h);
const pixels = encodePixelRLE(indices);
return { width: w, height: h, palette, frames: [{ id: 'f1', pixels }] };
}
function importPixelArtAsLayer(payload, targetOverride = '') {
if (!payload || typeof payload !== 'object') {
toastError('That file does not look like an Artstr Studio design.');
return false;
}
// Accept two shapes: a full design payload (with .pixelArt) OR a raw
// pixelArt blob (width/height/palette/frames).
const blob = (payload.pixelArt && typeof payload.pixelArt === 'object')
? payload.pixelArt
: (payload.width && payload.height && Array.isArray(payload.frames) ? payload : null);
const pa = sanitizePixelArtBlob(blob);
if (!pa) {
toastError('No pixel art found in that design.');
return false;
}
const target = targetOverride || $('layerTarget')?.value || primaryTargetForMode(state.templateMode || 'cover');
const bounds = targetBounds(target);
applyTargetMode(target);
const size = Math.min(bounds.w, bounds.h) * 0.45;
pushHistory('import pixel art layer');
const layer = {
id: makeLayerId(),
type: 'pixelart',
name: payload?.meta?.title ? `Pixel art — ${payload.meta.title}` : 'Pixel art',
target,
x: bounds.x + (bounds.w - size) / 2,
y: bounds.y + (bounds.h - size) / 2,
w: size, h: size,
rotate: 0, opacity: 1, z: nextLayerZ(),
pixelArt: pa
};
addLayer(layer);
toastSuccess('Pixel art imported.');
return true;
}
function openPixelArtImportBrowser() {
state._pixelArtImportMode = true;
$('nostrType').value = 'pixelart';
_feedState.filterNew = 'pixelart';
_feedState.filterPopular = 'pixelart';
_feedState.filterSearch = 'pixelart';
_feedState.filterMine = 'pixelart';
$('nostrModal')?.classList.add('open');
if (typeof switchModalTab === 'function') switchModalTab('New');
}
// Intercept a community-browser selection while pixel-art import mode is
// active: drop the chosen design in as a pixelart layer instead of
// loading it as the current document.
function tryPixelArtImportRow(row) {
if (!state._pixelArtImportMode) return false;
state._pixelArtImportMode = false;
try { closeNostrPreview(); } catch (e) {}
$('nostrModal')?.classList.remove('open');
if (row && row.payload) importPixelArtAsLayer(row.payload);
return true;
}
function pixelImportFromFile() {
$('pixelImportFile')?.click();
}
function handlePixelImportFile(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
let payload;
try { payload = JSON.parse(reader.result); }
catch (err) { toastError('Could not read that file as a design.'); return; }
importPixelArtAsLayer(payload);
};
reader.readAsText(file);
}
function pixelToolMenuToggle(forceOpen) {
const menu = $('pixelToolMenu');
const btn = $('tool-pixel');
if (!menu || !btn) return;
const isOpen = menu.classList.contains('open');
const willOpen = forceOpen !== undefined ? forceOpen : !isOpen;
menu.classList.toggle('open', willOpen);
btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
}
// Commit the standalone pixel editor's working grid into state.pixelArt.
function pixelCommitIfStandalone() {
if (!_pixelEdit || !_pixelEdit.standalone) return;
@@ -16765,6 +16894,7 @@
wall.appendChild(buildFeedCard(row, () => {
if (tryDesignLayerImportRow(row)) return;
if (tryDeckImportRow(row)) return;
if (tryPixelArtImportRow(row)) return;
if (row.mode === 'disc-design') {
const slotTarget = state._discImportTargetSlot;
if (slotTarget) {
@@ -16815,6 +16945,7 @@
const card = buildFeedCard(row, () => {
if (tryDesignLayerImportRow(row)) return;
if (tryDeckImportRow(row)) return;
if (tryPixelArtImportRow(row)) return;
if (row.mode === 'disc-design') {
const slotTarget = state._discImportTargetSlot;
if (slotTarget) {
@@ -17187,6 +17318,7 @@
if (!row?.payload) return;
if (tryDesignLayerImportRow(row)) return;
if (tryDeckImportRow(row)) return;
if (tryPixelArtImportRow(row)) return;
if (row.mode === 'disc-design') {
const slotTarget = state._discImportTargetSlot;
if (slotTarget) {
@@ -18373,6 +18505,7 @@
out.appendChild(buildResultCard(row, () => {
if (tryDesignLayerImportRow(row)) return;
if (tryDeckImportRow(row)) return;
if (tryPixelArtImportRow(row)) return;
if (row.mode === 'disc-design') {
const slotTarget = state._discImportTargetSlot;
if (slotTarget) {
@@ -19235,7 +19368,44 @@
$('deckPresentBtn')?.addEventListener('click', presentCurrentDeck);
// ---- Pixel Editor wiring ----
$('tool-pixel')?.addEventListener('click', () => addPixelArtLayer());
// The `tool-pixel` palette button opens a small menu with three options:
// add a blank layer, browse the community for an existing design, or
// import a design from a local JSON file.
$('tool-pixel')?.addEventListener('click', (e) => {
e.stopPropagation();
pixelToolMenuToggle();
});
$('pixelAddBlankBtn')?.addEventListener('click', () => {
pixelToolMenuToggle(false);
addPixelArtLayer();
});
$('pixelImportNostrBtn')?.addEventListener('click', () => {
pixelToolMenuToggle(false);
openPixelArtImportBrowser();
});
$('pixelImportFileBtn')?.addEventListener('click', () => {
pixelToolMenuToggle(false);
pixelImportFromFile();
});
$('pixelImportFile')?.addEventListener('change', handlePixelImportFile);
// Outside-click and Escape dismiss the menu — mirrors the export-PNG
// menu pattern.
const _pixelToolMenuEl = $('pixelToolMenu');
const _pixelToolBtnEl = $('tool-pixel');
if (_pixelToolMenuEl && _pixelToolBtnEl) {
const _maybeClosePixelToolMenu = (ev) => {
if (!_pixelToolMenuEl.classList.contains('open')) return;
if (_pixelToolMenuEl.contains(ev.target) || _pixelToolBtnEl.contains(ev.target)) return;
pixelToolMenuToggle(false);
};
document.addEventListener('click', _maybeClosePixelToolMenu);
document.addEventListener('pointerdown', _maybeClosePixelToolMenu, true);
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && _pixelToolMenuEl.classList.contains('open')) {
pixelToolMenuToggle(false);
}
});
}
$('editPixelsBtn')?.addEventListener('click', () => {
const l = selectedLayer();
if (l && l.type === 'pixelart') openPixelEditor(l.id);
@@ -19671,7 +19841,7 @@
$("designerCategoryField").addEventListener("change", (e)=>{pushHistory(`change category to ${e.target.value || 'Uncategorized'}`); state.meta.category=e.target.value; syncIdentifierVisibility(); render();});
$("designerLanguageField").addEventListener("change", (e)=>{pushHistory('change language'); state.meta.language=e.target.value; render();});
$("nostrClose").addEventListener("click", () => { closeNostrPreview(); $("nostrModal").classList.remove("open"); _feedCache.softModRevealed.clear(); state._deckImportMode = false; state._designLayerImportTarget = ''; state._designLayerReplaceId = ''; });
$("nostrClose").addEventListener("click", () => { closeNostrPreview(); $("nostrModal").classList.remove("open"); _feedCache.softModRevealed.clear(); state._deckImportMode = false; state._pixelArtImportMode = false; state._designLayerImportTarget = ''; state._designLayerReplaceId = ''; });
$("nostrRefreshBtn").addEventListener("click", async () => {
const btn = $("nostrRefreshBtn");
const original = btn.textContent;