math-tasks/assets/themes/sonic/components/conduit/conduit.editor.mjs

692 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `<div>${type} · ${state}</div>`;
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 = `<span class="name">${name}</span>
<span class="coord" data-axis="x">—</span>
<span class="coord" data-axis="y">—</span>`;
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 = `<span class="lbl">origin (${o.x.toFixed(1)}, ${o.y.toFixed(1)})</span>`;
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 = `<span class="lbl">${name} (${shown.x.toFixed(1)}, ${shown.y.toFixed(1)})</span>`;
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);
}