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