@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.
- package/README.md +129 -37
- package/dist/geojson-editor.js +3 -4
- package/package.json +4 -4
- package/src/geojson-editor.js +871 -690
package/src/geojson-editor.js
CHANGED
|
@@ -17,68 +17,43 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
17
17
|
this._cachedLineHeight = null;
|
|
18
18
|
this._cachedPaddingTop = null;
|
|
19
19
|
|
|
20
|
-
//
|
|
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', '
|
|
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
|
-
//
|
|
77
|
-
|
|
78
|
-
static FEATURE_COLLECTION_SUFFIX = ']}';
|
|
28
|
+
// Parsed default properties rules (cache)
|
|
29
|
+
_defaultPropertiesRules = null;
|
|
79
30
|
|
|
80
|
-
//
|
|
81
|
-
static
|
|
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 === '
|
|
133
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_PREFIX : '';
|
|
179
|
+
get defaultProperties() {
|
|
180
|
+
return this.getAttribute('default-properties') || '';
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
/*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
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-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
.
|
|
478
|
-
|
|
479
|
-
|
|
472
|
+
.prefix-wrapper, .suffix-wrapper {
|
|
473
|
+
display: flex;
|
|
474
|
+
flex-shrink: 0;
|
|
475
|
+
background: var(--bg-color, #fff);
|
|
480
476
|
}
|
|
481
477
|
|
|
482
|
-
.
|
|
483
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
.
|
|
504
|
-
|
|
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
|
-
.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
531
|
-
textarea {
|
|
532
|
-
|
|
533
|
-
|
|
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="
|
|
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="
|
|
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(
|
|
669
|
-
.replace(
|
|
670
|
-
.replace(
|
|
679
|
+
.replace(R.ampersand, '&')
|
|
680
|
+
.replace(R.lessThan, '<')
|
|
681
|
+
.replace(R.greaterThan, '>');
|
|
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
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
if (
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
//
|
|
906
|
-
const rootContext =
|
|
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
|
-
//
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
const
|
|
926
|
-
if (
|
|
927
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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.
|
|
1006
|
-
return GeoJsonEditor.
|
|
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.
|
|
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, '<')
|
|
1021
1031
|
.replace(R.greaterThan, '>')
|
|
1022
1032
|
// All JSON keys - validate against context
|
|
1023
|
-
.replace(R.jsonKey, (
|
|
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, (
|
|
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
|
|
1070
|
-
|
|
1071
|
-
|
|
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 (!
|
|
1083
|
+
if (!found) {
|
|
1094
1084
|
return;
|
|
1095
1085
|
}
|
|
1096
1086
|
|
|
1097
|
-
const {
|
|
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
|
-
//
|
|
1148
|
-
|
|
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
|
-
//
|
|
1215
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
1428
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
const
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
found
|
|
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
|
|
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
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
1692
|
-
|
|
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.
|
|
1745
|
-
errors.push(`Invalid geometry type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.
|
|
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.
|
|
1750
|
-
errors.push(`Invalid type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.
|
|
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:
|
|
1783
|
-
|
|
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
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
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
|
-
|
|
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
|
|
1852
|
+
const found = this._findCollapsedData(i, nodeKey, currentIndent);
|
|
1815
1853
|
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
|
1869
|
+
// Helper: Format JSON content (always in FeatureCollection mode)
|
|
1870
|
+
// Also applies default properties to features if configured
|
|
1842
1871
|
formatJSONContent(content) {
|
|
1843
|
-
const
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
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
|
-
|
|
1993
|
-
|
|
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
|
-
//
|
|
2058
|
-
const
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
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
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
-
|
|
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
|