diff --git a/.gitignore b/.gitignore index 8ac39d5..5817f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,8 @@ tasks/*/docs/*.output.html # Editor temp files (diffs, screenshots, transient data) tasks/*/temp/ +# Component-editor snapshots (transient — Claude reads and applies) +assets/themes/*/components/*/*.snapshot.json + # PDFs are committed (available for download) # output/pdf/ is NOT gitignored diff --git a/assets/themes/sonic/components/conduit/conduit.editor.html b/assets/themes/sonic/components/conduit/conduit.editor.html new file mode 100644 index 0000000..0c3b4df --- /dev/null +++ b/assets/themes/sonic/components/conduit/conduit.editor.html @@ -0,0 +1,393 @@ + + + + + Component Editor — sonic-conduit + + + + +
+

sonic-conduit

+ + + + +
+ +
+
+ + +
+ +
Snapshot copied
+ + + diff --git a/assets/themes/sonic/components/conduit/conduit.editor.mjs b/assets/themes/sonic/components/conduit/conduit.editor.mjs new file mode 100644 index 0000000..7b01b22 --- /dev/null +++ b/assets/themes/sonic/components/conduit/conduit.editor.mjs @@ -0,0 +1,691 @@ +/** + * 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 = `
${type} · ${state}
`; + + 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 = `${name} + — + —`; + 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 = `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]; + // 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 = `${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; + + // 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); +} diff --git a/assets/themes/sonic/components/conduit/conduit.mjs b/assets/themes/sonic/components/conduit/conduit.mjs new file mode 100644 index 0000000..3c7ce77 --- /dev/null +++ b/assets/themes/sonic/components/conduit/conduit.mjs @@ -0,0 +1,84 @@ +import { FunPenComponent } from '/src/components/base.mjs'; + +class SonicConduit extends FunPenComponent { + // @editor:origin-start + static origin = { x: 25, y: 16.9 }; + // @editor:origin-end + static baseSize = { w: 50, h: 20 }; + + static presetProps = { + type: ['lvl1', 'lvl2', 'lvl3', 'lvl4'], + state: ['on', 'off', 'broken', 'abandoned'], + }; + + // @editor:anchors-start + static anchorsDefault = { + slot1: { x: 3.6, y: 13.4 }, + slot2: { x: 8, y: 13.4 }, + slot3: { x: 12.5, y: 13.5 }, + slot4: { x: 17, y: 13.5 }, + slot5: { x: 21.4, y: 13.4 }, + slot6: { x: 28.5, y: 13.3 }, + slot7: { x: 32.9, y: 13.5 }, + slot8: { x: 37.4, y: 13.7 }, + slot9: { x: 41.8, y: 13.6 }, + slot10: { x: 46.5, y: 13.5 }, + }; + // @editor:anchors-end + + // @editor:variants-start + static variants = { + lvl1_on: { + img: '/assets/themes/sonic/items/conduit/conduit-lvl1.png', + dx: -0.6, dy: 1.4, scale: 1.02, + anchors: { + slot1: { x: 2.1, y: 13.9 }, + slot2: { x: 6.6, y: 13.8 }, + slot3: { x: 11.3, y: 13.8 }, + slot4: { x: 15.8, y: 13.9 }, + slot5: { x: 20.3, y: 13.8 }, + slot6: { x: 29.4, y: 13.8 }, + slot7: { x: 33.7, y: 13.9 }, + slot8: { x: 38.1, y: 13.8 }, + slot9: { x: 42.6, y: 13.7 }, + slot10: { x: 47, y: 13.8 }, + }, + }, + lvl1_off: { img: '/assets/themes/sonic/items/conduit/conduit-lvl1-off.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl1_broken: { img: '/assets/themes/sonic/items/conduit/conduit-lvl1-broken.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl1_abandoned: { img: '/assets/themes/sonic/items/conduit/conduit-lvl1-abandoned.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + + lvl2_on: { + img: '/assets/themes/sonic/items/conduit/conduit-lvl2.png', + dx: 0, dy: 0, scale: 1, + anchors: { + slot1: { x: 3.7, y: 13.5 }, + slot2: { x: 8, y: 13.4 }, + slot3: { x: 12.5, y: 13.4 }, + slot4: { x: 17.1, y: 13.4 }, + slot5: { x: 21.5, y: 13.4 }, + slot6: { x: 28.4, y: 13.4 }, + slot7: { x: 32.9, y: 13.4 }, + slot8: { x: 37.4, y: 13.4 }, + slot9: { x: 41.9, y: 13.4 }, + slot10: { x: 46.4, y: 13.4 }, + }, + }, + lvl2_off: { img: '/assets/themes/sonic/items/conduit/conduit-lvl2-off.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl2_broken: { img: '/assets/themes/sonic/items/conduit/conduit-lvl2-broken.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl2_abandoned: { img: '/assets/themes/sonic/items/conduit/conduit-lvl2-abandoned.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + + lvl3_on: { img: '/assets/themes/sonic/items/conduit/conduit-lvl3.png', dx: 0.1, dy: 0.5, scale: 1, anchors: {} }, + lvl3_off: { img: '/assets/themes/sonic/items/conduit/conduit-lvl3-off.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl3_broken: { img: '/assets/themes/sonic/items/conduit/conduit-lvl3-broken.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl3_abandoned: { img: '/assets/themes/sonic/items/conduit/conduit-lvl3-abandoned.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + + lvl4_on: { img: '/assets/themes/sonic/items/conduit/conduit-lvl4.png', dx: -0.1, dy: -1.4, scale: 1, anchors: {} }, + lvl4_off: { img: '/assets/themes/sonic/items/conduit/conduit-lvl4-off.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl4_broken: { img: '/assets/themes/sonic/items/conduit/conduit-lvl4-broken.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + lvl4_abandoned: { img: '/assets/themes/sonic/items/conduit/conduit-lvl4-abandoned.png', dx: 0, dy: 0, scale: 1, anchors: {} }, + }; + // @editor:variants-end +} + +customElements.define('sonic-conduit', SonicConduit); diff --git a/assets/themes/sonic/components/diamond/diamond.editor.html b/assets/themes/sonic/components/diamond/diamond.editor.html index beab263..876c4fd 100644 --- a/assets/themes/sonic/components/diamond/diamond.editor.html +++ b/assets/themes/sonic/components/diamond/diamond.editor.html @@ -11,7 +11,7 @@ --text: #1c1c1e; --muted: #8e8e93; --accent: #0a84ff; - --invalid: #fafafa; + --warn: #ff9500; --checker: #c8c8c8; } * { box-sizing: border-box; } @@ -25,13 +25,11 @@ header button { font: inherit; padding: 6px 14px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; } - header button:hover { opacity: 0.9; } header button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); } main { padding: 20px; } /* ---- Matrix view ---- */ - section { margin-bottom: 32px; } section > h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; @@ -41,7 +39,8 @@ .cell { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: white; min-height: 110px; - justify-content: center; cursor: pointer; transition: transform 0.1s, border-color 0.1s; } + justify-content: center; cursor: pointer; + transition: transform 0.1s, border-color 0.1s; } .cell:hover { border-color: var(--accent); transform: translateY(-1px); } .cell .preview { position: relative; width: 24mm; height: 24mm; display: flex; align-items: center; justify-content: center; } @@ -51,18 +50,42 @@ font-size: 9px; color: var(--text); } /* ---- Single view ---- */ - - .single-toolbar { display: flex; align-items: center; gap: 16px; + .single-toolbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; 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-toolbar label { display: inline-flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text); cursor: pointer; } + .single-toolbar .group { display: flex; align-items: center; gap: 6px; + padding-left: 12px; border-left: 1px solid var(--line); } + .single-toolbar .group input[type=range] { width: 120px; } + .single-toolbar .group select { font: inherit; padding: 4px 8px; + border: 1px solid var(--line); border-radius: 4px; + background: white; } + .single-toolbar .group .num { font-family: ui-monospace, Menlo, monospace; + font-size: 11px; color: var(--muted); + min-width: 38px; text-align: right; } + .single-toolbar .group button { font: inherit; padding: 4px 8px; + background: white; color: var(--text); + border: 1px solid var(--line); border-radius: 4px; + cursor: pointer; } + .single-toolbar .group button:hover { border-color: var(--accent); } + + .draft-banner { padding: 8px 14px; background: #fff8e1; + border: 1px solid #ffe082; border-radius: 8px; + margin-bottom: 12px; font-size: 12px; + display: flex; align-items: center; gap: 12px; } + .draft-banner button { font: inherit; padding: 4px 10px; + background: white; border: 1px solid #ffb300; + border-radius: 4px; cursor: pointer; + color: #c77700; } + + .single-body { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; } .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; } + display: flex; flex-direction: column; gap: 14px; } .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; } @@ -74,38 +97,168 @@ .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; + .ctrl-divider { border-top: 1px solid var(--line); margin: 4px 0; } + + .slider-row { display: grid; grid-template-columns: 40px 1fr 60px; + gap: 8px; align-items: center; } + .slider-row > .name { font-size: 11px; color: var(--muted); + text-transform: uppercase; letter-spacing: 0.5px; } + .slider-row input[type=range] { width: 100%; } + .slider-row input[type=number] { font: inherit; padding: 4px 6px; + border: 1px solid var(--line); border-radius: 4px; + width: 100%; font-family: ui-monospace, Menlo, monospace; + font-size: 11px; } + + .anchor-list { display: flex; flex-direction: column; gap: 4px; max-height: 280px; overflow-y: auto; } + .anchor-row { display: grid; grid-template-columns: 60px 1fr 1fr; + gap: 6px; align-items: center; padding: 4px 6px; + border-radius: 4px; font-family: ui-monospace, Menlo, monospace; + font-size: 11px; } + .anchor-row:hover { background: var(--bg); } + .anchor-row .name { color: var(--text); font-weight: 600; } + .anchor-row .coord { color: var(--muted); } + .anchor-row.overridden .name { color: var(--warn); } + .anchor-row.active { background: #e3f2ff; } + + .ctrl-info { padding-top: 12px; border-top: 1px solid var(--line); + display: flex; flex-direction: column; gap: 6px; 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; } + border-radius: 10px; + overflow: auto; + padding: 28px 16px 16px; + min-height: 500px; max-height: 80vh; } + .scene-zoom { display: inline-block; position: relative; } + .scene-zoom .scene-label { position: absolute; top: -22px; left: 0; + font-size: 10px; color: var(--muted); + font-family: ui-monospace, Menlo, monospace; + pointer-events: none; white-space: nowrap; } + .scene { transform-origin: 0 0; } + .scene { position: relative; + width: 210mm; height: 297mm; + 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 dashed var(--accent); border-radius: 1px; } + .scene-grid { position: absolute; inset: 0; pointer-events: none; z-index: 5; } + .scene-grid .grid-line { position: absolute; top: 0; bottom: 0; + width: 1px; + background: rgba(10, 132, 255, 0.35); } - /* ---- Component centering inside any preview frame ---- */ - - .cell .preview sonic-diamond, - .single-frame sonic-diamond { + /* Matrix cells: component centered visually */ + .cell .preview sonic-diamond { position: absolute !important; left: 50% !important; top: 50% !important; transform: translate(-50%, -50%) !important; } + /* Single mode: editable component near A4 top-center */ + .scene sonic-diamond { + position: absolute !important; + left: 50% !important; + top: 50mm !important; + transform: translate(-50%, -50%) !important; + } + .scene sonic-diamond.editable { cursor: grab; z-index: 4; } + .scene sonic-diamond.editable:focus { outline: 2px solid var(--accent); outline-offset: 4px; } + .scene sonic-diamond.editable.dragging { cursor: grabbing; } + .scene sonic-diamond.overlay-instance { pointer-events: none; z-index: 7; } + + .anchor-overlay { position: absolute; + left: 50%; top: 50mm; + transform: translate(-50%, -50%); + pointer-events: none; + outline: 1px dashed rgba(10, 132, 255, 0.3); + z-index: 10; } + .anchor-overlay.defaults-mode .anchor-handle:not(.overridden)::before, + .anchor-overlay.defaults-mode .anchor-handle:not(.overridden)::after { + background: #34c759; + } + .anchor-overlay.defaults-mode .anchor-handle.overridden { + opacity: 0.45; + } + + .anchor-handle { position: absolute; + width: 14px; height: 14px; + margin-left: -7px; margin-top: -7px; + background: transparent; + cursor: grab; + pointer-events: auto; + transition: transform 0.1s; } + .anchor-handle::before, .anchor-handle::after { + content: ''; position: absolute; + background: var(--accent); + } + .anchor-handle::before { left: 25%; right: 25%; top: 50%; height: 1.5px; margin-top: -0.75px; } + .anchor-handle::after { top: 25%; bottom: 25%; left: 50%; width: 1.5px; margin-left: -0.75px; } + .anchor-handle:hover { transform: scale(1.6); } + .anchor-handle.dragging { cursor: grabbing; transform: scale(2); } + .anchor-handle.overridden::before, + .anchor-handle.overridden::after { background: var(--warn); } + .anchor-handle.active { outline: 1px dashed var(--accent); outline-offset: 1px; } + .anchor-handle .lbl { position: absolute; left: 14px; top: 14px; + background: rgba(0,0,0,0.7); color: white; + padding: 1px 4px; border-radius: 3px; + font: 9px ui-monospace, Menlo, monospace; + white-space: nowrap; pointer-events: none; + opacity: 0; transition: opacity 0.1s; } + .anchor-handle:hover .lbl, + .anchor-handle.active .lbl, + .anchor-handle.dragging .lbl { opacity: 1; } + + .origin-marker { position: absolute; + width: 16px; height: 16px; + margin-left: -8px; margin-top: -8px; + pointer-events: auto; + cursor: grab; + transition: transform 0.1s; } + .origin-marker:hover { transform: scale(1.4); } + .origin-marker.dragging { transform: scale(1.6); cursor: grabbing; } + .origin-marker::before, .origin-marker::after { + content: ''; position: absolute; background: #ff3b30; + } + .origin-marker::before { left: 0; right: 0; top: 50%; height: 2px; margin-top: -1px; } + .origin-marker::after { top: 0; bottom: 0; left: 50%; width: 2px; margin-left: -1px; } + .origin-marker .lbl { position: absolute; left: 18px; top: -4px; + font: 9px ui-monospace, Menlo, monospace; + color: #ff3b30; white-space: nowrap; } + + .guide { position: absolute; pointer-events: auto; z-index: 8; } + .guide-v { top: 0; bottom: 0; width: 1px; + background: rgba(255, 149, 0, 0.7); cursor: ew-resize; } + .guide-h { left: 0; right: 0; height: 1px; + background: rgba(255, 149, 0, 0.7); cursor: ns-resize; } + .guide-v::before { content: ''; position: absolute; top: 0; bottom: 0; + left: -3px; width: 7px; } + .guide-h::before { content: ''; position: absolute; left: 0; right: 0; + top: -3px; height: 7px; } + .guide-remove { position: absolute; padding: 0; + width: 16px; height: 16px; + background: rgba(255, 149, 0, 0.9); color: white; + border: none; border-radius: 50%; + cursor: pointer; font: 11px sans-serif; line-height: 1; + opacity: 0; transition: opacity 0.1s; } + .guide-v .guide-remove { right: -8px; top: 8px; } + .guide-h .guide-remove { right: 8px; top: -8px; } + .guide:hover .guide-remove { opacity: 1; } + .guide-coord { position: absolute; + background: rgba(255, 149, 0, 0.9); color: white; + padding: 1px 4px; border-radius: 3px; + font: 9px ui-monospace, Menlo, monospace; + pointer-events: none; } + .guide-v .guide-coord { left: 2px; top: 4px; } + .guide-h .guide-coord { top: 2px; left: 4px; } + #toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); padding: 10px 20px; background: var(--text); color: white; border-radius: 8px; @@ -120,7 +273,8 @@

sonic-diamond

- + +
@@ -130,6 +284,44 @@
Variant: +
+ + + 500% +
+
+ + +
+
+ + + +
+
+ + + + 50% +
+ + + +
+
+ +
+
+
+ DX + + +
+
+ DY + + +
+
+ Scale + + +
+ +
+
+
+
baseSize:
origin:
image:
-
-
+
+
+ A4 · 210 × 297 mm +
+
+
+
+
diff --git a/assets/themes/sonic/components/diamond/diamond.editor.mjs b/assets/themes/sonic/components/diamond/diamond.editor.mjs index afec063..02a297e 100644 --- a/assets/themes/sonic/components/diamond/diamond.editor.mjs +++ b/assets/themes/sonic/components/diamond/diamond.editor.mjs @@ -1,26 +1,40 @@ /** * Component-editor for sonic-diamond. * - * Modes: - * - Matrix (default): grid of all valid preset combinations. Click → Single. - * - Single: one component instance with dropdown/checkbox controls and - * a checker-pattern preview frame. Diamond has no per-variant tuning, - * so this mode here only demonstrates the prop-switching flow. + * 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. * - * 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. + * 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})`; + `${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(); @@ -28,11 +42,65 @@ window.addEventListener('hashchange', route); route(); document.getElementById('btn-copy').addEventListener('click', async () => { - const text = serializeSnapshot(Comp.anchorsDefault, Comp.variants); - await navigator.clipboard.writeText(text); - showToast('Snapshot copied'); + 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() { @@ -121,8 +189,6 @@ function makeCell(shape, color, chipped) { // ---- Single mode ---- -let singleInstance = null; - function initSingle() { fillSelect('ctrl-shape', presetProps.shape); fillSelect('ctrl-color', presetProps.color); @@ -139,12 +205,142 @@ function initSingle() { 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'); - document.getElementById('single-frame').appendChild(singleInstance); + 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]; @@ -165,10 +361,317 @@ function applySingle(params) { 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 = Comp.variants[key]?.img ?? '—'; + 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) { @@ -182,39 +685,6 @@ function fillSelect(id, options) { } } -// ---- Snapshot serialization ---- - -function serializeSnapshot(anchorsDefault, variants) { - const lines = []; - lines.push(' // @editor:anchors-start'); - lines.push(` static anchorsDefault = ${formatAnchors(anchorsDefault, ' ')};`); - lines.push(' // @editor:anchors-end'); - lines.push(''); - lines.push(' // @editor:variants-start'); - lines.push(' static variants = {'); - for (const [key, v] of Object.entries(variants)) { - const a = formatAnchorsInline(v.anchors || {}); - lines.push(` ${key}: { img: ${JSON.stringify(v.img)}, dx: ${v.dx}, dy: ${v.dy}, scale: ${v.scale}, anchors: ${a} },`); - } - lines.push(' };'); - lines.push(' // @editor:variants-end'); - return lines.join('\n'); -} - -function formatAnchors(anchors, indent) { - const keys = Object.keys(anchors); - if (keys.length === 0) return '{}'; - const inner = keys.map(k => `${indent} ${k}: { x: ${anchors[k].x}, y: ${anchors[k].y} }`).join(',\n'); - return `{\n${inner},\n${indent}}`; -} - -function formatAnchorsInline(anchors) { - const keys = Object.keys(anchors); - if (keys.length === 0) return '{}'; - const parts = keys.map(k => `${k}: { x: ${anchors[k].x}, y: ${anchors[k].y} }`); - return `{ ${parts.join(', ')} }`; -} - function showToast(msg) { const toast = document.getElementById('toast'); toast.textContent = msg; diff --git a/assets/themes/sonic/components/diamond/diamond.mjs b/assets/themes/sonic/components/diamond/diamond.mjs index a353b38..c859bef 100644 --- a/assets/themes/sonic/components/diamond/diamond.mjs +++ b/assets/themes/sonic/components/diamond/diamond.mjs @@ -1,7 +1,9 @@ import { FunPenComponent } from '/src/components/base.mjs'; class SonicDiamond extends FunPenComponent { + // @editor:origin-start static origin = { x: 5, y: 5 }; + // @editor:origin-end static baseSize = { w: 10, h: 10 }; static presetProps = { diff --git a/bs-config.cjs b/bs-config.cjs index 28329e5..fec8908 100644 --- a/bs-config.cjs +++ b/bs-config.cjs @@ -18,6 +18,20 @@ module.exports = { "assets/themes/**/*", "public/**/*" ], + // Component-editor snapshots are written by the editor itself; reloading + // on them would race against the in-flight save and could overwrite tuned + // state with whatever loaded after the reload. + watchOptions: { + // Component-editor: never auto-reload. The editor holds in-memory tuning + // state and Save races; let the user reload manually when they want. + // Snapshots: written by the editor, reloading races against in-flight save. + ignored: [ + "**/components/**/*.editor.html", + "**/components/**/*.editor.mjs", + "**/*.snapshot.json", + ], + ignoreInitial: true, + }, port: 3300, open: false, notify: false, @@ -34,6 +48,46 @@ module.exports = { next(); } }, + { + route: "/api/save-component-snapshot", + handle: function (req, res, next) { + if (req.method !== 'POST') return next(); + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { + const payload = JSON.parse(body); + const { theme, componentName, ...data } = payload; + if (!theme || !componentName) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing theme or componentName' })); + return; + } + // Reject path traversal + if (theme.includes('..') || theme.includes('/') || + componentName.includes('..') || componentName.includes('/')) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid theme or componentName' })); + return; + } + const compDir = path.join(__dirname, 'assets', 'themes', theme, 'components', componentName); + if (!fs.existsSync(compDir)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Component directory not found: ' + compDir })); + return; + } + const snapshotPath = path.join(compDir, componentName + '.snapshot.json'); + fs.writeFileSync(snapshotPath, JSON.stringify(data, null, 2)); + console.log(`[snapshot] saved ${theme}/${componentName} → ${snapshotPath}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: snapshotPath })); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: e.message })); + } + }); + } + }, { route: "/api/save-edits", handle: function (req, res, next) { diff --git a/output/pdf/collecting-asteroids-1.pdf b/output/pdf/collecting-asteroids-1.pdf index 66a7b73..53140e1 100644 Binary files a/output/pdf/collecting-asteroids-1.pdf and b/output/pdf/collecting-asteroids-1.pdf differ diff --git a/output/pdf/crystal-battery-1.pdf b/output/pdf/crystal-battery-1.pdf new file mode 100644 index 0000000..b714942 Binary files /dev/null and b/output/pdf/crystal-battery-1.pdf differ diff --git a/src/components/base.mjs b/src/components/base.mjs index a977f4b..7345fb9 100644 --- a/src/components/base.mjs +++ b/src/components/base.mjs @@ -73,10 +73,11 @@ export class FunPenComponent extends HTMLElement { :host { display: block; width: ${w}mm; height: ${h}mm; } img { position: absolute; - left: ${dx}mm; - top: ${dy}mm; - width: ${w * scale}mm; - height: ${h * scale}mm; + width: ${w}mm; + left: 50%; top: 50%; + /* Image is centered in component box; dx/dy offset its center; + scale grows it from its center (transform-origin defaults to 50%). */ + transform: translate(-50%, -50%) translate(${dx}mm, ${dy}mm) scale(${scale}); } diff --git a/src/scripts/generate-pdf.mjs b/src/scripts/generate-pdf.mjs index 1b40028..bd2a5f9 100644 --- a/src/scripts/generate-pdf.mjs +++ b/src/scripts/generate-pdf.mjs @@ -11,7 +11,8 @@ const PROJECT_ROOT = resolve(__dirname, '../..'); const OUTPUT_DIR = resolve(PROJECT_ROOT, 'output/pdf'); const MIME_TYPES = { - '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.html': 'text/html', '.css': 'text/css', + '.js': 'application/javascript', '.mjs': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.json': 'application/json', }; @@ -53,6 +54,26 @@ async function generatePdf(htmlPath) { await page.goto(`http://127.0.0.1:${port}/${relPath}`, { waitUntil: 'networkidle0', timeout: 30000 }); + // Theme custom elements load via dynamic import inside the per-theme loader. + // networkidle0 doesn't wait for the post-import upgrade + shadow-DOM image fetch, + // so we explicitly wait for any used custom elements to be defined and their + // shadow-DOM images to finish loading. + const hasCustomTags = await page.evaluate(() => + [...document.querySelectorAll('*')].some(el => el.tagName.includes('-')) + ); + if (hasCustomTags) { + await page.waitForFunction(() => { + const customs = [...document.querySelectorAll('*')].filter(el => el.tagName.includes('-')); + return customs.every(el => { + const root = el.shadowRoot; + if (!root) return false; + const imgs = root.querySelectorAll('img'); + if (imgs.length === 0) return false; + return [...imgs].every(img => img.complete && img.naturalWidth > 0); + }); + }, { timeout: 15000, polling: 100 }); + } + await page.pdf({ path: pdfPath, format: 'A4', diff --git a/src/scripts/take-screenshots.mjs b/src/scripts/take-screenshots.mjs index cb3afc7..9d92b7a 100644 --- a/src/scripts/take-screenshots.mjs +++ b/src/scripts/take-screenshots.mjs @@ -24,7 +24,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '../..'); const MIME_TYPES = { - '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.html': 'text/html', '.css': 'text/css', + '.js': 'application/javascript', '.mjs': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.json': 'application/json', }; @@ -88,6 +89,26 @@ async function main() { await page.goto(`http://127.0.0.1:${port}${urlPath}`, { waitUntil: 'networkidle0', timeout: 30000 }); + // Theme custom elements load via dynamic import inside the per-theme loader. + // networkidle0 doesn't wait for the post-import upgrade + shadow-DOM image fetch, + // so we explicitly wait for any used custom elements to be defined and their + // shadow-DOM images to finish loading. + const hasCustomTags = await page.evaluate(() => + [...document.querySelectorAll('*')].some(el => el.tagName.includes('-')) + ); + if (hasCustomTags) { + await page.waitForFunction(() => { + const customs = [...document.querySelectorAll('*')].filter(el => el.tagName.includes('-')); + return customs.every(el => { + const root = el.shadowRoot; + if (!root) return false; + const imgs = root.querySelectorAll('img'); + if (imgs.length === 0) return false; + return [...imgs].every(img => img.complete && img.naturalWidth > 0); + }); + }, { timeout: 15000, polling: 100 }); + } + // Find all A4 page elements const pageHandles = await page.$$('.w-\\[210mm\\]'); diff --git a/tasks/crystal-battery/docs/crystal-battery-1.template.html b/tasks/crystal-battery/docs/crystal-battery-1.template.html index ac2615a..1eb29d9 100644 --- a/tasks/crystal-battery/docs/crystal-battery-1.template.html +++ b/tasks/crystal-battery/docs/crystal-battery-1.template.html @@ -7,6 +7,7 @@ + Кристальная Батарея