@softwarity/geojson-editor 1.0.5 → 1.0.7

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.
@@ -17,68 +17,43 @@ class GeoJsonEditor extends HTMLElement {
17
17
  this._cachedLineHeight = null;
18
18
  this._cachedPaddingTop = null;
19
19
 
20
- // Initialize themes from defaults
21
- this.themes = {
22
- dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
23
- light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
24
- };
20
+ // Custom theme overrides (empty by default, CSS has defaults)
21
+ this.themes = { dark: {}, light: {} };
25
22
  }
26
23
 
27
24
  static get observedAttributes() {
28
- return ['readonly', 'value', 'placeholder', 'dark-selector', 'feature-collection'];
29
- }
30
-
31
-
32
- // Default theme values
33
- static DEFAULT_THEMES = {
34
- dark: {
35
- background: '#1e1e1e',
36
- textColor: '#d4d4d4',
37
- caretColor: '#fff',
38
- gutterBackground: '#252526',
39
- gutterBorder: '#3e3e42',
40
- jsonKey: '#9cdcfe',
41
- jsonString: '#ce9178',
42
- jsonNumber: '#b5cea8',
43
- jsonBoolean: '#569cd6',
44
- jsonNull: '#569cd6',
45
- jsonPunctuation: '#d4d4d4',
46
- controlColor: '#c586c0',
47
- controlBg: '#3e3e42',
48
- controlBorder: '#555',
49
- geojsonKey: '#c586c0',
50
- geojsonType: '#4ec9b0',
51
- geojsonTypeInvalid: '#f44747',
52
- jsonKeyInvalid: '#f44747'
53
- },
54
- light: {
55
- background: '#ffffff',
56
- textColor: '#333333',
57
- caretColor: '#000',
58
- gutterBackground: '#f5f5f5',
59
- gutterBorder: '#ddd',
60
- jsonKey: '#0000ff',
61
- jsonString: '#a31515',
62
- jsonNumber: '#098658',
63
- jsonBoolean: '#0000ff',
64
- jsonNull: '#0000ff',
65
- jsonPunctuation: '#333333',
66
- controlColor: '#a31515',
67
- controlBg: '#e0e0e0',
68
- controlBorder: '#999',
69
- geojsonKey: '#af00db',
70
- geojsonType: '#267f99',
71
- geojsonTypeInvalid: '#d32f2f',
72
- jsonKeyInvalid: '#d32f2f'
73
- }
74
- };
25
+ return ['readonly', 'value', 'placeholder', 'dark-selector', 'default-properties'];
26
+ }
75
27
 
76
- // FeatureCollection wrapper constants
77
- static FEATURE_COLLECTION_PREFIX = '{"type": "FeatureCollection", "features": [';
78
- static FEATURE_COLLECTION_SUFFIX = ']}';
28
+ // Parsed default properties rules (cache)
29
+ _defaultPropertiesRules = null;
79
30
 
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>';
31
+ // Helper: Convert camelCase to kebab-case
32
+ static _toKebabCase(str) {
33
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
34
+ }
35
+
36
+ // Dark theme defaults - IntelliJ Darcula (light defaults are CSS fallbacks)
37
+ static DARK_THEME_DEFAULTS = {
38
+ bgColor: '#2b2b2b',
39
+ textColor: '#a9b7c6',
40
+ caretColor: '#bbbbbb',
41
+ gutterBg: '#313335',
42
+ gutterBorder: '#3c3f41',
43
+ jsonKey: '#9876aa',
44
+ jsonString: '#6a8759',
45
+ jsonNumber: '#6897bb',
46
+ jsonBoolean: '#cc7832',
47
+ jsonNull: '#cc7832',
48
+ jsonPunct: '#a9b7c6',
49
+ controlColor: '#cc7832',
50
+ controlBg: '#3c3f41',
51
+ controlBorder: '#5a5a5a',
52
+ geojsonKey: '#9876aa',
53
+ geojsonType: '#6a8759',
54
+ geojsonTypeInvalid: '#ff6b68',
55
+ jsonKeyInvalid: '#ff6b68'
56
+ };
82
57
 
83
58
  // Pre-compiled regex patterns (avoid recompilation on each call)
84
59
  static REGEX = {
@@ -101,6 +76,31 @@ class GeoJsonEditor extends HTMLElement {
101
76
  collapsedMarker: /^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/
102
77
  };
103
78
 
79
+ /**
80
+ * Find collapsed data by line index, nodeKey, and indent
81
+ * @param {number} lineIndex - Current line index
82
+ * @param {string} nodeKey - Node key to find
83
+ * @param {number} indent - Indentation level to match
84
+ * @returns {{key: string, data: Object}|null} Found key and data, or null
85
+ * @private
86
+ */
87
+ _findCollapsedData(lineIndex, nodeKey, indent) {
88
+ // Try exact match first
89
+ const exactKey = `${lineIndex}-${nodeKey}`;
90
+ if (this.collapsedData.has(exactKey)) {
91
+ return { key: exactKey, data: this.collapsedData.get(exactKey) };
92
+ }
93
+
94
+ // Search for any key with this nodeKey and matching indent
95
+ for (const [key, data] of this.collapsedData.entries()) {
96
+ if (data.nodeKey === nodeKey && data.indent === indent) {
97
+ return { key, data };
98
+ }
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
104
  connectedCallback() {
105
105
  this.render();
106
106
  this.setupEventListeners();
@@ -111,6 +111,9 @@ class GeoJsonEditor extends HTMLElement {
111
111
  // Setup theme CSS
112
112
  this.updateThemeCSS();
113
113
 
114
+ // Parse default properties rules
115
+ this._parseDefaultProperties();
116
+
114
117
  // Initialize textarea with value attribute (attributeChangedCallback fires before render)
115
118
  if (this.value) {
116
119
  this.updateValue(this.value);
@@ -118,6 +121,21 @@ class GeoJsonEditor extends HTMLElement {
118
121
  this.updatePlaceholderContent();
119
122
  }
120
123
 
124
+ disconnectedCallback() {
125
+ // Clean up any open color picker and its global listener
126
+ const colorPicker = document.querySelector('.geojson-color-picker-input');
127
+ if (colorPicker && colorPicker._closeListener) {
128
+ document.removeEventListener('click', colorPicker._closeListener, true);
129
+ colorPicker.remove();
130
+ }
131
+
132
+ // Clear any pending highlight timer
133
+ if (this.highlightTimer) {
134
+ clearTimeout(this.highlightTimer);
135
+ this.highlightTimer = null;
136
+ }
137
+ }
138
+
121
139
  attributeChangedCallback(name, oldValue, newValue) {
122
140
  if (oldValue === newValue) return;
123
141
 
@@ -129,8 +147,9 @@ class GeoJsonEditor extends HTMLElement {
129
147
  this.updatePlaceholderContent();
130
148
  } else if (name === 'dark-selector') {
131
149
  this.updateThemeCSS();
132
- } else if (name === 'feature-collection') {
133
- this.updatePrefixSuffix();
150
+ } else if (name === 'default-properties') {
151
+ // Re-parse the default properties rules
152
+ this._parseDefaultProperties();
134
153
  }
135
154
  }
136
155
 
@@ -148,38 +167,139 @@ class GeoJsonEditor extends HTMLElement {
148
167
  return this.getAttribute('placeholder') || '';
149
168
  }
150
169
 
151
- get featureCollection() {
152
- return this.hasAttribute('feature-collection');
170
+ // Always in FeatureCollection mode - prefix/suffix are constant
171
+ get prefix() {
172
+ return '{"type": "FeatureCollection", "features": [';
173
+ }
174
+
175
+ get suffix() {
176
+ return ']}';
153
177
  }
154
178
 
155
- // Internal getters for prefix/suffix based on feature-collection mode
156
- get prefix() {
157
- return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_PREFIX : '';
179
+ get defaultProperties() {
180
+ return this.getAttribute('default-properties') || '';
158
181
  }
159
182
 
160
- get suffix() {
161
- return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_SUFFIX : '';
183
+ /**
184
+ * Parse and cache the default-properties attribute.
185
+ * Supports two formats:
186
+ * 1. Simple object: {"fill-color": "#1a465b", "stroke-width": 2}
187
+ * 2. Conditional array: [{"match": {"geometry.type": "Polygon"}, "values": {...}}, ...]
188
+ *
189
+ * Returns an array of rules: [{match: null|object, values: object}]
190
+ */
191
+ _parseDefaultProperties() {
192
+ const attr = this.defaultProperties;
193
+ if (!attr) {
194
+ this._defaultPropertiesRules = [];
195
+ return this._defaultPropertiesRules;
196
+ }
197
+
198
+ try {
199
+ const parsed = JSON.parse(attr);
200
+
201
+ if (Array.isArray(parsed)) {
202
+ // Conditional format: array of rules
203
+ this._defaultPropertiesRules = parsed.map(rule => ({
204
+ match: rule.match || null,
205
+ values: rule.values || {}
206
+ }));
207
+ } else if (typeof parsed === 'object' && parsed !== null) {
208
+ // Simple format: single object of properties for all features
209
+ this._defaultPropertiesRules = [{ match: null, values: parsed }];
210
+ } else {
211
+ this._defaultPropertiesRules = [];
212
+ }
213
+ } catch (e) {
214
+ console.warn('geojson-editor: Invalid default-properties JSON:', e.message);
215
+ this._defaultPropertiesRules = [];
216
+ }
217
+
218
+ return this._defaultPropertiesRules;
219
+ }
220
+
221
+ /**
222
+ * Check if a feature matches a condition.
223
+ * Supports dot notation for nested properties:
224
+ * - "geometry.type": "Polygon"
225
+ * - "properties.category": "airport"
226
+ */
227
+ _matchesCondition(feature, match) {
228
+ if (!match || typeof match !== 'object') return true;
229
+
230
+ for (const [path, expectedValue] of Object.entries(match)) {
231
+ const actualValue = this._getNestedValue(feature, path);
232
+ if (actualValue !== expectedValue) {
233
+ return false;
234
+ }
235
+ }
236
+ return true;
237
+ }
238
+
239
+ /**
240
+ * Get a nested value from an object using dot notation.
241
+ * E.g., _getNestedValue(feature, "geometry.type") => "Polygon"
242
+ */
243
+ _getNestedValue(obj, path) {
244
+ const parts = path.split('.');
245
+ let current = obj;
246
+ for (const part of parts) {
247
+ if (current === null || current === undefined) return undefined;
248
+ current = current[part];
249
+ }
250
+ return current;
251
+ }
252
+
253
+ /**
254
+ * Apply default properties to a single feature.
255
+ * Only adds properties that don't already exist.
256
+ * Returns a new feature object (doesn't mutate original).
257
+ */
258
+ _applyDefaultPropertiesToFeature(feature) {
259
+ if (!feature || typeof feature !== 'object') return feature;
260
+ if (!this._defaultPropertiesRules || this._defaultPropertiesRules.length === 0) return feature;
261
+
262
+ // Collect all properties to apply (later rules override earlier for same key)
263
+ const propsToApply = {};
264
+
265
+ for (const rule of this._defaultPropertiesRules) {
266
+ if (this._matchesCondition(feature, rule.match)) {
267
+ Object.assign(propsToApply, rule.values);
268
+ }
269
+ }
270
+
271
+ if (Object.keys(propsToApply).length === 0) return feature;
272
+
273
+ // Apply only properties that don't already exist
274
+ const existingProps = feature.properties || {};
275
+ const newProps = { ...existingProps };
276
+ let hasChanges = false;
277
+
278
+ for (const [key, value] of Object.entries(propsToApply)) {
279
+ if (!(key in existingProps)) {
280
+ newProps[key] = value;
281
+ hasChanges = true;
282
+ }
283
+ }
284
+
285
+ if (!hasChanges) return feature;
286
+
287
+ return { ...feature, properties: newProps };
162
288
  }
163
289
 
164
290
  render() {
165
291
  const styles = `
166
292
  <style>
167
- /* Global reset with exact values to prevent external CSS interference */
168
- :host *,
169
- :host *::before,
170
- :host *::after {
293
+ /* Base reset - protect against inherited styles */
294
+ :host *, :host *::before, :host *::after {
171
295
  box-sizing: border-box;
172
- font-family: 'Courier New', Courier, monospace;
173
- font-size: 13px;
174
- font-weight: normal;
175
- font-style: normal;
296
+ font: normal normal 13px/1.5 'Courier New', Courier, monospace;
176
297
  font-variant: normal;
177
- line-height: 1.5;
178
298
  letter-spacing: 0;
299
+ word-spacing: 0;
179
300
  text-transform: none;
180
301
  text-decoration: none;
181
302
  text-indent: 0;
182
- word-spacing: 0;
183
303
  }
184
304
 
185
305
  :host {
@@ -188,9 +308,6 @@ class GeoJsonEditor extends HTMLElement {
188
308
  position: relative;
189
309
  width: 100%;
190
310
  height: 400px;
191
- font-family: 'Courier New', Courier, monospace;
192
- font-size: 13px;
193
- line-height: 1.5;
194
311
  border-radius: 4px;
195
312
  overflow: hidden;
196
313
  }
@@ -198,41 +315,27 @@ class GeoJsonEditor extends HTMLElement {
198
315
  :host([readonly]) .editor-wrapper::after {
199
316
  content: '';
200
317
  position: absolute;
201
- top: 0;
202
- left: 0;
203
- right: 0;
204
- bottom: 0;
318
+ inset: 0;
205
319
  pointer-events: none;
206
- background: repeating-linear-gradient(
207
- -45deg,
208
- rgba(128, 128, 128, 0.08),
209
- rgba(128, 128, 128, 0.08) 3px,
210
- transparent 3px,
211
- transparent 12px
212
- );
320
+ background: repeating-linear-gradient(-45deg, rgba(128,128,128,0.08), rgba(128,128,128,0.08) 3px, transparent 3px, transparent 12px);
213
321
  z-index: 1;
214
322
  }
215
323
 
216
- :host([readonly]) textarea {
217
- cursor: text;
218
- }
324
+ :host([readonly]) textarea { cursor: text; }
219
325
 
220
326
  .editor-wrapper {
221
327
  position: relative;
222
328
  width: 100%;
223
329
  flex: 1;
224
- background: var(--bg-color);
330
+ background: var(--bg-color, #fff);
225
331
  display: flex;
226
- font-family: 'Courier New', Courier, monospace;
227
- font-size: 13px;
228
- line-height: 1.5;
229
332
  }
230
333
 
231
334
  .gutter {
232
335
  width: 24px;
233
336
  height: 100%;
234
- background: var(--gutter-bg);
235
- border-right: 1px solid var(--gutter-border);
337
+ background: var(--gutter-bg, #f0f0f0);
338
+ border-right: 1px solid var(--gutter-border, #e0e0e0);
236
339
  overflow: hidden;
237
340
  flex-shrink: 0;
238
341
  position: relative;
@@ -256,42 +359,37 @@ class GeoJsonEditor extends HTMLElement {
256
359
  justify-content: center;
257
360
  }
258
361
 
259
- .color-indicator {
362
+ .color-indicator, .collapse-button {
260
363
  width: 12px;
261
364
  height: 12px;
262
365
  border-radius: 2px;
263
- border: 1px solid #555;
264
366
  cursor: pointer;
265
367
  transition: transform 0.1s;
266
368
  flex-shrink: 0;
267
369
  }
268
370
 
371
+ .color-indicator {
372
+ border: 1px solid #555;
373
+ }
269
374
  .color-indicator:hover {
270
375
  transform: scale(1.2);
271
376
  border-color: #fff;
272
377
  }
273
378
 
274
379
  .collapse-button {
275
- width: 12px;
276
- height: 12px;
277
- background: var(--control-bg);
278
- border: 1px solid var(--control-border);
279
- border-radius: 2px;
280
- color: var(--control-color);
380
+ padding-top: 1px;
381
+ background: var(--control-bg, #e8e8e8);
382
+ border: 1px solid var(--control-border, #c0c0c0);
383
+ color: var(--control-color, #000080);
281
384
  font-size: 8px;
282
385
  font-weight: bold;
283
- cursor: pointer;
284
386
  display: flex;
285
387
  align-items: center;
286
388
  justify-content: center;
287
- transition: all 0.1s;
288
- flex-shrink: 0;
289
389
  user-select: none;
290
390
  }
291
-
292
391
  .collapse-button:hover {
293
- background: var(--control-bg);
294
- border-color: var(--control-color);
392
+ border-color: var(--control-color, #000080);
295
393
  transform: scale(1.1);
296
394
  }
297
395
 
@@ -299,8 +397,8 @@ class GeoJsonEditor extends HTMLElement {
299
397
  width: 14px;
300
398
  height: 14px;
301
399
  background: transparent;
400
+ color: var(--control-color, #000080);
302
401
  border: none;
303
- color: var(--control-color);
304
402
  cursor: pointer;
305
403
  display: flex;
306
404
  align-items: center;
@@ -309,45 +407,12 @@ class GeoJsonEditor extends HTMLElement {
309
407
  flex-shrink: 0;
310
408
  opacity: 0.7;
311
409
  padding: 0;
410
+ font-size: 11px;
312
411
  }
412
+ .visibility-button:hover { opacity: 1; transform: scale(1.15); }
413
+ .visibility-button.hidden { opacity: 0.35; }
313
414
 
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
-
335
- .color-picker-popup {
336
- position: absolute;
337
- background: #2d2d30;
338
- border: 1px solid #555;
339
- border-radius: 4px;
340
- padding: 8px;
341
- z-index: 1000;
342
- box-shadow: 0 4px 12px rgba(0,0,0,0.5);
343
- }
344
-
345
- .color-picker-popup input[type="color"] {
346
- width: 150px;
347
- height: 30px;
348
- border: none;
349
- cursor: pointer;
350
- }
415
+ .line-hidden { opacity: 0.35; filter: grayscale(50%); }
351
416
 
352
417
  .editor-content {
353
418
  position: relative;
@@ -355,188 +420,118 @@ class GeoJsonEditor extends HTMLElement {
355
420
  overflow: hidden;
356
421
  }
357
422
 
358
- .highlight-layer {
423
+ .highlight-layer, textarea, .placeholder-layer {
359
424
  position: absolute;
360
- top: 0;
361
- left: 0;
362
- width: 100%;
363
- height: 100%;
425
+ inset: 0;
364
426
  padding: 8px 12px;
365
- font-family: 'Courier New', Courier, monospace;
366
- font-size: 13px;
367
- font-weight: normal;
368
- font-style: normal;
369
- line-height: 1.5;
370
427
  white-space: pre-wrap;
371
428
  word-wrap: break-word;
429
+ }
430
+
431
+ .highlight-layer {
372
432
  overflow: auto;
373
433
  pointer-events: none;
374
434
  z-index: 1;
375
- color: var(--text-color);
376
- }
377
-
378
- .highlight-layer::-webkit-scrollbar {
379
- display: none;
435
+ color: var(--text-color, #000);
380
436
  }
437
+ .highlight-layer::-webkit-scrollbar { display: none; }
381
438
 
382
439
  textarea {
383
- position: absolute;
384
- top: 0;
385
- left: 0;
386
- width: 100%;
387
- height: 100%;
388
- padding: 8px 12px;
389
440
  margin: 0;
390
441
  border: none;
391
442
  outline: none;
392
443
  background: transparent;
393
444
  color: transparent;
394
- caret-color: var(--caret-color);
395
- font-family: 'Courier New', Courier, monospace;
396
- font-size: 13px;
397
- font-weight: normal;
398
- font-style: normal;
399
- line-height: 1.5;
400
- white-space: pre-wrap;
401
- word-wrap: break-word;
445
+ caret-color: var(--caret-color, #000);
402
446
  resize: none;
403
447
  overflow: auto;
404
448
  z-index: 2;
405
- box-sizing: border-box;
406
- }
407
-
408
- textarea::selection {
409
- background: rgba(51, 153, 255, 0.3);
410
- }
411
-
412
- textarea::placeholder {
413
- color: transparent;
414
449
  }
450
+ textarea::selection { background: rgba(51,153,255,0.3); }
451
+ textarea::placeholder { color: transparent; }
452
+ textarea:disabled { cursor: not-allowed; opacity: 0.6; }
415
453
 
416
454
  .placeholder-layer {
417
- position: absolute;
418
- top: 0;
419
- left: 0;
420
- width: 100%;
421
- height: 100%;
422
- padding: 8px 12px;
423
- font-family: 'Courier New', Courier, monospace;
424
- font-size: 13px;
425
- font-weight: normal;
426
- font-style: normal;
427
- line-height: 1.5;
428
- white-space: pre-wrap;
429
- word-wrap: break-word;
430
455
  color: #6a6a6a;
431
456
  pointer-events: none;
432
457
  z-index: 0;
433
458
  overflow: hidden;
434
459
  }
435
460
 
436
- textarea:disabled {
437
- cursor: not-allowed;
438
- opacity: 0.6;
439
- }
440
-
441
- /* Syntax highlighting colors */
442
- .json-key {
443
- color: var(--json-key);
444
- }
445
-
446
- .json-string {
447
- color: var(--json-string);
448
- }
449
-
450
- .json-number {
451
- color: var(--json-number);
452
- }
453
-
454
- .json-boolean {
455
- color: var(--json-boolean);
456
- }
457
-
458
- .json-null {
459
- color: var(--json-null);
460
- }
461
-
462
- .json-punctuation {
463
- color: var(--json-punct);
464
- }
465
-
466
- /* GeoJSON-specific highlighting */
467
- .geojson-key {
468
- color: var(--geojson-key);
469
- font-weight: 600;
470
- }
461
+ .json-key { color: var(--json-key, #660e7a); }
462
+ .json-string { color: var(--json-string, #008000); }
463
+ .json-number { color: var(--json-number, #00f); }
464
+ .json-boolean, .json-null { color: var(--json-boolean, #000080); }
465
+ .json-punctuation { color: var(--json-punct, #000); }
466
+ .json-key-invalid { color: var(--json-key-invalid, #f00); }
471
467
 
472
- .geojson-type {
473
- color: var(--geojson-type);
474
- font-weight: 600;
475
- }
468
+ .geojson-key { color: var(--geojson-key, #660e7a); font-weight: 600; }
469
+ .geojson-type { color: var(--geojson-type, #008000); font-weight: 600; }
470
+ .geojson-type-invalid { color: var(--geojson-type-invalid, #f00); font-weight: 600; }
476
471
 
477
- .geojson-type-invalid {
478
- color: var(--geojson-type-invalid);
479
- font-weight: 600;
472
+ .prefix-wrapper, .suffix-wrapper {
473
+ display: flex;
474
+ flex-shrink: 0;
475
+ background: var(--bg-color, #fff);
480
476
  }
481
477
 
482
- .json-key-invalid {
483
- color: var(--json-key-invalid);
478
+ .prefix-gutter, .suffix-gutter {
479
+ width: 24px;
480
+ background: var(--gutter-bg, #f0f0f0);
481
+ border-right: 1px solid var(--gutter-border, #e0e0e0);
482
+ flex-shrink: 0;
484
483
  }
485
484
 
486
- /* Prefix and suffix styling */
487
- .editor-prefix,
488
- .editor-suffix {
485
+ .editor-prefix, .editor-suffix {
486
+ flex: 1;
489
487
  padding: 4px 12px;
490
- color: var(--text-color);
491
- background: var(--bg-color);
488
+ color: var(--text-color, #000);
489
+ background: var(--bg-color, #fff);
492
490
  user-select: none;
493
491
  white-space: pre-wrap;
494
492
  word-wrap: break-word;
495
- flex-shrink: 0;
496
- font-family: 'Courier New', Courier, monospace;
497
- font-size: 13px;
498
- line-height: 1.5;
499
493
  opacity: 0.6;
500
- border-left: 3px solid rgba(102, 126, 234, 0.5);
501
494
  }
502
495
 
503
- .editor-prefix {
504
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
505
- }
496
+ .prefix-wrapper { border-bottom: 1px solid rgba(255,255,255,0.1); }
497
+ .suffix-wrapper { border-top: 1px solid rgba(255,255,255,0.1); position: relative; }
506
498
 
507
- .editor-suffix {
508
- border-top: 1px solid rgba(255, 255, 255, 0.1);
509
- }
510
-
511
- /* Scrollbar styling - WebKit (Chrome, Safari, Edge) */
512
- textarea::-webkit-scrollbar {
513
- width: 10px;
514
- height: 10px;
515
- }
516
-
517
- textarea::-webkit-scrollbar-track {
518
- background: var(--control-bg);
519
- }
520
-
521
- textarea::-webkit-scrollbar-thumb {
522
- background: var(--control-border);
523
- border-radius: 5px;
524
- }
525
-
526
- textarea::-webkit-scrollbar-thumb:hover {
527
- background: var(--control-color);
499
+ .clear-btn {
500
+ position: absolute;
501
+ right: 0.5rem;
502
+ top: 50%;
503
+ transform: translateY(-50%);
504
+ background: transparent;
505
+ border: none;
506
+ color: var(--text-color, #000);
507
+ opacity: 0.3;
508
+ cursor: pointer;
509
+ font-size: 0.65rem;
510
+ width: 1rem;
511
+ height: 1rem;
512
+ padding: 0.15rem 0 0 0;
513
+ border-radius: 3px;
514
+ display: flex;
515
+ align-items: center;
516
+ justify-content: center;
517
+ transition: opacity 0.2s, background 0.2s;
528
518
  }
519
+ .clear-btn:hover { opacity: 0.7; background: rgba(255,255,255,0.1); }
520
+ .clear-btn[hidden] { display: none; }
529
521
 
530
- /* Scrollbar styling - Firefox */
531
- textarea {
532
- scrollbar-width: thin;
533
- scrollbar-color: var(--control-border) var(--control-bg);
534
- }
522
+ textarea::-webkit-scrollbar { width: 10px; height: 10px; }
523
+ textarea::-webkit-scrollbar-track { background: var(--control-bg, #e8e8e8); }
524
+ textarea::-webkit-scrollbar-thumb { background: var(--control-border, #c0c0c0); border-radius: 5px; }
525
+ textarea::-webkit-scrollbar-thumb:hover { background: var(--control-color, #000080); }
526
+ textarea { scrollbar-width: thin; scrollbar-color: var(--control-border, #c0c0c0) var(--control-bg, #e8e8e8); }
535
527
  </style>
536
528
  `;
537
529
 
538
530
  const template = `
539
- <div class="editor-prefix" id="editorPrefix"></div>
531
+ <div class="prefix-wrapper">
532
+ <div class="prefix-gutter"></div>
533
+ <div class="editor-prefix" id="editorPrefix"></div>
534
+ </div>
540
535
  <div class="editor-wrapper">
541
536
  <div class="gutter">
542
537
  <div class="gutter-content" id="gutterContent"></div>
@@ -553,7 +548,11 @@ class GeoJsonEditor extends HTMLElement {
553
548
  ></textarea>
554
549
  </div>
555
550
  </div>
556
- <div class="editor-suffix" id="editorSuffix"></div>
551
+ <div class="suffix-wrapper">
552
+ <div class="suffix-gutter"></div>
553
+ <div class="editor-suffix" id="editorSuffix"></div>
554
+ <button class="clear-btn" id="clearBtn" title="Clear editor">✕</button>
555
+ </div>
557
556
  `;
558
557
 
559
558
  this.shadowRoot.innerHTML = styles + template;
@@ -646,6 +645,12 @@ class GeoJsonEditor extends HTMLElement {
646
645
  this.handleCutWithCollapsedContent(e);
647
646
  });
648
647
 
648
+ // Clear button
649
+ const clearBtn = this.shadowRoot.getElementById('clearBtn');
650
+ clearBtn.addEventListener('click', () => {
651
+ this.removeAll();
652
+ });
653
+
649
654
  // Update readonly state
650
655
  this.updateReadonly();
651
656
  }
@@ -660,14 +665,20 @@ class GeoJsonEditor extends HTMLElement {
660
665
  if (textarea) {
661
666
  textarea.disabled = this.readonly;
662
667
  }
668
+ // Hide clear button in readonly mode
669
+ const clearBtn = this.shadowRoot.getElementById('clearBtn');
670
+ if (clearBtn) {
671
+ clearBtn.hidden = this.readonly;
672
+ }
663
673
  }
664
674
 
665
675
  escapeHtml(text) {
666
676
  if (!text) return '';
677
+ const R = GeoJsonEditor.REGEX;
667
678
  return text
668
- .replace(/&/g, '&amp;')
669
- .replace(/</g, '&lt;')
670
- .replace(/>/g, '&gt;');
679
+ .replace(R.ampersand, '&amp;')
680
+ .replace(R.lessThan, '&lt;')
681
+ .replace(R.greaterThan, '&gt;');
671
682
  }
672
683
 
673
684
  updatePlaceholderVisibility() {
@@ -694,32 +705,18 @@ class GeoJsonEditor extends HTMLElement {
694
705
  // Auto-format JSON content
695
706
  if (newValue) {
696
707
  try {
697
- const prefix = this.prefix;
698
- const suffix = this.suffix;
699
-
700
- // Check if prefix ends with [ and suffix starts with ]
701
- const prefixEndsWithBracket = prefix.trimEnd().endsWith('[');
702
- const suffixStartsWithBracket = suffix.trimStart().startsWith(']');
703
-
704
- if (prefixEndsWithBracket && suffixStartsWithBracket) {
705
- // Wrap content in array brackets for validation and formatting
706
- const wrapped = '[' + newValue + ']';
707
- const parsed = JSON.parse(wrapped);
708
- const formatted = JSON.stringify(parsed, null, 2);
709
-
710
- // Remove first [ and last ] from formatted
711
- const lines = formatted.split('\n');
712
- if (lines.length > 2) {
713
- textarea.value = lines.slice(1, -1).join('\n');
714
- } else {
715
- textarea.value = '';
716
- }
717
- } else if (!prefix && !suffix) {
718
- // No prefix/suffix - format directly
719
- const parsed = JSON.parse(newValue);
720
- textarea.value = JSON.stringify(parsed, null, 2);
708
+ // Wrap content in array brackets for validation and formatting
709
+ const wrapped = '[' + newValue + ']';
710
+ const parsed = JSON.parse(wrapped);
711
+ const formatted = JSON.stringify(parsed, null, 2);
712
+
713
+ // Remove first [ and last ] from formatted
714
+ const lines = formatted.split('\n');
715
+ if (lines.length > 2) {
716
+ textarea.value = lines.slice(1, -1).join('\n');
717
+ } else {
718
+ textarea.value = '';
721
719
  }
722
- // else: keep as-is for complex cases
723
720
  } catch (e) {
724
721
  // Invalid JSON, keep as-is
725
722
  }
@@ -744,24 +741,13 @@ class GeoJsonEditor extends HTMLElement {
744
741
  const prefixEl = this.shadowRoot.getElementById('editorPrefix');
745
742
  const suffixEl = this.shadowRoot.getElementById('editorSuffix');
746
743
 
744
+ // Always show prefix/suffix (always in FeatureCollection mode)
747
745
  if (prefixEl) {
748
- if (this.prefix) {
749
- prefixEl.textContent = this.prefix;
750
- prefixEl.style.display = 'block';
751
- } else {
752
- prefixEl.textContent = '';
753
- prefixEl.style.display = 'none';
754
- }
746
+ prefixEl.textContent = this.prefix;
755
747
  }
756
748
 
757
749
  if (suffixEl) {
758
- if (this.suffix) {
759
- suffixEl.textContent = this.suffix;
760
- suffixEl.style.display = 'block';
761
- } else {
762
- suffixEl.textContent = '';
763
- suffixEl.style.display = 'none';
764
- }
750
+ suffixEl.textContent = this.suffix;
765
751
  }
766
752
  }
767
753
 
@@ -867,10 +853,12 @@ class GeoJsonEditor extends HTMLElement {
867
853
  };
868
854
  }
869
855
 
870
- // GeoJSON type constants
871
- static GEOJSON_TYPES_FEATURE = ['Feature', 'FeatureCollection'];
872
- static GEOJSON_TYPES_GEOMETRY = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'];
873
- static GEOJSON_TYPES_ALL = [...GeoJsonEditor.GEOJSON_TYPES_FEATURE, ...GeoJsonEditor.GEOJSON_TYPES_GEOMETRY];
856
+ // GeoJSON type constants (consolidated)
857
+ static GEOJSON = {
858
+ FEATURE_TYPES: ['Feature', 'FeatureCollection'],
859
+ GEOMETRY_TYPES: ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'],
860
+ ALL_TYPES: ['Feature', 'FeatureCollection', 'Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection']
861
+ };
874
862
 
875
863
  // Valid keys per context (null = any key is valid)
876
864
  static VALID_KEYS_BY_CONTEXT = {
@@ -902,8 +890,8 @@ class GeoJsonEditor extends HTMLElement {
902
890
  const contextStack = []; // Stack of {context, isArray}
903
891
  let pendingContext = null; // Context for next object/array
904
892
 
905
- // Determine root context based on feature-collection mode
906
- const rootContext = this.featureCollection ? 'Feature' : null;
893
+ // Root context is always 'Feature' (always in FeatureCollection mode)
894
+ const rootContext = 'Feature';
907
895
 
908
896
  for (let i = 0; i < lines.length; i++) {
909
897
  const line = lines[i];
@@ -915,37 +903,61 @@ class GeoJsonEditor extends HTMLElement {
915
903
  contextMap.set(i, lineContext);
916
904
 
917
905
  // Process each character to track brackets for subsequent lines
906
+ // Track string state to ignore brackets inside strings
907
+ let inString = false;
908
+ let escape = false;
909
+
918
910
  for (let j = 0; j < line.length; j++) {
919
911
  const char = line[j];
920
912
 
921
- // Check for key that changes context: "keyName":
913
+ // Handle escape sequences
914
+ if (escape) {
915
+ escape = false;
916
+ continue;
917
+ }
918
+ if (char === '\\' && inString) {
919
+ escape = true;
920
+ continue;
921
+ }
922
+
923
+ // Track string boundaries
922
924
  if (char === '"') {
923
- const keyMatch = line.substring(j).match(/^"([^"]+)"\s*:/);
924
- if (keyMatch) {
925
- const keyName = keyMatch[1];
926
- if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
927
- pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
925
+ if (!inString) {
926
+ // Entering string - check for special patterns before toggling
927
+ const keyMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"\s*:/);
928
+ if (keyMatch) {
929
+ const keyName = keyMatch[1];
930
+ if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
931
+ pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
932
+ }
933
+ j += keyMatch[0].length - 1; // Skip past the key
934
+ continue;
928
935
  }
929
- j += keyMatch[0].length - 1; // Skip past the key
930
- continue;
931
- }
932
- }
933
936
 
934
- // Check for type value to refine context: "type": "Point"
935
- if (char === '"' && contextStack.length > 0) {
936
- const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
937
- if (typeMatch) {
938
- const valueMatch = line.substring(j).match(/^"([^"]+)"/);
939
- if (valueMatch && GeoJsonEditor.GEOJSON_TYPES_ALL.includes(valueMatch[1])) {
940
- // Update current context to the specific type
941
- const currentCtx = contextStack[contextStack.length - 1];
942
- if (currentCtx) {
943
- currentCtx.context = valueMatch[1];
937
+ // Check for type value to refine context: "type": "Point"
938
+ if (contextStack.length > 0) {
939
+ const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
940
+ if (typeMatch) {
941
+ const valueMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"/);
942
+ if (valueMatch && GeoJsonEditor.GEOJSON.ALL_TYPES.includes(valueMatch[1])) {
943
+ const currentCtx = contextStack[contextStack.length - 1];
944
+ if (currentCtx) {
945
+ currentCtx.context = valueMatch[1];
946
+ }
947
+ }
948
+ // Skip past this string value
949
+ j += valueMatch ? valueMatch[0].length - 1 : 0;
950
+ continue;
944
951
  }
945
952
  }
946
953
  }
954
+ inString = !inString;
955
+ continue;
947
956
  }
948
957
 
958
+ // Skip everything inside strings (brackets, etc.)
959
+ if (inString) continue;
960
+
949
961
  // Opening bracket - push context
950
962
  if (char === '{' || char === '[') {
951
963
  let newContext;
@@ -953,10 +965,8 @@ class GeoJsonEditor extends HTMLElement {
953
965
  newContext = pendingContext;
954
966
  pendingContext = null;
955
967
  } else if (contextStack.length === 0) {
956
- // Root level
957
968
  newContext = rootContext;
958
969
  } else {
959
- // Inherit from parent if in array
960
970
  const parent = contextStack[contextStack.length - 1];
961
971
  if (parent && parent.isArray) {
962
972
  newContext = parent.context;
@@ -1002,12 +1012,12 @@ class GeoJsonEditor extends HTMLElement {
1002
1012
  // Unknown context - don't validate (could be inside misspelled properties, etc.)
1003
1013
  if (!context) return true;
1004
1014
  if (context === 'properties') return true; // Any type in properties
1005
- if (context === 'geometry' || GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(context)) {
1006
- return GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(typeValue);
1015
+ if (context === 'geometry' || GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(context)) {
1016
+ return GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue);
1007
1017
  }
1008
1018
  // Only validate as GeoJSON type in known Feature/FeatureCollection context
1009
1019
  if (context === 'Feature' || context === 'FeatureCollection') {
1010
- return GeoJsonEditor.GEOJSON_TYPES_ALL.includes(typeValue);
1020
+ return GeoJsonEditor.GEOJSON.ALL_TYPES.includes(typeValue);
1011
1021
  }
1012
1022
  return true; // Unknown context - accept any type
1013
1023
  };
@@ -1020,7 +1030,7 @@ class GeoJsonEditor extends HTMLElement {
1020
1030
  .replace(R.lessThan, '&lt;')
1021
1031
  .replace(R.greaterThan, '&gt;')
1022
1032
  // All JSON keys - validate against context
1023
- .replace(R.jsonKey, (match, key) => {
1033
+ .replace(R.jsonKey, (_, key) => {
1024
1034
  // Inside properties - all keys are regular user keys
1025
1035
  if (context === 'properties') {
1026
1036
  return `<span class="json-key">"${key}"</span>:`;
@@ -1037,7 +1047,7 @@ class GeoJsonEditor extends HTMLElement {
1037
1047
  }
1038
1048
  })
1039
1049
  // GeoJSON "type" values - validate based on context
1040
- .replace(R.typeValue, (match, typeValue) => {
1050
+ .replace(R.typeValue, (_, typeValue) => {
1041
1051
  if (isTypeValid(typeValue)) {
1042
1052
  return `<span class="geojson-key">"type"</span>: <span class="geojson-type">"${typeValue}"</span>`;
1043
1053
  } else {
@@ -1066,35 +1076,16 @@ class GeoJsonEditor extends HTMLElement {
1066
1076
  const hasMarker = currentLine.includes('{...}') || currentLine.includes('[...]');
1067
1077
 
1068
1078
  if (hasMarker) {
1069
- // Expand: find the correct collapsed data by searching for this nodeKey
1070
- let foundKey = null;
1071
- let foundData = null;
1072
-
1073
- // Try exact match first
1074
- const exactKey = `${line}-${nodeKey}`;
1075
- if (this.collapsedData.has(exactKey)) {
1076
- foundKey = exactKey;
1077
- foundData = this.collapsedData.get(exactKey);
1078
- } else {
1079
- // Search for any key with this nodeKey (line numbers may have shifted)
1080
- for (const [key, data] of this.collapsedData.entries()) {
1081
- if (data.nodeKey === nodeKey) {
1082
- // Check indent to distinguish between multiple nodes with same name
1083
- const currentIndent = currentLine.match(/^(\s*)/)[1].length;
1084
- if (data.indent === currentIndent) {
1085
- foundKey = key;
1086
- foundData = data;
1087
- break;
1088
- }
1089
- }
1090
- }
1091
- }
1079
+ // Expand: find the correct collapsed data
1080
+ const currentIndent = currentLine.match(/^(\s*)/)[1].length;
1081
+ const found = this._findCollapsedData(line, nodeKey, currentIndent);
1092
1082
 
1093
- if (!foundKey || !foundData) {
1083
+ if (!found) {
1094
1084
  return;
1095
1085
  }
1096
1086
 
1097
- const {originalLine, content} = foundData;
1087
+ const { key: foundKey, data: foundData } = found;
1088
+ const { originalLine, content } = foundData;
1098
1089
 
1099
1090
  // Restore original line and content
1100
1091
  lines[line] = originalLine;
@@ -1109,48 +1100,9 @@ class GeoJsonEditor extends HTMLElement {
1109
1100
 
1110
1101
  const indent = match[1];
1111
1102
  const openBracket = match[3];
1112
- const closeBracket = openBracket === '{' ? '}' : ']';
1113
-
1114
- // Check if bracket closes on same line - can't collapse
1115
- if (this.bracketClosesOnSameLine(currentLine, openBracket)) return;
1116
-
1117
- // Find closing bracket in following lines
1118
- let depth = 1;
1119
- let endLine = line;
1120
- const content = [];
1121
-
1122
- for (let i = line + 1; i < lines.length; i++) {
1123
- const scanLine = lines[i];
1124
-
1125
- for (const char of scanLine) {
1126
- if (char === openBracket) depth++;
1127
- if (char === closeBracket) depth--;
1128
- }
1129
-
1130
- content.push(scanLine);
1131
-
1132
- if (depth === 0) {
1133
- endLine = i;
1134
- break;
1135
- }
1136
- }
1137
-
1138
- // Store the original data with unique key
1139
- const uniqueKey = `${line}-${nodeKey}`;
1140
- this.collapsedData.set(uniqueKey, {
1141
- originalLine: currentLine,
1142
- content: content,
1143
- indent: indent.length,
1144
- nodeKey: nodeKey // Store nodeKey for later use
1145
- });
1146
1103
 
1147
- // Replace with marker
1148
- const beforeBracket = currentLine.substring(0, currentLine.indexOf(openBracket));
1149
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1150
- lines[line] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1151
-
1152
- // Remove content lines
1153
- lines.splice(line + 1, endLine - line);
1104
+ // Use common collapse helper
1105
+ if (this._performCollapse(lines, line, nodeKey, indent, openBracket) === 0) return;
1154
1106
  }
1155
1107
 
1156
1108
  // Update textarea
@@ -1176,48 +1128,9 @@ class GeoJsonEditor extends HTMLElement {
1176
1128
  if (nodeKey === 'coordinates') {
1177
1129
  const indent = match[1];
1178
1130
  const openBracket = match[3];
1179
- const closeBracket = openBracket === '{' ? '}' : ']';
1180
-
1181
- // Skip if bracket closes on same line
1182
- if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1183
-
1184
- // Find closing bracket in following lines
1185
- let depth = 1;
1186
- let endLine = i;
1187
- const content = [];
1188
-
1189
- for (let j = i + 1; j < lines.length; j++) {
1190
- const scanLine = lines[j];
1191
-
1192
- for (const char of scanLine) {
1193
- if (char === openBracket) depth++;
1194
- if (char === closeBracket) depth--;
1195
- }
1196
-
1197
- content.push(scanLine);
1198
-
1199
- if (depth === 0) {
1200
- endLine = j;
1201
- break;
1202
- }
1203
- }
1204
-
1205
- // Store the original data with unique key
1206
- const uniqueKey = `${i}-${nodeKey}`;
1207
- this.collapsedData.set(uniqueKey, {
1208
- originalLine: line,
1209
- content: content,
1210
- indent: indent.length,
1211
- nodeKey: nodeKey
1212
- });
1213
1131
 
1214
- // Replace with marker
1215
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
1216
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1217
- lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1218
-
1219
- // Remove content lines
1220
- lines.splice(i + 1, endLine - i);
1132
+ // Use common collapse helper
1133
+ this._performCollapse(lines, i, nodeKey, indent, openBracket);
1221
1134
  }
1222
1135
  }
1223
1136
  }
@@ -1285,7 +1198,7 @@ class GeoJsonEditor extends HTMLElement {
1285
1198
  elements.visibilityButtons.forEach(({ featureKey, isHidden }) => {
1286
1199
  const button = document.createElement('button');
1287
1200
  button.className = 'visibility-button' + (isHidden ? ' hidden' : '');
1288
- button.innerHTML = GeoJsonEditor.ICON_EYE;
1201
+ button.textContent = '👁';
1289
1202
  button.dataset.featureKey = featureKey;
1290
1203
  button.title = isHidden ? 'Show feature in events' : 'Hide feature from events';
1291
1204
  gutterLine.appendChild(button);
@@ -1322,9 +1235,15 @@ class GeoJsonEditor extends HTMLElement {
1322
1235
  }
1323
1236
 
1324
1237
  showColorPicker(indicator, line, currentColor, attributeName) {
1325
- // Remove existing picker
1238
+ // Remove existing picker and clean up its listener
1326
1239
  const existing = document.querySelector('.geojson-color-picker-input');
1327
- if (existing) existing.remove();
1240
+ if (existing) {
1241
+ // Clean up the stored listener before removing
1242
+ if (existing._closeListener) {
1243
+ document.removeEventListener('click', existing._closeListener, true);
1244
+ }
1245
+ existing.remove();
1246
+ }
1328
1247
 
1329
1248
  // Create small color input positioned at the indicator
1330
1249
  const colorInput = document.createElement('input');
@@ -1358,11 +1277,14 @@ class GeoJsonEditor extends HTMLElement {
1358
1277
  // Close picker when clicking anywhere else
1359
1278
  const closeOnClickOutside = (e) => {
1360
1279
  if (e.target !== colorInput && !colorInput.contains(e.target)) {
1361
- colorInput.remove();
1362
1280
  document.removeEventListener('click', closeOnClickOutside, true);
1281
+ colorInput.remove();
1363
1282
  }
1364
1283
  };
1365
1284
 
1285
+ // Store the listener reference on the element for cleanup
1286
+ colorInput._closeListener = closeOnClickOutside;
1287
+
1366
1288
  // Add to document body with fixed positioning
1367
1289
  document.body.appendChild(colorInput);
1368
1290
 
@@ -1424,8 +1346,15 @@ class GeoJsonEditor extends HTMLElement {
1424
1346
  return; // No collapsed content, use default copy behavior
1425
1347
  }
1426
1348
 
1427
- // Replace collapsed markers with real content
1428
- const expandedText = this.expandCollapsedMarkersInText(selectedText, start);
1349
+ let expandedText;
1350
+
1351
+ // If selecting all content, use expandAllCollapsed directly (more reliable)
1352
+ if (start === 0 && end === textarea.value.length) {
1353
+ expandedText = this.expandAllCollapsed(selectedText);
1354
+ } else {
1355
+ // For partial selection, expand using line-by-line matching
1356
+ expandedText = this.expandCollapsedMarkersInText(selectedText, start);
1357
+ }
1429
1358
 
1430
1359
  // Put expanded text in clipboard
1431
1360
  e.preventDefault();
@@ -1436,6 +1365,7 @@ class GeoJsonEditor extends HTMLElement {
1436
1365
  const textarea = this.shadowRoot.getElementById('textarea');
1437
1366
  const beforeSelection = textarea.value.substring(0, startPos);
1438
1367
  const startLineNum = beforeSelection.split('\n').length - 1;
1368
+ const R = GeoJsonEditor.REGEX;
1439
1369
 
1440
1370
  const lines = text.split('\n');
1441
1371
  const expandedLines = [];
@@ -1445,20 +1375,31 @@ class GeoJsonEditor extends HTMLElement {
1445
1375
 
1446
1376
  // Check if this line has a collapsed marker
1447
1377
  if (line.includes('{...}') || line.includes('[...]')) {
1448
- // Find the collapsed node for this line
1449
- let found = false;
1450
- this.collapsedData.forEach((collapsed, key) => {
1451
- const collapsedLineNum = parseInt(key.split('-')[0]);
1452
- if (collapsedLineNum === absoluteLineNum) {
1453
- // Replace with original line and all collapsed content
1454
- expandedLines.push(collapsed.originalLine);
1455
- expandedLines.push(...collapsed.content);
1456
- found = true;
1378
+ const match = line.match(R.collapsedMarker);
1379
+ if (match) {
1380
+ const nodeKey = match[2];
1381
+ const currentIndent = match[1].length;
1382
+
1383
+ // Try to find collapsed data using helper
1384
+ const found = this._findCollapsedData(absoluteLineNum, nodeKey, currentIndent);
1385
+ if (found) {
1386
+ expandedLines.push(found.data.originalLine);
1387
+ expandedLines.push(...found.data.content);
1388
+ return;
1389
+ }
1390
+
1391
+ // Fallback: search by nodeKey only (line numbers may have shifted)
1392
+ for (const [, collapsed] of this.collapsedData.entries()) {
1393
+ if (collapsed.nodeKey === nodeKey) {
1394
+ expandedLines.push(collapsed.originalLine);
1395
+ expandedLines.push(...collapsed.content);
1396
+ return;
1397
+ }
1457
1398
  }
1458
- });
1459
- if (!found) {
1460
- expandedLines.push(line);
1461
1399
  }
1400
+
1401
+ // Line not found in collapsed data, keep as-is
1402
+ expandedLines.push(line);
1462
1403
  } else {
1463
1404
  expandedLines.push(line);
1464
1405
  }
@@ -1492,10 +1433,8 @@ class GeoJsonEditor extends HTMLElement {
1492
1433
  // Expand ALL collapsed nodes to get full content
1493
1434
  const editorContent = this.expandAllCollapsed(textarea.value);
1494
1435
 
1495
- // Build complete value with prefix/suffix
1496
- const prefix = this.prefix;
1497
- const suffix = this.suffix;
1498
- const fullValue = prefix + editorContent + suffix;
1436
+ // Build complete value with prefix/suffix (fixed FeatureCollection wrapper)
1437
+ const fullValue = this.prefix + editorContent + this.suffix;
1499
1438
 
1500
1439
  // Try to parse
1501
1440
  try {
@@ -1605,11 +1544,6 @@ class GeoJsonEditor extends HTMLElement {
1605
1544
  this.emitChange();
1606
1545
  }
1607
1546
 
1608
- // Check if a feature is hidden
1609
- isFeatureHidden(featureKey) {
1610
- return this.hiddenFeatures.has(featureKey);
1611
- }
1612
-
1613
1547
  // Parse JSON and extract feature ranges (line numbers for each Feature)
1614
1548
  updateFeatureRanges() {
1615
1549
  const textarea = this.shadowRoot.getElementById('textarea');
@@ -1663,22 +1597,16 @@ class GeoJsonEditor extends HTMLElement {
1663
1597
  inFeature = true;
1664
1598
 
1665
1599
  // Start braceDepth at 1 since we're inside the Feature's opening brace
1666
- // Then count any additional braces from startLine to current line
1600
+ // Then count any additional braces from startLine to current line (ignoring strings)
1667
1601
  braceDepth = 1;
1668
1602
  for (let k = startLine; k <= i; k++) {
1669
1603
  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
- }
1604
+ const counts = this._countBracketsOutsideStrings(scanLine, '{');
1605
+ if (k === startLine) {
1606
+ // Skip the first { we already counted
1607
+ braceDepth += (counts.open - 1) - counts.close;
1608
+ } else {
1609
+ braceDepth += counts.open - counts.close;
1682
1610
  }
1683
1611
  }
1684
1612
 
@@ -1687,11 +1615,9 @@ class GeoJsonEditor extends HTMLElement {
1687
1615
  currentFeatureKey = this.getFeatureKey(features[featureIndex]);
1688
1616
  }
1689
1617
  } else if (inFeature) {
1690
- // Count braces
1691
- for (const char of line) {
1692
- if (char === '{') braceDepth++;
1693
- else if (char === '}') braceDepth--;
1694
- }
1618
+ // Count braces (ignoring those in strings)
1619
+ const counts = this._countBracketsOutsideStrings(line, '{');
1620
+ braceDepth += counts.open - counts.close;
1695
1621
 
1696
1622
  // Feature ends when braceDepth returns to 0
1697
1623
  if (braceDepth <= 0) {
@@ -1741,13 +1667,13 @@ class GeoJsonEditor extends HTMLElement {
1741
1667
  if (typeof typeValue === 'string') {
1742
1668
  if (context === 'geometry') {
1743
1669
  // In geometry: must be a geometry type
1744
- if (!GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(typeValue)) {
1745
- errors.push(`Invalid geometry type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.join(', ')})`);
1670
+ if (!GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue)) {
1671
+ errors.push(`Invalid geometry type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.join(', ')})`);
1746
1672
  }
1747
1673
  } else {
1748
1674
  // At root or in features: must be Feature or FeatureCollection
1749
- if (!GeoJsonEditor.GEOJSON_TYPES_FEATURE.includes(typeValue)) {
1750
- errors.push(`Invalid type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON_TYPES_FEATURE.join(', ')})`);
1675
+ if (!GeoJsonEditor.GEOJSON.FEATURE_TYPES.includes(typeValue)) {
1676
+ errors.push(`Invalid type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON.FEATURE_TYPES.join(', ')})`);
1751
1677
  }
1752
1678
  }
1753
1679
  }
@@ -1779,19 +1705,131 @@ class GeoJsonEditor extends HTMLElement {
1779
1705
  return errors;
1780
1706
  }
1781
1707
 
1782
- // Helper: Check if bracket closes on same line
1783
- bracketClosesOnSameLine(line, openBracket) {
1708
+ // Helper: Count bracket depth change in a line, ignoring brackets inside strings
1709
+ // Returns {open: count, close: count} for the specified bracket type
1710
+ _countBracketsOutsideStrings(line, openBracket) {
1784
1711
  const closeBracket = openBracket === '{' ? '}' : ']';
1712
+ let openCount = 0;
1713
+ let closeCount = 0;
1714
+ let inString = false;
1715
+ let escape = false;
1716
+
1717
+ for (let i = 0; i < line.length; i++) {
1718
+ const char = line[i];
1719
+
1720
+ if (escape) {
1721
+ escape = false;
1722
+ continue;
1723
+ }
1724
+
1725
+ if (char === '\\' && inString) {
1726
+ escape = true;
1727
+ continue;
1728
+ }
1729
+
1730
+ if (char === '"') {
1731
+ inString = !inString;
1732
+ continue;
1733
+ }
1734
+
1735
+ if (!inString) {
1736
+ if (char === openBracket) openCount++;
1737
+ if (char === closeBracket) closeCount++;
1738
+ }
1739
+ }
1740
+
1741
+ return { open: openCount, close: closeCount };
1742
+ }
1743
+
1744
+ // Helper: Check if bracket closes on same line (ignores brackets in strings)
1745
+ bracketClosesOnSameLine(line, openBracket) {
1785
1746
  const bracketPos = line.indexOf(openBracket);
1786
1747
  if (bracketPos === -1) return false;
1748
+
1787
1749
  const restOfLine = line.substring(bracketPos + 1);
1750
+ const counts = this._countBracketsOutsideStrings(restOfLine, openBracket);
1751
+
1752
+ // Depth starts at 1 (we're after the opening bracket)
1753
+ // If closes equal or exceed opens + 1, the bracket closes on this line
1754
+ return counts.close > counts.open;
1755
+ }
1756
+
1757
+ // Helper: Find closing bracket line starting from startLine
1758
+ // Returns { endLine, content: string[] } or null if not found
1759
+ _findClosingBracket(lines, startLine, openBracket) {
1788
1760
  let depth = 1;
1789
- for (const char of restOfLine) {
1790
- if (char === openBracket) depth++;
1791
- if (char === closeBracket) depth--;
1792
- if (depth === 0) return true;
1761
+ const content = [];
1762
+
1763
+ // Count remaining brackets on the start line (after the opening bracket)
1764
+ const startLineContent = lines[startLine];
1765
+ const bracketPos = startLineContent.indexOf(openBracket);
1766
+ if (bracketPos !== -1) {
1767
+ const restOfStartLine = startLineContent.substring(bracketPos + 1);
1768
+ const startCounts = this._countBracketsOutsideStrings(restOfStartLine, openBracket);
1769
+ depth += startCounts.open - startCounts.close;
1770
+ if (depth === 0) {
1771
+ return { endLine: startLine, content: [] };
1772
+ }
1793
1773
  }
1794
- return false;
1774
+
1775
+ for (let i = startLine + 1; i < lines.length; i++) {
1776
+ const scanLine = lines[i];
1777
+ const counts = this._countBracketsOutsideStrings(scanLine, openBracket);
1778
+ depth += counts.open - counts.close;
1779
+
1780
+ content.push(scanLine);
1781
+
1782
+ if (depth === 0) {
1783
+ return { endLine: i, content };
1784
+ }
1785
+ }
1786
+
1787
+ return null; // Not found (malformed JSON)
1788
+ }
1789
+
1790
+ /**
1791
+ * Helper: Perform collapse operation on a node at given line
1792
+ * Stores data in collapsedData, replaces line with marker, removes content lines
1793
+ * @param {string[]} lines - Array of lines (modified in place)
1794
+ * @param {number} lineIndex - Index of line to collapse
1795
+ * @param {string} nodeKey - Key of the node (e.g., 'coordinates')
1796
+ * @param {string} indent - Indentation string
1797
+ * @param {string} openBracket - Opening bracket character ('{' or '[')
1798
+ * @returns {number} Number of lines removed, or 0 if collapse failed
1799
+ * @private
1800
+ */
1801
+ _performCollapse(lines, lineIndex, nodeKey, indent, openBracket) {
1802
+ const line = lines[lineIndex];
1803
+ const closeBracket = openBracket === '{' ? '}' : ']';
1804
+
1805
+ // Skip if bracket closes on same line
1806
+ if (this.bracketClosesOnSameLine(line, openBracket)) return 0;
1807
+
1808
+ // Find closing bracket
1809
+ const result = this._findClosingBracket(lines, lineIndex, openBracket);
1810
+ if (!result) return 0;
1811
+
1812
+ const { endLine, content } = result;
1813
+
1814
+ // Store the original data with unique key
1815
+ const uniqueKey = `${lineIndex}-${nodeKey}`;
1816
+ this.collapsedData.set(uniqueKey, {
1817
+ originalLine: line,
1818
+ content: content,
1819
+ indent: indent.length,
1820
+ nodeKey: nodeKey
1821
+ });
1822
+
1823
+ // Replace with marker
1824
+ const beforeBracket = line.substring(0, line.indexOf(openBracket));
1825
+ const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1826
+ lines[lineIndex] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1827
+
1828
+ // Remove content lines
1829
+ const linesRemoved = endLine - lineIndex;
1830
+ lines.splice(lineIndex + 1, linesRemoved);
1831
+
1832
+ return linesRemoved;
1795
1833
  }
1796
1834
 
1797
1835
  // Helper: Expand all collapsed markers and return expanded content
@@ -1811,20 +1849,10 @@ class GeoJsonEditor extends HTMLElement {
1811
1849
 
1812
1850
  const nodeKey = match[2];
1813
1851
  const currentIndent = match[1].length;
1814
- const exactKey = `${i}-${nodeKey}`;
1852
+ const found = this._findCollapsedData(i, nodeKey, currentIndent);
1815
1853
 
1816
- let foundKey = this.collapsedData.has(exactKey) ? exactKey : null;
1817
- if (!foundKey) {
1818
- for (const [key, data] of this.collapsedData.entries()) {
1819
- if (data.nodeKey === nodeKey && data.indent === currentIndent) {
1820
- foundKey = key;
1821
- break;
1822
- }
1823
- }
1824
- }
1825
-
1826
- if (foundKey) {
1827
- const {originalLine, content: nodeContent} = this.collapsedData.get(foundKey);
1854
+ if (found) {
1855
+ const { data: { originalLine, content: nodeContent } } = found;
1828
1856
  lines[i] = originalLine;
1829
1857
  lines.splice(i + 1, 0, ...nodeContent);
1830
1858
  expanded = true;
@@ -1838,27 +1866,20 @@ class GeoJsonEditor extends HTMLElement {
1838
1866
  return content;
1839
1867
  }
1840
1868
 
1841
- // Helper: Format JSON content respecting prefix/suffix
1869
+ // Helper: Format JSON content (always in FeatureCollection mode)
1870
+ // Also applies default properties to features if configured
1842
1871
  formatJSONContent(content) {
1843
- const prefix = this.prefix;
1844
- const suffix = this.suffix;
1845
- const prefixEndsWithBracket = prefix.trimEnd().endsWith('[');
1846
- const suffixStartsWithBracket = suffix.trimStart().startsWith(']');
1847
-
1848
- if (prefixEndsWithBracket && suffixStartsWithBracket) {
1849
- const wrapped = '[' + content + ']';
1850
- const parsed = JSON.parse(wrapped);
1851
- const formatted = JSON.stringify(parsed, null, 2);
1852
- const lines = formatted.split('\n');
1853
- return lines.length > 2 ? lines.slice(1, -1).join('\n') : '';
1854
- } else if (!prefix && !suffix) {
1855
- const parsed = JSON.parse(content);
1856
- return JSON.stringify(parsed, null, 2);
1857
- } else {
1858
- const fullValue = prefix + content + suffix;
1859
- JSON.parse(fullValue); // Validate only
1860
- return content;
1872
+ const wrapped = '[' + content + ']';
1873
+ let parsed = JSON.parse(wrapped);
1874
+
1875
+ // Apply default properties to each feature in the array
1876
+ if (Array.isArray(parsed)) {
1877
+ parsed = parsed.map(f => this._applyDefaultPropertiesToFeature(f));
1861
1878
  }
1879
+
1880
+ const formatted = JSON.stringify(parsed, null, 2);
1881
+ const lines = formatted.split('\n');
1882
+ return lines.length > 2 ? lines.slice(1, -1).join('\n') : '';
1862
1883
  }
1863
1884
 
1864
1885
  autoFormatContentWithCursor() {
@@ -1908,34 +1929,6 @@ class GeoJsonEditor extends HTMLElement {
1908
1929
  }
1909
1930
  }
1910
1931
 
1911
- autoFormatContent() {
1912
- const textarea = this.shadowRoot.getElementById('textarea');
1913
-
1914
- // Save collapsed node details
1915
- const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
1916
- nodeKey: data.nodeKey,
1917
- indent: data.indent
1918
- }));
1919
-
1920
- // Expand and format
1921
- const content = this.expandAllCollapsed(textarea.value);
1922
-
1923
- try {
1924
- const formattedContent = this.formatJSONContent(content);
1925
-
1926
- if (formattedContent !== content) {
1927
- this.collapsedData.clear();
1928
- textarea.value = formattedContent;
1929
-
1930
- if (collapsedNodes.length > 0) {
1931
- this.reapplyCollapsed(collapsedNodes);
1932
- }
1933
- }
1934
- } catch (e) {
1935
- // Invalid JSON, don't format
1936
- }
1937
- }
1938
-
1939
1932
  reapplyCollapsed(collapsedNodes) {
1940
1933
  const textarea = this.shadowRoot.getElementById('textarea');
1941
1934
  const lines = textarea.value.split('\n');
@@ -1967,50 +1960,11 @@ class GeoJsonEditor extends HTMLElement {
1967
1960
 
1968
1961
  // Only collapse if this occurrence should be collapsed
1969
1962
  if (currentOccurrence <= collapseMap.get(key)) {
1970
- const indent = match[1];
1971
- const openBracket = match[3];
1972
- const closeBracket = openBracket === '{' ? '}' : ']';
1973
-
1974
- // Skip if closes on same line
1975
- if (this.bracketClosesOnSameLine(line, openBracket)) continue;
1976
-
1977
- // Find closing bracket
1978
- let depth = 1;
1979
- let endLine = i;
1980
- const content = [];
1981
-
1982
- for (let j = i + 1; j < lines.length; j++) {
1983
- const scanLine = lines[j];
1984
-
1985
- for (const char of scanLine) {
1986
- if (char === openBracket) depth++;
1987
- if (char === closeBracket) depth--;
1988
- }
1989
-
1990
- content.push(scanLine);
1963
+ const indent = match[1];
1964
+ const openBracket = match[3];
1991
1965
 
1992
- if (depth === 0) {
1993
- endLine = j;
1994
- break;
1995
- }
1996
- }
1997
-
1998
- // Store with unique key
1999
- const uniqueKey = `${i}-${nodeKey}`;
2000
- this.collapsedData.set(uniqueKey, {
2001
- originalLine: line,
2002
- content: content,
2003
- indent: indent.length,
2004
- nodeKey: nodeKey
2005
- });
2006
-
2007
- // Replace with marker
2008
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
2009
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
2010
- lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
2011
-
2012
- // Remove content lines
2013
- lines.splice(i + 1, endLine - i);
1966
+ // Use common collapse helper
1967
+ this._performCollapse(lines, i, nodeKey, indent, openBracket);
2014
1968
  }
2015
1969
  }
2016
1970
  }
@@ -2039,77 +1993,41 @@ class GeoJsonEditor extends HTMLElement {
2039
1993
  // Generate and inject theme CSS based on dark selector
2040
1994
  updateThemeCSS() {
2041
1995
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
2042
-
2043
- // Parse selector to create CSS rule for dark theme
2044
1996
  const darkRule = this.parseSelectorToHostRule(darkSelector);
2045
- // Light theme is the default (no selector = light)
2046
- const lightRule = ':host';
2047
1997
 
2048
1998
  // Find or create theme style element
2049
1999
  let themeStyle = this.shadowRoot.getElementById('theme-styles');
2050
2000
  if (!themeStyle) {
2051
2001
  themeStyle = document.createElement('style');
2052
2002
  themeStyle.id = 'theme-styles';
2053
- // Insert at the beginning of shadow root to ensure it's before static styles
2054
2003
  this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
2055
2004
  }
2056
2005
 
2057
- // Generate CSS with theme variables (light first as default, then dark overrides)
2058
- const css = `
2059
- ${lightRule} {
2060
- --bg-color: ${this.themes.light.background};
2061
- --text-color: ${this.themes.light.textColor};
2062
- --caret-color: ${this.themes.light.caretColor};
2063
- --gutter-bg: ${this.themes.light.gutterBackground};
2064
- --gutter-border: ${this.themes.light.gutterBorder};
2065
- --json-key: ${this.themes.light.jsonKey};
2066
- --json-string: ${this.themes.light.jsonString};
2067
- --json-number: ${this.themes.light.jsonNumber};
2068
- --json-boolean: ${this.themes.light.jsonBoolean};
2069
- --json-null: ${this.themes.light.jsonNull};
2070
- --json-punct: ${this.themes.light.jsonPunctuation};
2071
- --control-color: ${this.themes.light.controlColor};
2072
- --control-bg: ${this.themes.light.controlBg};
2073
- --control-border: ${this.themes.light.controlBorder};
2074
- --geojson-key: ${this.themes.light.geojsonKey};
2075
- --geojson-type: ${this.themes.light.geojsonType};
2076
- --geojson-type-invalid: ${this.themes.light.geojsonTypeInvalid};
2077
- --json-key-invalid: ${this.themes.light.jsonKeyInvalid};
2078
- }
2006
+ // Helper to generate CSS variables from theme object
2007
+ const generateVars = (themeObj) => {
2008
+ return Object.entries(themeObj || {})
2009
+ .map(([key, value]) => `--${GeoJsonEditor._toKebabCase(key)}: ${value};`)
2010
+ .join('\n ');
2011
+ };
2079
2012
 
2080
- ${darkRule} {
2081
- --bg-color: ${this.themes.dark.background};
2082
- --text-color: ${this.themes.dark.textColor};
2083
- --caret-color: ${this.themes.dark.caretColor};
2084
- --gutter-bg: ${this.themes.dark.gutterBackground};
2085
- --gutter-border: ${this.themes.dark.gutterBorder};
2086
- --json-key: ${this.themes.dark.jsonKey};
2087
- --json-string: ${this.themes.dark.jsonString};
2088
- --json-number: ${this.themes.dark.jsonNumber};
2089
- --json-boolean: ${this.themes.dark.jsonBoolean};
2090
- --json-null: ${this.themes.dark.jsonNull};
2091
- --json-punct: ${this.themes.dark.jsonPunctuation};
2092
- --control-color: ${this.themes.dark.controlColor};
2093
- --control-bg: ${this.themes.dark.controlBg};
2094
- --control-border: ${this.themes.dark.controlBorder};
2095
- --geojson-key: ${this.themes.dark.geojsonKey};
2096
- --geojson-type: ${this.themes.dark.geojsonType};
2097
- --geojson-type-invalid: ${this.themes.dark.geojsonTypeInvalid};
2098
- --json-key-invalid: ${this.themes.dark.jsonKeyInvalid};
2099
- }
2100
- `;
2013
+ // Light theme: only overrides (defaults are in static CSS)
2014
+ const lightVars = generateVars(this.themes.light);
2015
+
2016
+ // Dark theme: ALWAYS generate with defaults + overrides (selector is dynamic)
2017
+ const darkTheme = { ...GeoJsonEditor.DARK_THEME_DEFAULTS, ...this.themes.dark };
2018
+ const darkVars = generateVars(darkTheme);
2019
+
2020
+ let css = '';
2021
+ if (lightVars) {
2022
+ css += `:host {\n ${lightVars}\n }\n`;
2023
+ }
2024
+ // Dark theme is always generated (selector is configurable)
2025
+ css += `${darkRule} {\n ${darkVars}\n }`;
2101
2026
 
2102
2027
  themeStyle.textContent = css;
2103
2028
  }
2104
2029
 
2105
2030
  // Public API: Theme management
2106
- getTheme() {
2107
- return {
2108
- dark: { ...this.themes.dark },
2109
- light: { ...this.themes.light }
2110
- };
2111
- }
2112
-
2113
2031
  setTheme(theme) {
2114
2032
  if (theme.dark) {
2115
2033
  this.themes.dark = { ...this.themes.dark, ...theme.dark };
@@ -2117,19 +2035,282 @@ class GeoJsonEditor extends HTMLElement {
2117
2035
  if (theme.light) {
2118
2036
  this.themes.light = { ...this.themes.light, ...theme.light };
2119
2037
  }
2120
-
2121
- // Regenerate CSS with new theme values
2122
2038
  this.updateThemeCSS();
2123
2039
  }
2124
2040
 
2125
2041
  resetTheme() {
2126
- // Reset to defaults
2127
- this.themes = {
2128
- dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
2129
- light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
2130
- };
2042
+ this.themes = { dark: {}, light: {} };
2131
2043
  this.updateThemeCSS();
2132
2044
  }
2045
+
2046
+ // ========================================
2047
+ // Features API - Programmatic manipulation
2048
+ // ========================================
2049
+
2050
+ /**
2051
+ * Normalize a Python-style index (supports negative values)
2052
+ * @param {number} index - Index to normalize (negative = from end)
2053
+ * @param {number} length - Length of the array
2054
+ * @param {boolean} clamp - If true, clamp to valid range; if false, return -1 for out of bounds
2055
+ * @returns {number} Normalized index, or -1 if out of bounds (when clamp=false)
2056
+ * @private
2057
+ */
2058
+ _normalizeIndex(index, length, clamp = false) {
2059
+ let idx = index;
2060
+ if (idx < 0) {
2061
+ idx = length + idx;
2062
+ }
2063
+ if (clamp) {
2064
+ return Math.max(0, Math.min(idx, length));
2065
+ }
2066
+ return (idx < 0 || idx >= length) ? -1 : idx;
2067
+ }
2068
+
2069
+ /**
2070
+ * Parse current textarea content into an array of features
2071
+ * @returns {Array} Array of feature objects
2072
+ * @private
2073
+ */
2074
+ _parseFeatures() {
2075
+ const textarea = this.shadowRoot.getElementById('textarea');
2076
+ if (!textarea || !textarea.value.trim()) {
2077
+ return [];
2078
+ }
2079
+
2080
+ try {
2081
+ // Expand collapsed nodes to get full content
2082
+ const content = this.expandAllCollapsed(textarea.value);
2083
+ // Wrap in array brackets and parse
2084
+ const wrapped = '[' + content + ']';
2085
+ return JSON.parse(wrapped);
2086
+ } catch (e) {
2087
+ return [];
2088
+ }
2089
+ }
2090
+
2091
+ /**
2092
+ * Update textarea with features array and trigger all updates
2093
+ * @param {Array} features - Array of feature objects
2094
+ * @private
2095
+ */
2096
+ _setFeatures(features) {
2097
+ const textarea = this.shadowRoot.getElementById('textarea');
2098
+ if (!textarea) return;
2099
+
2100
+ // Clear internal state when replacing features (prevent memory leaks)
2101
+ this.collapsedData.clear();
2102
+ this.hiddenFeatures.clear();
2103
+
2104
+ if (!features || features.length === 0) {
2105
+ textarea.value = '';
2106
+ } else {
2107
+ // Format each feature and join with comma
2108
+ const formatted = features
2109
+ .map(f => JSON.stringify(f, null, 2))
2110
+ .join(',\n');
2111
+
2112
+ textarea.value = formatted;
2113
+ }
2114
+
2115
+ // Trigger all updates
2116
+ this.updateHighlight();
2117
+ this.updatePlaceholderVisibility();
2118
+
2119
+ // Auto-collapse coordinates
2120
+ if (textarea.value) {
2121
+ requestAnimationFrame(() => {
2122
+ this.applyAutoCollapsed();
2123
+ });
2124
+ }
2125
+
2126
+ // Emit change event
2127
+ this.emitChange();
2128
+ }
2129
+
2130
+ /**
2131
+ * Validate a single feature object
2132
+ * @param {Object} feature - Feature object to validate
2133
+ * @returns {string[]} Array of validation error messages (empty if valid)
2134
+ * @private
2135
+ */
2136
+ _validateFeature(feature) {
2137
+ const errors = [];
2138
+
2139
+ if (!feature || typeof feature !== 'object') {
2140
+ errors.push('Feature must be an object');
2141
+ return errors;
2142
+ }
2143
+
2144
+ if (Array.isArray(feature)) {
2145
+ errors.push('Feature cannot be an array');
2146
+ return errors;
2147
+ }
2148
+
2149
+ // Check required type field
2150
+ if (!('type' in feature)) {
2151
+ errors.push('Feature must have a "type" property');
2152
+ } else if (feature.type !== 'Feature') {
2153
+ errors.push(`Feature type must be "Feature", got "${feature.type}"`);
2154
+ }
2155
+
2156
+ // Check geometry field exists (can be null for features without location)
2157
+ if (!('geometry' in feature)) {
2158
+ errors.push('Feature must have a "geometry" property (can be null)');
2159
+ } else if (feature.geometry !== null) {
2160
+ // Validate geometry if not null
2161
+ if (typeof feature.geometry !== 'object' || Array.isArray(feature.geometry)) {
2162
+ errors.push('Feature geometry must be an object or null');
2163
+ } else {
2164
+ // Check geometry has valid type
2165
+ if (!('type' in feature.geometry)) {
2166
+ errors.push('Geometry must have a "type" property');
2167
+ } else if (!GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(feature.geometry.type)) {
2168
+ errors.push(`Invalid geometry type "${feature.geometry.type}" (expected: ${GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.join(', ')})`);
2169
+ }
2170
+
2171
+ // Check geometry has coordinates (except GeometryCollection)
2172
+ if (feature.geometry.type !== 'GeometryCollection' && !('coordinates' in feature.geometry)) {
2173
+ errors.push('Geometry must have a "coordinates" property');
2174
+ }
2175
+
2176
+ // GeometryCollection must have geometries array
2177
+ if (feature.geometry.type === 'GeometryCollection' && !Array.isArray(feature.geometry.geometries)) {
2178
+ errors.push('GeometryCollection must have a "geometries" array');
2179
+ }
2180
+ }
2181
+ }
2182
+
2183
+ // Check properties field exists (can be null)
2184
+ if (!('properties' in feature)) {
2185
+ errors.push('Feature must have a "properties" property (can be null)');
2186
+ } else if (feature.properties !== null && (typeof feature.properties !== 'object' || Array.isArray(feature.properties))) {
2187
+ errors.push('Feature properties must be an object or null');
2188
+ }
2189
+
2190
+ return errors;
2191
+ }
2192
+
2193
+ /**
2194
+ * Replace all features with the given array
2195
+ * @param {Array} features - Array of feature objects to set
2196
+ * @throws {Error} If features is not an array or contains invalid features
2197
+ */
2198
+ set(features) {
2199
+ if (!Array.isArray(features)) {
2200
+ throw new Error('set() expects an array of features');
2201
+ }
2202
+
2203
+ // Validate each feature
2204
+ const allErrors = [];
2205
+ features.forEach((feature, index) => {
2206
+ const errors = this._validateFeature(feature);
2207
+ if (errors.length > 0) {
2208
+ allErrors.push(`Feature[${index}]: ${errors.join(', ')}`);
2209
+ }
2210
+ });
2211
+
2212
+ if (allErrors.length > 0) {
2213
+ throw new Error(`Invalid features: ${allErrors.join('; ')}`);
2214
+ }
2215
+
2216
+ // Apply default properties to each feature
2217
+ const featuresWithDefaults = features.map(f => this._applyDefaultPropertiesToFeature(f));
2218
+ this._setFeatures(featuresWithDefaults);
2219
+ }
2220
+
2221
+ /**
2222
+ * Add a feature at the end of the list
2223
+ * @param {Object} feature - Feature object to add
2224
+ * @throws {Error} If feature is invalid
2225
+ */
2226
+ add(feature) {
2227
+ const errors = this._validateFeature(feature);
2228
+ if (errors.length > 0) {
2229
+ throw new Error(`Invalid feature: ${errors.join(', ')}`);
2230
+ }
2231
+
2232
+ const features = this._parseFeatures();
2233
+ // Apply default properties before adding
2234
+ features.push(this._applyDefaultPropertiesToFeature(feature));
2235
+ this._setFeatures(features);
2236
+ }
2237
+
2238
+ /**
2239
+ * Insert a feature at the specified index
2240
+ * @param {Object} feature - Feature object to insert
2241
+ * @param {number} index - Index to insert at (negative = from end)
2242
+ * @throws {Error} If feature is invalid
2243
+ */
2244
+ insertAt(feature, index) {
2245
+ const errors = this._validateFeature(feature);
2246
+ if (errors.length > 0) {
2247
+ throw new Error(`Invalid feature: ${errors.join(', ')}`);
2248
+ }
2249
+
2250
+ const features = this._parseFeatures();
2251
+ const idx = this._normalizeIndex(index, features.length, true);
2252
+
2253
+ // Apply default properties before inserting
2254
+ features.splice(idx, 0, this._applyDefaultPropertiesToFeature(feature));
2255
+ this._setFeatures(features);
2256
+ }
2257
+
2258
+ /**
2259
+ * Remove the feature at the specified index
2260
+ * @param {number} index - Index to remove (negative = from end)
2261
+ * @returns {Object|undefined} The removed feature, or undefined if index out of bounds
2262
+ */
2263
+ removeAt(index) {
2264
+ const features = this._parseFeatures();
2265
+ if (features.length === 0) return undefined;
2266
+
2267
+ const idx = this._normalizeIndex(index, features.length);
2268
+ if (idx === -1) return undefined;
2269
+
2270
+ const removed = features.splice(idx, 1)[0];
2271
+ this._setFeatures(features);
2272
+ return removed;
2273
+ }
2274
+
2275
+ /**
2276
+ * Remove all features
2277
+ * @returns {Array} Array of removed features
2278
+ */
2279
+ removeAll() {
2280
+ const removed = this._parseFeatures();
2281
+ this._setFeatures([]);
2282
+ return removed;
2283
+ }
2284
+
2285
+ /**
2286
+ * Get the feature at the specified index
2287
+ * @param {number} index - Index to get (negative = from end)
2288
+ * @returns {Object|undefined} The feature, or undefined if index out of bounds
2289
+ */
2290
+ get(index) {
2291
+ const features = this._parseFeatures();
2292
+ if (features.length === 0) return undefined;
2293
+
2294
+ const idx = this._normalizeIndex(index, features.length);
2295
+ if (idx === -1) return undefined;
2296
+
2297
+ return features[idx];
2298
+ }
2299
+
2300
+ /**
2301
+ * Get all features as an array
2302
+ * @returns {Array} Array of all feature objects
2303
+ */
2304
+ getAll() {
2305
+ return this._parseFeatures();
2306
+ }
2307
+
2308
+ /**
2309
+ * Emit the current document on the change event
2310
+ */
2311
+ emit() {
2312
+ this.emitChange();
2313
+ }
2133
2314
  }
2134
2315
 
2135
2316
  // Register the custom element