@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 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)** | ~10 KB | ~2.5 MB | ~150 KB | ~15 KB + plugins |
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** - Optional automatic JSON formatting in real-time
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 Auto-format and Theme Detection
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
@@ -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.4
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"):""}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,"&amp;").replace(s.lessThan,"&lt;").replace(s.greaterThan,"&gt;").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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"):""}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,"&amp;").replace(s.lessThan,"&lt;").replace(s.greaterThan,"&gt;").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.4",
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",
@@ -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', 'auto-format', 'dark-selector', 'feature-collection'];
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 if enabled and JSON is valid
550
- if (this.autoFormat) {
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 if enabled and JSON is valid
567
- if (this.autoFormat) {
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
- // Apply auto-format if enabled
660
- if (this.autoFormat && newValue) {
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
- highlightedLines.push(this.highlightSyntax(line, context));
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, or both)
1249
+ // Create a map of line -> elements (color, collapse button, visibility button)
1197
1250
  const lineElements = new Map();
1198
1251
 
1199
- // Add color indicators
1200
- this.colorPositions.forEach(({ line, color, attributeName }) => {
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).colors.push({ color, attributeName });
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
- if (!lineElements.has(line)) {
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
- const parsed = JSON.parse(fullValue);
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') {