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.
1248 lines
44 KiB
HTML
1248 lines
44 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="© <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="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 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');
|
|
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>
|