@softwarity/geojson-editor 1.0.1
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/LICENSE +21 -0
- package/README.md +276 -0
- package/dist/geojson-editor.js +10 -0
- package/package.json +44 -0
- package/src/geojson-editor.js +1825 -0
|
@@ -0,0 +1,1825 @@
|
|
|
1
|
+
class GeoJsonEditor extends HTMLElement {
|
|
2
|
+
constructor() {
|
|
3
|
+
super();
|
|
4
|
+
this.attachShadow({ mode: 'open' });
|
|
5
|
+
|
|
6
|
+
// Internal state
|
|
7
|
+
this.collapsedData = new Map(); // nodeKey -> {originalLines: string[], indent: number}
|
|
8
|
+
this.colorPositions = []; // {line, color}
|
|
9
|
+
this.nodeTogglePositions = []; // {line, nodeKey, isCollapsed, indent}
|
|
10
|
+
|
|
11
|
+
// Debounce timer for syntax highlighting
|
|
12
|
+
this.highlightTimer = null;
|
|
13
|
+
|
|
14
|
+
// Cached computed styles (avoid repeated getComputedStyle calls)
|
|
15
|
+
this._cachedLineHeight = null;
|
|
16
|
+
this._cachedPaddingTop = null;
|
|
17
|
+
|
|
18
|
+
// Initialize themes from defaults
|
|
19
|
+
this.themes = {
|
|
20
|
+
dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
|
|
21
|
+
light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static get observedAttributes() {
|
|
26
|
+
return ['readonly', 'value', 'placeholder', 'auto-format', 'dark-selector', 'feature-collection'];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
// Default theme values
|
|
31
|
+
static DEFAULT_THEMES = {
|
|
32
|
+
dark: {
|
|
33
|
+
background: '#1e1e1e',
|
|
34
|
+
textColor: '#d4d4d4',
|
|
35
|
+
caretColor: '#fff',
|
|
36
|
+
gutterBackground: '#252526',
|
|
37
|
+
gutterBorder: '#3e3e42',
|
|
38
|
+
jsonKey: '#9cdcfe',
|
|
39
|
+
jsonString: '#ce9178',
|
|
40
|
+
jsonNumber: '#b5cea8',
|
|
41
|
+
jsonBoolean: '#569cd6',
|
|
42
|
+
jsonNull: '#569cd6',
|
|
43
|
+
jsonPunctuation: '#d4d4d4',
|
|
44
|
+
collapseButton: '#c586c0',
|
|
45
|
+
collapseButtonBg: '#3e3e42',
|
|
46
|
+
collapseButtonBorder: '#555',
|
|
47
|
+
geojsonKey: '#c586c0',
|
|
48
|
+
geojsonType: '#4ec9b0',
|
|
49
|
+
geojsonTypeInvalid: '#f44747',
|
|
50
|
+
jsonKeyInvalid: '#f44747'
|
|
51
|
+
},
|
|
52
|
+
light: {
|
|
53
|
+
background: '#ffffff',
|
|
54
|
+
textColor: '#333333',
|
|
55
|
+
caretColor: '#000',
|
|
56
|
+
gutterBackground: '#f5f5f5',
|
|
57
|
+
gutterBorder: '#ddd',
|
|
58
|
+
jsonKey: '#0000ff',
|
|
59
|
+
jsonString: '#a31515',
|
|
60
|
+
jsonNumber: '#098658',
|
|
61
|
+
jsonBoolean: '#0000ff',
|
|
62
|
+
jsonNull: '#0000ff',
|
|
63
|
+
jsonPunctuation: '#333333',
|
|
64
|
+
collapseButton: '#a31515',
|
|
65
|
+
collapseButtonBg: '#e0e0e0',
|
|
66
|
+
collapseButtonBorder: '#999',
|
|
67
|
+
geojsonKey: '#af00db',
|
|
68
|
+
geojsonType: '#267f99',
|
|
69
|
+
geojsonTypeInvalid: '#d32f2f',
|
|
70
|
+
jsonKeyInvalid: '#d32f2f'
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// FeatureCollection wrapper constants
|
|
75
|
+
static FEATURE_COLLECTION_PREFIX = '{"type": "FeatureCollection", "features": [';
|
|
76
|
+
static FEATURE_COLLECTION_SUFFIX = ']}';
|
|
77
|
+
|
|
78
|
+
// Pre-compiled regex patterns (avoid recompilation on each call)
|
|
79
|
+
static REGEX = {
|
|
80
|
+
// HTML escaping
|
|
81
|
+
ampersand: /&/g,
|
|
82
|
+
lessThan: /</g,
|
|
83
|
+
greaterThan: />/g,
|
|
84
|
+
// JSON structure
|
|
85
|
+
jsonKey: /"([^"]+)"\s*:/g,
|
|
86
|
+
typeValue: /<span class="geojson-key">"type"<\/span>:\s*"([^"]*)"/g,
|
|
87
|
+
stringValue: /:\s*"([^"]*)"/g,
|
|
88
|
+
numberAfterColon: /:\s*(-?\d+\.?\d*)/g,
|
|
89
|
+
boolean: /:\s*(true|false)/g,
|
|
90
|
+
nullValue: /:\s*(null)/g,
|
|
91
|
+
allNumbers: /\b(-?\d+\.?\d*)\b/g,
|
|
92
|
+
punctuation: /([{}[\],])/g,
|
|
93
|
+
// Highlighting detection
|
|
94
|
+
colorInLine: /"(\w+)"\s*:\s*"(#[0-9a-fA-F]{6})"/g,
|
|
95
|
+
collapsibleNode: /^(\s*)"(\w+)"\s*:\s*([{\[])/,
|
|
96
|
+
collapsedMarker: /^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
connectedCallback() {
|
|
100
|
+
this.render();
|
|
101
|
+
this.setupEventListeners();
|
|
102
|
+
|
|
103
|
+
// Update prefix/suffix display
|
|
104
|
+
this.updatePrefixSuffix();
|
|
105
|
+
|
|
106
|
+
// Setup theme CSS
|
|
107
|
+
this.updateThemeCSS();
|
|
108
|
+
|
|
109
|
+
// Initial highlight
|
|
110
|
+
if (this.value) {
|
|
111
|
+
this.updateHighlight();
|
|
112
|
+
// Auto-collapse coordinates nodes after initial rendering
|
|
113
|
+
requestAnimationFrame(() => {
|
|
114
|
+
this.applyAutoCollapsed();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
120
|
+
if (oldValue === newValue) return;
|
|
121
|
+
|
|
122
|
+
if (name === 'value') {
|
|
123
|
+
this.updateValue(newValue);
|
|
124
|
+
} else if (name === 'readonly') {
|
|
125
|
+
this.updateReadonly();
|
|
126
|
+
} else if (name === 'placeholder') {
|
|
127
|
+
const textarea = this.shadowRoot.querySelector('textarea');
|
|
128
|
+
if (textarea) textarea.placeholder = newValue || '';
|
|
129
|
+
} else if (name === 'dark-selector') {
|
|
130
|
+
this.updateThemeCSS();
|
|
131
|
+
} else if (name === 'feature-collection') {
|
|
132
|
+
this.updatePrefixSuffix();
|
|
133
|
+
} else if (name === 'auto-format') {
|
|
134
|
+
// When auto-format is enabled, format the current content
|
|
135
|
+
const textarea = this.shadowRoot?.getElementById('textarea');
|
|
136
|
+
if (textarea && textarea.value && this.autoFormat) {
|
|
137
|
+
this.autoFormatContent();
|
|
138
|
+
this.updateHighlight();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Properties
|
|
144
|
+
get readonly() {
|
|
145
|
+
return this.hasAttribute('readonly');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
get value() {
|
|
150
|
+
return this.getAttribute('value') || '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get placeholder() {
|
|
154
|
+
return this.getAttribute('placeholder') || '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get autoFormat() {
|
|
158
|
+
return this.hasAttribute('auto-format');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get featureCollection() {
|
|
162
|
+
return this.hasAttribute('feature-collection');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Internal getters for prefix/suffix based on feature-collection mode
|
|
166
|
+
get prefix() {
|
|
167
|
+
return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_PREFIX : '';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get suffix() {
|
|
171
|
+
return this.featureCollection ? GeoJsonEditor.FEATURE_COLLECTION_SUFFIX : '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
render() {
|
|
175
|
+
const styles = `
|
|
176
|
+
<style>
|
|
177
|
+
/* Global reset with exact values to prevent external CSS interference */
|
|
178
|
+
:host *,
|
|
179
|
+
:host *::before,
|
|
180
|
+
:host *::after {
|
|
181
|
+
box-sizing: border-box;
|
|
182
|
+
font-family: 'Courier New', Courier, monospace;
|
|
183
|
+
font-size: 13px;
|
|
184
|
+
font-weight: normal;
|
|
185
|
+
font-style: normal;
|
|
186
|
+
font-variant: normal;
|
|
187
|
+
line-height: 1.5;
|
|
188
|
+
letter-spacing: 0;
|
|
189
|
+
text-transform: none;
|
|
190
|
+
text-decoration: none;
|
|
191
|
+
text-indent: 0;
|
|
192
|
+
word-spacing: 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
:host {
|
|
196
|
+
display: flex;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
position: relative;
|
|
199
|
+
width: 100%;
|
|
200
|
+
height: 400px;
|
|
201
|
+
font-family: 'Courier New', Courier, monospace;
|
|
202
|
+
font-size: 13px;
|
|
203
|
+
line-height: 1.5;
|
|
204
|
+
border-radius: 4px;
|
|
205
|
+
overflow: hidden;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
:host([readonly]) .editor-wrapper::after {
|
|
209
|
+
content: '';
|
|
210
|
+
position: absolute;
|
|
211
|
+
top: 0;
|
|
212
|
+
left: 0;
|
|
213
|
+
right: 0;
|
|
214
|
+
bottom: 0;
|
|
215
|
+
pointer-events: none;
|
|
216
|
+
background: repeating-linear-gradient(
|
|
217
|
+
-45deg,
|
|
218
|
+
rgba(128, 128, 128, 0.08),
|
|
219
|
+
rgba(128, 128, 128, 0.08) 3px,
|
|
220
|
+
transparent 3px,
|
|
221
|
+
transparent 12px
|
|
222
|
+
);
|
|
223
|
+
z-index: 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
:host([readonly]) textarea {
|
|
227
|
+
cursor: text;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.editor-wrapper {
|
|
231
|
+
position: relative;
|
|
232
|
+
width: 100%;
|
|
233
|
+
flex: 1;
|
|
234
|
+
background: var(--bg-color);
|
|
235
|
+
display: flex;
|
|
236
|
+
font-family: 'Courier New', Courier, monospace;
|
|
237
|
+
font-size: 13px;
|
|
238
|
+
line-height: 1.5;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.gutter {
|
|
242
|
+
width: 24px;
|
|
243
|
+
height: 100%;
|
|
244
|
+
background: var(--gutter-bg);
|
|
245
|
+
border-right: 1px solid var(--gutter-border);
|
|
246
|
+
overflow: hidden;
|
|
247
|
+
flex-shrink: 0;
|
|
248
|
+
position: relative;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.gutter-content {
|
|
252
|
+
position: absolute;
|
|
253
|
+
top: 0;
|
|
254
|
+
left: 0;
|
|
255
|
+
width: 100%;
|
|
256
|
+
padding: 8px 4px;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.gutter-line {
|
|
260
|
+
position: absolute;
|
|
261
|
+
left: 0;
|
|
262
|
+
width: 100%;
|
|
263
|
+
height: 1.5em;
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
justify-content: center;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.color-indicator {
|
|
270
|
+
width: 12px;
|
|
271
|
+
height: 12px;
|
|
272
|
+
border-radius: 2px;
|
|
273
|
+
border: 1px solid #555;
|
|
274
|
+
cursor: pointer;
|
|
275
|
+
transition: transform 0.1s;
|
|
276
|
+
flex-shrink: 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.color-indicator:hover {
|
|
280
|
+
transform: scale(1.2);
|
|
281
|
+
border-color: #fff;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.collapse-button {
|
|
285
|
+
width: 12px;
|
|
286
|
+
height: 12px;
|
|
287
|
+
background: var(--collapse-btn-bg);
|
|
288
|
+
border: 1px solid var(--collapse-btn-border);
|
|
289
|
+
border-radius: 2px;
|
|
290
|
+
color: var(--collapse-btn);
|
|
291
|
+
font-size: 8px;
|
|
292
|
+
font-weight: bold;
|
|
293
|
+
cursor: pointer;
|
|
294
|
+
display: flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
justify-content: center;
|
|
297
|
+
transition: all 0.1s;
|
|
298
|
+
flex-shrink: 0;
|
|
299
|
+
user-select: none;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.collapse-button:hover {
|
|
303
|
+
background: var(--collapse-btn-bg);
|
|
304
|
+
border-color: var(--collapse-btn);
|
|
305
|
+
transform: scale(1.1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.color-picker-popup {
|
|
309
|
+
position: absolute;
|
|
310
|
+
background: #2d2d30;
|
|
311
|
+
border: 1px solid #555;
|
|
312
|
+
border-radius: 4px;
|
|
313
|
+
padding: 8px;
|
|
314
|
+
z-index: 1000;
|
|
315
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.color-picker-popup input[type="color"] {
|
|
319
|
+
width: 150px;
|
|
320
|
+
height: 30px;
|
|
321
|
+
border: none;
|
|
322
|
+
cursor: pointer;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.editor-content {
|
|
326
|
+
position: relative;
|
|
327
|
+
flex: 1;
|
|
328
|
+
overflow: hidden;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.highlight-layer {
|
|
332
|
+
position: absolute;
|
|
333
|
+
top: 0;
|
|
334
|
+
left: 0;
|
|
335
|
+
width: 100%;
|
|
336
|
+
height: 100%;
|
|
337
|
+
padding: 8px 12px;
|
|
338
|
+
font-family: 'Courier New', Courier, monospace;
|
|
339
|
+
font-size: 13px;
|
|
340
|
+
font-weight: normal;
|
|
341
|
+
font-style: normal;
|
|
342
|
+
line-height: 1.5;
|
|
343
|
+
white-space: pre-wrap;
|
|
344
|
+
word-wrap: break-word;
|
|
345
|
+
overflow: auto;
|
|
346
|
+
pointer-events: none;
|
|
347
|
+
z-index: 1;
|
|
348
|
+
color: var(--text-color);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.highlight-layer::-webkit-scrollbar {
|
|
352
|
+
display: none;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
textarea {
|
|
356
|
+
position: absolute;
|
|
357
|
+
top: 0;
|
|
358
|
+
left: 0;
|
|
359
|
+
width: 100%;
|
|
360
|
+
height: 100%;
|
|
361
|
+
padding: 8px 12px;
|
|
362
|
+
margin: 0;
|
|
363
|
+
border: none;
|
|
364
|
+
outline: none;
|
|
365
|
+
background: transparent;
|
|
366
|
+
color: transparent;
|
|
367
|
+
caret-color: var(--caret-color);
|
|
368
|
+
font-family: 'Courier New', Courier, monospace;
|
|
369
|
+
font-size: 13px;
|
|
370
|
+
font-weight: normal;
|
|
371
|
+
font-style: normal;
|
|
372
|
+
line-height: 1.5;
|
|
373
|
+
white-space: pre-wrap;
|
|
374
|
+
word-wrap: break-word;
|
|
375
|
+
resize: none;
|
|
376
|
+
overflow: auto;
|
|
377
|
+
z-index: 2;
|
|
378
|
+
box-sizing: border-box;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
textarea::selection {
|
|
382
|
+
background: rgba(51, 153, 255, 0.3);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
textarea::placeholder {
|
|
386
|
+
color: #6a6a6a;
|
|
387
|
+
font-family: 'Courier New', Courier, monospace;
|
|
388
|
+
font-size: 13px;
|
|
389
|
+
font-weight: normal;
|
|
390
|
+
font-style: normal;
|
|
391
|
+
opacity: 1;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
textarea:disabled {
|
|
395
|
+
cursor: not-allowed;
|
|
396
|
+
opacity: 0.6;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* Syntax highlighting colors */
|
|
400
|
+
.json-key {
|
|
401
|
+
color: var(--json-key);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.json-string {
|
|
405
|
+
color: var(--json-string);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.json-number {
|
|
409
|
+
color: var(--json-number);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.json-boolean {
|
|
413
|
+
color: var(--json-boolean);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.json-null {
|
|
417
|
+
color: var(--json-null);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.json-punctuation {
|
|
421
|
+
color: var(--json-punct);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* GeoJSON-specific highlighting */
|
|
425
|
+
.geojson-key {
|
|
426
|
+
color: var(--geojson-key);
|
|
427
|
+
font-weight: 600;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.geojson-type {
|
|
431
|
+
color: var(--geojson-type);
|
|
432
|
+
font-weight: 600;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.geojson-type-invalid {
|
|
436
|
+
color: var(--geojson-type-invalid);
|
|
437
|
+
font-weight: 600;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.json-key-invalid {
|
|
441
|
+
color: var(--json-key-invalid);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* Prefix and suffix styling */
|
|
445
|
+
.editor-prefix,
|
|
446
|
+
.editor-suffix {
|
|
447
|
+
padding: 4px 12px;
|
|
448
|
+
color: var(--text-color);
|
|
449
|
+
background: var(--bg-color);
|
|
450
|
+
user-select: none;
|
|
451
|
+
white-space: pre-wrap;
|
|
452
|
+
word-wrap: break-word;
|
|
453
|
+
flex-shrink: 0;
|
|
454
|
+
font-family: 'Courier New', Courier, monospace;
|
|
455
|
+
font-size: 13px;
|
|
456
|
+
line-height: 1.5;
|
|
457
|
+
opacity: 0.6;
|
|
458
|
+
border-left: 3px solid rgba(102, 126, 234, 0.5);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.editor-prefix {
|
|
462
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.editor-suffix {
|
|
466
|
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* Scrollbar styling */
|
|
470
|
+
textarea::-webkit-scrollbar {
|
|
471
|
+
width: 10px;
|
|
472
|
+
height: 10px;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
textarea::-webkit-scrollbar-track {
|
|
476
|
+
background: #1e1e1e;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
textarea::-webkit-scrollbar-thumb {
|
|
480
|
+
background: #424242;
|
|
481
|
+
border-radius: 5px;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
textarea::-webkit-scrollbar-thumb:hover {
|
|
485
|
+
background: #4e4e4e;
|
|
486
|
+
}
|
|
487
|
+
</style>
|
|
488
|
+
`;
|
|
489
|
+
|
|
490
|
+
const template = `
|
|
491
|
+
<div class="editor-prefix" id="editorPrefix"></div>
|
|
492
|
+
<div class="editor-wrapper">
|
|
493
|
+
<div class="gutter">
|
|
494
|
+
<div class="gutter-content" id="gutterContent"></div>
|
|
495
|
+
</div>
|
|
496
|
+
<div class="editor-content">
|
|
497
|
+
<div class="highlight-layer" id="highlightLayer"></div>
|
|
498
|
+
<textarea
|
|
499
|
+
id="textarea"
|
|
500
|
+
spellcheck="false"
|
|
501
|
+
autocomplete="off"
|
|
502
|
+
autocorrect="off"
|
|
503
|
+
autocapitalize="off"
|
|
504
|
+
placeholder="${this.placeholder}"
|
|
505
|
+
></textarea>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
<div class="editor-suffix" id="editorSuffix"></div>
|
|
509
|
+
`;
|
|
510
|
+
|
|
511
|
+
this.shadowRoot.innerHTML = styles + template;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
setupEventListeners() {
|
|
515
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
516
|
+
const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
|
|
517
|
+
|
|
518
|
+
// Sync scroll between textarea and highlight layer
|
|
519
|
+
textarea.addEventListener('scroll', () => {
|
|
520
|
+
highlightLayer.scrollTop = textarea.scrollTop;
|
|
521
|
+
highlightLayer.scrollLeft = textarea.scrollLeft;
|
|
522
|
+
this.syncGutterScroll(textarea.scrollTop);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Input handling with debounced highlight and auto-format
|
|
526
|
+
textarea.addEventListener('input', () => {
|
|
527
|
+
clearTimeout(this.highlightTimer);
|
|
528
|
+
this.highlightTimer = setTimeout(() => {
|
|
529
|
+
// Auto-format if enabled and JSON is valid
|
|
530
|
+
if (this.autoFormat) {
|
|
531
|
+
this.autoFormatContentWithCursor();
|
|
532
|
+
}
|
|
533
|
+
this.updateHighlight();
|
|
534
|
+
this.emitChange();
|
|
535
|
+
}, 150);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Paste handling - trigger immediately without debounce
|
|
539
|
+
textarea.addEventListener('paste', () => {
|
|
540
|
+
// Clear any pending highlight timer to avoid duplicate processing
|
|
541
|
+
clearTimeout(this.highlightTimer);
|
|
542
|
+
|
|
543
|
+
// Use a short delay to let the paste complete
|
|
544
|
+
setTimeout(() => {
|
|
545
|
+
// Auto-format if enabled and JSON is valid
|
|
546
|
+
if (this.autoFormat) {
|
|
547
|
+
this.autoFormatContentWithCursor();
|
|
548
|
+
}
|
|
549
|
+
this.updateHighlight();
|
|
550
|
+
this.emitChange();
|
|
551
|
+
}, 10);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Gutter clicks (color indicators and collapse buttons)
|
|
555
|
+
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
556
|
+
gutterContent.addEventListener('click', (e) => {
|
|
557
|
+
if (e.target.classList.contains('color-indicator')) {
|
|
558
|
+
const line = parseInt(e.target.dataset.line);
|
|
559
|
+
const color = e.target.dataset.color;
|
|
560
|
+
const attributeName = e.target.dataset.attributeName;
|
|
561
|
+
this.showColorPicker(e.target, line, color, attributeName);
|
|
562
|
+
} else if (e.target.classList.contains('collapse-button')) {
|
|
563
|
+
const nodeKey = e.target.dataset.nodeKey;
|
|
564
|
+
const line = parseInt(e.target.dataset.line);
|
|
565
|
+
this.toggleCollapse(nodeKey, line);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Transfer wheel scroll from gutter to textarea
|
|
570
|
+
const gutter = this.shadowRoot.querySelector('.gutter');
|
|
571
|
+
gutter.addEventListener('wheel', (e) => {
|
|
572
|
+
e.preventDefault();
|
|
573
|
+
textarea.scrollTop += e.deltaY;
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Block editing in collapsed areas
|
|
577
|
+
textarea.addEventListener('keydown', (e) => {
|
|
578
|
+
this.handleKeydownInCollapsedArea(e);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Handle copy to include collapsed content
|
|
582
|
+
textarea.addEventListener('copy', (e) => {
|
|
583
|
+
this.handleCopyWithCollapsedContent(e);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Handle cut to include collapsed content
|
|
587
|
+
textarea.addEventListener('cut', (e) => {
|
|
588
|
+
this.handleCutWithCollapsedContent(e);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Update readonly state
|
|
592
|
+
this.updateReadonly();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
syncGutterScroll(scrollTop) {
|
|
596
|
+
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
597
|
+
gutterContent.style.transform = `translateY(-${scrollTop}px)`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
updateReadonly() {
|
|
601
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
602
|
+
if (textarea) {
|
|
603
|
+
textarea.disabled = this.readonly;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
updateValue(newValue) {
|
|
608
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
609
|
+
if (textarea && textarea.value !== newValue) {
|
|
610
|
+
textarea.value = newValue || '';
|
|
611
|
+
|
|
612
|
+
// Apply auto-format if enabled
|
|
613
|
+
if (this.autoFormat && newValue) {
|
|
614
|
+
try {
|
|
615
|
+
const prefix = this.prefix;
|
|
616
|
+
const suffix = this.suffix;
|
|
617
|
+
|
|
618
|
+
// Check if prefix ends with [ and suffix starts with ]
|
|
619
|
+
const prefixEndsWithBracket = prefix.trimEnd().endsWith('[');
|
|
620
|
+
const suffixStartsWithBracket = suffix.trimStart().startsWith(']');
|
|
621
|
+
|
|
622
|
+
if (prefixEndsWithBracket && suffixStartsWithBracket) {
|
|
623
|
+
// Wrap content in array brackets for validation and formatting
|
|
624
|
+
const wrapped = '[' + newValue + ']';
|
|
625
|
+
const parsed = JSON.parse(wrapped);
|
|
626
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
627
|
+
|
|
628
|
+
// Remove first [ and last ] from formatted
|
|
629
|
+
const lines = formatted.split('\n');
|
|
630
|
+
if (lines.length > 2) {
|
|
631
|
+
textarea.value = lines.slice(1, -1).join('\n');
|
|
632
|
+
} else {
|
|
633
|
+
textarea.value = '';
|
|
634
|
+
}
|
|
635
|
+
} else if (!prefix && !suffix) {
|
|
636
|
+
// No prefix/suffix - format directly
|
|
637
|
+
const parsed = JSON.parse(newValue);
|
|
638
|
+
textarea.value = JSON.stringify(parsed, null, 2);
|
|
639
|
+
}
|
|
640
|
+
// else: keep as-is for complex cases
|
|
641
|
+
} catch (e) {
|
|
642
|
+
// Invalid JSON, keep as-is
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
this.updateHighlight();
|
|
647
|
+
|
|
648
|
+
// Auto-collapse coordinates nodes after value is set
|
|
649
|
+
if (textarea.value) {
|
|
650
|
+
requestAnimationFrame(() => {
|
|
651
|
+
this.applyAutoCollapsed();
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
updatePrefixSuffix() {
|
|
658
|
+
const prefixEl = this.shadowRoot.getElementById('editorPrefix');
|
|
659
|
+
const suffixEl = this.shadowRoot.getElementById('editorSuffix');
|
|
660
|
+
|
|
661
|
+
if (prefixEl) {
|
|
662
|
+
if (this.prefix) {
|
|
663
|
+
prefixEl.textContent = this.prefix;
|
|
664
|
+
prefixEl.style.display = 'block';
|
|
665
|
+
} else {
|
|
666
|
+
prefixEl.textContent = '';
|
|
667
|
+
prefixEl.style.display = 'none';
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (suffixEl) {
|
|
672
|
+
if (this.suffix) {
|
|
673
|
+
suffixEl.textContent = this.suffix;
|
|
674
|
+
suffixEl.style.display = 'block';
|
|
675
|
+
} else {
|
|
676
|
+
suffixEl.textContent = '';
|
|
677
|
+
suffixEl.style.display = 'none';
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
updateHighlight() {
|
|
683
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
684
|
+
const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
|
|
685
|
+
|
|
686
|
+
if (!textarea || !highlightLayer) return;
|
|
687
|
+
|
|
688
|
+
const text = textarea.value;
|
|
689
|
+
|
|
690
|
+
// Parse and highlight
|
|
691
|
+
const { highlighted, colors, toggles } = this.highlightJSON(text);
|
|
692
|
+
|
|
693
|
+
highlightLayer.innerHTML = highlighted;
|
|
694
|
+
this.colorPositions = colors;
|
|
695
|
+
this.nodeTogglePositions = toggles;
|
|
696
|
+
|
|
697
|
+
// Update gutter with color indicators
|
|
698
|
+
this.updateGutter();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
highlightJSON(text) {
|
|
702
|
+
if (!text.trim()) {
|
|
703
|
+
return { highlighted: '', colors: [], toggles: [] };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const lines = text.split('\n');
|
|
707
|
+
const colors = [];
|
|
708
|
+
const toggles = [];
|
|
709
|
+
let highlightedLines = [];
|
|
710
|
+
|
|
711
|
+
// Build context map for validation
|
|
712
|
+
const contextMap = this.buildContextMap(text);
|
|
713
|
+
|
|
714
|
+
lines.forEach((line, lineIndex) => {
|
|
715
|
+
// Detect any hex color (6 digits) in string values
|
|
716
|
+
const R = GeoJsonEditor.REGEX;
|
|
717
|
+
R.colorInLine.lastIndex = 0; // Reset for global regex
|
|
718
|
+
let colorMatch;
|
|
719
|
+
while ((colorMatch = R.colorInLine.exec(line)) !== null) {
|
|
720
|
+
colors.push({
|
|
721
|
+
line: lineIndex,
|
|
722
|
+
color: colorMatch[2], // The hex color
|
|
723
|
+
attributeName: colorMatch[1] // The attribute name
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Detect collapsible nodes (all nodes are collapsible)
|
|
728
|
+
const nodeMatch = line.match(R.collapsibleNode);
|
|
729
|
+
if (nodeMatch) {
|
|
730
|
+
const nodeKey = nodeMatch[2];
|
|
731
|
+
|
|
732
|
+
// Check if this is a collapsed marker first
|
|
733
|
+
const isCollapsed = line.includes('{...}') || line.includes('[...]');
|
|
734
|
+
|
|
735
|
+
if (isCollapsed) {
|
|
736
|
+
// It's collapsed, always show button
|
|
737
|
+
toggles.push({
|
|
738
|
+
line: lineIndex,
|
|
739
|
+
nodeKey,
|
|
740
|
+
isCollapsed: true
|
|
741
|
+
});
|
|
742
|
+
} else {
|
|
743
|
+
// Not collapsed - only add toggle button if it doesn't close on same line
|
|
744
|
+
if (!this.bracketClosesOnSameLine(line, nodeMatch[3])) {
|
|
745
|
+
toggles.push({
|
|
746
|
+
line: lineIndex,
|
|
747
|
+
nodeKey,
|
|
748
|
+
isCollapsed: false
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Highlight the line with context
|
|
755
|
+
const context = contextMap.get(lineIndex);
|
|
756
|
+
highlightedLines.push(this.highlightSyntax(line, context));
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
highlighted: highlightedLines.join('\n'),
|
|
761
|
+
colors,
|
|
762
|
+
toggles
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// GeoJSON type constants
|
|
767
|
+
static GEOJSON_TYPES_FEATURE = ['Feature', 'FeatureCollection'];
|
|
768
|
+
static GEOJSON_TYPES_GEOMETRY = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'];
|
|
769
|
+
static GEOJSON_TYPES_ALL = [...GeoJsonEditor.GEOJSON_TYPES_FEATURE, ...GeoJsonEditor.GEOJSON_TYPES_GEOMETRY];
|
|
770
|
+
|
|
771
|
+
// Valid keys per context (null = any key is valid)
|
|
772
|
+
static VALID_KEYS_BY_CONTEXT = {
|
|
773
|
+
Feature: ['type', 'geometry', 'properties', 'id', 'bbox'],
|
|
774
|
+
FeatureCollection: ['type', 'features', 'bbox', 'properties'],
|
|
775
|
+
Point: ['type', 'coordinates', 'bbox'],
|
|
776
|
+
MultiPoint: ['type', 'coordinates', 'bbox'],
|
|
777
|
+
LineString: ['type', 'coordinates', 'bbox'],
|
|
778
|
+
MultiLineString: ['type', 'coordinates', 'bbox'],
|
|
779
|
+
Polygon: ['type', 'coordinates', 'bbox'],
|
|
780
|
+
MultiPolygon: ['type', 'coordinates', 'bbox'],
|
|
781
|
+
GeometryCollection: ['type', 'geometries', 'bbox'],
|
|
782
|
+
properties: null, // Any key valid in properties
|
|
783
|
+
geometry: ['type', 'coordinates', 'geometries', 'bbox'], // Generic geometry context
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// Keys that change context for their value
|
|
787
|
+
static CONTEXT_CHANGING_KEYS = {
|
|
788
|
+
geometry: 'geometry',
|
|
789
|
+
properties: 'properties',
|
|
790
|
+
features: 'Feature', // Array of Features
|
|
791
|
+
geometries: 'geometry', // Array of geometries
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// Build context map for each line by analyzing JSON structure
|
|
795
|
+
buildContextMap(text) {
|
|
796
|
+
const lines = text.split('\n');
|
|
797
|
+
const contextMap = new Map(); // line index -> context
|
|
798
|
+
const contextStack = []; // Stack of {context, isArray}
|
|
799
|
+
let pendingContext = null; // Context for next object/array
|
|
800
|
+
|
|
801
|
+
// Determine root context based on feature-collection mode
|
|
802
|
+
const rootContext = this.featureCollection ? 'Feature' : null;
|
|
803
|
+
|
|
804
|
+
for (let i = 0; i < lines.length; i++) {
|
|
805
|
+
const line = lines[i];
|
|
806
|
+
|
|
807
|
+
// Record context at START of line (for key validation)
|
|
808
|
+
const lineContext = contextStack.length > 0
|
|
809
|
+
? contextStack[contextStack.length - 1]?.context
|
|
810
|
+
: rootContext;
|
|
811
|
+
contextMap.set(i, lineContext);
|
|
812
|
+
|
|
813
|
+
// Process each character to track brackets for subsequent lines
|
|
814
|
+
for (let j = 0; j < line.length; j++) {
|
|
815
|
+
const char = line[j];
|
|
816
|
+
|
|
817
|
+
// Check for key that changes context: "keyName":
|
|
818
|
+
if (char === '"') {
|
|
819
|
+
const keyMatch = line.substring(j).match(/^"([^"]+)"\s*:/);
|
|
820
|
+
if (keyMatch) {
|
|
821
|
+
const keyName = keyMatch[1];
|
|
822
|
+
if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
|
|
823
|
+
pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
|
|
824
|
+
}
|
|
825
|
+
j += keyMatch[0].length - 1; // Skip past the key
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Check for type value to refine context: "type": "Point"
|
|
831
|
+
if (char === '"' && contextStack.length > 0) {
|
|
832
|
+
const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
|
|
833
|
+
if (typeMatch) {
|
|
834
|
+
const valueMatch = line.substring(j).match(/^"([^"]+)"/);
|
|
835
|
+
if (valueMatch && GeoJsonEditor.GEOJSON_TYPES_ALL.includes(valueMatch[1])) {
|
|
836
|
+
// Update current context to the specific type
|
|
837
|
+
const currentCtx = contextStack[contextStack.length - 1];
|
|
838
|
+
if (currentCtx) {
|
|
839
|
+
currentCtx.context = valueMatch[1];
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Opening bracket - push context
|
|
846
|
+
if (char === '{' || char === '[') {
|
|
847
|
+
let newContext;
|
|
848
|
+
if (pendingContext) {
|
|
849
|
+
newContext = pendingContext;
|
|
850
|
+
pendingContext = null;
|
|
851
|
+
} else if (contextStack.length === 0) {
|
|
852
|
+
// Root level
|
|
853
|
+
newContext = rootContext;
|
|
854
|
+
} else {
|
|
855
|
+
// Inherit from parent if in array
|
|
856
|
+
const parent = contextStack[contextStack.length - 1];
|
|
857
|
+
if (parent && parent.isArray) {
|
|
858
|
+
newContext = parent.context;
|
|
859
|
+
} else {
|
|
860
|
+
newContext = null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
contextStack.push({ context: newContext, isArray: char === '[' });
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Closing bracket - pop context
|
|
867
|
+
if (char === '}' || char === ']') {
|
|
868
|
+
if (contextStack.length > 0) {
|
|
869
|
+
contextStack.pop();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return contextMap;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// All known GeoJSON structural keys (always valid in GeoJSON)
|
|
879
|
+
static GEOJSON_STRUCTURAL_KEYS = ['type', 'geometry', 'properties', 'features', 'geometries', 'coordinates', 'bbox', 'id', 'crs'];
|
|
880
|
+
|
|
881
|
+
highlightSyntax(text, context) {
|
|
882
|
+
if (!text.trim()) return '';
|
|
883
|
+
|
|
884
|
+
// Get valid keys for current context
|
|
885
|
+
const validKeys = context ? GeoJsonEditor.VALID_KEYS_BY_CONTEXT[context] : null;
|
|
886
|
+
|
|
887
|
+
// Helper to check if a key is valid in current context
|
|
888
|
+
const isKeyValid = (key) => {
|
|
889
|
+
// GeoJSON structural keys are always valid
|
|
890
|
+
if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) return true;
|
|
891
|
+
// No context or null validKeys means all keys are valid
|
|
892
|
+
if (!context || validKeys === null || validKeys === undefined) return true;
|
|
893
|
+
return validKeys.includes(key);
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
// Helper to check if a type value is valid in current context
|
|
897
|
+
const isTypeValid = (typeValue) => {
|
|
898
|
+
// Unknown context - don't validate (could be inside misspelled properties, etc.)
|
|
899
|
+
if (!context) return true;
|
|
900
|
+
if (context === 'properties') return true; // Any type in properties
|
|
901
|
+
if (context === 'geometry' || GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(context)) {
|
|
902
|
+
return GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(typeValue);
|
|
903
|
+
}
|
|
904
|
+
// Only validate as GeoJSON type in known Feature/FeatureCollection context
|
|
905
|
+
if (context === 'Feature' || context === 'FeatureCollection') {
|
|
906
|
+
return GeoJsonEditor.GEOJSON_TYPES_ALL.includes(typeValue);
|
|
907
|
+
}
|
|
908
|
+
return true; // Unknown context - accept any type
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
const R = GeoJsonEditor.REGEX;
|
|
912
|
+
|
|
913
|
+
return text
|
|
914
|
+
// Escape HTML first
|
|
915
|
+
.replace(R.ampersand, '&')
|
|
916
|
+
.replace(R.lessThan, '<')
|
|
917
|
+
.replace(R.greaterThan, '>')
|
|
918
|
+
// All JSON keys - validate against context
|
|
919
|
+
.replace(R.jsonKey, (match, key) => {
|
|
920
|
+
// Inside properties - all keys are regular user keys
|
|
921
|
+
if (context === 'properties') {
|
|
922
|
+
return `<span class="json-key">"${key}"</span>:`;
|
|
923
|
+
}
|
|
924
|
+
// GeoJSON structural keys - highlighted as geojson-key
|
|
925
|
+
if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) {
|
|
926
|
+
return `<span class="geojson-key">"${key}"</span>:`;
|
|
927
|
+
}
|
|
928
|
+
// Regular key - validate against context
|
|
929
|
+
if (isKeyValid(key)) {
|
|
930
|
+
return `<span class="json-key">"${key}"</span>:`;
|
|
931
|
+
} else {
|
|
932
|
+
return `<span class="json-key-invalid">"${key}"</span>:`;
|
|
933
|
+
}
|
|
934
|
+
})
|
|
935
|
+
// GeoJSON "type" values - validate based on context
|
|
936
|
+
.replace(R.typeValue, (match, typeValue) => {
|
|
937
|
+
if (isTypeValid(typeValue)) {
|
|
938
|
+
return `<span class="geojson-key">"type"</span>: <span class="geojson-type">"${typeValue}"</span>`;
|
|
939
|
+
} else {
|
|
940
|
+
return `<span class="geojson-key">"type"</span>: <span class="geojson-type-invalid">"${typeValue}"</span>`;
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
// Generic string values
|
|
944
|
+
.replace(R.stringValue, (match, value) => {
|
|
945
|
+
// Skip if already highlighted (has span)
|
|
946
|
+
if (match.includes('<span')) return match;
|
|
947
|
+
return `: <span class="json-string">"${value}"</span>`;
|
|
948
|
+
})
|
|
949
|
+
.replace(R.numberAfterColon, ': <span class="json-number">$1</span>')
|
|
950
|
+
.replace(R.boolean, ': <span class="json-boolean">$1</span>')
|
|
951
|
+
.replace(R.nullValue, ': <span class="json-null">$1</span>')
|
|
952
|
+
.replace(R.allNumbers, '<span class="json-number">$1</span>')
|
|
953
|
+
.replace(R.punctuation, '<span class="json-punctuation">$1</span>');
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
toggleCollapse(nodeKey, line) {
|
|
957
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
958
|
+
const lines = textarea.value.split('\n');
|
|
959
|
+
const currentLine = lines[line];
|
|
960
|
+
|
|
961
|
+
// Check if line has collapse marker
|
|
962
|
+
const hasMarker = currentLine.includes('{...}') || currentLine.includes('[...]');
|
|
963
|
+
|
|
964
|
+
if (hasMarker) {
|
|
965
|
+
// Expand: find the correct collapsed data by searching for this nodeKey
|
|
966
|
+
let foundKey = null;
|
|
967
|
+
let foundData = null;
|
|
968
|
+
|
|
969
|
+
// Try exact match first
|
|
970
|
+
const exactKey = `${line}-${nodeKey}`;
|
|
971
|
+
if (this.collapsedData.has(exactKey)) {
|
|
972
|
+
foundKey = exactKey;
|
|
973
|
+
foundData = this.collapsedData.get(exactKey);
|
|
974
|
+
} else {
|
|
975
|
+
// Search for any key with this nodeKey (line numbers may have shifted)
|
|
976
|
+
for (const [key, data] of this.collapsedData.entries()) {
|
|
977
|
+
if (data.nodeKey === nodeKey) {
|
|
978
|
+
// Check indent to distinguish between multiple nodes with same name
|
|
979
|
+
const currentIndent = currentLine.match(/^(\s*)/)[1].length;
|
|
980
|
+
if (data.indent === currentIndent) {
|
|
981
|
+
foundKey = key;
|
|
982
|
+
foundData = data;
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (!foundKey || !foundData) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const {originalLine, content} = foundData;
|
|
994
|
+
|
|
995
|
+
// Restore original line and content
|
|
996
|
+
lines[line] = originalLine;
|
|
997
|
+
lines.splice(line + 1, 0, ...content);
|
|
998
|
+
|
|
999
|
+
// Remove from storage
|
|
1000
|
+
this.collapsedData.delete(foundKey);
|
|
1001
|
+
} else {
|
|
1002
|
+
// Collapse: read and store content
|
|
1003
|
+
const match = currentLine.match(/^(\s*)"([^"]+)"\s*:\s*([{\[])/);
|
|
1004
|
+
if (!match) return;
|
|
1005
|
+
|
|
1006
|
+
const indent = match[1];
|
|
1007
|
+
const openBracket = match[3];
|
|
1008
|
+
const closeBracket = openBracket === '{' ? '}' : ']';
|
|
1009
|
+
|
|
1010
|
+
// Check if bracket closes on same line - can't collapse
|
|
1011
|
+
if (this.bracketClosesOnSameLine(currentLine, openBracket)) return;
|
|
1012
|
+
|
|
1013
|
+
// Find closing bracket in following lines
|
|
1014
|
+
let depth = 1;
|
|
1015
|
+
let endLine = line;
|
|
1016
|
+
const content = [];
|
|
1017
|
+
|
|
1018
|
+
for (let i = line + 1; i < lines.length; i++) {
|
|
1019
|
+
const scanLine = lines[i];
|
|
1020
|
+
|
|
1021
|
+
for (const char of scanLine) {
|
|
1022
|
+
if (char === openBracket) depth++;
|
|
1023
|
+
if (char === closeBracket) depth--;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
content.push(scanLine);
|
|
1027
|
+
|
|
1028
|
+
if (depth === 0) {
|
|
1029
|
+
endLine = i;
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Store the original data with unique key
|
|
1035
|
+
const uniqueKey = `${line}-${nodeKey}`;
|
|
1036
|
+
this.collapsedData.set(uniqueKey, {
|
|
1037
|
+
originalLine: currentLine,
|
|
1038
|
+
content: content,
|
|
1039
|
+
indent: indent.length,
|
|
1040
|
+
nodeKey: nodeKey // Store nodeKey for later use
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// Replace with marker
|
|
1044
|
+
const beforeBracket = currentLine.substring(0, currentLine.indexOf(openBracket));
|
|
1045
|
+
const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
|
|
1046
|
+
lines[line] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
|
|
1047
|
+
|
|
1048
|
+
// Remove content lines
|
|
1049
|
+
lines.splice(line + 1, endLine - line);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Update textarea
|
|
1053
|
+
textarea.value = lines.join('\n');
|
|
1054
|
+
this.updateHighlight();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
applyAutoCollapsed() {
|
|
1058
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1059
|
+
if (!textarea || !textarea.value) return;
|
|
1060
|
+
|
|
1061
|
+
const lines = textarea.value.split('\n');
|
|
1062
|
+
|
|
1063
|
+
// Iterate backwards to avoid index issues when collapsing
|
|
1064
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1065
|
+
const line = lines[i];
|
|
1066
|
+
const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
|
|
1067
|
+
|
|
1068
|
+
if (match) {
|
|
1069
|
+
const nodeKey = match[2];
|
|
1070
|
+
|
|
1071
|
+
// Check if this node should be auto-collapsed (coordinates only)
|
|
1072
|
+
if (nodeKey === 'coordinates') {
|
|
1073
|
+
const indent = match[1];
|
|
1074
|
+
const openBracket = match[3];
|
|
1075
|
+
const closeBracket = openBracket === '{' ? '}' : ']';
|
|
1076
|
+
|
|
1077
|
+
// Skip if bracket closes on same line
|
|
1078
|
+
if (this.bracketClosesOnSameLine(line, openBracket)) continue;
|
|
1079
|
+
|
|
1080
|
+
// Find closing bracket in following lines
|
|
1081
|
+
let depth = 1;
|
|
1082
|
+
let endLine = i;
|
|
1083
|
+
const content = [];
|
|
1084
|
+
|
|
1085
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1086
|
+
const scanLine = lines[j];
|
|
1087
|
+
|
|
1088
|
+
for (const char of scanLine) {
|
|
1089
|
+
if (char === openBracket) depth++;
|
|
1090
|
+
if (char === closeBracket) depth--;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
content.push(scanLine);
|
|
1094
|
+
|
|
1095
|
+
if (depth === 0) {
|
|
1096
|
+
endLine = j;
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Store the original data with unique key
|
|
1102
|
+
const uniqueKey = `${i}-${nodeKey}`;
|
|
1103
|
+
this.collapsedData.set(uniqueKey, {
|
|
1104
|
+
originalLine: line,
|
|
1105
|
+
content: content,
|
|
1106
|
+
indent: indent.length,
|
|
1107
|
+
nodeKey: nodeKey
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
// Replace with marker
|
|
1111
|
+
const beforeBracket = line.substring(0, line.indexOf(openBracket));
|
|
1112
|
+
const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
|
|
1113
|
+
lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
|
|
1114
|
+
|
|
1115
|
+
// Remove content lines
|
|
1116
|
+
lines.splice(i + 1, endLine - i);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Update textarea
|
|
1122
|
+
textarea.value = lines.join('\n');
|
|
1123
|
+
this.updateHighlight();
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
updateGutter() {
|
|
1128
|
+
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
1129
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1130
|
+
|
|
1131
|
+
if (!textarea) return;
|
|
1132
|
+
|
|
1133
|
+
// Use cached computed styles (computed once, reused)
|
|
1134
|
+
if (this._cachedLineHeight === null) {
|
|
1135
|
+
const styles = getComputedStyle(textarea);
|
|
1136
|
+
this._cachedLineHeight = parseFloat(styles.lineHeight);
|
|
1137
|
+
this._cachedPaddingTop = parseFloat(styles.paddingTop);
|
|
1138
|
+
}
|
|
1139
|
+
const lineHeight = this._cachedLineHeight;
|
|
1140
|
+
const paddingTop = this._cachedPaddingTop;
|
|
1141
|
+
|
|
1142
|
+
// Clear gutter
|
|
1143
|
+
gutterContent.textContent = '';
|
|
1144
|
+
|
|
1145
|
+
// Create a map of line -> elements (color, collapse button, or both)
|
|
1146
|
+
const lineElements = new Map();
|
|
1147
|
+
|
|
1148
|
+
// Add color indicators
|
|
1149
|
+
this.colorPositions.forEach(({ line, color, attributeName }) => {
|
|
1150
|
+
if (!lineElements.has(line)) {
|
|
1151
|
+
lineElements.set(line, { colors: [], buttons: [] });
|
|
1152
|
+
}
|
|
1153
|
+
lineElements.get(line).colors.push({ color, attributeName });
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// Add collapse buttons
|
|
1157
|
+
this.nodeTogglePositions.forEach(({ line, nodeKey, isCollapsed }) => {
|
|
1158
|
+
if (!lineElements.has(line)) {
|
|
1159
|
+
lineElements.set(line, { colors: [], buttons: [] });
|
|
1160
|
+
}
|
|
1161
|
+
lineElements.get(line).buttons.push({ nodeKey, isCollapsed });
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// Create gutter lines with DocumentFragment (single DOM update)
|
|
1165
|
+
const fragment = document.createDocumentFragment();
|
|
1166
|
+
|
|
1167
|
+
lineElements.forEach((elements, line) => {
|
|
1168
|
+
const gutterLine = document.createElement('div');
|
|
1169
|
+
gutterLine.className = 'gutter-line';
|
|
1170
|
+
gutterLine.style.top = `${paddingTop + line * lineHeight}px`;
|
|
1171
|
+
|
|
1172
|
+
// Add color indicators
|
|
1173
|
+
elements.colors.forEach(({ color, attributeName }) => {
|
|
1174
|
+
const indicator = document.createElement('div');
|
|
1175
|
+
indicator.className = 'color-indicator';
|
|
1176
|
+
indicator.style.backgroundColor = color;
|
|
1177
|
+
indicator.dataset.line = line;
|
|
1178
|
+
indicator.dataset.color = color;
|
|
1179
|
+
indicator.dataset.attributeName = attributeName;
|
|
1180
|
+
indicator.title = `${attributeName}: ${color}`;
|
|
1181
|
+
gutterLine.appendChild(indicator);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
// Add collapse buttons
|
|
1185
|
+
elements.buttons.forEach(({ nodeKey, isCollapsed }) => {
|
|
1186
|
+
const button = document.createElement('div');
|
|
1187
|
+
button.className = 'collapse-button';
|
|
1188
|
+
button.textContent = isCollapsed ? '+' : '-';
|
|
1189
|
+
button.dataset.line = line;
|
|
1190
|
+
button.dataset.nodeKey = nodeKey;
|
|
1191
|
+
button.title = isCollapsed ? 'Expand' : 'Collapse';
|
|
1192
|
+
gutterLine.appendChild(button);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
fragment.appendChild(gutterLine);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Single DOM insertion
|
|
1199
|
+
gutterContent.appendChild(fragment);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
showColorPicker(indicator, line, currentColor, attributeName) {
|
|
1203
|
+
// Remove existing picker
|
|
1204
|
+
const existing = document.querySelector('.geojson-color-picker-input');
|
|
1205
|
+
if (existing) existing.remove();
|
|
1206
|
+
|
|
1207
|
+
// Create small color input positioned at the indicator
|
|
1208
|
+
const colorInput = document.createElement('input');
|
|
1209
|
+
colorInput.type = 'color';
|
|
1210
|
+
colorInput.value = currentColor;
|
|
1211
|
+
colorInput.className = 'geojson-color-picker-input';
|
|
1212
|
+
|
|
1213
|
+
// Get indicator position in viewport
|
|
1214
|
+
const rect = indicator.getBoundingClientRect();
|
|
1215
|
+
|
|
1216
|
+
colorInput.style.position = 'fixed';
|
|
1217
|
+
colorInput.style.left = `${rect.left}px`;
|
|
1218
|
+
colorInput.style.top = `${rect.top}px`;
|
|
1219
|
+
colorInput.style.width = '12px';
|
|
1220
|
+
colorInput.style.height = '12px';
|
|
1221
|
+
colorInput.style.opacity = '0.01';
|
|
1222
|
+
colorInput.style.border = 'none';
|
|
1223
|
+
colorInput.style.padding = '0';
|
|
1224
|
+
colorInput.style.zIndex = '9999';
|
|
1225
|
+
|
|
1226
|
+
colorInput.addEventListener('input', (e) => {
|
|
1227
|
+
// User is actively changing the color - update in real-time
|
|
1228
|
+
this.updateColorValue(line, e.target.value, attributeName);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
colorInput.addEventListener('change', (e) => {
|
|
1232
|
+
// Picker closed with validation
|
|
1233
|
+
this.updateColorValue(line, e.target.value, attributeName);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// Close picker when clicking anywhere else
|
|
1237
|
+
const closeOnClickOutside = (e) => {
|
|
1238
|
+
if (e.target !== colorInput && !colorInput.contains(e.target)) {
|
|
1239
|
+
colorInput.remove();
|
|
1240
|
+
document.removeEventListener('click', closeOnClickOutside, true);
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
// Add to document body with fixed positioning
|
|
1245
|
+
document.body.appendChild(colorInput);
|
|
1246
|
+
|
|
1247
|
+
// Add click listener after a short delay to avoid immediate close
|
|
1248
|
+
setTimeout(() => {
|
|
1249
|
+
document.addEventListener('click', closeOnClickOutside, true);
|
|
1250
|
+
}, 100);
|
|
1251
|
+
|
|
1252
|
+
// Open the picker and focus it
|
|
1253
|
+
colorInput.focus();
|
|
1254
|
+
colorInput.click();
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
updateColorValue(line, newColor, attributeName) {
|
|
1258
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1259
|
+
const lines = textarea.value.split('\n');
|
|
1260
|
+
|
|
1261
|
+
// Replace color value on the specified line for the specific attribute
|
|
1262
|
+
const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#[0-9a-fA-F]{6}"`);
|
|
1263
|
+
lines[line] = lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
|
|
1264
|
+
|
|
1265
|
+
textarea.value = lines.join('\n');
|
|
1266
|
+
this.updateHighlight();
|
|
1267
|
+
this.emitChange();
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
handleKeydownInCollapsedArea(e) {
|
|
1271
|
+
// Allow navigation keys
|
|
1272
|
+
const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Tab'];
|
|
1273
|
+
if (navigationKeys.includes(e.key)) return;
|
|
1274
|
+
|
|
1275
|
+
// Allow copy/cut/paste (handled separately)
|
|
1276
|
+
if (e.ctrlKey || e.metaKey) return;
|
|
1277
|
+
|
|
1278
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1279
|
+
const cursorPos = textarea.selectionStart;
|
|
1280
|
+
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
|
1281
|
+
const currentLineNum = textBeforeCursor.split('\n').length - 1;
|
|
1282
|
+
const lines = textarea.value.split('\n');
|
|
1283
|
+
const currentLine = lines[currentLineNum];
|
|
1284
|
+
|
|
1285
|
+
// Check if current line is collapsed (contains {...} or [...])
|
|
1286
|
+
if (currentLine && (currentLine.includes('{...}') || currentLine.includes('[...]'))) {
|
|
1287
|
+
e.preventDefault();
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
handleCopyWithCollapsedContent(e) {
|
|
1292
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1293
|
+
const start = textarea.selectionStart;
|
|
1294
|
+
const end = textarea.selectionEnd;
|
|
1295
|
+
|
|
1296
|
+
if (start === end) return; // No selection
|
|
1297
|
+
|
|
1298
|
+
const selectedText = textarea.value.substring(start, end);
|
|
1299
|
+
|
|
1300
|
+
// Check if selection contains collapsed content
|
|
1301
|
+
if (!selectedText.includes('{...}') && !selectedText.includes('[...]')) {
|
|
1302
|
+
return; // No collapsed content, use default copy behavior
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Replace collapsed markers with real content
|
|
1306
|
+
const expandedText = this.expandCollapsedMarkersInText(selectedText, start);
|
|
1307
|
+
|
|
1308
|
+
// Put expanded text in clipboard
|
|
1309
|
+
e.preventDefault();
|
|
1310
|
+
e.clipboardData.setData('text/plain', expandedText);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
expandCollapsedMarkersInText(text, startPos) {
|
|
1314
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1315
|
+
const beforeSelection = textarea.value.substring(0, startPos);
|
|
1316
|
+
const startLineNum = beforeSelection.split('\n').length - 1;
|
|
1317
|
+
|
|
1318
|
+
const lines = text.split('\n');
|
|
1319
|
+
const expandedLines = [];
|
|
1320
|
+
|
|
1321
|
+
lines.forEach((line, relativeLineNum) => {
|
|
1322
|
+
const absoluteLineNum = startLineNum + relativeLineNum;
|
|
1323
|
+
|
|
1324
|
+
// Check if this line has a collapsed marker
|
|
1325
|
+
if (line.includes('{...}') || line.includes('[...]')) {
|
|
1326
|
+
// Find the collapsed node for this line
|
|
1327
|
+
let found = false;
|
|
1328
|
+
this.collapsedData.forEach((collapsed, key) => {
|
|
1329
|
+
const collapsedLineNum = parseInt(key.split('-')[0]);
|
|
1330
|
+
if (collapsedLineNum === absoluteLineNum) {
|
|
1331
|
+
// Replace with original line and all collapsed content
|
|
1332
|
+
expandedLines.push(collapsed.originalLine);
|
|
1333
|
+
expandedLines.push(...collapsed.content);
|
|
1334
|
+
found = true;
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
if (!found) {
|
|
1338
|
+
expandedLines.push(line);
|
|
1339
|
+
}
|
|
1340
|
+
} else {
|
|
1341
|
+
expandedLines.push(line);
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
return expandedLines.join('\n');
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
handleCutWithCollapsedContent(e) {
|
|
1349
|
+
// First copy with expanded content
|
|
1350
|
+
this.handleCopyWithCollapsedContent(e);
|
|
1351
|
+
|
|
1352
|
+
// Then delete the selection normally
|
|
1353
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1354
|
+
const start = textarea.selectionStart;
|
|
1355
|
+
const end = textarea.selectionEnd;
|
|
1356
|
+
|
|
1357
|
+
if (start !== end) {
|
|
1358
|
+
const value = textarea.value;
|
|
1359
|
+
textarea.value = value.substring(0, start) + value.substring(end);
|
|
1360
|
+
textarea.selectionStart = textarea.selectionEnd = start;
|
|
1361
|
+
this.updateHighlight();
|
|
1362
|
+
this.emitChange();
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
emitChange() {
|
|
1367
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1368
|
+
|
|
1369
|
+
// Expand ALL collapsed nodes to get full content
|
|
1370
|
+
const editorContent = this.expandAllCollapsed(textarea.value);
|
|
1371
|
+
|
|
1372
|
+
// Build complete value with prefix/suffix
|
|
1373
|
+
const prefix = this.prefix;
|
|
1374
|
+
const suffix = this.suffix;
|
|
1375
|
+
const fullValue = prefix + editorContent + suffix;
|
|
1376
|
+
|
|
1377
|
+
// Try to parse
|
|
1378
|
+
try {
|
|
1379
|
+
const parsed = JSON.parse(fullValue);
|
|
1380
|
+
|
|
1381
|
+
// Validate GeoJSON types
|
|
1382
|
+
const validationErrors = this.validateGeoJSON(parsed);
|
|
1383
|
+
|
|
1384
|
+
if (validationErrors.length > 0) {
|
|
1385
|
+
// Emit error event for GeoJSON validation errors
|
|
1386
|
+
this.dispatchEvent(new CustomEvent('error', {
|
|
1387
|
+
detail: {
|
|
1388
|
+
timestamp: new Date().toISOString(),
|
|
1389
|
+
error: `GeoJSON validation: ${validationErrors.join('; ')}`,
|
|
1390
|
+
errors: validationErrors,
|
|
1391
|
+
content: editorContent
|
|
1392
|
+
},
|
|
1393
|
+
bubbles: true,
|
|
1394
|
+
composed: true
|
|
1395
|
+
}));
|
|
1396
|
+
} else {
|
|
1397
|
+
// Emit change event with parsed GeoJSON directly
|
|
1398
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
1399
|
+
detail: parsed,
|
|
1400
|
+
bubbles: true,
|
|
1401
|
+
composed: true
|
|
1402
|
+
}));
|
|
1403
|
+
}
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
// Emit error event for invalid JSON
|
|
1406
|
+
this.dispatchEvent(new CustomEvent('error', {
|
|
1407
|
+
detail: {
|
|
1408
|
+
timestamp: new Date().toISOString(),
|
|
1409
|
+
error: e.message,
|
|
1410
|
+
content: editorContent // Raw content for debugging
|
|
1411
|
+
},
|
|
1412
|
+
bubbles: true,
|
|
1413
|
+
composed: true
|
|
1414
|
+
}));
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Validate GeoJSON structure and types
|
|
1419
|
+
// context: 'root' | 'geometry' | 'properties'
|
|
1420
|
+
validateGeoJSON(obj, path = '', context = 'root') {
|
|
1421
|
+
const errors = [];
|
|
1422
|
+
|
|
1423
|
+
if (!obj || typeof obj !== 'object') {
|
|
1424
|
+
return errors;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Check for invalid type values based on context
|
|
1428
|
+
if (context !== 'properties' && obj.type !== undefined) {
|
|
1429
|
+
const typeValue = obj.type;
|
|
1430
|
+
if (typeof typeValue === 'string') {
|
|
1431
|
+
if (context === 'geometry') {
|
|
1432
|
+
// In geometry: must be a geometry type
|
|
1433
|
+
if (!GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.includes(typeValue)) {
|
|
1434
|
+
errors.push(`Invalid geometry type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON_TYPES_GEOMETRY.join(', ')})`);
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
// At root or in features: must be Feature or FeatureCollection
|
|
1438
|
+
if (!GeoJsonEditor.GEOJSON_TYPES_FEATURE.includes(typeValue)) {
|
|
1439
|
+
errors.push(`Invalid type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON_TYPES_FEATURE.join(', ')})`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Recursively validate nested objects
|
|
1446
|
+
if (Array.isArray(obj)) {
|
|
1447
|
+
obj.forEach((item, index) => {
|
|
1448
|
+
errors.push(...this.validateGeoJSON(item, `${path}[${index}]`, context));
|
|
1449
|
+
});
|
|
1450
|
+
} else {
|
|
1451
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1452
|
+
if (typeof value === 'object' && value !== null) {
|
|
1453
|
+
const newPath = path ? `${path}.${key}` : key;
|
|
1454
|
+
// Determine context for nested objects
|
|
1455
|
+
let newContext = context;
|
|
1456
|
+
if (key === 'properties') {
|
|
1457
|
+
newContext = 'properties';
|
|
1458
|
+
} else if (key === 'geometry' || key === 'geometries') {
|
|
1459
|
+
newContext = 'geometry';
|
|
1460
|
+
} else if (key === 'features') {
|
|
1461
|
+
newContext = 'root'; // features contains Feature objects
|
|
1462
|
+
}
|
|
1463
|
+
errors.push(...this.validateGeoJSON(value, newPath, newContext));
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
return errors;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Helper: Check if bracket closes on same line
|
|
1472
|
+
bracketClosesOnSameLine(line, openBracket) {
|
|
1473
|
+
const closeBracket = openBracket === '{' ? '}' : ']';
|
|
1474
|
+
const bracketPos = line.indexOf(openBracket);
|
|
1475
|
+
if (bracketPos === -1) return false;
|
|
1476
|
+
const restOfLine = line.substring(bracketPos + 1);
|
|
1477
|
+
let depth = 1;
|
|
1478
|
+
for (const char of restOfLine) {
|
|
1479
|
+
if (char === openBracket) depth++;
|
|
1480
|
+
if (char === closeBracket) depth--;
|
|
1481
|
+
if (depth === 0) return true;
|
|
1482
|
+
}
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Helper: Expand all collapsed markers and return expanded content
|
|
1487
|
+
expandAllCollapsed(content) {
|
|
1488
|
+
const R = GeoJsonEditor.REGEX;
|
|
1489
|
+
|
|
1490
|
+
while (content.includes('{...}') || content.includes('[...]')) {
|
|
1491
|
+
const lines = content.split('\n');
|
|
1492
|
+
let expanded = false;
|
|
1493
|
+
|
|
1494
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1495
|
+
const line = lines[i];
|
|
1496
|
+
if (!line.includes('{...}') && !line.includes('[...]')) continue;
|
|
1497
|
+
|
|
1498
|
+
const match = line.match(R.collapsedMarker);
|
|
1499
|
+
if (!match) continue;
|
|
1500
|
+
|
|
1501
|
+
const nodeKey = match[2];
|
|
1502
|
+
const currentIndent = match[1].length;
|
|
1503
|
+
const exactKey = `${i}-${nodeKey}`;
|
|
1504
|
+
|
|
1505
|
+
let foundKey = this.collapsedData.has(exactKey) ? exactKey : null;
|
|
1506
|
+
if (!foundKey) {
|
|
1507
|
+
for (const [key, data] of this.collapsedData.entries()) {
|
|
1508
|
+
if (data.nodeKey === nodeKey && data.indent === currentIndent) {
|
|
1509
|
+
foundKey = key;
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if (foundKey) {
|
|
1516
|
+
const {originalLine, content: nodeContent} = this.collapsedData.get(foundKey);
|
|
1517
|
+
lines[i] = originalLine;
|
|
1518
|
+
lines.splice(i + 1, 0, ...nodeContent);
|
|
1519
|
+
expanded = true;
|
|
1520
|
+
break;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
if (!expanded) break;
|
|
1525
|
+
content = lines.join('\n');
|
|
1526
|
+
}
|
|
1527
|
+
return content;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Helper: Format JSON content respecting prefix/suffix
|
|
1531
|
+
formatJSONContent(content) {
|
|
1532
|
+
const prefix = this.prefix;
|
|
1533
|
+
const suffix = this.suffix;
|
|
1534
|
+
const prefixEndsWithBracket = prefix.trimEnd().endsWith('[');
|
|
1535
|
+
const suffixStartsWithBracket = suffix.trimStart().startsWith(']');
|
|
1536
|
+
|
|
1537
|
+
if (prefixEndsWithBracket && suffixStartsWithBracket) {
|
|
1538
|
+
const wrapped = '[' + content + ']';
|
|
1539
|
+
const parsed = JSON.parse(wrapped);
|
|
1540
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
1541
|
+
const lines = formatted.split('\n');
|
|
1542
|
+
return lines.length > 2 ? lines.slice(1, -1).join('\n') : '';
|
|
1543
|
+
} else if (!prefix && !suffix) {
|
|
1544
|
+
const parsed = JSON.parse(content);
|
|
1545
|
+
return JSON.stringify(parsed, null, 2);
|
|
1546
|
+
} else {
|
|
1547
|
+
const fullValue = prefix + content + suffix;
|
|
1548
|
+
JSON.parse(fullValue); // Validate only
|
|
1549
|
+
return content;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
autoFormatContentWithCursor() {
|
|
1554
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1555
|
+
|
|
1556
|
+
// Save cursor position
|
|
1557
|
+
const cursorPos = textarea.selectionStart;
|
|
1558
|
+
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
|
1559
|
+
const linesBeforeCursor = textBeforeCursor.split('\n');
|
|
1560
|
+
const cursorLine = linesBeforeCursor.length - 1;
|
|
1561
|
+
const cursorColumn = linesBeforeCursor[linesBeforeCursor.length - 1].length;
|
|
1562
|
+
|
|
1563
|
+
// Save collapsed node details
|
|
1564
|
+
const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
|
|
1565
|
+
nodeKey: data.nodeKey,
|
|
1566
|
+
indent: data.indent
|
|
1567
|
+
}));
|
|
1568
|
+
|
|
1569
|
+
// Expand and format
|
|
1570
|
+
const content = this.expandAllCollapsed(textarea.value);
|
|
1571
|
+
|
|
1572
|
+
try {
|
|
1573
|
+
const formattedContent = this.formatJSONContent(content);
|
|
1574
|
+
|
|
1575
|
+
if (formattedContent !== content) {
|
|
1576
|
+
this.collapsedData.clear();
|
|
1577
|
+
textarea.value = formattedContent;
|
|
1578
|
+
|
|
1579
|
+
if (collapsedNodes.length > 0) {
|
|
1580
|
+
this.reapplyCollapsed(collapsedNodes);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// Restore cursor position
|
|
1584
|
+
const newLines = textarea.value.split('\n');
|
|
1585
|
+
if (cursorLine < newLines.length) {
|
|
1586
|
+
const newColumn = Math.min(cursorColumn, newLines[cursorLine].length);
|
|
1587
|
+
let newPos = 0;
|
|
1588
|
+
for (let i = 0; i < cursorLine; i++) {
|
|
1589
|
+
newPos += newLines[i].length + 1;
|
|
1590
|
+
}
|
|
1591
|
+
newPos += newColumn;
|
|
1592
|
+
textarea.setSelectionRange(newPos, newPos);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
} catch (e) {
|
|
1596
|
+
// Invalid JSON, don't format
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
autoFormatContent() {
|
|
1601
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1602
|
+
|
|
1603
|
+
// Save collapsed node details
|
|
1604
|
+
const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
|
|
1605
|
+
nodeKey: data.nodeKey,
|
|
1606
|
+
indent: data.indent
|
|
1607
|
+
}));
|
|
1608
|
+
|
|
1609
|
+
// Expand and format
|
|
1610
|
+
const content = this.expandAllCollapsed(textarea.value);
|
|
1611
|
+
|
|
1612
|
+
try {
|
|
1613
|
+
const formattedContent = this.formatJSONContent(content);
|
|
1614
|
+
|
|
1615
|
+
if (formattedContent !== content) {
|
|
1616
|
+
this.collapsedData.clear();
|
|
1617
|
+
textarea.value = formattedContent;
|
|
1618
|
+
|
|
1619
|
+
if (collapsedNodes.length > 0) {
|
|
1620
|
+
this.reapplyCollapsed(collapsedNodes);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
// Invalid JSON, don't format
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
reapplyCollapsed(collapsedNodes) {
|
|
1629
|
+
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1630
|
+
const lines = textarea.value.split('\n');
|
|
1631
|
+
|
|
1632
|
+
// Group collapsed nodes by nodeKey+indent and count occurrences
|
|
1633
|
+
const collapseMap = new Map();
|
|
1634
|
+
collapsedNodes.forEach(({nodeKey, indent}) => {
|
|
1635
|
+
const key = `${nodeKey}-${indent}`;
|
|
1636
|
+
collapseMap.set(key, (collapseMap.get(key) || 0) + 1);
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
// Track occurrences as we iterate
|
|
1640
|
+
const occurrenceCount = new Map();
|
|
1641
|
+
|
|
1642
|
+
// Iterate backwards to avoid index issues
|
|
1643
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1644
|
+
const line = lines[i];
|
|
1645
|
+
const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
|
|
1646
|
+
|
|
1647
|
+
if (match) {
|
|
1648
|
+
const nodeKey = match[2];
|
|
1649
|
+
const currentIndent = match[1].length;
|
|
1650
|
+
const key = `${nodeKey}-${currentIndent}`;
|
|
1651
|
+
|
|
1652
|
+
if (collapseMap.has(key)) {
|
|
1653
|
+
// Count this occurrence
|
|
1654
|
+
occurrenceCount.set(key, (occurrenceCount.get(key) || 0) + 1);
|
|
1655
|
+
const currentOccurrence = occurrenceCount.get(key);
|
|
1656
|
+
|
|
1657
|
+
// Only collapse if this occurrence should be collapsed
|
|
1658
|
+
if (currentOccurrence <= collapseMap.get(key)) {
|
|
1659
|
+
const indent = match[1];
|
|
1660
|
+
const openBracket = match[3];
|
|
1661
|
+
const closeBracket = openBracket === '{' ? '}' : ']';
|
|
1662
|
+
|
|
1663
|
+
// Skip if closes on same line
|
|
1664
|
+
if (this.bracketClosesOnSameLine(line, openBracket)) continue;
|
|
1665
|
+
|
|
1666
|
+
// Find closing bracket
|
|
1667
|
+
let depth = 1;
|
|
1668
|
+
let endLine = i;
|
|
1669
|
+
const content = [];
|
|
1670
|
+
|
|
1671
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1672
|
+
const scanLine = lines[j];
|
|
1673
|
+
|
|
1674
|
+
for (const char of scanLine) {
|
|
1675
|
+
if (char === openBracket) depth++;
|
|
1676
|
+
if (char === closeBracket) depth--;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
content.push(scanLine);
|
|
1680
|
+
|
|
1681
|
+
if (depth === 0) {
|
|
1682
|
+
endLine = j;
|
|
1683
|
+
break;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Store with unique key
|
|
1688
|
+
const uniqueKey = `${i}-${nodeKey}`;
|
|
1689
|
+
this.collapsedData.set(uniqueKey, {
|
|
1690
|
+
originalLine: line,
|
|
1691
|
+
content: content,
|
|
1692
|
+
indent: indent.length,
|
|
1693
|
+
nodeKey: nodeKey
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
// Replace with marker
|
|
1697
|
+
const beforeBracket = line.substring(0, line.indexOf(openBracket));
|
|
1698
|
+
const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
|
|
1699
|
+
lines[i] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
|
|
1700
|
+
|
|
1701
|
+
// Remove content lines
|
|
1702
|
+
lines.splice(i + 1, endLine - i);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
textarea.value = lines.join('\n');
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
// Parse selector and generate CSS rule for dark theme
|
|
1713
|
+
parseSelectorToHostRule(selector) {
|
|
1714
|
+
if (!selector || selector === '') {
|
|
1715
|
+
// Fallback: use data attribute on host element
|
|
1716
|
+
return ':host([data-color-scheme="dark"])';
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// Check if it's a simple class on host (.dark)
|
|
1720
|
+
if (selector.startsWith('.') && !selector.includes(' ')) {
|
|
1721
|
+
return `:host(${selector})`;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Complex selector - use :host-context for parent elements
|
|
1725
|
+
return `:host-context(${selector})`;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Generate and inject theme CSS based on dark selector
|
|
1729
|
+
updateThemeCSS() {
|
|
1730
|
+
const darkSelector = this.getAttribute('dark-selector') || '.dark';
|
|
1731
|
+
|
|
1732
|
+
// Parse selector to create CSS rule for dark theme
|
|
1733
|
+
const darkRule = this.parseSelectorToHostRule(darkSelector);
|
|
1734
|
+
// Light theme is the default (no selector = light)
|
|
1735
|
+
const lightRule = ':host';
|
|
1736
|
+
|
|
1737
|
+
// Find or create theme style element
|
|
1738
|
+
let themeStyle = this.shadowRoot.getElementById('theme-styles');
|
|
1739
|
+
if (!themeStyle) {
|
|
1740
|
+
themeStyle = document.createElement('style');
|
|
1741
|
+
themeStyle.id = 'theme-styles';
|
|
1742
|
+
// Insert at the beginning of shadow root to ensure it's before static styles
|
|
1743
|
+
this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Generate CSS with theme variables (light first as default, then dark overrides)
|
|
1747
|
+
const css = `
|
|
1748
|
+
${lightRule} {
|
|
1749
|
+
--bg-color: ${this.themes.light.background};
|
|
1750
|
+
--text-color: ${this.themes.light.textColor};
|
|
1751
|
+
--caret-color: ${this.themes.light.caretColor};
|
|
1752
|
+
--gutter-bg: ${this.themes.light.gutterBackground};
|
|
1753
|
+
--gutter-border: ${this.themes.light.gutterBorder};
|
|
1754
|
+
--json-key: ${this.themes.light.jsonKey};
|
|
1755
|
+
--json-string: ${this.themes.light.jsonString};
|
|
1756
|
+
--json-number: ${this.themes.light.jsonNumber};
|
|
1757
|
+
--json-boolean: ${this.themes.light.jsonBoolean};
|
|
1758
|
+
--json-null: ${this.themes.light.jsonNull};
|
|
1759
|
+
--json-punct: ${this.themes.light.jsonPunctuation};
|
|
1760
|
+
--collapse-btn: ${this.themes.light.collapseButton};
|
|
1761
|
+
--collapse-btn-bg: ${this.themes.light.collapseButtonBg};
|
|
1762
|
+
--collapse-btn-border: ${this.themes.light.collapseButtonBorder};
|
|
1763
|
+
--geojson-key: ${this.themes.light.geojsonKey};
|
|
1764
|
+
--geojson-type: ${this.themes.light.geojsonType};
|
|
1765
|
+
--geojson-type-invalid: ${this.themes.light.geojsonTypeInvalid};
|
|
1766
|
+
--json-key-invalid: ${this.themes.light.jsonKeyInvalid};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
${darkRule} {
|
|
1770
|
+
--bg-color: ${this.themes.dark.background};
|
|
1771
|
+
--text-color: ${this.themes.dark.textColor};
|
|
1772
|
+
--caret-color: ${this.themes.dark.caretColor};
|
|
1773
|
+
--gutter-bg: ${this.themes.dark.gutterBackground};
|
|
1774
|
+
--gutter-border: ${this.themes.dark.gutterBorder};
|
|
1775
|
+
--json-key: ${this.themes.dark.jsonKey};
|
|
1776
|
+
--json-string: ${this.themes.dark.jsonString};
|
|
1777
|
+
--json-number: ${this.themes.dark.jsonNumber};
|
|
1778
|
+
--json-boolean: ${this.themes.dark.jsonBoolean};
|
|
1779
|
+
--json-null: ${this.themes.dark.jsonNull};
|
|
1780
|
+
--json-punct: ${this.themes.dark.jsonPunctuation};
|
|
1781
|
+
--collapse-btn: ${this.themes.dark.collapseButton};
|
|
1782
|
+
--collapse-btn-bg: ${this.themes.dark.collapseButtonBg};
|
|
1783
|
+
--collapse-btn-border: ${this.themes.dark.collapseButtonBorder};
|
|
1784
|
+
--geojson-key: ${this.themes.dark.geojsonKey};
|
|
1785
|
+
--geojson-type: ${this.themes.dark.geojsonType};
|
|
1786
|
+
--geojson-type-invalid: ${this.themes.dark.geojsonTypeInvalid};
|
|
1787
|
+
--json-key-invalid: ${this.themes.dark.jsonKeyInvalid};
|
|
1788
|
+
}
|
|
1789
|
+
`;
|
|
1790
|
+
|
|
1791
|
+
themeStyle.textContent = css;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// Public API: Theme management
|
|
1795
|
+
getTheme() {
|
|
1796
|
+
return {
|
|
1797
|
+
dark: { ...this.themes.dark },
|
|
1798
|
+
light: { ...this.themes.light }
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
setTheme(theme) {
|
|
1803
|
+
if (theme.dark) {
|
|
1804
|
+
this.themes.dark = { ...this.themes.dark, ...theme.dark };
|
|
1805
|
+
}
|
|
1806
|
+
if (theme.light) {
|
|
1807
|
+
this.themes.light = { ...this.themes.light, ...theme.light };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Regenerate CSS with new theme values
|
|
1811
|
+
this.updateThemeCSS();
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
resetTheme() {
|
|
1815
|
+
// Reset to defaults
|
|
1816
|
+
this.themes = {
|
|
1817
|
+
dark: { ...GeoJsonEditor.DEFAULT_THEMES.dark },
|
|
1818
|
+
light: { ...GeoJsonEditor.DEFAULT_THEMES.light }
|
|
1819
|
+
};
|
|
1820
|
+
this.updateThemeCSS();
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// Register the custom element
|
|
1825
|
+
customElements.define('geojson-editor', GeoJsonEditor);
|