exploded-classic #1

Merged
chris merged 15 commits from exploded-classic into main 2025-12-19 09:18:59 -05:00
5 changed files with 966 additions and 260 deletions
Showing only changes of commit 7e6ac4cf4b - Show all commits

View File

@ -26,7 +26,7 @@
<body class="p-0 md:p-6 flex flex-col items-center justify-start min-h-screen bg-[conic-gradient(at_top_left,_var(--tw-gradient-stops))] from-indigo-100 via-white to-pink-100 text-slate-800 overflow-hidden">
<div class="container mx-auto mt-2 p-4 lg:p-6 bg-white/80 lg:backdrop-blur-xl rounded-3xl border border-white/50 shadow-2xl flex flex-col gap-4 max-w-7xl lg:h-[calc(100vh-2rem)] overflow-hidden ring-1 ring-black/5">
<header class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
<header id="app-header" class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
<div class="flex items-center gap-3">
<div>
@ -196,14 +196,17 @@
</div>
<div class="panel-heading mt-4">Replace Color</div>
<div class="panel-card">
<div class="panel-card space-y-3">
<div class="flex items-center gap-2 replace-row">
<button type="button" class="replace-chip" id="replace-from-chip" aria-label="Pick color to replace"></button>
<span class="text-xs font-semibold text-slate-500"></span>
<button type="button" class="replace-chip" id="replace-to-chip" aria-label="Pick replacement color"></button>
<span id="replace-count" class="text-xs text-slate-500 ml-auto"></span>
</div>
<div class="grid grid-cols-1 gap-2">
<label class="text-sm font-medium">From (in design):</label>
<select id="replace-from" class="select"></select>
<label class="text-sm font-medium">To (library):</label>
<select id="replace-to" class="select"></select>
<p class="hint text-xs">Tap a chip to choose colors. “From” shows only colors used on canvas.</p>
<select id="replace-from" class="sr-only"></select>
<select id="replace-to" class="sr-only"></select>
<button id="replace-btn" class="btn-blue">Replace</button>
<p id="replace-msg" class="hint"></p>
</div>
@ -439,14 +442,6 @@
<option value="x">X / Diamond</option>
</select>
</label>
<div class="text-sm font-medium flex flex-col gap-1 col-span-2">
<span>Spacing</span>
<span class="text-xs text-gray-500" id="wall-spacing-label">75 px (fixed)</span>
</div>
<div class="text-sm font-medium flex flex-col gap-1 col-span-2">
<span>Balloon Size</span>
<span class="text-xs text-gray-500" id="wall-size-label">52 px (fixed)</span>
</div>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-show-wire" type="checkbox" class="align-middle" checked>
Show wireframe for empty spots
@ -456,9 +451,34 @@
Outline balloons
</label>
</div>
<div class="panel-heading mt-4">Tools</div>
<div class="panel-card">
<div class="wall-toolbar">
<button type="button" id="wall-tool-paint" class="tool-btn" aria-pressed="true">
<i class="fa-solid fa-brush"></i>
<span>Paint</span>
</button>
<button type="button" id="wall-tool-erase" class="tool-btn" aria-pressed="false">
<i class="fa-solid fa-eraser"></i>
<span>Erase</span>
</button>
</div>
<p class="hint mt-2 text-xs">Paint applies the active color; Erase clears. Hold Shift/Ctrl for temporary erase.</p>
</div>
</div>
<div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading mt-4">Active Color</div>
<div class="panel-card">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700">Current</span>
<div id="wall-active-color-chip" class="current-color-chip">
<span id="wall-active-color-label" class="text-[10px] font-semibold text-slate-700"></span>
</div>
</div>
<p class="hint mt-2">Tap a swatch to set. Tap a balloon to paint; tap again (same color) to clear. Alt+click (desktop) to pick.</p>
</div>
<div class="panel-heading mt-4">Used Colors</div>
<div class="panel-card">
<div class="flex items-center justify-between mb-2">
@ -472,17 +492,20 @@
<div id="wall-palette" class="palette-box min-h-[3rem]"></div>
</div>
<div class="panel-heading mt-4">Replace Colors</div>
<div class="panel-card grid grid-cols-1 gap-2">
<label class="text-sm font-medium flex flex-col gap-1">
Replace
<select id="wall-replace-from" class="select text-sm"></select>
</label>
<label class="text-sm font-medium flex flex-col gap-1">
With
<select id="wall-replace-to" class="select text-sm"></select>
</label>
<button type="button" id="wall-replace-btn" class="btn-dark text-sm">Replace</button>
<div id="wall-replace-msg" class="text-xs text-gray-500"></div>
<div class="panel-card space-y-3">
<div class="flex items-center gap-2 replace-row">
<button type="button" class="replace-chip" id="wall-replace-from-chip" aria-label="Pick wall color to replace"></button>
<span class="text-xs font-semibold text-slate-500"></span>
<button type="button" class="replace-chip" id="wall-replace-to-chip" aria-label="Pick wall replacement color"></button>
<span id="wall-replace-count" class="text-xs text-slate-500 ml-auto"></span>
</div>
<div class="grid grid-cols-1 gap-2">
<p class="hint text-xs">Tap a chip to choose colors. “Replace” shows only colors used in this wall.</p>
<select id="wall-replace-from" class="sr-only"></select>
<select id="wall-replace-to" class="sr-only"></select>
<button type="button" id="wall-replace-btn" class="btn-dark text-sm">Replace</button>
<div id="wall-replace-msg" class="text-xs text-gray-500"></div>
</div>
</div>
</div>
@ -540,6 +563,71 @@
</button>
</div>
<!-- Mobile sticky action bar -->
<div id="mobile-action-bar" class="mobile-action-bar hidden">
<div class="mobile-action-chip" id="mobile-active-color-chip" title="Active color"></div>
<div class="mobile-action-row">
<button type="button" class="mobile-action-btn" id="mobile-act-undo" aria-label="Undo">
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i>
<span>Undo</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-redo" aria-label="Redo">
<i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
<span>Redo</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-eyedrop" aria-label="Eyedropper">
<i class="fa-solid fa-eye-dropper" aria-hidden="true"></i>
<span>Pick</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-erase" aria-label="Toggle Erase">
<i class="fa-solid fa-eraser" aria-hidden="true"></i>
<span>Erase</span>
</button>
<button type="button" class="mobile-action-btn danger" id="mobile-act-clear" aria-label="Clear canvas">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span>Clear</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-export" aria-label="Export PNG">
<i class="fa-solid fa-download" aria-hidden="true"></i>
<span>Export</span>
</button>
</div>
</div>
<!-- Color picker modal -->
<div id="color-picker-modal" class="color-modal hidden" role="dialog" aria-modal="true" aria-labelledby="color-picker-title">
<div class="color-modal-backdrop"></div>
<div class="color-modal-card">
<div class="color-modal-header">
<div>
<div id="color-picker-title" class="color-modal-title">Choose a color</div>
<div id="color-picker-subtitle" class="color-modal-subtitle"></div>
</div>
<button type="button" id="color-picker-close" class="color-modal-close" aria-label="Close">&times;</button>
</div>
<div id="color-picker-grid" class="color-modal-grid"></div>
</div>
</div>
<!-- Export modal -->
<div id="export-modal" class="color-modal hidden" role="dialog" aria-modal="true" aria-labelledby="export-modal-title">
<div class="color-modal-backdrop"></div>
<div class="color-modal-card">
<div class="color-modal-header">
<div>
<div id="export-modal-title" class="color-modal-title">Export design</div>
<div class="color-modal-subtitle">Choose a format to download</div>
</div>
<button type="button" id="export-modal-close" class="color-modal-close" aria-label="Close">&times;</button>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button type="button" class="btn-blue flex-1" data-export-choice="png">Export PNG</button>
<button type="button" class="btn-dark flex-1" data-export-choice="svg">Export SVG</button>
</div>
<p class="hint mt-2 text-xs text-slate-500">SVG keeps vector shapes where possible (textures/images stay raster). PNG renders a high-res snapshot.</p>
</div>
</div>
<div id="message-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">
<div class="bg-white p-6 rounded-lg shadow-lg max-w-sm text-center">
<p id="modal-text" class="text-gray-800 text-lg"></p>

View File

@ -164,6 +164,9 @@
const replaceToSel = document.getElementById('replace-to');
const replaceBtn = document.getElementById('replace-btn');
const replaceMsg = document.getElementById('replace-msg');
const replaceFromChip = document.getElementById('replace-from-chip');
const replaceToChip = document.getElementById('replace-to-chip');
const replaceCountLabel = document.getElementById('replace-count');
// IO
const clearCanvasBtn = document.getElementById('clear-canvas-btn');
@ -177,7 +180,7 @@
// Debug overlay to diagnose mobile input issues
const debugOverlay = document.createElement('div');
debugOverlay.id = 'organic-debug-overlay';
debugOverlay.style.cssText = 'position:fixed;bottom:8px;right:8px;z-index:9999;background:rgba(0,0,0,0.7);color:#fff;padding:6px 8px;border-radius:8px;font-size:10px;font-family:monospace;pointer-events:none;opacity:0.9;line-height:1.3;';
debugOverlay.style.cssText = 'position:fixed;bottom:8px;right:8px;z-index:9999;background:rgba(0,0,0,0.7);color:#fff;padding:6px 8px;border-radius:8px;font-size:10px;font-family:monospace;pointer-events:none;opacity:0.9;line-height:1.3;display:none;';
debugOverlay.textContent = 'organic debug';
document.body.appendChild(debugOverlay);
@ -216,7 +219,6 @@
let lastCommitMode = '';
let lastAddStatus = '';
let evtStats = { down: 0, up: 0, cancel: 0, touchEnd: 0, addBalloon: 0, addGarland: 0, lastType: '' };
let addedThisPointer = false;
// History for Undo/Redo
const historyStack = [];
@ -308,6 +310,14 @@
const v = Math.round(val * 10) / 10;
return `${String(v).replace(/\.0$/, '')}"`;
}
const makeId = (() => {
let n = 0;
return () => {
try { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID(); } catch {}
return `b-${Date.now().toString(36)}-${(n++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
};
})();
function radiusToSizeIndex(r) {
let best = 0, bestDiff = Infinity;
for (let i = 0; i < SIZE_PRESETS.length; i++) {
@ -343,13 +353,6 @@
y: (e.clientY - r.top) / view.s - view.ty
};
}
function getTouchPos(touch) {
const r = canvas.getBoundingClientRect();
return {
x: (touch.clientX - r.left) / view.s - view.tx,
y: (touch.clientY - r.top) / view.s - view.ty
};
}
// ====== Global shine sync (shared with Classic)
window.syncAppShine = function(isEnabled) {
@ -383,6 +386,15 @@
toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase'));
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
const mobileErase = document.getElementById('mobile-act-erase');
const mobilePick = document.getElementById('mobile-act-eyedrop');
const setActive = (el, on) => {
if (!el) return;
el.setAttribute('aria-pressed', String(on));
el.classList.toggle('active', !!on);
};
setActive(mobileErase, mode === 'erase');
setActive(mobilePick, mode === 'eyedropper');
eraserControls?.classList.toggle('hidden', mode !== 'erase');
selectControls?.classList.toggle('hidden', mode !== 'select');
@ -456,6 +468,8 @@
let marqueeActive = false;
let marqueeStart = { x: 0, y: 0 };
let marqueeEnd = { x: 0, y: 0 };
let pointerEventsSeen = false;
let touchFallbackHandled = false;
function requestDraw() {
if (drawPending) return;
@ -466,17 +480,24 @@
});
}
// Avoid touch scrolling stealing pointer events.
canvas.style.touchAction = 'none';
const pointerTypeOf = (evt, fromTouch) => fromTouch ? 'touch' : (evt.pointerType || '');
function handlePointerDownInternal(pos, pointerType) {
if (pointerType === 'touch') evtStats.lastType = 'touch';
function handlePrimaryDown(evt, { fromTouch = false } = {}) {
// If the canvas never got sized (some mobile browsers skip ResizeObserver early), size it now.
if (canvas.width === 0 || canvas.height === 0) resizeCanvas();
mouseInside = true;
mousePos = pos;
mousePos = getMousePos(evt);
evtStats.down += 1;
evtStats.lastType = pointerTypeOf(evt, fromTouch);
if (evt.altKey || mode === 'eyedropper') {
pickColorAt(mousePos.x, mousePos.y);
if (mode === 'eyedropper') setMode('draw');
return;
}
if (mode === 'erase') {
pointerDown = true;
evtStats.down += 1;
erasingActive = true;
eraseChanged = eraseAt(mousePos.x, mousePos.y);
return;
@ -484,7 +505,6 @@
if (mode === 'garland') {
pointerDown = true;
evtStats.down += 1;
garlandPath = [{ ...mousePos }];
requestDraw();
return;
@ -494,129 +514,59 @@
pointerDown = true;
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
if (clickedIdx !== -1) {
const b = balloons[clickedIdx];
if (event?.shiftKey) {
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
else selectedIds.add(b.id);
} else if (!selectedIds.has(b.id)) {
selectedIds.clear();
selectedIds.add(b.id);
}
updateSelectButtons();
draw();
isDragging = true;
dragStartPos = { ...mousePos };
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
dragMoved = false;
const b = balloons[clickedIdx];
if (evt.shiftKey) {
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
else selectedIds.add(b.id);
} else if (!selectedIds.has(b.id)) {
selectedIds.clear();
selectedIds.add(b.id);
}
updateSelectButtons();
draw();
isDragging = true;
dragStartPos = { ...mousePos };
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
dragMoved = false;
} else {
if (!event?.shiftKey) selectedIds.clear();
updateSelectButtons();
marqueeActive = true;
marqueeStart = { ...mousePos };
marqueeEnd = { ...mousePos };
requestDraw();
if (!evt.shiftKey) selectedIds.clear();
updateSelectButtons();
marqueeActive = true;
marqueeStart = { ...mousePos };
marqueeEnd = { ...mousePos };
requestDraw();
}
return;
}
// draw mode: add
pointerDown = true;
evtStats.down += 1;
addBalloon(mousePos.x, mousePos.y);
evtStats.addBalloon += 1;
addedThisPointer = true;
pointerDown = true;
}
canvas.addEventListener('pointerdown', e => {
if (e.pointerType === 'touch') e.preventDefault();
canvas.setPointerCapture?.(e.pointerId);
function handlePrimaryMove(evt, { fromTouch = false } = {}) {
mouseInside = true;
mousePos = getMousePos(e);
evtStats.down += 1;
evtStats.lastType = e.pointerType || '';
if (e.altKey || mode === 'eyedropper') {
pickColorAt(mousePos.x, mousePos.y);
if (mode === 'eyedropper') setMode('draw'); // Auto-switch back? or stay? Let's stay for multi-pick, or switch for quick workflow. Let's switch back for now.
return;
}
if (mode === 'erase') {
pointerDown = true;
erasingActive = true;
eraseChanged = eraseAt(mousePos.x, mousePos.y);
return;
}
if (mode === 'garland') {
pointerDown = true;
garlandPath = [{ ...mousePos }];
requestDraw();
return;
}
mousePos = getMousePos(evt);
if (mode === 'select') {
pointerDown = true;
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
if (clickedIdx !== -1) {
const b = balloons[clickedIdx];
if (e.shiftKey) {
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
else selectedIds.add(b.id);
} else if (!selectedIds.has(b.id)) {
selectedIds.clear();
selectedIds.add(b.id);
if (isDragging && selectedIds.size) {
const dx = mousePos.x - dragStartPos.x;
const dy = mousePos.y - dragStartPos.y;
dragOffsets.forEach(off => {
const b = balloons.find(bb => bb.id === off.id);
if (b) {
b.x = mousePos.x + off.dx;
b.y = mousePos.y + off.dy;
}
updateSelectButtons();
draw();
isDragging = true;
dragStartPos = { ...mousePos };
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
dragMoved = false;
});
requestDraw();
dragMoved = true;
} else if (marqueeActive) {
marqueeEnd = { ...mousePos };
requestDraw();
} else {
if (!e.shiftKey) selectedIds.clear();
updateSelectButtons();
marqueeActive = true;
marqueeStart = { ...mousePos };
marqueeEnd = { ...mousePos };
requestDraw();
const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
}
return;
}
// draw mode: add
pointerDown = true;
evtStats.down += 1;
evtStats.lastType = e.pointerType || '';
addBalloon(mousePos.x, mousePos.y);
evtStats.addBalloon += 1;
addedThisPointer = true;
}, { passive: false });
canvas.addEventListener('pointermove', e => {
mouseInside = true;
mousePos = getMousePos(e);
if (mode === 'select') {
if (isDragging && selectedIds.size) {
const dx = mousePos.x - dragStartPos.x;
const dy = mousePos.y - dragStartPos.y;
dragOffsets.forEach(off => {
const b = balloons.find(bb => bb.id === off.id);
if (b) {
b.x = mousePos.x + off.dx;
b.y = mousePos.y + off.dy;
}
});
requestDraw();
dragMoved = true;
} else if (marqueeActive) {
marqueeEnd = { ...mousePos };
requestDraw();
} else {
const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
}
}
if (mode === 'garland') {
@ -638,28 +588,19 @@
requestDraw();
}
}
}, { passive: true });
canvas.addEventListener('pointerenter', () => {
mouseInside = true;
if (mode === 'erase') requestDraw();
});
function commitGarlandPath() {
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
garlandPath = [];
requestDraw();
lastCommitMode = mode;
}
canvas.addEventListener('pointerup', e => {
function handlePrimaryUp(evt, { fromTouch = false } = {}) {
pointerDown = false;
isDragging = false;
evtStats.up += 1;
evtStats.lastType = e.pointerType || '';
evtStats.lastType = pointerTypeOf(evt, fromTouch);
if (fromTouch) evtStats.touchEnd += 1;
if (mode === 'garland') {
commitGarlandPath();
canvas.releasePointerCapture?.(e.pointerId);
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
garlandPath = [];
requestDraw();
return;
}
if (mode === 'select' && dragMoved) {
@ -672,28 +613,62 @@
const minY = Math.min(marqueeStart.y, marqueeEnd.y);
const maxY = Math.max(marqueeStart.y, marqueeEnd.y);
const ids = balloons.filter(b => b.x >= minX && b.x <= maxX && b.y >= minY && b.y <= maxY).map(b => b.id);
if (!e.shiftKey) selectedIds.clear();
if (!evt.shiftKey) selectedIds.clear();
ids.forEach(id => selectedIds.add(id));
marqueeActive = false;
updateSelectButtons();
requestDraw();
}
if (mode === 'erase' && eraseChanged) {
refreshAll(); // update palette/persist once after the stroke
refreshAll();
pushHistory();
}
if (mode === 'draw' && !addedThisPointer) {
addBalloon(mousePos.x, mousePos.y);
evtStats.addBalloon += 1;
lastAddStatus = 'balloon:up';
}
addedThisPointer = false;
erasingActive = false;
dragMoved = false;
eraseChanged = false;
marqueeActive = false;
}
function handlePrimaryCancel(evt, { fromTouch = false } = {}) {
pointerDown = false;
evtStats.cancel += 1;
evtStats.lastType = pointerTypeOf(evt, fromTouch);
if (mode === 'garland') {
garlandPath = [];
requestDraw();
}
}
// Avoid touch scrolling stealing pointer events.
canvas.style.touchAction = 'none';
canvas.addEventListener('pointerdown', e => {
// If a touch fallback already handled this gesture, ignore the duplicate pointer event.
if (touchFallbackHandled && e.pointerType === 'touch') return;
pointerEventsSeen = true;
touchFallbackHandled = false;
e.preventDefault();
canvas.setPointerCapture?.(e.pointerId);
handlePrimaryDown(e, { fromTouch: e.pointerType === 'touch' });
}, { passive: false });
canvas.addEventListener('pointermove', e => {
if (touchFallbackHandled && e.pointerType === 'touch') return;
handlePrimaryMove(e, { fromTouch: e.pointerType === 'touch' });
}, { passive: true });
canvas.addEventListener('pointerenter', () => {
mouseInside = true;
if (mode === 'erase') requestDraw();
});
canvas.addEventListener('pointerup', e => {
if (touchFallbackHandled && e.pointerType === 'touch') {
canvas.releasePointerCapture?.(e.pointerId);
return;
}
handlePrimaryUp(e, { fromTouch: e.pointerType === 'touch' });
canvas.releasePointerCapture?.(e.pointerId);
requestDraw();
}, { passive: true });
canvas.addEventListener('pointerleave', () => {
@ -701,43 +676,65 @@
marqueeActive = false;
if (mode === 'garland') {
pointerDown = false;
commitGarlandPath();
}
if (mode === 'draw') addedThisPointer = false;
if (mode === 'erase') requestDraw();
}, { passive: true });
canvas.addEventListener('pointercancel', (e) => {
pointerDown = false;
evtStats.cancel += 1;
evtStats.lastType = e.pointerType || '';
if (mode === 'draw') addedThisPointer = false;
if (mode === 'garland') commitGarlandPath();
}, { passive: true });
const commitIfGarland = () => {
if (mode === 'garland' && garlandPath.length > 1) {
commitGarlandPath();
} else if (mode === 'garland') {
garlandPath = [];
requestDraw();
}
};
window.addEventListener('pointerup', () => {
pointerDown = false;
if (mode === 'draw') addedThisPointer = false;
commitIfGarland();
if (mode === 'erase') requestDraw();
}, { passive: true });
const touchEndCommit = () => {
pointerDown = false;
evtStats.touchEnd += 1;
evtStats.lastType = 'touch';
if (mode === 'draw') addedThisPointer = false;
commitIfGarland();
canvas.addEventListener('pointercancel', e => {
if (touchFallbackHandled && e.pointerType === 'touch') return;
handlePrimaryCancel(e, { fromTouch: e.pointerType === 'touch' });
}, { passive: true });
// Touch fallback for browsers where pointer events are not delivered.
const touchToPointerLike = (e) => {
const t = e.changedTouches && e.changedTouches[0];
if (!t) return null;
return {
clientX: t.clientX,
clientY: t.clientY,
pointerType: 'touch',
altKey: e.altKey,
shiftKey: e.shiftKey
};
};
window.addEventListener('touchend', touchEndCommit, { passive: true });
window.addEventListener('touchcancel', touchEndCommit, { passive: true });
const shouldHandleTouchFallback = () => !pointerEventsSeen;
canvas.addEventListener('touchstart', e => {
if (!shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e);
if (!fake) return;
touchFallbackHandled = true;
e.preventDefault();
handlePrimaryDown(fake, { fromTouch: true });
}, { passive: false });
canvas.addEventListener('touchmove', e => {
if (!touchFallbackHandled || !shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e);
if (!fake) return;
e.preventDefault();
handlePrimaryMove(fake, { fromTouch: true });
}, { passive: false });
canvas.addEventListener('touchend', e => {
if (!touchFallbackHandled || !shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e) || { pointerType: 'touch', shiftKey: false, altKey: false, clientX: 0, clientY: 0 };
e.preventDefault();
handlePrimaryUp(fake, { fromTouch: true });
touchFallbackHandled = false;
}, { passive: false });
canvas.addEventListener('touchcancel', e => {
if (!touchFallbackHandled || !shouldHandleTouchFallback()) return;
const fake = touchToPointerLike(e) || { pointerType: 'touch' };
handlePrimaryCancel(fake, { fromTouch: true });
touchFallbackHandled = false;
}, { passive: true });
// No global pointer/touch commits; rely on canvas handlers (as in the working older version).
// ====== Canvas & Drawing ======
function resizeCanvas() {
@ -945,8 +942,8 @@
`pointerDown:${pointerDown}`,
`lastCommit:${lastCommitMode || '-'}`,
`lastAdd:${lastAddStatus || '-'}`,
`dpr:${dpr.toFixed(2)} s:${view.s.toFixed(2)}`,
`down:${evtStats.down} up:${evtStats.up} cancel:${evtStats.cancel} touchEnd:${evtStats.touchEnd} type:${evtStats.lastType}`
`dpr:${dpr.toFixed(2)} s:${view.s.toFixed(2)} canvas:${canvas.width}x${canvas.height}`,
`down:${evtStats.down} up:${evtStats.up} cancel:${evtStats.cancel} touchEnd:${evtStats.touchEnd} add:${evtStats.addBalloon} type:${evtStats.lastType}`
];
if (debugOverlay) debugOverlay.textContent = dbg.join(' | ');
}
@ -1078,7 +1075,7 @@
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
const updateChip = (chipId, labelId, { showLabel = true } = {}) => {
const chip = document.getElementById(chipId);
const label = document.getElementById(labelId);
const label = labelId ? document.getElementById(labelId) : null;
if (!chip || !meta) return;
if (meta.image) {
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
@ -1099,6 +1096,7 @@
};
updateChip('current-color-chip', 'current-color-label', { showLabel: true });
updateChip('current-color-chip-global', 'current-color-label-global', { showLabel: false });
updateChip('mobile-active-color-chip', null, { showLabel: false });
}
window.organic = {
@ -1167,6 +1165,7 @@
opt.textContent = `${name} (${item.count})`;
replaceFromSel.appendChild(opt);
});
updateReplaceChips();
}
}
@ -1178,7 +1177,7 @@
color: meta.hex,
image: meta.image || null,
colorIdx: meta._idx,
id: crypto.randomUUID()
id: makeId()
};
}
@ -1191,6 +1190,7 @@
}
balloons.push(buildBalloon(meta, x, y, currentRadius));
lastAddStatus = 'balloon';
evtStats.addBalloon += 1;
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
refreshAll();
pushHistory();
@ -1418,7 +1418,7 @@
function duplicateSelected() {
const sel = selectionBalloons();
if (!sel.length) return;
const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() }));
const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: makeId() }));
copies.forEach(c => balloons.push(c));
selectedIds = new Set(copies.map(c => c.id));
refreshAll({ autoFit: true });
@ -1495,7 +1495,7 @@
color: meta.hex || b.color,
image: meta.image || null,
colorIdx: idx,
id: crypto.randomUUID()
id: makeId()
};
})
: [];
@ -1646,7 +1646,7 @@
const diam = SIZE_PRESETS[sizeIdx] ?? SIZE_PRESETS[0];
const radius = inchesToRadiusPx(diam);
const meta = FLAT_COLORS[colorIdx] || FLAT_COLORS[0];
return { x, y, radius, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: crypto.randomUUID() };
return { x, y, radius, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: makeId() };
});
}
@ -1668,7 +1668,7 @@
? data.balloons.map(b => {
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
const meta = FLAT_COLORS[idx] || {};
return { x: b.x, y: b.y, radius: b.radius, color: meta.hex, image: meta.image, colorIdx: idx, id: crypto.randomUUID() };
return { x: b.x, y: b.y, radius: b.radius, color: meta.hex, image: meta.image, colorIdx: idx, id: makeId() };
})
: compactToDesign(data);
balloons = loaded.slice(0, MAX_BALLOONS);
@ -1925,6 +1925,7 @@
});
replaceToSel.appendChild(og);
});
updateReplaceChips();
}
function populateGarlandColorSelects() {
@ -1976,6 +1977,78 @@
setSw(garlandSwatchAccent, garlandAccentIdx);
}
const updateReplaceChips = () => {
const fromHex = replaceFromSel?.value;
const toIdx = parseInt(replaceToSel?.value || '-1', 10);
const setChip = (chip, hex, meta = null) => {
if (!chip) return;
if (meta?.image) {
chip.style.backgroundImage = `url("${meta.image}")`;
chip.style.backgroundColor = meta.hex || '#fff';
chip.style.backgroundSize = 'cover';
} else {
chip.style.backgroundImage = 'none';
chip.style.backgroundColor = hex || '#f1f5f9';
}
};
const toMeta = Number.isInteger(toIdx) && toIdx >= 0 ? FLAT_COLORS[toIdx] : null;
setChip(replaceFromChip, fromHex || '#f8fafc', null);
setChip(replaceToChip, toMeta?.hex || '#f8fafc', toMeta);
// count matches
const targetHex = normalizeHex(fromHex || '');
let count = 0;
if (targetHex) {
balloons.forEach(b => { if (normalizeHex(b.color) === targetHex) count++; });
}
if (replaceCountLabel) replaceCountLabel.textContent = count ? `${count} match${count === 1 ? '' : 'es'}` : '0 matches';
return count;
};
const openReplacePicker = (mode = 'from') => {
if (!window.openColorPicker) return;
if (mode === 'from') {
const used = getUsedColors();
const items = used.map(u => ({
label: u.name || NAME_BY_HEX.get(u.hex) || u.hex,
metaText: `${u.count} in design`,
hex: u.hex
}));
window.openColorPicker({
title: 'Replace: From color',
subtitle: 'Pick a color that already exists on canvas',
items,
onSelect: (item) => {
if (!replaceFromSel) return;
replaceFromSel.value = item.hex;
updateReplaceChips();
}
});
} else {
const items = (FLAT_COLORS || []).map((c, idx) => ({
label: c.name || c.hex,
metaText: c.family || '',
idx
}));
window.openColorPicker({
title: 'Replace: To color',
subtitle: 'Choose any color from the library',
items,
onSelect: (item) => {
if (!replaceToSel) return;
replaceToSel.value = String(item.idx);
updateReplaceChips();
}
});
}
};
replaceFromChip?.addEventListener('click', () => openReplacePicker('from'));
replaceToChip?.addEventListener('click', () => openReplacePicker('to'));
replaceFromSel?.addEventListener('change', updateReplaceChips);
replaceToSel?.addEventListener('change', updateReplaceChips);
replaceBtn?.addEventListener('click', () => {
const fromHex = replaceFromSel?.value;
const toIdx = parseInt(replaceToSel?.value || '', 10);
@ -1993,6 +2066,10 @@
});
if (count > 0) {
if (count > 80) {
const ok = window.confirm(`Replace ${count} balloons? This cannot be undone except via Undo.`);
if (!ok) return;
}
pushHistory();
if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`;
if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx;
@ -2001,6 +2078,7 @@
} else {
if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.';
}
updateReplaceChips();
});
// ====== Init ======

111
script.js
View File

@ -8,6 +8,7 @@
// Ensure shared helpers are ready
if (!window.shared) return;
const { clamp, clamp01 } = window.shared;
const { FLAT_COLORS } = window.shared;
// Modal helpers
const messageModal = document.getElementById('message-modal');
@ -31,6 +32,7 @@
const claSection = document.getElementById('tab-classic');
const wallSection = document.getElementById('tab-wall');
const tabBtns = Array.from(document.querySelectorAll('#mode-tabs .tab-btn'));
const mobileActionBar = document.getElementById('mobile-action-bar');
// Export buttons
const exportPngBtn = document.querySelector('[data-export="png"]');
@ -38,6 +40,65 @@
const { XLINK_NS } = window.shared || {};
const MOBILE_TAB_DEFAULT = 'controls';
const MOBILE_TAB_PREFIX = 'designer:mobileTab:';
const exportModal = document.getElementById('export-modal');
const exportModalClose = document.getElementById('export-modal-close');
// Generic color picker modal (used by replace chips)
(function initColorPickerModal() {
const modal = document.getElementById('color-picker-modal');
const grid = document.getElementById('color-picker-grid');
const titleEl = document.getElementById('color-picker-title');
const subtitleEl = document.getElementById('color-picker-subtitle');
const closeBtn = document.getElementById('color-picker-close');
if (!modal || !grid || !titleEl || !subtitleEl) return;
const close = () => modal.classList.add('hidden');
closeBtn?.addEventListener('click', close);
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
const setChipStyle = (el, meta) => {
if (!el || !meta) return;
if (meta.image) {
el.style.backgroundImage = `url("${meta.image}")`;
el.style.backgroundColor = meta.hex || '#fff';
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
} else {
el.style.backgroundImage = 'none';
el.style.backgroundColor = meta.hex || '#fff';
}
};
window.openColorPicker = ({ title = 'Choose a color', subtitle = '', items = [], onSelect }) => {
titleEl.textContent = title;
subtitleEl.textContent = subtitle;
grid.innerHTML = '';
items.forEach(item => {
const meta = item.meta || (Number.isInteger(item.idx) ? FLAT_COLORS?.[item.idx] : null) || {};
const sw = document.createElement('button');
sw.type = 'button';
sw.className = 'color-option';
sw.setAttribute('aria-label', item.label || meta.name || meta.hex || 'Color');
const swatch = document.createElement('div');
swatch.className = 'swatch';
setChipStyle(swatch, meta.hex ? meta : { hex: item.hex });
const label = document.createElement('div');
label.className = 'label';
label.textContent = item.label || meta.name || meta.hex || 'Color';
const metaLine = document.createElement('div');
metaLine.className = 'meta';
metaLine.textContent = item.metaText || meta.hex || '';
sw.appendChild(swatch);
sw.appendChild(label);
sw.appendChild(metaLine);
sw.addEventListener('click', () => {
close();
onSelect?.(item);
});
grid.appendChild(sw);
});
modal.classList.remove('hidden');
};
})();
function getImageHref(el) {
return el.getAttribute('href') || (XLINK_NS ? el.getAttributeNS(XLINK_NS, 'href') : null);
@ -295,12 +356,23 @@
}
}
const openExportModal = () => { exportModal?.classList.remove('hidden'); };
const closeExportModal = () => { exportModal?.classList.add('hidden'); };
exportModalClose?.addEventListener('click', closeExportModal);
exportModal?.addEventListener('click', (e) => { if (e.target === exportModal) closeExportModal(); });
document.body.addEventListener('click', e => {
const choice = e.target.closest('[data-export-choice]');
if (choice) {
const type = choice.dataset.exportChoice;
closeExportModal();
if (type === 'png') exportPng();
else if (type === 'svg') exportSvg();
return;
}
const btn = e.target.closest('[data-export]');
if (!btn) return;
const type = btn.dataset.export;
if (type === 'png') exportPng();
else if (type === 'svg') exportSvg();
e.preventDefault();
openExportModal();
});
// Tab logic
@ -451,6 +523,31 @@
// Tab switching
if (orgSection && claSection && tabBtns.length > 0) {
let current = '#tab-organic';
const isMobileView = () => window.matchMedia('(max-width: 1023px)').matches;
const updateMobileActionBarVisibility = () => {
if (!mobileActionBar) return;
const shouldShow = current === '#tab-organic' && isMobileView();
mobileActionBar.classList.toggle('hidden', !shouldShow);
};
const wireMobileActionButtons = () => {
const guardOrganic = () => current === '#tab-organic';
const clickBtn = (sel) => { if (!guardOrganic()) return; document.querySelector(sel)?.click(); };
const on = (id, fn) => document.getElementById(id)?.addEventListener('click', fn);
on('mobile-act-undo', () => clickBtn('#tool-undo'));
on('mobile-act-redo', () => clickBtn('#tool-redo'));
on('mobile-act-eyedrop', () => clickBtn('#tool-eyedropper'));
on('mobile-act-erase', () => {
if (!guardOrganic()) return;
const erase = document.getElementById('tool-erase');
const draw = document.getElementById('tool-draw');
const active = erase?.getAttribute('aria-pressed') === 'true';
(active ? draw : erase)?.click();
});
on('mobile-act-clear', () => clickBtn('#clear-canvas-btn-top'));
on('mobile-act-export', () => clickBtn('[data-export="png"]'));
};
wireMobileActionButtons();
window.addEventListener('resize', updateMobileActionBarVisibility);
function setTab(id, isInitial = false) {
if (!id || !document.querySelector(id)) id = '#tab-organic';
current = id;
@ -475,7 +572,11 @@
if (document.body) delete document.body.dataset.controlsHidden;
const isOrganic = id === '#tab-organic';
const showHeaderColor = id !== '#tab-classic';
document.getElementById('clear-canvas-btn-top')?.classList.toggle('hidden', !isOrganic);
const clearTop = document.getElementById('clear-canvas-btn-top');
if (clearTop) {
clearTop.classList.toggle('hidden', !isOrganic);
clearTop.style.display = isOrganic ? '' : 'none';
}
const headerActiveSwatch = document.getElementById('current-color-chip-global')?.closest('.flex');
headerActiveSwatch?.classList.toggle('hidden', !showHeaderColor);
const savedMobile = (() => {
@ -488,6 +589,7 @@
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
window.updateExportButtonVisibility();
updateMobileActionBarVisibility();
}
tabBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
@ -509,6 +611,7 @@
setMobileTab(document.body.dataset.mobileTab, current, true);
updateSheets();
updateMobileStacks(document.body.dataset.mobileTab);
updateMobileActionBarVisibility();
}
// Mobile tabbar

268
style.css
View File

@ -1,5 +1,33 @@
/* Minimal extras (Tailwind handles most styling) */
body { color: #1f2937; }
body[data-active-tab="#tab-classic"] #clear-canvas-btn-top,
body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
display: none !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
#app-header {
position: sticky;
top: 0;
z-index: 35;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: .75rem 0.5rem;
border-radius: 1.25rem;
box-shadow: 0 8px 24px rgba(15,23,42,0.08);
}
#balloon-canvas { touch-action: none; }
@ -12,6 +40,31 @@ body { color: #1f2937; }
}
/* Buttons */
.btn-dark,
.btn-blue,
.btn-green,
.btn-yellow,
.btn-danger,
.btn-indigo {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .35rem;
font-weight: 700;
letter-spacing: -0.01em;
border: 0;
font-size: 0.95rem;
}
.btn-dark:focus-visible,
.btn-blue:focus-visible,
.btn-green:focus-visible,
.btn-yellow:focus-visible,
.btn-danger:focus-visible,
.btn-indigo:focus-visible,
.tool-btn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
.tool-btn {
display: flex;
align-items: center;
@ -67,12 +120,14 @@ body { color: #1f2937; }
flex-direction: column;
gap: .5rem;
padding: .5rem;
background: rgba(255,255,255,0.6); /* More transparent */
border: 1px solid #e5e7eb;
border-radius: .75rem;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(226,232,240,0.9);
border-radius: .9rem;
max-height: 260px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
touch-action: pan-y;
}
.swatch {
@ -189,6 +244,34 @@ body { color: #1f2937; }
}
.slot-swatch.active::after { display: none; }
.replace-row {
padding: 0.35rem;
background: rgba(248,250,252,0.9);
border: 1px solid rgba(226,232,240,0.9);
border-radius: 12px;
}
.replace-chip {
width: 40px;
height: 40px;
border-radius: 12px;
border: 2px solid rgba(51,65,85,0.18);
box-shadow: 0 3px 8px rgba(0,0,0,0.06);
background-size: cover;
background-position: center;
background-color: #fff;
cursor: pointer;
}
.wall-toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.wall-toolbar .tool-btn {
width: 100%;
justify-content: center;
}
.topper-type-group {
display: flex;
gap: .5rem;
@ -274,13 +357,13 @@ body { color: #1f2937; }
letter-spacing: -0.02em;
}
.panel-card {
background: rgba(255,255,255,0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.6);
background: rgba(255,255,255,0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(226,232,240,0.9);
border-radius: 1rem;
padding: 1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
box-shadow: 0 12px 30px rgba(15,23,42,0.06);
}
.control-stack {
display: flex;
@ -340,6 +423,14 @@ body { color: #1f2937; }
@media (max-width: 1023px) {
body { padding-bottom: 0; overflow: auto; }
html, body { height: auto; overflow: auto; }
#current-color-chip-global { display: none; }
#clear-canvas-btn-top { display: none !important; }
/* Add breathing room under canvases so sheets/tabbar dont cover content */
#classic-display,
#wall-display,
#balloon-canvas {
margin-bottom: 5rem;
}
/* Stack switching: show only the active mobile tab stack across panels */
.control-sheet .control-stack { display: none; }
@ -354,6 +445,166 @@ body { color: #1f2937; }
body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"] {
display: block;
}
.control-sheet { bottom: 4.5rem; max-height: 55vh; }
.control-sheet.minimized { transform: translateY(95%); }
/* Larger tap targets and spacing */
.tool-btn,
.btn-dark,
.btn-blue,
.btn-green,
.btn-yellow,
.btn-danger,
.btn-indigo {
min-height: 44px;
padding: 0.75rem 0.85rem;
font-size: 1rem;
}
.swatch { width: 2.4rem; height: 2.4rem; }
.swatch.tiny { width: 1.8rem; height: 1.8rem; }
.select { min-height: 44px; }
.panel-card { padding: 0.85rem; }
}
.mobile-action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 4.75rem;
padding: 0.35rem 0.75rem 0.7rem;
background: linear-gradient(180deg, rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.96) 100%);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-top: 1px solid rgba(226,232,240,0.9);
box-shadow: 0 -10px 30px rgba(15,23,42,0.08);
display: flex;
align-items: center;
gap: 0.5rem;
z-index: 45;
}
.color-modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
}
.color-modal.hidden { display: none; }
.color-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(15,23,42,0.35);
backdrop-filter: blur(4px);
}
.color-modal-card {
position: relative;
width: min(640px, 92vw);
max-height: 80vh;
background: #fff;
border-radius: 1.25rem;
padding: 1.1rem 1.1rem 1.25rem;
box-shadow: 0 24px 60px rgba(15,23,42,0.2);
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 1;
}
.color-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.color-modal-title { font-size: 1.1rem; font-weight: 800; color: #0f172a; letter-spacing: -0.01em; }
.color-modal-subtitle { font-size: 0.9rem; color: #475569; }
.color-modal-close {
background: #e2e8f0;
border: none;
width: 36px;
height: 36px;
border-radius: 12px;
font-size: 1.4rem;
color: #0f172a;
}
.color-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 0.75rem;
overflow-y: auto;
padding: 0.25rem;
}
.color-option {
border: 1px solid rgba(226,232,240,0.9);
border-radius: 12px;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: center;
justify-content: center;
cursor: pointer;
background: #fff;
box-shadow: 0 4px 12px rgba(15,23,42,0.05);
transition: transform 0.1s ease;
}
.color-option:hover { transform: translateY(-1px); }
.color-option .swatch {
width: 2.4rem;
height: 2.4rem;
border-width: 2px;
}
.color-option .label {
font-size: 0.78rem;
font-weight: 700;
color: #0f172a;
text-align: center;
line-height: 1.1;
}
.color-option .meta {
font-size: 0.72rem;
color: #475569;
text-align: center;
}
.mobile-action-bar.hidden { display: none; }
.mobile-action-chip {
width: 44px;
height: 44px;
border-radius: 14px;
border: 2px solid rgba(51,65,85,0.18);
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
background-size: cover;
background-position: center;
background-color: #fff;
}
.mobile-action-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.35rem;
width: 100%;
}
.mobile-action-btn {
background: rgba(255,255,255,0.92);
border: 1px solid rgba(226,232,240,0.9);
border-radius: 14px;
padding: 0.55rem 0.35rem;
font-size: 0.9rem;
font-weight: 700;
color: #0f172a;
box-shadow: 0 4px 14px rgba(15,23,42,0.08);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
}
.mobile-action-btn i { font-size: 1rem; }
.mobile-action-btn.danger { color: #dc2626; border-color: rgba(248,113,113,0.35); }
.mobile-action-btn:active { transform: translateY(1px); }
.mobile-action-btn.active {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,0.18), 0 6px 16px rgba(37,99,235,0.2);
}
.mobile-tabbar {
@ -420,6 +671,7 @@ body { color: #1f2937; }
border: 1px solid rgba(255,255,255,0.4);
}
body { padding-bottom: 0; overflow: auto; }
.mobile-action-bar { display: none !important; }
}
/* Compact viewport fallback */

247
wall.js
View File

@ -18,6 +18,7 @@
let wallState = null;
let selectedColorIdx = 0; // This should be synced with organic's selectedColorIdx
let wallToolMode = 'paint';
// DOM elements
const wallDisplay = document.getElementById('wall-display');
@ -36,6 +37,13 @@
const wallReplaceToSel = document.getElementById('wall-replace-to');
const wallReplaceBtn = document.getElementById('wall-replace-btn');
const wallReplaceMsg = document.getElementById('wall-replace-msg');
const wallReplaceFromChip = document.getElementById('wall-replace-from-chip');
const wallReplaceToChip = document.getElementById('wall-replace-to-chip');
const wallReplaceCount = document.getElementById('wall-replace-count');
const wallToolPaintBtn = document.getElementById('wall-tool-paint');
const wallToolEraseBtn = document.getElementById('wall-tool-erase');
let wallReplaceFromIdx = null;
let wallReplaceToIdx = null;
const wallSpacingLabel = document.getElementById('wall-spacing-label');
const wallSizeLabel = document.getElementById('wall-size-label');
const wallPaintLinksBtn = document.getElementById('wall-paint-links');
@ -60,12 +68,14 @@
});
};
const cloneColors = (colors = []) => colors.map(row => Array.isArray(row) ? [...row] : []);
function saveActivePatternState() {
ensurePatternStore();
const key = patternKey();
wallState.patternStore[key] = {
colors: wallState.colors,
customColors: wallState.customColors,
colors: cloneColors(wallState.colors),
customColors: { ...(wallState.customColors || {}) },
showWireframes: wallState.showWireframes,
outline: wallState.outline
};
@ -74,12 +84,8 @@
function loadPatternState(key) {
ensurePatternStore();
const st = wallState.patternStore[key] || {};
if (Array.isArray(st.colors) && st.colors.length) {
wallState.colors = st.colors;
}
if (st.customColors && typeof st.customColors === 'object' && Object.keys(st.customColors).length) {
wallState.customColors = st.customColors;
}
wallState.colors = Array.isArray(st.colors) ? cloneColors(st.colors) : [];
wallState.customColors = (st.customColors && typeof st.customColors === 'object') ? { ...st.customColors } : {};
if (typeof st.showWireframes === 'boolean') wallState.showWireframes = st.showWireframes;
if (typeof st.outline === 'boolean') wallState.outline = st.outline;
}
@ -116,10 +122,10 @@
base.pattern = saved.pattern === 'x' ? 'x' : 'grid';
base.fillGaps = false;
base.showWireframes = saved.showWireframes !== false;
base.patternStore = saved.patternStore && typeof saved.patternStore === 'object' ? saved.patternStore : {};
base.patternStore = (saved.patternStore && typeof saved.patternStore === 'object') ? saved.patternStore : {};
base.customColors = (saved.customColors && typeof saved.customColors === 'object') ? saved.customColors : {};
if (Number.isInteger(saved.activeColorIdx)) base.activeColorIdx = saved.activeColorIdx;
if (Array.isArray(saved.colors)) base.colors = saved.colors;
if (Array.isArray(saved.colors)) base.colors = cloneColors(saved.colors);
if (typeof saved.outline === 'boolean') base.outline = saved.outline;
}
} catch {}
@ -427,12 +433,14 @@
? override.idx
: (override.mode === 'empty' ? null : (showGaps ? autoGapColorIdx() : null));
const isEmpty = gapIdx === null;
const invisible = isEmpty && !showWireframes;
const hitFill = 'rgba(0,0,0,0.001)';
// Hide wireframes for 11" gap balloons; keep them clickable with a hit target.
const invisible = isEmpty;
const meta = wallColorMeta(gapIdx);
const patId = ensurePattern(meta);
const fill = invisible ? 'rgba(0,0,0,0.001)' : (isEmpty ? 'none' : (patId ? `url(#${patId})` : meta.hex));
const stroke = invisible ? 'none' : (isEmpty ? '#cbd5e1' : (showOutline ? '#111827' : 'none'));
const strokeW = invisible ? 0 : (isEmpty ? 1.4 : (showOutline ? 0.6 : 0));
const fill = invisible ? hitFill : (patId ? `url(#${patId})` : meta.hex);
const stroke = 'none';
const strokeW = 0;
const filter = invisible || isEmpty ? '' : `filter="url(#${bigShadow})"`;
const rGap = bigR * 0.82; // slightly smaller 11" gap balloon
const shineGap = isEmpty ? '' : shineNodeRelative(rGap, rGap, meta.hex);
@ -596,6 +604,9 @@
function wallUsedColors() {
ensureFlatColors();
if (!wallState) wallState = loadWallState();
ensureWallGridSize(wallState.rows, wallState.cols);
const map = new Map();
const addIdx = (idx) => {
if (!Number.isInteger(idx) || idx < 0 || !FLAT_COLORS[idx]) return;
@ -616,6 +627,8 @@
wallUsedPaletteEl.innerHTML = '';
if (!used.length) {
wallUsedPaletteEl.innerHTML = '<div class="text-xs text-gray-500">No colors yet.</div>';
populateWallReplaceSelects();
updateWallReplacePreview();
return;
}
const row = document.createElement('div');
@ -640,23 +653,150 @@
row.appendChild(sw);
});
wallUsedPaletteEl.appendChild(row);
populateWallReplaceSelects();
updateWallReplacePreview();
}
function populateWallReplaceSelects() {
const sels = [wallReplaceFromSel, wallReplaceToSel];
sels.forEach(sel => {
if (!sel) return;
sel.innerHTML = '';
FLAT_COLORS.forEach((c, idx) => {
ensureFlatColors();
// "From" = only colors currently used on the wall
if (wallReplaceFromSel) {
wallReplaceFromSel.innerHTML = '';
const used = wallUsedColors();
used.forEach(u => {
const opt = document.createElement('option');
opt.value = String(idx);
opt.textContent = c.name || c.hex;
opt.style.backgroundColor = c.hex;
sel.appendChild(opt);
opt.value = String(u.idx);
opt.textContent = `${u.name || u.hex} (${u.count})`;
wallReplaceFromSel.appendChild(opt);
});
});
if (used.length) {
const val = wallReplaceFromIdx ?? used[0].idx;
wallReplaceFromSel.value = String(val);
wallReplaceFromIdx = val;
}
}
// "To" = colors from the wall palette
if (wallReplaceToSel) {
wallReplaceToSel.innerHTML = '';
(window.PALETTE || []).forEach(group => {
(group.colors || []).forEach(c => {
const idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family);
if (idx < 0) return;
const opt = document.createElement('option');
opt.value = String(idx);
opt.textContent = c.name || c.hex;
wallReplaceToSel.appendChild(opt);
});
});
if (wallReplaceToSel.options.length) {
const val = wallReplaceToIdx ?? parseInt(wallReplaceToSel.options[0].value, 10);
wallReplaceToSel.value = String(val);
wallReplaceToIdx = val;
}
}
}
const wallSetChip = (chip, meta) => {
if (!chip) return;
if (meta?.image) {
chip.style.backgroundImage = `url("${meta.image}")`;
chip.style.backgroundColor = meta.hex || WALL_FALLBACK_COLOR;
chip.style.backgroundSize = `${100 * 2.5}%`;
chip.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
} else {
chip.style.backgroundImage = 'none';
chip.style.backgroundColor = meta?.hex || WALL_FALLBACK_COLOR;
}
};
const wallCountMatches = (idx) => {
if (!Number.isInteger(idx) || idx < 0) return 0;
ensureWallGridSize(wallState.rows, wallState.cols);
let count = 0;
wallState.colors.forEach(row => row.forEach(v => { if (v === idx) count++; }));
Object.values(wallState.customColors || {}).forEach(v => { if (Number.isInteger(v) && v === idx) count++; });
return count;
};
const updateWallReplacePreview = () => {
let fromIdx = Number.isInteger(wallReplaceFromIdx) ? wallReplaceFromIdx : parseInt(wallReplaceFromSel?.value || '-1', 10);
let toIdx = Number.isInteger(wallReplaceToIdx) ? wallReplaceToIdx : parseInt(wallReplaceToSel?.value || '-1', 10);
if ((!Number.isInteger(fromIdx) || fromIdx < 0) && wallReplaceFromSel?.options?.length) {
fromIdx = parseInt(wallReplaceFromSel.options[0].value, 10);
wallReplaceFromSel.value = String(fromIdx);
wallReplaceFromIdx = fromIdx;
}
if ((!Number.isInteger(toIdx) || toIdx < 0) && wallReplaceToSel?.options?.length) {
toIdx = parseInt(wallReplaceToSel.options[0].value, 10);
wallReplaceToSel.value = String(toIdx);
wallReplaceToIdx = toIdx;
}
wallSetChip(wallReplaceFromChip, wallColorMeta(fromIdx));
wallSetChip(wallReplaceToChip, wallColorMeta(toIdx));
const cnt = wallCountMatches(fromIdx);
if (wallReplaceCount) wallReplaceCount.textContent = cnt ? `${cnt} match${cnt === 1 ? '' : 'es'}` : '0 matches';
return cnt;
};
const openWallReplacePicker = (mode = 'from') => {
const picker = window.openColorPicker;
if (!picker) return;
populateWallReplaceSelects();
if (mode === 'from') {
const used = wallUsedColors();
const items = used.map(u => ({
label: u.name || u.hex,
metaText: `${u.count} in wall`,
idx: u.idx
}));
if (!items.length) {
if (wallReplaceMsg) wallReplaceMsg.textContent = 'No colors on the wall yet.';
return;
}
picker({
title: 'Replace: From color',
subtitle: 'Pick a color currently used in the wall',
items,
onSelect: (item) => {
if (!wallReplaceFromSel) return;
wallReplaceFromSel.value = String(item.idx);
wallReplaceFromIdx = item.idx;
updateWallReplacePreview();
}
});
} else {
ensureFlatColors();
const items = [];
(window.PALETTE || []).forEach(group => {
(group.colors || []).forEach(c => {
const idx = FLAT_COLORS.findIndex(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family);
if (idx >= 0) {
items.push({
label: c.name || c.hex,
metaText: group.family || '',
idx
});
}
});
});
if (!items.length) {
if (wallReplaceMsg) wallReplaceMsg.textContent = 'No palette colors available.';
return;
}
picker({
title: 'Replace: To color',
subtitle: 'Choose any color from the wall palette',
items,
onSelect: (item) => {
if (!wallReplaceToSel) return;
wallReplaceToSel.value = String(item.idx);
wallReplaceToIdx = item.idx;
updateWallReplacePreview();
}
});
}
};
// Pick a visible default (first reasonably saturated entry).
function defaultActiveColorIdx() {
if (!Array.isArray(FLAT_COLORS) || !FLAT_COLORS.length) return 0;
@ -755,6 +895,19 @@
wallActiveLabel.textContent = meta.name || meta.hex || '';
}
function setWallToolMode(mode) {
wallToolMode = mode === 'erase' ? 'erase' : 'paint';
if (wallToolPaintBtn && wallToolEraseBtn) {
const isErase = wallToolMode === 'erase';
wallToolPaintBtn.setAttribute('aria-pressed', String(!isErase));
wallToolEraseBtn.setAttribute('aria-pressed', String(isErase));
wallToolPaintBtn.classList.toggle('tab-active', !isErase);
wallToolEraseBtn.classList.toggle('tab-active', isErase);
wallToolPaintBtn.classList.toggle('tab-idle', isErase);
wallToolEraseBtn.classList.toggle('tab-idle', !isErase);
}
}
// Paint a specific group of nodes with the active color.
function paintWallGroup(group) {
ensureWallGridSize(wallState.rows, wallState.cols);
@ -838,6 +991,7 @@
// Force a reflow to ensure the browser repaints the new SVG.
void wallDisplay.offsetWidth;
renderWallUsedPalette();
updateWallReplacePreview();
console.info('[Wall] render done');
} catch (err) {
console.error('[Wall] render failed', err?.stack || err);
@ -892,6 +1046,7 @@
});
renderWallUsedPalette();
updateWallActiveChip(getActiveWallColorIdx());
updateWallReplacePreview();
}
function syncWallInputs() {
@ -913,10 +1068,12 @@
if (!wallDisplay) return;
wallState = loadWallState();
ensurePatternStore();
loadPatternState(patternKey());
if (Number.isInteger(wallState.activeColorIdx)) selectedColorIdx = normalizeColorIdx(wallState.activeColorIdx);
else if (window.organic?.getColor) selectedColorIdx = normalizeColorIdx(window.organic.getColor());
else selectedColorIdx = defaultActiveColorIdx();
setActiveColor(selectedColorIdx);
setWallToolMode('paint');
loadPatternState(patternKey());
ensureWallGridSize(wallState.rows, wallState.cols);
syncWallInputs();
@ -947,6 +1104,8 @@
saveWallState();
renderWall();
syncWallInputs();
renderWallUsedPalette();
updateWallReplacePreview();
});
wallShowWireCb?.addEventListener('change', () => {
wallState.showWireframes = !!wallShowWireCb.checked;
@ -963,6 +1122,12 @@
wallPaintLinksBtn?.addEventListener('click', () => paintWallGroup('links'));
wallPaintSmallBtn?.addEventListener('click', () => paintWallGroup('small'));
wallPaintGapsBtn?.addEventListener('click', () => paintWallGroup('gaps'));
wallReplaceFromSel?.addEventListener('change', updateWallReplacePreview);
wallReplaceToSel?.addEventListener('change', updateWallReplacePreview);
wallReplaceFromChip?.addEventListener('click', () => openWallReplacePicker('from'));
wallReplaceToChip?.addEventListener('click', () => openWallReplacePicker('to'));
wallToolPaintBtn?.addEventListener('click', () => setWallToolMode('paint'));
wallToolEraseBtn?.addEventListener('click', () => setWallToolMode('erase'));
const findWallNode = (el) => {
let cur = el;
@ -1003,8 +1168,8 @@
return;
}
// Only erase when a modifier is held (shift/ctrl/cmd). A plain click always paints.
const isEraseClick = e.shiftKey || e.metaKey || e.ctrlKey;
// Paint/erase based on tool mode; modifiers still erase.
const isEraseClick = wallToolMode === 'erase' || e.shiftKey || e.metaKey || e.ctrlKey;
wallState.customColors[key] = isEraseClick ? -1 : activeColor;
saveActivePatternState();
@ -1089,20 +1254,40 @@
wallReplaceBtn?.addEventListener('click', () => {
if (!wallReplaceFromSel || !wallReplaceToSel) return;
const fromIdx = parseInt(wallReplaceFromSel.value, 10);
const toIdx = parseInt(wallReplaceToSel.value, 10);
const fromIdx = normalizeColorIdx(Number.isInteger(wallReplaceFromIdx) ? wallReplaceFromIdx : parseInt(wallReplaceFromSel.value, 10));
const toIdx = normalizeColorIdx(Number.isInteger(wallReplaceToIdx) ? wallReplaceToIdx : parseInt(wallReplaceToSel.value, 10));
if (!Number.isInteger(fromIdx) || !Number.isInteger(toIdx) || fromIdx === toIdx) {
if (wallReplaceMsg) wallReplaceMsg.textContent = 'Choose two different colors.';
return;
}
const matches = updateWallReplacePreview();
if (!matches) {
if (wallReplaceMsg) wallReplaceMsg.textContent = 'No matches to replace.';
return;
}
if (matches > 120) {
const ok = window.confirm(`Replace ${matches} balloons? This cannot be undone except via undo/reload.`);
if (!ok) return;
}
ensureWallGridSize(wallState.rows, wallState.cols);
wallState.colors = wallState.colors.map(row => row.map(v => (v === fromIdx ? toIdx : v)));
let replaced = 0;
wallState.colors = wallState.colors.map(row => row.map(v => {
const val = Number.isInteger(v) ? v : parseInt(v, 10);
if (val === fromIdx) { replaced++; return toIdx; }
return v;
}));
Object.keys(wallState.customColors || {}).forEach(k => {
if (wallState.customColors[k] === fromIdx) wallState.customColors[k] = toIdx;
const raw = wallState.customColors[k];
const val = Number.isInteger(raw) ? raw : parseInt(raw, 10);
if (val === fromIdx) { wallState.customColors[k] = toIdx; replaced++; }
});
// Keep pattern store in sync for this pattern
saveActivePatternState();
saveWallState();
renderWall();
if (wallReplaceMsg) wallReplaceMsg.textContent = 'Replaced.';
renderWallUsedPalette();
updateWallReplacePreview();
if (wallReplaceMsg) wallReplaceMsg.textContent = replaced ? `Replaced ${replaced} item${replaced === 1 ? '' : 's'}.` : 'Nothing to replace.';
});
}