You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/index.html

1282 lines
46 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leaflet Components Demo</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 20px;
font-family: sans-serif;
}
.two-pane {
display: flex;
gap: 16px;
margin-bottom: 16px;
height: 600px;
}
.pane {
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pane-header {
padding: 10px 14px;
font-weight: 600;
font-size: 13px;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
}
.pane-body {
flex: 1;
overflow-y: auto;
padding: 10px 14px;
}
#tree-pane {
width: 280px;
min-width: 200px;
}
#editor-pane {
flex: 1;
min-width: 300px;
}
/* ── Tree styles ── */
.tree-node {
margin: 2px 0;
}
.tree-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
font-family: monospace;
font-size: 13px;
}
.tree-row:hover {
background: #e8e8e8;
}
.tree-row.selected {
background: #dbeafe;
font-weight: 600;
}
.tree-row .tag {
color: #2563eb;
}
.tree-children {
margin-left: 18px;
border-left: 1px solid #ddd;
padding-left: 4px;
}
.tree-row .remove-btn {
margin-left: auto;
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
}
.tree-row .remove-btn:hover {
color: #dc2626;
}
.tree-row .add-child-btn {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
margin: 0 0 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree-row .add-child-btn:hover {
color: #22c55e;
}
.add-child-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 4px 10px;
font-size: 12px;
border: 1px dashed #aaa;
border-radius: 4px;
background: none;
color: #666;
cursor: pointer;
}
.add-child-btn:hover {
border-color: #3b82f6;
color: #2563eb;
}
/* ── Editor styles ── */
.editor-empty {
color: #999;
font-style: italic;
font-size: 13px;
}
.editor-section {
margin-bottom: 14px;
}
.editor-section h4 {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
}
.editor-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.editor-row label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #555;
min-width: 120px;
}
.editor-row label:has(input[type="checkbox"]) {
font-family: monospace;
font-size: 12px;
min-width: auto;
}
.editor-row input[type="number"],
.editor-row input[type="text"] {
flex: 1;
max-width: 200px;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
}
.editor-row input:focus {
outline: none;
border-color: #66f;
}
.editor-row input[type="checkbox"] {
width: 16px;
height: 16px;
}
.pick-btn {
padding: 4px 8px;
font-size: 11px;
border: 1px solid #22c55e;
border-radius: 4px;
background: #f0fdf4;
color: #16a34a;
cursor: pointer;
white-space: nowrap;
}
.pick-btn:hover {
background: #dcfce7;
}
.pick-btn.picking {
background: #16a34a;
color: white;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ── Add-child modal ── */
.add-child-form {
margin-top: 10px;
padding: 10px;
background: #fafafa;
border: 1px solid #ddd;
border-radius: 6px;
}
.add-child-form h5 {
margin: 0 0 8px;
font-size: 12px;
color: #555;
}
.add-child-form select,
.add-child-form input {
width: 100%;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
margin-bottom: 6px;
}
.add-child-form .form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.add-child-form button {
padding: 6px 14px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
border: 1px solid #ccc;
background: white;
}
.add-child-form button.primary {
background: #2563eb;
color: white;
border-color: #2563eb;
}
.add-child-form button.primary:hover {
background: #1d4ed8;
}
.add-child-form button.cancel {
background: #f5f5f5;
}
leaflet-map {
height: 500px;
border: 1px solid #ccc;
display: block;
}
</style>
</head>
<body>
<h1>Leaflet Web Components</h1>
<div class="two-pane">
<!-- LEFT PANE: Component tree -->
<div class="pane" id="tree-pane">
<div class="pane-header">Component Tree</div>
<div class="pane-body" id="tree-root"></div>
</div>
<!-- RIGHT PANE: Attribute editor -->
<div class="pane" id="editor-pane">
<div class="pane-header">Attributes</div>
<div class="pane-body" id="editor-root">
<p class="editor-empty">Select a component in the tree to edit its attributes.</p>
</div>
</div>
</div>
<leaflet-map id="map" lat="51.505" lng="-0.09" zoom="13">
<leaflet-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="&copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
>
</leaflet-tile-layer>
<leaflet-marker lat="51.505" lng="-0.09" title="A Marker">
<leaflet-popup><b>Hello world!</b><br />I am a popup.</leaflet-popup>
<leaflet-tooltip>I am a tooltip</leaflet-tooltip>
</leaflet-marker>
<leaflet-circle
lat="51.508"
lng="-0.11"
color="red"
fill-color="#f03"
fill-opacity="0.5"
radius="500"
>
<leaflet-popup>I am a circle.</leaflet-popup>
</leaflet-circle>
<leaflet-polygon color="blue" fill-color="cyan" fill-opacity="0.3">
<leaflet-line lat="51.509" lng="-0.08"></leaflet-line>
<leaflet-line lat="51.503" lng="-0.06"></leaflet-line>
<leaflet-line lat="51.51" lng="-0.047"></leaflet-line>
<leaflet-popup>I am a polygon.</leaflet-popup>
</leaflet-polygon>
<leaflet-feature-group>
<leaflet-marker lat="51.513" lng="-0.07" title="Feature Group Marker">
<leaflet-tooltip>Part of a feature group</leaflet-tooltip>
</leaflet-marker>
<leaflet-circle lat="51.513" lng="-0.07" radius="100" color="purple" fill-color="purple" fill-opacity="0.2">
</leaflet-circle>
</leaflet-feature-group>
<leaflet-geojson
color="#e67e22"
weight="3"
fill-color="#f39c12"
fill-opacity="0.4"
data='{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-0.13,51.50],[-0.12,51.50],[-0.12,51.51],[-0.13,51.51],[-0.13,51.50]]]},"properties":{}}'
></leaflet-geojson>
<leaflet-control-scale position="bottomleft"></leaflet-control-scale>
</leaflet-map>
<script type="importmap">
{
"imports": {
"leaflet": "/node_modules/leaflet/dist/leaflet-src.esm.js"
}
}
</script>
<script type="module" src="/dist/index.js"></script>
<script type="module">
// ── Component metadata ──────────────────────────────────────────────────
// Defines valid child types for each parent and attributes for each component.
const CHILDREN_OF = {
'leaflet-map': [
'leaflet-tile-layer', 'leaflet-tile-layer-wms', 'leaflet-marker',
'leaflet-circle', 'leaflet-circle-marker', 'leaflet-polygon',
'leaflet-polyline', 'leaflet-rectangle', 'leaflet-geojson',
'leaflet-image-overlay', 'leaflet-video-overlay', 'leaflet-svg-overlay',
'leaflet-layer-group', 'leaflet-feature-group', 'leaflet-control-zoom',
'leaflet-control-scale', 'leaflet-control-attribution',
],
'leaflet-layer-group': [
'leaflet-marker', 'leaflet-circle', 'leaflet-circle-marker',
'leaflet-polygon', 'leaflet-polyline', 'leaflet-rectangle',
'leaflet-geojson', 'leaflet-image-overlay', 'leaflet-video-overlay',
'leaflet-svg-overlay', 'leaflet-layer-group', 'leaflet-feature-group',
],
'leaflet-feature-group': [
'leaflet-marker', 'leaflet-circle', 'leaflet-circle-marker',
'leaflet-polygon', 'leaflet-polyline', 'leaflet-rectangle',
],
'leaflet-polygon': ['leaflet-line', 'leaflet-popup'],
'leaflet-polyline': ['leaflet-line', 'leaflet-popup'],
'leaflet-rectangle': ['leaflet-popup'],
'leaflet-marker': ['leaflet-popup', 'leaflet-tooltip'],
'leaflet-circle': ['leaflet-popup', 'leaflet-tooltip'],
'leaflet-circle-marker': ['leaflet-popup', 'leaflet-tooltip'],
'leaflet-image-overlay': ['leaflet-popup', 'leaflet-tooltip'],
'leaflet-video-overlay': ['leaflet-popup', 'leaflet-tooltip'],
'leaflet-geojson': ['leaflet-popup', 'leaflet-tooltip'],
};
const TAG_GROUPS = {
'leaflet-map': [
{ label: 'Tile Layers', tags: ['leaflet-tile-layer', 'leaflet-tile-layer-wms'] },
{ label: 'Markers', tags: ['leaflet-marker'] },
{ label: 'Shapes', tags: ['leaflet-circle', 'leaflet-circle-marker', 'leaflet-polygon', 'leaflet-polyline', 'leaflet-rectangle'] },
{ label: 'Data', tags: ['leaflet-geojson'] },
{ label: 'Overlays', tags: ['leaflet-image-overlay', 'leaflet-video-overlay', 'leaflet-svg-overlay'] },
{ label: 'Groups', tags: ['leaflet-layer-group', 'leaflet-feature-group'] },
{ label: 'Controls', tags: ['leaflet-control-zoom', 'leaflet-control-scale', 'leaflet-control-attribution'] },
],
};
const ATTRS = {
'leaflet-map': {
View: [
{ attr: 'lat', kind: 'num', step: 'any' },
{ attr: 'lng', kind: 'num', step: 'any' },
{ attr: 'zoom', kind: 'num', step: '1', min: '0', max: '22' },
{ attr: 'min-zoom', kind: 'num', min: '0', max: '22' },
{ attr: 'max-zoom', kind: 'num', min: '0', max: '22' },
],
Toggles: [
{ attr: 'prefer-canvas', kind: 'bool' },
{ attr: 'world-copy-jump', kind: 'bool' },
{ attr: 'disable-scroll-wheel-zoom', kind: 'bool' },
{ attr: 'disable-dragging', kind: 'bool' },
{ attr: 'disable-touch-zoom', kind: 'bool' },
{ attr: 'disable-double-click-zoom', kind: 'bool' },
{ attr: 'disable-box-zoom', kind: 'bool' },
{ attr: 'disable-keyboard', kind: 'bool' },
{ attr: 'disable-tap-hold', kind: 'bool' },
{ attr: 'disable-inertia', kind: 'bool' },
{ attr: 'disable-close-popup-on-click', kind: 'bool' },
{ attr: 'disable-track-resize', kind: 'bool' },
{ attr: 'disable-zoom-control', kind: 'bool' },
{ attr: 'disable-attribution-control', kind: 'bool' },
{ attr: 'disable-zoom-animation', kind: 'bool' },
{ attr: 'disable-fade-animation', kind: 'bool' },
{ attr: 'disable-marker-zoom-animation', kind: 'bool' },
{ attr: 'disable-bounce-at-zoom-limits', kind: 'bool' },
],
Animation: [
{ attr: 'zoom-animation-threshold', kind: 'num' },
],
'Inertia & panning': [
{ attr: 'inertia-deceleration', kind: 'num' },
{ attr: 'inertia-max-speed', kind: 'num' },
{ attr: 'ease-linearity', kind: 'num', step: 'any' },
],
'Zoom behaviour': [
{ attr: 'zoom-snap', kind: 'num', step: 'any' },
{ attr: 'zoom-delta', kind: 'num', step: 'any' },
{ attr: 'max-bounds-viscosity', kind: 'num', step: 'any', min: '0', max: '1' },
],
'Scroll wheel': [
{ attr: 'wheel-debounce-time', kind: 'num' },
{ attr: 'wheel-px-per-zoom-level', kind: 'num' },
],
'Keyboard & touch': [
{ attr: 'keyboard-pan-delta', kind: 'num' },
{ attr: 'tap-tolerance', kind: 'num' },
],
Rendering: [
{ attr: 'transform-3d-limit', kind: 'num' },
],
CSS: [
{ attr: 'css-url', kind: 'text' },
{ attr: 'css-integrity', kind: 'text' },
{ attr: 'css-crossorigin', kind: 'text' },
],
},
'leaflet-tile-layer': {
Required: [
{ attr: 'url', kind: 'text' },
],
Options: [
{ attr: 'attribution', kind: 'text' },
{ attr: 'max-zoom', kind: 'num', min: '0', max: '22' },
{ attr: 'min-zoom', kind: 'num', min: '0', max: '22' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'tile-size', kind: 'num', min: '0' },
{ attr: 'z-index', kind: 'num' },
{ attr: 'bounds', kind: 'text' },
{ attr: 'cross-origin', kind: 'bool' },
],
},
'leaflet-tile-layer-wms': {
Required: [
{ attr: 'url', kind: 'text' },
{ attr: 'layers', kind: 'text' },
],
Options: [
{ attr: 'attribution', kind: 'text' },
{ attr: 'styles', kind: 'text' },
{ attr: 'format', kind: 'text' },
{ attr: 'transparent', kind: 'bool' },
{ attr: 'version', kind: 'text' },
{ attr: 'uppercase', kind: 'bool' },
{ attr: 'max-zoom', kind: 'num', min: '0', max: '22' },
{ attr: 'min-zoom', kind: 'num', min: '0', max: '22' },
{ attr: 'z-index', kind: 'num' },
],
},
'leaflet-marker': {
Position: [
{ attr: 'lat', kind: 'num', step: 'any' },
{ attr: 'lng', kind: 'num', step: 'any' },
],
Options: [
{ attr: 'title', kind: 'text' },
{ attr: 'alt', kind: 'text' },
{ attr: 'draggable', kind: 'bool' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'z-index-offset', kind: 'num' },
],
},
'leaflet-circle': {
Position: [
{ attr: 'lat', kind: 'num', step: 'any' },
{ attr: 'lng', kind: 'num', step: 'any' },
],
Options: [
{ attr: 'radius', kind: 'num', min: '0' },
{ attr: 'color', kind: 'text' },
{ attr: 'weight', kind: 'num', min: '0' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'line-cap', kind: 'text' },
{ attr: 'line-join', kind: 'text' },
{ attr: 'dash-array', kind: 'text' },
{ attr: 'dash-offset', kind: 'text' },
{ attr: 'fill', kind: 'bool' },
{ attr: 'fill-color', kind: 'text' },
{ attr: 'fill-opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'fill-rule', kind: 'text' },
{ attr: 'bubbling-mouse-events', kind: 'bool' },
],
},
'leaflet-circle-marker': {
Position: [
{ attr: 'lat', kind: 'num', step: 'any' },
{ attr: 'lng', kind: 'num', step: 'any' },
],
Options: [
{ attr: 'radius', kind: 'num', min: '0' },
{ attr: 'color', kind: 'text' },
{ attr: 'weight', kind: 'num', min: '0' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'line-cap', kind: 'text' },
{ attr: 'line-join', kind: 'text' },
{ attr: 'dash-array', kind: 'text' },
{ attr: 'dash-offset', kind: 'text' },
{ attr: 'fill', kind: 'bool' },
{ attr: 'fill-color', kind: 'text' },
{ attr: 'fill-opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'fill-rule', kind: 'text' },
],
},
'leaflet-polygon': {
Options: [
{ attr: 'color', kind: 'text' },
{ attr: 'weight', kind: 'num', min: '0' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'line-cap', kind: 'text' },
{ attr: 'line-join', kind: 'text' },
{ attr: 'dash-array', kind: 'text' },
{ attr: 'dash-offset', kind: 'text' },
{ attr: 'fill', kind: 'bool' },
{ attr: 'fill-color', kind: 'text' },
{ attr: 'fill-opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'fill-rule', kind: 'text' },
],
},
'leaflet-polyline': {
Options: [
{ attr: 'color', kind: 'text' },
{ attr: 'weight', kind: 'num', min: '0' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'line-cap', kind: 'text' },
{ attr: 'line-join', kind: 'text' },
{ attr: 'dash-array', kind: 'text' },
{ attr: 'dash-offset', kind: 'text' },
],
},
'leaflet-line': {
Position: [
{ attr: 'lat', kind: 'num', step: 'any' },
{ attr: 'lng', kind: 'num', step: 'any' },
],
},
'leaflet-rectangle': {
Options: [
{ attr: 'bounds', kind: 'text' },
{ attr: 'color', kind: 'text' },
{ attr: 'weight', kind: 'num', min: '0' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'line-cap', kind: 'text' },
{ attr: 'line-join', kind: 'text' },
{ attr: 'dash-array', kind: 'text' },
{ attr: 'dash-offset', kind: 'text' },
{ attr: 'fill', kind: 'bool' },
{ attr: 'fill-color', kind: 'text' },
{ attr: 'fill-opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'fill-rule', kind: 'text' },
],
},
'leaflet-geojson': {
Data: [
{ attr: 'data', kind: 'text' },
],
Style: [
{ attr: 'stroke', kind: 'text' },
{ attr: 'color', kind: 'text' },
{ attr: 'weight', kind: 'num', min: '0' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'line-cap', kind: 'text' },
{ attr: 'line-join', kind: 'text' },
{ attr: 'dash-array', kind: 'text' },
{ attr: 'dash-offset', kind: 'text' },
{ attr: 'fill', kind: 'bool' },
{ attr: 'fill-color', kind: 'text' },
{ attr: 'fill-opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'fill-rule', kind: 'text' },
],
},
'leaflet-image-overlay': {
Required: [
{ attr: 'url', kind: 'text' },
{ attr: 'bounds', kind: 'text' },
],
Options: [
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'alt', kind: 'text' },
{ attr: 'interactive', kind: 'bool' },
{ attr: 'cross-origin', kind: 'text' },
{ attr: 'error-overlay-url', kind: 'text' },
{ attr: 'z-index', kind: 'num' },
{ attr: 'class-name', kind: 'text' },
{ attr: 'attribution', kind: 'text' },
],
},
'leaflet-video-overlay': {
Required: [
{ attr: 'url', kind: 'text' },
{ attr: 'bounds', kind: 'text' },
],
Options: [
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'alt', kind: 'text' },
{ attr: 'interactive', kind: 'bool' },
{ attr: 'cross-origin', kind: 'text' },
{ attr: 'loop', kind: 'bool' },
{ attr: 'autoplay', kind: 'bool' },
{ attr: 'muted', kind: 'bool' },
{ attr: 'playsinline', kind: 'bool' },
{ attr: 'attribution', kind: 'text' },
],
},
'leaflet-svg-overlay': {
Options: [
{ attr: 'bounds', kind: 'text' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
{ attr: 'interactive', kind: 'bool' },
{ attr: 'cross-origin', kind: 'text' },
{ attr: 'z-index', kind: 'num' },
{ attr: 'class-name', kind: 'text' },
],
},
'leaflet-layer-group': {},
'leaflet-feature-group': {},
'leaflet-popup': {
Position: [
{ attr: 'lat', kind: 'num', step: 'any' },
{ attr: 'lng', kind: 'num', step: 'any' },
],
Options: [
{ attr: 'max-width', kind: 'num' },
{ attr: 'min-width', kind: 'num' },
{ attr: 'max-height', kind: 'num' },
{ attr: 'auto-pan', kind: 'bool' },
{ attr: 'close-button', kind: 'bool' },
{ attr: 'auto-close', kind: 'bool' },
],
},
'leaflet-tooltip': {
Position: [
{ attr: 'lat', kind: 'num', step: 'any' },
{ attr: 'lng', kind: 'num', step: 'any' },
],
Options: [
{ attr: 'pane', kind: 'text' },
{ attr: 'offset', kind: 'text' },
{ attr: 'direction', kind: 'text' },
{ attr: 'permanent', kind: 'bool' },
{ attr: 'sticky', kind: 'bool' },
{ attr: 'opacity', kind: 'num', step: 'any', min: '0', max: '1' },
],
},
'leaflet-control-zoom': {
Options: [
{ attr: 'position', kind: 'text' },
{ attr: 'zoom-in-text', kind: 'text' },
{ attr: 'zoom-in-title', kind: 'text' },
{ attr: 'zoom-out-text', kind: 'text' },
{ attr: 'zoom-out-title', kind: 'text' },
],
},
'leaflet-control-scale': {
Options: [
{ attr: 'position', kind: 'text' },
{ attr: 'max-width', kind: 'num' },
{ attr: 'metric', kind: 'bool' },
{ attr: 'imperial', kind: 'bool' },
{ attr: 'update-when-idle', kind: 'bool' },
],
},
'leaflet-control-attribution': {
Options: [
{ attr: 'position', kind: 'text' },
{ attr: 'prefix', kind: 'text' },
],
},
};
// ── State ────────────────────────────────────────────────────────────────
let selectedEl = null;
let addingToEl = null;
const observer = new MutationObserver(rebuildTree);
const elMap = new WeakMap(); // node -> tree DOM node
// ── Tree rendering ──────────────────────────────────────────────────────
function tagName(el) {
return el.tagName.toLowerCase();
}
function buildTree(parentEl, container) {
for (const child of parentEl.children) {
if (!child.tagName.startsWith('LEAFLET-')) continue;
const node = document.createElement('div');
node.className = 'tree-node';
const row = document.createElement('div');
row.className = 'tree-row' + (child === selectedEl ? ' selected' : '');
row.dataset.el = child;
const tag = document.createElement('span');
tag.className = 'tag';
tag.textContent = tagName(child);
row.appendChild(tag);
// Add-child button (before remove-btn for non-map, after tag for map)
const validChildren = CHILDREN_OF[tagName(child)];
if (validChildren && validChildren.length > 0) {
const addBtn = document.createElement('button');
addBtn.className = 'add-child-btn';
addBtn.textContent = '+';
addBtn.title = 'Add child';
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
startAddChild(child);
});
row.appendChild(addBtn);
}
// Remove button (not for root map)
if (tagName(child) !== 'leaflet-map') {
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.textContent = '\u00d7';
removeBtn.title = 'Remove';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
child.remove();
});
row.appendChild(removeBtn);
}
row.addEventListener('click', (e) => {
e.stopPropagation();
selectElement(child);
});
node.appendChild(row);
// Children
const childContainer = document.createElement('div');
childContainer.className = 'tree-children';
buildTree(child, childContainer);
if (childContainer.childNodes.length > 0) {
node.appendChild(childContainer);
}
container.appendChild(node);
}
}
function rebuildTree() {
const treeRoot = document.getElementById('tree-root');
treeRoot.innerHTML = '';
const mapEl = document.getElementById('map');
if (!mapEl) return;
// Map row
const mapNode = document.createElement('div');
mapNode.className = 'tree-node';
const mapRow = document.createElement('div');
mapRow.className = 'tree-row' + (selectedEl === mapEl ? ' selected' : '');
mapRow.dataset.el = mapEl;
const mapTag = document.createElement('span');
mapTag.className = 'tag';
mapTag.textContent = 'leaflet-map';
mapRow.appendChild(mapTag);
// Add-child button
const mapAddBtn = document.createElement('button');
mapAddBtn.className = 'add-child-btn';
mapAddBtn.textContent = '+';
mapAddBtn.title = 'Add child';
mapAddBtn.addEventListener('click', (e) => {
e.stopPropagation();
startAddChild(mapEl);
});
mapRow.appendChild(mapAddBtn);
mapRow.addEventListener('click', (e) => {
e.stopPropagation();
selectElement(mapEl);
});
mapNode.appendChild(mapRow);
const childContainer = document.createElement('div');
childContainer.className = 'tree-children';
buildTree(mapEl, childContainer);
mapNode.appendChild(childContainer);
treeRoot.appendChild(mapNode);
}
// ── Element selection ───────────────────────────────────────────────────
function selectElement(el) {
selectedEl = el;
rebuildTree();
renderEditor(el);
}
// ── Editor rendering ────────────────────────────────────────────────────
function renderEditor(el) {
const editorRoot = document.getElementById('editor-root');
editorRoot.innerHTML = '';
if (!el) {
editorRoot.innerHTML = '<p class="editor-empty">Select a component in the tree to edit its attributes.</p>';
return;
}
const tag = tagName(el);
const attrDefs = ATTRS[tag] || {};
// Component name header
const header = document.createElement('div');
header.style.marginBottom = '12px';
const tagSpan = document.createElement('span');
tagSpan.style.fontFamily = 'monospace';
tagSpan.style.fontSize = '14px';
tagSpan.style.color = '#2563eb';
tagSpan.style.fontWeight = '600';
tagSpan.textContent = tag;
header.appendChild(tagSpan);
if (el.id) {
const idSpan = document.createElement('span');
idSpan.style.marginLeft = '8px';
idSpan.style.fontSize = '12px';
idSpan.style.color = '#888';
idSpan.textContent = `#${el.id}`;
header.appendChild(idSpan);
}
editorRoot.appendChild(header);
// Render attribute sections
for (const [section, attrs] of Object.entries(attrDefs)) {
if (attrs.length === 0) continue;
const sectionDiv = document.createElement('div');
sectionDiv.className = 'editor-section';
const h4 = document.createElement('h4');
h4.textContent = section;
sectionDiv.appendChild(h4);
for (const attrDef of attrs) {
const row = document.createElement('div');
row.className = 'editor-row';
const label = document.createElement('label');
label.textContent = attrDef.attr;
if (attrDef.kind === 'bool') {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = el.hasAttribute(attrDef.attr);
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
el.setAttribute(attrDef.attr, '');
} else {
el.removeAttribute(attrDef.attr);
}
});
// Sync from DOM
const syncObs = new MutationObserver(() => {
checkbox.checked = el.hasAttribute(attrDef.attr);
});
syncObs.observe(el, { attributes: true, attributeFilter: [attrDef.attr] });
label.prepend(checkbox);
row.appendChild(label);
} else if (attrDef.kind === 'num') {
const input = document.createElement('input');
input.type = 'number';
input.value = el.getAttribute(attrDef.attr) ?? '';
if (attrDef.step) input.step = attrDef.step;
if (attrDef.min) input.min = attrDef.min;
if (attrDef.max) input.max = attrDef.max;
input.placeholder = attrDef.attr;
input.addEventListener('input', () => {
if (input.value === '') {
el.removeAttribute(attrDef.attr);
} else {
el.setAttribute(attrDef.attr, input.value);
}
});
// Sync from DOM
const syncObs = new MutationObserver(() => {
input.value = el.getAttribute(attrDef.attr) ?? '';
});
syncObs.observe(el, { attributes: true, attributeFilter: [attrDef.attr] });
row.appendChild(label);
row.appendChild(input);
} else {
const input = document.createElement('input');
input.type = 'text';
input.value = el.getAttribute(attrDef.attr) ?? '';
input.placeholder = attrDef.attr;
input.addEventListener('input', () => {
if (input.value === '') {
el.removeAttribute(attrDef.attr);
} else {
el.setAttribute(attrDef.attr, input.value);
}
});
// Sync from DOM
const syncObs = new MutationObserver(() => {
input.value = el.getAttribute(attrDef.attr) ?? '';
});
syncObs.observe(el, { attributes: true, attributeFilter: [attrDef.attr] });
row.appendChild(label);
row.appendChild(input);
}
sectionDiv.appendChild(row);
}
// Add single "Pick on map" button if section has lat/lng pair
const hasLat = attrs.some(a => a.attr === 'lat');
const hasLng = attrs.some(a => a.attr === 'lng');
if (hasLat && hasLng) {
const pickRow = document.createElement('div');
pickRow.className = 'editor-row';
const pickBtn = document.createElement('button');
pickBtn.className = 'pick-btn';
pickBtn.textContent = 'Pick on map';
pickBtn.addEventListener('click', () => startPickLatLng(el, pickBtn));
pickRow.appendChild(pickBtn);
sectionDiv.appendChild(pickRow);
}
editorRoot.appendChild(sectionDiv);
}
// Show innerHTML content for popup/tooltip
if (tag === 'leaflet-popup' || tag === 'leaflet-tooltip') {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'editor-section';
const h4 = document.createElement('h4');
h4.textContent = 'Inner HTML';
sectionDiv.appendChild(h4);
const textarea = document.createElement('textarea');
textarea.style.width = '100%';
textarea.style.minHeight = '80px';
textarea.style.fontFamily = 'monospace';
textarea.style.fontSize = '12px';
textarea.style.padding = '6px';
textarea.style.border = '1px solid #ccc';
textarea.style.borderRadius = '4px';
textarea.value = el.innerHTML;
textarea.addEventListener('input', () => {
el.innerHTML = textarea.value;
});
// Sync from DOM
const syncObs = new MutationObserver(() => {
if (document.activeElement !== textarea) {
textarea.value = el.innerHTML;
}
});
syncObs.observe(el, { childList: true, characterData: true, subtree: true });
sectionDiv.appendChild(textarea);
editorRoot.appendChild(sectionDiv);
}
// Show data attribute for geojson
if (tag === 'leaflet-geojson') {
const sectionDiv = document.createElement('div');
sectionDiv.className = 'editor-section';
const h4 = document.createElement('h4');
h4.textContent = 'GeoJSON Data';
sectionDiv.appendChild(h4);
const textarea = document.createElement('textarea');
textarea.style.width = '100%';
textarea.style.minHeight = '120px';
textarea.style.fontFamily = 'monospace';
textarea.style.fontSize = '12px';
textarea.style.padding = '6px';
textarea.style.border = '1px solid #ccc';
textarea.style.borderRadius = '4px';
textarea.value = el.getAttribute('data') ?? '';
textarea.addEventListener('input', () => {
el.setAttribute('data', textarea.value);
});
sectionDiv.appendChild(textarea);
editorRoot.appendChild(sectionDiv);
}
// Source HTML
const htmlSection = document.createElement('div');
htmlSection.className = 'editor-section';
const htmlH4 = document.createElement('h4');
htmlH4.textContent = 'Source HTML';
htmlSection.appendChild(htmlH4);
const htmlDisplay = document.createElement('textarea');
htmlDisplay.readOnly = true;
htmlDisplay.style.width = '100%';
htmlDisplay.style.minHeight = '60px';
htmlDisplay.style.fontFamily = 'monospace';
htmlDisplay.style.fontSize = '12px';
htmlDisplay.style.padding = '6px';
htmlDisplay.style.border = '1px solid #ddd';
htmlDisplay.style.borderRadius = '4px';
htmlDisplay.style.background = '#fafafa';
htmlDisplay.style.color = '#333';
htmlDisplay.value = el.outerHTML;
htmlSection.appendChild(htmlDisplay);
const htmlSync = new MutationObserver(() => {
htmlDisplay.value = el.outerHTML;
});
htmlSync.observe(el, { attributes: true, childList: true, subtree: true, characterData: true });
editorRoot.appendChild(htmlSection);
// Add-child form for container elements
const validChildren = CHILDREN_OF[tag];
if (validChildren && validChildren.length > 0) {
const addBtn = document.createElement('button');
addBtn.className = 'add-child-btn';
addBtn.textContent = '+ Add child';
addBtn.style.marginTop = '12px';
addBtn.addEventListener('click', () => startAddChild(el));
editorRoot.appendChild(addBtn);
}
}
// ── Pick lat/lng from map ──────────────────────────────────────────────
function startPickLatLng(el, btn, onPickCallback) {
const mapEl = document.getElementById('map');
const map = mapEl.map;
if (!map) return;
btn.classList.add('picking');
btn.textContent = 'Click on map...';
const cleanup = () => {
btn.classList.remove('picking');
btn.textContent = onPickCallback ? 'Pick' : 'Pick on map';
map.off('click', onPick);
map.off('keydown', onEscape);
};
const onPick = (e) => {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
if (onPickCallback) {
onPickCallback(lat, lng);
} else {
el.setAttribute('lat', String(lat));
el.setAttribute('lng', String(lng));
// Update editor inputs if visible
const latInput = document.querySelector(`[data-attr="lat"]`);
const lngInput = document.querySelector(`[data-attr="lng"]`);
if (latInput && latInput.closest('.pane-body')?.id === 'editor-root') {
latInput.value = String(lat);
}
if (lngInput && lngInput.closest('.pane-body')?.id === 'editor-root') {
lngInput.value = String(lng);
}
}
cleanup();
};
const onEscape = (e) => {
if (e.originalEvent.key === 'Escape') cleanup();
};
map.on('click', onPick);
map.on('keydown', onEscape);
}
// ── Add child flow ──────────────────────────────────────────────────────
let prevEditorHTML = '';
function startAddChild(parentEl) {
addingToEl = parentEl;
const editorRoot = document.getElementById('editor-root');
const validChildren = CHILDREN_OF[tagName(parentEl)];
if (!validChildren || validChildren.length === 0) return;
// Save current editor content and replace with wizard
prevEditorHTML = editorRoot.innerHTML;
editorRoot.innerHTML = '';
const form = document.createElement('div');
form.className = 'add-child-form';
const h5 = document.createElement('h5');
h5.textContent = `Add child to <${tagName(parentEl)}>`;
h5.style.fontFamily = 'monospace';
form.appendChild(h5);
const select = document.createElement('select');
const groups = TAG_GROUPS[tagName(parentEl)];
if (groups) {
for (const group of groups) {
const optgroup = document.createElement('optgroup');
optgroup.label = group.label;
for (const childTag of group.tags) {
const opt = document.createElement('option');
opt.value = childTag;
opt.textContent = childTag;
optgroup.appendChild(opt);
}
select.appendChild(optgroup);
}
} else {
for (const childTag of validChildren) {
const opt = document.createElement('option');
opt.value = childTag;
opt.textContent = childTag;
select.appendChild(opt);
}
}
form.appendChild(select);
// Preview
const preview = document.createElement('div');
preview.style.fontFamily = 'monospace';
preview.style.fontSize = '12px';
preview.style.color = '#2563eb';
preview.style.margin = '6px 0';
form.appendChild(preview);
// Dynamic attribute form container
const attrFormContainer = document.createElement('div');
form.appendChild(attrFormContainer);
function renderAddChildAttrs() {
const tag = select.value;
const attrDefs = ATTRS[tag] || {};
attrFormContainer.innerHTML = '';
preview.textContent = `<${tag}></${tag}>`;
for (const [section, attrs] of Object.entries(attrDefs)) {
if (attrs.length === 0) continue;
const sectionDiv = document.createElement('div');
sectionDiv.className = 'editor-section';
const h4 = document.createElement('h4');
h4.textContent = section;
sectionDiv.appendChild(h4);
for (const attrDef of attrs) {
const row = document.createElement('div');
row.className = 'editor-row';
const label = document.createElement('label');
label.textContent = attrDef.attr;
if (attrDef.kind === 'bool') {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.dataset.addAttr = attrDef.attr;
label.prepend(checkbox);
row.appendChild(label);
} else if (attrDef.kind === 'num') {
const input = document.createElement('input');
input.type = 'number';
input.dataset.addAttr = attrDef.attr;
if (attrDef.step) input.step = attrDef.step;
if (attrDef.min) input.min = attrDef.min;
if (attrDef.max) input.max = attrDef.max;
input.placeholder = attrDef.attr;
row.appendChild(label);
row.appendChild(input);
if (attrDef.attr === 'lng') {
const pickBtn = document.createElement('button');
pickBtn.className = 'pick-btn';
pickBtn.textContent = 'Pick';
pickBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
startPickLatLng(null, pickBtn, (lat, lng) => {
const latInput = attrFormContainer.querySelector('[data-add-attr="lat"]');
const lngInput = attrFormContainer.querySelector('[data-add-attr="lng"]');
if (latInput) latInput.value = String(lat);
if (lngInput) lngInput.value = String(lng);
});
});
row.appendChild(pickBtn);
}
} else {
const input = document.createElement('input');
input.type = 'text';
input.dataset.addAttr = attrDef.attr;
input.placeholder = attrDef.attr;
row.appendChild(label);
row.appendChild(input);
}
sectionDiv.appendChild(row);
}
attrFormContainer.appendChild(sectionDiv);
}
}
select.addEventListener('change', renderAddChildAttrs);
renderAddChildAttrs();
// Actions
const actions = document.createElement('div');
actions.className = 'form-actions';
const insertBtn = document.createElement('button');
insertBtn.className = 'primary';
insertBtn.textContent = 'Insert';
insertBtn.addEventListener('click', () => {
const tag = select.value;
const newEl = document.createElement(tag);
for (const input of attrFormContainer.querySelectorAll('[data-add-attr]')) {
const attr = input.dataset.addAttr;
if (input.type === 'checkbox') {
if (input.checked) newEl.setAttribute(attr, '');
} else if (input.value !== '') {
newEl.setAttribute(attr, input.value);
}
}
parentEl.appendChild(newEl);
addingToEl = null;
selectElement(newEl);
});
const cancelBtn = document.createElement('button');
cancelBtn.className = 'cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => {
addingToEl = null;
editorRoot.innerHTML = prevEditorHTML;
prevEditorHTML = '';
});
actions.appendChild(insertBtn);
actions.appendChild(cancelBtn);
form.appendChild(actions);
editorRoot.appendChild(form);
}
// ── Initialize ──────────────────────────────────────────────────────────
const mapEl = document.getElementById('map');
// Watch for DOM changes to rebuild tree
observer.observe(mapEl, { childList: true, subtree: true, attributes: false });
rebuildTree();
selectElement(mapEl);
</script>
</body>
</html>