694 lines
23 KiB
JavaScript
694 lines
23 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|