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

694 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-diamond.
*
* Matrix: two grids — Full (5×6) and Chipped (2×6). Click cell → Single mode.
* Single: Shape/Color dropdowns + Chipped checkbox (auto-disabled for shapes
* that don't support chipping). Sliders for dx/dy/scale, draggable origin,
* draggable anchors (none for diamond), guides, overlay, A4 scene.
*
* Save: POST /api/save-component-snapshot writes JSON next to the component.
* Claude reads the JSON and edits the marker-bracketed sections of .mjs.
*/
await customElements.whenDefined('sonic-diamond');
const Comp = customElements.get('sonic-diamond');
const { presetProps, chippedShapes } = Comp;
const ANCHOR_NAMES = Object.keys(Comp.anchorsDefault);
const A4_W_MM = 210;
const A4_H_MM = 297;
const STORAGE_KEY = 'funpen.editor.sonic-diamond.draft';
const pendingGuides = [];
if (loadDraft()) {
document.getElementById('draft-banner').hidden = false;
}
document.getElementById('btn-discard-draft').addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
location.reload();
});
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 = {};
const anchorRows = {};
const guides = [];
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: 'diamond',
...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);
}
});
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 */ }
}
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 }));
}
// ---- 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('shape')) {
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');
renderGrid(root, 'Full', false);
renderGrid(root, 'Chipped', true);
}
function renderGrid(root, title, chipped) {
const shapes = chipped
? presetProps.shape.filter(s => chippedShapes.has(s))
: presetProps.shape;
const colors = presetProps.color;
const section = document.createElement('section');
const h2 = document.createElement('h2');
h2.textContent = `${title}${shapes.length} × ${colors.length}`;
section.appendChild(h2);
const grid = document.createElement('div');
grid.className = 'grid';
grid.style.gridTemplateColumns = `repeat(${colors.length}, 1fr)`;
for (const shape of shapes) {
for (const color of colors) {
grid.appendChild(makeCell(shape, color, chipped));
}
}
section.appendChild(grid);
root.appendChild(section);
}
function makeCell(shape, color, chipped) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.title = 'Open in single mode';
cell.addEventListener('click', () => {
location.hash = `shape=${shape}&color=${color}&chipped=${chipped}`;
});
const preview = document.createElement('div');
preview.className = 'preview';
const inst = document.createElement('sonic-diamond');
inst.setAttribute('shape', shape);
inst.setAttribute('color', color);
inst.setAttribute('chipped', String(chipped));
preview.appendChild(inst);
const label = document.createElement('div');
label.className = 'label';
label.innerHTML = `<div>${shape} · ${color}</div>`;
const key = document.createElement('div');
key.className = 'key';
key.textContent = inst._variantKey();
cell.appendChild(preview);
cell.appendChild(label);
cell.appendChild(key);
return cell;
}
// ---- Single mode ----
function initSingle() {
fillSelect('ctrl-shape', presetProps.shape);
fillSelect('ctrl-color', presetProps.color);
document.getElementById('ctrl-shape').addEventListener('change', e =>
setHashParams({ shape: e.target.value })
);
document.getElementById('ctrl-color').addEventListener('change', e =>
setHashParams({ color: e.target.value })
);
document.getElementById('ctrl-chipped').addEventListener('change', e =>
setHashParams({ chipped: e.target.checked })
);
document.getElementById('btn-back').addEventListener('click', () => {
location.hash = '';
});
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();
});
const zoom = document.getElementById('ctrl-zoom');
zoom.addEventListener('input', () => applyZoom(parseFloat(zoom.value)));
applyZoom(parseFloat(zoom.value));
document.getElementById('ctrl-grid').addEventListener('change', e =>
setGridColumns(parseInt(e.target.value, 10))
);
document.getElementById('btn-reset').addEventListener('click', resetVariant);
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();
});
document.getElementById('btn-guide-v').addEventListener('click', () => addGuide('v'));
document.getElementById('btn-guide-h').addEventListener('click', () => addGuide('h'));
for (const g of pendingGuides) addGuide(g.axis, g.mm);
pendingGuides.length = 0;
buildAnchorRows();
if (ANCHOR_NAMES.length === 0) {
document.getElementById('anchors-label').textContent = 'Anchors — (none defined)';
} else {
document.getElementById('anchors-label').textContent =
`Anchors (${ANCHOR_NAMES.length}) — drag handles on stage`;
}
}
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 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) {
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;
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;
for (let i = 1; i < cols; i++) {
const line = document.createElement('div');
line.className = 'grid-line';
line.style.left = `${(A4_W_MM / cols) * i}mm`;
grid.appendChild(line);
}
}
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-diamond');
singleInstance.classList.add('editable');
singleInstance.tabIndex = 0;
singleInstance.addEventListener('mousedown', startComponentDrag);
singleInstance.addEventListener('keydown', handleNudge);
document.getElementById('scene').appendChild(singleInstance);
}
const shape = params.get('shape') ?? presetProps.shape[0];
const color = params.get('color') ?? presetProps.color[0];
const chipped = params.get('chipped') === 'true';
singleInstance.setAttribute('shape', shape);
singleInstance.setAttribute('color', color);
singleInstance.setAttribute('chipped', String(chipped));
document.getElementById('ctrl-shape').value = shape;
document.getElementById('ctrl-color').value = color;
const cb = document.getElementById('ctrl-chipped');
const cbLabel = document.getElementById('ctrl-chipped-label');
const supportsChipped = chippedShapes.has(shape);
cb.checked = chipped && supportsChipped;
cb.disabled = !supportsChipped;
cbLabel.textContent = supportsChipped ? 'Chipped' : 'Chipped (n/a for this shape)';
const key = singleInstance._variantKey();
const v = Comp.variants[key] || {};
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-origin').textContent = `(${Comp.origin.x}, ${Comp.origin.y})`;
document.getElementById('info-img').textContent = v.img ?? '—';
const overlay = document.getElementById('anchor-overlay');
overlay.style.width = `${Comp.baseSize.w}mm`;
overlay.style.height = `${Comp.baseSize.h}mm`;
refreshAnchors();
applyOverlay();
scrollToComponent();
}
function setSlider(rangeId, value) {
const r = document.getElementById(rangeId);
const n = document.getElementById(rangeId + '-num');
r.value = value;
n.value = value;
}
function measurePxPerMm() {
const overlay = document.getElementById('anchor-overlay');
const rect = overlay.getBoundingClientRect();
return rect.width / Comp.baseSize.w;
}
function refreshAnchors() {
const overlay = document.getElementById('anchor-overlay');
overlay.querySelectorAll('.anchor-handle, .origin-marker').forEach(h => h.remove());
const editDefaults = document.getElementById('ctrl-edit-defaults').checked;
overlay.classList.toggle('defaults-mode', editDefaults);
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];
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;
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');
const ppm = 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 newPos = {
x: round1(startPos.x + (ev.clientX - startMouse.x) / ppm),
y: round1(startPos.y + (ev.clientY - startMouse.y) / ppm),
};
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);
}
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)})`;
document.getElementById('info-origin').textContent =
`(${Comp.origin.x}, ${Comp.origin.y})`;
}
function onUp() {
handle.classList.remove('dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveDraft();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function startComponentDrag(e) {
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()];
if (!v) return;
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 (!v) return;
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();
}
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-diamond');
overlayInstance.classList.add('overlay-instance');
document.getElementById('scene').appendChild(overlayInstance);
}
// Decompose key like "classic_blue" or "classic_blue_chipped"
const parts = variantKey.split('_');
let shape = parts[0];
let color = parts[1];
let chipped = parts[2] === 'chipped';
overlayInstance.setAttribute('shape', shape);
overlayInstance.setAttribute('color', color);
overlayInstance.setAttribute('chipped', String(chipped));
overlayInstance.style.opacity = String(opacity);
}
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() {
if (!singleInstance) {
const params = new URLSearchParams(location.hash.slice(1));
const shape = params.get('shape') ?? presetProps.shape[0];
const color = params.get('color') ?? presetProps.color[0];
const chipped = params.get('chipped') === 'true';
const supportsChipped = chippedShapes.has(shape);
return supportsChipped && chipped ? `${shape}_${color}_chipped` : `${shape}_${color}`;
}
return singleInstance._variantKey();
}
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);
}