/** * Component-editor for sonic-conduit. * * Modes: * - Matrix: 4 × 4 grid (type × state). Click → Single. * - Single: prop dropdowns + dx/dy/scale sliders + 10 draggable anchors * on top of a checker-pattern stage. * * Tuning model: * - dx/dy/scale edits go into Comp.variants[currentKey] directly (live). * - Anchor drags go to Comp.variants[currentKey].anchors[name] by default, * OR Comp.anchorsDefault[name] when "Edit defaults" is checked. * - Reload = lose changes. Use "Copy snapshot" to grab the JS source * ready to paste between @editor:* markers in conduit.mjs. */ await customElements.whenDefined('sonic-conduit'); const Comp = customElements.get('sonic-conduit'); const { presetProps } = Comp; const ANCHOR_NAMES = Object.keys(Comp.anchorsDefault); const A4_W_MM = 210; const A4_H_MM = 297; const STORAGE_KEY = 'funpen.editor.sonic-conduit.draft'; const pendingGuides = []; // Restore any localStorage draft before any UI is built if (loadDraft()) { document.getElementById('draft-banner').hidden = false; } document.getElementById('btn-discard-draft').addEventListener('click', () => { localStorage.removeItem(STORAGE_KEY); location.reload(); }); function snapshotData() { return { origin: { ...Comp.origin }, anchorsDefault: structuredClone(Comp.anchorsDefault), variants: structuredClone(Comp.variants), guides: guidesSerialize(), }; } function saveDraft() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshotData())); } catch (e) { /* quota or disabled — silently ignore */ } } function loadDraft() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return false; const data = JSON.parse(raw); if (data.origin) { Comp.origin.x = data.origin.x; Comp.origin.y = data.origin.y; } if (data.anchorsDefault) Object.assign(Comp.anchorsDefault, data.anchorsDefault); if (data.variants) { for (const [key, v] of Object.entries(data.variants)) { if (Comp.variants[key]) Object.assign(Comp.variants[key], v); } } if (data.guides) pendingGuides.push(...data.guides); return true; } catch (e) { return false; } } function guidesSerialize() { return guides.map(g => ({ axis: g.axis, mm: g.mm })); } document.getElementById('meta').textContent = `${Object.keys(Comp.variants).length} variants · baseSize ${Comp.baseSize.w}×${Comp.baseSize.h}mm · origin (${Comp.origin.x}, ${Comp.origin.y}) · ${ANCHOR_NAMES.length} anchors`; let singleInstance = null; let overlayInstance = null; const anchorHandles = {}; // name → handle DOM const anchorRows = {}; // name → row DOM const guides = []; // [{ axis: 'v'|'h', mm: number, el: HTMLElement }] initMatrix(); initSingle(); window.addEventListener('hashchange', route); route(); document.getElementById('btn-copy').addEventListener('click', async () => { await navigator.clipboard.writeText(JSON.stringify(snapshotData(), null, 2)); showToast('Snapshot JSON copied'); }); document.getElementById('btn-save').addEventListener('click', async () => { try { const r = await fetch('/api/save-component-snapshot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme: 'sonic', componentName: 'conduit', ...snapshotData(), }), }); const data = await r.json(); if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); showToast('Saved → ' + data.path.split('/').slice(-3).join('/')); } catch (e) { showToast('Save failed: ' + e.message); } }); // ---- Routing ---- function route() { const params = new URLSearchParams(location.hash.slice(1)); const matrixView = document.getElementById('matrix-view'); const singleView = document.getElementById('single-view'); if (params.has('type')) { matrixView.hidden = true; singleView.hidden = false; applySingle(params); } else { matrixView.hidden = false; singleView.hidden = true; } } function setHashParams(updates) { const params = new URLSearchParams(location.hash.slice(1)); for (const [k, v] of Object.entries(updates)) params.set(k, String(v)); location.hash = params.toString(); } // ---- Matrix mode ---- function initMatrix() { const root = document.getElementById('matrix-view'); const types = presetProps.type; const states = presetProps.state; const section = document.createElement('section'); const h2 = document.createElement('h2'); h2.textContent = `${types.length} types × ${states.length} states`; section.appendChild(h2); const grid = document.createElement('div'); grid.className = 'grid'; grid.style.gridTemplateColumns = `repeat(${states.length}, 1fr)`; for (const type of types) { for (const state of states) { grid.appendChild(makeCell(type, state)); } } section.appendChild(grid); root.appendChild(section); } function makeCell(type, state) { const cell = document.createElement('div'); cell.className = 'cell'; cell.title = 'Open in single mode'; cell.addEventListener('click', () => { location.hash = `type=${type}&state=${state}`; }); const preview = document.createElement('div'); preview.className = 'preview'; const inst = document.createElement('sonic-conduit'); inst.setAttribute('type', type); inst.setAttribute('state', state); preview.appendChild(inst); const label = document.createElement('div'); label.className = 'label'; label.innerHTML = `
${type} · ${state}
`; const key = document.createElement('div'); key.className = 'key'; key.textContent = `${type}_${state}`; cell.appendChild(preview); cell.appendChild(label); cell.appendChild(key); return cell; } // ---- Single mode ---- function initSingle() { fillSelect('ctrl-type', presetProps.type); fillSelect('ctrl-state', presetProps.state); document.getElementById('ctrl-type').addEventListener('change', e => setHashParams({ type: e.target.value }) ); document.getElementById('ctrl-state').addEventListener('change', e => setHashParams({ state: e.target.value }) ); document.getElementById('btn-back').addEventListener('click', () => { location.hash = ''; }); // dx/dy/scale: bind range ↔ number, write through to live variant data bindSlider('ctrl-dx', 'ctrl-dx-num', val => updateField('dx', val)); bindSlider('ctrl-dy', 'ctrl-dy-num', val => updateField('dy', val)); bindSlider('ctrl-scale', 'ctrl-scale-num', val => updateField('scale', val)); document.getElementById('ctrl-edit-defaults').addEventListener('change', () => { refreshAnchors(); }); // Zoom: scale the entire scene const zoom = document.getElementById('ctrl-zoom'); zoom.addEventListener('input', () => applyZoom(parseFloat(zoom.value))); applyZoom(parseFloat(zoom.value)); // Grid columns document.getElementById('ctrl-grid').addEventListener('change', e => setGridColumns(parseInt(e.target.value, 10)) ); // Reset variant button document.getElementById('btn-reset').addEventListener('click', resetVariant); // Overlay select + opacity slider const overlaySel = document.getElementById('ctrl-overlay-variant'); for (const key of Object.keys(Comp.variants)) { const o = document.createElement('option'); o.value = key; o.textContent = key; overlaySel.appendChild(o); } overlaySel.addEventListener('change', () => applyOverlay()); const overlayOpacity = document.getElementById('ctrl-overlay-opacity'); overlayOpacity.addEventListener('input', () => { document.getElementById('overlay-opacity-label').textContent = `${Math.round(parseFloat(overlayOpacity.value) * 100)}%`; applyOverlay(); }); // Guides document.getElementById('btn-guide-v').addEventListener('click', () => addGuide('v')); document.getElementById('btn-guide-h').addEventListener('click', () => addGuide('h')); // Restore any guides from draft for (const g of pendingGuides) addGuide(g.axis, g.mm); pendingGuides.length = 0; buildAnchorRows(); } function resetVariant() { const v = Comp.variants[currentKey()]; if (!v) return; v.dx = 0; v.dy = 0; v.scale = 1; v.anchors = {}; setSlider('ctrl-dx', 0); setSlider('ctrl-dy', 0); setSlider('ctrl-scale', 1); if (singleInstance) singleInstance._render(); refreshAnchors(); saveDraft(); } function applyZoom(z) { // Apply scale transform to .scene only; size .scene-zoom explicitly to // the post-scale visual dimensions so the parent's scroll area is correct. document.getElementById('scene').style.transform = `scale(${z})`; const sz = document.getElementById('scene-zoom'); sz.style.width = `${A4_W_MM * z}mm`; sz.style.height = `${A4_H_MM * z}mm`; document.getElementById('zoom-label').textContent = `${Math.round(z * 100)}%`; scrollToComponent(); } function scrollToComponent() { const stage = document.getElementById('single-stage'); const overlay = document.getElementById('anchor-overlay'); if (!stage || !overlay) return; // Wait for layout to settle before scrolling requestAnimationFrame(() => { const stageRect = stage.getBoundingClientRect(); const overlayRect = overlay.getBoundingClientRect(); const cx = overlayRect.left + overlayRect.width / 2; const cy = overlayRect.top + overlayRect.height / 2; const targetX = stageRect.left + stageRect.width / 2; const targetY = stageRect.top + stageRect.height / 2; stage.scrollLeft += cx - targetX; stage.scrollTop += cy - targetY; }); } function setGridColumns(cols) { const grid = document.getElementById('scene-grid'); grid.innerHTML = ''; if (!cols) return; const A4_W = 210; for (let i = 1; i < cols; i++) { const line = document.createElement('div'); line.className = 'grid-line'; line.style.left = `${(A4_W / cols) * i}mm`; grid.appendChild(line); } } function bindSlider(rangeId, numId, onChange) { const r = document.getElementById(rangeId); const n = document.getElementById(numId); r.addEventListener('input', () => { n.value = r.value; onChange(parseFloat(r.value)); }); n.addEventListener('input', () => { r.value = n.value; onChange(parseFloat(n.value)); }); } function updateField(field, value) { const key = currentKey(); const v = Comp.variants[key]; if (!v) return; v[field] = value; if (singleInstance) singleInstance._render(); saveDraft(); } function buildAnchorRows() { const list = document.getElementById('anchor-list'); list.innerHTML = ''; for (const name of ANCHOR_NAMES) { const row = document.createElement('div'); row.className = 'anchor-row'; row.innerHTML = `${name} `; row.addEventListener('click', () => focusAnchor(name)); list.appendChild(row); anchorRows[name] = row; } } function applySingle(params) { if (!singleInstance) { singleInstance = document.createElement('sonic-conduit'); singleInstance.classList.add('editable'); singleInstance.tabIndex = 0; singleInstance.addEventListener('mousedown', startComponentDrag); singleInstance.addEventListener('keydown', handleNudge); document.getElementById('scene').appendChild(singleInstance); } const type = params.get('type') ?? presetProps.type[0]; const state = params.get('state') ?? presetProps.state[0]; singleInstance.setAttribute('type', type); singleInstance.setAttribute('state', state); document.getElementById('ctrl-type').value = type; document.getElementById('ctrl-state').value = state; const key = `${type}_${state}`; const v = Comp.variants[key] || {}; // Reflect current variant's dx/dy/scale on sliders setSlider('ctrl-dx', v.dx ?? 0); setSlider('ctrl-dy', v.dy ?? 0); setSlider('ctrl-scale', v.scale ?? 1); document.getElementById('info-variant').textContent = key; document.getElementById('info-basesize').textContent = `${Comp.baseSize.w}×${Comp.baseSize.h}mm`; document.getElementById('info-img').textContent = v.img ?? '—'; // Size the anchor overlay to match component baseSize const overlay = document.getElementById('anchor-overlay'); overlay.style.width = `${Comp.baseSize.w}mm`; overlay.style.height = `${Comp.baseSize.h}mm`; refreshAnchors(); applyOverlay(); scrollToComponent(); } function measurePxPerMm() { const overlay = document.getElementById('anchor-overlay'); const rect = overlay.getBoundingClientRect(); return rect.width / Comp.baseSize.w; } function setSlider(rangeId, value) { const r = document.getElementById(rangeId); const n = document.getElementById(rangeId + '-num'); r.value = value; n.value = value; } function refreshAnchors() { const overlay = document.getElementById('anchor-overlay'); // Remove old handles + origin marker, rebuild overlay.querySelectorAll('.anchor-handle, .origin-marker').forEach(h => h.remove()); const editDefaults = document.getElementById('ctrl-edit-defaults').checked; overlay.classList.toggle('defaults-mode', editDefaults); // Origin marker (×) — drag to change Comp.origin globally const o = Comp.origin; const origin = document.createElement('div'); origin.className = 'origin-marker'; origin.style.left = `${o.x}mm`; origin.style.top = `${o.y}mm`; origin.innerHTML = `origin (${o.x.toFixed(1)}, ${o.y.toFixed(1)})`; origin.addEventListener('mousedown', startOriginDrag); overlay.appendChild(origin); const key = currentKey(); const variant = Comp.variants[key]; if (!variant) return; for (const name of ANCHOR_NAMES) { const def = Comp.anchorsDefault[name]; const override = variant.anchors?.[name]; // In defaults-mode, handle shows DEFAULT position. Otherwise resolved (default + override). const shown = editDefaults ? { ...def } : { ...def, ...(override || {}) }; const isOverridden = !!override; const handle = document.createElement('div'); handle.className = 'anchor-handle' + (isOverridden ? ' overridden' : ''); handle.style.left = `${shown.x}mm`; handle.style.top = `${shown.y}mm`; handle.dataset.anchor = name; handle.innerHTML = `${name} (${shown.x.toFixed(1)}, ${shown.y.toFixed(1)})`; handle.addEventListener('mousedown', startDrag); handle.addEventListener('click', e => { e.stopPropagation(); focusAnchor(name); }); overlay.appendChild(handle); anchorHandles[name] = handle; // Update sidebar row (always shows resolved position for the variant) const resolved = { ...def, ...(override || {}) }; const row = anchorRows[name]; if (row) { row.classList.toggle('overridden', isOverridden); row.querySelector('[data-axis=x]').textContent = resolved.x.toFixed(1); row.querySelector('[data-axis=y]').textContent = resolved.y.toFixed(1); } } } function focusAnchor(name) { for (const [n, h] of Object.entries(anchorHandles)) h.classList.toggle('active', n === name); for (const [n, r] of Object.entries(anchorRows)) r.classList.toggle('active', n === name); } function startDrag(e) { e.preventDefault(); const handle = e.currentTarget; const name = handle.dataset.anchor; focusAnchor(name); handle.classList.add('dragging'); // Measure fresh — accounts for any zoom level applied to the scene const pxPerMmLocal = measurePxPerMm(); const startMouse = { x: e.clientX, y: e.clientY }; const editDefaults = document.getElementById('ctrl-edit-defaults').checked; const startPos = editDefaults ? { ...Comp.anchorsDefault[name] } : (() => { const variant = Comp.variants[currentKey()]; const def = Comp.anchorsDefault[name]; const ovr = variant.anchors?.[name]; return { ...def, ...(ovr || {}) }; })(); function onMove(ev) { const dxMm = (ev.clientX - startMouse.x) / pxPerMmLocal; const dyMm = (ev.clientY - startMouse.y) / pxPerMmLocal; const newPos = { x: round1(startPos.x + dxMm), y: round1(startPos.y + dyMm), }; if (editDefaults) { Comp.anchorsDefault[name] = newPos; } else { const variant = Comp.variants[currentKey()]; variant.anchors = variant.anchors || {}; variant.anchors[name] = newPos; } handle.style.left = `${newPos.x}mm`; handle.style.top = `${newPos.y}mm`; handle.querySelector('.lbl').textContent = `${name} (${newPos.x.toFixed(1)}, ${newPos.y.toFixed(1)})`; const row = anchorRows[name]; if (row) { row.querySelector('[data-axis=x]').textContent = newPos.x.toFixed(1); row.querySelector('[data-axis=y]').textContent = newPos.y.toFixed(1); row.classList.toggle('overridden', !editDefaults); } if (!editDefaults) handle.classList.add('overridden'); } function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveDraft(); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } // ---- Drag the origin marker globally ---- function startOriginDrag(e) { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const handle = e.currentTarget; handle.classList.add('dragging'); const ppm = measurePxPerMm(); const startMouse = { x: e.clientX, y: e.clientY }; const start = { ...Comp.origin }; function onMove(ev) { Comp.origin.x = round1(start.x + (ev.clientX - startMouse.x) / ppm); Comp.origin.y = round1(start.y + (ev.clientY - startMouse.y) / ppm); handle.style.left = `${Comp.origin.x}mm`; handle.style.top = `${Comp.origin.y}mm`; handle.querySelector('.lbl').textContent = `origin (${Comp.origin.x.toFixed(1)}, ${Comp.origin.y.toFixed(1)})`; } function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveDraft(); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } // ---- Drag the component itself for dx/dy ---- function startComponentDrag(e) { // Don't hijack drags on anchor handles (they sit above and stop propagation in their own handler). if (e.button !== 0) return; e.preventDefault(); singleInstance.focus(); singleInstance.classList.add('dragging'); const ppm = measurePxPerMm(); const startMouse = { x: e.clientX, y: e.clientY }; const v = Comp.variants[currentKey()]; const startDx = v.dx; const startDy = v.dy; function onMove(ev) { v.dx = round1(startDx + (ev.clientX - startMouse.x) / ppm); v.dy = round1(startDy + (ev.clientY - startMouse.y) / ppm); setSlider('ctrl-dx', v.dx); setSlider('ctrl-dy', v.dy); singleInstance._render(); } function onUp() { singleInstance.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveDraft(); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } function handleNudge(e) { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return; e.preventDefault(); const step = e.shiftKey ? 1 : 0.1; const v = Comp.variants[currentKey()]; if (e.key === 'ArrowLeft') v.dx = round1(v.dx - step); if (e.key === 'ArrowRight') v.dx = round1(v.dx + step); if (e.key === 'ArrowUp') v.dy = round1(v.dy - step); if (e.key === 'ArrowDown') v.dy = round1(v.dy + step); setSlider('ctrl-dx', v.dx); setSlider('ctrl-dy', v.dy); singleInstance._render(); saveDraft(); } // ---- Overlay (compare) instance ---- function applyOverlay() { const sel = document.getElementById('ctrl-overlay-variant'); const opacity = parseFloat(document.getElementById('ctrl-overlay-opacity').value); const variantKey = sel.value; if (!variantKey) { if (overlayInstance) { overlayInstance.remove(); overlayInstance = null; } return; } if (!overlayInstance) { overlayInstance = document.createElement('sonic-conduit'); overlayInstance.classList.add('overlay-instance'); document.getElementById('scene').appendChild(overlayInstance); } // Variant key format: "type_state" (e.g. "lvl1_on" or "lvl1_off") const idx = variantKey.indexOf('_'); const type = variantKey.slice(0, idx); const state = variantKey.slice(idx + 1); overlayInstance.setAttribute('type', type); overlayInstance.setAttribute('state', state); overlayInstance.style.opacity = String(opacity); } // ---- Guides ---- function addGuide(axis, mm) { if (mm === undefined) mm = axis === 'v' ? A4_W_MM / 2 : A4_H_MM / 4; const el = document.createElement('div'); el.className = `guide guide-${axis}`; if (axis === 'v') el.style.left = `${mm}mm`; else el.style.top = `${mm}mm`; const coord = document.createElement('span'); coord.className = 'guide-coord'; coord.textContent = `${mm.toFixed(1)}mm`; el.appendChild(coord); const remove = document.createElement('button'); remove.className = 'guide-remove'; remove.textContent = '×'; remove.addEventListener('click', e => { e.stopPropagation(); removeGuide(guide); }); el.appendChild(remove); const guide = { axis, mm, el, coord }; el.addEventListener('mousedown', e => startGuideDrag(e, guide)); document.getElementById('scene').appendChild(el); guides.push(guide); saveDraft(); } function removeGuide(guide) { guide.el.remove(); const i = guides.indexOf(guide); if (i >= 0) guides.splice(i, 1); saveDraft(); } function startGuideDrag(e, guide) { if (e.target.classList.contains('guide-remove')) return; e.preventDefault(); const ppm = measurePxPerMm(); const startMouse = { x: e.clientX, y: e.clientY }; const startMm = guide.mm; function onMove(ev) { if (guide.axis === 'v') { guide.mm = round1(startMm + (ev.clientX - startMouse.x) / ppm); guide.el.style.left = `${guide.mm}mm`; } else { guide.mm = round1(startMm + (ev.clientY - startMouse.y) / ppm); guide.el.style.top = `${guide.mm}mm`; } guide.coord.textContent = `${guide.mm.toFixed(1)}mm`; } function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveDraft(); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } function round1(v) { return Math.round(v * 10) / 10; } function currentKey() { const params = new URLSearchParams(location.hash.slice(1)); const type = params.get('type') ?? presetProps.type[0]; const state = params.get('state') ?? presetProps.state[0]; return `${type}_${state}`; } function fillSelect(id, options) { const sel = document.getElementById(id); sel.innerHTML = ''; for (const opt of options) { const o = document.createElement('option'); o.value = opt; o.textContent = opt; sel.appendChild(o); } } function showToast(msg) { const toast = document.getElementById('toast'); toast.textContent = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1500); }