diff --git a/frontend/textbooks/algebra_8_ch2.html b/frontend/textbooks/algebra_8_ch2.html index 0fbf1f0..751d6dc 100644 --- a/frontend/textbooks/algebra_8_ch2.html +++ b/frontend/textbooks/algebra_8_ch2.html @@ -266,6 +266,23 @@ input,select,textarea{font-family:inherit} .eq-show{font-family:'JetBrains Mono',monospace} .pipe-tabs .btn.active{background:var(--sec-acc,var(--pri));color:#fff;border-color:var(--sec-acc,var(--pri))} +/* DRAG & DROP — sortable chips */ +.dnd-pool{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px;padding:10px;border:1.5px dashed var(--border);border-radius:10px;min-height:54px;transition:border-color .18s,background .18s} +.dnd-pool.over{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));border-style:solid} +.dnd-pool.col{flex-direction:column;align-items:stretch} +.dnd-pool.col .dnd-chip{width:auto} +.dnd-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--card);border:1.5px solid var(--border);border-radius:10px;cursor:grab;user-select:none;font-size:.92rem;line-height:1.4;transition:transform .12s,box-shadow .12s,border-color .12s;touch-action:none;max-width:100%} +.dnd-chip:hover{transform:translateY(-1px);border-color:var(--sec-acc,var(--pri));box-shadow:var(--sh)} +.dnd-chip:active{cursor:grabbing} +.dnd-chip.armed{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));box-shadow:0 0 0 3px rgba(244,63,94,.22);transform:translateY(-1px)} +.dnd-chip.dragging{opacity:.28} +.dnd-chip.placed{background:var(--sec-acc-soft,var(--pri-soft));border-color:var(--sec-acc,var(--pri))} +.dnd-chip .dnd-x{padding:0 5px;color:var(--muted);font-weight:700;font-size:1.05rem;border-radius:4px;cursor:pointer} +.dnd-chip .dnd-x:hover{color:var(--bad,var(--fail));background:var(--fail-bg)} +.drop-box.over{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));border-style:solid;transform:scale(1.015)} +.dnd-hint{font-size:.78rem;color:var(--muted);margin-bottom:8px;display:flex;align-items:center;gap:6px} +.dnd-hint svg{width:14px;height:14px;flex-shrink:0} + /* SIDEBAR DRAWER for narrow viewports */ .col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none;animation:fadeIn .18s ease} .col-side-backdrop.show{display:block} @@ -712,6 +729,137 @@ function confetti(){ frame(); } +/* ============================================================ + DRAG & DROP SORTER — shared helper + ============================================================ + - desktop: drag chip → drop on .drop-box (or back on .dnd-pool) + - mobile/tap: tap chip → armed; tap a box → placed; tap × → remove + - re-rendering keeps it simple; setupSorter returns { placed, render } */ +function setupSorter(cfg){ + // cfg: { poolId, cats:[...], items:[{id,html,cat}], scopeSelector, columnLayout?:bool } + const placed = {}; + const pool = document.getElementById(cfg.poolId); + const scope = document.querySelector(cfg.scopeSelector); + if(!pool || !scope) return { placed, render: ()=>{} }; + pool.classList.add('dnd-pool'); + if(cfg.columnLayout) pool.classList.add('col'); + let armed = null; + + function buildChip(it, isPlaced){ + const el = document.createElement('div'); + el.className = 'dnd-chip' + (isPlaced ? ' placed' : ''); + el.dataset.id = it.id; + el.innerHTML = '' + it.html + '×'; + attachHandlers(el, it.id); + return el; + } + + function attachHandlers(el, itId){ + let ghost = null, dragging = false, startX = 0, startY = 0, captured = false; + el.addEventListener('pointerdown', ev => { + if(ev.button !== undefined && ev.button !== 0) return; + if(ev.target.classList && ev.target.classList.contains('dnd-x')){ + ev.stopPropagation(); + if(placed[itId]){ delete placed[itId]; render(); } + else if(armed === itId){ armed = null; render(); } + return; + } + startX = ev.clientX; startY = ev.clientY; + const rect = el.getBoundingClientRect(); + const ox = ev.clientX - rect.left, oy = ev.clientY - rect.top; + try { el.setPointerCapture(ev.pointerId); captured = true; } catch(e){} + function onMove(e){ + const dx = e.clientX - startX, dy = e.clientY - startY; + if(!dragging && Math.hypot(dx, dy) > 8){ + dragging = true; + ghost = el.cloneNode(true); + ghost.classList.remove('armed'); + ghost.style.cssText = 'position:fixed;z-index:9999;pointer-events:none;opacity:.9;transform:rotate(-2.5deg);box-shadow:0 14px 36px rgba(0,0,0,.32);width:' + rect.width + 'px;left:' + (e.clientX - ox) + 'px;top:' + (e.clientY - oy) + 'px'; + document.body.appendChild(ghost); + el.classList.add('dragging'); + } + if(dragging && ghost){ + ghost.style.left = (e.clientX - ox) + 'px'; + ghost.style.top = (e.clientY - oy) + 'px'; + const under = document.elementsFromPoint(e.clientX, e.clientY); + scope.querySelectorAll('.drop-box.over, .dnd-pool.over').forEach(n => n.classList.remove('over')); + const tgt = under.find(n => n.classList && (n.classList.contains('drop-box') || n.classList.contains('dnd-pool'))); + if(tgt) tgt.classList.add('over'); + } + } + function onUp(e){ + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointercancel', onUp); + if(captured){ try { el.releasePointerCapture(ev.pointerId); } catch(_){} } + el.classList.remove('dragging'); + if(ghost){ ghost.remove(); ghost = null; } + scope.querySelectorAll('.drop-box.over, .dnd-pool.over').forEach(n => n.classList.remove('over')); + if(dragging){ + const under = document.elementsFromPoint(e.clientX, e.clientY); + const box = under.find(n => n.classList && n.classList.contains('drop-box')); + const pl = under.find(n => n.classList && n.classList.contains('dnd-pool')); + if(box){ + const di = box.querySelector('[data-cat]'); + if(di){ placed[itId] = di.dataset.cat; armed = null; render(); return; } + } else if(pl){ delete placed[itId]; armed = null; render(); return; } + // fell outside — revert + } else { + // tap fallback + if(placed[itId]){ delete placed[itId]; armed = null; render(); } + else { armed = (armed === itId) ? null : itId; render(); } + } + dragging = false; + } + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointercancel', onUp); + }); + } + + function attachBoxTaps(){ + scope.querySelectorAll('.drop-box').forEach(box => { + box.addEventListener('click', ev => { + if(!armed) return; + if(ev.target.closest('.dnd-chip')) return; + const di = box.querySelector('[data-cat]'); + if(di){ placed[armed] = di.dataset.cat; armed = null; render(); } + }); + }); + pool.addEventListener('click', ev => { + if(!armed) return; + if(ev.target.closest('.dnd-chip')) return; + // empty pool click also de-arms + armed = null; render(); + }); + } + + function render(){ + pool.innerHTML = ''; + cfg.items.forEach(it => { + if(placed[it.id]) return; + const chip = buildChip(it, false); + if(armed === it.id) chip.classList.add('armed'); + pool.appendChild(chip); + }); + cfg.cats.forEach(cat => { + const box = scope.querySelector('.drop-items[data-cat="' + cat + '"]'); + if(!box) return; + box.innerHTML = ''; + cfg.items.forEach(it => { + if(placed[it.id] !== cat) return; + box.appendChild(buildChip(it, true)); + }); + }); + if(window.renderMathInElement) try { renderMath(scope); } catch(_){} + } + + attachBoxTaps(); + render(); + return { placed, render, reset(){ for(const k in placed) delete placed[k]; armed = null; render(); } }; +} +const DND_HINT_HTML = '