feat: component editor

This commit is contained in:
Oleg Proskurin 2026-04-30 15:14:02 +07:00
parent 2319f2549b
commit 04c7310749
3 changed files with 220 additions and 19 deletions

View File

@ -12,6 +12,7 @@
--muted: #8e8e93; --muted: #8e8e93;
--accent: #0a84ff; --accent: #0a84ff;
--invalid: #fafafa; --invalid: #fafafa;
--checker: #c8c8c8;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; font: 13px/1.4 -apple-system, system-ui, sans-serif; body { margin: 0; font: 13px/1.4 -apple-system, system-ui, sans-serif;
@ -25,7 +26,12 @@
background: var(--accent); color: white; background: var(--accent); color: white;
border: none; border-radius: 6px; cursor: pointer; } border: none; border-radius: 6px; cursor: pointer; }
header button:hover { opacity: 0.9; } header button:hover { opacity: 0.9; }
header button.ghost { background: transparent; color: var(--text);
border: 1px solid var(--line); }
main { padding: 20px; } main { padding: 20px; }
/* ---- Matrix view ---- */
section { margin-bottom: 32px; } section { margin-bottom: 32px; }
section > h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600; section > h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600;
color: var(--muted); text-transform: uppercase; color: var(--muted); text-transform: uppercase;
@ -35,19 +41,71 @@
.cell { display: flex; flex-direction: column; align-items: center; .cell { display: flex; flex-direction: column; align-items: center;
gap: 6px; padding: 12px; border: 1px solid var(--line); gap: 6px; padding: 12px; border: 1px solid var(--line);
border-radius: 8px; background: white; min-height: 110px; border-radius: 8px; background: white; min-height: 110px;
justify-content: center; } justify-content: center; cursor: pointer; transition: transform 0.1s, border-color 0.1s; }
.cell.invalid { background: var(--invalid); opacity: 0.4; } .cell:hover { border-color: var(--accent); transform: translateY(-1px); }
.cell .preview { position: relative; width: 24mm; height: 24mm; .cell .preview { position: relative; width: 24mm; height: 24mm;
display: flex; align-items: center; justify-content: center; } display: flex; align-items: center; justify-content: center; }
/* Stage component absolutely inside preview, centered. The component
uses absolute positioning, so we offset to center it visually. */
.cell .preview sonic-diamond { position: absolute !important;
left: 50% !important; top: 50% !important;
transform: translate(-50%, -50%) !important; }
.cell .label { font-size: 10px; color: var(--muted); .cell .label { font-size: 10px; color: var(--muted);
text-align: center; line-height: 1.3; } text-align: center; line-height: 1.3; }
.cell .key { font-family: ui-monospace, Menlo, monospace; .cell .key { font-family: ui-monospace, Menlo, monospace;
font-size: 9px; color: var(--text); } font-size: 9px; color: var(--text); }
/* ---- Single view ---- */
.single-toolbar { display: flex; align-items: center; gap: 16px;
padding: 12px 16px; background: var(--panel);
border: 1px solid var(--line); border-radius: 10px;
margin-bottom: 16px; }
.single-toolbar code { background: var(--bg); padding: 2px 6px;
border-radius: 4px; font-size: 12px; }
.single-body { display: grid; grid-template-columns: 280px 1fr; gap: 16px; }
.single-controls { background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 16px;
display: flex; flex-direction: column; gap: 14px;
align-self: start; }
.ctrl-row { display: flex; flex-direction: column; gap: 4px; }
.ctrl-row > label { font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.5px; }
.ctrl-row select { font: inherit; padding: 6px 8px;
border: 1px solid var(--line); border-radius: 6px;
background: white; }
.ctrl-row.checkbox { flex-direction: row; align-items: center;
gap: 8px; padding-top: 4px; }
.ctrl-row.checkbox label { font-size: 13px; color: var(--text);
text-transform: none; letter-spacing: 0; }
.ctrl-row.checkbox input[disabled] + label { color: var(--muted); }
.ctrl-info { margin-top: 12px; padding-top: 12px;
border-top: 1px solid var(--line);
display: flex; flex-direction: column; gap: 8px;
font-size: 11px; color: var(--muted); }
.ctrl-info code { font-family: ui-monospace, Menlo, monospace;
font-size: 10px; color: var(--text);
word-break: break-all; }
.single-stage { background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 24px;
display: flex; align-items: center; justify-content: center; }
.single-frame { position: relative;
width: 60mm; height: 60mm;
background-color: white;
background-image:
linear-gradient(45deg, var(--checker) 25%, transparent 25%),
linear-gradient(-45deg, var(--checker) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--checker) 75%),
linear-gradient(-45deg, transparent 75%, var(--checker) 75%);
background-size: 4mm 4mm;
background-position: 0 0, 0 2mm, 2mm -2mm, -2mm 0;
border: 1px solid var(--line); border-radius: 4px; }
/* ---- Component centering inside any preview frame ---- */
.cell .preview sonic-diamond,
.single-frame sonic-diamond {
position: absolute !important;
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%) !important;
}
#toast { position: fixed; bottom: 20px; left: 50%; #toast { position: fixed; bottom: 20px; left: 50%;
transform: translateX(-50%); padding: 10px 20px; transform: translateX(-50%); padding: 10px 20px;
background: var(--text); color: white; border-radius: 8px; background: var(--text); color: white; border-radius: 8px;
@ -64,7 +122,42 @@
<span style="flex: 1"></span> <span style="flex: 1"></span>
<button id="btn-copy">Copy snapshot</button> <button id="btn-copy">Copy snapshot</button>
</header> </header>
<main id="main"></main>
<main>
<div id="matrix-view"></div>
<div id="single-view" hidden>
<div class="single-toolbar">
<button class="ghost" id="btn-back">← Matrix</button>
<span>Variant: <code id="info-variant"></code></span>
</div>
<div class="single-body">
<aside class="single-controls">
<div class="ctrl-row">
<label for="ctrl-shape">Shape</label>
<select id="ctrl-shape"></select>
</div>
<div class="ctrl-row">
<label for="ctrl-color">Color</label>
<select id="ctrl-color"></select>
</div>
<div class="ctrl-row checkbox">
<input type="checkbox" id="ctrl-chipped">
<label for="ctrl-chipped" id="ctrl-chipped-label">Chipped</label>
</div>
<div class="ctrl-info">
<div>baseSize: <code id="info-basesize"></code></div>
<div>origin: <code id="info-origin"></code></div>
<div>image: <code id="info-img"></code></div>
</div>
</aside>
<main class="single-stage">
<div class="single-frame" id="single-frame"></div>
</main>
</div>
</div>
</main>
<div id="toast">Snapshot copied</div> <div id="toast">Snapshot copied</div>
<script type="module" src="./diamond.editor.mjs"></script> <script type="module" src="./diamond.editor.mjs"></script>
</body> </body>

View File

@ -1,10 +1,18 @@
/** /**
* Component-editor for sonic-diamond. * Component-editor for sonic-diamond.
* *
* MVP: matrix view only. Diamonds need no per-variant tuning (all images * Modes:
* are pre-aligned), so single-mode and anchor editing are not implemented. * - Matrix (default): grid of all valid preset combinations. Click Single.
* The "Copy snapshot" button still works it produces the current * - Single: one component instance with dropdown/checkbox controls and
* `anchorsDefault` and `variants` ready to paste between marker comments. * a checker-pattern preview frame. Diamond has no per-variant tuning,
* so this mode here only demonstrates the prop-switching flow.
*
* Mode is driven by the URL hash:
* '' Matrix
* 'shape=...&color=...&chipped=...' Single
*
* "Copy snapshot" works in either mode and produces JS source ready to
* paste between `@editor:anchors-*` and `@editor:variants-*` markers.
*/ */
await customElements.whenDefined('sonic-diamond'); await customElements.whenDefined('sonic-diamond');
@ -14,9 +22,10 @@ const { presetProps, chippedShapes } = Comp;
document.getElementById('meta').textContent = 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})`; `${Object.keys(Comp.variants).length} variants · baseSize ${Comp.baseSize.w}×${Comp.baseSize.h}mm · origin (${Comp.origin.x}, ${Comp.origin.y})`;
const main = document.getElementById('main'); initMatrix();
renderGrid('Full', false); initSingle();
renderGrid('Chipped', true); window.addEventListener('hashchange', route);
route();
document.getElementById('btn-copy').addEventListener('click', async () => { document.getElementById('btn-copy').addEventListener('click', async () => {
const text = serializeSnapshot(Comp.anchorsDefault, Comp.variants); const text = serializeSnapshot(Comp.anchorsDefault, Comp.variants);
@ -24,7 +33,37 @@ document.getElementById('btn-copy').addEventListener('click', async () => {
showToast('Snapshot copied'); showToast('Snapshot copied');
}); });
function renderGrid(title, chipped) { // ---- 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 const shapes = chipped
? presetProps.shape.filter(s => chippedShapes.has(s)) ? presetProps.shape.filter(s => chippedShapes.has(s))
: presetProps.shape; : presetProps.shape;
@ -46,12 +85,16 @@ function renderGrid(title, chipped) {
} }
section.appendChild(grid); section.appendChild(grid);
main.appendChild(section); root.appendChild(section);
} }
function makeCell(shape, color, chipped) { function makeCell(shape, color, chipped) {
const cell = document.createElement('div'); const cell = document.createElement('div');
cell.className = 'cell'; 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'); const preview = document.createElement('div');
preview.className = 'preview'; preview.className = 'preview';
@ -76,6 +119,71 @@ function makeCell(shape, color, chipped) {
return cell; return cell;
} }
// ---- Single mode ----
let singleInstance = null;
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 = '';
});
}
function applySingle(params) {
if (!singleInstance) {
singleInstance = document.createElement('sonic-diamond');
document.getElementById('single-frame').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();
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 = Comp.variants[key]?.img ?? '—';
}
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);
}
}
// ---- Snapshot serialization ----
function serializeSnapshot(anchorsDefault, variants) { function serializeSnapshot(anchorsDefault, variants) {
const lines = []; const lines = [];
lines.push(' // @editor:anchors-start'); lines.push(' // @editor:anchors-start');

View File

@ -1,8 +1,8 @@
import { FunPenComponent } from '/src/components/base.mjs'; import { FunPenComponent } from '/src/components/base.mjs';
class SonicDiamond extends FunPenComponent { class SonicDiamond extends FunPenComponent {
static origin = { x: 9, y: 9 }; static origin = { x: 5, y: 5 };
static baseSize = { w: 18, h: 18 }; static baseSize = { w: 10, h: 10 };
static presetProps = { static presetProps = {
shape: ['classic', 'elongated', 'flat', 'round', 'raw'], shape: ['classic', 'elongated', 'flat', 'round', 'raw'],