finite: conduite task
This commit is contained in:
@@ -9,5 +9,8 @@ tasks/*/docs/*.output.html
|
|||||||
# Editor temp files (diffs, screenshots, transient data)
|
# Editor temp files (diffs, screenshots, transient data)
|
||||||
tasks/*/temp/
|
tasks/*/temp/
|
||||||
|
|
||||||
|
# Component-editor snapshots (transient — Claude reads and applies)
|
||||||
|
assets/themes/*/components/*/*.snapshot.json
|
||||||
|
|
||||||
# PDFs are committed (available for download)
|
# PDFs are committed (available for download)
|
||||||
# output/pdf/ is NOT gitignored
|
# output/pdf/ is NOT gitignored
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Component Editor — sonic-conduit</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: #e5e5ea;
|
||||||
|
--text: #1c1c1e;
|
||||||
|
--muted: #8e8e93;
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--warn: #ff9500;
|
||||||
|
--checker: #c8c8c8;
|
||||||
|
}
|
||||||
|
* { 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.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;
|
||||||
|
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; cursor: pointer;
|
||||||
|
transition: transform 0.1s, border-color 0.1s; }
|
||||||
|
.cell:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||||
|
.cell .preview { position: relative; width: 60mm; height: 24mm;
|
||||||
|
display: flex; align-items: center; justify-content: center; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* ---- Single view ---- */
|
||||||
|
.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-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; }
|
||||||
|
.ctrl-row { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.ctrl-row > label { font-size: 11px; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.ctrl-row select { font: inherit; padding: 6px 8px;
|
||||||
|
border: 1px solid var(--line); border-radius: 6px;
|
||||||
|
background: white; }
|
||||||
|
.ctrl-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;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 28px 16px 16px;
|
||||||
|
min-height: 500px; max-height: 80vh; }
|
||||||
|
/* scene-zoom: explicit dimensions equal to scaled-visual size so the
|
||||||
|
parent's scrollable area matches what's actually rendered.
|
||||||
|
The actual `transform: scale()` is applied to .scene, not here. */
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Matrix cells: component centered visually */
|
||||||
|
.cell .preview sonic-conduit {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 50% !important;
|
||||||
|
top: 50% !important;
|
||||||
|
transform: translate(-50%, -50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single mode: component at fixed point near A4 top-center.
|
||||||
|
Editable instance is interactive; overlay instance is below it. */
|
||||||
|
.scene sonic-conduit {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 50% !important;
|
||||||
|
top: 50mm !important;
|
||||||
|
transform: translate(-50%, -50%) !important;
|
||||||
|
}
|
||||||
|
.scene sonic-conduit.editable {
|
||||||
|
cursor: grab; z-index: 4;
|
||||||
|
}
|
||||||
|
.scene sonic-conduit.editable:focus { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||||
|
.scene sonic-conduit.editable.dragging { cursor: grabbing; }
|
||||||
|
/* Overlay sits ABOVE the editable so its silhouette can be compared
|
||||||
|
on top. pointer-events: none lets clicks reach the editable below. */
|
||||||
|
.scene sonic-conduit.overlay-instance {
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor overlay: aligned to component center (same point) */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Guides — global, span the entire scene, draggable, scaled with zoom */
|
||||||
|
.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; }
|
||||||
|
/* Bigger hit area via pseudo-element */
|
||||||
|
.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; }
|
||||||
|
/* Slot handles: transparent with a small × so the image beneath
|
||||||
|
remains visible. Hit area is the 14px wrapper; visual is the cross. */
|
||||||
|
.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 { /* horizontal arm */
|
||||||
|
left: 25%; right: 25%; top: 50%; height: 1.5px; margin-top: -0.75px;
|
||||||
|
}
|
||||||
|
.anchor-handle::after { /* vertical arm */
|
||||||
|
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; }
|
||||||
|
|
||||||
|
#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="./conduit.mjs"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>sonic-conduit</h1>
|
||||||
|
<span class="meta" id="meta"></span>
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<button id="btn-save" title="Save snapshot to disk for Claude to apply">Save</button>
|
||||||
|
<button id="btn-copy" class="ghost" style="background: white; color: var(--text); border: 1px solid var(--line)" title="Copy snapshot to clipboard (manual fallback)">Copy</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="matrix-view"></div>
|
||||||
|
|
||||||
|
<div id="single-view" hidden>
|
||||||
|
<div class="single-toolbar">
|
||||||
|
<button class="ghost" id="btn-back">← Matrix</button>
|
||||||
|
<span>Variant: <code id="info-variant"></code></span>
|
||||||
|
<div class="group">
|
||||||
|
<label for="ctrl-zoom">Zoom</label>
|
||||||
|
<input type="range" id="ctrl-zoom" min="0.25" max="5" step="0.05" value="2">
|
||||||
|
<span class="num" id="zoom-label">200%</span>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="ctrl-grid">Grid</label>
|
||||||
|
<select id="ctrl-grid">
|
||||||
|
<option value="0">off</option>
|
||||||
|
<option value="2">2 cols</option>
|
||||||
|
<option value="3">3 cols</option>
|
||||||
|
<option value="4">4 cols</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label>Guides</label>
|
||||||
|
<button id="btn-guide-v">+ V</button>
|
||||||
|
<button id="btn-guide-h">+ H</button>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="ctrl-overlay-variant">Overlay</label>
|
||||||
|
<select id="ctrl-overlay-variant">
|
||||||
|
<option value="">none</option>
|
||||||
|
</select>
|
||||||
|
<input type="range" id="ctrl-overlay-opacity" min="0" max="1" step="0.05" value="0.5">
|
||||||
|
<span class="num" id="overlay-opacity-label">50%</span>
|
||||||
|
</div>
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<button id="btn-reset" title="Reset dx/dy/scale and clear anchor overrides for this variant">Reset variant</button>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="ctrl-edit-defaults">
|
||||||
|
Edit anchor defaults
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="draft-banner" id="draft-banner" hidden>
|
||||||
|
<span>Draft restored from local storage. Edits since last reload will be re-applied.</span>
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<button id="btn-discard-draft">Discard draft</button>
|
||||||
|
</div>
|
||||||
|
<div class="single-body">
|
||||||
|
<aside class="single-controls">
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<label for="ctrl-type">Type</label>
|
||||||
|
<select id="ctrl-type"></select>
|
||||||
|
</div>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<label for="ctrl-state">State</label>
|
||||||
|
<select id="ctrl-state"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-divider"></div>
|
||||||
|
<div class="ctrl-row"><label>Image position (per variant)</label></div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="name">DX</span>
|
||||||
|
<input type="range" id="ctrl-dx" min="-15" max="15" step="0.1" value="0">
|
||||||
|
<input type="number" id="ctrl-dx-num" min="-15" max="15" step="0.1" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="name">DY</span>
|
||||||
|
<input type="range" id="ctrl-dy" min="-15" max="15" step="0.1" value="0">
|
||||||
|
<input type="number" id="ctrl-dy-num" min="-15" max="15" step="0.1" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="name">Scale</span>
|
||||||
|
<input type="range" id="ctrl-scale" min="0.5" max="2" step="0.01" value="1">
|
||||||
|
<input type="number" id="ctrl-scale-num" min="0.5" max="2" step="0.01" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-divider"></div>
|
||||||
|
<div class="ctrl-row"><label>Anchors (10 slots) — drag handles on stage</label></div>
|
||||||
|
<div class="anchor-list" id="anchor-list"></div>
|
||||||
|
|
||||||
|
<div class="ctrl-info">
|
||||||
|
<div>baseSize: <code id="info-basesize"></code></div>
|
||||||
|
<div>image: <code id="info-img"></code></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="single-stage" id="single-stage">
|
||||||
|
<div class="scene-zoom" id="scene-zoom">
|
||||||
|
<span class="scene-label">A4 · 210 × 297 mm</span>
|
||||||
|
<div class="scene" id="scene">
|
||||||
|
<div class="scene-grid" id="scene-grid"></div>
|
||||||
|
<div class="anchor-overlay" id="anchor-overlay"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast">Snapshot copied</div>
|
||||||
|
<script type="module" src="./conduit.editor.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = `<div>${type} · ${state}</div>`;
|
||||||
|
|
||||||
|
const key = document.createElement('div');
|
||||||
|
key.className = 'key';
|
||||||
|
key.textContent = `${type}_${state}`;
|
||||||
|
|
||||||
|
cell.appendChild(preview);
|
||||||
|
cell.appendChild(label);
|
||||||
|
cell.appendChild(key);
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Single mode ----
|
||||||
|
|
||||||
|
function initSingle() {
|
||||||
|
fillSelect('ctrl-type', presetProps.type);
|
||||||
|
fillSelect('ctrl-state', presetProps.state);
|
||||||
|
|
||||||
|
document.getElementById('ctrl-type').addEventListener('change', e =>
|
||||||
|
setHashParams({ type: e.target.value })
|
||||||
|
);
|
||||||
|
document.getElementById('ctrl-state').addEventListener('change', e =>
|
||||||
|
setHashParams({ state: e.target.value })
|
||||||
|
);
|
||||||
|
document.getElementById('btn-back').addEventListener('click', () => {
|
||||||
|
location.hash = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// dx/dy/scale: bind range ↔ number, write through to live variant data
|
||||||
|
bindSlider('ctrl-dx', 'ctrl-dx-num', val => updateField('dx', val));
|
||||||
|
bindSlider('ctrl-dy', 'ctrl-dy-num', val => updateField('dy', val));
|
||||||
|
bindSlider('ctrl-scale', 'ctrl-scale-num', val => updateField('scale', val));
|
||||||
|
|
||||||
|
document.getElementById('ctrl-edit-defaults').addEventListener('change', () => {
|
||||||
|
refreshAnchors();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zoom: scale the entire scene
|
||||||
|
const zoom = document.getElementById('ctrl-zoom');
|
||||||
|
zoom.addEventListener('input', () => applyZoom(parseFloat(zoom.value)));
|
||||||
|
applyZoom(parseFloat(zoom.value));
|
||||||
|
|
||||||
|
// Grid columns
|
||||||
|
document.getElementById('ctrl-grid').addEventListener('change', e =>
|
||||||
|
setGridColumns(parseInt(e.target.value, 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset variant button
|
||||||
|
document.getElementById('btn-reset').addEventListener('click', resetVariant);
|
||||||
|
|
||||||
|
// Overlay select + opacity slider
|
||||||
|
const overlaySel = document.getElementById('ctrl-overlay-variant');
|
||||||
|
for (const key of Object.keys(Comp.variants)) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = key; o.textContent = key;
|
||||||
|
overlaySel.appendChild(o);
|
||||||
|
}
|
||||||
|
overlaySel.addEventListener('change', () => applyOverlay());
|
||||||
|
const overlayOpacity = document.getElementById('ctrl-overlay-opacity');
|
||||||
|
overlayOpacity.addEventListener('input', () => {
|
||||||
|
document.getElementById('overlay-opacity-label').textContent =
|
||||||
|
`${Math.round(parseFloat(overlayOpacity.value) * 100)}%`;
|
||||||
|
applyOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
document.getElementById('btn-guide-v').addEventListener('click', () => addGuide('v'));
|
||||||
|
document.getElementById('btn-guide-h').addEventListener('click', () => addGuide('h'));
|
||||||
|
|
||||||
|
// Restore any guides from draft
|
||||||
|
for (const g of pendingGuides) addGuide(g.axis, g.mm);
|
||||||
|
pendingGuides.length = 0;
|
||||||
|
|
||||||
|
buildAnchorRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVariant() {
|
||||||
|
const v = Comp.variants[currentKey()];
|
||||||
|
if (!v) return;
|
||||||
|
v.dx = 0; v.dy = 0; v.scale = 1; v.anchors = {};
|
||||||
|
setSlider('ctrl-dx', 0);
|
||||||
|
setSlider('ctrl-dy', 0);
|
||||||
|
setSlider('ctrl-scale', 1);
|
||||||
|
if (singleInstance) singleInstance._render();
|
||||||
|
refreshAnchors();
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom(z) {
|
||||||
|
// Apply scale transform to .scene only; size .scene-zoom explicitly to
|
||||||
|
// the post-scale visual dimensions so the parent's scroll area is correct.
|
||||||
|
document.getElementById('scene').style.transform = `scale(${z})`;
|
||||||
|
const sz = document.getElementById('scene-zoom');
|
||||||
|
sz.style.width = `${A4_W_MM * z}mm`;
|
||||||
|
sz.style.height = `${A4_H_MM * z}mm`;
|
||||||
|
document.getElementById('zoom-label').textContent = `${Math.round(z * 100)}%`;
|
||||||
|
scrollToComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToComponent() {
|
||||||
|
const stage = document.getElementById('single-stage');
|
||||||
|
const overlay = document.getElementById('anchor-overlay');
|
||||||
|
if (!stage || !overlay) return;
|
||||||
|
// Wait for layout to settle before scrolling
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const stageRect = stage.getBoundingClientRect();
|
||||||
|
const overlayRect = overlay.getBoundingClientRect();
|
||||||
|
const cx = overlayRect.left + overlayRect.width / 2;
|
||||||
|
const cy = overlayRect.top + overlayRect.height / 2;
|
||||||
|
const targetX = stageRect.left + stageRect.width / 2;
|
||||||
|
const targetY = stageRect.top + stageRect.height / 2;
|
||||||
|
stage.scrollLeft += cx - targetX;
|
||||||
|
stage.scrollTop += cy - targetY;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGridColumns(cols) {
|
||||||
|
const grid = document.getElementById('scene-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
if (!cols) return;
|
||||||
|
const A4_W = 210;
|
||||||
|
for (let i = 1; i < cols; i++) {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'grid-line';
|
||||||
|
line.style.left = `${(A4_W / cols) * i}mm`;
|
||||||
|
grid.appendChild(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSlider(rangeId, numId, onChange) {
|
||||||
|
const r = document.getElementById(rangeId);
|
||||||
|
const n = document.getElementById(numId);
|
||||||
|
r.addEventListener('input', () => { n.value = r.value; onChange(parseFloat(r.value)); });
|
||||||
|
n.addEventListener('input', () => { r.value = n.value; onChange(parseFloat(n.value)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(field, value) {
|
||||||
|
const key = currentKey();
|
||||||
|
const v = Comp.variants[key];
|
||||||
|
if (!v) return;
|
||||||
|
v[field] = value;
|
||||||
|
if (singleInstance) singleInstance._render();
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnchorRows() {
|
||||||
|
const list = document.getElementById('anchor-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const name of ANCHOR_NAMES) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'anchor-row';
|
||||||
|
row.innerHTML = `<span class="name">${name}</span>
|
||||||
|
<span class="coord" data-axis="x">—</span>
|
||||||
|
<span class="coord" data-axis="y">—</span>`;
|
||||||
|
row.addEventListener('click', () => focusAnchor(name));
|
||||||
|
list.appendChild(row);
|
||||||
|
anchorRows[name] = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySingle(params) {
|
||||||
|
if (!singleInstance) {
|
||||||
|
singleInstance = document.createElement('sonic-conduit');
|
||||||
|
singleInstance.classList.add('editable');
|
||||||
|
singleInstance.tabIndex = 0;
|
||||||
|
singleInstance.addEventListener('mousedown', startComponentDrag);
|
||||||
|
singleInstance.addEventListener('keydown', handleNudge);
|
||||||
|
document.getElementById('scene').appendChild(singleInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = params.get('type') ?? presetProps.type[0];
|
||||||
|
const state = params.get('state') ?? presetProps.state[0];
|
||||||
|
|
||||||
|
singleInstance.setAttribute('type', type);
|
||||||
|
singleInstance.setAttribute('state', state);
|
||||||
|
|
||||||
|
document.getElementById('ctrl-type').value = type;
|
||||||
|
document.getElementById('ctrl-state').value = state;
|
||||||
|
|
||||||
|
const key = `${type}_${state}`;
|
||||||
|
const v = Comp.variants[key] || {};
|
||||||
|
|
||||||
|
// Reflect current variant's dx/dy/scale on sliders
|
||||||
|
setSlider('ctrl-dx', v.dx ?? 0);
|
||||||
|
setSlider('ctrl-dy', v.dy ?? 0);
|
||||||
|
setSlider('ctrl-scale', v.scale ?? 1);
|
||||||
|
|
||||||
|
document.getElementById('info-variant').textContent = key;
|
||||||
|
document.getElementById('info-basesize').textContent = `${Comp.baseSize.w}×${Comp.baseSize.h}mm`;
|
||||||
|
document.getElementById('info-img').textContent = v.img ?? '—';
|
||||||
|
|
||||||
|
// Size the anchor overlay to match component baseSize
|
||||||
|
const overlay = document.getElementById('anchor-overlay');
|
||||||
|
overlay.style.width = `${Comp.baseSize.w}mm`;
|
||||||
|
overlay.style.height = `${Comp.baseSize.h}mm`;
|
||||||
|
|
||||||
|
refreshAnchors();
|
||||||
|
applyOverlay();
|
||||||
|
scrollToComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function measurePxPerMm() {
|
||||||
|
const overlay = document.getElementById('anchor-overlay');
|
||||||
|
const rect = overlay.getBoundingClientRect();
|
||||||
|
return rect.width / Comp.baseSize.w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSlider(rangeId, value) {
|
||||||
|
const r = document.getElementById(rangeId);
|
||||||
|
const n = document.getElementById(rangeId + '-num');
|
||||||
|
r.value = value;
|
||||||
|
n.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAnchors() {
|
||||||
|
const overlay = document.getElementById('anchor-overlay');
|
||||||
|
// Remove old handles + origin marker, rebuild
|
||||||
|
overlay.querySelectorAll('.anchor-handle, .origin-marker').forEach(h => h.remove());
|
||||||
|
|
||||||
|
const editDefaults = document.getElementById('ctrl-edit-defaults').checked;
|
||||||
|
overlay.classList.toggle('defaults-mode', editDefaults);
|
||||||
|
|
||||||
|
// Origin marker (×) — drag to change Comp.origin globally
|
||||||
|
const o = Comp.origin;
|
||||||
|
const origin = document.createElement('div');
|
||||||
|
origin.className = 'origin-marker';
|
||||||
|
origin.style.left = `${o.x}mm`;
|
||||||
|
origin.style.top = `${o.y}mm`;
|
||||||
|
origin.innerHTML = `<span class="lbl">origin (${o.x.toFixed(1)}, ${o.y.toFixed(1)})</span>`;
|
||||||
|
origin.addEventListener('mousedown', startOriginDrag);
|
||||||
|
overlay.appendChild(origin);
|
||||||
|
|
||||||
|
const key = currentKey();
|
||||||
|
const variant = Comp.variants[key];
|
||||||
|
if (!variant) return;
|
||||||
|
|
||||||
|
for (const name of ANCHOR_NAMES) {
|
||||||
|
const def = Comp.anchorsDefault[name];
|
||||||
|
const override = variant.anchors?.[name];
|
||||||
|
// In defaults-mode, handle shows DEFAULT position. Otherwise resolved (default + override).
|
||||||
|
const shown = editDefaults ? { ...def } : { ...def, ...(override || {}) };
|
||||||
|
const isOverridden = !!override;
|
||||||
|
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = 'anchor-handle' + (isOverridden ? ' overridden' : '');
|
||||||
|
handle.style.left = `${shown.x}mm`;
|
||||||
|
handle.style.top = `${shown.y}mm`;
|
||||||
|
handle.dataset.anchor = name;
|
||||||
|
handle.innerHTML = `<span class="lbl">${name} (${shown.x.toFixed(1)}, ${shown.y.toFixed(1)})</span>`;
|
||||||
|
handle.addEventListener('mousedown', startDrag);
|
||||||
|
handle.addEventListener('click', e => { e.stopPropagation(); focusAnchor(name); });
|
||||||
|
overlay.appendChild(handle);
|
||||||
|
anchorHandles[name] = handle;
|
||||||
|
|
||||||
|
// Update sidebar row (always shows resolved position for the variant)
|
||||||
|
const resolved = { ...def, ...(override || {}) };
|
||||||
|
const row = anchorRows[name];
|
||||||
|
if (row) {
|
||||||
|
row.classList.toggle('overridden', isOverridden);
|
||||||
|
row.querySelector('[data-axis=x]').textContent = resolved.x.toFixed(1);
|
||||||
|
row.querySelector('[data-axis=y]').textContent = resolved.y.toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAnchor(name) {
|
||||||
|
for (const [n, h] of Object.entries(anchorHandles)) h.classList.toggle('active', n === name);
|
||||||
|
for (const [n, r] of Object.entries(anchorRows)) r.classList.toggle('active', n === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const handle = e.currentTarget;
|
||||||
|
const name = handle.dataset.anchor;
|
||||||
|
focusAnchor(name);
|
||||||
|
handle.classList.add('dragging');
|
||||||
|
|
||||||
|
// Measure fresh — accounts for any zoom level applied to the scene
|
||||||
|
const pxPerMmLocal = measurePxPerMm();
|
||||||
|
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const editDefaults = document.getElementById('ctrl-edit-defaults').checked;
|
||||||
|
const startPos = editDefaults
|
||||||
|
? { ...Comp.anchorsDefault[name] }
|
||||||
|
: (() => {
|
||||||
|
const variant = Comp.variants[currentKey()];
|
||||||
|
const def = Comp.anchorsDefault[name];
|
||||||
|
const ovr = variant.anchors?.[name];
|
||||||
|
return { ...def, ...(ovr || {}) };
|
||||||
|
})();
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
const dxMm = (ev.clientX - startMouse.x) / pxPerMmLocal;
|
||||||
|
const dyMm = (ev.clientY - startMouse.y) / pxPerMmLocal;
|
||||||
|
const newPos = {
|
||||||
|
x: round1(startPos.x + dxMm),
|
||||||
|
y: round1(startPos.y + dyMm),
|
||||||
|
};
|
||||||
|
if (editDefaults) {
|
||||||
|
Comp.anchorsDefault[name] = newPos;
|
||||||
|
} else {
|
||||||
|
const variant = Comp.variants[currentKey()];
|
||||||
|
variant.anchors = variant.anchors || {};
|
||||||
|
variant.anchors[name] = newPos;
|
||||||
|
}
|
||||||
|
handle.style.left = `${newPos.x}mm`;
|
||||||
|
handle.style.top = `${newPos.y}mm`;
|
||||||
|
handle.querySelector('.lbl').textContent =
|
||||||
|
`${name} (${newPos.x.toFixed(1)}, ${newPos.y.toFixed(1)})`;
|
||||||
|
const row = anchorRows[name];
|
||||||
|
if (row) {
|
||||||
|
row.querySelector('[data-axis=x]').textContent = newPos.x.toFixed(1);
|
||||||
|
row.querySelector('[data-axis=y]').textContent = newPos.y.toFixed(1);
|
||||||
|
row.classList.toggle('overridden', !editDefaults);
|
||||||
|
}
|
||||||
|
if (!editDefaults) handle.classList.add('overridden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
handle.classList.remove('dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Drag the origin marker globally ----
|
||||||
|
|
||||||
|
function startOriginDrag(e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const handle = e.currentTarget;
|
||||||
|
handle.classList.add('dragging');
|
||||||
|
const ppm = measurePxPerMm();
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const start = { ...Comp.origin };
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
Comp.origin.x = round1(start.x + (ev.clientX - startMouse.x) / ppm);
|
||||||
|
Comp.origin.y = round1(start.y + (ev.clientY - startMouse.y) / ppm);
|
||||||
|
handle.style.left = `${Comp.origin.x}mm`;
|
||||||
|
handle.style.top = `${Comp.origin.y}mm`;
|
||||||
|
handle.querySelector('.lbl').textContent =
|
||||||
|
`origin (${Comp.origin.x.toFixed(1)}, ${Comp.origin.y.toFixed(1)})`;
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
handle.classList.remove('dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Drag the component itself for dx/dy ----
|
||||||
|
|
||||||
|
function startComponentDrag(e) {
|
||||||
|
// Don't hijack drags on anchor handles (they sit above and stop propagation in their own handler).
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
singleInstance.focus();
|
||||||
|
singleInstance.classList.add('dragging');
|
||||||
|
|
||||||
|
const ppm = measurePxPerMm();
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const v = Comp.variants[currentKey()];
|
||||||
|
const startDx = v.dx;
|
||||||
|
const startDy = v.dy;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
v.dx = round1(startDx + (ev.clientX - startMouse.x) / ppm);
|
||||||
|
v.dy = round1(startDy + (ev.clientY - startMouse.y) / ppm);
|
||||||
|
setSlider('ctrl-dx', v.dx);
|
||||||
|
setSlider('ctrl-dy', v.dy);
|
||||||
|
singleInstance._render();
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
singleInstance.classList.remove('dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNudge(e) {
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.shiftKey ? 1 : 0.1;
|
||||||
|
const v = Comp.variants[currentKey()];
|
||||||
|
if (e.key === 'ArrowLeft') v.dx = round1(v.dx - step);
|
||||||
|
if (e.key === 'ArrowRight') v.dx = round1(v.dx + step);
|
||||||
|
if (e.key === 'ArrowUp') v.dy = round1(v.dy - step);
|
||||||
|
if (e.key === 'ArrowDown') v.dy = round1(v.dy + step);
|
||||||
|
setSlider('ctrl-dx', v.dx);
|
||||||
|
setSlider('ctrl-dy', v.dy);
|
||||||
|
singleInstance._render();
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Overlay (compare) instance ----
|
||||||
|
|
||||||
|
function applyOverlay() {
|
||||||
|
const sel = document.getElementById('ctrl-overlay-variant');
|
||||||
|
const opacity = parseFloat(document.getElementById('ctrl-overlay-opacity').value);
|
||||||
|
const variantKey = sel.value;
|
||||||
|
if (!variantKey) {
|
||||||
|
if (overlayInstance) { overlayInstance.remove(); overlayInstance = null; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!overlayInstance) {
|
||||||
|
overlayInstance = document.createElement('sonic-conduit');
|
||||||
|
overlayInstance.classList.add('overlay-instance');
|
||||||
|
document.getElementById('scene').appendChild(overlayInstance);
|
||||||
|
}
|
||||||
|
// Variant key format: "type_state" (e.g. "lvl1_on" or "lvl1_off")
|
||||||
|
const idx = variantKey.indexOf('_');
|
||||||
|
const type = variantKey.slice(0, idx);
|
||||||
|
const state = variantKey.slice(idx + 1);
|
||||||
|
overlayInstance.setAttribute('type', type);
|
||||||
|
overlayInstance.setAttribute('state', state);
|
||||||
|
overlayInstance.style.opacity = String(opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Guides ----
|
||||||
|
|
||||||
|
function addGuide(axis, mm) {
|
||||||
|
if (mm === undefined) mm = axis === 'v' ? A4_W_MM / 2 : A4_H_MM / 4;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `guide guide-${axis}`;
|
||||||
|
if (axis === 'v') el.style.left = `${mm}mm`;
|
||||||
|
else el.style.top = `${mm}mm`;
|
||||||
|
|
||||||
|
const coord = document.createElement('span');
|
||||||
|
coord.className = 'guide-coord';
|
||||||
|
coord.textContent = `${mm.toFixed(1)}mm`;
|
||||||
|
el.appendChild(coord);
|
||||||
|
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.className = 'guide-remove';
|
||||||
|
remove.textContent = '×';
|
||||||
|
remove.addEventListener('click', e => { e.stopPropagation(); removeGuide(guide); });
|
||||||
|
el.appendChild(remove);
|
||||||
|
|
||||||
|
const guide = { axis, mm, el, coord };
|
||||||
|
el.addEventListener('mousedown', e => startGuideDrag(e, guide));
|
||||||
|
|
||||||
|
document.getElementById('scene').appendChild(el);
|
||||||
|
guides.push(guide);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGuide(guide) {
|
||||||
|
guide.el.remove();
|
||||||
|
const i = guides.indexOf(guide);
|
||||||
|
if (i >= 0) guides.splice(i, 1);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGuideDrag(e, guide) {
|
||||||
|
if (e.target.classList.contains('guide-remove')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const ppm = measurePxPerMm();
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const startMm = guide.mm;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
if (guide.axis === 'v') {
|
||||||
|
guide.mm = round1(startMm + (ev.clientX - startMouse.x) / ppm);
|
||||||
|
guide.el.style.left = `${guide.mm}mm`;
|
||||||
|
} else {
|
||||||
|
guide.mm = round1(startMm + (ev.clientY - startMouse.y) / ppm);
|
||||||
|
guide.el.style.top = `${guide.mm}mm`;
|
||||||
|
}
|
||||||
|
guide.coord.textContent = `${guide.mm.toFixed(1)}mm`;
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(v) { return Math.round(v * 10) / 10; }
|
||||||
|
|
||||||
|
function currentKey() {
|
||||||
|
const params = new URLSearchParams(location.hash.slice(1));
|
||||||
|
const type = params.get('type') ?? presetProps.type[0];
|
||||||
|
const state = params.get('state') ?? presetProps.state[0];
|
||||||
|
return `${type}_${state}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(id, options) {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
sel.innerHTML = '';
|
||||||
|
for (const opt of options) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = opt;
|
||||||
|
o.textContent = opt;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => toast.classList.remove('show'), 1500);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
--text: #1c1c1e;
|
--text: #1c1c1e;
|
||||||
--muted: #8e8e93;
|
--muted: #8e8e93;
|
||||||
--accent: #0a84ff;
|
--accent: #0a84ff;
|
||||||
--invalid: #fafafa;
|
--warn: #ff9500;
|
||||||
--checker: #c8c8c8;
|
--checker: #c8c8c8;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -25,13 +25,11 @@
|
|||||||
header button { font: inherit; padding: 6px 14px;
|
header button { font: inherit; padding: 6px 14px;
|
||||||
background: var(--accent); color: white;
|
background: var(--accent); color: white;
|
||||||
border: none; border-radius: 6px; cursor: pointer; }
|
border: none; border-radius: 6px; cursor: pointer; }
|
||||||
header button:hover { opacity: 0.9; }
|
|
||||||
header button.ghost { background: transparent; color: var(--text);
|
header button.ghost { background: transparent; color: var(--text);
|
||||||
border: 1px solid var(--line); }
|
border: 1px solid var(--line); }
|
||||||
main { padding: 20px; }
|
main { padding: 20px; }
|
||||||
|
|
||||||
/* ---- Matrix view ---- */
|
/* ---- Matrix view ---- */
|
||||||
|
|
||||||
section { margin-bottom: 32px; }
|
section { margin-bottom: 32px; }
|
||||||
section > h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600;
|
section > h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600;
|
||||||
color: var(--muted); text-transform: uppercase;
|
color: var(--muted); text-transform: uppercase;
|
||||||
@@ -41,7 +39,8 @@
|
|||||||
.cell { display: flex; flex-direction: column; align-items: center;
|
.cell { display: flex; flex-direction: column; align-items: center;
|
||||||
gap: 6px; padding: 12px; border: 1px solid var(--line);
|
gap: 6px; padding: 12px; border: 1px solid var(--line);
|
||||||
border-radius: 8px; background: white; min-height: 110px;
|
border-radius: 8px; background: white; min-height: 110px;
|
||||||
justify-content: center; 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:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||||
.cell .preview { position: relative; width: 24mm; height: 24mm;
|
.cell .preview { position: relative; width: 24mm; height: 24mm;
|
||||||
display: flex; align-items: center; justify-content: center; }
|
display: flex; align-items: center; justify-content: center; }
|
||||||
@@ -51,18 +50,42 @@
|
|||||||
font-size: 9px; color: var(--text); }
|
font-size: 9px; color: var(--text); }
|
||||||
|
|
||||||
/* ---- Single view ---- */
|
/* ---- Single view ---- */
|
||||||
|
.single-toolbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||||
.single-toolbar { display: flex; align-items: center; gap: 16px;
|
|
||||||
padding: 12px 16px; background: var(--panel);
|
padding: 12px 16px; background: var(--panel);
|
||||||
border: 1px solid var(--line); border-radius: 10px;
|
border: 1px solid var(--line); border-radius: 10px;
|
||||||
margin-bottom: 16px; }
|
margin-bottom: 16px; }
|
||||||
.single-toolbar code { background: var(--bg); padding: 2px 6px;
|
.single-toolbar code { background: var(--bg); padding: 2px 6px;
|
||||||
border-radius: 4px; font-size: 12px; }
|
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);
|
.single-controls { background: var(--panel); border: 1px solid var(--line);
|
||||||
border-radius: 10px; padding: 16px;
|
border-radius: 10px; padding: 16px;
|
||||||
display: flex; flex-direction: column; gap: 14px;
|
display: flex; flex-direction: column; gap: 14px; }
|
||||||
align-self: start; }
|
|
||||||
.ctrl-row { display: flex; flex-direction: column; gap: 4px; }
|
.ctrl-row { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.ctrl-row > label { font-size: 11px; color: var(--muted);
|
.ctrl-row > label { font-size: 11px; color: var(--muted);
|
||||||
text-transform: uppercase; letter-spacing: 0.5px; }
|
text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
@@ -74,18 +97,49 @@
|
|||||||
.ctrl-row.checkbox label { font-size: 13px; color: var(--text);
|
.ctrl-row.checkbox label { font-size: 13px; color: var(--text);
|
||||||
text-transform: none; letter-spacing: 0; }
|
text-transform: none; letter-spacing: 0; }
|
||||||
.ctrl-row.checkbox input[disabled] + label { color: var(--muted); }
|
.ctrl-row.checkbox input[disabled] + label { color: var(--muted); }
|
||||||
.ctrl-info { margin-top: 12px; padding-top: 12px;
|
.ctrl-divider { border-top: 1px solid var(--line); margin: 4px 0; }
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
.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); }
|
font-size: 11px; color: var(--muted); }
|
||||||
.ctrl-info code { font-family: ui-monospace, Menlo, monospace;
|
.ctrl-info code { font-family: ui-monospace, Menlo, monospace;
|
||||||
font-size: 10px; color: var(--text);
|
font-size: 10px; color: var(--text);
|
||||||
word-break: break-all; }
|
word-break: break-all; }
|
||||||
|
|
||||||
.single-stage { background: var(--panel); border: 1px solid var(--line);
|
.single-stage { background: var(--panel); border: 1px solid var(--line);
|
||||||
border-radius: 10px; padding: 24px;
|
border-radius: 10px;
|
||||||
display: flex; align-items: center; justify-content: center; }
|
overflow: auto;
|
||||||
.single-frame { position: relative;
|
padding: 28px 16px 16px;
|
||||||
width: 60mm; height: 60mm;
|
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-color: white;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(45deg, var(--checker) 25%, transparent 25%),
|
linear-gradient(45deg, var(--checker) 25%, transparent 25%),
|
||||||
@@ -94,18 +148,117 @@
|
|||||||
linear-gradient(-45deg, transparent 75%, var(--checker) 75%);
|
linear-gradient(-45deg, transparent 75%, var(--checker) 75%);
|
||||||
background-size: 4mm 4mm;
|
background-size: 4mm 4mm;
|
||||||
background-position: 0 0, 0 2mm, 2mm -2mm, -2mm 0;
|
background-position: 0 0, 0 2mm, 2mm -2mm, -2mm 0;
|
||||||
border: 1px solid var(--line); border-radius: 4px; }
|
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 ---- */
|
/* Matrix cells: component centered visually */
|
||||||
|
.cell .preview sonic-diamond {
|
||||||
.cell .preview sonic-diamond,
|
|
||||||
.single-frame sonic-diamond {
|
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
left: 50% !important;
|
left: 50% !important;
|
||||||
top: 50% !important;
|
top: 50% !important;
|
||||||
transform: translate(-50%, -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%;
|
#toast { position: fixed; bottom: 20px; left: 50%;
|
||||||
transform: translateX(-50%); padding: 10px 20px;
|
transform: translateX(-50%); padding: 10px 20px;
|
||||||
background: var(--text); color: white; border-radius: 8px;
|
background: var(--text); color: white; border-radius: 8px;
|
||||||
@@ -120,7 +273,8 @@
|
|||||||
<h1>sonic-diamond</h1>
|
<h1>sonic-diamond</h1>
|
||||||
<span class="meta" id="meta"></span>
|
<span class="meta" id="meta"></span>
|
||||||
<span style="flex: 1"></span>
|
<span style="flex: 1"></span>
|
||||||
<button id="btn-copy">Copy snapshot</button>
|
<button id="btn-save" title="Save snapshot to disk for Claude to apply">Save</button>
|
||||||
|
<button id="btn-copy" class="ghost" style="background: white; color: var(--text); border: 1px solid var(--line)" title="Copy snapshot to clipboard">Copy</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -130,6 +284,44 @@
|
|||||||
<div class="single-toolbar">
|
<div class="single-toolbar">
|
||||||
<button class="ghost" id="btn-back">← Matrix</button>
|
<button class="ghost" id="btn-back">← Matrix</button>
|
||||||
<span>Variant: <code id="info-variant"></code></span>
|
<span>Variant: <code id="info-variant"></code></span>
|
||||||
|
<div class="group">
|
||||||
|
<label for="ctrl-zoom">Zoom</label>
|
||||||
|
<input type="range" id="ctrl-zoom" min="0.5" max="10" step="0.05" value="5">
|
||||||
|
<span class="num" id="zoom-label">500%</span>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="ctrl-grid">Grid</label>
|
||||||
|
<select id="ctrl-grid">
|
||||||
|
<option value="0">off</option>
|
||||||
|
<option value="2">2 cols</option>
|
||||||
|
<option value="3">3 cols</option>
|
||||||
|
<option value="4">4 cols</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label>Guides</label>
|
||||||
|
<button id="btn-guide-v">+ V</button>
|
||||||
|
<button id="btn-guide-h">+ H</button>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<label for="ctrl-overlay-variant">Overlay</label>
|
||||||
|
<select id="ctrl-overlay-variant">
|
||||||
|
<option value="">none</option>
|
||||||
|
</select>
|
||||||
|
<input type="range" id="ctrl-overlay-opacity" min="0" max="1" step="0.05" value="0.5">
|
||||||
|
<span class="num" id="overlay-opacity-label">50%</span>
|
||||||
|
</div>
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<button id="btn-reset" title="Reset dx/dy/scale and clear anchor overrides for this variant">Reset variant</button>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="ctrl-edit-defaults">
|
||||||
|
Edit anchor defaults
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="draft-banner" id="draft-banner" hidden>
|
||||||
|
<span>Draft restored from local storage. Edits since last reload will be re-applied.</span>
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<button id="btn-discard-draft">Discard draft</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="single-body">
|
<div class="single-body">
|
||||||
<aside class="single-controls">
|
<aside class="single-controls">
|
||||||
@@ -145,14 +337,43 @@
|
|||||||
<input type="checkbox" id="ctrl-chipped">
|
<input type="checkbox" id="ctrl-chipped">
|
||||||
<label for="ctrl-chipped" id="ctrl-chipped-label">Chipped</label>
|
<label for="ctrl-chipped" id="ctrl-chipped-label">Chipped</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-divider"></div>
|
||||||
|
<div class="ctrl-row"><label>Image position (per variant)</label></div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="name">DX</span>
|
||||||
|
<input type="range" id="ctrl-dx" min="-10" max="10" step="0.1" value="0">
|
||||||
|
<input type="number" id="ctrl-dx-num" min="-10" max="10" step="0.1" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="name">DY</span>
|
||||||
|
<input type="range" id="ctrl-dy" min="-10" max="10" step="0.1" value="0">
|
||||||
|
<input type="number" id="ctrl-dy-num" min="-10" max="10" step="0.1" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="name">Scale</span>
|
||||||
|
<input type="range" id="ctrl-scale" min="0.5" max="2" step="0.01" value="1">
|
||||||
|
<input type="number" id="ctrl-scale-num" min="0.5" max="2" step="0.01" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-divider"></div>
|
||||||
|
<div class="ctrl-row"><label id="anchors-label">Anchors</label></div>
|
||||||
|
<div class="anchor-list" id="anchor-list"></div>
|
||||||
|
|
||||||
<div class="ctrl-info">
|
<div class="ctrl-info">
|
||||||
<div>baseSize: <code id="info-basesize"></code></div>
|
<div>baseSize: <code id="info-basesize"></code></div>
|
||||||
<div>origin: <code id="info-origin"></code></div>
|
<div>origin: <code id="info-origin"></code></div>
|
||||||
<div>image: <code id="info-img"></code></div>
|
<div>image: <code id="info-img"></code></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="single-stage">
|
<main class="single-stage" id="single-stage">
|
||||||
<div class="single-frame" id="single-frame"></div>
|
<div class="scene-zoom" id="scene-zoom">
|
||||||
|
<span class="scene-label">A4 · 210 × 297 mm</span>
|
||||||
|
<div class="scene" id="scene">
|
||||||
|
<div class="scene-grid" id="scene-grid"></div>
|
||||||
|
<div class="anchor-overlay" id="anchor-overlay"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Component-editor for sonic-diamond.
|
* Component-editor for sonic-diamond.
|
||||||
*
|
*
|
||||||
* Modes:
|
* Matrix: two grids — Full (5×6) and Chipped (2×6). Click cell → Single mode.
|
||||||
* - Matrix (default): grid of all valid preset combinations. Click → Single.
|
* Single: Shape/Color dropdowns + Chipped checkbox (auto-disabled for shapes
|
||||||
* - Single: one component instance with dropdown/checkbox controls and
|
* that don't support chipping). Sliders for dx/dy/scale, draggable origin,
|
||||||
* a checker-pattern preview frame. Diamond has no per-variant tuning,
|
* draggable anchors (none for diamond), guides, overlay, A4 scene.
|
||||||
* so this mode here only demonstrates the prop-switching flow.
|
|
||||||
*
|
*
|
||||||
* Mode is driven by the URL hash:
|
* Save: POST /api/save-component-snapshot writes JSON next to the component.
|
||||||
* '' → Matrix
|
* Claude reads the JSON and edits the marker-bracketed sections of .mjs.
|
||||||
* 'shape=...&color=...&chipped=...' → Single
|
|
||||||
*
|
|
||||||
* "Copy snapshot" works in either mode and produces JS source ready to
|
|
||||||
* paste between `@editor:anchors-*` and `@editor:variants-*` markers.
|
|
||||||
*/
|
*/
|
||||||
await customElements.whenDefined('sonic-diamond');
|
await customElements.whenDefined('sonic-diamond');
|
||||||
|
|
||||||
const Comp = customElements.get('sonic-diamond');
|
const Comp = customElements.get('sonic-diamond');
|
||||||
const { presetProps, chippedShapes } = Comp;
|
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 =
|
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();
|
initMatrix();
|
||||||
initSingle();
|
initSingle();
|
||||||
@@ -28,11 +42,65 @@ window.addEventListener('hashchange', route);
|
|||||||
route();
|
route();
|
||||||
|
|
||||||
document.getElementById('btn-copy').addEventListener('click', async () => {
|
document.getElementById('btn-copy').addEventListener('click', async () => {
|
||||||
const text = serializeSnapshot(Comp.anchorsDefault, Comp.variants);
|
await navigator.clipboard.writeText(JSON.stringify(snapshotData(), null, 2));
|
||||||
await navigator.clipboard.writeText(text);
|
showToast('Snapshot JSON copied');
|
||||||
showToast('Snapshot 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 ----
|
// ---- Routing ----
|
||||||
|
|
||||||
function route() {
|
function route() {
|
||||||
@@ -121,8 +189,6 @@ function makeCell(shape, color, chipped) {
|
|||||||
|
|
||||||
// ---- Single mode ----
|
// ---- Single mode ----
|
||||||
|
|
||||||
let singleInstance = null;
|
|
||||||
|
|
||||||
function initSingle() {
|
function initSingle() {
|
||||||
fillSelect('ctrl-shape', presetProps.shape);
|
fillSelect('ctrl-shape', presetProps.shape);
|
||||||
fillSelect('ctrl-color', presetProps.color);
|
fillSelect('ctrl-color', presetProps.color);
|
||||||
@@ -139,12 +205,142 @@ function initSingle() {
|
|||||||
document.getElementById('btn-back').addEventListener('click', () => {
|
document.getElementById('btn-back').addEventListener('click', () => {
|
||||||
location.hash = '';
|
location.hash = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bindSlider('ctrl-dx', 'ctrl-dx-num', val => updateField('dx', val));
|
||||||
|
bindSlider('ctrl-dy', 'ctrl-dy-num', val => updateField('dy', val));
|
||||||
|
bindSlider('ctrl-scale', 'ctrl-scale-num', val => updateField('scale', val));
|
||||||
|
|
||||||
|
document.getElementById('ctrl-edit-defaults').addEventListener('change', () => {
|
||||||
|
refreshAnchors();
|
||||||
|
});
|
||||||
|
|
||||||
|
const zoom = document.getElementById('ctrl-zoom');
|
||||||
|
zoom.addEventListener('input', () => applyZoom(parseFloat(zoom.value)));
|
||||||
|
applyZoom(parseFloat(zoom.value));
|
||||||
|
|
||||||
|
document.getElementById('ctrl-grid').addEventListener('change', e =>
|
||||||
|
setGridColumns(parseInt(e.target.value, 10))
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementById('btn-reset').addEventListener('click', resetVariant);
|
||||||
|
|
||||||
|
const overlaySel = document.getElementById('ctrl-overlay-variant');
|
||||||
|
for (const key of Object.keys(Comp.variants)) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = key; o.textContent = key;
|
||||||
|
overlaySel.appendChild(o);
|
||||||
|
}
|
||||||
|
overlaySel.addEventListener('change', () => applyOverlay());
|
||||||
|
const overlayOpacity = document.getElementById('ctrl-overlay-opacity');
|
||||||
|
overlayOpacity.addEventListener('input', () => {
|
||||||
|
document.getElementById('overlay-opacity-label').textContent =
|
||||||
|
`${Math.round(parseFloat(overlayOpacity.value) * 100)}%`;
|
||||||
|
applyOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-guide-v').addEventListener('click', () => addGuide('v'));
|
||||||
|
document.getElementById('btn-guide-h').addEventListener('click', () => addGuide('h'));
|
||||||
|
|
||||||
|
for (const g of pendingGuides) addGuide(g.axis, g.mm);
|
||||||
|
pendingGuides.length = 0;
|
||||||
|
|
||||||
|
buildAnchorRows();
|
||||||
|
if (ANCHOR_NAMES.length === 0) {
|
||||||
|
document.getElementById('anchors-label').textContent = 'Anchors — (none defined)';
|
||||||
|
} else {
|
||||||
|
document.getElementById('anchors-label').textContent =
|
||||||
|
`Anchors (${ANCHOR_NAMES.length}) — drag handles on stage`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSlider(rangeId, numId, onChange) {
|
||||||
|
const r = document.getElementById(rangeId);
|
||||||
|
const n = document.getElementById(numId);
|
||||||
|
r.addEventListener('input', () => { n.value = r.value; onChange(parseFloat(r.value)); });
|
||||||
|
n.addEventListener('input', () => { r.value = n.value; onChange(parseFloat(n.value)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(field, value) {
|
||||||
|
const key = currentKey();
|
||||||
|
const v = Comp.variants[key];
|
||||||
|
if (!v) return;
|
||||||
|
v[field] = value;
|
||||||
|
if (singleInstance) singleInstance._render();
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVariant() {
|
||||||
|
const v = Comp.variants[currentKey()];
|
||||||
|
if (!v) return;
|
||||||
|
v.dx = 0; v.dy = 0; v.scale = 1; v.anchors = {};
|
||||||
|
setSlider('ctrl-dx', 0);
|
||||||
|
setSlider('ctrl-dy', 0);
|
||||||
|
setSlider('ctrl-scale', 1);
|
||||||
|
if (singleInstance) singleInstance._render();
|
||||||
|
refreshAnchors();
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom(z) {
|
||||||
|
document.getElementById('scene').style.transform = `scale(${z})`;
|
||||||
|
const sz = document.getElementById('scene-zoom');
|
||||||
|
sz.style.width = `${A4_W_MM * z}mm`;
|
||||||
|
sz.style.height = `${A4_H_MM * z}mm`;
|
||||||
|
document.getElementById('zoom-label').textContent = `${Math.round(z * 100)}%`;
|
||||||
|
scrollToComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToComponent() {
|
||||||
|
const stage = document.getElementById('single-stage');
|
||||||
|
const overlay = document.getElementById('anchor-overlay');
|
||||||
|
if (!stage || !overlay) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const stageRect = stage.getBoundingClientRect();
|
||||||
|
const overlayRect = overlay.getBoundingClientRect();
|
||||||
|
const cx = overlayRect.left + overlayRect.width / 2;
|
||||||
|
const cy = overlayRect.top + overlayRect.height / 2;
|
||||||
|
const targetX = stageRect.left + stageRect.width / 2;
|
||||||
|
const targetY = stageRect.top + stageRect.height / 2;
|
||||||
|
stage.scrollLeft += cx - targetX;
|
||||||
|
stage.scrollTop += cy - targetY;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGridColumns(cols) {
|
||||||
|
const grid = document.getElementById('scene-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
if (!cols) return;
|
||||||
|
for (let i = 1; i < cols; i++) {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'grid-line';
|
||||||
|
line.style.left = `${(A4_W_MM / cols) * i}mm`;
|
||||||
|
grid.appendChild(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnchorRows() {
|
||||||
|
const list = document.getElementById('anchor-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const name of ANCHOR_NAMES) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'anchor-row';
|
||||||
|
row.innerHTML = `<span class="name">${name}</span>
|
||||||
|
<span class="coord" data-axis="x">—</span>
|
||||||
|
<span class="coord" data-axis="y">—</span>`;
|
||||||
|
row.addEventListener('click', () => focusAnchor(name));
|
||||||
|
list.appendChild(row);
|
||||||
|
anchorRows[name] = row;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySingle(params) {
|
function applySingle(params) {
|
||||||
if (!singleInstance) {
|
if (!singleInstance) {
|
||||||
singleInstance = document.createElement('sonic-diamond');
|
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];
|
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)';
|
cbLabel.textContent = supportsChipped ? 'Chipped' : 'Chipped (n/a for this shape)';
|
||||||
|
|
||||||
const key = singleInstance._variantKey();
|
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-variant').textContent = key;
|
||||||
document.getElementById('info-basesize').textContent = `${Comp.baseSize.w}×${Comp.baseSize.h}mm`;
|
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-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 = `<span class="lbl">origin (${o.x.toFixed(1)}, ${o.y.toFixed(1)})</span>`;
|
||||||
|
origin.addEventListener('mousedown', startOriginDrag);
|
||||||
|
overlay.appendChild(origin);
|
||||||
|
|
||||||
|
const key = currentKey();
|
||||||
|
const variant = Comp.variants[key];
|
||||||
|
if (!variant) return;
|
||||||
|
|
||||||
|
for (const name of ANCHOR_NAMES) {
|
||||||
|
const def = Comp.anchorsDefault[name];
|
||||||
|
const override = variant.anchors?.[name];
|
||||||
|
const shown = editDefaults ? { ...def } : { ...def, ...(override || {}) };
|
||||||
|
const isOverridden = !!override;
|
||||||
|
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = 'anchor-handle' + (isOverridden ? ' overridden' : '');
|
||||||
|
handle.style.left = `${shown.x}mm`;
|
||||||
|
handle.style.top = `${shown.y}mm`;
|
||||||
|
handle.dataset.anchor = name;
|
||||||
|
handle.innerHTML = `<span class="lbl">${name} (${shown.x.toFixed(1)}, ${shown.y.toFixed(1)})</span>`;
|
||||||
|
handle.addEventListener('mousedown', startDrag);
|
||||||
|
handle.addEventListener('click', e => { e.stopPropagation(); focusAnchor(name); });
|
||||||
|
overlay.appendChild(handle);
|
||||||
|
anchorHandles[name] = handle;
|
||||||
|
|
||||||
|
const resolved = { ...def, ...(override || {}) };
|
||||||
|
const row = anchorRows[name];
|
||||||
|
if (row) {
|
||||||
|
row.classList.toggle('overridden', isOverridden);
|
||||||
|
row.querySelector('[data-axis=x]').textContent = resolved.x.toFixed(1);
|
||||||
|
row.querySelector('[data-axis=y]').textContent = resolved.y.toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAnchor(name) {
|
||||||
|
for (const [n, h] of Object.entries(anchorHandles)) h.classList.toggle('active', n === name);
|
||||||
|
for (const [n, r] of Object.entries(anchorRows)) r.classList.toggle('active', n === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const handle = e.currentTarget;
|
||||||
|
const name = handle.dataset.anchor;
|
||||||
|
focusAnchor(name);
|
||||||
|
handle.classList.add('dragging');
|
||||||
|
|
||||||
|
const ppm = measurePxPerMm();
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const editDefaults = document.getElementById('ctrl-edit-defaults').checked;
|
||||||
|
const startPos = editDefaults
|
||||||
|
? { ...Comp.anchorsDefault[name] }
|
||||||
|
: (() => {
|
||||||
|
const variant = Comp.variants[currentKey()];
|
||||||
|
const def = Comp.anchorsDefault[name];
|
||||||
|
const ovr = variant.anchors?.[name];
|
||||||
|
return { ...def, ...(ovr || {}) };
|
||||||
|
})();
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
const newPos = {
|
||||||
|
x: round1(startPos.x + (ev.clientX - startMouse.x) / ppm),
|
||||||
|
y: round1(startPos.y + (ev.clientY - startMouse.y) / ppm),
|
||||||
|
};
|
||||||
|
if (editDefaults) {
|
||||||
|
Comp.anchorsDefault[name] = newPos;
|
||||||
|
} else {
|
||||||
|
const variant = Comp.variants[currentKey()];
|
||||||
|
variant.anchors = variant.anchors || {};
|
||||||
|
variant.anchors[name] = newPos;
|
||||||
|
}
|
||||||
|
handle.style.left = `${newPos.x}mm`;
|
||||||
|
handle.style.top = `${newPos.y}mm`;
|
||||||
|
handle.querySelector('.lbl').textContent =
|
||||||
|
`${name} (${newPos.x.toFixed(1)}, ${newPos.y.toFixed(1)})`;
|
||||||
|
const row = anchorRows[name];
|
||||||
|
if (row) {
|
||||||
|
row.querySelector('[data-axis=x]').textContent = newPos.x.toFixed(1);
|
||||||
|
row.querySelector('[data-axis=y]').textContent = newPos.y.toFixed(1);
|
||||||
|
row.classList.toggle('overridden', !editDefaults);
|
||||||
|
}
|
||||||
|
if (!editDefaults) handle.classList.add('overridden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
handle.classList.remove('dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOriginDrag(e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const handle = e.currentTarget;
|
||||||
|
handle.classList.add('dragging');
|
||||||
|
const ppm = measurePxPerMm();
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const start = { ...Comp.origin };
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
Comp.origin.x = round1(start.x + (ev.clientX - startMouse.x) / ppm);
|
||||||
|
Comp.origin.y = round1(start.y + (ev.clientY - startMouse.y) / ppm);
|
||||||
|
handle.style.left = `${Comp.origin.x}mm`;
|
||||||
|
handle.style.top = `${Comp.origin.y}mm`;
|
||||||
|
handle.querySelector('.lbl').textContent =
|
||||||
|
`origin (${Comp.origin.x.toFixed(1)}, ${Comp.origin.y.toFixed(1)})`;
|
||||||
|
document.getElementById('info-origin').textContent =
|
||||||
|
`(${Comp.origin.x}, ${Comp.origin.y})`;
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
handle.classList.remove('dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startComponentDrag(e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
singleInstance.focus();
|
||||||
|
singleInstance.classList.add('dragging');
|
||||||
|
|
||||||
|
const ppm = measurePxPerMm();
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const v = Comp.variants[currentKey()];
|
||||||
|
if (!v) return;
|
||||||
|
const startDx = v.dx;
|
||||||
|
const startDy = v.dy;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
v.dx = round1(startDx + (ev.clientX - startMouse.x) / ppm);
|
||||||
|
v.dy = round1(startDy + (ev.clientY - startMouse.y) / ppm);
|
||||||
|
setSlider('ctrl-dx', v.dx);
|
||||||
|
setSlider('ctrl-dy', v.dy);
|
||||||
|
singleInstance._render();
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
singleInstance.classList.remove('dragging');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNudge(e) {
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.shiftKey ? 1 : 0.1;
|
||||||
|
const v = Comp.variants[currentKey()];
|
||||||
|
if (!v) return;
|
||||||
|
if (e.key === 'ArrowLeft') v.dx = round1(v.dx - step);
|
||||||
|
if (e.key === 'ArrowRight') v.dx = round1(v.dx + step);
|
||||||
|
if (e.key === 'ArrowUp') v.dy = round1(v.dy - step);
|
||||||
|
if (e.key === 'ArrowDown') v.dy = round1(v.dy + step);
|
||||||
|
setSlider('ctrl-dx', v.dx);
|
||||||
|
setSlider('ctrl-dy', v.dy);
|
||||||
|
singleInstance._render();
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOverlay() {
|
||||||
|
const sel = document.getElementById('ctrl-overlay-variant');
|
||||||
|
const opacity = parseFloat(document.getElementById('ctrl-overlay-opacity').value);
|
||||||
|
const variantKey = sel.value;
|
||||||
|
if (!variantKey) {
|
||||||
|
if (overlayInstance) { overlayInstance.remove(); overlayInstance = null; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!overlayInstance) {
|
||||||
|
overlayInstance = document.createElement('sonic-diamond');
|
||||||
|
overlayInstance.classList.add('overlay-instance');
|
||||||
|
document.getElementById('scene').appendChild(overlayInstance);
|
||||||
|
}
|
||||||
|
// Decompose key like "classic_blue" or "classic_blue_chipped"
|
||||||
|
const parts = variantKey.split('_');
|
||||||
|
let shape = parts[0];
|
||||||
|
let color = parts[1];
|
||||||
|
let chipped = parts[2] === 'chipped';
|
||||||
|
overlayInstance.setAttribute('shape', shape);
|
||||||
|
overlayInstance.setAttribute('color', color);
|
||||||
|
overlayInstance.setAttribute('chipped', String(chipped));
|
||||||
|
overlayInstance.style.opacity = String(opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGuide(axis, mm) {
|
||||||
|
if (mm === undefined) mm = axis === 'v' ? A4_W_MM / 2 : A4_H_MM / 4;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `guide guide-${axis}`;
|
||||||
|
if (axis === 'v') el.style.left = `${mm}mm`;
|
||||||
|
else el.style.top = `${mm}mm`;
|
||||||
|
|
||||||
|
const coord = document.createElement('span');
|
||||||
|
coord.className = 'guide-coord';
|
||||||
|
coord.textContent = `${mm.toFixed(1)}mm`;
|
||||||
|
el.appendChild(coord);
|
||||||
|
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.className = 'guide-remove';
|
||||||
|
remove.textContent = '×';
|
||||||
|
remove.addEventListener('click', e => { e.stopPropagation(); removeGuide(guide); });
|
||||||
|
el.appendChild(remove);
|
||||||
|
|
||||||
|
const guide = { axis, mm, el, coord };
|
||||||
|
el.addEventListener('mousedown', e => startGuideDrag(e, guide));
|
||||||
|
|
||||||
|
document.getElementById('scene').appendChild(el);
|
||||||
|
guides.push(guide);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGuide(guide) {
|
||||||
|
guide.el.remove();
|
||||||
|
const i = guides.indexOf(guide);
|
||||||
|
if (i >= 0) guides.splice(i, 1);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGuideDrag(e, guide) {
|
||||||
|
if (e.target.classList.contains('guide-remove')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const ppm = measurePxPerMm();
|
||||||
|
const startMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
const startMm = guide.mm;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
if (guide.axis === 'v') {
|
||||||
|
guide.mm = round1(startMm + (ev.clientX - startMouse.x) / ppm);
|
||||||
|
guide.el.style.left = `${guide.mm}mm`;
|
||||||
|
} else {
|
||||||
|
guide.mm = round1(startMm + (ev.clientY - startMouse.y) / ppm);
|
||||||
|
guide.el.style.top = `${guide.mm}mm`;
|
||||||
|
}
|
||||||
|
guide.coord.textContent = `${guide.mm.toFixed(1)}mm`;
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(v) { return Math.round(v * 10) / 10; }
|
||||||
|
|
||||||
|
function currentKey() {
|
||||||
|
if (!singleInstance) {
|
||||||
|
const params = new URLSearchParams(location.hash.slice(1));
|
||||||
|
const shape = params.get('shape') ?? presetProps.shape[0];
|
||||||
|
const color = params.get('color') ?? presetProps.color[0];
|
||||||
|
const chipped = params.get('chipped') === 'true';
|
||||||
|
const supportsChipped = chippedShapes.has(shape);
|
||||||
|
return supportsChipped && chipped ? `${shape}_${color}_chipped` : `${shape}_${color}`;
|
||||||
|
}
|
||||||
|
return singleInstance._variantKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillSelect(id, options) {
|
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) {
|
function showToast(msg) {
|
||||||
const toast = document.getElementById('toast');
|
const toast = document.getElementById('toast');
|
||||||
toast.textContent = msg;
|
toast.textContent = msg;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { FunPenComponent } from '/src/components/base.mjs';
|
import { FunPenComponent } from '/src/components/base.mjs';
|
||||||
|
|
||||||
class SonicDiamond extends FunPenComponent {
|
class SonicDiamond extends FunPenComponent {
|
||||||
|
// @editor:origin-start
|
||||||
static origin = { x: 5, y: 5 };
|
static origin = { x: 5, y: 5 };
|
||||||
|
// @editor:origin-end
|
||||||
static baseSize = { w: 10, h: 10 };
|
static baseSize = { w: 10, h: 10 };
|
||||||
|
|
||||||
static presetProps = {
|
static presetProps = {
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ module.exports = {
|
|||||||
"assets/themes/**/*",
|
"assets/themes/**/*",
|
||||||
"public/**/*"
|
"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,
|
port: 3300,
|
||||||
open: false,
|
open: false,
|
||||||
notify: false,
|
notify: false,
|
||||||
@@ -34,6 +48,46 @@ module.exports = {
|
|||||||
next();
|
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",
|
route: "/api/save-edits",
|
||||||
handle: function (req, res, next) {
|
handle: function (req, res, next) {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -73,10 +73,11 @@ export class FunPenComponent extends HTMLElement {
|
|||||||
:host { display: block; width: ${w}mm; height: ${h}mm; }
|
:host { display: block; width: ${w}mm; height: ${h}mm; }
|
||||||
img {
|
img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: ${dx}mm;
|
width: ${w}mm;
|
||||||
top: ${dy}mm;
|
left: 50%; top: 50%;
|
||||||
width: ${w * scale}mm;
|
/* Image is centered in component box; dx/dy offset its center;
|
||||||
height: ${h * scale}mm;
|
scale grows it from its center (transform-origin defaults to 50%). */
|
||||||
|
transform: translate(-50%, -50%) translate(${dx}mm, ${dy}mm) scale(${scale});
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<img src="${img}" alt="">
|
<img src="${img}" alt="">
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const PROJECT_ROOT = resolve(__dirname, '../..');
|
|||||||
const OUTPUT_DIR = resolve(PROJECT_ROOT, 'output/pdf');
|
const OUTPUT_DIR = resolve(PROJECT_ROOT, 'output/pdf');
|
||||||
|
|
||||||
const MIME_TYPES = {
|
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',
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.json': 'application/json',
|
'.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 });
|
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({
|
await page.pdf({
|
||||||
path: pdfPath,
|
path: pdfPath,
|
||||||
format: 'A4',
|
format: 'A4',
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const PROJECT_ROOT = resolve(__dirname, '../..');
|
const PROJECT_ROOT = resolve(__dirname, '../..');
|
||||||
|
|
||||||
const MIME_TYPES = {
|
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',
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.json': 'application/json',
|
'.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 });
|
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
|
// Find all A4 page elements
|
||||||
const pageHandles = await page.$$('.w-\\[210mm\\]');
|
const pageHandles = await page.$$('.w-\\[210mm\\]');
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user