@softwarity/geojson-editor 1.0.10 → 1.0.12
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 +9 -63
- package/dist/geojson-editor.js +2 -2
- package/package.json +2 -1
- package/src/geojson-editor.css +453 -0
- package/src/geojson-editor.js +2154 -2090
- package/src/geojson-editor.template.js +57 -0
package/src/geojson-editor.js
CHANGED
|
@@ -1,2122 +1,2095 @@
|
|
|
1
|
+
import styles from './geojson-editor.css?inline';
|
|
2
|
+
import { getTemplate } from './geojson-editor.template.js';
|
|
3
|
+
|
|
4
|
+
// GeoJSON constants
|
|
5
|
+
const GEOJSON_KEYS = ['type', 'geometry', 'properties', 'coordinates', 'id', 'features'];
|
|
6
|
+
const GEOMETRY_TYPES = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GeoJSON Editor Web Component
|
|
10
|
+
* Monaco-like architecture with virtualized line rendering
|
|
11
|
+
*/
|
|
1
12
|
class GeoJsonEditor extends HTMLElement {
|
|
2
13
|
constructor() {
|
|
3
14
|
super();
|
|
4
15
|
this.attachShadow({ mode: 'open' });
|
|
5
16
|
|
|
6
|
-
//
|
|
7
|
-
this.
|
|
8
|
-
this.
|
|
9
|
-
this.
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
// ========== Model (Source of Truth) ==========
|
|
18
|
+
this.lines = []; // Array of line strings
|
|
19
|
+
this.collapsedNodes = new Set(); // Set of unique node IDs that are collapsed
|
|
20
|
+
this.hiddenFeatures = new Set(); // Set of feature keys hidden from events
|
|
21
|
+
|
|
22
|
+
// ========== Node ID Management ==========
|
|
23
|
+
this._nodeIdCounter = 0; // Counter for generating unique node IDs
|
|
24
|
+
this._lineToNodeId = new Map(); // lineIndex -> nodeId (for collapsible lines)
|
|
25
|
+
this._nodeIdToLines = new Map(); // nodeId -> {startLine, endLine} (range of collapsed content)
|
|
26
|
+
|
|
27
|
+
// ========== Derived State (computed from model) ==========
|
|
28
|
+
this.visibleLines = []; // Lines to render (after collapse filter)
|
|
29
|
+
this.lineMetadata = new Map(); // lineIndex -> {colors, booleans, collapse, visibility, hidden, featureKey}
|
|
12
30
|
this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
this.
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
|
|
32
|
+
// ========== View State ==========
|
|
33
|
+
this.scrollTop = 0;
|
|
34
|
+
this.viewportHeight = 0;
|
|
35
|
+
this.lineHeight = 19.5; // CSS: line-height * font-size = 1.5 * 13px
|
|
36
|
+
this.bufferLines = 5; // Extra lines to render above/below viewport
|
|
37
|
+
|
|
38
|
+
// ========== Render Cache ==========
|
|
39
|
+
this._lastStartIndex = -1;
|
|
40
|
+
this._lastEndIndex = -1;
|
|
41
|
+
this._lastTotalLines = -1;
|
|
42
|
+
this._scrollRaf = null;
|
|
43
|
+
|
|
44
|
+
// ========== Cursor/Selection ==========
|
|
45
|
+
this.cursorLine = 0;
|
|
46
|
+
this.cursorColumn = 0;
|
|
47
|
+
this.selectionStart = null; // {line, column}
|
|
48
|
+
this.selectionEnd = null; // {line, column}
|
|
49
|
+
|
|
50
|
+
// ========== Debounce ==========
|
|
51
|
+
this.renderTimer = null;
|
|
52
|
+
this.inputTimer = null;
|
|
53
|
+
|
|
54
|
+
// ========== Theme ==========
|
|
22
55
|
this.themes = { dark: {}, light: {} };
|
|
23
56
|
}
|
|
24
57
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// Parsed default properties rules (cache)
|
|
30
|
-
_defaultPropertiesRules = null;
|
|
31
|
-
|
|
32
|
-
// Helper: Convert camelCase to kebab-case
|
|
33
|
-
static _toKebabCase(str) {
|
|
34
|
-
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Dark theme defaults - IntelliJ Darcula (light defaults are CSS fallbacks)
|
|
38
|
-
static DARK_THEME_DEFAULTS = {
|
|
39
|
-
bgColor: '#2b2b2b',
|
|
40
|
-
textColor: '#a9b7c6',
|
|
41
|
-
caretColor: '#bbbbbb',
|
|
42
|
-
gutterBg: '#313335',
|
|
43
|
-
gutterBorder: '#3c3f41',
|
|
44
|
-
jsonKey: '#9876aa',
|
|
45
|
-
jsonString: '#6a8759',
|
|
46
|
-
jsonNumber: '#6897bb',
|
|
47
|
-
jsonBoolean: '#cc7832',
|
|
48
|
-
jsonNull: '#cc7832',
|
|
49
|
-
jsonPunct: '#a9b7c6',
|
|
50
|
-
controlColor: '#cc7832',
|
|
51
|
-
controlBg: '#3c3f41',
|
|
52
|
-
controlBorder: '#5a5a5a',
|
|
53
|
-
geojsonKey: '#9876aa',
|
|
54
|
-
geojsonType: '#6a8759',
|
|
55
|
-
geojsonTypeInvalid: '#ff6b68',
|
|
56
|
-
jsonKeyInvalid: '#ff6b68'
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
// Pre-compiled regex patterns (avoid recompilation on each call)
|
|
60
|
-
static REGEX = {
|
|
61
|
-
// HTML escaping
|
|
62
|
-
ampersand: /&/g,
|
|
63
|
-
lessThan: /</g,
|
|
64
|
-
greaterThan: />/g,
|
|
65
|
-
// JSON structure
|
|
66
|
-
jsonKey: /"([^"]+)"\s*:/g,
|
|
67
|
-
typeValue: /<span class="geojson-key">"type"<\/span>:\s*"([^"]*)"/g,
|
|
68
|
-
stringValue: /:\s*"([^"]*)"/g,
|
|
69
|
-
numberAfterColon: /:\s*(-?\d+\.?\d*)/g,
|
|
70
|
-
boolean: /:\s*(true|false)/g,
|
|
71
|
-
nullValue: /:\s*(null)/g,
|
|
72
|
-
allNumbers: /\b(-?\d+\.?\d*)\b/g,
|
|
73
|
-
punctuation: /([{}[\],])/g,
|
|
74
|
-
// Highlighting detection
|
|
75
|
-
colorInLine: /"([\w-]+)"\s*:\s*"(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))"/g,
|
|
76
|
-
booleanInLine: /"([\w-]+)"\s*:\s*(true|false)/g,
|
|
77
|
-
collapsibleNode: /^(\s*)"(\w+)"\s*:\s*([{\[])/,
|
|
78
|
-
collapsedMarker: /^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Icons used in the gutter
|
|
82
|
-
static ICONS = {
|
|
83
|
-
expanded: '⌄', // Chevron down (collapse button when expanded)
|
|
84
|
-
collapsed: '›', // Chevron right (expand button when collapsed)
|
|
85
|
-
visibility: '👁' // Eye icon for visibility toggle
|
|
86
|
-
};
|
|
58
|
+
// ========== Unique ID Generation ==========
|
|
59
|
+
_generateNodeId() {
|
|
60
|
+
return `node_${++this._nodeIdCounter}`;
|
|
61
|
+
}
|
|
87
62
|
|
|
88
63
|
/**
|
|
89
|
-
*
|
|
90
|
-
* @param {number} lineIndex -
|
|
91
|
-
* @
|
|
92
|
-
* @param {number} indent - Indentation level to match
|
|
93
|
-
* @returns {{key: string, data: Object}|null} Found key and data, or null
|
|
94
|
-
* @private
|
|
64
|
+
* Check if a line is inside a collapsed node (hidden lines between opening and closing)
|
|
65
|
+
* @param {number} lineIndex - The line index to check
|
|
66
|
+
* @returns {Object|null} - The collapsed range info or null
|
|
95
67
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Search for any key with this nodeKey and matching indent
|
|
104
|
-
for (const [key, data] of this.collapsedData.entries()) {
|
|
105
|
-
if (data.nodeKey === nodeKey && data.indent === indent) {
|
|
106
|
-
return { key, data };
|
|
68
|
+
_getCollapsedRangeForLine(lineIndex) {
|
|
69
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
70
|
+
// Lines strictly between opening and closing are hidden
|
|
71
|
+
if (this.collapsedNodes.has(nodeId) && lineIndex > info.startLine && lineIndex < info.endLine) {
|
|
72
|
+
return { nodeId, ...info };
|
|
107
73
|
}
|
|
108
74
|
}
|
|
109
|
-
|
|
110
75
|
return null;
|
|
111
76
|
}
|
|
112
77
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// Parse default properties rules
|
|
124
|
-
this._parseDefaultProperties();
|
|
125
|
-
|
|
126
|
-
// Initialize textarea with value attribute (attributeChangedCallback fires before render)
|
|
127
|
-
if (this.value) {
|
|
128
|
-
this.updateValue(this.value);
|
|
78
|
+
/**
|
|
79
|
+
* Check if cursor is on the closing line of a collapsed node
|
|
80
|
+
* @param {number} lineIndex - The line index to check
|
|
81
|
+
* @returns {Object|null} - The collapsed range info or null
|
|
82
|
+
*/
|
|
83
|
+
_getCollapsedClosingLine(lineIndex) {
|
|
84
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
85
|
+
if (this.collapsedNodes.has(nodeId) && lineIndex === info.endLine) {
|
|
86
|
+
return { nodeId, ...info };
|
|
87
|
+
}
|
|
129
88
|
}
|
|
130
|
-
|
|
89
|
+
return null;
|
|
131
90
|
}
|
|
132
91
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (this.highlightTimer) {
|
|
143
|
-
clearTimeout(this.highlightTimer);
|
|
144
|
-
this.highlightTimer = null;
|
|
145
|
-
}
|
|
92
|
+
/**
|
|
93
|
+
* Get the position of the closing bracket on a line
|
|
94
|
+
* @param {string} line - The line content
|
|
95
|
+
* @returns {number} - Position of bracket or -1
|
|
96
|
+
*/
|
|
97
|
+
_getClosingBracketPos(line) {
|
|
98
|
+
// Find the last ] or } on the line
|
|
99
|
+
const lastBracket = Math.max(line.lastIndexOf(']'), line.lastIndexOf('}'));
|
|
100
|
+
return lastBracket;
|
|
146
101
|
}
|
|
147
102
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
this.
|
|
157
|
-
|
|
158
|
-
this.updateThemeCSS();
|
|
159
|
-
} else if (name === 'default-properties') {
|
|
160
|
-
// Re-parse the default properties rules
|
|
161
|
-
this._parseDefaultProperties();
|
|
103
|
+
/**
|
|
104
|
+
* Check if cursor is on the opening line of a collapsed node
|
|
105
|
+
* @param {number} lineIndex - The line index to check
|
|
106
|
+
* @returns {Object|null} - The collapsed range info or null
|
|
107
|
+
*/
|
|
108
|
+
_getCollapsedNodeAtLine(lineIndex) {
|
|
109
|
+
const nodeId = this._lineToNodeId.get(lineIndex);
|
|
110
|
+
if (nodeId && this.collapsedNodes.has(nodeId)) {
|
|
111
|
+
const info = this._nodeIdToLines.get(nodeId);
|
|
112
|
+
return { nodeId, ...info };
|
|
162
113
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// Properties
|
|
166
|
-
get readonly() {
|
|
167
|
-
return this.hasAttribute('readonly');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
get value() {
|
|
172
|
-
return this.getAttribute('value') || '';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
get placeholder() {
|
|
176
|
-
return this.getAttribute('placeholder') || '';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Always in FeatureCollection mode - prefix/suffix are constant
|
|
180
|
-
get prefix() {
|
|
181
|
-
return '{"type": "FeatureCollection", "features": [';
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
get suffix() {
|
|
185
|
-
return ']}';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
get defaultProperties() {
|
|
189
|
-
return this.getAttribute('default-properties') || '';
|
|
114
|
+
return null;
|
|
190
115
|
}
|
|
191
116
|
|
|
192
117
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* 2. Conditional array: [{"match": {"geometry.type": "Polygon"}, "values": {...}}, ...]
|
|
197
|
-
*
|
|
198
|
-
* Returns an array of rules: [{match: null|object, values: object}]
|
|
118
|
+
* Check if cursor is on a line that has a collapsible node (expanded or collapsed)
|
|
119
|
+
* @param {number} lineIndex - The line index to check
|
|
120
|
+
* @returns {Object|null} - The node info with isCollapsed flag or null
|
|
199
121
|
*/
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
const parsed = JSON.parse(attr);
|
|
209
|
-
|
|
210
|
-
if (Array.isArray(parsed)) {
|
|
211
|
-
// Conditional format: array of rules
|
|
212
|
-
this._defaultPropertiesRules = parsed.map(rule => ({
|
|
213
|
-
match: rule.match || null,
|
|
214
|
-
values: rule.values || {}
|
|
215
|
-
}));
|
|
216
|
-
} else if (typeof parsed === 'object' && parsed !== null) {
|
|
217
|
-
// Simple format: single object of properties for all features
|
|
218
|
-
this._defaultPropertiesRules = [{ match: null, values: parsed }];
|
|
219
|
-
} else {
|
|
220
|
-
this._defaultPropertiesRules = [];
|
|
221
|
-
}
|
|
222
|
-
} catch (e) {
|
|
223
|
-
console.warn('geojson-editor: Invalid default-properties JSON:', e.message);
|
|
224
|
-
this._defaultPropertiesRules = [];
|
|
122
|
+
_getCollapsibleNodeAtLine(lineIndex) {
|
|
123
|
+
const nodeId = this._lineToNodeId.get(lineIndex);
|
|
124
|
+
if (nodeId) {
|
|
125
|
+
const info = this._nodeIdToLines.get(nodeId);
|
|
126
|
+
const isCollapsed = this.collapsedNodes.has(nodeId);
|
|
127
|
+
return { nodeId, isCollapsed, ...info };
|
|
225
128
|
}
|
|
226
|
-
|
|
227
|
-
return this._defaultPropertiesRules;
|
|
129
|
+
return null;
|
|
228
130
|
}
|
|
229
131
|
|
|
230
132
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* -
|
|
234
|
-
* -
|
|
133
|
+
* Find the innermost expanded node that contains the given line
|
|
134
|
+
* Used for Shift+Tab to collapse the parent node from anywhere inside it
|
|
135
|
+
* @param {number} lineIndex - The line index to check
|
|
136
|
+
* @returns {Object|null} - The containing node info or null
|
|
235
137
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
for (const [
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
138
|
+
_getContainingExpandedNode(lineIndex) {
|
|
139
|
+
let bestMatch = null;
|
|
140
|
+
|
|
141
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
142
|
+
// Skip collapsed nodes
|
|
143
|
+
if (this.collapsedNodes.has(nodeId)) continue;
|
|
144
|
+
|
|
145
|
+
// Check if line is within this node's range
|
|
146
|
+
if (lineIndex >= info.startLine && lineIndex <= info.endLine) {
|
|
147
|
+
// Prefer the innermost (smallest) containing node
|
|
148
|
+
if (!bestMatch || (info.endLine - info.startLine) < (bestMatch.endLine - bestMatch.startLine)) {
|
|
149
|
+
bestMatch = { nodeId, ...info };
|
|
150
|
+
}
|
|
243
151
|
}
|
|
244
152
|
}
|
|
245
|
-
|
|
153
|
+
|
|
154
|
+
return bestMatch;
|
|
246
155
|
}
|
|
247
156
|
|
|
248
157
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
158
|
+
* Delete an entire collapsed node (opening line to closing line)
|
|
159
|
+
* @param {Object} range - The range info {startLine, endLine}
|
|
251
160
|
*/
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
161
|
+
_deleteCollapsedNode(range) {
|
|
162
|
+
// Remove all lines from startLine to endLine
|
|
163
|
+
const count = range.endLine - range.startLine + 1;
|
|
164
|
+
this.lines.splice(range.startLine, count);
|
|
165
|
+
|
|
166
|
+
// Position cursor at the line where the node was
|
|
167
|
+
this.cursorLine = Math.min(range.startLine, this.lines.length - 1);
|
|
168
|
+
this.cursorColumn = 0;
|
|
169
|
+
|
|
170
|
+
this.formatAndUpdate();
|
|
260
171
|
}
|
|
261
172
|
|
|
262
173
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* Returns a new feature object (doesn't mutate original).
|
|
174
|
+
* Rebuild nodeId mappings after content changes
|
|
175
|
+
* Preserves collapsed state by matching nodeKey + sequential occurrence
|
|
266
176
|
*/
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
177
|
+
_rebuildNodeIdMappings() {
|
|
178
|
+
// Save old state to try to preserve collapsed nodes
|
|
179
|
+
const oldCollapsed = new Set(this.collapsedNodes);
|
|
180
|
+
const oldNodeKeyMap = new Map(); // nodeId -> nodeKey
|
|
181
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
182
|
+
if (info.nodeKey) oldNodeKeyMap.set(nodeId, info.nodeKey);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Build list of collapsed nodeKeys for matching
|
|
186
|
+
const collapsedNodeKeys = [];
|
|
187
|
+
for (const nodeId of oldCollapsed) {
|
|
188
|
+
const nodeKey = oldNodeKeyMap.get(nodeId);
|
|
189
|
+
if (nodeKey) collapsedNodeKeys.push(nodeKey);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reset mappings
|
|
193
|
+
this._nodeIdCounter = 0;
|
|
194
|
+
this._lineToNodeId.clear();
|
|
195
|
+
this._nodeIdToLines.clear();
|
|
196
|
+
this.collapsedNodes.clear();
|
|
197
|
+
|
|
198
|
+
// Track occurrences of each nodeKey for matching
|
|
199
|
+
const nodeKeyOccurrences = new Map();
|
|
200
|
+
|
|
201
|
+
// Assign fresh IDs to all collapsible nodes
|
|
202
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
203
|
+
const line = this.lines[i];
|
|
204
|
+
|
|
205
|
+
// Match "key": { or "key": [
|
|
206
|
+
const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
|
|
207
|
+
// Also match standalone { or {, (root Feature objects)
|
|
208
|
+
const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
|
|
209
|
+
|
|
210
|
+
if (!kvMatch && !rootMatch) continue;
|
|
211
|
+
|
|
212
|
+
let nodeKey, openBracket;
|
|
213
|
+
|
|
214
|
+
if (kvMatch) {
|
|
215
|
+
nodeKey = kvMatch[1];
|
|
216
|
+
openBracket = kvMatch[2];
|
|
217
|
+
} else {
|
|
218
|
+
// Root object - use special key based on line number and bracket type
|
|
219
|
+
openBracket = rootMatch[1];
|
|
220
|
+
nodeKey = `__root_${openBracket}_${i}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if closes on same line
|
|
224
|
+
const rest = line.substring(line.indexOf(openBracket) + 1);
|
|
225
|
+
const counts = this._countBrackets(rest, openBracket);
|
|
226
|
+
if (counts.close > counts.open) continue;
|
|
227
|
+
|
|
228
|
+
const endLine = this._findClosingLine(i, openBracket);
|
|
229
|
+
if (endLine === -1 || endLine === i) continue;
|
|
230
|
+
|
|
231
|
+
// Generate unique ID for this node
|
|
232
|
+
const nodeId = this._generateNodeId();
|
|
233
|
+
|
|
234
|
+
this._lineToNodeId.set(i, nodeId);
|
|
235
|
+
this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, isRootFeature: !!rootMatch });
|
|
236
|
+
|
|
237
|
+
// Track occurrence of this nodeKey
|
|
238
|
+
const occurrence = nodeKeyOccurrences.get(nodeKey) || 0;
|
|
239
|
+
nodeKeyOccurrences.set(nodeKey, occurrence + 1);
|
|
240
|
+
|
|
241
|
+
// Check if this nodeKey was previously collapsed
|
|
242
|
+
const keyIndex = collapsedNodeKeys.indexOf(nodeKey);
|
|
243
|
+
if (keyIndex !== -1) {
|
|
244
|
+
// Remove from list so we don't match it again
|
|
245
|
+
collapsedNodeKeys.splice(keyIndex, 1);
|
|
246
|
+
this.collapsedNodes.add(nodeId);
|
|
277
247
|
}
|
|
278
248
|
}
|
|
249
|
+
}
|
|
279
250
|
|
|
280
|
-
|
|
251
|
+
// ========== Observed Attributes ==========
|
|
252
|
+
static get observedAttributes() {
|
|
253
|
+
return ['readonly', 'value', 'placeholder', 'dark-selector'];
|
|
254
|
+
}
|
|
281
255
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
256
|
+
// ========== Lifecycle ==========
|
|
257
|
+
connectedCallback() {
|
|
258
|
+
this.render();
|
|
259
|
+
this.setupEventListeners();
|
|
260
|
+
this.updatePrefixSuffix();
|
|
261
|
+
this.updateThemeCSS();
|
|
262
|
+
|
|
263
|
+
if (this.value) {
|
|
264
|
+
this.setValue(this.value);
|
|
265
|
+
}
|
|
266
|
+
this.updatePlaceholderVisibility();
|
|
267
|
+
}
|
|
286
268
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
269
|
+
disconnectedCallback() {
|
|
270
|
+
if (this.renderTimer) clearTimeout(this.renderTimer);
|
|
271
|
+
if (this.inputTimer) clearTimeout(this.inputTimer);
|
|
272
|
+
|
|
273
|
+
// Cleanup color picker
|
|
274
|
+
const colorPicker = document.querySelector('.geojson-color-picker-input');
|
|
275
|
+
if (colorPicker) {
|
|
276
|
+
if (colorPicker._closeListener) {
|
|
277
|
+
document.removeEventListener('click', colorPicker._closeListener, true);
|
|
291
278
|
}
|
|
279
|
+
colorPicker.remove();
|
|
292
280
|
}
|
|
293
|
-
|
|
294
|
-
if (!hasChanges) return feature;
|
|
295
|
-
|
|
296
|
-
return { ...feature, properties: newProps };
|
|
297
281
|
}
|
|
298
282
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
<style>
|
|
302
|
-
/* Base reset - protect against inherited styles */
|
|
303
|
-
:host *, :host *::before, :host *::after {
|
|
304
|
-
box-sizing: border-box;
|
|
305
|
-
font: normal normal 13px/1.5 'Courier New', Courier, monospace;
|
|
306
|
-
font-variant: normal;
|
|
307
|
-
letter-spacing: 0;
|
|
308
|
-
word-spacing: 0;
|
|
309
|
-
text-transform: none;
|
|
310
|
-
text-decoration: none;
|
|
311
|
-
text-indent: 0;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
:host {
|
|
315
|
-
display: flex;
|
|
316
|
-
flex-direction: column;
|
|
317
|
-
position: relative;
|
|
318
|
-
width: 100%;
|
|
319
|
-
height: 400px;
|
|
320
|
-
border-radius: 4px;
|
|
321
|
-
overflow: hidden;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
:host([readonly]) .editor-wrapper::after {
|
|
325
|
-
content: '';
|
|
326
|
-
position: absolute;
|
|
327
|
-
inset: 0;
|
|
328
|
-
pointer-events: none;
|
|
329
|
-
background: repeating-linear-gradient(-45deg, rgba(128,128,128,0.08), rgba(128,128,128,0.08) 3px, transparent 3px, transparent 12px);
|
|
330
|
-
z-index: 1;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
:host([readonly]) textarea { cursor: text; }
|
|
334
|
-
|
|
335
|
-
.editor-wrapper {
|
|
336
|
-
position: relative;
|
|
337
|
-
width: 100%;
|
|
338
|
-
flex: 1;
|
|
339
|
-
background: var(--bg-color, #fff);
|
|
340
|
-
display: flex;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
.gutter {
|
|
344
|
-
width: 24px;
|
|
345
|
-
height: 100%;
|
|
346
|
-
background: var(--gutter-bg, #f0f0f0);
|
|
347
|
-
border-right: 1px solid var(--gutter-border, #e0e0e0);
|
|
348
|
-
overflow: hidden;
|
|
349
|
-
flex-shrink: 0;
|
|
350
|
-
position: relative;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
.gutter-content {
|
|
354
|
-
position: absolute;
|
|
355
|
-
top: 0;
|
|
356
|
-
left: 0;
|
|
357
|
-
width: 100%;
|
|
358
|
-
padding: 8px 4px;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
.gutter-line {
|
|
362
|
-
position: absolute;
|
|
363
|
-
left: 0;
|
|
364
|
-
width: 100%;
|
|
365
|
-
height: 1.5em;
|
|
366
|
-
display: flex;
|
|
367
|
-
align-items: center;
|
|
368
|
-
justify-content: center;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
.color-indicator, .collapse-button, .boolean-checkbox {
|
|
372
|
-
width: 12px;
|
|
373
|
-
height: 12px;
|
|
374
|
-
border-radius: 2px;
|
|
375
|
-
cursor: pointer;
|
|
376
|
-
transition: transform 0.1s;
|
|
377
|
-
flex-shrink: 0;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
.color-indicator {
|
|
381
|
-
border: 1px solid #555;
|
|
382
|
-
}
|
|
383
|
-
.color-indicator:hover {
|
|
384
|
-
transform: scale(1.2);
|
|
385
|
-
border-color: #fff;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
.boolean-checkbox {
|
|
389
|
-
appearance: none;
|
|
390
|
-
-webkit-appearance: none;
|
|
391
|
-
background: transparent;
|
|
392
|
-
border: 1.5px solid var(--control-border, #c0c0c0);
|
|
393
|
-
border-radius: 2px;
|
|
394
|
-
margin: 0;
|
|
395
|
-
position: relative;
|
|
396
|
-
}
|
|
397
|
-
.boolean-checkbox:checked {
|
|
398
|
-
border-color: var(--control-color, #000080);
|
|
399
|
-
}
|
|
400
|
-
.boolean-checkbox:checked::after {
|
|
401
|
-
content: '✔';
|
|
402
|
-
color: var(--control-color, #000080);
|
|
403
|
-
font-size: 11px;
|
|
404
|
-
font-weight: bold;
|
|
405
|
-
position: absolute;
|
|
406
|
-
top: -3px;
|
|
407
|
-
right: -1px;
|
|
408
|
-
}
|
|
409
|
-
.boolean-checkbox:hover {
|
|
410
|
-
transform: scale(1.2);
|
|
411
|
-
border-color: var(--control-color, #000080);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
.collapse-button {
|
|
415
|
-
background: transparent;
|
|
416
|
-
border: none;
|
|
417
|
-
color: var(--json-punct, #a9b7c6);
|
|
418
|
-
font-size: 10px;
|
|
419
|
-
display: flex;
|
|
420
|
-
align-items: center;
|
|
421
|
-
justify-content: center;
|
|
422
|
-
user-select: none;
|
|
423
|
-
opacity: 0;
|
|
424
|
-
transition: opacity 0.15s;
|
|
425
|
-
}
|
|
426
|
-
.collapse-button.collapsed {
|
|
427
|
-
opacity: 1;
|
|
428
|
-
}
|
|
429
|
-
.gutter:hover .collapse-button {
|
|
430
|
-
opacity: 1;
|
|
431
|
-
}
|
|
432
|
-
.collapse-button:hover {
|
|
433
|
-
transform: scale(1.2);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/* Touch devices: always show collapse buttons */
|
|
437
|
-
@media (hover: none), (pointer: coarse) {
|
|
438
|
-
.collapse-button {
|
|
439
|
-
opacity: 1;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
.visibility-button {
|
|
444
|
-
width: 14px;
|
|
445
|
-
height: 14px;
|
|
446
|
-
background: transparent;
|
|
447
|
-
color: var(--control-color, #000080);
|
|
448
|
-
border: none;
|
|
449
|
-
cursor: pointer;
|
|
450
|
-
display: flex;
|
|
451
|
-
align-items: center;
|
|
452
|
-
justify-content: center;
|
|
453
|
-
transition: all 0.1s;
|
|
454
|
-
flex-shrink: 0;
|
|
455
|
-
opacity: 0.7;
|
|
456
|
-
padding: 0;
|
|
457
|
-
font-size: 11px;
|
|
458
|
-
}
|
|
459
|
-
.visibility-button:hover { opacity: 1; transform: scale(1.15); }
|
|
460
|
-
.visibility-button.hidden { opacity: 0.35; }
|
|
461
|
-
|
|
462
|
-
.line-hidden { opacity: 0.35; filter: grayscale(50%); }
|
|
463
|
-
|
|
464
|
-
.editor-content {
|
|
465
|
-
position: relative;
|
|
466
|
-
flex: 1;
|
|
467
|
-
overflow: hidden;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
.highlight-layer, textarea, .placeholder-layer {
|
|
471
|
-
position: absolute;
|
|
472
|
-
inset: 0;
|
|
473
|
-
padding: 8px 12px;
|
|
474
|
-
white-space: pre;
|
|
475
|
-
word-wrap: normal;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
.highlight-layer {
|
|
479
|
-
overflow: hidden;
|
|
480
|
-
pointer-events: none;
|
|
481
|
-
z-index: 1;
|
|
482
|
-
color: var(--text-color, #000);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
textarea {
|
|
486
|
-
margin: 0;
|
|
487
|
-
border: none;
|
|
488
|
-
outline: none;
|
|
489
|
-
background: transparent;
|
|
490
|
-
color: transparent;
|
|
491
|
-
caret-color: var(--caret-color, #000);
|
|
492
|
-
resize: none;
|
|
493
|
-
overflow: auto;
|
|
494
|
-
z-index: 2;
|
|
495
|
-
}
|
|
496
|
-
textarea::selection { background: rgba(51,153,255,0.3); }
|
|
497
|
-
textarea::placeholder { color: transparent; }
|
|
498
|
-
textarea:disabled { cursor: not-allowed; opacity: 0.6; }
|
|
499
|
-
|
|
500
|
-
.placeholder-layer {
|
|
501
|
-
color: #6a6a6a;
|
|
502
|
-
pointer-events: none;
|
|
503
|
-
z-index: 0;
|
|
504
|
-
overflow: hidden;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
.json-key { color: var(--json-key, #660e7a); }
|
|
508
|
-
.json-string { color: var(--json-string, #008000); }
|
|
509
|
-
.json-number { color: var(--json-number, #00f); }
|
|
510
|
-
.json-boolean, .json-null { color: var(--json-boolean, #000080); }
|
|
511
|
-
.json-punctuation { color: var(--json-punct, #000); }
|
|
512
|
-
.json-key-invalid { color: var(--json-key-invalid, #f00); }
|
|
513
|
-
|
|
514
|
-
.geojson-key { color: var(--geojson-key, #660e7a); font-weight: 600; }
|
|
515
|
-
.geojson-type { color: var(--geojson-type, #008000); font-weight: 600; }
|
|
516
|
-
.geojson-type-invalid { color: var(--geojson-type-invalid, #f00); font-weight: 600; }
|
|
517
|
-
|
|
518
|
-
.prefix-wrapper, .suffix-wrapper {
|
|
519
|
-
display: flex;
|
|
520
|
-
flex-shrink: 0;
|
|
521
|
-
background: var(--bg-color, #fff);
|
|
522
|
-
}
|
|
283
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
284
|
+
if (oldValue === newValue) return;
|
|
523
285
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
286
|
+
switch (name) {
|
|
287
|
+
case 'value':
|
|
288
|
+
this.setValue(newValue);
|
|
289
|
+
break;
|
|
290
|
+
case 'readonly':
|
|
291
|
+
this.updateReadonly();
|
|
292
|
+
break;
|
|
293
|
+
case 'placeholder':
|
|
294
|
+
this.updatePlaceholderContent();
|
|
295
|
+
break;
|
|
296
|
+
case 'dark-selector':
|
|
297
|
+
this.updateThemeCSS();
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ========== Properties ==========
|
|
303
|
+
get readonly() { return this.hasAttribute('readonly'); }
|
|
304
|
+
get value() { return this.getAttribute('value') || ''; }
|
|
305
|
+
get placeholder() { return this.getAttribute('placeholder') || ''; }
|
|
306
|
+
get prefix() { return '{"type": "FeatureCollection", "features": ['; }
|
|
307
|
+
get suffix() { return ']}'; }
|
|
308
|
+
|
|
309
|
+
// ========== Initial Render ==========
|
|
310
|
+
render() {
|
|
311
|
+
const styleEl = document.createElement('style');
|
|
312
|
+
styleEl.textContent = styles;
|
|
313
|
+
|
|
314
|
+
const template = document.createElement('div');
|
|
315
|
+
template.innerHTML = getTemplate(this.placeholder);
|
|
316
|
+
|
|
317
|
+
this.shadowRoot.innerHTML = '';
|
|
318
|
+
this.shadowRoot.appendChild(styleEl);
|
|
319
|
+
while (template.firstChild) {
|
|
320
|
+
this.shadowRoot.appendChild(template.firstChild);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
530
323
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
324
|
+
// ========== Event Listeners ==========
|
|
325
|
+
setupEventListeners() {
|
|
326
|
+
const hiddenTextarea = this.shadowRoot.getElementById('hiddenTextarea');
|
|
327
|
+
const viewport = this.shadowRoot.getElementById('viewport');
|
|
328
|
+
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
329
|
+
const gutter = this.shadowRoot.querySelector('.gutter');
|
|
330
|
+
const clearBtn = this.shadowRoot.getElementById('clearBtn');
|
|
331
|
+
const editorWrapper = this.shadowRoot.querySelector('.editor-wrapper');
|
|
332
|
+
|
|
333
|
+
// Mouse selection state
|
|
334
|
+
this._isSelecting = false;
|
|
335
|
+
|
|
336
|
+
// Focus hidden textarea when clicking viewport
|
|
337
|
+
// Editor inline control clicks (color swatches, checkboxes, visibility icons)
|
|
338
|
+
// Use capture phase to intercept before mousedown
|
|
339
|
+
viewport.addEventListener('click', (e) => {
|
|
340
|
+
this.handleEditorClick(e);
|
|
341
|
+
}, true);
|
|
342
|
+
|
|
343
|
+
viewport.addEventListener('mousedown', (e) => {
|
|
344
|
+
// Skip if clicking on visibility pseudo-element (line-level)
|
|
345
|
+
const lineEl = e.target.closest('.line.has-visibility');
|
|
346
|
+
if (lineEl) {
|
|
347
|
+
const rect = lineEl.getBoundingClientRect();
|
|
348
|
+
const clickX = e.clientX - rect.left;
|
|
349
|
+
if (clickX < 14) {
|
|
350
|
+
return;
|
|
540
351
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
border: none;
|
|
552
|
-
color: var(--text-color, #000);
|
|
553
|
-
opacity: 0.3;
|
|
554
|
-
cursor: pointer;
|
|
555
|
-
font-size: 0.65rem;
|
|
556
|
-
width: 1rem;
|
|
557
|
-
height: 1rem;
|
|
558
|
-
padding: 0.15rem 0 0 0;
|
|
559
|
-
border-radius: 3px;
|
|
560
|
-
display: flex;
|
|
561
|
-
align-items: center;
|
|
562
|
-
justify-content: center;
|
|
563
|
-
transition: opacity 0.2s, background 0.2s;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Skip if clicking on an inline control pseudo-element (positioned with negative left)
|
|
355
|
+
if (e.target.classList.contains('json-color') ||
|
|
356
|
+
e.target.classList.contains('json-boolean')) {
|
|
357
|
+
const rect = e.target.getBoundingClientRect();
|
|
358
|
+
const clickX = e.clientX - rect.left;
|
|
359
|
+
// Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
|
|
360
|
+
if (clickX < 0 && clickX >= -8) {
|
|
361
|
+
return;
|
|
564
362
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Prevent default to avoid losing focus after click
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
|
|
368
|
+
// Calculate click position
|
|
369
|
+
const pos = this._getPositionFromClick(e);
|
|
370
|
+
|
|
371
|
+
if (e.shiftKey && this.selectionStart) {
|
|
372
|
+
// Shift+click: extend selection
|
|
373
|
+
this.selectionEnd = pos;
|
|
374
|
+
this.cursorLine = pos.line;
|
|
375
|
+
this.cursorColumn = pos.column;
|
|
376
|
+
} else {
|
|
377
|
+
// Normal click: start new selection
|
|
378
|
+
this.cursorLine = pos.line;
|
|
379
|
+
this.cursorColumn = pos.column;
|
|
380
|
+
this.selectionStart = { line: pos.line, column: pos.column };
|
|
381
|
+
this.selectionEnd = null;
|
|
382
|
+
this._isSelecting = true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Focus textarea
|
|
386
|
+
hiddenTextarea.focus();
|
|
387
|
+
this._lastStartIndex = -1;
|
|
388
|
+
this.scheduleRender();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Mouse move for drag selection
|
|
392
|
+
viewport.addEventListener('mousemove', (e) => {
|
|
393
|
+
if (!this._isSelecting) return;
|
|
394
|
+
|
|
395
|
+
const pos = this._getPositionFromClick(e);
|
|
396
|
+
this.selectionEnd = pos;
|
|
397
|
+
this.cursorLine = pos.line;
|
|
398
|
+
this.cursorColumn = pos.column;
|
|
399
|
+
|
|
400
|
+
// Auto-scroll when near edges
|
|
401
|
+
const rect = viewport.getBoundingClientRect();
|
|
402
|
+
const scrollMargin = 30; // pixels from edge to start scrolling
|
|
403
|
+
const scrollSpeed = 20; // pixels to scroll per frame
|
|
404
|
+
|
|
405
|
+
if (e.clientY < rect.top + scrollMargin) {
|
|
406
|
+
// Near top edge, scroll up
|
|
407
|
+
viewport.scrollTop -= scrollSpeed;
|
|
408
|
+
} else if (e.clientY > rect.bottom - scrollMargin) {
|
|
409
|
+
// Near bottom edge, scroll down
|
|
410
|
+
viewport.scrollTop += scrollSpeed;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
this._lastStartIndex = -1;
|
|
414
|
+
this.scheduleRender();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Mouse up to end selection
|
|
418
|
+
document.addEventListener('mouseup', () => {
|
|
419
|
+
this._isSelecting = false;
|
|
420
|
+
});
|
|
603
421
|
|
|
604
|
-
|
|
605
|
-
|
|
422
|
+
// Focus/blur handling to show/hide cursor
|
|
423
|
+
hiddenTextarea.addEventListener('focus', () => {
|
|
424
|
+
editorWrapper.classList.add('focused');
|
|
425
|
+
this._lastStartIndex = -1; // Force re-render to show cursor
|
|
426
|
+
this.scheduleRender();
|
|
427
|
+
});
|
|
606
428
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
// Sync scroll between textarea and highlight layer
|
|
612
|
-
textarea.addEventListener('scroll', () => {
|
|
613
|
-
highlightLayer.scrollTop = textarea.scrollTop;
|
|
614
|
-
highlightLayer.scrollLeft = textarea.scrollLeft;
|
|
615
|
-
this.syncGutterScroll(textarea.scrollTop);
|
|
429
|
+
hiddenTextarea.addEventListener('blur', () => {
|
|
430
|
+
editorWrapper.classList.remove('focused');
|
|
431
|
+
this._lastStartIndex = -1; // Force re-render to hide cursor
|
|
432
|
+
this.scheduleRender();
|
|
616
433
|
});
|
|
617
434
|
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
this.
|
|
628
|
-
|
|
629
|
-
|
|
435
|
+
// Scroll handling
|
|
436
|
+
let isRendering = false;
|
|
437
|
+
viewport.addEventListener('scroll', () => {
|
|
438
|
+
if (isRendering) return;
|
|
439
|
+
this.scrollTop = viewport.scrollTop;
|
|
440
|
+
this.syncGutterScroll();
|
|
441
|
+
|
|
442
|
+
// Use requestAnimationFrame to batch scroll updates
|
|
443
|
+
if (!this._scrollRaf) {
|
|
444
|
+
this._scrollRaf = requestAnimationFrame(() => {
|
|
445
|
+
this._scrollRaf = null;
|
|
446
|
+
isRendering = true;
|
|
447
|
+
this.renderViewport();
|
|
448
|
+
isRendering = false;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
630
451
|
});
|
|
631
452
|
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
clearTimeout(this.highlightTimer);
|
|
636
|
-
|
|
637
|
-
// Use a short delay to let the paste complete
|
|
638
|
-
setTimeout(() => {
|
|
639
|
-
this.updatePlaceholderVisibility();
|
|
640
|
-
// Auto-format JSON content
|
|
641
|
-
this.autoFormatContentWithCursor();
|
|
642
|
-
this.updateHighlight();
|
|
643
|
-
this.emitChange();
|
|
644
|
-
// Auto-collapse coordinates after paste
|
|
645
|
-
this.applyAutoCollapsed();
|
|
646
|
-
}, 10);
|
|
453
|
+
// Input handling (hidden textarea)
|
|
454
|
+
hiddenTextarea.addEventListener('input', () => {
|
|
455
|
+
this.handleInput();
|
|
647
456
|
});
|
|
648
457
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
// Check for visibility button (may click on SVG inside button)
|
|
653
|
-
const visibilityButton = e.target.closest('.visibility-button');
|
|
654
|
-
if (visibilityButton) {
|
|
655
|
-
const featureKey = visibilityButton.dataset.featureKey;
|
|
656
|
-
this.toggleFeatureVisibility(featureKey);
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
458
|
+
hiddenTextarea.addEventListener('keydown', (e) => {
|
|
459
|
+
this.handleKeydown(e);
|
|
460
|
+
});
|
|
659
461
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const attributeName = e.target.dataset.attributeName;
|
|
664
|
-
this.showColorPicker(e.target, line, color, attributeName);
|
|
665
|
-
} else if (e.target.classList.contains('boolean-checkbox')) {
|
|
666
|
-
const line = parseInt(e.target.dataset.line);
|
|
667
|
-
const attributeName = e.target.dataset.attributeName;
|
|
668
|
-
const newValue = e.target.checked;
|
|
669
|
-
this.updateBooleanValue(line, newValue, attributeName);
|
|
670
|
-
} else if (e.target.classList.contains('collapse-button')) {
|
|
671
|
-
const nodeKey = e.target.dataset.nodeKey;
|
|
672
|
-
const line = parseInt(e.target.dataset.line);
|
|
673
|
-
this.toggleCollapse(nodeKey, line);
|
|
674
|
-
}
|
|
462
|
+
// Paste handling
|
|
463
|
+
hiddenTextarea.addEventListener('paste', (e) => {
|
|
464
|
+
this.handlePaste(e);
|
|
675
465
|
});
|
|
676
466
|
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
e.preventDefault();
|
|
681
|
-
textarea.scrollTop += e.deltaY;
|
|
467
|
+
// Copy handling
|
|
468
|
+
hiddenTextarea.addEventListener('copy', (e) => {
|
|
469
|
+
this.handleCopy(e);
|
|
682
470
|
});
|
|
683
471
|
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
this.
|
|
472
|
+
// Cut handling
|
|
473
|
+
hiddenTextarea.addEventListener('cut', (e) => {
|
|
474
|
+
this.handleCut(e);
|
|
687
475
|
});
|
|
688
476
|
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
this.
|
|
477
|
+
// Gutter interactions
|
|
478
|
+
gutterContent.addEventListener('click', (e) => {
|
|
479
|
+
this.handleGutterClick(e);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Prevent gutter from stealing focus
|
|
483
|
+
gutter.addEventListener('mousedown', (e) => {
|
|
484
|
+
e.preventDefault();
|
|
692
485
|
});
|
|
693
486
|
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
487
|
+
// Wheel on gutter -> scroll viewport
|
|
488
|
+
gutter.addEventListener('wheel', (e) => {
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
viewport.scrollTop += e.deltaY;
|
|
697
491
|
});
|
|
698
492
|
|
|
699
493
|
// Clear button
|
|
700
|
-
const clearBtn = this.shadowRoot.getElementById('clearBtn');
|
|
701
494
|
clearBtn.addEventListener('click', () => {
|
|
702
495
|
this.removeAll();
|
|
703
496
|
});
|
|
704
497
|
|
|
705
|
-
//
|
|
498
|
+
// Initial readonly state
|
|
706
499
|
this.updateReadonly();
|
|
707
500
|
}
|
|
708
501
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
502
|
+
// ========== Model Operations ==========
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Set the editor content from a string value
|
|
506
|
+
*/
|
|
507
|
+
setValue(value) {
|
|
508
|
+
if (!value || !value.trim()) {
|
|
509
|
+
this.lines = [];
|
|
510
|
+
} else {
|
|
511
|
+
// Try to format JSON
|
|
512
|
+
try {
|
|
513
|
+
const wrapped = '[' + value + ']';
|
|
514
|
+
const parsed = JSON.parse(wrapped);
|
|
515
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
516
|
+
const lines = formatted.split('\n');
|
|
517
|
+
// Remove wrapper brackets
|
|
518
|
+
this.lines = lines.slice(1, -1);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
// Invalid JSON, use as-is
|
|
521
|
+
this.lines = value.split('\n');
|
|
522
|
+
}
|
|
718
523
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
524
|
+
|
|
525
|
+
// Clear state for new content
|
|
526
|
+
this.collapsedNodes.clear();
|
|
527
|
+
this.hiddenFeatures.clear();
|
|
528
|
+
this._lineToNodeId.clear();
|
|
529
|
+
this._nodeIdToLines.clear();
|
|
530
|
+
this.cursorLine = 0;
|
|
531
|
+
this.cursorColumn = 0;
|
|
532
|
+
|
|
533
|
+
this.updateModel();
|
|
534
|
+
this.scheduleRender();
|
|
535
|
+
this.updatePlaceholderVisibility();
|
|
536
|
+
|
|
537
|
+
// Auto-collapse coordinates
|
|
538
|
+
if (this.lines.length > 0) {
|
|
539
|
+
requestAnimationFrame(() => {
|
|
540
|
+
this.autoCollapseCoordinates();
|
|
541
|
+
});
|
|
723
542
|
}
|
|
543
|
+
|
|
544
|
+
this.emitChange();
|
|
724
545
|
}
|
|
725
546
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
.replace(R.lessThan, '<')
|
|
732
|
-
.replace(R.greaterThan, '>');
|
|
547
|
+
/**
|
|
548
|
+
* Get full content as string (expanded, no hidden markers)
|
|
549
|
+
*/
|
|
550
|
+
getContent() {
|
|
551
|
+
return this.lines.join('\n');
|
|
733
552
|
}
|
|
734
553
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Update derived state from model
|
|
556
|
+
* Rebuilds line-to-nodeId mapping while preserving collapsed state
|
|
557
|
+
*/
|
|
558
|
+
updateModel() {
|
|
559
|
+
// Rebuild lineToNodeId mapping (may shift due to edits)
|
|
560
|
+
this._rebuildNodeIdMappings();
|
|
561
|
+
|
|
562
|
+
this.computeFeatureRanges();
|
|
563
|
+
this.computeLineMetadata();
|
|
564
|
+
this.computeVisibleLines();
|
|
741
565
|
}
|
|
742
566
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
this.
|
|
567
|
+
/**
|
|
568
|
+
* Update view state without rebuilding nodeId mappings
|
|
569
|
+
* Used for collapse/expand operations where content doesn't change
|
|
570
|
+
*/
|
|
571
|
+
updateView() {
|
|
572
|
+
this.computeLineMetadata();
|
|
573
|
+
this.computeVisibleLines();
|
|
749
574
|
}
|
|
750
575
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
576
|
+
/**
|
|
577
|
+
* Compute feature ranges (which lines belong to which feature)
|
|
578
|
+
*/
|
|
579
|
+
computeFeatureRanges() {
|
|
580
|
+
this.featureRanges.clear();
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
const content = this.lines.join('\n');
|
|
584
|
+
const fullValue = this.prefix + content + this.suffix;
|
|
585
|
+
const parsed = JSON.parse(fullValue);
|
|
586
|
+
|
|
587
|
+
if (!parsed.features) return;
|
|
588
|
+
|
|
589
|
+
let featureIndex = 0;
|
|
590
|
+
let braceDepth = 0;
|
|
591
|
+
let inFeature = false;
|
|
592
|
+
let featureStartLine = -1;
|
|
593
|
+
let currentFeatureKey = null;
|
|
594
|
+
|
|
595
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
596
|
+
const line = this.lines[i];
|
|
597
|
+
|
|
598
|
+
if (!inFeature && /"type"\s*:\s*"Feature"/.test(line)) {
|
|
599
|
+
// Find opening brace
|
|
600
|
+
let startLine = i;
|
|
601
|
+
for (let j = i; j >= 0; j--) {
|
|
602
|
+
const trimmed = this.lines[j].trim();
|
|
603
|
+
if (trimmed === '{' || trimmed === '{,') {
|
|
604
|
+
startLine = j;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
featureStartLine = startLine;
|
|
609
|
+
inFeature = true;
|
|
610
|
+
braceDepth = 1;
|
|
611
|
+
|
|
612
|
+
// Count braces from start to current line
|
|
613
|
+
for (let k = startLine; k <= i; k++) {
|
|
614
|
+
const counts = this._countBrackets(this.lines[k], '{');
|
|
615
|
+
if (k === startLine) {
|
|
616
|
+
braceDepth += (counts.open - 1) - counts.close;
|
|
617
|
+
} else {
|
|
618
|
+
braceDepth += counts.open - counts.close;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (featureIndex < parsed.features.length) {
|
|
623
|
+
currentFeatureKey = this._getFeatureKey(parsed.features[featureIndex]);
|
|
624
|
+
}
|
|
625
|
+
} else if (inFeature) {
|
|
626
|
+
const counts = this._countBrackets(line, '{');
|
|
627
|
+
braceDepth += counts.open - counts.close;
|
|
628
|
+
|
|
629
|
+
if (braceDepth <= 0) {
|
|
630
|
+
if (currentFeatureKey) {
|
|
631
|
+
this.featureRanges.set(currentFeatureKey, {
|
|
632
|
+
startLine: featureStartLine,
|
|
633
|
+
endLine: i,
|
|
634
|
+
featureIndex
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
featureIndex++;
|
|
638
|
+
inFeature = false;
|
|
639
|
+
currentFeatureKey = null;
|
|
770
640
|
}
|
|
771
|
-
} catch (e) {
|
|
772
|
-
// Invalid JSON, keep as-is
|
|
773
641
|
}
|
|
774
642
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
this.updatePlaceholderVisibility();
|
|
778
|
-
|
|
779
|
-
// Auto-collapse coordinates nodes after value is set
|
|
780
|
-
if (textarea.value) {
|
|
781
|
-
requestAnimationFrame(() => {
|
|
782
|
-
this.applyAutoCollapsed();
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// Emit change/error event for programmatic value changes
|
|
787
|
-
this.emitChange();
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
updatePrefixSuffix() {
|
|
792
|
-
const prefixEl = this.shadowRoot.getElementById('editorPrefix');
|
|
793
|
-
const suffixEl = this.shadowRoot.getElementById('editorSuffix');
|
|
794
|
-
|
|
795
|
-
// Always show prefix/suffix (always in FeatureCollection mode)
|
|
796
|
-
if (prefixEl) {
|
|
797
|
-
prefixEl.textContent = this.prefix;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
if (suffixEl) {
|
|
801
|
-
suffixEl.textContent = this.suffix;
|
|
643
|
+
} catch (e) {
|
|
644
|
+
// Invalid JSON
|
|
802
645
|
}
|
|
803
646
|
}
|
|
804
647
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
// Update gutter with color indicators
|
|
828
|
-
this.updateGutter();
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
highlightJSON(text, hiddenRanges = []) {
|
|
832
|
-
if (!text.trim()) {
|
|
833
|
-
return { highlighted: '', colors: [], booleans: [], toggles: [] };
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const lines = text.split('\n');
|
|
837
|
-
const colors = [];
|
|
838
|
-
const booleans = [];
|
|
839
|
-
const toggles = [];
|
|
840
|
-
let highlightedLines = [];
|
|
841
|
-
|
|
842
|
-
// Build context map for validation
|
|
843
|
-
const contextMap = this.buildContextMap(text);
|
|
844
|
-
|
|
845
|
-
// Helper to check if a line is in a hidden range
|
|
846
|
-
const isLineHidden = (lineIndex) => {
|
|
847
|
-
return hiddenRanges.some(range => lineIndex >= range.startLine && lineIndex <= range.endLine);
|
|
848
|
-
};
|
|
849
|
-
|
|
850
|
-
lines.forEach((line, lineIndex) => {
|
|
851
|
-
// Detect any hex color (6 digits) in string values
|
|
852
|
-
const R = GeoJsonEditor.REGEX;
|
|
853
|
-
R.colorInLine.lastIndex = 0; // Reset for global regex
|
|
648
|
+
/**
|
|
649
|
+
* Compute metadata for each line (colors, booleans, collapse buttons, etc.)
|
|
650
|
+
*/
|
|
651
|
+
computeLineMetadata() {
|
|
652
|
+
this.lineMetadata.clear();
|
|
653
|
+
|
|
654
|
+
const collapsibleRanges = this._findCollapsibleRanges();
|
|
655
|
+
|
|
656
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
657
|
+
const line = this.lines[i];
|
|
658
|
+
const meta = {
|
|
659
|
+
colors: [],
|
|
660
|
+
booleans: [],
|
|
661
|
+
collapseButton: null,
|
|
662
|
+
visibilityButton: null,
|
|
663
|
+
isHidden: false,
|
|
664
|
+
isCollapsed: false,
|
|
665
|
+
featureKey: null
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// Detect colors
|
|
669
|
+
const colorRegex = /"([\w-]+)"\s*:\s*"(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))"/g;
|
|
854
670
|
let colorMatch;
|
|
855
|
-
while ((colorMatch =
|
|
856
|
-
colors.push({
|
|
857
|
-
line: lineIndex,
|
|
858
|
-
color: colorMatch[2], // The hex color
|
|
859
|
-
attributeName: colorMatch[1] // The attribute name
|
|
860
|
-
});
|
|
671
|
+
while ((colorMatch = colorRegex.exec(line)) !== null) {
|
|
672
|
+
meta.colors.push({ attributeName: colorMatch[1], color: colorMatch[2] });
|
|
861
673
|
}
|
|
862
|
-
|
|
863
|
-
// Detect
|
|
864
|
-
|
|
865
|
-
let
|
|
866
|
-
while ((
|
|
867
|
-
booleans.push({
|
|
868
|
-
line: lineIndex,
|
|
869
|
-
value: booleanMatch[2] === 'true', // The boolean value
|
|
870
|
-
attributeName: booleanMatch[1] // The attribute name
|
|
871
|
-
});
|
|
674
|
+
|
|
675
|
+
// Detect booleans
|
|
676
|
+
const boolRegex = /"([\w-]+)"\s*:\s*(true|false)/g;
|
|
677
|
+
let boolMatch;
|
|
678
|
+
while ((boolMatch = boolRegex.exec(line)) !== null) {
|
|
679
|
+
meta.booleans.push({ attributeName: boolMatch[1], value: boolMatch[2] === 'true' });
|
|
872
680
|
}
|
|
873
|
-
|
|
874
|
-
//
|
|
875
|
-
const
|
|
876
|
-
if (
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
if (isCollapsed) {
|
|
883
|
-
// It's collapsed, always show button
|
|
884
|
-
toggles.push({
|
|
885
|
-
line: lineIndex,
|
|
886
|
-
nodeKey,
|
|
887
|
-
isCollapsed: true
|
|
888
|
-
});
|
|
889
|
-
} else {
|
|
890
|
-
// Not collapsed - only add toggle button if it doesn't close on same line
|
|
891
|
-
if (!this.bracketClosesOnSameLine(line, nodeMatch[3])) {
|
|
892
|
-
toggles.push({
|
|
893
|
-
line: lineIndex,
|
|
894
|
-
nodeKey,
|
|
895
|
-
isCollapsed: false
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
}
|
|
681
|
+
|
|
682
|
+
// Check if line starts a collapsible node
|
|
683
|
+
const collapsible = collapsibleRanges.find(r => r.startLine === i);
|
|
684
|
+
if (collapsible) {
|
|
685
|
+
meta.collapseButton = {
|
|
686
|
+
nodeKey: collapsible.nodeKey,
|
|
687
|
+
nodeId: collapsible.nodeId,
|
|
688
|
+
isCollapsed: this.collapsedNodes.has(collapsible.nodeId)
|
|
689
|
+
};
|
|
899
690
|
}
|
|
900
|
-
|
|
901
|
-
//
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
highlightedLine = `<span class="line-hidden">${highlightedLine}</span>`;
|
|
691
|
+
|
|
692
|
+
// Check if line is inside a collapsed node (exclude closing bracket line)
|
|
693
|
+
const insideCollapsed = collapsibleRanges.find(r =>
|
|
694
|
+
this.collapsedNodes.has(r.nodeId) && i > r.startLine && i < r.endLine
|
|
695
|
+
);
|
|
696
|
+
if (insideCollapsed) {
|
|
697
|
+
meta.isCollapsed = true;
|
|
908
698
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
booleans,
|
|
917
|
-
toggles
|
|
918
|
-
};
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// GeoJSON type constants (consolidated)
|
|
922
|
-
static GEOJSON = {
|
|
923
|
-
GEOMETRY_TYPES: ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'],
|
|
924
|
-
};
|
|
925
|
-
|
|
926
|
-
// Valid keys per context (null = any key is valid)
|
|
927
|
-
static VALID_KEYS_BY_CONTEXT = {
|
|
928
|
-
Feature: ['type', 'geometry', 'properties', 'id'],
|
|
929
|
-
properties: null, // Any key valid in properties
|
|
930
|
-
geometry: ['type', 'coordinates'], // Generic geometry context
|
|
931
|
-
};
|
|
932
|
-
|
|
933
|
-
// Keys that change context for their value
|
|
934
|
-
static CONTEXT_CHANGING_KEYS = {
|
|
935
|
-
geometry: 'geometry',
|
|
936
|
-
properties: 'properties',
|
|
937
|
-
features: 'Feature', // Array of Features
|
|
938
|
-
};
|
|
939
|
-
|
|
940
|
-
// Build context map for each line by analyzing JSON structure
|
|
941
|
-
buildContextMap(text) {
|
|
942
|
-
const lines = text.split('\n');
|
|
943
|
-
const contextMap = new Map(); // line index -> context
|
|
944
|
-
const contextStack = []; // Stack of {context, isArray}
|
|
945
|
-
let pendingContext = null; // Context for next object/array
|
|
946
|
-
|
|
947
|
-
// Root context is always 'Feature' (always in FeatureCollection mode)
|
|
948
|
-
const rootContext = 'Feature';
|
|
949
|
-
|
|
950
|
-
for (let i = 0; i < lines.length; i++) {
|
|
951
|
-
const line = lines[i];
|
|
952
|
-
|
|
953
|
-
// Record context at START of line (for key validation)
|
|
954
|
-
const lineContext = contextStack.length > 0
|
|
955
|
-
? contextStack[contextStack.length - 1]?.context
|
|
956
|
-
: rootContext;
|
|
957
|
-
contextMap.set(i, lineContext);
|
|
958
|
-
|
|
959
|
-
// Process each character to track brackets for subsequent lines
|
|
960
|
-
// Track string state to ignore brackets inside strings
|
|
961
|
-
let inString = false;
|
|
962
|
-
let escape = false;
|
|
963
|
-
|
|
964
|
-
for (let j = 0; j < line.length; j++) {
|
|
965
|
-
const char = line[j];
|
|
966
|
-
|
|
967
|
-
// Handle escape sequences
|
|
968
|
-
if (escape) {
|
|
969
|
-
escape = false;
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
if (char === '\\' && inString) {
|
|
973
|
-
escape = true;
|
|
974
|
-
continue;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
// Track string boundaries
|
|
978
|
-
if (char === '"') {
|
|
979
|
-
if (!inString) {
|
|
980
|
-
// Entering string - check for special patterns before toggling
|
|
981
|
-
const keyMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"\s*:/);
|
|
982
|
-
if (keyMatch) {
|
|
983
|
-
const keyName = keyMatch[1];
|
|
984
|
-
if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
|
|
985
|
-
pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
|
|
986
|
-
}
|
|
987
|
-
j += keyMatch[0].length - 1; // Skip past the key
|
|
988
|
-
continue;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Check for type value to refine context: "type": "Point"
|
|
992
|
-
if (contextStack.length > 0) {
|
|
993
|
-
const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
|
|
994
|
-
if (typeMatch) {
|
|
995
|
-
const valueMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
|
996
|
-
const validTypes = ['Feature', ...GeoJsonEditor.GEOJSON.GEOMETRY_TYPES];
|
|
997
|
-
if (valueMatch && validTypes.includes(valueMatch[1])) {
|
|
998
|
-
const currentCtx = contextStack[contextStack.length - 1];
|
|
999
|
-
if (currentCtx) {
|
|
1000
|
-
currentCtx.context = valueMatch[1];
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
// Skip past this string value
|
|
1004
|
-
j += valueMatch ? valueMatch[0].length - 1 : 0;
|
|
1005
|
-
continue;
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
699
|
+
|
|
700
|
+
// Check if line belongs to a hidden feature
|
|
701
|
+
for (const [featureKey, range] of this.featureRanges) {
|
|
702
|
+
if (i >= range.startLine && i <= range.endLine) {
|
|
703
|
+
meta.featureKey = featureKey;
|
|
704
|
+
if (this.hiddenFeatures.has(featureKey)) {
|
|
705
|
+
meta.isHidden = true;
|
|
1008
706
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
// Opening bracket - push context
|
|
1017
|
-
if (char === '{' || char === '[') {
|
|
1018
|
-
let newContext;
|
|
1019
|
-
if (pendingContext) {
|
|
1020
|
-
newContext = pendingContext;
|
|
1021
|
-
pendingContext = null;
|
|
1022
|
-
} else if (contextStack.length === 0) {
|
|
1023
|
-
newContext = rootContext;
|
|
1024
|
-
} else {
|
|
1025
|
-
const parent = contextStack[contextStack.length - 1];
|
|
1026
|
-
if (parent && parent.isArray) {
|
|
1027
|
-
newContext = parent.context;
|
|
1028
|
-
} else {
|
|
1029
|
-
newContext = null;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
contextStack.push({ context: newContext, isArray: char === '[' });
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// Closing bracket - pop context
|
|
1036
|
-
if (char === '}' || char === ']') {
|
|
1037
|
-
if (contextStack.length > 0) {
|
|
1038
|
-
contextStack.pop();
|
|
707
|
+
// Add visibility button only on feature start line
|
|
708
|
+
if (i === range.startLine) {
|
|
709
|
+
meta.visibilityButton = {
|
|
710
|
+
featureKey,
|
|
711
|
+
isHidden: this.hiddenFeatures.has(featureKey)
|
|
712
|
+
};
|
|
1039
713
|
}
|
|
714
|
+
break;
|
|
1040
715
|
}
|
|
1041
716
|
}
|
|
717
|
+
|
|
718
|
+
this.lineMetadata.set(i, meta);
|
|
1042
719
|
}
|
|
1043
|
-
|
|
1044
|
-
return contextMap;
|
|
1045
720
|
}
|
|
1046
721
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
722
|
+
/**
|
|
723
|
+
* Compute which lines are visible (not inside collapsed nodes)
|
|
724
|
+
*/
|
|
725
|
+
computeVisibleLines() {
|
|
726
|
+
this.visibleLines = [];
|
|
727
|
+
|
|
728
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
729
|
+
const meta = this.lineMetadata.get(i);
|
|
730
|
+
if (!meta || !meta.isCollapsed) {
|
|
731
|
+
this.visibleLines.push({
|
|
732
|
+
index: i,
|
|
733
|
+
content: this.lines[i],
|
|
734
|
+
meta
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Reset render cache to force re-render
|
|
740
|
+
this._lastStartIndex = -1;
|
|
741
|
+
this._lastEndIndex = -1;
|
|
742
|
+
this._lastTotalLines = -1;
|
|
743
|
+
}
|
|
1056
744
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
};
|
|
745
|
+
// ========== Rendering ==========
|
|
746
|
+
|
|
747
|
+
scheduleRender() {
|
|
748
|
+
if (this.renderTimer) return;
|
|
749
|
+
this.renderTimer = requestAnimationFrame(() => {
|
|
750
|
+
this.renderTimer = null;
|
|
751
|
+
this.renderViewport();
|
|
752
|
+
});
|
|
753
|
+
}
|
|
1065
754
|
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
755
|
+
renderViewport() {
|
|
756
|
+
const viewport = this.shadowRoot.getElementById('viewport');
|
|
757
|
+
const linesContainer = this.shadowRoot.getElementById('linesContainer');
|
|
758
|
+
const scrollContent = this.shadowRoot.getElementById('scrollContent');
|
|
759
|
+
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
760
|
+
|
|
761
|
+
if (!viewport || !linesContainer) return;
|
|
762
|
+
|
|
763
|
+
this.viewportHeight = viewport.clientHeight;
|
|
764
|
+
|
|
765
|
+
const totalLines = this.visibleLines.length;
|
|
766
|
+
const totalHeight = totalLines * this.lineHeight;
|
|
767
|
+
|
|
768
|
+
// Set total scrollable height (only once or when content changes)
|
|
769
|
+
if (scrollContent) {
|
|
770
|
+
scrollContent.style.height = `${totalHeight}px`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Calculate visible range based on scroll position
|
|
774
|
+
const scrollTop = viewport.scrollTop;
|
|
775
|
+
const firstVisible = Math.floor(scrollTop / this.lineHeight);
|
|
776
|
+
const visibleCount = Math.ceil(this.viewportHeight / this.lineHeight);
|
|
777
|
+
|
|
778
|
+
const startIndex = Math.max(0, firstVisible - this.bufferLines);
|
|
779
|
+
const endIndex = Math.min(totalLines, firstVisible + visibleCount + this.bufferLines);
|
|
780
|
+
|
|
781
|
+
// Skip render if visible range hasn't changed
|
|
782
|
+
if (this._lastStartIndex === startIndex && this._lastEndIndex === endIndex && this._lastTotalLines === totalLines) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
this._lastStartIndex = startIndex;
|
|
786
|
+
this._lastEndIndex = endIndex;
|
|
787
|
+
this._lastTotalLines = totalLines;
|
|
788
|
+
|
|
789
|
+
// Position linesContainer using transform (no layout recalc)
|
|
790
|
+
const offsetY = startIndex * this.lineHeight;
|
|
791
|
+
linesContainer.style.transform = `translateY(${offsetY}px)`;
|
|
792
|
+
|
|
793
|
+
// Build context map for syntax highlighting
|
|
794
|
+
const contextMap = this._buildContextMap();
|
|
795
|
+
|
|
796
|
+
// Check if editor is focused (for cursor display)
|
|
797
|
+
const editorWrapper = this.shadowRoot.querySelector('.editor-wrapper');
|
|
798
|
+
const isFocused = editorWrapper?.classList.contains('focused');
|
|
799
|
+
|
|
800
|
+
// Render visible lines
|
|
801
|
+
const fragment = document.createDocumentFragment();
|
|
802
|
+
|
|
803
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
804
|
+
const lineData = this.visibleLines[i];
|
|
805
|
+
if (!lineData) continue;
|
|
806
|
+
|
|
807
|
+
const lineEl = document.createElement('div');
|
|
808
|
+
lineEl.className = 'line';
|
|
809
|
+
lineEl.dataset.lineIndex = lineData.index;
|
|
810
|
+
|
|
811
|
+
// Add visibility button on line (uses ::before pseudo-element)
|
|
812
|
+
if (lineData.meta?.visibilityButton) {
|
|
813
|
+
lineEl.classList.add('has-visibility');
|
|
814
|
+
lineEl.dataset.featureKey = lineData.meta.visibilityButton.featureKey;
|
|
815
|
+
if (lineData.meta.visibilityButton.isHidden) {
|
|
816
|
+
lineEl.classList.add('feature-hidden');
|
|
817
|
+
}
|
|
1073
818
|
}
|
|
1074
|
-
|
|
1075
|
-
if
|
|
1076
|
-
|
|
819
|
+
|
|
820
|
+
// Add hidden class if feature is hidden
|
|
821
|
+
if (lineData.meta?.isHidden) {
|
|
822
|
+
lineEl.classList.add('line-hidden');
|
|
1077
823
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
.replace(R.lessThan, '<')
|
|
1087
|
-
.replace(R.greaterThan, '>')
|
|
1088
|
-
// All JSON keys - validate against context
|
|
1089
|
-
.replace(R.jsonKey, (_, key) => {
|
|
1090
|
-
// Inside properties - all keys are regular user keys
|
|
1091
|
-
if (context === 'properties') {
|
|
1092
|
-
return `<span class="json-key">"${key}"</span>:`;
|
|
1093
|
-
}
|
|
1094
|
-
// GeoJSON structural keys - highlighted as geojson-key
|
|
1095
|
-
if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) {
|
|
1096
|
-
return `<span class="geojson-key">"${key}"</span>:`;
|
|
1097
|
-
}
|
|
1098
|
-
// Regular key - validate against context
|
|
1099
|
-
if (isKeyValid(key)) {
|
|
1100
|
-
return `<span class="json-key">"${key}"</span>:`;
|
|
1101
|
-
} else {
|
|
1102
|
-
return `<span class="json-key-invalid">"${key}"</span>:`;
|
|
1103
|
-
}
|
|
1104
|
-
})
|
|
1105
|
-
// GeoJSON "type" values - validate based on context
|
|
1106
|
-
.replace(R.typeValue, (_, typeValue) => {
|
|
1107
|
-
if (isTypeValid(typeValue)) {
|
|
1108
|
-
return `<span class="geojson-key">"type"</span>: <span class="geojson-type">"${typeValue}"</span>`;
|
|
1109
|
-
} else {
|
|
1110
|
-
return `<span class="geojson-key">"type"</span>: <span class="geojson-type-invalid">"${typeValue}"</span>`;
|
|
1111
|
-
}
|
|
1112
|
-
})
|
|
1113
|
-
// Generic string values
|
|
1114
|
-
.replace(R.stringValue, (match, value) => {
|
|
1115
|
-
// Skip if already highlighted (has span)
|
|
1116
|
-
if (match.includes('<span')) return match;
|
|
1117
|
-
return `: <span class="json-string">"${value}"</span>`;
|
|
1118
|
-
})
|
|
1119
|
-
.replace(R.numberAfterColon, ': <span class="json-number">$1</span>')
|
|
1120
|
-
.replace(R.boolean, ': <span class="json-boolean">$1</span>')
|
|
1121
|
-
.replace(R.nullValue, ': <span class="json-null">$1</span>')
|
|
1122
|
-
.replace(R.allNumbers, '<span class="json-number">$1</span>')
|
|
1123
|
-
.replace(R.punctuation, '<span class="json-punctuation">$1</span>');
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
toggleCollapse(nodeKey, line) {
|
|
1127
|
-
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1128
|
-
const lines = textarea.value.split('\n');
|
|
1129
|
-
const currentLine = lines[line];
|
|
1130
|
-
|
|
1131
|
-
// Check if line has collapse marker
|
|
1132
|
-
const hasMarker = currentLine.includes('{...}') || currentLine.includes('[...]');
|
|
1133
|
-
|
|
1134
|
-
if (hasMarker) {
|
|
1135
|
-
// Expand: find the correct collapsed data
|
|
1136
|
-
const currentIndent = currentLine.match(/^(\s*)/)[1].length;
|
|
1137
|
-
const found = this._findCollapsedData(line, nodeKey, currentIndent);
|
|
1138
|
-
|
|
1139
|
-
if (!found) {
|
|
1140
|
-
return;
|
|
824
|
+
|
|
825
|
+
// Highlight syntax and add cursor if this is the cursor line and editor is focused
|
|
826
|
+
const context = contextMap.get(lineData.index);
|
|
827
|
+
let html = this._highlightSyntax(lineData.content, context, lineData.meta);
|
|
828
|
+
|
|
829
|
+
// Add selection highlight if line is in selection
|
|
830
|
+
if (isFocused && this._hasSelection()) {
|
|
831
|
+
html = this._addSelectionHighlight(html, lineData.index, lineData.content);
|
|
1141
832
|
}
|
|
833
|
+
|
|
834
|
+
// Add cursor if this is the cursor line and editor is focused
|
|
835
|
+
if (isFocused && lineData.index === this.cursorLine) {
|
|
836
|
+
html += this._insertCursor(this.cursorColumn);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
lineEl.innerHTML = html;
|
|
840
|
+
|
|
841
|
+
fragment.appendChild(lineEl);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
linesContainer.innerHTML = '';
|
|
845
|
+
linesContainer.appendChild(fragment);
|
|
846
|
+
|
|
847
|
+
// Render gutter with same range
|
|
848
|
+
this.renderGutter(startIndex, endIndex);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Insert cursor element at the specified column position
|
|
853
|
+
* Uses absolute positioning to avoid affecting text layout
|
|
854
|
+
*/
|
|
855
|
+
_insertCursor(column) {
|
|
856
|
+
// Calculate cursor position in pixels using character width
|
|
857
|
+
const charWidth = this._getCharWidth();
|
|
858
|
+
const left = column * charWidth;
|
|
859
|
+
return `<span class="cursor" style="left: ${left}px"></span>`;
|
|
860
|
+
}
|
|
1142
861
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
862
|
+
/**
|
|
863
|
+
* Add selection highlight to a line
|
|
864
|
+
*/
|
|
865
|
+
_addSelectionHighlight(html, lineIndex, content) {
|
|
866
|
+
const { start, end } = this._normalizeSelection();
|
|
867
|
+
if (!start || !end) return html;
|
|
868
|
+
|
|
869
|
+
// Check if this line is in the selection
|
|
870
|
+
if (lineIndex < start.line || lineIndex > end.line) return html;
|
|
871
|
+
|
|
872
|
+
const charWidth = this._getCharWidth();
|
|
873
|
+
let selStart, selEnd;
|
|
874
|
+
|
|
875
|
+
if (lineIndex === start.line && lineIndex === end.line) {
|
|
876
|
+
// Selection is within this line
|
|
877
|
+
selStart = start.column;
|
|
878
|
+
selEnd = end.column;
|
|
879
|
+
} else if (lineIndex === start.line) {
|
|
880
|
+
// Selection starts on this line
|
|
881
|
+
selStart = start.column;
|
|
882
|
+
selEnd = content.length;
|
|
883
|
+
} else if (lineIndex === end.line) {
|
|
884
|
+
// Selection ends on this line
|
|
885
|
+
selStart = 0;
|
|
886
|
+
selEnd = end.column;
|
|
1152
887
|
} else {
|
|
1153
|
-
//
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const indent = match[1];
|
|
1158
|
-
const openBracket = match[3];
|
|
1159
|
-
|
|
1160
|
-
// Use common collapse helper
|
|
1161
|
-
if (this._performCollapse(lines, line, nodeKey, indent, openBracket) === 0) return;
|
|
888
|
+
// Entire line is selected
|
|
889
|
+
selStart = 0;
|
|
890
|
+
selEnd = content.length;
|
|
1162
891
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
892
|
+
|
|
893
|
+
const left = selStart * charWidth;
|
|
894
|
+
const width = (selEnd - selStart) * charWidth;
|
|
895
|
+
|
|
896
|
+
// Add selection overlay
|
|
897
|
+
const selectionSpan = `<span class="selection" style="left: ${left}px; width: ${width}px"></span>`;
|
|
898
|
+
return selectionSpan + html;
|
|
1167
899
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
|
|
1179
|
-
|
|
1180
|
-
if (match) {
|
|
1181
|
-
const nodeKey = match[2];
|
|
1182
|
-
|
|
1183
|
-
// Check if this node should be auto-collapsed (coordinates only)
|
|
1184
|
-
if (nodeKey === 'coordinates') {
|
|
1185
|
-
const indent = match[1];
|
|
1186
|
-
const openBracket = match[3];
|
|
1187
|
-
|
|
1188
|
-
// Use common collapse helper
|
|
1189
|
-
this._performCollapse(lines, i, nodeKey, indent, openBracket);
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Get character width for monospace font
|
|
903
|
+
*/
|
|
904
|
+
_getCharWidth() {
|
|
905
|
+
if (!this._charWidth) {
|
|
906
|
+
const canvas = document.createElement('canvas');
|
|
907
|
+
const ctx = canvas.getContext('2d');
|
|
908
|
+
ctx.font = '13px monospace';
|
|
909
|
+
this._charWidth = ctx.measureText('M').width;
|
|
1192
910
|
}
|
|
1193
|
-
|
|
1194
|
-
// Update textarea
|
|
1195
|
-
textarea.value = lines.join('\n');
|
|
1196
|
-
this.updateHighlight();
|
|
911
|
+
return this._charWidth;
|
|
1197
912
|
}
|
|
1198
913
|
|
|
1199
|
-
|
|
1200
|
-
updateGutter() {
|
|
914
|
+
renderGutter(startIndex, endIndex) {
|
|
1201
915
|
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
if (
|
|
1208
|
-
|
|
1209
|
-
this._cachedLineHeight = parseFloat(styles.lineHeight);
|
|
1210
|
-
this._cachedPaddingTop = parseFloat(styles.paddingTop);
|
|
1211
|
-
}
|
|
1212
|
-
const lineHeight = this._cachedLineHeight;
|
|
1213
|
-
const paddingTop = this._cachedPaddingTop;
|
|
1214
|
-
|
|
1215
|
-
// Clear gutter
|
|
1216
|
-
gutterContent.textContent = '';
|
|
1217
|
-
|
|
1218
|
-
// Create a map of line -> elements (color, boolean, collapse button, visibility button)
|
|
1219
|
-
const lineElements = new Map();
|
|
1220
|
-
|
|
1221
|
-
// Helper to ensure line entry exists
|
|
1222
|
-
const ensureLine = (line) => {
|
|
1223
|
-
if (!lineElements.has(line)) {
|
|
1224
|
-
lineElements.set(line, { colors: [], booleans: [], buttons: [], visibilityButtons: [] });
|
|
1225
|
-
}
|
|
1226
|
-
return lineElements.get(line);
|
|
1227
|
-
};
|
|
1228
|
-
|
|
1229
|
-
// Add color indicators
|
|
1230
|
-
this.colorPositions.forEach(({ line, color, attributeName }) => {
|
|
1231
|
-
ensureLine(line).colors.push({ color, attributeName });
|
|
1232
|
-
});
|
|
1233
|
-
|
|
1234
|
-
// Add boolean checkboxes
|
|
1235
|
-
this.booleanPositions.forEach(({ line, value, attributeName }) => {
|
|
1236
|
-
ensureLine(line).booleans.push({ value, attributeName });
|
|
1237
|
-
});
|
|
1238
|
-
|
|
1239
|
-
// Add collapse buttons
|
|
1240
|
-
this.nodeTogglePositions.forEach(({ line, nodeKey, isCollapsed }) => {
|
|
1241
|
-
ensureLine(line).buttons.push({ nodeKey, isCollapsed });
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
// Add visibility buttons for Features (on the opening brace line)
|
|
1245
|
-
for (const [featureKey, range] of this.featureRanges) {
|
|
1246
|
-
const isHidden = this.hiddenFeatures.has(featureKey);
|
|
1247
|
-
ensureLine(range.startLine).visibilityButtons.push({ featureKey, isHidden });
|
|
916
|
+
const gutterScrollContent = this.shadowRoot.getElementById('gutterScrollContent');
|
|
917
|
+
if (!gutterContent) return;
|
|
918
|
+
|
|
919
|
+
// Set total height for gutter scroll
|
|
920
|
+
const totalHeight = this.visibleLines.length * this.lineHeight;
|
|
921
|
+
if (gutterScrollContent) {
|
|
922
|
+
gutterScrollContent.style.height = `${totalHeight}px`;
|
|
1248
923
|
}
|
|
1249
|
-
|
|
1250
|
-
//
|
|
924
|
+
|
|
925
|
+
// Position gutter content using transform
|
|
926
|
+
const offsetY = startIndex * this.lineHeight;
|
|
927
|
+
gutterContent.style.transform = `translateY(${offsetY}px)`;
|
|
928
|
+
|
|
1251
929
|
const fragment = document.createDocumentFragment();
|
|
1252
|
-
|
|
1253
|
-
|
|
930
|
+
|
|
931
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
932
|
+
const lineData = this.visibleLines[i];
|
|
933
|
+
if (!lineData) continue;
|
|
934
|
+
|
|
1254
935
|
const gutterLine = document.createElement('div');
|
|
1255
936
|
gutterLine.className = 'gutter-line';
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
indicator.title = `${attributeName}: ${color}`;
|
|
1277
|
-
gutterLine.appendChild(indicator);
|
|
1278
|
-
});
|
|
1279
|
-
|
|
1280
|
-
// Add boolean checkboxes
|
|
1281
|
-
elements.booleans.forEach(({ value, attributeName }) => {
|
|
1282
|
-
const checkbox = document.createElement('input');
|
|
1283
|
-
checkbox.type = 'checkbox';
|
|
1284
|
-
checkbox.className = 'boolean-checkbox';
|
|
1285
|
-
checkbox.checked = value;
|
|
1286
|
-
checkbox.dataset.line = line;
|
|
1287
|
-
checkbox.dataset.attributeName = attributeName;
|
|
1288
|
-
checkbox.title = `${attributeName}: ${value}`;
|
|
1289
|
-
gutterLine.appendChild(checkbox);
|
|
1290
|
-
});
|
|
1291
|
-
|
|
1292
|
-
// Add collapse buttons
|
|
1293
|
-
elements.buttons.forEach(({ nodeKey, isCollapsed }) => {
|
|
1294
|
-
const button = document.createElement('div');
|
|
1295
|
-
button.className = isCollapsed ? 'collapse-button collapsed' : 'collapse-button';
|
|
1296
|
-
button.textContent = isCollapsed ? GeoJsonEditor.ICONS.collapsed : GeoJsonEditor.ICONS.expanded;
|
|
1297
|
-
button.dataset.line = line;
|
|
1298
|
-
button.dataset.nodeKey = nodeKey;
|
|
1299
|
-
button.title = isCollapsed ? 'Expand' : 'Collapse';
|
|
1300
|
-
gutterLine.appendChild(button);
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
fragment.appendChild(gutterLine);
|
|
1304
|
-
});
|
|
1305
|
-
|
|
1306
|
-
// Single DOM insertion
|
|
1307
|
-
gutterContent.appendChild(fragment);
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
showColorPicker(indicator, line, currentColor, attributeName) {
|
|
1311
|
-
// Remove existing picker and clean up its listener
|
|
1312
|
-
const existing = document.querySelector('.geojson-color-picker-input');
|
|
1313
|
-
if (existing) {
|
|
1314
|
-
// Clean up the stored listener before removing
|
|
1315
|
-
if (existing._closeListener) {
|
|
1316
|
-
document.removeEventListener('click', existing._closeListener, true);
|
|
937
|
+
|
|
938
|
+
const meta = lineData.meta;
|
|
939
|
+
|
|
940
|
+
// Line number first
|
|
941
|
+
const lineNum = document.createElement('span');
|
|
942
|
+
lineNum.className = 'line-number';
|
|
943
|
+
lineNum.textContent = lineData.index + 1;
|
|
944
|
+
gutterLine.appendChild(lineNum);
|
|
945
|
+
|
|
946
|
+
// Collapse column (always present for alignment)
|
|
947
|
+
const collapseCol = document.createElement('div');
|
|
948
|
+
collapseCol.className = 'collapse-column';
|
|
949
|
+
if (meta?.collapseButton) {
|
|
950
|
+
const btn = document.createElement('div');
|
|
951
|
+
btn.className = 'collapse-button' + (meta.collapseButton.isCollapsed ? ' collapsed' : '');
|
|
952
|
+
btn.textContent = meta.collapseButton.isCollapsed ? '›' : '⌄';
|
|
953
|
+
btn.dataset.line = lineData.index;
|
|
954
|
+
btn.dataset.nodeId = meta.collapseButton.nodeId;
|
|
955
|
+
btn.title = meta.collapseButton.isCollapsed ? 'Expand' : 'Collapse';
|
|
956
|
+
collapseCol.appendChild(btn);
|
|
1317
957
|
}
|
|
1318
|
-
|
|
958
|
+
gutterLine.appendChild(collapseCol);
|
|
959
|
+
|
|
960
|
+
fragment.appendChild(gutterLine);
|
|
1319
961
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
colorInput.type = 'color';
|
|
1324
|
-
colorInput.value = currentColor;
|
|
1325
|
-
colorInput.className = 'geojson-color-picker-input';
|
|
1326
|
-
|
|
1327
|
-
// Get indicator position in viewport
|
|
1328
|
-
const rect = indicator.getBoundingClientRect();
|
|
1329
|
-
|
|
1330
|
-
colorInput.style.position = 'fixed';
|
|
1331
|
-
colorInput.style.left = `${rect.left}px`;
|
|
1332
|
-
colorInput.style.top = `${rect.top}px`;
|
|
1333
|
-
colorInput.style.width = '12px';
|
|
1334
|
-
colorInput.style.height = '12px';
|
|
1335
|
-
colorInput.style.opacity = '0.01';
|
|
1336
|
-
colorInput.style.border = 'none';
|
|
1337
|
-
colorInput.style.padding = '0';
|
|
1338
|
-
colorInput.style.zIndex = '9999';
|
|
1339
|
-
|
|
1340
|
-
colorInput.addEventListener('input', (e) => {
|
|
1341
|
-
// User is actively changing the color - update in real-time
|
|
1342
|
-
this.updateColorValue(line, e.target.value, attributeName);
|
|
1343
|
-
});
|
|
1344
|
-
|
|
1345
|
-
colorInput.addEventListener('change', (e) => {
|
|
1346
|
-
// Picker closed with validation
|
|
1347
|
-
this.updateColorValue(line, e.target.value, attributeName);
|
|
1348
|
-
});
|
|
1349
|
-
|
|
1350
|
-
// Close picker when clicking anywhere else
|
|
1351
|
-
const closeOnClickOutside = (e) => {
|
|
1352
|
-
if (e.target !== colorInput && !colorInput.contains(e.target)) {
|
|
1353
|
-
document.removeEventListener('click', closeOnClickOutside, true);
|
|
1354
|
-
colorInput.remove();
|
|
1355
|
-
}
|
|
1356
|
-
};
|
|
1357
|
-
|
|
1358
|
-
// Store the listener reference on the element for cleanup
|
|
1359
|
-
colorInput._closeListener = closeOnClickOutside;
|
|
1360
|
-
|
|
1361
|
-
// Add to document body with fixed positioning
|
|
1362
|
-
document.body.appendChild(colorInput);
|
|
1363
|
-
|
|
1364
|
-
// Add click listener after a short delay to avoid immediate close
|
|
1365
|
-
setTimeout(() => {
|
|
1366
|
-
document.addEventListener('click', closeOnClickOutside, true);
|
|
1367
|
-
}, 100);
|
|
1368
|
-
|
|
1369
|
-
// Open the picker and focus it
|
|
1370
|
-
colorInput.focus();
|
|
1371
|
-
colorInput.click();
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
updateColorValue(line, newColor, attributeName) {
|
|
1375
|
-
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1376
|
-
const lines = textarea.value.split('\n');
|
|
1377
|
-
|
|
1378
|
-
// Replace color value on the specified line for the specific attribute (supports #rgb and #rrggbb)
|
|
1379
|
-
const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})"`);
|
|
1380
|
-
lines[line] = lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
|
|
1381
|
-
|
|
1382
|
-
textarea.value = lines.join('\n');
|
|
1383
|
-
this.updateHighlight();
|
|
1384
|
-
this.emitChange();
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
updateBooleanValue(line, newValue, attributeName) {
|
|
1388
|
-
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1389
|
-
const lines = textarea.value.split('\n');
|
|
1390
|
-
|
|
1391
|
-
// Replace boolean value on the specified line for the specific attribute
|
|
1392
|
-
const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
|
|
1393
|
-
lines[line] = lines[line].replace(regex, `"${attributeName}": ${newValue}`);
|
|
1394
|
-
|
|
1395
|
-
textarea.value = lines.join('\n');
|
|
1396
|
-
this.updateHighlight();
|
|
1397
|
-
this.emitChange();
|
|
962
|
+
|
|
963
|
+
gutterContent.innerHTML = '';
|
|
964
|
+
gutterContent.appendChild(fragment);
|
|
1398
965
|
}
|
|
1399
966
|
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const
|
|
1403
|
-
if (
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
if (e.ctrlKey || e.metaKey) return;
|
|
1407
|
-
|
|
1408
|
-
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1409
|
-
const cursorPos = textarea.selectionStart;
|
|
1410
|
-
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
|
1411
|
-
const currentLineNum = textBeforeCursor.split('\n').length - 1;
|
|
1412
|
-
const lines = textarea.value.split('\n');
|
|
1413
|
-
const currentLine = lines[currentLineNum];
|
|
1414
|
-
|
|
1415
|
-
// Check if current line is collapsed (contains {...} or [...])
|
|
1416
|
-
if (currentLine && (currentLine.includes('{...}') || currentLine.includes('[...]'))) {
|
|
1417
|
-
e.preventDefault();
|
|
967
|
+
syncGutterScroll() {
|
|
968
|
+
const gutterScroll = this.shadowRoot.getElementById('gutterScroll');
|
|
969
|
+
const viewport = this.shadowRoot.getElementById('viewport');
|
|
970
|
+
if (gutterScroll && viewport) {
|
|
971
|
+
// Sync gutter scroll position with viewport
|
|
972
|
+
gutterScroll.scrollTop = viewport.scrollTop;
|
|
1418
973
|
}
|
|
1419
974
|
}
|
|
1420
975
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
return;
|
|
976
|
+
// ========== Input Handling ==========
|
|
977
|
+
|
|
978
|
+
handleInput() {
|
|
979
|
+
const textarea = this.shadowRoot.getElementById('hiddenTextarea');
|
|
980
|
+
const inputValue = textarea.value;
|
|
981
|
+
|
|
982
|
+
if (!inputValue) return;
|
|
983
|
+
|
|
984
|
+
// Block input in hidden collapsed zones
|
|
985
|
+
if (this._getCollapsedRangeForLine(this.cursorLine)) {
|
|
986
|
+
textarea.value = '';
|
|
987
|
+
return;
|
|
1433
988
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
989
|
+
|
|
990
|
+
// On closing line, only allow after bracket
|
|
991
|
+
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
992
|
+
if (onClosingLine) {
|
|
993
|
+
const line = this.lines[this.cursorLine];
|
|
994
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
995
|
+
if (this.cursorColumn <= bracketPos) {
|
|
996
|
+
textarea.value = '';
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
1443
999
|
}
|
|
1444
|
-
|
|
1445
|
-
//
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
const startLineNum = beforeSelection.split('\n').length - 1;
|
|
1454
|
-
const R = GeoJsonEditor.REGEX;
|
|
1455
|
-
|
|
1456
|
-
const lines = text.split('\n');
|
|
1457
|
-
const expandedLines = [];
|
|
1458
|
-
|
|
1459
|
-
lines.forEach((line, relativeLineNum) => {
|
|
1460
|
-
const absoluteLineNum = startLineNum + relativeLineNum;
|
|
1461
|
-
|
|
1462
|
-
// Check if this line has a collapsed marker
|
|
1463
|
-
if (line.includes('{...}') || line.includes('[...]')) {
|
|
1464
|
-
const match = line.match(R.collapsedMarker);
|
|
1465
|
-
if (match) {
|
|
1466
|
-
const nodeKey = match[2];
|
|
1467
|
-
const currentIndent = match[1].length;
|
|
1468
|
-
|
|
1469
|
-
// Try to find collapsed data using helper
|
|
1470
|
-
const found = this._findCollapsedData(absoluteLineNum, nodeKey, currentIndent);
|
|
1471
|
-
if (found) {
|
|
1472
|
-
expandedLines.push(found.data.originalLine);
|
|
1473
|
-
expandedLines.push(...found.data.content);
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
// Fallback: search by nodeKey only (line numbers may have shifted)
|
|
1478
|
-
for (const [, collapsed] of this.collapsedData.entries()) {
|
|
1479
|
-
if (collapsed.nodeKey === nodeKey) {
|
|
1480
|
-
expandedLines.push(collapsed.originalLine);
|
|
1481
|
-
expandedLines.push(...collapsed.content);
|
|
1482
|
-
return;
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
// Line not found in collapsed data, keep as-is
|
|
1488
|
-
expandedLines.push(line);
|
|
1489
|
-
} else {
|
|
1490
|
-
expandedLines.push(line);
|
|
1000
|
+
|
|
1001
|
+
// On collapsed opening line, only allow before bracket
|
|
1002
|
+
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1003
|
+
if (onCollapsed) {
|
|
1004
|
+
const line = this.lines[this.cursorLine];
|
|
1005
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1006
|
+
if (this.cursorColumn > bracketPos) {
|
|
1007
|
+
textarea.value = '';
|
|
1008
|
+
return;
|
|
1491
1009
|
}
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
return expandedLines.join('\n');
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
handleCutWithCollapsedContent(e) {
|
|
1498
|
-
// First copy with expanded content
|
|
1499
|
-
this.handleCopyWithCollapsedContent(e);
|
|
1500
|
-
|
|
1501
|
-
// Then delete the selection normally
|
|
1502
|
-
const textarea = this.shadowRoot.getElementById('textarea');
|
|
1503
|
-
const start = textarea.selectionStart;
|
|
1504
|
-
const end = textarea.selectionEnd;
|
|
1505
|
-
|
|
1506
|
-
if (start !== end) {
|
|
1507
|
-
const value = textarea.value;
|
|
1508
|
-
textarea.value = value.substring(0, start) + value.substring(end);
|
|
1509
|
-
textarea.selectionStart = textarea.selectionEnd = start;
|
|
1510
|
-
this.updateHighlight();
|
|
1511
|
-
this.updatePlaceholderVisibility();
|
|
1512
|
-
this.emitChange();
|
|
1513
1010
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
try {
|
|
1527
|
-
let parsed = JSON.parse(fullValue);
|
|
1528
|
-
|
|
1529
|
-
// Filter out hidden features before emitting
|
|
1530
|
-
parsed = this.filterHiddenFeatures(parsed);
|
|
1531
|
-
|
|
1532
|
-
// Validate GeoJSON types (validate only features, not the wrapper)
|
|
1533
|
-
let validationErrors = [];
|
|
1534
|
-
parsed.features.forEach((feature, index) => {
|
|
1535
|
-
validationErrors.push(...this.validateGeoJSON(feature, `features[${index}]`, 'root'));
|
|
1536
|
-
});
|
|
1537
|
-
|
|
1538
|
-
if (validationErrors.length > 0) {
|
|
1539
|
-
// Emit error event for GeoJSON validation errors
|
|
1540
|
-
this.dispatchEvent(new CustomEvent('error', {
|
|
1541
|
-
detail: {
|
|
1542
|
-
timestamp: new Date().toISOString(),
|
|
1543
|
-
error: `GeoJSON validation: ${validationErrors.join('; ')}`,
|
|
1544
|
-
errors: validationErrors,
|
|
1545
|
-
content: editorContent
|
|
1546
|
-
},
|
|
1547
|
-
bubbles: true,
|
|
1548
|
-
composed: true
|
|
1549
|
-
}));
|
|
1011
|
+
|
|
1012
|
+
// Insert the input at cursor position
|
|
1013
|
+
if (this.cursorLine < this.lines.length) {
|
|
1014
|
+
const line = this.lines[this.cursorLine];
|
|
1015
|
+
const before = line.substring(0, this.cursorColumn);
|
|
1016
|
+
const after = line.substring(this.cursorColumn);
|
|
1017
|
+
|
|
1018
|
+
// Handle newlines in input
|
|
1019
|
+
const inputLines = inputValue.split('\n');
|
|
1020
|
+
if (inputLines.length === 1) {
|
|
1021
|
+
this.lines[this.cursorLine] = before + inputValue + after;
|
|
1022
|
+
this.cursorColumn += inputValue.length;
|
|
1550
1023
|
} else {
|
|
1551
|
-
//
|
|
1552
|
-
this.
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1024
|
+
// Multi-line input
|
|
1025
|
+
this.lines[this.cursorLine] = before + inputLines[0];
|
|
1026
|
+
for (let i = 1; i < inputLines.length - 1; i++) {
|
|
1027
|
+
this.lines.splice(this.cursorLine + i, 0, inputLines[i]);
|
|
1028
|
+
}
|
|
1029
|
+
const lastLine = inputLines[inputLines.length - 1] + after;
|
|
1030
|
+
this.lines.splice(this.cursorLine + inputLines.length - 1, 0, lastLine);
|
|
1031
|
+
this.cursorLine += inputLines.length - 1;
|
|
1032
|
+
this.cursorColumn = inputLines[inputLines.length - 1].length;
|
|
1557
1033
|
}
|
|
1558
|
-
} catch (e) {
|
|
1559
|
-
// Emit error event for invalid JSON
|
|
1560
|
-
this.dispatchEvent(new CustomEvent('error', {
|
|
1561
|
-
detail: {
|
|
1562
|
-
timestamp: new Date().toISOString(),
|
|
1563
|
-
error: e.message,
|
|
1564
|
-
content: editorContent // Raw content for debugging
|
|
1565
|
-
},
|
|
1566
|
-
bubbles: true,
|
|
1567
|
-
composed: true
|
|
1568
|
-
}));
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
// Filter hidden features from parsed GeoJSON before emitting events
|
|
1573
|
-
filterHiddenFeatures(parsed) {
|
|
1574
|
-
if (!parsed || this.hiddenFeatures.size === 0) return parsed;
|
|
1575
|
-
|
|
1576
|
-
// parsed is always a FeatureCollection (from wrapper)
|
|
1577
|
-
const visibleFeatures = parsed.features.filter((feature) => {
|
|
1578
|
-
const key = this.getFeatureKey(feature);
|
|
1579
|
-
return !this.hiddenFeatures.has(key);
|
|
1580
|
-
});
|
|
1581
|
-
return { ...parsed, features: visibleFeatures };
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
// ========== Feature Visibility Management ==========
|
|
1585
|
-
|
|
1586
|
-
// Generate a unique key for a Feature to track visibility state
|
|
1587
|
-
getFeatureKey(feature) {
|
|
1588
|
-
if (!feature || typeof feature !== 'object') return null;
|
|
1589
|
-
|
|
1590
|
-
// 1. Use GeoJSON id if present (most stable)
|
|
1591
|
-
if (feature.id !== undefined) return `id:${feature.id}`;
|
|
1592
|
-
|
|
1593
|
-
// 2. Use properties.id if present
|
|
1594
|
-
if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
|
|
1595
|
-
|
|
1596
|
-
// 3. Fallback: hash based on geometry type + ALL coordinates
|
|
1597
|
-
const geomType = feature.geometry?.type || 'null';
|
|
1598
|
-
const coords = JSON.stringify(feature.geometry?.coordinates || []);
|
|
1599
|
-
return `hash:${geomType}:${this.simpleHash(coords)}`;
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
// Simple hash function for string
|
|
1603
|
-
simpleHash(str) {
|
|
1604
|
-
let hash = 0;
|
|
1605
|
-
for (let i = 0; i < str.length; i++) {
|
|
1606
|
-
const char = str.charCodeAt(i);
|
|
1607
|
-
hash = ((hash << 5) - hash) + char;
|
|
1608
|
-
hash = hash & hash; // Convert to 32bit integer
|
|
1609
|
-
}
|
|
1610
|
-
return hash.toString(36);
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
// Toggle feature visibility
|
|
1614
|
-
toggleFeatureVisibility(featureKey) {
|
|
1615
|
-
if (this.hiddenFeatures.has(featureKey)) {
|
|
1616
|
-
this.hiddenFeatures.delete(featureKey);
|
|
1617
1034
|
} else {
|
|
1618
|
-
|
|
1035
|
+
// Append new lines
|
|
1036
|
+
const inputLines = inputValue.split('\n');
|
|
1037
|
+
this.lines.push(...inputLines);
|
|
1038
|
+
this.cursorLine = this.lines.length - 1;
|
|
1039
|
+
this.cursorColumn = this.lines[this.cursorLine].length;
|
|
1619
1040
|
}
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
let featureIndex = 0;
|
|
1649
|
-
let braceDepth = 0;
|
|
1650
|
-
let inFeature = false;
|
|
1651
|
-
let featureStartLine = -1;
|
|
1652
|
-
let currentFeatureKey = null;
|
|
1653
|
-
|
|
1654
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1655
|
-
const line = lines[i];
|
|
1656
|
-
|
|
1657
|
-
// Detect start of a Feature object (not FeatureCollection)
|
|
1658
|
-
// Use regex to match exact "Feature" value, not "FeatureCollection"
|
|
1659
|
-
const isFeatureTypeLine = /"type"\s*:\s*"Feature"/.test(line);
|
|
1660
|
-
if (!inFeature && isFeatureTypeLine) {
|
|
1661
|
-
// Find the opening brace for this Feature
|
|
1662
|
-
// Look backwards for a line that starts with just '{' (the Feature's opening brace)
|
|
1663
|
-
// Not a line like '"geometry": {' which contains other content before the brace
|
|
1664
|
-
let startLine = i;
|
|
1665
|
-
for (let j = i; j >= 0; j--) {
|
|
1666
|
-
const trimmed = lines[j].trim();
|
|
1667
|
-
// Line is just '{' or '{' followed by nothing significant (opening brace only)
|
|
1668
|
-
if (trimmed === '{' || trimmed === '{,') {
|
|
1669
|
-
startLine = j;
|
|
1670
|
-
break;
|
|
1671
|
-
}
|
|
1672
|
-
// Also handle case where Feature starts on same line: { "type": "Feature"
|
|
1673
|
-
if (trimmed.startsWith('{') && !trimmed.includes(':')) {
|
|
1674
|
-
startLine = j;
|
|
1675
|
-
break;
|
|
1676
|
-
}
|
|
1041
|
+
|
|
1042
|
+
// Clear textarea
|
|
1043
|
+
textarea.value = '';
|
|
1044
|
+
|
|
1045
|
+
// Debounce formatting and update
|
|
1046
|
+
clearTimeout(this.inputTimer);
|
|
1047
|
+
this.inputTimer = setTimeout(() => {
|
|
1048
|
+
this.formatAndUpdate();
|
|
1049
|
+
}, 150);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
handleKeydown(e) {
|
|
1053
|
+
// Check if cursor is in a collapsed zone
|
|
1054
|
+
const inCollapsedZone = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1055
|
+
const onCollapsedNode = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1056
|
+
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1057
|
+
|
|
1058
|
+
switch (e.key) {
|
|
1059
|
+
case 'Enter':
|
|
1060
|
+
e.preventDefault();
|
|
1061
|
+
// Block in collapsed zones
|
|
1062
|
+
if (onCollapsedNode || inCollapsedZone) return;
|
|
1063
|
+
// On closing line, before bracket -> block
|
|
1064
|
+
if (onClosingLine) {
|
|
1065
|
+
const line = this.lines[this.cursorLine];
|
|
1066
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1067
|
+
if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
|
|
1068
|
+
return;
|
|
1677
1069
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1070
|
+
// After bracket, allow normal enter (add new line)
|
|
1071
|
+
}
|
|
1072
|
+
this.insertNewline();
|
|
1073
|
+
break;
|
|
1074
|
+
case 'Backspace':
|
|
1075
|
+
e.preventDefault();
|
|
1076
|
+
// Delete selection if any
|
|
1077
|
+
if (this._hasSelection()) {
|
|
1078
|
+
this._deleteSelection();
|
|
1079
|
+
this.formatAndUpdate();
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
// On closing line
|
|
1083
|
+
if (onClosingLine) {
|
|
1084
|
+
const line = this.lines[this.cursorLine];
|
|
1085
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1086
|
+
if (bracketPos >= 0 && this.cursorColumn > bracketPos + 1) {
|
|
1087
|
+
// After bracket, allow delete
|
|
1088
|
+
this.deleteBackward();
|
|
1089
|
+
return;
|
|
1090
|
+
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1091
|
+
// Just after bracket, delete whole node
|
|
1092
|
+
this._deleteCollapsedNode(onClosingLine);
|
|
1093
|
+
return;
|
|
1693
1094
|
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1095
|
+
// On or before bracket, delete whole node
|
|
1096
|
+
this._deleteCollapsedNode(onClosingLine);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
// If on collapsed node opening line at position 0, delete whole node
|
|
1100
|
+
if (onCollapsedNode && this.cursorColumn === 0) {
|
|
1101
|
+
this._deleteCollapsedNode(onCollapsedNode);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
// Block inside collapsed zones
|
|
1105
|
+
if (inCollapsedZone) return;
|
|
1106
|
+
// On opening line, allow editing before and at bracket
|
|
1107
|
+
if (onCollapsedNode) {
|
|
1108
|
+
const line = this.lines[this.cursorLine];
|
|
1109
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1110
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1111
|
+
// After bracket, delete whole node
|
|
1112
|
+
this._deleteCollapsedNode(onCollapsedNode);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
this.deleteBackward();
|
|
1117
|
+
break;
|
|
1118
|
+
case 'Delete':
|
|
1119
|
+
e.preventDefault();
|
|
1120
|
+
// Delete selection if any
|
|
1121
|
+
if (this._hasSelection()) {
|
|
1122
|
+
this._deleteSelection();
|
|
1123
|
+
this.formatAndUpdate();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
// On closing line
|
|
1127
|
+
if (onClosingLine) {
|
|
1128
|
+
const line = this.lines[this.cursorLine];
|
|
1129
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1130
|
+
if (bracketPos >= 0 && this.cursorColumn > bracketPos) {
|
|
1131
|
+
// After bracket, allow delete
|
|
1132
|
+
this.deleteForward();
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// On or before bracket, delete whole node
|
|
1136
|
+
this._deleteCollapsedNode(onClosingLine);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
// If on collapsed node opening line
|
|
1140
|
+
if (onCollapsedNode) {
|
|
1141
|
+
const line = this.lines[this.cursorLine];
|
|
1142
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1143
|
+
if (this.cursorColumn > bracketPos) {
|
|
1144
|
+
// After bracket, delete whole node
|
|
1145
|
+
this._deleteCollapsedNode(onCollapsedNode);
|
|
1146
|
+
return;
|
|
1698
1147
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1148
|
+
// Before bracket, allow editing key name
|
|
1149
|
+
}
|
|
1150
|
+
// Block inside collapsed zones
|
|
1151
|
+
if (inCollapsedZone) return;
|
|
1152
|
+
this.deleteForward();
|
|
1153
|
+
break;
|
|
1154
|
+
case 'ArrowUp':
|
|
1155
|
+
e.preventDefault();
|
|
1156
|
+
this._handleArrowKey(-1, 0, e.shiftKey);
|
|
1157
|
+
break;
|
|
1158
|
+
case 'ArrowDown':
|
|
1159
|
+
e.preventDefault();
|
|
1160
|
+
this._handleArrowKey(1, 0, e.shiftKey);
|
|
1161
|
+
break;
|
|
1162
|
+
case 'ArrowLeft':
|
|
1163
|
+
e.preventDefault();
|
|
1164
|
+
this._handleArrowKey(0, -1, e.shiftKey);
|
|
1165
|
+
break;
|
|
1166
|
+
case 'ArrowRight':
|
|
1167
|
+
e.preventDefault();
|
|
1168
|
+
this._handleArrowKey(0, 1, e.shiftKey);
|
|
1169
|
+
break;
|
|
1170
|
+
case 'Home':
|
|
1171
|
+
e.preventDefault();
|
|
1172
|
+
this._handleHomeEnd('home', e.shiftKey, onClosingLine);
|
|
1173
|
+
break;
|
|
1174
|
+
case 'End':
|
|
1175
|
+
e.preventDefault();
|
|
1176
|
+
this._handleHomeEnd('end', e.shiftKey, onClosingLine);
|
|
1177
|
+
break;
|
|
1178
|
+
case 'a':
|
|
1179
|
+
// Ctrl+A or Cmd+A: select all
|
|
1180
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1181
|
+
e.preventDefault();
|
|
1182
|
+
this._selectAll();
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
break;
|
|
1186
|
+
case 'Tab':
|
|
1187
|
+
e.preventDefault();
|
|
1188
|
+
|
|
1189
|
+
// Shift+Tab: collapse the containing expanded node
|
|
1190
|
+
if (e.shiftKey) {
|
|
1191
|
+
const containingNode = this._getContainingExpandedNode(this.cursorLine);
|
|
1192
|
+
if (containingNode) {
|
|
1193
|
+
// Find the position just after the opening bracket
|
|
1194
|
+
const startLine = this.lines[containingNode.startLine];
|
|
1195
|
+
const bracketPos = startLine.search(/[{\[]/);
|
|
1196
|
+
|
|
1197
|
+
this.toggleCollapse(containingNode.nodeId);
|
|
1198
|
+
|
|
1199
|
+
// Move cursor to just after the opening bracket
|
|
1200
|
+
this.cursorLine = containingNode.startLine;
|
|
1201
|
+
this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
|
|
1202
|
+
this._clearSelection();
|
|
1203
|
+
this._scrollToCursor();
|
|
1716
1204
|
}
|
|
1205
|
+
return;
|
|
1717
1206
|
}
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1207
|
+
|
|
1208
|
+
// Tab: expand collapsed node if on one
|
|
1209
|
+
if (onCollapsedNode) {
|
|
1210
|
+
this.toggleCollapse(onCollapsedNode.nodeId);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (onClosingLine) {
|
|
1214
|
+
this.toggleCollapse(onClosingLine.nodeId);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Block in hidden collapsed zones
|
|
1219
|
+
if (inCollapsedZone) return;
|
|
1220
|
+
break;
|
|
1721
1221
|
}
|
|
1722
1222
|
}
|
|
1723
1223
|
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1224
|
+
insertNewline() {
|
|
1225
|
+
if (this.cursorLine < this.lines.length) {
|
|
1226
|
+
const line = this.lines[this.cursorLine];
|
|
1227
|
+
const before = line.substring(0, this.cursorColumn);
|
|
1228
|
+
const after = line.substring(this.cursorColumn);
|
|
1229
|
+
|
|
1230
|
+
this.lines[this.cursorLine] = before;
|
|
1231
|
+
this.lines.splice(this.cursorLine + 1, 0, after);
|
|
1232
|
+
this.cursorLine++;
|
|
1233
|
+
this.cursorColumn = 0;
|
|
1234
|
+
} else {
|
|
1235
|
+
this.lines.push('');
|
|
1236
|
+
this.cursorLine = this.lines.length - 1;
|
|
1237
|
+
this.cursorColumn = 0;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
this.formatAndUpdate();
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
deleteBackward() {
|
|
1244
|
+
if (this.cursorColumn > 0) {
|
|
1245
|
+
const line = this.lines[this.cursorLine];
|
|
1246
|
+
this.lines[this.cursorLine] = line.substring(0, this.cursorColumn - 1) + line.substring(this.cursorColumn);
|
|
1247
|
+
this.cursorColumn--;
|
|
1248
|
+
} else if (this.cursorLine > 0) {
|
|
1249
|
+
// Merge with previous line
|
|
1250
|
+
const currentLine = this.lines[this.cursorLine];
|
|
1251
|
+
const prevLine = this.lines[this.cursorLine - 1];
|
|
1252
|
+
this.cursorColumn = prevLine.length;
|
|
1253
|
+
this.lines[this.cursorLine - 1] = prevLine + currentLine;
|
|
1254
|
+
this.lines.splice(this.cursorLine, 1);
|
|
1255
|
+
this.cursorLine--;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
this.formatAndUpdate();
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
deleteForward() {
|
|
1262
|
+
if (this.cursorLine < this.lines.length) {
|
|
1263
|
+
const line = this.lines[this.cursorLine];
|
|
1264
|
+
if (this.cursorColumn < line.length) {
|
|
1265
|
+
this.lines[this.cursorLine] = line.substring(0, this.cursorColumn) + line.substring(this.cursorColumn + 1);
|
|
1266
|
+
} else if (this.cursorLine < this.lines.length - 1) {
|
|
1267
|
+
// Merge with next line
|
|
1268
|
+
this.lines[this.cursorLine] = line + this.lines[this.cursorLine + 1];
|
|
1269
|
+
this.lines.splice(this.cursorLine + 1, 1);
|
|
1730
1270
|
}
|
|
1731
1271
|
}
|
|
1732
|
-
|
|
1272
|
+
|
|
1273
|
+
this.formatAndUpdate();
|
|
1733
1274
|
}
|
|
1734
1275
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1276
|
+
/**
|
|
1277
|
+
* Move cursor vertically, skipping hidden collapsed lines only
|
|
1278
|
+
*/
|
|
1279
|
+
moveCursorSkipCollapsed(deltaLine) {
|
|
1280
|
+
let targetLine = this.cursorLine + deltaLine;
|
|
1281
|
+
|
|
1282
|
+
// Skip over hidden collapsed zones only (not opening/closing lines)
|
|
1283
|
+
while (targetLine >= 0 && targetLine < this.lines.length) {
|
|
1284
|
+
const collapsed = this._getCollapsedRangeForLine(targetLine);
|
|
1285
|
+
if (collapsed) {
|
|
1286
|
+
// Jump past the hidden zone
|
|
1287
|
+
if (deltaLine > 0) {
|
|
1288
|
+
targetLine = collapsed.endLine; // Jump to closing bracket line
|
|
1289
|
+
} else {
|
|
1290
|
+
targetLine = collapsed.startLine; // Jump to opening line
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
break;
|
|
1744
1294
|
}
|
|
1295
|
+
|
|
1296
|
+
this.cursorLine = Math.max(0, Math.min(this.lines.length - 1, targetLine));
|
|
1297
|
+
|
|
1298
|
+
// Clamp column to line length
|
|
1299
|
+
const maxCol = this.lines[this.cursorLine]?.length || 0;
|
|
1300
|
+
this.cursorColumn = Math.min(this.cursorColumn, maxCol);
|
|
1301
|
+
|
|
1302
|
+
this._lastStartIndex = -1;
|
|
1303
|
+
this._scrollToCursor();
|
|
1304
|
+
this.scheduleRender();
|
|
1305
|
+
}
|
|
1745
1306
|
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1307
|
+
/**
|
|
1308
|
+
* Move cursor horizontally with smart navigation around collapsed nodes
|
|
1309
|
+
*/
|
|
1310
|
+
moveCursorHorizontal(delta) {
|
|
1311
|
+
const line = this.lines[this.cursorLine];
|
|
1312
|
+
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1313
|
+
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1314
|
+
|
|
1315
|
+
if (delta > 0) {
|
|
1316
|
+
// Moving right
|
|
1317
|
+
if (onClosingLine) {
|
|
1318
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1319
|
+
if (this.cursorColumn < bracketPos) {
|
|
1320
|
+
// Before bracket, jump to bracket
|
|
1321
|
+
this.cursorColumn = bracketPos;
|
|
1322
|
+
} else if (this.cursorColumn >= line.length) {
|
|
1323
|
+
// At end, go to next line
|
|
1324
|
+
if (this.cursorLine < this.lines.length - 1) {
|
|
1325
|
+
this.cursorLine++;
|
|
1326
|
+
this.cursorColumn = 0;
|
|
1754
1327
|
}
|
|
1755
1328
|
} else {
|
|
1756
|
-
//
|
|
1757
|
-
|
|
1758
|
-
|
|
1329
|
+
// On or after bracket, move normally
|
|
1330
|
+
this.cursorColumn++;
|
|
1331
|
+
}
|
|
1332
|
+
} else if (onCollapsed) {
|
|
1333
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1334
|
+
if (this.cursorColumn < bracketPos) {
|
|
1335
|
+
// Before bracket, move normally
|
|
1336
|
+
this.cursorColumn++;
|
|
1337
|
+
} else if (this.cursorColumn === bracketPos) {
|
|
1338
|
+
// On bracket, go to after bracket
|
|
1339
|
+
this.cursorColumn = bracketPos + 1;
|
|
1340
|
+
} else {
|
|
1341
|
+
// After bracket, jump to closing line at bracket
|
|
1342
|
+
this.cursorLine = onCollapsed.endLine;
|
|
1343
|
+
const closingLine = this.lines[this.cursorLine];
|
|
1344
|
+
this.cursorColumn = this._getClosingBracketPos(closingLine);
|
|
1345
|
+
}
|
|
1346
|
+
} else if (this.cursorColumn >= line.length) {
|
|
1347
|
+
// Move to next line
|
|
1348
|
+
if (this.cursorLine < this.lines.length - 1) {
|
|
1349
|
+
this.cursorLine++;
|
|
1350
|
+
this.cursorColumn = 0;
|
|
1351
|
+
// Skip hidden collapsed zones
|
|
1352
|
+
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1353
|
+
if (collapsed) {
|
|
1354
|
+
this.cursorLine = collapsed.endLine;
|
|
1355
|
+
this.cursorColumn = 0;
|
|
1759
1356
|
}
|
|
1760
1357
|
}
|
|
1358
|
+
} else {
|
|
1359
|
+
this.cursorColumn++;
|
|
1761
1360
|
}
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
// Recursively validate nested objects
|
|
1765
|
-
if (Array.isArray(obj)) {
|
|
1766
|
-
obj.forEach((item, index) => {
|
|
1767
|
-
errors.push(...this.validateGeoJSON(item, `${path}[${index}]`, context));
|
|
1768
|
-
});
|
|
1769
1361
|
} else {
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1362
|
+
// Moving left
|
|
1363
|
+
if (onClosingLine) {
|
|
1364
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1365
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1366
|
+
// After bracket, move normally
|
|
1367
|
+
this.cursorColumn--;
|
|
1368
|
+
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1369
|
+
// Just after bracket, jump to opening line after bracket
|
|
1370
|
+
this.cursorLine = onClosingLine.startLine;
|
|
1371
|
+
const openLine = this.lines[this.cursorLine];
|
|
1372
|
+
const openBracketPos = openLine.search(/[{\[]/);
|
|
1373
|
+
this.cursorColumn = openBracketPos + 1;
|
|
1374
|
+
} else {
|
|
1375
|
+
// On bracket, jump to opening line after bracket
|
|
1376
|
+
this.cursorLine = onClosingLine.startLine;
|
|
1377
|
+
const openLine = this.lines[this.cursorLine];
|
|
1378
|
+
const openBracketPos = openLine.search(/[{\[]/);
|
|
1379
|
+
this.cursorColumn = openBracketPos + 1;
|
|
1380
|
+
}
|
|
1381
|
+
} else if (onCollapsed) {
|
|
1382
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1383
|
+
if (this.cursorColumn > bracketPos + 1) {
|
|
1384
|
+
// After bracket, go to just after bracket
|
|
1385
|
+
this.cursorColumn = bracketPos + 1;
|
|
1386
|
+
} else if (this.cursorColumn === bracketPos + 1) {
|
|
1387
|
+
// Just after bracket, go to bracket
|
|
1388
|
+
this.cursorColumn = bracketPos;
|
|
1389
|
+
} else if (this.cursorColumn > 0) {
|
|
1390
|
+
// Before bracket, move normally
|
|
1391
|
+
this.cursorColumn--;
|
|
1392
|
+
} else {
|
|
1393
|
+
// At start, go to previous line
|
|
1394
|
+
if (this.cursorLine > 0) {
|
|
1395
|
+
this.cursorLine--;
|
|
1396
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
} else if (this.cursorColumn > 0) {
|
|
1400
|
+
this.cursorColumn--;
|
|
1401
|
+
} else if (this.cursorLine > 0) {
|
|
1402
|
+
// Move to previous line
|
|
1403
|
+
this.cursorLine--;
|
|
1404
|
+
|
|
1405
|
+
// Check if previous line is closing line of collapsed
|
|
1406
|
+
const closing = this._getCollapsedClosingLine(this.cursorLine);
|
|
1407
|
+
if (closing) {
|
|
1408
|
+
// Go to end of closing line
|
|
1409
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1410
|
+
} else {
|
|
1411
|
+
// Check if previous line is inside collapsed zone
|
|
1412
|
+
const collapsed = this._getCollapsedRangeForLine(this.cursorLine);
|
|
1413
|
+
if (collapsed) {
|
|
1414
|
+
// Jump to opening line after bracket
|
|
1415
|
+
this.cursorLine = collapsed.startLine;
|
|
1416
|
+
const openLine = this.lines[this.cursorLine];
|
|
1417
|
+
const bracketPos = openLine.search(/[{\[]/);
|
|
1418
|
+
this.cursorColumn = bracketPos + 1;
|
|
1419
|
+
} else {
|
|
1420
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1781
1421
|
}
|
|
1782
|
-
errors.push(...this.validateGeoJSON(value, newPath, newContext));
|
|
1783
1422
|
}
|
|
1784
1423
|
}
|
|
1785
1424
|
}
|
|
1786
|
-
|
|
1787
|
-
|
|
1425
|
+
|
|
1426
|
+
this._lastStartIndex = -1;
|
|
1427
|
+
this._scrollToCursor();
|
|
1428
|
+
this.scheduleRender();
|
|
1788
1429
|
}
|
|
1789
1430
|
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1431
|
+
/**
|
|
1432
|
+
* Scroll viewport to ensure cursor is visible
|
|
1433
|
+
*/
|
|
1434
|
+
_scrollToCursor() {
|
|
1435
|
+
const viewport = this.shadowRoot.getElementById('viewport');
|
|
1436
|
+
if (!viewport) return;
|
|
1437
|
+
|
|
1438
|
+
// Find the visible line index for the cursor
|
|
1439
|
+
const visibleIndex = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
|
|
1440
|
+
if (visibleIndex === -1) return;
|
|
1441
|
+
|
|
1442
|
+
const cursorY = visibleIndex * this.lineHeight;
|
|
1443
|
+
const viewportTop = viewport.scrollTop;
|
|
1444
|
+
const viewportBottom = viewportTop + viewport.clientHeight;
|
|
1445
|
+
|
|
1446
|
+
// Scroll up if cursor is above viewport
|
|
1447
|
+
if (cursorY < viewportTop) {
|
|
1448
|
+
viewport.scrollTop = cursorY;
|
|
1449
|
+
}
|
|
1450
|
+
// Scroll down if cursor is below viewport
|
|
1451
|
+
else if (cursorY + this.lineHeight > viewportBottom) {
|
|
1452
|
+
viewport.scrollTop = cursorY + this.lineHeight - viewport.clientHeight;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1801
1455
|
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1456
|
+
/**
|
|
1457
|
+
* Legacy moveCursor for compatibility
|
|
1458
|
+
*/
|
|
1459
|
+
moveCursor(deltaLine, deltaCol) {
|
|
1460
|
+
if (deltaLine !== 0) {
|
|
1461
|
+
this.moveCursorSkipCollapsed(deltaLine);
|
|
1462
|
+
} else if (deltaCol !== 0) {
|
|
1463
|
+
this.moveCursorHorizontal(deltaCol);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1806
1466
|
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1467
|
+
/**
|
|
1468
|
+
* Handle arrow key with optional selection
|
|
1469
|
+
*/
|
|
1470
|
+
_handleArrowKey(deltaLine, deltaCol, isShift) {
|
|
1471
|
+
// Start selection if shift is pressed and no selection exists
|
|
1472
|
+
if (isShift && !this.selectionStart) {
|
|
1473
|
+
this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Move cursor
|
|
1477
|
+
if (deltaLine !== 0) {
|
|
1478
|
+
this.moveCursorSkipCollapsed(deltaLine);
|
|
1479
|
+
} else if (deltaCol !== 0) {
|
|
1480
|
+
this.moveCursorHorizontal(deltaCol);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Update selection end if shift is pressed
|
|
1484
|
+
if (isShift) {
|
|
1485
|
+
this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
|
|
1486
|
+
} else {
|
|
1487
|
+
// Clear selection if shift not pressed
|
|
1488
|
+
this.selectionStart = null;
|
|
1489
|
+
this.selectionEnd = null;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1811
1492
|
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1493
|
+
/**
|
|
1494
|
+
* Handle Home/End with optional selection
|
|
1495
|
+
*/
|
|
1496
|
+
_handleHomeEnd(key, isShift, onClosingLine) {
|
|
1497
|
+
// Start selection if shift is pressed and no selection exists
|
|
1498
|
+
if (isShift && !this.selectionStart) {
|
|
1499
|
+
this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
if (key === 'home') {
|
|
1503
|
+
if (onClosingLine) {
|
|
1504
|
+
this.cursorLine = onClosingLine.startLine;
|
|
1815
1505
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1506
|
+
this.cursorColumn = 0;
|
|
1507
|
+
} else {
|
|
1508
|
+
if (this.cursorLine < this.lines.length) {
|
|
1509
|
+
this.cursorColumn = this.lines[this.cursorLine].length;
|
|
1820
1510
|
}
|
|
1821
1511
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1512
|
+
|
|
1513
|
+
// Update selection end if shift is pressed
|
|
1514
|
+
if (isShift) {
|
|
1515
|
+
this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
|
|
1516
|
+
} else {
|
|
1517
|
+
this.selectionStart = null;
|
|
1518
|
+
this.selectionEnd = null;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
this._lastStartIndex = -1;
|
|
1522
|
+
this._scrollToCursor();
|
|
1523
|
+
this.scheduleRender();
|
|
1824
1524
|
}
|
|
1825
1525
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
const
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1526
|
+
/**
|
|
1527
|
+
* Select all content
|
|
1528
|
+
*/
|
|
1529
|
+
_selectAll() {
|
|
1530
|
+
this.selectionStart = { line: 0, column: 0 };
|
|
1531
|
+
const lastLine = this.lines.length - 1;
|
|
1532
|
+
this.selectionEnd = { line: lastLine, column: this.lines[lastLine]?.length || 0 };
|
|
1533
|
+
this.cursorLine = lastLine;
|
|
1534
|
+
this.cursorColumn = this.lines[lastLine]?.length || 0;
|
|
1535
|
+
|
|
1536
|
+
this._lastStartIndex = -1;
|
|
1537
|
+
this._scrollToCursor();
|
|
1538
|
+
this.scheduleRender();
|
|
1837
1539
|
}
|
|
1838
1540
|
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
if (
|
|
1849
|
-
|
|
1850
|
-
const startCounts = this._countBracketsOutsideStrings(restOfStartLine, openBracket);
|
|
1851
|
-
depth += startCounts.open - startCounts.close;
|
|
1852
|
-
if (depth === 0) {
|
|
1853
|
-
return { endLine: startLine, content: [] };
|
|
1854
|
-
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Get selected text
|
|
1543
|
+
*/
|
|
1544
|
+
_getSelectedText() {
|
|
1545
|
+
if (!this.selectionStart || !this.selectionEnd) return '';
|
|
1546
|
+
|
|
1547
|
+
const { start, end } = this._normalizeSelection();
|
|
1548
|
+
if (!start || !end) return '';
|
|
1549
|
+
|
|
1550
|
+
if (start.line === end.line) {
|
|
1551
|
+
return this.lines[start.line].substring(start.column, end.column);
|
|
1855
1552
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
depth += counts.open - counts.close;
|
|
1861
|
-
|
|
1862
|
-
content.push(scanLine);
|
|
1863
|
-
|
|
1864
|
-
if (depth === 0) {
|
|
1865
|
-
return { endLine: i, content };
|
|
1866
|
-
}
|
|
1553
|
+
|
|
1554
|
+
let text = this.lines[start.line].substring(start.column) + '\n';
|
|
1555
|
+
for (let i = start.line + 1; i < end.line; i++) {
|
|
1556
|
+
text += this.lines[i] + '\n';
|
|
1867
1557
|
}
|
|
1868
|
-
|
|
1869
|
-
|
|
1558
|
+
text += this.lines[end.line].substring(0, end.column);
|
|
1559
|
+
|
|
1560
|
+
return text;
|
|
1870
1561
|
}
|
|
1871
1562
|
|
|
1872
1563
|
/**
|
|
1873
|
-
*
|
|
1874
|
-
* Stores data in collapsedData, replaces line with marker, removes content lines
|
|
1875
|
-
* @param {string[]} lines - Array of lines (modified in place)
|
|
1876
|
-
* @param {number} lineIndex - Index of line to collapse
|
|
1877
|
-
* @param {string} nodeKey - Key of the node (e.g., 'coordinates')
|
|
1878
|
-
* @param {string} indent - Indentation string
|
|
1879
|
-
* @param {string} openBracket - Opening bracket character ('{' or '[')
|
|
1880
|
-
* @returns {number} Number of lines removed, or 0 if collapse failed
|
|
1881
|
-
* @private
|
|
1564
|
+
* Normalize selection so start is before end
|
|
1882
1565
|
*/
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1566
|
+
_normalizeSelection() {
|
|
1567
|
+
if (!this.selectionStart || !this.selectionEnd) {
|
|
1568
|
+
return { start: null, end: null };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const s = this.selectionStart;
|
|
1572
|
+
const e = this.selectionEnd;
|
|
1573
|
+
|
|
1574
|
+
if (s.line < e.line || (s.line === e.line && s.column <= e.column)) {
|
|
1575
|
+
return { start: s, end: e };
|
|
1576
|
+
} else {
|
|
1577
|
+
return { start: e, end: s };
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1886
1580
|
|
|
1887
|
-
|
|
1888
|
-
|
|
1581
|
+
/**
|
|
1582
|
+
* Check if there is an active selection
|
|
1583
|
+
*/
|
|
1584
|
+
_hasSelection() {
|
|
1585
|
+
if (!this.selectionStart || !this.selectionEnd) return false;
|
|
1586
|
+
return this.selectionStart.line !== this.selectionEnd.line ||
|
|
1587
|
+
this.selectionStart.column !== this.selectionEnd.column;
|
|
1588
|
+
}
|
|
1889
1589
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1590
|
+
/**
|
|
1591
|
+
* Clear the current selection
|
|
1592
|
+
*/
|
|
1593
|
+
_clearSelection() {
|
|
1594
|
+
this.selectionStart = null;
|
|
1595
|
+
this.selectionEnd = null;
|
|
1596
|
+
}
|
|
1893
1597
|
|
|
1894
|
-
|
|
1598
|
+
/**
|
|
1599
|
+
* Delete selected text
|
|
1600
|
+
*/
|
|
1601
|
+
_deleteSelection() {
|
|
1602
|
+
if (!this._hasSelection()) return false;
|
|
1603
|
+
|
|
1604
|
+
const { start, end } = this._normalizeSelection();
|
|
1605
|
+
|
|
1606
|
+
if (start.line === end.line) {
|
|
1607
|
+
// Single line selection
|
|
1608
|
+
const line = this.lines[start.line];
|
|
1609
|
+
this.lines[start.line] = line.substring(0, start.column) + line.substring(end.column);
|
|
1610
|
+
} else {
|
|
1611
|
+
// Multi-line selection
|
|
1612
|
+
const startLine = this.lines[start.line].substring(0, start.column);
|
|
1613
|
+
const endLine = this.lines[end.line].substring(end.column);
|
|
1614
|
+
this.lines[start.line] = startLine + endLine;
|
|
1615
|
+
this.lines.splice(start.line + 1, end.line - start.line);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
this.cursorLine = start.line;
|
|
1619
|
+
this.cursorColumn = start.column;
|
|
1620
|
+
this.selectionStart = null;
|
|
1621
|
+
this.selectionEnd = null;
|
|
1622
|
+
|
|
1623
|
+
return true;
|
|
1624
|
+
}
|
|
1895
1625
|
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
this.
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1626
|
+
insertText(text) {
|
|
1627
|
+
// Delete selection first if any
|
|
1628
|
+
if (this._hasSelection()) {
|
|
1629
|
+
this._deleteSelection();
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Block insertion in hidden collapsed zones
|
|
1633
|
+
if (this._getCollapsedRangeForLine(this.cursorLine)) return;
|
|
1634
|
+
|
|
1635
|
+
// On closing line, only allow after bracket
|
|
1636
|
+
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1637
|
+
if (onClosingLine) {
|
|
1638
|
+
const line = this.lines[this.cursorLine];
|
|
1639
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1640
|
+
if (this.cursorColumn <= bracketPos) return;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// On collapsed opening line, only allow before bracket
|
|
1644
|
+
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1645
|
+
if (onCollapsed) {
|
|
1646
|
+
const line = this.lines[this.cursorLine];
|
|
1647
|
+
const bracketPos = line.search(/[{\[]/);
|
|
1648
|
+
if (this.cursorColumn > bracketPos) return;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Handle empty editor case
|
|
1652
|
+
if (this.lines.length === 0) {
|
|
1653
|
+
this.lines = [text];
|
|
1654
|
+
this.cursorColumn = text.length;
|
|
1655
|
+
} else if (this.cursorLine < this.lines.length) {
|
|
1656
|
+
const line = this.lines[this.cursorLine];
|
|
1657
|
+
this.lines[this.cursorLine] = line.substring(0, this.cursorColumn) + text + line.substring(this.cursorColumn);
|
|
1658
|
+
this.cursorColumn += text.length;
|
|
1659
|
+
}
|
|
1660
|
+
this.formatAndUpdate();
|
|
1661
|
+
}
|
|
1904
1662
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
const
|
|
1908
|
-
|
|
1663
|
+
handlePaste(e) {
|
|
1664
|
+
e.preventDefault();
|
|
1665
|
+
const text = e.clipboardData.getData('text/plain');
|
|
1666
|
+
if (text) {
|
|
1667
|
+
this.insertText(text);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1909
1670
|
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1671
|
+
handleCopy(e) {
|
|
1672
|
+
e.preventDefault();
|
|
1673
|
+
// Copy selected text if there's a selection, otherwise copy all
|
|
1674
|
+
if (this._hasSelection()) {
|
|
1675
|
+
e.clipboardData.setData('text/plain', this._getSelectedText());
|
|
1676
|
+
} else {
|
|
1677
|
+
e.clipboardData.setData('text/plain', this.getContent());
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1913
1680
|
|
|
1914
|
-
|
|
1681
|
+
handleCut(e) {
|
|
1682
|
+
e.preventDefault();
|
|
1683
|
+
if (this._hasSelection()) {
|
|
1684
|
+
e.clipboardData.setData('text/plain', this._getSelectedText());
|
|
1685
|
+
this._deleteSelection();
|
|
1686
|
+
this.formatAndUpdate();
|
|
1687
|
+
} else {
|
|
1688
|
+
// Cut all content
|
|
1689
|
+
e.clipboardData.setData('text/plain', this.getContent());
|
|
1690
|
+
this.lines = [];
|
|
1691
|
+
this.cursorLine = 0;
|
|
1692
|
+
this.cursorColumn = 0;
|
|
1693
|
+
this.formatAndUpdate();
|
|
1694
|
+
}
|
|
1915
1695
|
}
|
|
1916
1696
|
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1697
|
+
/**
|
|
1698
|
+
* Get line/column position from mouse event
|
|
1699
|
+
*/
|
|
1700
|
+
_getPositionFromClick(e) {
|
|
1701
|
+
const viewport = this.shadowRoot.getElementById('viewport');
|
|
1702
|
+
const rect = viewport.getBoundingClientRect();
|
|
1703
|
+
|
|
1704
|
+
const paddingTop = 8;
|
|
1705
|
+
const paddingLeft = 12;
|
|
1706
|
+
|
|
1707
|
+
const y = e.clientY - rect.top + viewport.scrollTop - paddingTop;
|
|
1708
|
+
const x = e.clientX - rect.left - paddingLeft;
|
|
1709
|
+
|
|
1710
|
+
const visibleLineIndex = Math.floor(y / this.lineHeight);
|
|
1711
|
+
|
|
1712
|
+
let line = 0;
|
|
1713
|
+
let column = 0;
|
|
1714
|
+
|
|
1715
|
+
if (visibleLineIndex >= 0 && visibleLineIndex < this.visibleLines.length) {
|
|
1716
|
+
const lineData = this.visibleLines[visibleLineIndex];
|
|
1717
|
+
line = lineData.index;
|
|
1718
|
+
|
|
1719
|
+
const charWidth = this._getCharWidth();
|
|
1720
|
+
const rawColumn = Math.round(x / charWidth);
|
|
1721
|
+
const lineLength = lineData.content?.length || 0;
|
|
1722
|
+
column = Math.max(0, Math.min(rawColumn, lineLength));
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
return { line, column };
|
|
1726
|
+
}
|
|
1920
1727
|
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1728
|
+
// ========== Gutter Interactions ==========
|
|
1729
|
+
|
|
1730
|
+
handleGutterClick(e) {
|
|
1731
|
+
// Visibility button in gutter
|
|
1732
|
+
const visBtn = e.target.closest('.visibility-button');
|
|
1733
|
+
if (visBtn) {
|
|
1734
|
+
this.toggleFeatureVisibility(visBtn.dataset.featureKey);
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Collapse button in gutter
|
|
1739
|
+
if (e.target.classList.contains('collapse-button')) {
|
|
1740
|
+
const nodeId = e.target.dataset.nodeId;
|
|
1741
|
+
this.toggleCollapse(nodeId);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
handleEditorClick(e) {
|
|
1747
|
+
// Line-level visibility button (pseudo-element ::before on .line.has-visibility)
|
|
1748
|
+
const lineEl = e.target.closest('.line.has-visibility');
|
|
1749
|
+
if (lineEl) {
|
|
1750
|
+
const rect = lineEl.getBoundingClientRect();
|
|
1751
|
+
const clickX = e.clientX - rect.left;
|
|
1752
|
+
// Pseudo-element is at the start of the line, check first ~14px
|
|
1753
|
+
if (clickX < 14) {
|
|
1754
|
+
e.preventDefault();
|
|
1755
|
+
e.stopPropagation();
|
|
1756
|
+
this.toggleFeatureVisibility(lineEl.dataset.featureKey);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Inline color swatch (pseudo-element positioned with left: -8px)
|
|
1762
|
+
if (e.target.classList.contains('json-color')) {
|
|
1763
|
+
const rect = e.target.getBoundingClientRect();
|
|
1764
|
+
const clickX = e.clientX - rect.left;
|
|
1765
|
+
// Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
|
|
1766
|
+
if (clickX < 0 && clickX >= -8) {
|
|
1767
|
+
e.preventDefault();
|
|
1768
|
+
e.stopPropagation();
|
|
1769
|
+
const color = e.target.dataset.color;
|
|
1770
|
+
const targetLineEl = e.target.closest('.line');
|
|
1771
|
+
if (targetLineEl) {
|
|
1772
|
+
const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
|
|
1773
|
+
const line = this.lines[lineIndex];
|
|
1774
|
+
const match = line.match(/"([\w-]+)"\s*:\s*"#/);
|
|
1775
|
+
if (match) {
|
|
1776
|
+
this.showColorPicker(e.target, lineIndex, color, match[1]);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Inline boolean checkbox (pseudo-element positioned with left: -8px)
|
|
1784
|
+
if (e.target.classList.contains('json-boolean')) {
|
|
1785
|
+
const rect = e.target.getBoundingClientRect();
|
|
1786
|
+
const clickX = e.clientX - rect.left;
|
|
1787
|
+
// Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
|
|
1788
|
+
if (clickX < 0 && clickX >= -8) {
|
|
1789
|
+
e.preventDefault();
|
|
1790
|
+
e.stopPropagation();
|
|
1791
|
+
const targetLineEl = e.target.closest('.line');
|
|
1792
|
+
if (targetLineEl) {
|
|
1793
|
+
const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
|
|
1794
|
+
const line = this.lines[lineIndex];
|
|
1795
|
+
const match = line.match(/"([\w-]+)"\s*:\s*(true|false)/);
|
|
1796
|
+
if (match) {
|
|
1797
|
+
const currentValue = match[2] === 'true';
|
|
1798
|
+
this.updateBooleanValue(lineIndex, !currentValue, match[1]);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1924
1805
|
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1806
|
+
// ========== Collapse/Expand ==========
|
|
1807
|
+
|
|
1808
|
+
toggleCollapse(nodeId) {
|
|
1809
|
+
if (this.collapsedNodes.has(nodeId)) {
|
|
1810
|
+
this.collapsedNodes.delete(nodeId);
|
|
1811
|
+
} else {
|
|
1812
|
+
this.collapsedNodes.add(nodeId);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// Use updateView - don't rebuild nodeId mappings since content didn't change
|
|
1816
|
+
this.updateView();
|
|
1817
|
+
this._lastStartIndex = -1; // Force re-render
|
|
1818
|
+
this.scheduleRender();
|
|
1819
|
+
}
|
|
1928
1820
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1821
|
+
autoCollapseCoordinates() {
|
|
1822
|
+
const ranges = this._findCollapsibleRanges();
|
|
1823
|
+
|
|
1824
|
+
for (const range of ranges) {
|
|
1825
|
+
if (range.nodeKey === 'coordinates') {
|
|
1826
|
+
this.collapsedNodes.add(range.nodeId);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Use updateView since nodeIds were just assigned by updateModel/setValue
|
|
1831
|
+
this.updateView();
|
|
1832
|
+
this.scheduleRender();
|
|
1833
|
+
}
|
|
1931
1834
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1835
|
+
// ========== Feature Visibility ==========
|
|
1836
|
+
|
|
1837
|
+
toggleFeatureVisibility(featureKey) {
|
|
1838
|
+
if (this.hiddenFeatures.has(featureKey)) {
|
|
1839
|
+
this.hiddenFeatures.delete(featureKey);
|
|
1840
|
+
} else {
|
|
1841
|
+
this.hiddenFeatures.add(featureKey);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Use updateView - content didn't change, just visibility
|
|
1845
|
+
this.updateView();
|
|
1846
|
+
this.scheduleRender();
|
|
1847
|
+
this.emitChange();
|
|
1848
|
+
}
|
|
1935
1849
|
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1850
|
+
// ========== Color Picker ==========
|
|
1851
|
+
|
|
1852
|
+
showColorPicker(indicator, line, currentColor, attributeName) {
|
|
1853
|
+
// Remove existing picker and anchor
|
|
1854
|
+
const existing = document.querySelector('.geojson-color-picker-anchor');
|
|
1855
|
+
if (existing) {
|
|
1856
|
+
existing.remove();
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Create an anchor element at the pseudo-element position
|
|
1860
|
+
// The browser will position the color picker popup relative to this
|
|
1861
|
+
const anchor = document.createElement('div');
|
|
1862
|
+
anchor.className = 'geojson-color-picker-anchor';
|
|
1863
|
+
const rect = indicator.getBoundingClientRect();
|
|
1864
|
+
anchor.style.cssText = `
|
|
1865
|
+
position: fixed;
|
|
1866
|
+
left: ${rect.left - 8}px;
|
|
1867
|
+
top: ${rect.top + rect.height}px;
|
|
1868
|
+
width: 10px;
|
|
1869
|
+
height: 10px;
|
|
1870
|
+
z-index: 9998;
|
|
1871
|
+
`;
|
|
1872
|
+
document.body.appendChild(anchor);
|
|
1873
|
+
|
|
1874
|
+
const colorInput = document.createElement('input');
|
|
1875
|
+
colorInput.type = 'color';
|
|
1876
|
+
colorInput.value = currentColor;
|
|
1877
|
+
colorInput.className = 'geojson-color-picker-input';
|
|
1878
|
+
|
|
1879
|
+
// Position the color input inside the anchor
|
|
1880
|
+
colorInput.style.cssText = `
|
|
1881
|
+
position: absolute;
|
|
1882
|
+
left: 0;
|
|
1883
|
+
top: 0;
|
|
1884
|
+
width: 10px;
|
|
1885
|
+
height: 10px;
|
|
1886
|
+
opacity: 0;
|
|
1887
|
+
border: none;
|
|
1888
|
+
padding: 0;
|
|
1889
|
+
cursor: pointer;
|
|
1890
|
+
`;
|
|
1891
|
+
anchor.appendChild(colorInput);
|
|
1892
|
+
|
|
1893
|
+
colorInput.addEventListener('input', (e) => {
|
|
1894
|
+
this.updateColorValue(line, e.target.value, attributeName);
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
const closeOnClickOutside = (e) => {
|
|
1898
|
+
if (e.target !== colorInput) {
|
|
1899
|
+
document.removeEventListener('click', closeOnClickOutside, true);
|
|
1900
|
+
anchor.remove(); // Remove anchor (which contains the input)
|
|
1943
1901
|
}
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
colorInput._closeListener = closeOnClickOutside;
|
|
1905
|
+
|
|
1906
|
+
setTimeout(() => {
|
|
1907
|
+
document.addEventListener('click', closeOnClickOutside, true);
|
|
1908
|
+
}, 100);
|
|
1909
|
+
|
|
1910
|
+
colorInput.focus();
|
|
1911
|
+
colorInput.click();
|
|
1912
|
+
}
|
|
1944
1913
|
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1914
|
+
updateColorValue(line, newColor, attributeName) {
|
|
1915
|
+
const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})"`);
|
|
1916
|
+
this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
|
|
1917
|
+
|
|
1918
|
+
// Use updateView to preserve collapsed state (line count didn't change)
|
|
1919
|
+
this.updateView();
|
|
1920
|
+
this.scheduleRender();
|
|
1921
|
+
this.emitChange();
|
|
1949
1922
|
}
|
|
1950
1923
|
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
const wrapped = '[' + content + ']';
|
|
1955
|
-
let parsed = JSON.parse(wrapped);
|
|
1956
|
-
|
|
1957
|
-
// Apply default properties to each feature in the array
|
|
1958
|
-
if (Array.isArray(parsed)) {
|
|
1959
|
-
parsed = parsed.map(f => this._applyDefaultPropertiesToFeature(f));
|
|
1960
|
-
}
|
|
1924
|
+
updateBooleanValue(line, newValue, attributeName) {
|
|
1925
|
+
const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
|
|
1926
|
+
this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": ${newValue}`);
|
|
1961
1927
|
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1928
|
+
// Use updateView to preserve collapsed state (line count didn't change)
|
|
1929
|
+
this.updateView();
|
|
1930
|
+
this.scheduleRender();
|
|
1931
|
+
this.emitChange();
|
|
1965
1932
|
}
|
|
1966
1933
|
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
// Save cursor position
|
|
1971
|
-
const cursorPos = textarea.selectionStart;
|
|
1972
|
-
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
|
1973
|
-
const linesBeforeCursor = textBeforeCursor.split('\n');
|
|
1974
|
-
const cursorLine = linesBeforeCursor.length - 1;
|
|
1975
|
-
const cursorColumn = linesBeforeCursor[linesBeforeCursor.length - 1].length;
|
|
1976
|
-
|
|
1977
|
-
// Save collapsed node details
|
|
1978
|
-
const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
|
|
1979
|
-
nodeKey: data.nodeKey,
|
|
1980
|
-
indent: data.indent
|
|
1981
|
-
}));
|
|
1982
|
-
|
|
1983
|
-
// Expand and format
|
|
1984
|
-
const content = this.expandAllCollapsed(textarea.value);
|
|
1985
|
-
|
|
1934
|
+
// ========== Format and Update ==========
|
|
1935
|
+
|
|
1936
|
+
formatAndUpdate() {
|
|
1986
1937
|
try {
|
|
1987
|
-
const
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
this.reapplyCollapsed(collapsedNodes);
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
// Restore cursor position
|
|
1998
|
-
const newLines = textarea.value.split('\n');
|
|
1999
|
-
if (cursorLine < newLines.length) {
|
|
2000
|
-
const newColumn = Math.min(cursorColumn, newLines[cursorLine].length);
|
|
2001
|
-
let newPos = 0;
|
|
2002
|
-
for (let i = 0; i < cursorLine; i++) {
|
|
2003
|
-
newPos += newLines[i].length + 1;
|
|
2004
|
-
}
|
|
2005
|
-
newPos += newColumn;
|
|
2006
|
-
textarea.setSelectionRange(newPos, newPos);
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
1938
|
+
const content = this.lines.join('\n');
|
|
1939
|
+
const wrapped = '[' + content + ']';
|
|
1940
|
+
const parsed = JSON.parse(wrapped);
|
|
1941
|
+
|
|
1942
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
1943
|
+
const lines = formatted.split('\n');
|
|
1944
|
+
this.lines = lines.slice(1, -1); // Remove wrapper brackets
|
|
2009
1945
|
} catch (e) {
|
|
2010
|
-
// Invalid JSON,
|
|
1946
|
+
// Invalid JSON, keep as-is
|
|
2011
1947
|
}
|
|
1948
|
+
|
|
1949
|
+
this.updateModel();
|
|
1950
|
+
this.scheduleRender();
|
|
1951
|
+
this.updatePlaceholderVisibility();
|
|
1952
|
+
this.emitChange();
|
|
2012
1953
|
}
|
|
2013
1954
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
const openBracket = match[3];
|
|
2047
|
-
|
|
2048
|
-
// Use common collapse helper
|
|
2049
|
-
this._performCollapse(lines, i, nodeKey, indent, openBracket);
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
1955
|
+
// ========== Event Emission ==========
|
|
1956
|
+
|
|
1957
|
+
emitChange() {
|
|
1958
|
+
const content = this.getContent();
|
|
1959
|
+
const fullValue = this.prefix + content + this.suffix;
|
|
1960
|
+
|
|
1961
|
+
try {
|
|
1962
|
+
let parsed = JSON.parse(fullValue);
|
|
1963
|
+
|
|
1964
|
+
// Filter hidden features
|
|
1965
|
+
if (this.hiddenFeatures.size > 0) {
|
|
1966
|
+
parsed.features = parsed.features.filter((feature) => {
|
|
1967
|
+
const key = this._getFeatureKey(feature);
|
|
1968
|
+
return !this.hiddenFeatures.has(key);
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// Validate
|
|
1973
|
+
const errors = this._validateGeoJSON(parsed);
|
|
1974
|
+
|
|
1975
|
+
if (errors.length > 0) {
|
|
1976
|
+
this.dispatchEvent(new CustomEvent('error', {
|
|
1977
|
+
detail: { error: errors.join('; '), errors, content },
|
|
1978
|
+
bubbles: true,
|
|
1979
|
+
composed: true
|
|
1980
|
+
}));
|
|
1981
|
+
} else {
|
|
1982
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
1983
|
+
detail: parsed,
|
|
1984
|
+
bubbles: true,
|
|
1985
|
+
composed: true
|
|
1986
|
+
}));
|
|
2052
1987
|
}
|
|
1988
|
+
} catch (e) {
|
|
1989
|
+
this.dispatchEvent(new CustomEvent('error', {
|
|
1990
|
+
detail: { error: e.message, content },
|
|
1991
|
+
bubbles: true,
|
|
1992
|
+
composed: true
|
|
1993
|
+
}));
|
|
2053
1994
|
}
|
|
2054
|
-
|
|
2055
|
-
textarea.value = lines.join('\n');
|
|
2056
1995
|
}
|
|
2057
1996
|
|
|
1997
|
+
// ========== UI Updates ==========
|
|
1998
|
+
|
|
1999
|
+
updateReadonly() {
|
|
2000
|
+
const textarea = this.shadowRoot.getElementById('hiddenTextarea');
|
|
2001
|
+
const clearBtn = this.shadowRoot.getElementById('clearBtn');
|
|
2002
|
+
|
|
2003
|
+
// Use readOnly instead of disabled to allow text selection for copying
|
|
2004
|
+
if (textarea) textarea.readOnly = this.readonly;
|
|
2005
|
+
if (clearBtn) clearBtn.hidden = this.readonly;
|
|
2006
|
+
}
|
|
2058
2007
|
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
if (
|
|
2062
|
-
|
|
2063
|
-
return ':host([data-color-scheme="dark"])';
|
|
2008
|
+
updatePlaceholderVisibility() {
|
|
2009
|
+
const placeholder = this.shadowRoot.getElementById('placeholderLayer');
|
|
2010
|
+
if (placeholder) {
|
|
2011
|
+
placeholder.style.display = this.lines.length > 0 ? 'none' : 'block';
|
|
2064
2012
|
}
|
|
2013
|
+
}
|
|
2065
2014
|
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2015
|
+
updatePlaceholderContent() {
|
|
2016
|
+
const placeholder = this.shadowRoot.getElementById('placeholderLayer');
|
|
2017
|
+
if (placeholder) {
|
|
2018
|
+
placeholder.textContent = this.placeholder;
|
|
2069
2019
|
}
|
|
2020
|
+
this.updatePlaceholderVisibility();
|
|
2021
|
+
}
|
|
2070
2022
|
|
|
2071
|
-
|
|
2072
|
-
|
|
2023
|
+
updatePrefixSuffix() {
|
|
2024
|
+
const prefix = this.shadowRoot.getElementById('editorPrefix');
|
|
2025
|
+
const suffix = this.shadowRoot.getElementById('editorSuffix');
|
|
2026
|
+
|
|
2027
|
+
if (prefix) prefix.textContent = this.prefix;
|
|
2028
|
+
if (suffix) suffix.textContent = this.suffix;
|
|
2073
2029
|
}
|
|
2074
2030
|
|
|
2075
|
-
//
|
|
2031
|
+
// ========== Theme ==========
|
|
2032
|
+
|
|
2076
2033
|
updateThemeCSS() {
|
|
2077
2034
|
const darkSelector = this.getAttribute('dark-selector') || '.dark';
|
|
2078
|
-
const darkRule = this.
|
|
2079
|
-
|
|
2080
|
-
// Find or create theme style element
|
|
2035
|
+
const darkRule = this._parseSelectorToHostRule(darkSelector);
|
|
2036
|
+
|
|
2081
2037
|
let themeStyle = this.shadowRoot.getElementById('theme-styles');
|
|
2082
2038
|
if (!themeStyle) {
|
|
2083
2039
|
themeStyle = document.createElement('style');
|
|
2084
2040
|
themeStyle.id = 'theme-styles';
|
|
2085
2041
|
this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
|
|
2086
2042
|
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2043
|
+
|
|
2044
|
+
const darkDefaults = {
|
|
2045
|
+
bgColor: '#2b2b2b',
|
|
2046
|
+
textColor: '#a9b7c6',
|
|
2047
|
+
caretColor: '#bbbbbb',
|
|
2048
|
+
gutterBg: '#313335',
|
|
2049
|
+
gutterBorder: '#3c3f41',
|
|
2050
|
+
gutterText: '#606366',
|
|
2051
|
+
jsonKey: '#9876aa',
|
|
2052
|
+
jsonString: '#6a8759',
|
|
2053
|
+
jsonNumber: '#6897bb',
|
|
2054
|
+
jsonBoolean: '#cc7832',
|
|
2055
|
+
jsonNull: '#cc7832',
|
|
2056
|
+
jsonPunct: '#a9b7c6',
|
|
2057
|
+
jsonError: '#ff6b68',
|
|
2058
|
+
controlColor: '#cc7832',
|
|
2059
|
+
controlBg: '#3c3f41',
|
|
2060
|
+
controlBorder: '#5a5a5a',
|
|
2061
|
+
geojsonKey: '#9876aa',
|
|
2062
|
+
geojsonType: '#6a8759',
|
|
2063
|
+
geojsonTypeInvalid: '#ff6b68',
|
|
2064
|
+
jsonKeyInvalid: '#ff6b68'
|
|
2093
2065
|
};
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2066
|
+
|
|
2067
|
+
const toKebab = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
2068
|
+
const generateVars = (obj) => Object.entries(obj)
|
|
2069
|
+
.map(([k, v]) => `--${toKebab(k)}: ${v};`)
|
|
2070
|
+
.join('\n ');
|
|
2071
|
+
|
|
2072
|
+
const lightVars = generateVars(this.themes.light || {});
|
|
2073
|
+
const darkTheme = { ...darkDefaults, ...this.themes.dark };
|
|
2100
2074
|
const darkVars = generateVars(darkTheme);
|
|
2101
|
-
|
|
2102
|
-
let css = '';
|
|
2103
|
-
if (lightVars) {
|
|
2104
|
-
css += `:host {\n ${lightVars}\n }\n`;
|
|
2105
|
-
}
|
|
2106
|
-
// Dark theme is always generated (selector is configurable)
|
|
2075
|
+
|
|
2076
|
+
let css = lightVars ? `:host {\n ${lightVars}\n }\n` : '';
|
|
2107
2077
|
css += `${darkRule} {\n ${darkVars}\n }`;
|
|
2108
|
-
|
|
2078
|
+
|
|
2109
2079
|
themeStyle.textContent = css;
|
|
2110
2080
|
}
|
|
2111
2081
|
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
if (
|
|
2115
|
-
|
|
2116
|
-
}
|
|
2117
|
-
if (theme.light) {
|
|
2118
|
-
this.themes.light = { ...this.themes.light, ...theme.light };
|
|
2082
|
+
_parseSelectorToHostRule(selector) {
|
|
2083
|
+
if (!selector) return ':host([data-color-scheme="dark"])';
|
|
2084
|
+
if (selector.startsWith('.') && !selector.includes(' ')) {
|
|
2085
|
+
return `:host(${selector})`;
|
|
2119
2086
|
}
|
|
2087
|
+
return `:host-context(${selector})`;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
setTheme(theme) {
|
|
2091
|
+
if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
|
|
2092
|
+
if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
|
|
2120
2093
|
this.updateThemeCSS();
|
|
2121
2094
|
}
|
|
2122
2095
|
|
|
@@ -2125,272 +2098,363 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2125
2098
|
this.updateThemeCSS();
|
|
2126
2099
|
}
|
|
2127
2100
|
|
|
2128
|
-
//
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
let idx = index;
|
|
2142
|
-
if (idx < 0) {
|
|
2143
|
-
idx = length + idx;
|
|
2144
|
-
}
|
|
2145
|
-
if (clamp) {
|
|
2146
|
-
return Math.max(0, Math.min(idx, length));
|
|
2101
|
+
// ========== Helper Methods ==========
|
|
2102
|
+
|
|
2103
|
+
_getFeatureKey(feature) {
|
|
2104
|
+
if (!feature) return null;
|
|
2105
|
+
if (feature.id !== undefined) return `id:${feature.id}`;
|
|
2106
|
+
if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
|
|
2107
|
+
|
|
2108
|
+
const geomType = feature.geometry?.type || 'null';
|
|
2109
|
+
const coords = JSON.stringify(feature.geometry?.coordinates || []);
|
|
2110
|
+
let hash = 0;
|
|
2111
|
+
for (let i = 0; i < coords.length; i++) {
|
|
2112
|
+
hash = ((hash << 5) - hash) + coords.charCodeAt(i);
|
|
2113
|
+
hash = hash & hash;
|
|
2147
2114
|
}
|
|
2148
|
-
return (
|
|
2115
|
+
return `hash:${geomType}:${hash.toString(36)}`;
|
|
2149
2116
|
}
|
|
2150
2117
|
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
// Expand collapsed nodes to get full content
|
|
2164
|
-
const content = this.expandAllCollapsed(textarea.value);
|
|
2165
|
-
// Wrap in array brackets and parse
|
|
2166
|
-
const wrapped = '[' + content + ']';
|
|
2167
|
-
return JSON.parse(wrapped);
|
|
2168
|
-
} catch (e) {
|
|
2169
|
-
return [];
|
|
2118
|
+
_countBrackets(line, openBracket) {
|
|
2119
|
+
const closeBracket = openBracket === '{' ? '}' : ']';
|
|
2120
|
+
let open = 0, close = 0, inString = false, escape = false;
|
|
2121
|
+
|
|
2122
|
+
for (const char of line) {
|
|
2123
|
+
if (escape) { escape = false; continue; }
|
|
2124
|
+
if (char === '\\' && inString) { escape = true; continue; }
|
|
2125
|
+
if (char === '"') { inString = !inString; continue; }
|
|
2126
|
+
if (!inString) {
|
|
2127
|
+
if (char === openBracket) open++;
|
|
2128
|
+
if (char === closeBracket) close++;
|
|
2129
|
+
}
|
|
2170
2130
|
}
|
|
2131
|
+
|
|
2132
|
+
return { open, close };
|
|
2171
2133
|
}
|
|
2172
2134
|
|
|
2173
2135
|
/**
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
2176
|
-
* @private
|
|
2136
|
+
* Find all collapsible ranges using the mappings built by _rebuildNodeIdMappings
|
|
2137
|
+
* This method only READS the existing mappings, it doesn't create new IDs
|
|
2177
2138
|
*/
|
|
2178
|
-
|
|
2179
|
-
const
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
//
|
|
2190
|
-
const
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2139
|
+
_findCollapsibleRanges() {
|
|
2140
|
+
const ranges = [];
|
|
2141
|
+
|
|
2142
|
+
// Simply iterate through the existing mappings
|
|
2143
|
+
for (const [lineIndex, nodeId] of this._lineToNodeId) {
|
|
2144
|
+
const rangeInfo = this._nodeIdToLines.get(nodeId);
|
|
2145
|
+
if (!rangeInfo) continue;
|
|
2146
|
+
|
|
2147
|
+
const line = this.lines[lineIndex];
|
|
2148
|
+
if (!line) continue;
|
|
2149
|
+
|
|
2150
|
+
// Match "key": { or "key": [
|
|
2151
|
+
const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
|
|
2152
|
+
// Also match standalone { or [ (root Feature objects)
|
|
2153
|
+
const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
|
|
2154
|
+
|
|
2155
|
+
if (!kvMatch && !rootMatch) continue;
|
|
2156
|
+
|
|
2157
|
+
const openBracket = kvMatch ? kvMatch[2] : rootMatch[1];
|
|
2158
|
+
|
|
2159
|
+
ranges.push({
|
|
2160
|
+
startLine: rangeInfo.startLine,
|
|
2161
|
+
endLine: rangeInfo.endLine,
|
|
2162
|
+
nodeKey: rangeInfo.nodeKey || (kvMatch ? kvMatch[1] : `__root_${lineIndex}`),
|
|
2163
|
+
nodeId,
|
|
2164
|
+
openBracket,
|
|
2165
|
+
isRootFeature: !!rootMatch
|
|
2205
2166
|
});
|
|
2206
2167
|
}
|
|
2207
|
-
|
|
2208
|
-
//
|
|
2209
|
-
|
|
2168
|
+
|
|
2169
|
+
// Sort by startLine for consistent ordering
|
|
2170
|
+
ranges.sort((a, b) => a.startLine - b.startLine);
|
|
2171
|
+
|
|
2172
|
+
return ranges;
|
|
2210
2173
|
}
|
|
2211
2174
|
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
errors.push('Feature must be an object');
|
|
2223
|
-
return errors;
|
|
2175
|
+
_findClosingLine(startLine, openBracket) {
|
|
2176
|
+
let depth = 1;
|
|
2177
|
+
const line = this.lines[startLine];
|
|
2178
|
+
const bracketPos = line.indexOf(openBracket);
|
|
2179
|
+
|
|
2180
|
+
if (bracketPos !== -1) {
|
|
2181
|
+
const rest = line.substring(bracketPos + 1);
|
|
2182
|
+
const counts = this._countBrackets(rest, openBracket);
|
|
2183
|
+
depth += counts.open - counts.close;
|
|
2184
|
+
if (depth === 0) return startLine;
|
|
2224
2185
|
}
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2186
|
+
|
|
2187
|
+
for (let i = startLine + 1; i < this.lines.length; i++) {
|
|
2188
|
+
const counts = this._countBrackets(this.lines[i], openBracket);
|
|
2189
|
+
depth += counts.open - counts.close;
|
|
2190
|
+
if (depth === 0) return i;
|
|
2229
2191
|
}
|
|
2192
|
+
|
|
2193
|
+
return -1;
|
|
2194
|
+
}
|
|
2230
2195
|
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2196
|
+
_buildContextMap() {
|
|
2197
|
+
const contextMap = new Map();
|
|
2198
|
+
const contextStack = [];
|
|
2199
|
+
let pendingContext = null;
|
|
2200
|
+
|
|
2201
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
2202
|
+
const line = this.lines[i];
|
|
2203
|
+
const currentContext = contextStack[contextStack.length - 1]?.context || 'Feature';
|
|
2204
|
+
contextMap.set(i, currentContext);
|
|
2205
|
+
|
|
2206
|
+
// Check for context-changing keys
|
|
2207
|
+
if (/"geometry"\s*:/.test(line)) pendingContext = 'geometry';
|
|
2208
|
+
else if (/"properties"\s*:/.test(line)) pendingContext = 'properties';
|
|
2209
|
+
else if (/"features"\s*:/.test(line)) pendingContext = 'Feature';
|
|
2210
|
+
|
|
2211
|
+
// Track brackets
|
|
2212
|
+
const openBraces = (line.match(/\{/g) || []).length;
|
|
2213
|
+
const closeBraces = (line.match(/\}/g) || []).length;
|
|
2214
|
+
const openBrackets = (line.match(/\[/g) || []).length;
|
|
2215
|
+
const closeBrackets = (line.match(/\]/g) || []).length;
|
|
2216
|
+
|
|
2217
|
+
for (let j = 0; j < openBraces + openBrackets; j++) {
|
|
2218
|
+
contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
|
|
2219
|
+
pendingContext = null;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
for (let j = 0; j < closeBraces + closeBrackets && contextStack.length > 0; j++) {
|
|
2223
|
+
contextStack.pop();
|
|
2224
|
+
}
|
|
2236
2225
|
}
|
|
2226
|
+
|
|
2227
|
+
return contextMap;
|
|
2228
|
+
}
|
|
2237
2229
|
|
|
2238
|
-
|
|
2239
|
-
if (!
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2230
|
+
_highlightSyntax(text, context, meta) {
|
|
2231
|
+
if (!text) return '';
|
|
2232
|
+
|
|
2233
|
+
// For collapsed nodes, truncate the text at the opening bracket
|
|
2234
|
+
let displayText = text;
|
|
2235
|
+
let collapsedBracket = null;
|
|
2236
|
+
|
|
2237
|
+
if (meta?.collapseButton?.isCollapsed) {
|
|
2238
|
+
// Match "key": { or "key": [
|
|
2239
|
+
const bracketMatch = text.match(/^(\s*"[^"]+"\s*:\s*)([{\[])/);
|
|
2240
|
+
// Also match standalone { or [ (root Feature objects)
|
|
2241
|
+
const rootMatch = !bracketMatch && text.match(/^(\s*)([{\[]),?\s*$/);
|
|
2242
|
+
|
|
2243
|
+
if (bracketMatch) {
|
|
2244
|
+
// Keep only the part up to and including the opening bracket
|
|
2245
|
+
displayText = bracketMatch[1] + bracketMatch[2];
|
|
2246
|
+
collapsedBracket = bracketMatch[2];
|
|
2247
|
+
} else if (rootMatch) {
|
|
2248
|
+
// Root object - just keep the bracket
|
|
2249
|
+
displayText = rootMatch[1] + rootMatch[2];
|
|
2250
|
+
collapsedBracket = rootMatch[2];
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// Escape HTML first
|
|
2255
|
+
let result = displayText
|
|
2256
|
+
.replace(/&/g, '&')
|
|
2257
|
+
.replace(/</g, '<')
|
|
2258
|
+
.replace(/>/g, '>');
|
|
2259
|
+
|
|
2260
|
+
// Punctuation FIRST (before other replacements can interfere)
|
|
2261
|
+
result = result.replace(/([{}[\],:])/g, '<span class="json-punctuation">$1</span>');
|
|
2262
|
+
|
|
2263
|
+
// JSON keys - match "key" followed by :
|
|
2264
|
+
// In properties context, all keys are treated as regular JSON keys
|
|
2265
|
+
result = result.replace(/"([^"]+)"(<span class="json-punctuation">:<\/span>)/g, (match, key, colon) => {
|
|
2266
|
+
if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
|
|
2267
|
+
return `<span class="geojson-key">"${key}"</span>${colon}`;
|
|
2268
|
+
}
|
|
2269
|
+
return `<span class="json-key">"${key}"</span>${colon}`;
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
// Type values - "type": "Value" - but NOT inside properties context
|
|
2273
|
+
if (context !== 'properties') {
|
|
2274
|
+
result = result.replace(
|
|
2275
|
+
/<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>\s*"([^"]*)"/g,
|
|
2276
|
+
(match, type) => {
|
|
2277
|
+
const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
|
|
2278
|
+
const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
|
|
2279
|
+
return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span> <span class="${cls}">"${type}"</span>`;
|
|
2251
2280
|
}
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// String values (not already wrapped in spans)
|
|
2285
|
+
result = result.replace(
|
|
2286
|
+
/(<span class="json-punctuation">:<\/span>)\s*"([^"]*)"/g,
|
|
2287
|
+
(match, colon, val) => {
|
|
2288
|
+
// Don't double-wrap if already has a span after colon
|
|
2289
|
+
if (match.includes('geojson-type') || match.includes('json-string')) return match;
|
|
2290
|
+
|
|
2291
|
+
// Check if it's a color value (hex) - use ::before for swatch via CSS class
|
|
2292
|
+
if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(val)) {
|
|
2293
|
+
return `${colon} <span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
|
|
2256
2294
|
}
|
|
2295
|
+
|
|
2296
|
+
return `${colon} <span class="json-string">"${val}"</span>`;
|
|
2257
2297
|
}
|
|
2298
|
+
);
|
|
2299
|
+
|
|
2300
|
+
// Numbers after colon
|
|
2301
|
+
result = result.replace(
|
|
2302
|
+
/(<span class="json-punctuation">:<\/span>)\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
|
|
2303
|
+
'$1 <span class="json-number">$2</span>'
|
|
2304
|
+
);
|
|
2305
|
+
|
|
2306
|
+
// Numbers in arrays (after [ or ,)
|
|
2307
|
+
result = result.replace(
|
|
2308
|
+
/(<span class="json-punctuation">[\[,]<\/span>)\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
|
|
2309
|
+
'$1<span class="json-number">$2</span>'
|
|
2310
|
+
);
|
|
2311
|
+
|
|
2312
|
+
// Standalone numbers at start of line (coordinates arrays)
|
|
2313
|
+
result = result.replace(
|
|
2314
|
+
/^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim,
|
|
2315
|
+
'$1<span class="json-number">$2</span>'
|
|
2316
|
+
);
|
|
2317
|
+
|
|
2318
|
+
// Booleans - use ::before for checkbox via CSS class
|
|
2319
|
+
result = result.replace(
|
|
2320
|
+
/(<span class="json-punctuation">:<\/span>)\s*(true|false)/g,
|
|
2321
|
+
(match, colon, val) => {
|
|
2322
|
+
const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
|
|
2323
|
+
return `${colon} <span class="json-boolean${checkedClass}">${val}</span>`;
|
|
2324
|
+
}
|
|
2325
|
+
);
|
|
2326
|
+
|
|
2327
|
+
// Null
|
|
2328
|
+
result = result.replace(
|
|
2329
|
+
/(<span class="json-punctuation">:<\/span>)\s*(null)/g,
|
|
2330
|
+
'$1 <span class="json-null">$2</span>'
|
|
2331
|
+
);
|
|
2332
|
+
|
|
2333
|
+
// Collapsed bracket indicator - just add the class, CSS ::after adds the "...]" or "...}"
|
|
2334
|
+
if (collapsedBracket) {
|
|
2335
|
+
const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
|
|
2336
|
+
// Replace the last punctuation span (the opening bracket) with collapsed style class
|
|
2337
|
+
result = result.replace(
|
|
2338
|
+
new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
|
|
2339
|
+
`<span class="${bracketClass}">${collapsedBracket}</span>`
|
|
2340
|
+
);
|
|
2258
2341
|
}
|
|
2259
|
-
|
|
2260
|
-
//
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2342
|
+
|
|
2343
|
+
// Mark unrecognized text as error - text that's not inside a span and not just whitespace
|
|
2344
|
+
// This catches invalid JSON like unquoted strings, malformed values, etc.
|
|
2345
|
+
result = result.replace(
|
|
2346
|
+
/(<\/span>|^)([^<]+)(<span|$)/g,
|
|
2347
|
+
(match, before, text, after) => {
|
|
2348
|
+
// Skip if text is only whitespace or empty
|
|
2349
|
+
if (!text || /^\s*$/.test(text)) return match;
|
|
2350
|
+
// Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
|
|
2351
|
+
// Keep whitespace as-is, wrap any non-whitespace unrecognized token
|
|
2352
|
+
const parts = text.split(/(\s+)/);
|
|
2353
|
+
let hasError = false;
|
|
2354
|
+
const processed = parts.map(part => {
|
|
2355
|
+
// If it's whitespace, keep it
|
|
2356
|
+
if (/^\s*$/.test(part)) return part;
|
|
2357
|
+
// Mark as error
|
|
2358
|
+
hasError = true;
|
|
2359
|
+
return `<span class="json-error">${part}</span>`;
|
|
2360
|
+
}).join('');
|
|
2361
|
+
return hasError ? before + processed + after : match;
|
|
2362
|
+
}
|
|
2363
|
+
);
|
|
2364
|
+
|
|
2365
|
+
// Note: visibility is now handled at line level (has-visibility class on .line element)
|
|
2366
|
+
|
|
2367
|
+
return result;
|
|
2268
2368
|
}
|
|
2269
2369
|
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
const errors = this._validateFeature(feature);
|
|
2284
|
-
if (errors.length > 0) {
|
|
2285
|
-
allErrors.push(`Feature[${index}]: ${errors.join(', ')}`);
|
|
2370
|
+
_validateGeoJSON(parsed) {
|
|
2371
|
+
const errors = [];
|
|
2372
|
+
|
|
2373
|
+
if (!parsed.features) return errors;
|
|
2374
|
+
|
|
2375
|
+
parsed.features.forEach((feature, i) => {
|
|
2376
|
+
if (feature.type !== 'Feature') {
|
|
2377
|
+
errors.push(`features[${i}]: type must be "Feature"`);
|
|
2378
|
+
}
|
|
2379
|
+
if (feature.geometry && feature.geometry.type) {
|
|
2380
|
+
if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
|
|
2381
|
+
errors.push(`features[${i}].geometry: invalid type "${feature.geometry.type}"`);
|
|
2382
|
+
}
|
|
2286
2383
|
}
|
|
2287
2384
|
});
|
|
2385
|
+
|
|
2386
|
+
return errors;
|
|
2387
|
+
}
|
|
2288
2388
|
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
this._setFeatures(featuresWithDefaults);
|
|
2389
|
+
// ========== Public API ==========
|
|
2390
|
+
|
|
2391
|
+
set(features) {
|
|
2392
|
+
if (!Array.isArray(features)) throw new Error('set() expects an array');
|
|
2393
|
+
const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
|
|
2394
|
+
this.setValue(formatted);
|
|
2296
2395
|
}
|
|
2297
2396
|
|
|
2298
|
-
/**
|
|
2299
|
-
* Add a feature at the end of the list
|
|
2300
|
-
* @param {Object} feature - Feature object to add
|
|
2301
|
-
* @throws {Error} If feature is invalid
|
|
2302
|
-
*/
|
|
2303
2397
|
add(feature) {
|
|
2304
|
-
const errors = this._validateFeature(feature);
|
|
2305
|
-
if (errors.length > 0) {
|
|
2306
|
-
throw new Error(`Invalid feature: ${errors.join(', ')}`);
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
2398
|
const features = this._parseFeatures();
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
this._setFeatures(features);
|
|
2399
|
+
features.push(feature);
|
|
2400
|
+
this.set(features);
|
|
2313
2401
|
}
|
|
2314
2402
|
|
|
2315
|
-
/**
|
|
2316
|
-
* Insert a feature at the specified index
|
|
2317
|
-
* @param {Object} feature - Feature object to insert
|
|
2318
|
-
* @param {number} index - Index to insert at (negative = from end)
|
|
2319
|
-
* @throws {Error} If feature is invalid
|
|
2320
|
-
*/
|
|
2321
2403
|
insertAt(feature, index) {
|
|
2322
|
-
const errors = this._validateFeature(feature);
|
|
2323
|
-
if (errors.length > 0) {
|
|
2324
|
-
throw new Error(`Invalid feature: ${errors.join(', ')}`);
|
|
2325
|
-
}
|
|
2326
|
-
|
|
2327
2404
|
const features = this._parseFeatures();
|
|
2328
|
-
const idx =
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
features.splice(idx, 0, this._applyDefaultPropertiesToFeature(feature));
|
|
2332
|
-
this._setFeatures(features);
|
|
2405
|
+
const idx = index < 0 ? features.length + index : index;
|
|
2406
|
+
features.splice(Math.max(0, Math.min(idx, features.length)), 0, feature);
|
|
2407
|
+
this.set(features);
|
|
2333
2408
|
}
|
|
2334
2409
|
|
|
2335
|
-
/**
|
|
2336
|
-
* Remove the feature at the specified index
|
|
2337
|
-
* @param {number} index - Index to remove (negative = from end)
|
|
2338
|
-
* @returns {Object|undefined} The removed feature, or undefined if index out of bounds
|
|
2339
|
-
*/
|
|
2340
2410
|
removeAt(index) {
|
|
2341
2411
|
const features = this._parseFeatures();
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
return removed;
|
|
2412
|
+
const idx = index < 0 ? features.length + index : index;
|
|
2413
|
+
if (idx >= 0 && idx < features.length) {
|
|
2414
|
+
const removed = features.splice(idx, 1)[0];
|
|
2415
|
+
this.set(features);
|
|
2416
|
+
return removed;
|
|
2417
|
+
}
|
|
2418
|
+
return undefined;
|
|
2350
2419
|
}
|
|
2351
2420
|
|
|
2352
|
-
/**
|
|
2353
|
-
* Remove all features
|
|
2354
|
-
* @returns {Array} Array of removed features
|
|
2355
|
-
*/
|
|
2356
2421
|
removeAll() {
|
|
2357
2422
|
const removed = this._parseFeatures();
|
|
2358
|
-
this.
|
|
2423
|
+
this.lines = [];
|
|
2424
|
+
this.collapsedNodes.clear();
|
|
2425
|
+
this.hiddenFeatures.clear();
|
|
2426
|
+
this.updateModel();
|
|
2427
|
+
this.scheduleRender();
|
|
2428
|
+
this.updatePlaceholderVisibility();
|
|
2429
|
+
this.emitChange();
|
|
2359
2430
|
return removed;
|
|
2360
2431
|
}
|
|
2361
2432
|
|
|
2362
|
-
/**
|
|
2363
|
-
* Get the feature at the specified index
|
|
2364
|
-
* @param {number} index - Index to get (negative = from end)
|
|
2365
|
-
* @returns {Object|undefined} The feature, or undefined if index out of bounds
|
|
2366
|
-
*/
|
|
2367
2433
|
get(index) {
|
|
2368
2434
|
const features = this._parseFeatures();
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
const idx = this._normalizeIndex(index, features.length);
|
|
2372
|
-
if (idx === -1) return undefined;
|
|
2373
|
-
|
|
2435
|
+
const idx = index < 0 ? features.length + index : index;
|
|
2374
2436
|
return features[idx];
|
|
2375
2437
|
}
|
|
2376
2438
|
|
|
2377
|
-
/**
|
|
2378
|
-
* Get all features as an array
|
|
2379
|
-
* @returns {Array} Array of all feature objects
|
|
2380
|
-
*/
|
|
2381
2439
|
getAll() {
|
|
2382
2440
|
return this._parseFeatures();
|
|
2383
2441
|
}
|
|
2384
2442
|
|
|
2385
|
-
/**
|
|
2386
|
-
* Emit the current document on the change event
|
|
2387
|
-
*/
|
|
2388
2443
|
emit() {
|
|
2389
2444
|
this.emitChange();
|
|
2390
2445
|
}
|
|
2446
|
+
|
|
2447
|
+
_parseFeatures() {
|
|
2448
|
+
try {
|
|
2449
|
+
const content = this.lines.join('\n');
|
|
2450
|
+
if (!content.trim()) return [];
|
|
2451
|
+
return JSON.parse('[' + content + ']');
|
|
2452
|
+
} catch (e) {
|
|
2453
|
+
return [];
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2391
2456
|
}
|
|
2392
2457
|
|
|
2393
|
-
// Register the custom element
|
|
2394
2458
|
customElements.define('geojson-editor', GeoJsonEditor);
|
|
2395
2459
|
|
|
2396
2460
|
export default GeoJsonEditor;
|