feat: components
|
|
@ -0,0 +1 @@
|
||||||
|
{"sessionId":"dec1e8b5-dc16-43d2-b20f-0fad3fe0354e","pid":148363,"procStart":"635354","acquiredAt":1777523259923}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Component Editor — sonic-diamond</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: #e5e5ea;
|
||||||
|
--text: #1c1c1e;
|
||||||
|
--muted: #8e8e93;
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--invalid: #fafafa;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font: 13px/1.4 -apple-system, system-ui, sans-serif;
|
||||||
|
background: var(--bg); color: var(--text); }
|
||||||
|
header { position: sticky; top: 0; z-index: 10;
|
||||||
|
background: var(--panel); border-bottom: 1px solid var(--line);
|
||||||
|
padding: 12px 20px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
header h1 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||||
|
header .meta { color: var(--muted); font-size: 12px; }
|
||||||
|
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; }
|
||||||
|
main { padding: 20px; }
|
||||||
|
section { margin-bottom: 32px; }
|
||||||
|
section > h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600;
|
||||||
|
color: var(--muted); text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px; }
|
||||||
|
.grid { display: grid; gap: 12px; background: var(--panel);
|
||||||
|
padding: 16px; border-radius: 10px; border: 1px solid var(--line); }
|
||||||
|
.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; }
|
||||||
|
.cell.invalid { background: var(--invalid); opacity: 0.4; }
|
||||||
|
.cell .preview { position: relative; width: 24mm; height: 24mm;
|
||||||
|
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);
|
||||||
|
text-align: center; line-height: 1.3; }
|
||||||
|
.cell .key { font-family: ui-monospace, Menlo, monospace;
|
||||||
|
font-size: 9px; color: var(--text); }
|
||||||
|
#toast { position: fixed; bottom: 20px; left: 50%;
|
||||||
|
transform: translateX(-50%); padding: 10px 20px;
|
||||||
|
background: var(--text); color: white; border-radius: 8px;
|
||||||
|
opacity: 0; pointer-events: none; transition: opacity 0.2s;
|
||||||
|
font-size: 12px; }
|
||||||
|
#toast.show { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
<script type="module" src="./diamond.mjs"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>sonic-diamond</h1>
|
||||||
|
<span class="meta" id="meta"></span>
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<button id="btn-copy">Copy snapshot</button>
|
||||||
|
</header>
|
||||||
|
<main id="main"></main>
|
||||||
|
<div id="toast">Snapshot copied</div>
|
||||||
|
<script type="module" src="./diamond.editor.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Component-editor for sonic-diamond.
|
||||||
|
*
|
||||||
|
* MVP: matrix view only. Diamonds need no per-variant tuning (all images
|
||||||
|
* are pre-aligned), so single-mode and anchor editing are not implemented.
|
||||||
|
* The "Copy snapshot" button still works — it produces the current
|
||||||
|
* `anchorsDefault` and `variants` ready to paste between marker comments.
|
||||||
|
*/
|
||||||
|
await customElements.whenDefined('sonic-diamond');
|
||||||
|
|
||||||
|
const Comp = customElements.get('sonic-diamond');
|
||||||
|
const { presetProps, chippedShapes } = Comp;
|
||||||
|
|
||||||
|
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})`;
|
||||||
|
|
||||||
|
const main = document.getElementById('main');
|
||||||
|
renderGrid('Full', false);
|
||||||
|
renderGrid('Chipped', true);
|
||||||
|
|
||||||
|
document.getElementById('btn-copy').addEventListener('click', async () => {
|
||||||
|
const text = serializeSnapshot(Comp.anchorsDefault, Comp.variants);
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
showToast('Snapshot copied');
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderGrid(title, chipped) {
|
||||||
|
const shapes = chipped
|
||||||
|
? presetProps.shape.filter(s => chippedShapes.has(s))
|
||||||
|
: presetProps.shape;
|
||||||
|
const colors = presetProps.color;
|
||||||
|
|
||||||
|
const section = document.createElement('section');
|
||||||
|
const h2 = document.createElement('h2');
|
||||||
|
h2.textContent = `${title} — ${shapes.length} × ${colors.length}`;
|
||||||
|
section.appendChild(h2);
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'grid';
|
||||||
|
grid.style.gridTemplateColumns = `repeat(${colors.length}, 1fr)`;
|
||||||
|
|
||||||
|
for (const shape of shapes) {
|
||||||
|
for (const color of colors) {
|
||||||
|
grid.appendChild(makeCell(shape, color, chipped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section.appendChild(grid);
|
||||||
|
main.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCell(shape, color, chipped) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'cell';
|
||||||
|
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'preview';
|
||||||
|
|
||||||
|
const inst = document.createElement('sonic-diamond');
|
||||||
|
inst.setAttribute('shape', shape);
|
||||||
|
inst.setAttribute('color', color);
|
||||||
|
inst.setAttribute('chipped', String(chipped));
|
||||||
|
preview.appendChild(inst);
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'label';
|
||||||
|
label.innerHTML = `<div>${shape} · ${color}</div>`;
|
||||||
|
|
||||||
|
const key = document.createElement('div');
|
||||||
|
key.className = 'key';
|
||||||
|
key.textContent = inst._variantKey();
|
||||||
|
|
||||||
|
cell.appendChild(preview);
|
||||||
|
cell.appendChild(label);
|
||||||
|
cell.appendChild(key);
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => toast.classList.remove('show'), 1500);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { FunPenComponent } from '/src/components/base.mjs';
|
||||||
|
|
||||||
|
class SonicDiamond extends FunPenComponent {
|
||||||
|
static origin = { x: 9, y: 9 };
|
||||||
|
static baseSize = { w: 18, h: 18 };
|
||||||
|
|
||||||
|
static presetProps = {
|
||||||
|
shape: ['classic', 'elongated', 'flat', 'round', 'raw'],
|
||||||
|
color: ['blue', 'cyan', 'green', 'magenta', 'pink', 'red'],
|
||||||
|
chipped: ['false', 'true'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chipped variants exist only for these shapes. Other combinations
|
||||||
|
// silently fall back to the non-chipped variant.
|
||||||
|
static chippedShapes = new Set(['classic', 'round']);
|
||||||
|
|
||||||
|
// @editor:anchors-start
|
||||||
|
static anchorsDefault = {};
|
||||||
|
// @editor:anchors-end
|
||||||
|
|
||||||
|
// @editor:variants-start
|
||||||
|
static variants = {
|
||||||
|
classic_blue: { img: '/assets/themes/sonic/items/diamonds/01-classic-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_cyan: { img: '/assets/themes/sonic/items/diamonds/01-classic-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_green: { img: '/assets/themes/sonic/items/diamonds/01-classic-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_magenta: { img: '/assets/themes/sonic/items/diamonds/01-classic-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_pink: { img: '/assets/themes/sonic/items/diamonds/01-classic-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_red: { img: '/assets/themes/sonic/items/diamonds/01-classic-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
|
||||||
|
elongated_blue: { img: '/assets/themes/sonic/items/diamonds/02-elongated-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
elongated_cyan: { img: '/assets/themes/sonic/items/diamonds/02-elongated-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
elongated_green: { img: '/assets/themes/sonic/items/diamonds/02-elongated-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
elongated_magenta: { img: '/assets/themes/sonic/items/diamonds/02-elongated-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
elongated_pink: { img: '/assets/themes/sonic/items/diamonds/02-elongated-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
elongated_red: { img: '/assets/themes/sonic/items/diamonds/02-elongated-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
|
||||||
|
flat_blue: { img: '/assets/themes/sonic/items/diamonds/03-flat-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
flat_cyan: { img: '/assets/themes/sonic/items/diamonds/03-flat-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
flat_green: { img: '/assets/themes/sonic/items/diamonds/03-flat-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
flat_magenta: { img: '/assets/themes/sonic/items/diamonds/03-flat-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
flat_pink: { img: '/assets/themes/sonic/items/diamonds/03-flat-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
flat_red: { img: '/assets/themes/sonic/items/diamonds/03-flat-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
|
||||||
|
round_blue: { img: '/assets/themes/sonic/items/diamonds/04-round-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_cyan: { img: '/assets/themes/sonic/items/diamonds/04-round-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_green: { img: '/assets/themes/sonic/items/diamonds/04-round-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_magenta: { img: '/assets/themes/sonic/items/diamonds/04-round-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_pink: { img: '/assets/themes/sonic/items/diamonds/04-round-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_red: { img: '/assets/themes/sonic/items/diamonds/04-round-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
|
||||||
|
raw_blue: { img: '/assets/themes/sonic/items/diamonds/05-raw-blue.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
raw_cyan: { img: '/assets/themes/sonic/items/diamonds/05-raw-cyan.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
raw_green: { img: '/assets/themes/sonic/items/diamonds/05-raw-green.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
raw_magenta: { img: '/assets/themes/sonic/items/diamonds/05-raw-magenta.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
raw_pink: { img: '/assets/themes/sonic/items/diamonds/05-raw-pink.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
raw_red: { img: '/assets/themes/sonic/items/diamonds/05-raw-red.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
|
||||||
|
classic_blue_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-blue-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_cyan_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-cyan-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_green_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-green-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_magenta_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-magenta-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_pink_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-pink-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
classic_red_chipped: { img: '/assets/themes/sonic/items/diamonds/06-classic-red-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
|
||||||
|
round_blue_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-blue-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_cyan_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-cyan-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_green_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-green-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_magenta_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-magenta-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_pink_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-pink-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
round_red_chipped: { img: '/assets/themes/sonic/items/diamonds/07-round-red-chipped.png', dx: 0, dy: 0, scale: 1, anchors: {} },
|
||||||
|
};
|
||||||
|
// @editor:variants-end
|
||||||
|
|
||||||
|
_variantKey() {
|
||||||
|
const shape = this.getAttribute('shape') ?? this.constructor.presetProps.shape[0];
|
||||||
|
const color = this.getAttribute('color') ?? this.constructor.presetProps.color[0];
|
||||||
|
const chipped = this.getAttribute('chipped') === 'true';
|
||||||
|
const supportsChipped = this.constructor.chippedShapes.has(shape);
|
||||||
|
return supportsChipped && chipped
|
||||||
|
? `${shape}_${color}_chipped`
|
||||||
|
: `${shape}_${color}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('sonic-diamond', SonicDiamond);
|
||||||
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 819 KiB |
|
After Width: | Height: | Size: 817 KiB |
|
After Width: | Height: | Size: 880 KiB |
|
After Width: | Height: | Size: 988 KiB |
|
After Width: | Height: | Size: 1006 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 718 KiB |
|
After Width: | Height: | Size: 1009 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 862 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
Before Width: | Height: | Size: 1012 KiB |
|
After Width: | Height: | Size: 868 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1011 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 997 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 995 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 998 KiB |
|
After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 987 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1015 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1022 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1005 KiB |
|
After Width: | Height: | Size: 907 KiB |
|
After Width: | Height: | Size: 890 KiB |
|
After Width: | Height: | Size: 911 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 979 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Sonic theme component loader.
|
||||||
|
* Scans the document for `sonic-*` custom elements and lazy-imports
|
||||||
|
* only those component modules. See src/components/CLAUDE.md.
|
||||||
|
*/
|
||||||
|
const PREFIX = 'sonic-';
|
||||||
|
const tags = new Set();
|
||||||
|
document.querySelectorAll('*').forEach(el => {
|
||||||
|
const t = el.tagName.toLowerCase();
|
||||||
|
if (t.startsWith(PREFIX)) tags.add(t);
|
||||||
|
});
|
||||||
|
await Promise.all([...tags].map(t => {
|
||||||
|
const name = t.slice(PREFIX.length);
|
||||||
|
return import(`./components/${name}/${name}.mjs`);
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
# Components System
|
||||||
|
|
||||||
|
Themed Web Components for FunPen worksheets. Components wrap images so that:
|
||||||
|
- visual variants are picked via props (e.g. `shape="hex" color="blue"`)
|
||||||
|
- per-image positioning offsets are baked into the component file once and applied automatically
|
||||||
|
- key points (anchors / connectors) are exposed via a stable DOM API for document-level composition
|
||||||
|
|
||||||
|
This folder holds the **shared infrastructure** (base class, editor framework). Actual components live next to their assets under `assets/themes/{theme}/components/{name}/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/ — shared infrastructure (this folder)
|
||||||
|
CLAUDE.md — decisions and conventions (this file)
|
||||||
|
base.mjs — FunPenComponent base class
|
||||||
|
editor/ — reusable component-editor framework
|
||||||
|
matrix-view.mjs — matrix mode (grid of preset variants)
|
||||||
|
single-view.mjs — single mode (tune one variant)
|
||||||
|
write-back.mjs — saves edits back into component .mjs
|
||||||
|
styles.css — shared editor styles
|
||||||
|
README-editor.md — how to build a component-editor
|
||||||
|
|
||||||
|
assets/themes/{theme}/
|
||||||
|
loader.mjs — tiny per-theme on-demand loader (~10 lines)
|
||||||
|
components/
|
||||||
|
{component}/
|
||||||
|
{component}.mjs — self-contained Web Component (final)
|
||||||
|
{component}.editor.html — component-editor page (dev only)
|
||||||
|
{component}.editor.mjs — component-editor logic (dev only)
|
||||||
|
|
||||||
|
assets/themes/{theme}/items/{cat}/ — image assets (UNCHANGED — components reference these)
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor framework files (`editor/*`) are written iteratively as we build the first component. Don't pre-create them empty — extract reusable pieces from the first concrete editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
|
||||||
|
These were agreed during the design discussion and should not be changed without revisiting:
|
||||||
|
|
||||||
|
1. **Web Components with Shadow DOM** — for true encapsulation. Doc-editor cannot peek inside, no style bleed, real isolation. Single `<script type="module">` per document, the rest is plain HTML.
|
||||||
|
2. **Self-contained component files** — after tuning, the `.mjs` file holds all data (variants, anchors, offsets) as static constants. No external JSON, no runtime fetches.
|
||||||
|
3. **Theme-prefixed tag names** — `sonic-crystal`, `nms-asteroid`. Theme is part of the component identity, no cross-theme namespace conflicts.
|
||||||
|
4. **On-demand loader** — per-theme `loader.mjs` scans the document for used tags and dynamically imports only what's referenced. Documents pay only for what they use.
|
||||||
|
5. **Units: mm everywhere** — origin, baseSize, anchors, per-variant offsets. No percentages, no pixels.
|
||||||
|
6. **Composition at document level** — components are leaf elements. They expose anchors via DOM API; documents place children using that information. Components never hardcode child types or fixed inner composition.
|
||||||
|
7. **Per-variant image positioning is the central data** — different image files have different intrinsic offsets/scales within the component bounds. The component-editor's primary purpose is fine-tuning those per-variant adjustments and baking them into the source file.
|
||||||
|
8. **Anchors: global default + per-variant overrides** — each component declares default anchor positions; each variant can override only the points that differ. Resolved as `{ ...default, ...variantOverride }`.
|
||||||
|
9. **Generate.mjs is not involved** — Web Components are runtime, the build pipeline stays untouched. Templates with components are valid HTML and render as-is.
|
||||||
|
10. **Doc-editor is not involved** — it sees components as atomic positioned blocks via their host attributes (`x`, `y`, `scale`, `rotation`). No special handling needed.
|
||||||
|
11. **Component-editor is a dev-time tool** — committed to git, lives next to the component, but does not participate in document lifecycle. No production code path imports it.
|
||||||
|
12. **Asset paths unchanged** — components reference existing images at `assets/themes/{theme}/items/{cat}/{file}`. No new asset folders for components.
|
||||||
|
13. **No automated write-back from editor to source** — editor produces a JS source snapshot, the AI agent pastes it between marker comments and verifies the result. Following the project's orchestrator pattern: scripts produce data, the agent makes decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component anatomy
|
||||||
|
|
||||||
|
A component is one self-contained `.mjs` file. Skeleton:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { FunPenComponent } from '/src/components/base.mjs';
|
||||||
|
|
||||||
|
class SonicCrystal extends FunPenComponent {
|
||||||
|
// ===== Static metadata =====
|
||||||
|
|
||||||
|
// Insertion point in component-local mm. When document places the
|
||||||
|
// component at (x, y), THIS point lands at (x, y).
|
||||||
|
static origin = { x: 15, y: 15 };
|
||||||
|
|
||||||
|
// Natural size at scale=1, in mm.
|
||||||
|
static baseSize = { w: 30, h: 30 };
|
||||||
|
|
||||||
|
// Props that drive variant selection. Cartesian product = matrix view
|
||||||
|
// in component-editor.
|
||||||
|
static presetProps = {
|
||||||
|
shape: ['hex', 'round', 'tri'],
|
||||||
|
color: ['blue', 'red', 'green'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default anchor coordinates in component-local mm. Each variant may
|
||||||
|
// override individual anchors; resolution merges default + override.
|
||||||
|
// @editor:anchors-start
|
||||||
|
static anchorsDefault = {
|
||||||
|
tip: { x: 15, y: 0 },
|
||||||
|
base: { x: 15, y: 30 },
|
||||||
|
};
|
||||||
|
// @editor:anchors-end
|
||||||
|
|
||||||
|
// Per-variant data: image path + position adjustments + anchor overrides.
|
||||||
|
// Variant key = preset-prop values joined by '_' in declaration order.
|
||||||
|
// @editor:variants-start
|
||||||
|
static variants = {
|
||||||
|
hex_blue: { img: '/assets/themes/sonic/items/crystal/hex-blue.png', dx: -1.5, dy: 0.0, scale: 1.00, anchors: {} },
|
||||||
|
hex_red: { img: '/assets/themes/sonic/items/crystal/hex-red.png', dx: -1.2, dy: -0.3, scale: 1.00, anchors: { tip: { x: 14.5, y: -0.2 } } },
|
||||||
|
round_blue: { img: '/assets/themes/sonic/items/crystal/round-blue.png', dx: 0.0, dy: 0.0, scale: 0.92, anchors: {} },
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
// @editor:variants-end
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('sonic-crystal', SonicCrystal);
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it for the simple case. The base class provides default rendering (single image positioned per the variant). Override `render(variant)` if a component needs more (multiple images, decorative SVG overlays, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base class API
|
||||||
|
|
||||||
|
`FunPenComponent` (in `base.mjs`) provides:
|
||||||
|
|
||||||
|
### Standard attributes (auto-handled)
|
||||||
|
|
||||||
|
| Attribute | Meaning |
|
||||||
|
|------------|---------|
|
||||||
|
| `x` | Origin's X position in document mm |
|
||||||
|
| `y` | Origin's Y position in document mm |
|
||||||
|
| `scale` | Multiplier on baseSize, default `1` |
|
||||||
|
| `rotation` | Degrees, default `0` |
|
||||||
|
|
||||||
|
These are observed; changing them re-applies the host transform without re-rendering the inner content.
|
||||||
|
|
||||||
|
Preset props (declared in `presetProps`) are also observed; changing them triggers re-render with the new variant.
|
||||||
|
|
||||||
|
### Public DOM API
|
||||||
|
|
||||||
|
```js
|
||||||
|
const el = document.querySelector('sonic-crystal');
|
||||||
|
|
||||||
|
el.getOrigin(); // → { x, y } in component-local mm
|
||||||
|
el.getAnchor('tip'); // → { x, y } in component-local mm, with variant override applied
|
||||||
|
el.getAnchorDocPosition('tip'); // → { x, y } in document mm, accounting for x/y/scale/rotation
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `getAnchorDocPosition` to place neighboring document elements relative to anchor points.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composition (document-level)
|
||||||
|
|
||||||
|
Components are leaves. To place one component at another's anchor, read the anchor in the document and use it as input for the next placement:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Document author, possibly via doc-editor: -->
|
||||||
|
<sonic-crystal id="c1" x="100" y="60" shape="hex" color="blue"></sonic-crystal>
|
||||||
|
<sonic-crystal id="c2" x="100" y="90" shape="round" color="red"></sonic-crystal>
|
||||||
|
```
|
||||||
|
|
||||||
|
If snapping is needed at runtime (rare for static documents), JS can read `c1.getAnchorDocPosition('base')` and write the result into `c2`'s `x/y` attributes. For most static layouts the document author hardcodes coordinates; anchor metadata is an aid for the doc-editor when snapping/aligning.
|
||||||
|
|
||||||
|
**No `<slot>`-based composition inside components.** Components don't presume what goes near them. Documents decide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-theme loader
|
||||||
|
|
||||||
|
Each theme has a tiny loader that scans the document and lazy-imports used components:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// assets/themes/sonic/loader.mjs
|
||||||
|
const PREFIX = 'sonic-';
|
||||||
|
const tags = new Set();
|
||||||
|
document.querySelectorAll('*').forEach(el => {
|
||||||
|
const t = el.tagName.toLowerCase();
|
||||||
|
if (t.startsWith(PREFIX)) tags.add(t);
|
||||||
|
});
|
||||||
|
await Promise.all([...tags].map(t => {
|
||||||
|
const name = t.slice(PREFIX.length);
|
||||||
|
return import(`./components/${name}/${name}.mjs`);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Documents include it once:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<head>
|
||||||
|
<script type="module" src="/assets/themes/sonic/loader.mjs"></script>
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader runs as `module` → deferred by default → the DOM is parsed before it scans. If a component is added to the page after load (very rare in our static documents), the loader does not pick it up — that's fine for now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component-editor
|
||||||
|
|
||||||
|
Two modes, one page (`{component}.editor.html`):
|
||||||
|
|
||||||
|
### Matrix mode (default on open)
|
||||||
|
|
||||||
|
Cartesian product of all `presetProps` rendered as a grid. Each cell is a live instance of the component with that prop combination plus a label. Clicking a cell enters single mode for those props.
|
||||||
|
|
||||||
|
Free props (`x`, `y`, `scale`, `rotation`) are NOT iterated — they don't affect the variant.
|
||||||
|
|
||||||
|
### Single mode
|
||||||
|
|
||||||
|
One large instance of the selected variant. Sidebar with:
|
||||||
|
- Sliders/inputs for the selected variant's `dx`, `dy`, `scale` (adjustments to the image inside the component bounds — this is the main work)
|
||||||
|
- Drag handles on each anchor point — moving them stores per-variant overrides on top of `anchorsDefault`
|
||||||
|
- "Edit defaults" toggle: adjust `anchorsDefault` for ALL variants at once
|
||||||
|
- Save button → write changes back into the component's `.mjs` file
|
||||||
|
- Back button → return to matrix
|
||||||
|
|
||||||
|
### Save mechanism — Claude in the middle, no automated write-back
|
||||||
|
|
||||||
|
Following the project's orchestrator pattern: the editor produces data, the AI agent applies it. **No `/api/save-component` endpoint, no auto-rewrite of the source file.**
|
||||||
|
|
||||||
|
The component's `.mjs` file marks two sections that contain editor-tunable data:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @editor:anchors-start
|
||||||
|
static anchorsDefault = { ... };
|
||||||
|
// @editor:anchors-end
|
||||||
|
|
||||||
|
// @editor:variants-start
|
||||||
|
static variants = { ... };
|
||||||
|
// @editor:variants-end
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
1. User tunes in component-editor (matrix → single → adjusts dx/dy/scale and anchors)
|
||||||
|
2. User clicks **"Copy snapshot"** in the editor toolbar
|
||||||
|
3. Editor copies a JS source fragment to clipboard — the new `anchorsDefault = { ... };` and `variants = { ... };` blocks, formatted to drop in between the markers
|
||||||
|
4. User pastes into chat / asks Claude to apply
|
||||||
|
5. Claude opens the component `.mjs` file, replaces ONLY the content between `@editor:anchors-start/end` and `@editor:variants-start/end` markers using the Edit tool
|
||||||
|
6. Claude verifies: re-opens the editor, compares visual result against what the user tuned (screenshots of the matrix before/after), confirms nothing else in the file changed (`git diff`)
|
||||||
|
|
||||||
|
This intentionally avoids chained automation. The cost of a 30-second manual paste-and-edit is much lower than the cost of a write-back script silently corrupting handwritten code, and the agent's review pass catches mistakes the editor cannot see (markers gone, ordering changed, formatting drift).
|
||||||
|
|
||||||
|
If you need to add or rename a marker section, edit the `.mjs` file by hand.
|
||||||
|
|
||||||
|
### What lives in `src/components/editor/`
|
||||||
|
|
||||||
|
Reusable pieces extracted from the first concrete editor. Expected modules:
|
||||||
|
|
||||||
|
- `matrix-view.mjs` — given a component class, render the cartesian grid of preset combinations
|
||||||
|
- `single-view.mjs` — given a component instance + sidebar root, wire up controls for `dx`/`dy`/`scale` and anchor drag
|
||||||
|
- `serialize.mjs` — turns the editor's in-memory `anchorsDefault` and `variants` into a formatted JS source fragment ready to paste between markers. Used by the "Copy snapshot" button — entirely client-side, no server round-trip.
|
||||||
|
- `styles.css` — common styles (toolbar, sidebar, grid, drag handles)
|
||||||
|
|
||||||
|
Build these incrementally. Don't pre-create empty files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new component
|
||||||
|
|
||||||
|
1. Create `assets/themes/{theme}/components/{name}/{name}.mjs` from the skeleton above
|
||||||
|
2. Fill `origin`, `baseSize`, `presetProps`, `anchorsDefault`
|
||||||
|
3. Fill `variants` with rough defaults (any non-zero starting point — the editor refines them)
|
||||||
|
4. Create `{name}.editor.html` from the editor template (see `src/components/editor/`)
|
||||||
|
5. Open `http://localhost:3300/assets/themes/{theme}/components/{name}/{name}.editor.html`
|
||||||
|
6. Tune in matrix → single → "Copy snapshot" → ask Claude to paste between markers in `{name}.mjs`
|
||||||
|
7. Claude verifies the result matches the editor (re-open, compare screenshots) before considering the tune-pass done
|
||||||
|
8. Use the component in any document via `<{theme}-{name} x="..." y="..." {presetProps...}></{theme}-{name}>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new theme
|
||||||
|
|
||||||
|
1. Create `assets/themes/{theme}/loader.mjs` (copy from existing, change `PREFIX`)
|
||||||
|
2. Add components under `components/`
|
||||||
|
|
||||||
|
Nothing else changes — the base class and editor framework are theme-agnostic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document-side rules
|
||||||
|
|
||||||
|
**To use components in a document:**
|
||||||
|
|
||||||
|
1. Add loader once to the document `<head>`:
|
||||||
|
```html
|
||||||
|
<script type="module" src="/assets/themes/{theme}/loader.mjs"></script>
|
||||||
|
```
|
||||||
|
2. Place components anywhere in the body:
|
||||||
|
```html
|
||||||
|
<sonic-crystal x="120" y="80" shape="hex" color="blue"></sonic-crystal>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compatibility with doc-editor and generate.mjs:**
|
||||||
|
|
||||||
|
- Doc-editor moves components by their `x`/`y` attributes (or wraps them in style.left/top — TBD on first integration). Components are atomic; the doc-editor never touches their internals.
|
||||||
|
- generate.mjs treats the component tag as plain HTML and copies it through. No special handling.
|
||||||
|
- Puppeteer (PDF) needs to wait for component registration before snapshotting. `generate-pdf.mjs` should `waitForFunction(() => customElements.get('sonic-crystal'))` for each used tag, or `waitForNetworkIdle()` is usually enough.
|
||||||
|
|
||||||
|
**Compatibility with the existing "static HTML" rule:**
|
||||||
|
|
||||||
|
The project rule "OUTPUT MUST BE STATIC HTML — no embedded `<script>` that computes content at runtime" admits Tailwind CDN as an exception. Theme component loaders are a similar registration-only exception: they don't compute document content, they only define custom elements. Nothing in the document body is a `<script>` tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- Don't put theme-specific logic in `base.mjs`. The base class is shared across all themes.
|
||||||
|
- Don't hardcode child component types inside a parent component. Composition is the document's job.
|
||||||
|
- Don't fetch external JSON at runtime. Components are self-contained after tuning.
|
||||||
|
- Don't add a `<script>` per component to the document. Use the per-theme loader.
|
||||||
|
- Don't bake `x`/`y`/`scale`/`rotation` into the component file — those are per-instance attributes.
|
||||||
|
- Don't duplicate assets into `components/{name}/assets/`. Reference existing paths under `items/`.
|
||||||
|
- Don't add automated write-back from the editor to the source file. Editor produces a snapshot, Claude applies it manually between marker comments and verifies. This is the project's orchestrator pattern: scripts are tools, not autonomous actors.
|
||||||
|
- Don't touch code outside the marker-bracketed sections when applying an editor snapshot. Anchors and variants are the only data the editor produces; everything else is handwritten.
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* FunPenComponent — base class for FunPen themed Web Components.
|
||||||
|
*
|
||||||
|
* See src/components/CLAUDE.md for the full architecture and conventions.
|
||||||
|
*
|
||||||
|
* Subclass contract:
|
||||||
|
* static origin { x, y } — insertion point in component-local mm
|
||||||
|
* static baseSize { w, h } — natural size at scale=1, in mm
|
||||||
|
* static presetProps { name: [enum values] }
|
||||||
|
* static anchorsDefault { name: { x, y } } — local-mm
|
||||||
|
* static variants { variantKey: { img, dx, dy, scale, anchors } }
|
||||||
|
*
|
||||||
|
* Variant key is preset-prop values joined by '_' in declaration order.
|
||||||
|
*
|
||||||
|
* Standard observed attributes: x, y, scale, rotation. Plus every preset prop.
|
||||||
|
*
|
||||||
|
* Optional override:
|
||||||
|
* render(variant) — custom rendering. Default renders a single positioned image.
|
||||||
|
*/
|
||||||
|
export class FunPenComponent extends HTMLElement {
|
||||||
|
static origin = { x: 0, y: 0 };
|
||||||
|
static baseSize = { w: 0, h: 0 };
|
||||||
|
static presetProps = {};
|
||||||
|
static anchorsDefault = {};
|
||||||
|
static variants = {};
|
||||||
|
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ['x', 'y', 'scale', 'rotation', ...Object.keys(this.presetProps)];
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._applyTransform();
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name) {
|
||||||
|
if (!this.isConnected) return;
|
||||||
|
if (name === 'x' || name === 'y' || name === 'scale' || name === 'rotation') {
|
||||||
|
this._applyTransform();
|
||||||
|
} else {
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyTransform() {
|
||||||
|
const x = parseFloat(this.getAttribute('x')) || 0;
|
||||||
|
const y = parseFloat(this.getAttribute('y')) || 0;
|
||||||
|
const s = parseFloat(this.getAttribute('scale')) || 1;
|
||||||
|
const r = parseFloat(this.getAttribute('rotation')) || 0;
|
||||||
|
const o = this.constructor.origin;
|
||||||
|
this.style.position = 'absolute';
|
||||||
|
this.style.left = `${x}mm`;
|
||||||
|
this.style.top = `${y}mm`;
|
||||||
|
this.style.transformOrigin = '0 0';
|
||||||
|
this.style.transform = `rotate(${r}deg) scale(${s}) translate(${-o.x}mm, ${-o.y}mm)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
|
||||||
|
const variant = this._currentVariant();
|
||||||
|
if (!variant) {
|
||||||
|
this.shadowRoot.innerHTML = `<div style="color:red;font:8pt sans-serif;padding:2mm;">[${this.tagName.toLowerCase()}: variant '${this._variantKey()}' not found]</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.render(variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(variant) {
|
||||||
|
const { w, h } = this.constructor.baseSize;
|
||||||
|
const { img, dx = 0, dy = 0, scale = 1 } = variant;
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<img src="${img}" alt="">
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_variantKey() {
|
||||||
|
return Object.keys(this.constructor.presetProps)
|
||||||
|
.map(prop => this.getAttribute(prop) ?? this.constructor.presetProps[prop][0])
|
||||||
|
.join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentVariant() {
|
||||||
|
return this.constructor.variants[this._variantKey()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Public DOM API ----
|
||||||
|
|
||||||
|
getOrigin() {
|
||||||
|
return { ...this.constructor.origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anchor in component-local mm, with variant override merged onto defaults. */
|
||||||
|
getAnchor(name) {
|
||||||
|
const fromDefault = this.constructor.anchorsDefault[name];
|
||||||
|
if (!fromDefault) return null;
|
||||||
|
const override = this._currentVariant()?.anchors?.[name];
|
||||||
|
return { ...fromDefault, ...override };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anchor position in document mm, accounting for x/y/scale/rotation. */
|
||||||
|
getAnchorDocPosition(name) {
|
||||||
|
const anchor = this.getAnchor(name);
|
||||||
|
if (!anchor) return null;
|
||||||
|
const x = parseFloat(this.getAttribute('x')) || 0;
|
||||||
|
const y = parseFloat(this.getAttribute('y')) || 0;
|
||||||
|
const s = parseFloat(this.getAttribute('scale')) || 1;
|
||||||
|
const rad = ((parseFloat(this.getAttribute('rotation')) || 0) * Math.PI) / 180;
|
||||||
|
const o = this.constructor.origin;
|
||||||
|
const lx = (anchor.x - o.x) * s;
|
||||||
|
const ly = (anchor.y - o.y) * s;
|
||||||
|
const rx = lx * Math.cos(rad) - ly * Math.sin(rad);
|
||||||
|
const ry = lx * Math.sin(rad) + ly * Math.cos(rad);
|
||||||
|
return { x: x + rx, y: y + ry };
|
||||||
|
}
|
||||||
|
}
|
||||||