@softwarity/geojson-editor 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/geojson-editor.js +2 -2
- package/package.json +1 -1
- package/src/geojson-editor.js +295 -36
package/README.md
CHANGED
|
@@ -32,12 +32,13 @@ A feature-rich, framework-agnostic **Web Component** for editing GeoJSON feature
|
|
|
32
32
|
|
|
33
33
|
| | @softwarity/geojson-editor | Monaco Editor | CodeMirror | Prism.js |
|
|
34
34
|
|---|:---:|:---:|:---:|:---:|
|
|
35
|
-
| **Size (gzip)** | ~
|
|
35
|
+
| **Size (gzip)** | ~12 KB | ~2.5 MB | ~150 KB | ~15 KB + plugins |
|
|
36
36
|
| **GeoJSON validation** | ✅ Built-in | ❌ Manual | ❌ Manual | ❌ None |
|
|
37
37
|
| **Type highlighting** | ✅ Contextual | ⚠️ Generic JSON | ⚠️ Generic JSON | ⚠️ Generic JSON |
|
|
38
38
|
| **Invalid type detection** | ✅ Visual feedback | ❌ | ❌ | ❌ |
|
|
39
39
|
| **Collapsible nodes** | ✅ Native | ✅ | ✅ Plugin | ❌ |
|
|
40
40
|
| **Color picker** | ✅ Integrated | ❌ | ❌ | ❌ |
|
|
41
|
+
| **Feature visibility toggle** | ✅ | ❌ | ❌ | ❌ |
|
|
41
42
|
| **Auto-collapse coordinates** | ✅ | ❌ | ❌ | ❌ |
|
|
42
43
|
| **FeatureCollection mode** | ✅ | ❌ | ❌ | ❌ |
|
|
43
44
|
| **Dark mode detection** | ✅ Auto | ⚠️ Manual | ⚠️ Manual | ⚠️ Manual |
|
|
@@ -52,9 +53,10 @@ A feature-rich, framework-agnostic **Web Component** for editing GeoJSON feature
|
|
|
52
53
|
- **GeoJSON Type Validation** - Valid types (`Point`, `LineString`, `Polygon`, etc.) highlighted distinctly; invalid types (`LinearRing`, unknown types) shown with error styling (colors configurable via theme)
|
|
53
54
|
- **Syntax Highlighting** - JSON syntax highlighting with customizable color schemes
|
|
54
55
|
- **Collapsible Nodes** - Collapse/expand JSON objects and arrays with visual indicators (`{...}` / `[...]`); `coordinates` auto-collapsed on load
|
|
56
|
+
- **Feature Visibility Toggle** - Hide/show individual Features via eye icon in gutter; hidden features are grayed out and excluded from `change` events (useful for temporary filtering without deleting data)
|
|
55
57
|
- **Color Picker** - Built-in color picker for color properties in left gutter
|
|
56
58
|
- **Dark/Light Themes** - Automatic theme detection from parent page (Bootstrap, Tailwind, custom)
|
|
57
|
-
- **Auto-format** -
|
|
59
|
+
- **Auto-format** - Automatic JSON formatting in real-time (always enabled)
|
|
58
60
|
- **Readonly Mode** - Visual indicator with diagonal stripes when editing is disabled
|
|
59
61
|
- **Block Editing in Collapsed Areas** - Prevents accidental edits in collapsed sections
|
|
60
62
|
- **Smart Copy/Paste** - Copy includes expanded content even from collapsed nodes
|
|
@@ -127,13 +129,12 @@ import '@softwarity/geojson-editor';
|
|
|
127
129
|
></geojson-editor>
|
|
128
130
|
```
|
|
129
131
|
|
|
130
|
-
### With
|
|
132
|
+
### With Theme Detection
|
|
131
133
|
|
|
132
134
|
```html
|
|
133
135
|
<geojson-editor
|
|
134
136
|
feature-collection
|
|
135
137
|
dark-selector="html.dark"
|
|
136
|
-
auto-format
|
|
137
138
|
></geojson-editor>
|
|
138
139
|
```
|
|
139
140
|
|
|
@@ -161,7 +162,6 @@ editor.addEventListener('error', (e) => {
|
|
|
161
162
|
| `value` | `string` | `""` | Initial editor content |
|
|
162
163
|
| `placeholder` | `string` | `""` | Placeholder text |
|
|
163
164
|
| `readonly` | `boolean` | `false` | Make editor read-only |
|
|
164
|
-
| `auto-format` | `boolean` | `false` | Auto-format JSON on input |
|
|
165
165
|
| `dark-selector` | `string` | `".dark"` | CSS selector for dark theme (if matches → dark, else → light) |
|
|
166
166
|
| `feature-collection` | `boolean` | `false` | When set, wraps editor content in a FeatureCollection for validation/events |
|
|
167
167
|
|
|
@@ -210,6 +210,8 @@ editor.addEventListener('change', (e) => {
|
|
|
210
210
|
|
|
211
211
|
**Event detail:** The parsed GeoJSON object directly. In `feature-collection` mode, the wrapper is included.
|
|
212
212
|
|
|
213
|
+
**Note:** Hidden features (toggled via the eye icon) are automatically excluded from the emitted GeoJSON. This allows temporary filtering without modifying the actual JSON content.
|
|
214
|
+
|
|
213
215
|
**Example with FeatureCollection mode:**
|
|
214
216
|
|
|
215
217
|
```html
|
package/dist/geojson-editor.js
CHANGED
|
@@ -2,9 +2,9 @@ var e=Object.defineProperty,t=(t,n,o)=>n in t?e(t,n,{enumerable:!0,configurable:
|
|
|
2
2
|
/**
|
|
3
3
|
* @license MIT
|
|
4
4
|
* @name @softwarity/geojson-editor
|
|
5
|
-
* @version 1.0.
|
|
5
|
+
* @version 1.0.5
|
|
6
6
|
* @author Softwarity (https://www.softwarity.io/)
|
|
7
7
|
* @copyright 2024 Softwarity
|
|
8
8
|
* @see https://github.com/softwarity/geojson-editor
|
|
9
9
|
*/
|
|
10
|
-
const o=class e extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this.collapsedData=new Map,this.colorPositions=[],this.nodeTogglePositions=[],this.highlightTimer=null,this._cachedLineHeight=null,this._cachedPaddingTop=null,this.themes={dark:{...e.DEFAULT_THEMES.dark},light:{...e.DEFAULT_THEMES.light}}}static get observedAttributes(){return["readonly","value","placeholder","auto-format","dark-selector","feature-collection"]}connectedCallback(){this.render(),this.setupEventListeners(),this.updatePrefixSuffix(),this.updateThemeCSS(),this.value&&this.updateValue(this.value),this.updatePlaceholderContent()}attributeChangedCallback(e,t,n){var o;if(t!==n)if("value"===e)this.updateValue(n);else if("readonly"===e)this.updateReadonly();else if("placeholder"===e)this.updatePlaceholderContent();else if("dark-selector"===e)this.updateThemeCSS();else if("feature-collection"===e)this.updatePrefixSuffix();else if("auto-format"===e){const e=null==(o=this.shadowRoot)?void 0:o.getElementById("textarea");e&&e.value&&this.autoFormat&&(this.autoFormatContent(),this.updateHighlight())}}get readonly(){return this.hasAttribute("readonly")}get value(){return this.getAttribute("value")||""}get placeholder(){return this.getAttribute("placeholder")||""}get autoFormat(){return this.hasAttribute("auto-format")}get featureCollection(){return this.hasAttribute("feature-collection")}get prefix(){return this.featureCollection?e.FEATURE_COLLECTION_PREFIX:""}get suffix(){return this.featureCollection?e.FEATURE_COLLECTION_SUFFIX:""}render(){const e=`\n <div class="editor-prefix" id="editorPrefix"></div>\n <div class="editor-wrapper">\n <div class="gutter">\n <div class="gutter-content" id="gutterContent"></div>\n </div>\n <div class="editor-content">\n <div class="placeholder-layer" id="placeholderLayer">${this.escapeHtml(this.placeholder)}</div>\n <div class="highlight-layer" id="highlightLayer"></div>\n <textarea\n id="textarea"\n spellcheck="false"\n autocomplete="off"\n autocorrect="off"\n autocapitalize="off"\n ></textarea>\n </div>\n </div>\n <div class="editor-suffix" id="editorSuffix"></div>\n `;this.shadowRoot.innerHTML="\n <style>\n /* Global reset with exact values to prevent external CSS interference */\n :host *,\n :host *::before,\n :host *::after {\n box-sizing: border-box;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n font-variant: normal;\n line-height: 1.5;\n letter-spacing: 0;\n text-transform: none;\n text-decoration: none;\n text-indent: 0;\n word-spacing: 0;\n }\n\n :host {\n display: flex;\n flex-direction: column;\n position: relative;\n width: 100%;\n height: 400px;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n line-height: 1.5;\n border-radius: 4px;\n overflow: hidden;\n }\n\n :host([readonly]) .editor-wrapper::after {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n background: repeating-linear-gradient(\n -45deg,\n rgba(128, 128, 128, 0.08),\n rgba(128, 128, 128, 0.08) 3px,\n transparent 3px,\n transparent 12px\n );\n z-index: 1;\n }\n\n :host([readonly]) textarea {\n cursor: text;\n }\n\n .editor-wrapper {\n position: relative;\n width: 100%;\n flex: 1;\n background: var(--bg-color);\n display: flex;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .gutter {\n width: 24px;\n height: 100%;\n background: var(--gutter-bg);\n border-right: 1px solid var(--gutter-border);\n overflow: hidden;\n flex-shrink: 0;\n position: relative;\n }\n\n .gutter-content {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n padding: 8px 4px;\n }\n\n .gutter-line {\n position: absolute;\n left: 0;\n width: 100%;\n height: 1.5em;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .color-indicator {\n width: 12px;\n height: 12px;\n border-radius: 2px;\n border: 1px solid #555;\n cursor: pointer;\n transition: transform 0.1s;\n flex-shrink: 0;\n }\n\n .color-indicator:hover {\n transform: scale(1.2);\n border-color: #fff;\n }\n\n .collapse-button {\n width: 12px;\n height: 12px;\n background: var(--control-bg);\n border: 1px solid var(--control-border);\n border-radius: 2px;\n color: var(--control-color);\n font-size: 8px;\n font-weight: bold;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all 0.1s;\n flex-shrink: 0;\n user-select: none;\n }\n\n .collapse-button:hover {\n background: var(--control-bg);\n border-color: var(--control-color);\n transform: scale(1.1);\n }\n\n .color-picker-popup {\n position: absolute;\n background: #2d2d30;\n border: 1px solid #555;\n border-radius: 4px;\n padding: 8px;\n z-index: 1000;\n box-shadow: 0 4px 12px rgba(0,0,0,0.5);\n }\n\n .color-picker-popup input[type=\"color\"] {\n width: 150px;\n height: 30px;\n border: none;\n cursor: pointer;\n }\n\n .editor-content {\n position: relative;\n flex: 1;\n overflow: hidden;\n }\n\n .highlight-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n padding: 8px 12px;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n line-height: 1.5;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow: auto;\n pointer-events: none;\n z-index: 1;\n color: var(--text-color);\n }\n\n .highlight-layer::-webkit-scrollbar {\n display: none;\n }\n\n textarea {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n padding: 8px 12px;\n margin: 0;\n border: none;\n outline: none;\n background: transparent;\n color: transparent;\n caret-color: var(--caret-color);\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n line-height: 1.5;\n white-space: pre-wrap;\n word-wrap: break-word;\n resize: none;\n overflow: auto;\n z-index: 2;\n box-sizing: border-box;\n }\n\n textarea::selection {\n background: rgba(51, 153, 255, 0.3);\n }\n\n textarea::placeholder {\n color: transparent;\n }\n\n .placeholder-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n padding: 8px 12px;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n line-height: 1.5;\n white-space: pre-wrap;\n word-wrap: break-word;\n color: #6a6a6a;\n pointer-events: none;\n z-index: 0;\n overflow: hidden;\n }\n\n textarea:disabled {\n cursor: not-allowed;\n opacity: 0.6;\n }\n\n /* Syntax highlighting colors */\n .json-key {\n color: var(--json-key);\n }\n\n .json-string {\n color: var(--json-string);\n }\n\n .json-number {\n color: var(--json-number);\n }\n\n .json-boolean {\n color: var(--json-boolean);\n }\n\n .json-null {\n color: var(--json-null);\n }\n\n .json-punctuation {\n color: var(--json-punct);\n }\n\n /* GeoJSON-specific highlighting */\n .geojson-key {\n color: var(--geojson-key);\n font-weight: 600;\n }\n\n .geojson-type {\n color: var(--geojson-type);\n font-weight: 600;\n }\n\n .geojson-type-invalid {\n color: var(--geojson-type-invalid);\n font-weight: 600;\n }\n\n .json-key-invalid {\n color: var(--json-key-invalid);\n }\n\n /* Prefix and suffix styling */\n .editor-prefix,\n .editor-suffix {\n padding: 4px 12px;\n color: var(--text-color);\n background: var(--bg-color);\n user-select: none;\n white-space: pre-wrap;\n word-wrap: break-word;\n flex-shrink: 0;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n line-height: 1.5;\n opacity: 0.6;\n border-left: 3px solid rgba(102, 126, 234, 0.5);\n }\n\n .editor-prefix {\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n }\n\n .editor-suffix {\n border-top: 1px solid rgba(255, 255, 255, 0.1);\n }\n\n /* Scrollbar styling - WebKit (Chrome, Safari, Edge) */\n textarea::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n }\n\n textarea::-webkit-scrollbar-track {\n background: var(--control-bg);\n }\n\n textarea::-webkit-scrollbar-thumb {\n background: var(--control-border);\n border-radius: 5px;\n }\n\n textarea::-webkit-scrollbar-thumb:hover {\n background: var(--control-color);\n }\n\n /* Scrollbar styling - Firefox */\n textarea {\n scrollbar-width: thin;\n scrollbar-color: var(--control-border) var(--control-bg);\n }\n </style>\n "+e}setupEventListeners(){const e=this.shadowRoot.getElementById("textarea"),t=this.shadowRoot.getElementById("highlightLayer");e.addEventListener("scroll",()=>{t.scrollTop=e.scrollTop,t.scrollLeft=e.scrollLeft,this.syncGutterScroll(e.scrollTop)}),e.addEventListener("input",()=>{this.updatePlaceholderVisibility(),clearTimeout(this.highlightTimer),this.highlightTimer=setTimeout(()=>{this.autoFormat&&this.autoFormatContentWithCursor(),this.updateHighlight(),this.emitChange()},150)}),e.addEventListener("paste",()=>{clearTimeout(this.highlightTimer),setTimeout(()=>{this.updatePlaceholderVisibility(),this.autoFormat&&this.autoFormatContentWithCursor(),this.updateHighlight(),this.emitChange(),this.applyAutoCollapsed()},10)}),this.shadowRoot.getElementById("gutterContent").addEventListener("click",e=>{if(e.target.classList.contains("color-indicator")){const t=parseInt(e.target.dataset.line),n=e.target.dataset.color,o=e.target.dataset.attributeName;this.showColorPicker(e.target,t,n,o)}else if(e.target.classList.contains("collapse-button")){const t=e.target.dataset.nodeKey,n=parseInt(e.target.dataset.line);this.toggleCollapse(t,n)}}),this.shadowRoot.querySelector(".gutter").addEventListener("wheel",t=>{t.preventDefault(),e.scrollTop+=t.deltaY}),e.addEventListener("keydown",e=>{this.handleKeydownInCollapsedArea(e)}),e.addEventListener("copy",e=>{this.handleCopyWithCollapsedContent(e)}),e.addEventListener("cut",e=>{this.handleCutWithCollapsedContent(e)}),this.updateReadonly()}syncGutterScroll(e){this.shadowRoot.getElementById("gutterContent").style.transform=`translateY(-${e}px)`}updateReadonly(){const e=this.shadowRoot.getElementById("textarea");e&&(e.disabled=this.readonly)}escapeHtml(e){return e?e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"):""}updatePlaceholderVisibility(){const e=this.shadowRoot.getElementById("textarea"),t=this.shadowRoot.getElementById("placeholderLayer");e&&t&&(t.style.display=e.value?"none":"block")}updatePlaceholderContent(){const e=this.shadowRoot.getElementById("placeholderLayer");e&&(e.textContent=this.placeholder),this.updatePlaceholderVisibility()}updateValue(e){const t=this.shadowRoot.getElementById("textarea");if(t&&t.value!==e){if(t.value=e||"",this.autoFormat&&e)try{const n=this.prefix,o=this.suffix,s=n.trimEnd().endsWith("["),i=o.trimStart().startsWith("]");if(s&&i){const n="["+e+"]",o=JSON.parse(n),s=JSON.stringify(o,null,2).split("\n");s.length>2?t.value=s.slice(1,-1).join("\n"):t.value=""}else if(!n&&!o){const n=JSON.parse(e);t.value=JSON.stringify(n,null,2)}}catch{}this.updateHighlight(),this.updatePlaceholderVisibility(),t.value&&requestAnimationFrame(()=>{this.applyAutoCollapsed()}),this.emitChange()}}updatePrefixSuffix(){const e=this.shadowRoot.getElementById("editorPrefix"),t=this.shadowRoot.getElementById("editorSuffix");e&&(this.prefix?(e.textContent=this.prefix,e.style.display="block"):(e.textContent="",e.style.display="none")),t&&(this.suffix?(t.textContent=this.suffix,t.style.display="block"):(t.textContent="",t.style.display="none"))}updateHighlight(){const e=this.shadowRoot.getElementById("textarea"),t=this.shadowRoot.getElementById("highlightLayer");if(!e||!t)return;const n=e.value,{highlighted:o,colors:s,toggles:i}=this.highlightJSON(n);t.innerHTML=o,this.colorPositions=s,this.nodeTogglePositions=i,this.updateGutter()}highlightJSON(t){if(!t.trim())return{highlighted:"",colors:[],toggles:[]};const n=t.split("\n"),o=[],s=[];let i=[];const r=this.buildContextMap(t);return n.forEach((t,n)=>{const l=e.REGEX;let a;for(l.colorInLine.lastIndex=0;null!==(a=l.colorInLine.exec(t));)o.push({line:n,color:a[2],attributeName:a[1]});const h=t.match(l.collapsibleNode);if(h){const e=h[2];t.includes("{...}")||t.includes("[...]")?s.push({line:n,nodeKey:e,isCollapsed:!0}):this.bracketClosesOnSameLine(t,h[3])||s.push({line:n,nodeKey:e,isCollapsed:!1})}const d=r.get(n);i.push(this.highlightSyntax(t,d))}),{highlighted:i.join("\n"),colors:o,toggles:s}}buildContextMap(t){var n;const o=t.split("\n"),s=new Map,i=[];let r=null;const l=this.featureCollection?"Feature":null;for(let t=0;t<o.length;t++){const a=o[t],h=i.length>0?null==(n=i[i.length-1])?void 0:n.context:l;s.set(t,h);for(let t=0;t<a.length;t++){const n=a[t];if('"'===n){const n=a.substring(t).match(/^"([^"]+)"\s*:/);if(n){const o=n[1];e.CONTEXT_CHANGING_KEYS[o]&&(r=e.CONTEXT_CHANGING_KEYS[o]),t+=n[0].length-1;continue}}if('"'===n&&i.length>0&&a.substring(0,t).match(/"type"\s*:\s*$/)){const n=a.substring(t).match(/^"([^"]+)"/);if(n&&e.GEOJSON_TYPES_ALL.includes(n[1])){const e=i[i.length-1];e&&(e.context=n[1])}}if("{"===n||"["===n){let e;if(r)e=r,r=null;else if(0===i.length)e=l;else{const t=i[i.length-1];e=t&&t.isArray?t.context:null}i.push({context:e,isArray:"["===n})}("}"===n||"]"===n)&&i.length>0&&i.pop()}}return s}highlightSyntax(t,n){if(!t.trim())return"";const o=n?e.VALID_KEYS_BY_CONTEXT[n]:null,s=e.REGEX;return t.replace(s.ampersand,"&").replace(s.lessThan,"<").replace(s.greaterThan,">").replace(s.jsonKey,(t,s)=>"properties"===n?`<span class="json-key">"${s}"</span>:`:e.GEOJSON_STRUCTURAL_KEYS.includes(s)?`<span class="geojson-key">"${s}"</span>:`:(t=>!(!e.GEOJSON_STRUCTURAL_KEYS.includes(t)&&n&&null!=o)||o.includes(t))(s)?`<span class="json-key">"${s}"</span>:`:`<span class="json-key-invalid">"${s}"</span>:`).replace(s.typeValue,(t,o)=>(t=>!n||"properties"===n||("geometry"===n||e.GEOJSON_TYPES_GEOMETRY.includes(n)?e.GEOJSON_TYPES_GEOMETRY.includes(t):"Feature"!==n&&"FeatureCollection"!==n||e.GEOJSON_TYPES_ALL.includes(t)))(o)?`<span class="geojson-key">"type"</span>: <span class="geojson-type">"${o}"</span>`:`<span class="geojson-key">"type"</span>: <span class="geojson-type-invalid">"${o}"</span>`).replace(s.stringValue,(e,t)=>e.includes("<span")?e:`: <span class="json-string">"${t}"</span>`).replace(s.numberAfterColon,': <span class="json-number">$1</span>').replace(s.boolean,': <span class="json-boolean">$1</span>').replace(s.nullValue,': <span class="json-null">$1</span>').replace(s.allNumbers,'<span class="json-number">$1</span>').replace(s.punctuation,'<span class="json-punctuation">$1</span>')}toggleCollapse(e,t){const n=this.shadowRoot.getElementById("textarea"),o=n.value.split("\n"),s=o[t];if(s.includes("{...}")||s.includes("[...]")){let n=null,i=null;const r=`${t}-${e}`;if(this.collapsedData.has(r))n=r,i=this.collapsedData.get(r);else for(const[t,o]of this.collapsedData.entries())if(o.nodeKey===e){const e=s.match(/^(\s*)/)[1].length;if(o.indent===e){n=t,i=o;break}}if(!n||!i)return;const{originalLine:l,content:a}=i;o[t]=l,o.splice(t+1,0,...a),this.collapsedData.delete(n)}else{const n=s.match(/^(\s*)"([^"]+)"\s*:\s*([{\[])/);if(!n)return;const i=n[1],r=n[3],l="{"===r?"}":"]";if(this.bracketClosesOnSameLine(s,r))return;let a=1,h=t;const d=[];for(let e=t+1;e<o.length;e++){const t=o[e];for(const e of t)e===r&&a++,e===l&&a--;if(d.push(t),0===a){h=e;break}}const c=`${t}-${e}`;this.collapsedData.set(c,{originalLine:s,content:d,indent:i.length,nodeKey:e});const p=s.substring(0,s.indexOf(r)),u=o[h]&&o[h].trim().endsWith(",");o[t]=`${p}${r}...${l}${u?",":""}`,o.splice(t+1,h-t)}n.value=o.join("\n"),this.updateHighlight()}applyAutoCollapsed(){const e=this.shadowRoot.getElementById("textarea");if(!e||!e.value)return;const t=e.value.split("\n");for(let e=t.length-1;e>=0;e--){const n=t[e],o=n.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);if(o){const s=o[2];if("coordinates"===s){const i=o[1],r=o[3],l="{"===r?"}":"]";if(this.bracketClosesOnSameLine(n,r))continue;let a=1,h=e;const d=[];for(let n=e+1;n<t.length;n++){const e=t[n];for(const t of e)t===r&&a++,t===l&&a--;if(d.push(e),0===a){h=n;break}}const c=`${e}-${s}`;this.collapsedData.set(c,{originalLine:n,content:d,indent:i.length,nodeKey:s});const p=n.substring(0,n.indexOf(r)),u=t[h]&&t[h].trim().endsWith(",");t[e]=`${p}${r}...${l}${u?",":""}`,t.splice(e+1,h-e)}}}e.value=t.join("\n"),this.updateHighlight()}updateGutter(){const e=this.shadowRoot.getElementById("gutterContent"),t=this.shadowRoot.getElementById("textarea");if(!t)return;if(null===this._cachedLineHeight){const e=getComputedStyle(t);this._cachedLineHeight=parseFloat(e.lineHeight),this._cachedPaddingTop=parseFloat(e.paddingTop)}const n=this._cachedLineHeight,o=this._cachedPaddingTop;e.textContent="";const s=new Map;this.colorPositions.forEach(({line:e,color:t,attributeName:n})=>{s.has(e)||s.set(e,{colors:[],buttons:[]}),s.get(e).colors.push({color:t,attributeName:n})}),this.nodeTogglePositions.forEach(({line:e,nodeKey:t,isCollapsed:n})=>{s.has(e)||s.set(e,{colors:[],buttons:[]}),s.get(e).buttons.push({nodeKey:t,isCollapsed:n})});const i=document.createDocumentFragment();s.forEach((e,t)=>{const s=document.createElement("div");s.className="gutter-line",s.style.top=`${o+t*n}px`,e.colors.forEach(({color:e,attributeName:n})=>{const o=document.createElement("div");o.className="color-indicator",o.style.backgroundColor=e,o.dataset.line=t,o.dataset.color=e,o.dataset.attributeName=n,o.title=`${n}: ${e}`,s.appendChild(o)}),e.buttons.forEach(({nodeKey:e,isCollapsed:n})=>{const o=document.createElement("div");o.className="collapse-button",o.textContent=n?"+":"-",o.dataset.line=t,o.dataset.nodeKey=e,o.title=n?"Expand":"Collapse",s.appendChild(o)}),i.appendChild(s)}),e.appendChild(i)}showColorPicker(e,t,n,o){const s=document.querySelector(".geojson-color-picker-input");s&&s.remove();const i=document.createElement("input");i.type="color",i.value=n,i.className="geojson-color-picker-input";const r=e.getBoundingClientRect();i.style.position="fixed",i.style.left=`${r.left}px`,i.style.top=`${r.top}px`,i.style.width="12px",i.style.height="12px",i.style.opacity="0.01",i.style.border="none",i.style.padding="0",i.style.zIndex="9999",i.addEventListener("input",e=>{this.updateColorValue(t,e.target.value,o)}),i.addEventListener("change",e=>{this.updateColorValue(t,e.target.value,o)});const l=e=>{e.target!==i&&!i.contains(e.target)&&(i.remove(),document.removeEventListener("click",l,!0))};document.body.appendChild(i),setTimeout(()=>{document.addEventListener("click",l,!0)},100),i.focus(),i.click()}updateColorValue(e,t,n){const o=this.shadowRoot.getElementById("textarea"),s=o.value.split("\n"),i=new RegExp(`"${n}"\\s*:\\s*"#[0-9a-fA-F]{6}"`);s[e]=s[e].replace(i,`"${n}": "${t}"`),o.value=s.join("\n"),this.updateHighlight(),this.emitChange()}handleKeydownInCollapsedArea(e){if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Tab"].includes(e.key)||e.ctrlKey||e.metaKey)return;const t=this.shadowRoot.getElementById("textarea"),n=t.selectionStart,o=t.value.substring(0,n).split("\n").length-1,s=t.value.split("\n")[o];s&&(s.includes("{...}")||s.includes("[...]"))&&e.preventDefault()}handleCopyWithCollapsedContent(e){const t=this.shadowRoot.getElementById("textarea"),n=t.selectionStart,o=t.selectionEnd;if(n===o)return;const s=t.value.substring(n,o);if(!s.includes("{...}")&&!s.includes("[...]"))return;const i=this.expandCollapsedMarkersInText(s,n);e.preventDefault(),e.clipboardData.setData("text/plain",i)}expandCollapsedMarkersInText(e,t){const n=this.shadowRoot.getElementById("textarea").value.substring(0,t).split("\n").length-1,o=e.split("\n"),s=[];return o.forEach((e,t)=>{const o=n+t;if(e.includes("{...}")||e.includes("[...]")){let t=!1;this.collapsedData.forEach((e,n)=>{parseInt(n.split("-")[0])===o&&(s.push(e.originalLine),s.push(...e.content),t=!0)}),t||s.push(e)}else s.push(e)}),s.join("\n")}handleCutWithCollapsedContent(e){this.handleCopyWithCollapsedContent(e);const t=this.shadowRoot.getElementById("textarea"),n=t.selectionStart,o=t.selectionEnd;if(n!==o){const e=t.value;t.value=e.substring(0,n)+e.substring(o),t.selectionStart=t.selectionEnd=n,this.updateHighlight(),this.updatePlaceholderVisibility(),this.emitChange()}}emitChange(){const e=this.shadowRoot.getElementById("textarea"),t=this.expandAllCollapsed(e.value),n=this.prefix+t+this.suffix;try{const e=JSON.parse(n),o=this.validateGeoJSON(e);o.length>0?this.dispatchEvent(new CustomEvent("error",{detail:{timestamp:(new Date).toISOString(),error:`GeoJSON validation: ${o.join("; ")}`,errors:o,content:t},bubbles:!0,composed:!0})):this.dispatchEvent(new CustomEvent("change",{detail:e,bubbles:!0,composed:!0}))}catch(e){this.dispatchEvent(new CustomEvent("error",{detail:{timestamp:(new Date).toISOString(),error:e.message,content:t},bubbles:!0,composed:!0}))}}validateGeoJSON(t,n="",o="root"){const s=[];if(!t||"object"!=typeof t)return s;if("properties"!==o&&void 0!==t.type){const i=t.type;"string"==typeof i&&("geometry"===o?e.GEOJSON_TYPES_GEOMETRY.includes(i)||s.push(`Invalid geometry type "${i}" at ${n||"root"} (expected: ${e.GEOJSON_TYPES_GEOMETRY.join(", ")})`):e.GEOJSON_TYPES_FEATURE.includes(i)||s.push(`Invalid type "${i}" at ${n||"root"} (expected: ${e.GEOJSON_TYPES_FEATURE.join(", ")})`))}if(Array.isArray(t))t.forEach((e,t)=>{s.push(...this.validateGeoJSON(e,`${n}[${t}]`,o))});else for(const[e,i]of Object.entries(t))if("object"==typeof i&&null!==i){const t=n?`${n}.${e}`:e;let r=o;"properties"===e?r="properties":"geometry"===e||"geometries"===e?r="geometry":"features"===e&&(r="root"),s.push(...this.validateGeoJSON(i,t,r))}return s}bracketClosesOnSameLine(e,t){const n="{"===t?"}":"]",o=e.indexOf(t);if(-1===o)return!1;const s=e.substring(o+1);let i=1;for(const e of s)if(e===t&&i++,e===n&&i--,0===i)return!0;return!1}expandAllCollapsed(t){const n=e.REGEX;for(;t.includes("{...}")||t.includes("[...]");){const e=t.split("\n");let o=!1;for(let t=0;t<e.length;t++){const s=e[t];if(!s.includes("{...}")&&!s.includes("[...]"))continue;const i=s.match(n.collapsedMarker);if(!i)continue;const r=i[2],l=i[1].length,a=`${t}-${r}`;let h=this.collapsedData.has(a)?a:null;if(!h)for(const[e,t]of this.collapsedData.entries())if(t.nodeKey===r&&t.indent===l){h=e;break}if(h){const{originalLine:n,content:s}=this.collapsedData.get(h);e[t]=n,e.splice(t+1,0,...s),o=!0;break}}if(!o)break;t=e.join("\n")}return t}formatJSONContent(e){const t=this.prefix,n=this.suffix,o=t.trimEnd().endsWith("["),s=n.trimStart().startsWith("]");if(o&&s){const t="["+e+"]",n=JSON.parse(t),o=JSON.stringify(n,null,2).split("\n");return o.length>2?o.slice(1,-1).join("\n"):""}if(t||n){const o=t+e+n;return JSON.parse(o),e}{const t=JSON.parse(e);return JSON.stringify(t,null,2)}}autoFormatContentWithCursor(){const e=this.shadowRoot.getElementById("textarea"),t=e.selectionStart,n=e.value.substring(0,t).split("\n"),o=n.length-1,s=n[n.length-1].length,i=Array.from(this.collapsedData.values()).map(e=>({nodeKey:e.nodeKey,indent:e.indent})),r=this.expandAllCollapsed(e.value);try{const t=this.formatJSONContent(r);if(t!==r){this.collapsedData.clear(),e.value=t,i.length>0&&this.reapplyCollapsed(i);const n=e.value.split("\n");if(o<n.length){const t=Math.min(s,n[o].length);let i=0;for(let e=0;e<o;e++)i+=n[e].length+1;i+=t,e.setSelectionRange(i,i)}}}catch{}}autoFormatContent(){const e=this.shadowRoot.getElementById("textarea"),t=Array.from(this.collapsedData.values()).map(e=>({nodeKey:e.nodeKey,indent:e.indent})),n=this.expandAllCollapsed(e.value);try{const o=this.formatJSONContent(n);o!==n&&(this.collapsedData.clear(),e.value=o,t.length>0&&this.reapplyCollapsed(t))}catch{}}reapplyCollapsed(e){const t=this.shadowRoot.getElementById("textarea"),n=t.value.split("\n"),o=new Map;e.forEach(({nodeKey:e,indent:t})=>{const n=`${e}-${t}`;o.set(n,(o.get(n)||0)+1)});const s=new Map;for(let e=n.length-1;e>=0;e--){const t=n[e],i=t.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);if(i){const r=i[2],l=`${r}-${i[1].length}`;if(o.has(l)&&(s.set(l,(s.get(l)||0)+1),s.get(l)<=o.get(l))){const o=i[1],s=i[3],l="{"===s?"}":"]";if(this.bracketClosesOnSameLine(t,s))continue;let a=1,h=e;const d=[];for(let t=e+1;t<n.length;t++){const e=n[t];for(const t of e)t===s&&a++,t===l&&a--;if(d.push(e),0===a){h=t;break}}const c=`${e}-${r}`;this.collapsedData.set(c,{originalLine:t,content:d,indent:o.length,nodeKey:r});const p=t.substring(0,t.indexOf(s)),u=n[h]&&n[h].trim().endsWith(",");n[e]=`${p}${s}...${l}${u?",":""}`,n.splice(e+1,h-e)}}}t.value=n.join("\n")}parseSelectorToHostRule(e){return e&&""!==e?e.startsWith(".")&&!e.includes(" ")?`:host(${e})`:`:host-context(${e})`:':host([data-color-scheme="dark"])'}updateThemeCSS(){const e=this.getAttribute("dark-selector")||".dark",t=this.parseSelectorToHostRule(e);let n=this.shadowRoot.getElementById("theme-styles");n||(n=document.createElement("style"),n.id="theme-styles",this.shadowRoot.insertBefore(n,this.shadowRoot.firstChild));const o=`\n :host {\n --bg-color: ${this.themes.light.background};\n --text-color: ${this.themes.light.textColor};\n --caret-color: ${this.themes.light.caretColor};\n --gutter-bg: ${this.themes.light.gutterBackground};\n --gutter-border: ${this.themes.light.gutterBorder};\n --json-key: ${this.themes.light.jsonKey};\n --json-string: ${this.themes.light.jsonString};\n --json-number: ${this.themes.light.jsonNumber};\n --json-boolean: ${this.themes.light.jsonBoolean};\n --json-null: ${this.themes.light.jsonNull};\n --json-punct: ${this.themes.light.jsonPunctuation};\n --control-color: ${this.themes.light.controlColor};\n --control-bg: ${this.themes.light.controlBg};\n --control-border: ${this.themes.light.controlBorder};\n --geojson-key: ${this.themes.light.geojsonKey};\n --geojson-type: ${this.themes.light.geojsonType};\n --geojson-type-invalid: ${this.themes.light.geojsonTypeInvalid};\n --json-key-invalid: ${this.themes.light.jsonKeyInvalid};\n }\n\n ${t} {\n --bg-color: ${this.themes.dark.background};\n --text-color: ${this.themes.dark.textColor};\n --caret-color: ${this.themes.dark.caretColor};\n --gutter-bg: ${this.themes.dark.gutterBackground};\n --gutter-border: ${this.themes.dark.gutterBorder};\n --json-key: ${this.themes.dark.jsonKey};\n --json-string: ${this.themes.dark.jsonString};\n --json-number: ${this.themes.dark.jsonNumber};\n --json-boolean: ${this.themes.dark.jsonBoolean};\n --json-null: ${this.themes.dark.jsonNull};\n --json-punct: ${this.themes.dark.jsonPunctuation};\n --control-color: ${this.themes.dark.controlColor};\n --control-bg: ${this.themes.dark.controlBg};\n --control-border: ${this.themes.dark.controlBorder};\n --geojson-key: ${this.themes.dark.geojsonKey};\n --geojson-type: ${this.themes.dark.geojsonType};\n --geojson-type-invalid: ${this.themes.dark.geojsonTypeInvalid};\n --json-key-invalid: ${this.themes.dark.jsonKeyInvalid};\n }\n `;n.textContent=o}getTheme(){return{dark:{...this.themes.dark},light:{...this.themes.light}}}setTheme(e){e.dark&&(this.themes.dark={...this.themes.dark,...e.dark}),e.light&&(this.themes.light={...this.themes.light,...e.light}),this.updateThemeCSS()}resetTheme(){this.themes={dark:{...e.DEFAULT_THEMES.dark},light:{...e.DEFAULT_THEMES.light}},this.updateThemeCSS()}};n(o,"DEFAULT_THEMES",{dark:{background:"#1e1e1e",textColor:"#d4d4d4",caretColor:"#fff",gutterBackground:"#252526",gutterBorder:"#3e3e42",jsonKey:"#9cdcfe",jsonString:"#ce9178",jsonNumber:"#b5cea8",jsonBoolean:"#569cd6",jsonNull:"#569cd6",jsonPunctuation:"#d4d4d4",controlColor:"#c586c0",controlBg:"#3e3e42",controlBorder:"#555",geojsonKey:"#c586c0",geojsonType:"#4ec9b0",geojsonTypeInvalid:"#f44747",jsonKeyInvalid:"#f44747"},light:{background:"#ffffff",textColor:"#333333",caretColor:"#000",gutterBackground:"#f5f5f5",gutterBorder:"#ddd",jsonKey:"#0000ff",jsonString:"#a31515",jsonNumber:"#098658",jsonBoolean:"#0000ff",jsonNull:"#0000ff",jsonPunctuation:"#333333",controlColor:"#a31515",controlBg:"#e0e0e0",controlBorder:"#999",geojsonKey:"#af00db",geojsonType:"#267f99",geojsonTypeInvalid:"#d32f2f",jsonKeyInvalid:"#d32f2f"}}),n(o,"FEATURE_COLLECTION_PREFIX",'{"type": "FeatureCollection", "features": ['),n(o,"FEATURE_COLLECTION_SUFFIX","]}"),n(o,"REGEX",{ampersand:/&/g,lessThan:/</g,greaterThan:/>/g,jsonKey:/"([^"]+)"\s*:/g,typeValue:/<span class="geojson-key">"type"<\/span>:\s*"([^"]*)"/g,stringValue:/:\s*"([^"]*)"/g,numberAfterColon:/:\s*(-?\d+\.?\d*)/g,boolean:/:\s*(true|false)/g,nullValue:/:\s*(null)/g,allNumbers:/\b(-?\d+\.?\d*)\b/g,punctuation:/([{}[\],])/g,colorInLine:/"(\w+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,collapsibleNode:/^(\s*)"(\w+)"\s*:\s*([{\[])/,collapsedMarker:/^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/}),n(o,"GEOJSON_TYPES_FEATURE",["Feature","FeatureCollection"]),n(o,"GEOJSON_TYPES_GEOMETRY",["Point","MultiPoint","LineString","MultiLineString","Polygon","MultiPolygon","GeometryCollection"]),n(o,"GEOJSON_TYPES_ALL",[...o.GEOJSON_TYPES_FEATURE,...o.GEOJSON_TYPES_GEOMETRY]),n(o,"VALID_KEYS_BY_CONTEXT",{Feature:["type","geometry","properties","id","bbox"],FeatureCollection:["type","features","bbox","properties"],Point:["type","coordinates","bbox"],MultiPoint:["type","coordinates","bbox"],LineString:["type","coordinates","bbox"],MultiLineString:["type","coordinates","bbox"],Polygon:["type","coordinates","bbox"],MultiPolygon:["type","coordinates","bbox"],GeometryCollection:["type","geometries","bbox"],properties:null,geometry:["type","coordinates","geometries","bbox"]}),n(o,"CONTEXT_CHANGING_KEYS",{geometry:"geometry",properties:"properties",features:"Feature",geometries:"geometry"}),n(o,"GEOJSON_STRUCTURAL_KEYS",["type","geometry","properties","features","geometries","coordinates","bbox","id","crs"]);let s=o;customElements.define("geojson-editor",s);
|
|
10
|
+
const o=class e extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this.collapsedData=new Map,this.colorPositions=[],this.nodeTogglePositions=[],this.hiddenFeatures=new Set,this.featureRanges=new Map,this.highlightTimer=null,this._cachedLineHeight=null,this._cachedPaddingTop=null,this.themes={dark:{...e.DEFAULT_THEMES.dark},light:{...e.DEFAULT_THEMES.light}}}static get observedAttributes(){return["readonly","value","placeholder","dark-selector","feature-collection"]}connectedCallback(){this.render(),this.setupEventListeners(),this.updatePrefixSuffix(),this.updateThemeCSS(),this.value&&this.updateValue(this.value),this.updatePlaceholderContent()}attributeChangedCallback(e,t,n){t!==n&&("value"===e?this.updateValue(n):"readonly"===e?this.updateReadonly():"placeholder"===e?this.updatePlaceholderContent():"dark-selector"===e?this.updateThemeCSS():"feature-collection"===e&&this.updatePrefixSuffix())}get readonly(){return this.hasAttribute("readonly")}get value(){return this.getAttribute("value")||""}get placeholder(){return this.getAttribute("placeholder")||""}get featureCollection(){return this.hasAttribute("feature-collection")}get prefix(){return this.featureCollection?e.FEATURE_COLLECTION_PREFIX:""}get suffix(){return this.featureCollection?e.FEATURE_COLLECTION_SUFFIX:""}render(){const e=`\n <div class="editor-prefix" id="editorPrefix"></div>\n <div class="editor-wrapper">\n <div class="gutter">\n <div class="gutter-content" id="gutterContent"></div>\n </div>\n <div class="editor-content">\n <div class="placeholder-layer" id="placeholderLayer">${this.escapeHtml(this.placeholder)}</div>\n <div class="highlight-layer" id="highlightLayer"></div>\n <textarea\n id="textarea"\n spellcheck="false"\n autocomplete="off"\n autocorrect="off"\n autocapitalize="off"\n ></textarea>\n </div>\n </div>\n <div class="editor-suffix" id="editorSuffix"></div>\n `;this.shadowRoot.innerHTML="\n <style>\n /* Global reset with exact values to prevent external CSS interference */\n :host *,\n :host *::before,\n :host *::after {\n box-sizing: border-box;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n font-variant: normal;\n line-height: 1.5;\n letter-spacing: 0;\n text-transform: none;\n text-decoration: none;\n text-indent: 0;\n word-spacing: 0;\n }\n\n :host {\n display: flex;\n flex-direction: column;\n position: relative;\n width: 100%;\n height: 400px;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n line-height: 1.5;\n border-radius: 4px;\n overflow: hidden;\n }\n\n :host([readonly]) .editor-wrapper::after {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n background: repeating-linear-gradient(\n -45deg,\n rgba(128, 128, 128, 0.08),\n rgba(128, 128, 128, 0.08) 3px,\n transparent 3px,\n transparent 12px\n );\n z-index: 1;\n }\n\n :host([readonly]) textarea {\n cursor: text;\n }\n\n .editor-wrapper {\n position: relative;\n width: 100%;\n flex: 1;\n background: var(--bg-color);\n display: flex;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n line-height: 1.5;\n }\n\n .gutter {\n width: 24px;\n height: 100%;\n background: var(--gutter-bg);\n border-right: 1px solid var(--gutter-border);\n overflow: hidden;\n flex-shrink: 0;\n position: relative;\n }\n\n .gutter-content {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n padding: 8px 4px;\n }\n\n .gutter-line {\n position: absolute;\n left: 0;\n width: 100%;\n height: 1.5em;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .color-indicator {\n width: 12px;\n height: 12px;\n border-radius: 2px;\n border: 1px solid #555;\n cursor: pointer;\n transition: transform 0.1s;\n flex-shrink: 0;\n }\n\n .color-indicator:hover {\n transform: scale(1.2);\n border-color: #fff;\n }\n\n .collapse-button {\n width: 12px;\n height: 12px;\n background: var(--control-bg);\n border: 1px solid var(--control-border);\n border-radius: 2px;\n color: var(--control-color);\n font-size: 8px;\n font-weight: bold;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all 0.1s;\n flex-shrink: 0;\n user-select: none;\n }\n\n .collapse-button:hover {\n background: var(--control-bg);\n border-color: var(--control-color);\n transform: scale(1.1);\n }\n\n .visibility-button {\n width: 14px;\n height: 14px;\n background: transparent;\n border: none;\n color: var(--control-color);\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all 0.1s;\n flex-shrink: 0;\n opacity: 0.7;\n padding: 0;\n }\n\n .visibility-button:hover {\n opacity: 1;\n transform: scale(1.1);\n }\n\n .visibility-button.hidden {\n opacity: 0.4;\n }\n\n .visibility-button svg {\n width: 12px;\n height: 12px;\n fill: currentColor;\n }\n\n /* Hidden feature lines - grayed out */\n .line-hidden {\n opacity: 0.35;\n filter: grayscale(50%);\n }\n\n .color-picker-popup {\n position: absolute;\n background: #2d2d30;\n border: 1px solid #555;\n border-radius: 4px;\n padding: 8px;\n z-index: 1000;\n box-shadow: 0 4px 12px rgba(0,0,0,0.5);\n }\n\n .color-picker-popup input[type=\"color\"] {\n width: 150px;\n height: 30px;\n border: none;\n cursor: pointer;\n }\n\n .editor-content {\n position: relative;\n flex: 1;\n overflow: hidden;\n }\n\n .highlight-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n padding: 8px 12px;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n line-height: 1.5;\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow: auto;\n pointer-events: none;\n z-index: 1;\n color: var(--text-color);\n }\n\n .highlight-layer::-webkit-scrollbar {\n display: none;\n }\n\n textarea {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n padding: 8px 12px;\n margin: 0;\n border: none;\n outline: none;\n background: transparent;\n color: transparent;\n caret-color: var(--caret-color);\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n line-height: 1.5;\n white-space: pre-wrap;\n word-wrap: break-word;\n resize: none;\n overflow: auto;\n z-index: 2;\n box-sizing: border-box;\n }\n\n textarea::selection {\n background: rgba(51, 153, 255, 0.3);\n }\n\n textarea::placeholder {\n color: transparent;\n }\n\n .placeholder-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n padding: 8px 12px;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n font-weight: normal;\n font-style: normal;\n line-height: 1.5;\n white-space: pre-wrap;\n word-wrap: break-word;\n color: #6a6a6a;\n pointer-events: none;\n z-index: 0;\n overflow: hidden;\n }\n\n textarea:disabled {\n cursor: not-allowed;\n opacity: 0.6;\n }\n\n /* Syntax highlighting colors */\n .json-key {\n color: var(--json-key);\n }\n\n .json-string {\n color: var(--json-string);\n }\n\n .json-number {\n color: var(--json-number);\n }\n\n .json-boolean {\n color: var(--json-boolean);\n }\n\n .json-null {\n color: var(--json-null);\n }\n\n .json-punctuation {\n color: var(--json-punct);\n }\n\n /* GeoJSON-specific highlighting */\n .geojson-key {\n color: var(--geojson-key);\n font-weight: 600;\n }\n\n .geojson-type {\n color: var(--geojson-type);\n font-weight: 600;\n }\n\n .geojson-type-invalid {\n color: var(--geojson-type-invalid);\n font-weight: 600;\n }\n\n .json-key-invalid {\n color: var(--json-key-invalid);\n }\n\n /* Prefix and suffix styling */\n .editor-prefix,\n .editor-suffix {\n padding: 4px 12px;\n color: var(--text-color);\n background: var(--bg-color);\n user-select: none;\n white-space: pre-wrap;\n word-wrap: break-word;\n flex-shrink: 0;\n font-family: 'Courier New', Courier, monospace;\n font-size: 13px;\n line-height: 1.5;\n opacity: 0.6;\n border-left: 3px solid rgba(102, 126, 234, 0.5);\n }\n\n .editor-prefix {\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n }\n\n .editor-suffix {\n border-top: 1px solid rgba(255, 255, 255, 0.1);\n }\n\n /* Scrollbar styling - WebKit (Chrome, Safari, Edge) */\n textarea::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n }\n\n textarea::-webkit-scrollbar-track {\n background: var(--control-bg);\n }\n\n textarea::-webkit-scrollbar-thumb {\n background: var(--control-border);\n border-radius: 5px;\n }\n\n textarea::-webkit-scrollbar-thumb:hover {\n background: var(--control-color);\n }\n\n /* Scrollbar styling - Firefox */\n textarea {\n scrollbar-width: thin;\n scrollbar-color: var(--control-border) var(--control-bg);\n }\n </style>\n "+e}setupEventListeners(){const e=this.shadowRoot.getElementById("textarea"),t=this.shadowRoot.getElementById("highlightLayer");e.addEventListener("scroll",()=>{t.scrollTop=e.scrollTop,t.scrollLeft=e.scrollLeft,this.syncGutterScroll(e.scrollTop)}),e.addEventListener("input",()=>{this.updatePlaceholderVisibility(),clearTimeout(this.highlightTimer),this.highlightTimer=setTimeout(()=>{this.autoFormatContentWithCursor(),this.updateHighlight(),this.emitChange()},150)}),e.addEventListener("paste",()=>{clearTimeout(this.highlightTimer),setTimeout(()=>{this.updatePlaceholderVisibility(),this.autoFormatContentWithCursor(),this.updateHighlight(),this.emitChange(),this.applyAutoCollapsed()},10)}),this.shadowRoot.getElementById("gutterContent").addEventListener("click",e=>{const t=e.target.closest(".visibility-button");if(t){const e=t.dataset.featureKey;return void this.toggleFeatureVisibility(e)}if(e.target.classList.contains("color-indicator")){const t=parseInt(e.target.dataset.line),n=e.target.dataset.color,o=e.target.dataset.attributeName;this.showColorPicker(e.target,t,n,o)}else if(e.target.classList.contains("collapse-button")){const t=e.target.dataset.nodeKey,n=parseInt(e.target.dataset.line);this.toggleCollapse(t,n)}}),this.shadowRoot.querySelector(".gutter").addEventListener("wheel",t=>{t.preventDefault(),e.scrollTop+=t.deltaY}),e.addEventListener("keydown",e=>{this.handleKeydownInCollapsedArea(e)}),e.addEventListener("copy",e=>{this.handleCopyWithCollapsedContent(e)}),e.addEventListener("cut",e=>{this.handleCutWithCollapsedContent(e)}),this.updateReadonly()}syncGutterScroll(e){this.shadowRoot.getElementById("gutterContent").style.transform=`translateY(-${e}px)`}updateReadonly(){const e=this.shadowRoot.getElementById("textarea");e&&(e.disabled=this.readonly)}escapeHtml(e){return e?e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"):""}updatePlaceholderVisibility(){const e=this.shadowRoot.getElementById("textarea"),t=this.shadowRoot.getElementById("placeholderLayer");e&&t&&(t.style.display=e.value?"none":"block")}updatePlaceholderContent(){const e=this.shadowRoot.getElementById("placeholderLayer");e&&(e.textContent=this.placeholder),this.updatePlaceholderVisibility()}updateValue(e){const t=this.shadowRoot.getElementById("textarea");if(t&&t.value!==e){if(t.value=e||"",e)try{const n=this.prefix,o=this.suffix,s=n.trimEnd().endsWith("["),i=o.trimStart().startsWith("]");if(s&&i){const n="["+e+"]",o=JSON.parse(n),s=JSON.stringify(o,null,2).split("\n");s.length>2?t.value=s.slice(1,-1).join("\n"):t.value=""}else if(!n&&!o){const n=JSON.parse(e);t.value=JSON.stringify(n,null,2)}}catch{}this.updateHighlight(),this.updatePlaceholderVisibility(),t.value&&requestAnimationFrame(()=>{this.applyAutoCollapsed()}),this.emitChange()}}updatePrefixSuffix(){const e=this.shadowRoot.getElementById("editorPrefix"),t=this.shadowRoot.getElementById("editorSuffix");e&&(this.prefix?(e.textContent=this.prefix,e.style.display="block"):(e.textContent="",e.style.display="none")),t&&(this.suffix?(t.textContent=this.suffix,t.style.display="block"):(t.textContent="",t.style.display="none"))}updateHighlight(){const e=this.shadowRoot.getElementById("textarea"),t=this.shadowRoot.getElementById("highlightLayer");if(!e||!t)return;const n=e.value;this.updateFeatureRanges();const o=this.getHiddenLineRanges(),{highlighted:s,colors:i,toggles:r}=this.highlightJSON(n,o);t.innerHTML=s,this.colorPositions=i,this.nodeTogglePositions=r,this.updateGutter()}highlightJSON(t,n=[]){if(!t.trim())return{highlighted:"",colors:[],toggles:[]};const o=t.split("\n"),s=[],i=[];let r=[];const a=this.buildContextMap(t);return o.forEach((t,o)=>{const l=e.REGEX;let d;for(l.colorInLine.lastIndex=0;null!==(d=l.colorInLine.exec(t));)s.push({line:o,color:d[2],attributeName:d[1]});const h=t.match(l.collapsibleNode);if(h){const e=h[2];t.includes("{...}")||t.includes("[...]")?i.push({line:o,nodeKey:e,isCollapsed:!0}):this.bracketClosesOnSameLine(t,h[3])||i.push({line:o,nodeKey:e,isCollapsed:!1})}const c=a.get(o);let p=this.highlightSyntax(t,c);(e=>n.some(t=>e>=t.startLine&&e<=t.endLine))(o)&&(p=`<span class="line-hidden">${p}</span>`),r.push(p)}),{highlighted:r.join("\n"),colors:s,toggles:i}}buildContextMap(t){var n;const o=t.split("\n"),s=new Map,i=[];let r=null;const a=this.featureCollection?"Feature":null;for(let t=0;t<o.length;t++){const l=o[t],d=i.length>0?null==(n=i[i.length-1])?void 0:n.context:a;s.set(t,d);for(let t=0;t<l.length;t++){const n=l[t];if('"'===n){const n=l.substring(t).match(/^"([^"]+)"\s*:/);if(n){const o=n[1];e.CONTEXT_CHANGING_KEYS[o]&&(r=e.CONTEXT_CHANGING_KEYS[o]),t+=n[0].length-1;continue}}if('"'===n&&i.length>0&&l.substring(0,t).match(/"type"\s*:\s*$/)){const n=l.substring(t).match(/^"([^"]+)"/);if(n&&e.GEOJSON_TYPES_ALL.includes(n[1])){const e=i[i.length-1];e&&(e.context=n[1])}}if("{"===n||"["===n){let e;if(r)e=r,r=null;else if(0===i.length)e=a;else{const t=i[i.length-1];e=t&&t.isArray?t.context:null}i.push({context:e,isArray:"["===n})}("}"===n||"]"===n)&&i.length>0&&i.pop()}}return s}highlightSyntax(t,n){if(!t.trim())return"";const o=n?e.VALID_KEYS_BY_CONTEXT[n]:null,s=e.REGEX;return t.replace(s.ampersand,"&").replace(s.lessThan,"<").replace(s.greaterThan,">").replace(s.jsonKey,(t,s)=>"properties"===n?`<span class="json-key">"${s}"</span>:`:e.GEOJSON_STRUCTURAL_KEYS.includes(s)?`<span class="geojson-key">"${s}"</span>:`:(t=>!(!e.GEOJSON_STRUCTURAL_KEYS.includes(t)&&n&&null!=o)||o.includes(t))(s)?`<span class="json-key">"${s}"</span>:`:`<span class="json-key-invalid">"${s}"</span>:`).replace(s.typeValue,(t,o)=>(t=>!n||"properties"===n||("geometry"===n||e.GEOJSON_TYPES_GEOMETRY.includes(n)?e.GEOJSON_TYPES_GEOMETRY.includes(t):"Feature"!==n&&"FeatureCollection"!==n||e.GEOJSON_TYPES_ALL.includes(t)))(o)?`<span class="geojson-key">"type"</span>: <span class="geojson-type">"${o}"</span>`:`<span class="geojson-key">"type"</span>: <span class="geojson-type-invalid">"${o}"</span>`).replace(s.stringValue,(e,t)=>e.includes("<span")?e:`: <span class="json-string">"${t}"</span>`).replace(s.numberAfterColon,': <span class="json-number">$1</span>').replace(s.boolean,': <span class="json-boolean">$1</span>').replace(s.nullValue,': <span class="json-null">$1</span>').replace(s.allNumbers,'<span class="json-number">$1</span>').replace(s.punctuation,'<span class="json-punctuation">$1</span>')}toggleCollapse(e,t){const n=this.shadowRoot.getElementById("textarea"),o=n.value.split("\n"),s=o[t];if(s.includes("{...}")||s.includes("[...]")){let n=null,i=null;const r=`${t}-${e}`;if(this.collapsedData.has(r))n=r,i=this.collapsedData.get(r);else for(const[t,o]of this.collapsedData.entries())if(o.nodeKey===e){const e=s.match(/^(\s*)/)[1].length;if(o.indent===e){n=t,i=o;break}}if(!n||!i)return;const{originalLine:a,content:l}=i;o[t]=a,o.splice(t+1,0,...l),this.collapsedData.delete(n)}else{const n=s.match(/^(\s*)"([^"]+)"\s*:\s*([{\[])/);if(!n)return;const i=n[1],r=n[3],a="{"===r?"}":"]";if(this.bracketClosesOnSameLine(s,r))return;let l=1,d=t;const h=[];for(let e=t+1;e<o.length;e++){const t=o[e];for(const e of t)e===r&&l++,e===a&&l--;if(h.push(t),0===l){d=e;break}}const c=`${t}-${e}`;this.collapsedData.set(c,{originalLine:s,content:h,indent:i.length,nodeKey:e});const p=s.substring(0,s.indexOf(r)),u=o[d]&&o[d].trim().endsWith(",");o[t]=`${p}${r}...${a}${u?",":""}`,o.splice(t+1,d-t)}n.value=o.join("\n"),this.updateHighlight()}applyAutoCollapsed(){const e=this.shadowRoot.getElementById("textarea");if(!e||!e.value)return;const t=e.value.split("\n");for(let e=t.length-1;e>=0;e--){const n=t[e],o=n.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);if(o){const s=o[2];if("coordinates"===s){const i=o[1],r=o[3],a="{"===r?"}":"]";if(this.bracketClosesOnSameLine(n,r))continue;let l=1,d=e;const h=[];for(let n=e+1;n<t.length;n++){const e=t[n];for(const t of e)t===r&&l++,t===a&&l--;if(h.push(e),0===l){d=n;break}}const c=`${e}-${s}`;this.collapsedData.set(c,{originalLine:n,content:h,indent:i.length,nodeKey:s});const p=n.substring(0,n.indexOf(r)),u=t[d]&&t[d].trim().endsWith(",");t[e]=`${p}${r}...${a}${u?",":""}`,t.splice(e+1,d-e)}}}e.value=t.join("\n"),this.updateHighlight()}updateGutter(){const t=this.shadowRoot.getElementById("gutterContent"),n=this.shadowRoot.getElementById("textarea");if(!n)return;if(null===this._cachedLineHeight){const e=getComputedStyle(n);this._cachedLineHeight=parseFloat(e.lineHeight),this._cachedPaddingTop=parseFloat(e.paddingTop)}const o=this._cachedLineHeight,s=this._cachedPaddingTop;t.textContent="";const i=new Map,r=e=>(i.has(e)||i.set(e,{colors:[],buttons:[],visibilityButtons:[]}),i.get(e));this.colorPositions.forEach(({line:e,color:t,attributeName:n})=>{r(e).colors.push({color:t,attributeName:n})}),this.nodeTogglePositions.forEach(({line:e,nodeKey:t,isCollapsed:n})=>{r(e).buttons.push({nodeKey:t,isCollapsed:n})});for(const[e,t]of this.featureRanges){const n=this.hiddenFeatures.has(e);r(t.startLine).visibilityButtons.push({featureKey:e,isHidden:n})}const a=document.createDocumentFragment();i.forEach((t,n)=>{const i=document.createElement("div");i.className="gutter-line",i.style.top=`${s+n*o}px`,t.visibilityButtons.forEach(({featureKey:t,isHidden:n})=>{const o=document.createElement("button");o.className="visibility-button"+(n?" hidden":""),o.innerHTML=e.ICON_EYE,o.dataset.featureKey=t,o.title=n?"Show feature in events":"Hide feature from events",i.appendChild(o)}),t.colors.forEach(({color:e,attributeName:t})=>{const o=document.createElement("div");o.className="color-indicator",o.style.backgroundColor=e,o.dataset.line=n,o.dataset.color=e,o.dataset.attributeName=t,o.title=`${t}: ${e}`,i.appendChild(o)}),t.buttons.forEach(({nodeKey:e,isCollapsed:t})=>{const o=document.createElement("div");o.className="collapse-button",o.textContent=t?"+":"-",o.dataset.line=n,o.dataset.nodeKey=e,o.title=t?"Expand":"Collapse",i.appendChild(o)}),a.appendChild(i)}),t.appendChild(a)}showColorPicker(e,t,n,o){const s=document.querySelector(".geojson-color-picker-input");s&&s.remove();const i=document.createElement("input");i.type="color",i.value=n,i.className="geojson-color-picker-input";const r=e.getBoundingClientRect();i.style.position="fixed",i.style.left=`${r.left}px`,i.style.top=`${r.top}px`,i.style.width="12px",i.style.height="12px",i.style.opacity="0.01",i.style.border="none",i.style.padding="0",i.style.zIndex="9999",i.addEventListener("input",e=>{this.updateColorValue(t,e.target.value,o)}),i.addEventListener("change",e=>{this.updateColorValue(t,e.target.value,o)});const a=e=>{e.target!==i&&!i.contains(e.target)&&(i.remove(),document.removeEventListener("click",a,!0))};document.body.appendChild(i),setTimeout(()=>{document.addEventListener("click",a,!0)},100),i.focus(),i.click()}updateColorValue(e,t,n){const o=this.shadowRoot.getElementById("textarea"),s=o.value.split("\n"),i=new RegExp(`"${n}"\\s*:\\s*"#[0-9a-fA-F]{6}"`);s[e]=s[e].replace(i,`"${n}": "${t}"`),o.value=s.join("\n"),this.updateHighlight(),this.emitChange()}handleKeydownInCollapsedArea(e){if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Tab"].includes(e.key)||e.ctrlKey||e.metaKey)return;const t=this.shadowRoot.getElementById("textarea"),n=t.selectionStart,o=t.value.substring(0,n).split("\n").length-1,s=t.value.split("\n")[o];s&&(s.includes("{...}")||s.includes("[...]"))&&e.preventDefault()}handleCopyWithCollapsedContent(e){const t=this.shadowRoot.getElementById("textarea"),n=t.selectionStart,o=t.selectionEnd;if(n===o)return;const s=t.value.substring(n,o);if(!s.includes("{...}")&&!s.includes("[...]"))return;const i=this.expandCollapsedMarkersInText(s,n);e.preventDefault(),e.clipboardData.setData("text/plain",i)}expandCollapsedMarkersInText(e,t){const n=this.shadowRoot.getElementById("textarea").value.substring(0,t).split("\n").length-1,o=e.split("\n"),s=[];return o.forEach((e,t)=>{const o=n+t;if(e.includes("{...}")||e.includes("[...]")){let t=!1;this.collapsedData.forEach((e,n)=>{parseInt(n.split("-")[0])===o&&(s.push(e.originalLine),s.push(...e.content),t=!0)}),t||s.push(e)}else s.push(e)}),s.join("\n")}handleCutWithCollapsedContent(e){this.handleCopyWithCollapsedContent(e);const t=this.shadowRoot.getElementById("textarea"),n=t.selectionStart,o=t.selectionEnd;if(n!==o){const e=t.value;t.value=e.substring(0,n)+e.substring(o),t.selectionStart=t.selectionEnd=n,this.updateHighlight(),this.updatePlaceholderVisibility(),this.emitChange()}}emitChange(){const e=this.shadowRoot.getElementById("textarea"),t=this.expandAllCollapsed(e.value),n=this.prefix+t+this.suffix;try{let e=JSON.parse(n);e=this.filterHiddenFeatures(e);const o=this.validateGeoJSON(e);o.length>0?this.dispatchEvent(new CustomEvent("error",{detail:{timestamp:(new Date).toISOString(),error:`GeoJSON validation: ${o.join("; ")}`,errors:o,content:t},bubbles:!0,composed:!0})):this.dispatchEvent(new CustomEvent("change",{detail:e,bubbles:!0,composed:!0}))}catch(e){this.dispatchEvent(new CustomEvent("error",{detail:{timestamp:(new Date).toISOString(),error:e.message,content:t},bubbles:!0,composed:!0}))}}filterHiddenFeatures(e){if(!e||0===this.hiddenFeatures.size)return e;if("FeatureCollection"===e.type&&Array.isArray(e.features)){const t=e.features.filter(e=>{const t=this.getFeatureKey(e);return!this.hiddenFeatures.has(t)});return{...e,features:t}}if("Feature"===e.type){const t=this.getFeatureKey(e);if(this.hiddenFeatures.has(t))return{type:"FeatureCollection",features:[]}}return e}getFeatureKey(e){var t,n,o;if(!e||"object"!=typeof e)return null;if(void 0!==e.id)return`id:${e.id}`;if(void 0!==(null==(t=e.properties)?void 0:t.id))return`prop:${e.properties.id}`;const s=(null==(n=e.geometry)?void 0:n.type)||"null",i=JSON.stringify((null==(o=e.geometry)?void 0:o.coordinates)||[]).slice(0,100);return`hash:${s}:${this.simpleHash(i)}`}simpleHash(e){let t=0;for(let n=0;n<e.length;n++)t=(t<<5)-t+e.charCodeAt(n),t&=t;return t.toString(36)}toggleFeatureVisibility(e){this.hiddenFeatures.has(e)?this.hiddenFeatures.delete(e):this.hiddenFeatures.add(e),this.updateHighlight(),this.updateGutter(),this.emitChange()}isFeatureHidden(e){return this.hiddenFeatures.has(e)}updateFeatureRanges(){const e=this.shadowRoot.getElementById("textarea");if(!e)return;const t=e.value;this.featureRanges.clear();try{const e=this.expandAllCollapsed(t),n=this.prefix+e+this.suffix,o=JSON.parse(n);let s=[];"FeatureCollection"===o.type&&Array.isArray(o.features)?s=o.features:"Feature"===o.type&&(s=[o]);const i=t.split("\n");let r=0,a=0,l=!1,d=-1,h=null;for(let e=0;e<i.length;e++){const t=i[e],n=/"type"\s*:\s*"Feature"/.test(t);if(!l&&n){let t=e;for(let n=e;n>=0;n--)if(i[n].includes("{")){t=n;break}d=t,l=!0,a=1;for(let n=t;n<=e;n++){const e=i[n];let o=n===t;for(const t of e)"{"===t?o?o=!1:a++:"}"===t&&a--}r<s.length&&(h=this.getFeatureKey(s[r]))}else if(l){for(const e of t)"{"===e?a++:"}"===e&&a--;a<=0&&(h&&this.featureRanges.set(h,{startLine:d,endLine:e,featureIndex:r}),r++,l=!1,h=null)}}}catch{}}getHiddenLineRanges(){const e=[];for(const[t,n]of this.featureRanges)this.hiddenFeatures.has(t)&&e.push(n);return e}validateGeoJSON(t,n="",o="root"){const s=[];if(!t||"object"!=typeof t)return s;if("properties"!==o&&void 0!==t.type){const i=t.type;"string"==typeof i&&("geometry"===o?e.GEOJSON_TYPES_GEOMETRY.includes(i)||s.push(`Invalid geometry type "${i}" at ${n||"root"} (expected: ${e.GEOJSON_TYPES_GEOMETRY.join(", ")})`):e.GEOJSON_TYPES_FEATURE.includes(i)||s.push(`Invalid type "${i}" at ${n||"root"} (expected: ${e.GEOJSON_TYPES_FEATURE.join(", ")})`))}if(Array.isArray(t))t.forEach((e,t)=>{s.push(...this.validateGeoJSON(e,`${n}[${t}]`,o))});else for(const[e,i]of Object.entries(t))if("object"==typeof i&&null!==i){const t=n?`${n}.${e}`:e;let r=o;"properties"===e?r="properties":"geometry"===e||"geometries"===e?r="geometry":"features"===e&&(r="root"),s.push(...this.validateGeoJSON(i,t,r))}return s}bracketClosesOnSameLine(e,t){const n="{"===t?"}":"]",o=e.indexOf(t);if(-1===o)return!1;const s=e.substring(o+1);let i=1;for(const e of s)if(e===t&&i++,e===n&&i--,0===i)return!0;return!1}expandAllCollapsed(t){const n=e.REGEX;for(;t.includes("{...}")||t.includes("[...]");){const e=t.split("\n");let o=!1;for(let t=0;t<e.length;t++){const s=e[t];if(!s.includes("{...}")&&!s.includes("[...]"))continue;const i=s.match(n.collapsedMarker);if(!i)continue;const r=i[2],a=i[1].length,l=`${t}-${r}`;let d=this.collapsedData.has(l)?l:null;if(!d)for(const[e,t]of this.collapsedData.entries())if(t.nodeKey===r&&t.indent===a){d=e;break}if(d){const{originalLine:n,content:s}=this.collapsedData.get(d);e[t]=n,e.splice(t+1,0,...s),o=!0;break}}if(!o)break;t=e.join("\n")}return t}formatJSONContent(e){const t=this.prefix,n=this.suffix,o=t.trimEnd().endsWith("["),s=n.trimStart().startsWith("]");if(o&&s){const t="["+e+"]",n=JSON.parse(t),o=JSON.stringify(n,null,2).split("\n");return o.length>2?o.slice(1,-1).join("\n"):""}if(t||n){const o=t+e+n;return JSON.parse(o),e}{const t=JSON.parse(e);return JSON.stringify(t,null,2)}}autoFormatContentWithCursor(){const e=this.shadowRoot.getElementById("textarea"),t=e.selectionStart,n=e.value.substring(0,t).split("\n"),o=n.length-1,s=n[n.length-1].length,i=Array.from(this.collapsedData.values()).map(e=>({nodeKey:e.nodeKey,indent:e.indent})),r=this.expandAllCollapsed(e.value);try{const t=this.formatJSONContent(r);if(t!==r){this.collapsedData.clear(),e.value=t,i.length>0&&this.reapplyCollapsed(i);const n=e.value.split("\n");if(o<n.length){const t=Math.min(s,n[o].length);let i=0;for(let e=0;e<o;e++)i+=n[e].length+1;i+=t,e.setSelectionRange(i,i)}}}catch{}}autoFormatContent(){const e=this.shadowRoot.getElementById("textarea"),t=Array.from(this.collapsedData.values()).map(e=>({nodeKey:e.nodeKey,indent:e.indent})),n=this.expandAllCollapsed(e.value);try{const o=this.formatJSONContent(n);o!==n&&(this.collapsedData.clear(),e.value=o,t.length>0&&this.reapplyCollapsed(t))}catch{}}reapplyCollapsed(e){const t=this.shadowRoot.getElementById("textarea"),n=t.value.split("\n"),o=new Map;e.forEach(({nodeKey:e,indent:t})=>{const n=`${e}-${t}`;o.set(n,(o.get(n)||0)+1)});const s=new Map;for(let e=n.length-1;e>=0;e--){const t=n[e],i=t.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);if(i){const r=i[2],a=`${r}-${i[1].length}`;if(o.has(a)&&(s.set(a,(s.get(a)||0)+1),s.get(a)<=o.get(a))){const o=i[1],s=i[3],a="{"===s?"}":"]";if(this.bracketClosesOnSameLine(t,s))continue;let l=1,d=e;const h=[];for(let t=e+1;t<n.length;t++){const e=n[t];for(const t of e)t===s&&l++,t===a&&l--;if(h.push(e),0===l){d=t;break}}const c=`${e}-${r}`;this.collapsedData.set(c,{originalLine:t,content:h,indent:o.length,nodeKey:r});const p=t.substring(0,t.indexOf(s)),u=n[d]&&n[d].trim().endsWith(",");n[e]=`${p}${s}...${a}${u?",":""}`,n.splice(e+1,d-e)}}}t.value=n.join("\n")}parseSelectorToHostRule(e){return e&&""!==e?e.startsWith(".")&&!e.includes(" ")?`:host(${e})`:`:host-context(${e})`:':host([data-color-scheme="dark"])'}updateThemeCSS(){const e=this.getAttribute("dark-selector")||".dark",t=this.parseSelectorToHostRule(e);let n=this.shadowRoot.getElementById("theme-styles");n||(n=document.createElement("style"),n.id="theme-styles",this.shadowRoot.insertBefore(n,this.shadowRoot.firstChild));const o=`\n :host {\n --bg-color: ${this.themes.light.background};\n --text-color: ${this.themes.light.textColor};\n --caret-color: ${this.themes.light.caretColor};\n --gutter-bg: ${this.themes.light.gutterBackground};\n --gutter-border: ${this.themes.light.gutterBorder};\n --json-key: ${this.themes.light.jsonKey};\n --json-string: ${this.themes.light.jsonString};\n --json-number: ${this.themes.light.jsonNumber};\n --json-boolean: ${this.themes.light.jsonBoolean};\n --json-null: ${this.themes.light.jsonNull};\n --json-punct: ${this.themes.light.jsonPunctuation};\n --control-color: ${this.themes.light.controlColor};\n --control-bg: ${this.themes.light.controlBg};\n --control-border: ${this.themes.light.controlBorder};\n --geojson-key: ${this.themes.light.geojsonKey};\n --geojson-type: ${this.themes.light.geojsonType};\n --geojson-type-invalid: ${this.themes.light.geojsonTypeInvalid};\n --json-key-invalid: ${this.themes.light.jsonKeyInvalid};\n }\n\n ${t} {\n --bg-color: ${this.themes.dark.background};\n --text-color: ${this.themes.dark.textColor};\n --caret-color: ${this.themes.dark.caretColor};\n --gutter-bg: ${this.themes.dark.gutterBackground};\n --gutter-border: ${this.themes.dark.gutterBorder};\n --json-key: ${this.themes.dark.jsonKey};\n --json-string: ${this.themes.dark.jsonString};\n --json-number: ${this.themes.dark.jsonNumber};\n --json-boolean: ${this.themes.dark.jsonBoolean};\n --json-null: ${this.themes.dark.jsonNull};\n --json-punct: ${this.themes.dark.jsonPunctuation};\n --control-color: ${this.themes.dark.controlColor};\n --control-bg: ${this.themes.dark.controlBg};\n --control-border: ${this.themes.dark.controlBorder};\n --geojson-key: ${this.themes.dark.geojsonKey};\n --geojson-type: ${this.themes.dark.geojsonType};\n --geojson-type-invalid: ${this.themes.dark.geojsonTypeInvalid};\n --json-key-invalid: ${this.themes.dark.jsonKeyInvalid};\n }\n `;n.textContent=o}getTheme(){return{dark:{...this.themes.dark},light:{...this.themes.light}}}setTheme(e){e.dark&&(this.themes.dark={...this.themes.dark,...e.dark}),e.light&&(this.themes.light={...this.themes.light,...e.light}),this.updateThemeCSS()}resetTheme(){this.themes={dark:{...e.DEFAULT_THEMES.dark},light:{...e.DEFAULT_THEMES.light}},this.updateThemeCSS()}};n(o,"DEFAULT_THEMES",{dark:{background:"#1e1e1e",textColor:"#d4d4d4",caretColor:"#fff",gutterBackground:"#252526",gutterBorder:"#3e3e42",jsonKey:"#9cdcfe",jsonString:"#ce9178",jsonNumber:"#b5cea8",jsonBoolean:"#569cd6",jsonNull:"#569cd6",jsonPunctuation:"#d4d4d4",controlColor:"#c586c0",controlBg:"#3e3e42",controlBorder:"#555",geojsonKey:"#c586c0",geojsonType:"#4ec9b0",geojsonTypeInvalid:"#f44747",jsonKeyInvalid:"#f44747"},light:{background:"#ffffff",textColor:"#333333",caretColor:"#000",gutterBackground:"#f5f5f5",gutterBorder:"#ddd",jsonKey:"#0000ff",jsonString:"#a31515",jsonNumber:"#098658",jsonBoolean:"#0000ff",jsonNull:"#0000ff",jsonPunctuation:"#333333",controlColor:"#a31515",controlBg:"#e0e0e0",controlBorder:"#999",geojsonKey:"#af00db",geojsonType:"#267f99",geojsonTypeInvalid:"#d32f2f",jsonKeyInvalid:"#d32f2f"}}),n(o,"FEATURE_COLLECTION_PREFIX",'{"type": "FeatureCollection", "features": ['),n(o,"FEATURE_COLLECTION_SUFFIX","]}"),n(o,"ICON_EYE",'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z"/></svg>'),n(o,"REGEX",{ampersand:/&/g,lessThan:/</g,greaterThan:/>/g,jsonKey:/"([^"]+)"\s*:/g,typeValue:/<span class="geojson-key">"type"<\/span>:\s*"([^"]*)"/g,stringValue:/:\s*"([^"]*)"/g,numberAfterColon:/:\s*(-?\d+\.?\d*)/g,boolean:/:\s*(true|false)/g,nullValue:/:\s*(null)/g,allNumbers:/\b(-?\d+\.?\d*)\b/g,punctuation:/([{}[\],])/g,colorInLine:/"([\w-]+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,collapsibleNode:/^(\s*)"(\w+)"\s*:\s*([{\[])/,collapsedMarker:/^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/}),n(o,"GEOJSON_TYPES_FEATURE",["Feature","FeatureCollection"]),n(o,"GEOJSON_TYPES_GEOMETRY",["Point","MultiPoint","LineString","MultiLineString","Polygon","MultiPolygon","GeometryCollection"]),n(o,"GEOJSON_TYPES_ALL",[...o.GEOJSON_TYPES_FEATURE,...o.GEOJSON_TYPES_GEOMETRY]),n(o,"VALID_KEYS_BY_CONTEXT",{Feature:["type","geometry","properties","id","bbox"],FeatureCollection:["type","features","bbox","properties"],Point:["type","coordinates","bbox"],MultiPoint:["type","coordinates","bbox"],LineString:["type","coordinates","bbox"],MultiLineString:["type","coordinates","bbox"],Polygon:["type","coordinates","bbox"],MultiPolygon:["type","coordinates","bbox"],GeometryCollection:["type","geometries","bbox"],properties:null,geometry:["type","coordinates","geometries","bbox"]}),n(o,"CONTEXT_CHANGING_KEYS",{geometry:"geometry",properties:"properties",features:"Feature",geometries:"geometry"}),n(o,"GEOJSON_STRUCTURAL_KEYS",["type","geometry","properties","features","geometries","coordinates","bbox","id","crs"]);let s=o;customElements.define("geojson-editor",s);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softwarity/geojson-editor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "A feature-rich GeoJSON editor Web Component with syntax highlighting, collapsible nodes, and color picker",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/geojson-editor.js",
|
package/src/geojson-editor.js
CHANGED
|
@@ -7,6 +7,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
7
7
|
this.collapsedData = new Map(); // nodeKey -> {originalLines: string[], indent: number}
|
|
8
8
|
this.colorPositions = []; // {line, color}
|
|
9
9
|
this.nodeTogglePositions = []; // {line, nodeKey, isCollapsed, indent}
|
|
10
|
+
this.hiddenFeatures = new Set(); // Set of feature keys (hidden from events)
|
|
11
|
+
this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
|
|
10
12
|
|
|
11
13
|
// Debounce timer for syntax highlighting
|
|
12
14
|
this.highlightTimer = null;
|
|
@@ -23,7 +25,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
static get observedAttributes() {
|
|
26
|
-
return ['readonly', 'value', 'placeholder', '
|
|
28
|
+
return ['readonly', 'value', 'placeholder', 'dark-selector', 'feature-collection'];
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
|
|
@@ -75,6 +77,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
75
77
|
static FEATURE_COLLECTION_PREFIX = '{"type": "FeatureCollection", "features": [';
|
|
76
78
|
static FEATURE_COLLECTION_SUFFIX = ']}';
|
|
77
79
|
|
|
80
|
+
// SVG icon for visibility toggle (single icon, style changes based on state)
|
|
81
|
+
static ICON_EYE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z"/></svg>';
|
|
82
|
+
|
|
78
83
|
// Pre-compiled regex patterns (avoid recompilation on each call)
|
|
79
84
|
static REGEX = {
|
|
80
85
|
// HTML escaping
|
|
@@ -91,7 +96,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
91
96
|
allNumbers: /\b(-?\d+\.?\d*)\b/g,
|
|
92
97
|
punctuation: /([{}[\],])/g,
|
|
93
98
|
// Highlighting detection
|
|
94
|
-
colorInLine: /"(\w+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,
|
|
99
|
+
colorInLine: /"([\w-]+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,
|
|
95
100
|
collapsibleNode: /^(\s*)"(\w+)"\s*:\s*([{\[])/,
|
|
96
101
|
collapsedMarker: /^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/
|
|
97
102
|
};
|
|
@@ -126,13 +131,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
126
131
|
this.updateThemeCSS();
|
|
127
132
|
} else if (name === 'feature-collection') {
|
|
128
133
|
this.updatePrefixSuffix();
|
|
129
|
-
} else if (name === 'auto-format') {
|
|
130
|
-
// When auto-format is enabled, format the current content
|
|
131
|
-
const textarea = this.shadowRoot?.getElementById('textarea');
|
|
132
|
-
if (textarea && textarea.value && this.autoFormat) {
|
|
133
|
-
this.autoFormatContent();
|
|
134
|
-
this.updateHighlight();
|
|
135
|
-
}
|
|
136
134
|
}
|
|
137
135
|
}
|
|
138
136
|
|
|
@@ -150,10 +148,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
150
148
|
return this.getAttribute('placeholder') || '';
|
|
151
149
|
}
|
|
152
150
|
|
|
153
|
-
get autoFormat() {
|
|
154
|
-
return this.hasAttribute('auto-format');
|
|
155
|
-
}
|
|
156
|
-
|
|
157
151
|
get featureCollection() {
|
|
158
152
|
return this.hasAttribute('feature-collection');
|
|
159
153
|
}
|
|
@@ -301,6 +295,43 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
301
295
|
transform: scale(1.1);
|
|
302
296
|
}
|
|
303
297
|
|
|
298
|
+
.visibility-button {
|
|
299
|
+
width: 14px;
|
|
300
|
+
height: 14px;
|
|
301
|
+
background: transparent;
|
|
302
|
+
border: none;
|
|
303
|
+
color: var(--control-color);
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
display: flex;
|
|
306
|
+
align-items: center;
|
|
307
|
+
justify-content: center;
|
|
308
|
+
transition: all 0.1s;
|
|
309
|
+
flex-shrink: 0;
|
|
310
|
+
opacity: 0.7;
|
|
311
|
+
padding: 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.visibility-button:hover {
|
|
315
|
+
opacity: 1;
|
|
316
|
+
transform: scale(1.1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.visibility-button.hidden {
|
|
320
|
+
opacity: 0.4;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.visibility-button svg {
|
|
324
|
+
width: 12px;
|
|
325
|
+
height: 12px;
|
|
326
|
+
fill: currentColor;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* Hidden feature lines - grayed out */
|
|
330
|
+
.line-hidden {
|
|
331
|
+
opacity: 0.35;
|
|
332
|
+
filter: grayscale(50%);
|
|
333
|
+
}
|
|
334
|
+
|
|
304
335
|
.color-picker-popup {
|
|
305
336
|
position: absolute;
|
|
306
337
|
background: #2d2d30;
|
|
@@ -546,10 +577,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
546
577
|
|
|
547
578
|
clearTimeout(this.highlightTimer);
|
|
548
579
|
this.highlightTimer = setTimeout(() => {
|
|
549
|
-
// Auto-format
|
|
550
|
-
|
|
551
|
-
this.autoFormatContentWithCursor();
|
|
552
|
-
}
|
|
580
|
+
// Auto-format JSON content
|
|
581
|
+
this.autoFormatContentWithCursor();
|
|
553
582
|
this.updateHighlight();
|
|
554
583
|
this.emitChange();
|
|
555
584
|
}, 150);
|
|
@@ -563,10 +592,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
563
592
|
// Use a short delay to let the paste complete
|
|
564
593
|
setTimeout(() => {
|
|
565
594
|
this.updatePlaceholderVisibility();
|
|
566
|
-
// Auto-format
|
|
567
|
-
|
|
568
|
-
this.autoFormatContentWithCursor();
|
|
569
|
-
}
|
|
595
|
+
// Auto-format JSON content
|
|
596
|
+
this.autoFormatContentWithCursor();
|
|
570
597
|
this.updateHighlight();
|
|
571
598
|
this.emitChange();
|
|
572
599
|
// Auto-collapse coordinates after paste
|
|
@@ -577,6 +604,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
577
604
|
// Gutter clicks (color indicators and collapse buttons)
|
|
578
605
|
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
579
606
|
gutterContent.addEventListener('click', (e) => {
|
|
607
|
+
// Check for visibility button (may click on SVG inside button)
|
|
608
|
+
const visibilityButton = e.target.closest('.visibility-button');
|
|
609
|
+
if (visibilityButton) {
|
|
610
|
+
const featureKey = visibilityButton.dataset.featureKey;
|
|
611
|
+
this.toggleFeatureVisibility(featureKey);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
580
615
|
if (e.target.classList.contains('color-indicator')) {
|
|
581
616
|
const line = parseInt(e.target.dataset.line);
|
|
582
617
|
const color = e.target.dataset.color;
|
|
@@ -656,8 +691,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
656
691
|
if (textarea && textarea.value !== newValue) {
|
|
657
692
|
textarea.value = newValue || '';
|
|
658
693
|
|
|
659
|
-
//
|
|
660
|
-
if (
|
|
694
|
+
// Auto-format JSON content
|
|
695
|
+
if (newValue) {
|
|
661
696
|
try {
|
|
662
697
|
const prefix = this.prefix;
|
|
663
698
|
const suffix = this.suffix;
|
|
@@ -738,8 +773,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
738
773
|
|
|
739
774
|
const text = textarea.value;
|
|
740
775
|
|
|
776
|
+
// Update feature ranges for visibility tracking
|
|
777
|
+
this.updateFeatureRanges();
|
|
778
|
+
|
|
779
|
+
// Get hidden line ranges
|
|
780
|
+
const hiddenRanges = this.getHiddenLineRanges();
|
|
781
|
+
|
|
741
782
|
// Parse and highlight
|
|
742
|
-
const { highlighted, colors, toggles } = this.highlightJSON(text);
|
|
783
|
+
const { highlighted, colors, toggles } = this.highlightJSON(text, hiddenRanges);
|
|
743
784
|
|
|
744
785
|
highlightLayer.innerHTML = highlighted;
|
|
745
786
|
this.colorPositions = colors;
|
|
@@ -749,7 +790,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
749
790
|
this.updateGutter();
|
|
750
791
|
}
|
|
751
792
|
|
|
752
|
-
highlightJSON(text) {
|
|
793
|
+
highlightJSON(text, hiddenRanges = []) {
|
|
753
794
|
if (!text.trim()) {
|
|
754
795
|
return { highlighted: '', colors: [], toggles: [] };
|
|
755
796
|
}
|
|
@@ -762,6 +803,11 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
762
803
|
// Build context map for validation
|
|
763
804
|
const contextMap = this.buildContextMap(text);
|
|
764
805
|
|
|
806
|
+
// Helper to check if a line is in a hidden range
|
|
807
|
+
const isLineHidden = (lineIndex) => {
|
|
808
|
+
return hiddenRanges.some(range => lineIndex >= range.startLine && lineIndex <= range.endLine);
|
|
809
|
+
};
|
|
810
|
+
|
|
765
811
|
lines.forEach((line, lineIndex) => {
|
|
766
812
|
// Detect any hex color (6 digits) in string values
|
|
767
813
|
const R = GeoJsonEditor.REGEX;
|
|
@@ -804,7 +850,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
804
850
|
|
|
805
851
|
// Highlight the line with context
|
|
806
852
|
const context = contextMap.get(lineIndex);
|
|
807
|
-
|
|
853
|
+
let highlightedLine = this.highlightSyntax(line, context);
|
|
854
|
+
|
|
855
|
+
// Wrap hidden lines with .line-hidden class
|
|
856
|
+
if (isLineHidden(lineIndex)) {
|
|
857
|
+
highlightedLine = `<span class="line-hidden">${highlightedLine}</span>`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
highlightedLines.push(highlightedLine);
|
|
808
861
|
});
|
|
809
862
|
|
|
810
863
|
return {
|
|
@@ -1193,25 +1246,33 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1193
1246
|
// Clear gutter
|
|
1194
1247
|
gutterContent.textContent = '';
|
|
1195
1248
|
|
|
1196
|
-
// Create a map of line -> elements (color, collapse button,
|
|
1249
|
+
// Create a map of line -> elements (color, collapse button, visibility button)
|
|
1197
1250
|
const lineElements = new Map();
|
|
1198
1251
|
|
|
1199
|
-
//
|
|
1200
|
-
|
|
1252
|
+
// Helper to ensure line entry exists
|
|
1253
|
+
const ensureLine = (line) => {
|
|
1201
1254
|
if (!lineElements.has(line)) {
|
|
1202
|
-
lineElements.set(line, { colors: [], buttons: [] });
|
|
1255
|
+
lineElements.set(line, { colors: [], buttons: [], visibilityButtons: [] });
|
|
1203
1256
|
}
|
|
1204
|
-
lineElements.get(line)
|
|
1257
|
+
return lineElements.get(line);
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// Add color indicators
|
|
1261
|
+
this.colorPositions.forEach(({ line, color, attributeName }) => {
|
|
1262
|
+
ensureLine(line).colors.push({ color, attributeName });
|
|
1205
1263
|
});
|
|
1206
1264
|
|
|
1207
1265
|
// Add collapse buttons
|
|
1208
1266
|
this.nodeTogglePositions.forEach(({ line, nodeKey, isCollapsed }) => {
|
|
1209
|
-
|
|
1210
|
-
lineElements.set(line, { colors: [], buttons: [] });
|
|
1211
|
-
}
|
|
1212
|
-
lineElements.get(line).buttons.push({ nodeKey, isCollapsed });
|
|
1267
|
+
ensureLine(line).buttons.push({ nodeKey, isCollapsed });
|
|
1213
1268
|
});
|
|
1214
1269
|
|
|
1270
|
+
// Add visibility buttons for Features (on the opening brace line)
|
|
1271
|
+
for (const [featureKey, range] of this.featureRanges) {
|
|
1272
|
+
const isHidden = this.hiddenFeatures.has(featureKey);
|
|
1273
|
+
ensureLine(range.startLine).visibilityButtons.push({ featureKey, isHidden });
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1215
1276
|
// Create gutter lines with DocumentFragment (single DOM update)
|
|
1216
1277
|
const fragment = document.createDocumentFragment();
|
|
1217
1278
|
|
|
@@ -1220,6 +1281,16 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1220
1281
|
gutterLine.className = 'gutter-line';
|
|
1221
1282
|
gutterLine.style.top = `${paddingTop + line * lineHeight}px`;
|
|
1222
1283
|
|
|
1284
|
+
// Add visibility buttons first (leftmost)
|
|
1285
|
+
elements.visibilityButtons.forEach(({ featureKey, isHidden }) => {
|
|
1286
|
+
const button = document.createElement('button');
|
|
1287
|
+
button.className = 'visibility-button' + (isHidden ? ' hidden' : '');
|
|
1288
|
+
button.innerHTML = GeoJsonEditor.ICON_EYE;
|
|
1289
|
+
button.dataset.featureKey = featureKey;
|
|
1290
|
+
button.title = isHidden ? 'Show feature in events' : 'Hide feature from events';
|
|
1291
|
+
gutterLine.appendChild(button);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1223
1294
|
// Add color indicators
|
|
1224
1295
|
elements.colors.forEach(({ color, attributeName }) => {
|
|
1225
1296
|
const indicator = document.createElement('div');
|
|
@@ -1428,7 +1499,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1428
1499
|
|
|
1429
1500
|
// Try to parse
|
|
1430
1501
|
try {
|
|
1431
|
-
|
|
1502
|
+
let parsed = JSON.parse(fullValue);
|
|
1503
|
+
|
|
1504
|
+
// Filter out hidden features before emitting
|
|
1505
|
+
parsed = this.filterHiddenFeatures(parsed);
|
|
1432
1506
|
|
|
1433
1507
|
// Validate GeoJSON types
|
|
1434
1508
|
const validationErrors = this.validateGeoJSON(parsed);
|
|
@@ -1467,6 +1541,191 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1467
1541
|
}
|
|
1468
1542
|
}
|
|
1469
1543
|
|
|
1544
|
+
// Filter hidden features from parsed GeoJSON before emitting events
|
|
1545
|
+
filterHiddenFeatures(parsed) {
|
|
1546
|
+
if (!parsed || this.hiddenFeatures.size === 0) return parsed;
|
|
1547
|
+
|
|
1548
|
+
if (parsed.type === 'FeatureCollection' && Array.isArray(parsed.features)) {
|
|
1549
|
+
// Filter features array
|
|
1550
|
+
const visibleFeatures = parsed.features.filter(feature => {
|
|
1551
|
+
const key = this.getFeatureKey(feature);
|
|
1552
|
+
return !this.hiddenFeatures.has(key);
|
|
1553
|
+
});
|
|
1554
|
+
return { ...parsed, features: visibleFeatures };
|
|
1555
|
+
} else if (parsed.type === 'Feature') {
|
|
1556
|
+
// Single feature - check if hidden
|
|
1557
|
+
const key = this.getFeatureKey(parsed);
|
|
1558
|
+
if (this.hiddenFeatures.has(key)) {
|
|
1559
|
+
// Return empty FeatureCollection when single feature is hidden
|
|
1560
|
+
return { type: 'FeatureCollection', features: [] };
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
return parsed;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// ========== Feature Visibility Management ==========
|
|
1568
|
+
|
|
1569
|
+
// Generate a unique key for a Feature to track visibility state
|
|
1570
|
+
getFeatureKey(feature) {
|
|
1571
|
+
if (!feature || typeof feature !== 'object') return null;
|
|
1572
|
+
|
|
1573
|
+
// 1. Use GeoJSON id if present (most stable)
|
|
1574
|
+
if (feature.id !== undefined) return `id:${feature.id}`;
|
|
1575
|
+
|
|
1576
|
+
// 2. Use properties.id if present
|
|
1577
|
+
if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
|
|
1578
|
+
|
|
1579
|
+
// 3. Fallback: hash based on geometry type + first coordinates
|
|
1580
|
+
const geomType = feature.geometry?.type || 'null';
|
|
1581
|
+
const coords = JSON.stringify(feature.geometry?.coordinates || []).slice(0, 100);
|
|
1582
|
+
return `hash:${geomType}:${this.simpleHash(coords)}`;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// Simple hash function for string
|
|
1586
|
+
simpleHash(str) {
|
|
1587
|
+
let hash = 0;
|
|
1588
|
+
for (let i = 0; i < str.length; i++) {
|
|
1589
|
+
const char = str.charCodeAt(i);
|
|
1590
|
+
hash = ((hash << 5) - hash) + char;
|
|
1591
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
1592
|
+
}
|
|
1593
|
+
return hash.toString(36);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// Toggle feature visibility
|
|
1597
|
+
toggleFeatureVisibility(featureKey) {
|
|
1598
|
+
if (this.hiddenFeatures.has(featureKey)) {
|
|
1599
|
+
this.hiddenFeatures.delete(featureKey);
|
|
1600
|
+
} else {
|
|
1601
|
+
this.hiddenFeatures.add(featureKey);
|
|
1602
|
+
}
|
|
1603
|
+
this.updateHighlight();
|
|
1604
|
+
this.updateGutter();
|
|
1605
|
+
this.emitChange();
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Check if a feature is hidden
|
|
1609
|
+
isFeatureHidden(featureKey) {
|
|
1610
|
+
return this.hiddenFeatures.has(featureKey);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// Parse JSON and extract feature ranges (line numbers for each Feature)
|
|
1614
|
+
updateFeatureRanges() {
|
|
1615
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1616
|
+
if (!textarea) return;
|
|
1617
|
+
|
|
1618
|
+
const text = textarea.value;
|
|
1619
|
+
this.featureRanges.clear();
|
|
1620
|
+
|
|
1621
|
+
try {
|
|
1622
|
+
// Expand collapsed content for parsing (collapsed markers like [...] are not valid JSON)
|
|
1623
|
+
const expandedText = this.expandAllCollapsed(text);
|
|
1624
|
+
|
|
1625
|
+
// Try to parse and find Features
|
|
1626
|
+
const prefix = this.prefix;
|
|
1627
|
+
const suffix = this.suffix;
|
|
1628
|
+
const fullValue = prefix + expandedText + suffix;
|
|
1629
|
+
const parsed = JSON.parse(fullValue);
|
|
1630
|
+
|
|
1631
|
+
let features = [];
|
|
1632
|
+
if (parsed.type === 'FeatureCollection' && Array.isArray(parsed.features)) {
|
|
1633
|
+
features = parsed.features;
|
|
1634
|
+
} else if (parsed.type === 'Feature') {
|
|
1635
|
+
features = [parsed];
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Now find each feature's line range in the text
|
|
1639
|
+
const lines = text.split('\n');
|
|
1640
|
+
let featureIndex = 0;
|
|
1641
|
+
let braceDepth = 0;
|
|
1642
|
+
let inFeature = false;
|
|
1643
|
+
let featureStartLine = -1;
|
|
1644
|
+
let currentFeatureKey = null;
|
|
1645
|
+
|
|
1646
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1647
|
+
const line = lines[i];
|
|
1648
|
+
|
|
1649
|
+
// Detect start of a Feature object (not FeatureCollection)
|
|
1650
|
+
// Use regex to match exact "Feature" value, not "FeatureCollection"
|
|
1651
|
+
const isFeatureTypeLine = /"type"\s*:\s*"Feature"/.test(line);
|
|
1652
|
+
if (!inFeature && isFeatureTypeLine) {
|
|
1653
|
+
// Find the opening brace for this Feature
|
|
1654
|
+
// Look backwards for the opening brace
|
|
1655
|
+
let startLine = i;
|
|
1656
|
+
for (let j = i; j >= 0; j--) {
|
|
1657
|
+
if (lines[j].includes('{')) {
|
|
1658
|
+
startLine = j;
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
featureStartLine = startLine;
|
|
1663
|
+
inFeature = true;
|
|
1664
|
+
|
|
1665
|
+
// Start braceDepth at 1 since we're inside the Feature's opening brace
|
|
1666
|
+
// Then count any additional braces from startLine to current line
|
|
1667
|
+
braceDepth = 1;
|
|
1668
|
+
for (let k = startLine; k <= i; k++) {
|
|
1669
|
+
const scanLine = lines[k];
|
|
1670
|
+
// Skip the first { we already counted
|
|
1671
|
+
let skipFirst = (k === startLine);
|
|
1672
|
+
for (const char of scanLine) {
|
|
1673
|
+
if (char === '{') {
|
|
1674
|
+
if (skipFirst) {
|
|
1675
|
+
skipFirst = false;
|
|
1676
|
+
} else {
|
|
1677
|
+
braceDepth++;
|
|
1678
|
+
}
|
|
1679
|
+
} else if (char === '}') {
|
|
1680
|
+
braceDepth--;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Get the feature key
|
|
1686
|
+
if (featureIndex < features.length) {
|
|
1687
|
+
currentFeatureKey = this.getFeatureKey(features[featureIndex]);
|
|
1688
|
+
}
|
|
1689
|
+
} else if (inFeature) {
|
|
1690
|
+
// Count braces
|
|
1691
|
+
for (const char of line) {
|
|
1692
|
+
if (char === '{') braceDepth++;
|
|
1693
|
+
else if (char === '}') braceDepth--;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Feature ends when braceDepth returns to 0
|
|
1697
|
+
if (braceDepth <= 0) {
|
|
1698
|
+
if (currentFeatureKey) {
|
|
1699
|
+
this.featureRanges.set(currentFeatureKey, {
|
|
1700
|
+
startLine: featureStartLine,
|
|
1701
|
+
endLine: i,
|
|
1702
|
+
featureIndex: featureIndex
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
featureIndex++;
|
|
1706
|
+
inFeature = false;
|
|
1707
|
+
currentFeatureKey = null;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
} catch (e) {
|
|
1712
|
+
// Invalid JSON, can't extract feature ranges
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Get hidden line ranges for highlighting
|
|
1717
|
+
getHiddenLineRanges() {
|
|
1718
|
+
const ranges = [];
|
|
1719
|
+
for (const [featureKey, range] of this.featureRanges) {
|
|
1720
|
+
if (this.hiddenFeatures.has(featureKey)) {
|
|
1721
|
+
ranges.push(range);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
return ranges;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// ========== GeoJSON Validation ==========
|
|
1728
|
+
|
|
1470
1729
|
// Validate GeoJSON structure and types
|
|
1471
1730
|
// context: 'root' | 'geometry' | 'properties'
|
|
1472
1731
|
validateGeoJSON(obj, path = '', context = 'root') {
|