exploded-classic #1
142
index.html
142
index.html
@ -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">×</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">×</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>
|
||||
|
||||
458
organic.js
458
organic.js
@ -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') {
|
||||
@ -629,7 +579,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (mode === 'erase') {
|
||||
if (pointerDown) {
|
||||
eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged;
|
||||
@ -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
111
script.js
@ -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
268
style.css
@ -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 don’t 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
247
wall.js
@ -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.';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user