/**
* 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 = `
${shape} · ${color}
`;
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 = `${name}
—
—`;
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 = `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];
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;
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);
}