@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.
@@ -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
- // Internal state
7
- this.collapsedData = new Map(); // nodeKey -> {originalLines: string[], indent: number}
8
- this.colorPositions = []; // {line, color}
9
- this.booleanPositions = []; // {line, value, attributeName}
10
- this.nodeTogglePositions = []; // {line, nodeKey, isCollapsed, indent}
11
- this.hiddenFeatures = new Set(); // Set of feature keys (hidden from events)
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
- // Debounce timer for syntax highlighting
15
- this.highlightTimer = null;
16
-
17
- // Cached computed styles (avoid repeated getComputedStyle calls)
18
- this._cachedLineHeight = null;
19
- this._cachedPaddingTop = null;
20
-
21
- // Custom theme overrides (empty by default, CSS has defaults)
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
- static get observedAttributes() {
26
- return ['readonly', 'value', 'placeholder', 'dark-selector', 'default-properties'];
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
- * Find collapsed data by line index, nodeKey, and indent
90
- * @param {number} lineIndex - Current line index
91
- * @param {string} nodeKey - Node key to find
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
- _findCollapsedData(lineIndex, nodeKey, indent) {
97
- // Try exact match first
98
- const exactKey = `${lineIndex}-${nodeKey}`;
99
- if (this.collapsedData.has(exactKey)) {
100
- return { key: exactKey, data: this.collapsedData.get(exactKey) };
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
- connectedCallback() {
114
- this.render();
115
- this.setupEventListeners();
116
-
117
- // Update prefix/suffix display
118
- this.updatePrefixSuffix();
119
-
120
- // Setup theme CSS
121
- this.updateThemeCSS();
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
- this.updatePlaceholderContent();
89
+ return null;
131
90
  }
132
91
 
133
- disconnectedCallback() {
134
- // Clean up any open color picker and its global listener
135
- const colorPicker = document.querySelector('.geojson-color-picker-input');
136
- if (colorPicker && colorPicker._closeListener) {
137
- document.removeEventListener('click', colorPicker._closeListener, true);
138
- colorPicker.remove();
139
- }
140
-
141
- // Clear any pending highlight timer
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
- attributeChangedCallback(name, oldValue, newValue) {
149
- if (oldValue === newValue) return;
150
-
151
- if (name === 'value') {
152
- this.updateValue(newValue);
153
- } else if (name === 'readonly') {
154
- this.updateReadonly();
155
- } else if (name === 'placeholder') {
156
- this.updatePlaceholderContent();
157
- } else if (name === 'dark-selector') {
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
- * Parse and cache the default-properties attribute.
194
- * Supports two formats:
195
- * 1. Simple object: {"fill-color": "#1a465b", "stroke-width": 2}
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
- _parseDefaultProperties() {
201
- const attr = this.defaultProperties;
202
- if (!attr) {
203
- this._defaultPropertiesRules = [];
204
- return this._defaultPropertiesRules;
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
- * Check if a feature matches a condition.
232
- * Supports dot notation for nested properties:
233
- * - "geometry.type": "Polygon"
234
- * - "properties.category": "airport"
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
- _matchesCondition(feature, match) {
237
- if (!match || typeof match !== 'object') return true;
238
-
239
- for (const [path, expectedValue] of Object.entries(match)) {
240
- const actualValue = this._getNestedValue(feature, path);
241
- if (actualValue !== expectedValue) {
242
- return false;
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
- return true;
153
+
154
+ return bestMatch;
246
155
  }
247
156
 
248
157
  /**
249
- * Get a nested value from an object using dot notation.
250
- * E.g., _getNestedValue(feature, "geometry.type") => "Polygon"
158
+ * Delete an entire collapsed node (opening line to closing line)
159
+ * @param {Object} range - The range info {startLine, endLine}
251
160
  */
252
- _getNestedValue(obj, path) {
253
- const parts = path.split('.');
254
- let current = obj;
255
- for (const part of parts) {
256
- if (current === null || current === undefined) return undefined;
257
- current = current[part];
258
- }
259
- return current;
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
- * Apply default properties to a single feature.
264
- * Only adds properties that don't already exist.
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
- _applyDefaultPropertiesToFeature(feature) {
268
- if (!feature || typeof feature !== 'object') return feature;
269
- if (!this._defaultPropertiesRules || this._defaultPropertiesRules.length === 0) return feature;
270
-
271
- // Collect all properties to apply (later rules override earlier for same key)
272
- const propsToApply = {};
273
-
274
- for (const rule of this._defaultPropertiesRules) {
275
- if (this._matchesCondition(feature, rule.match)) {
276
- Object.assign(propsToApply, rule.values);
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
- if (Object.keys(propsToApply).length === 0) return feature;
251
+ // ========== Observed Attributes ==========
252
+ static get observedAttributes() {
253
+ return ['readonly', 'value', 'placeholder', 'dark-selector'];
254
+ }
281
255
 
282
- // Apply only properties that don't already exist
283
- const existingProps = feature.properties || {};
284
- const newProps = { ...existingProps };
285
- let hasChanges = false;
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
- for (const [key, value] of Object.entries(propsToApply)) {
288
- if (!(key in existingProps)) {
289
- newProps[key] = value;
290
- hasChanges = true;
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
- render() {
300
- const styles = `
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
- .prefix-gutter, .suffix-gutter {
525
- width: 24px;
526
- background: var(--gutter-bg, #f0f0f0);
527
- border-right: 1px solid var(--gutter-border, #e0e0e0);
528
- flex-shrink: 0;
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
- .editor-prefix, .editor-suffix {
532
- flex: 1;
533
- padding: 4px 12px;
534
- color: var(--text-color, #000);
535
- background: var(--bg-color, #fff);
536
- user-select: none;
537
- white-space: pre-wrap;
538
- word-wrap: break-word;
539
- opacity: 0.6;
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
- .prefix-wrapper { border-bottom: 1px solid rgba(255,255,255,0.1); }
543
- .suffix-wrapper { border-top: 1px solid rgba(255,255,255,0.1); position: relative; }
544
-
545
- .clear-btn {
546
- position: absolute;
547
- right: 0.5rem;
548
- top: 50%;
549
- transform: translateY(-50%);
550
- background: transparent;
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
- .clear-btn:hover { opacity: 0.7; background: rgba(255,255,255,0.1); }
566
- .clear-btn[hidden] { display: none; }
567
-
568
- textarea::-webkit-scrollbar { width: 10px; height: 10px; }
569
- textarea::-webkit-scrollbar-track { background: var(--control-bg, #e8e8e8); }
570
- textarea::-webkit-scrollbar-thumb { background: var(--control-border, #c0c0c0); border-radius: 5px; }
571
- textarea::-webkit-scrollbar-thumb:hover { background: var(--control-color, #000080); }
572
- textarea { scrollbar-width: thin; scrollbar-color: var(--control-border, #c0c0c0) var(--control-bg, #e8e8e8); }
573
- </style>
574
- `;
575
-
576
- const template = `
577
- <div class="prefix-wrapper">
578
- <div class="prefix-gutter"></div>
579
- <div class="editor-prefix" id="editorPrefix"></div>
580
- </div>
581
- <div class="editor-wrapper">
582
- <div class="gutter">
583
- <div class="gutter-content" id="gutterContent"></div>
584
- </div>
585
- <div class="editor-content">
586
- <div class="placeholder-layer" id="placeholderLayer">${this.escapeHtml(this.placeholder)}</div>
587
- <div class="highlight-layer" id="highlightLayer"></div>
588
- <textarea
589
- id="textarea"
590
- spellcheck="false"
591
- autocomplete="off"
592
- autocorrect="off"
593
- autocapitalize="off"
594
- ></textarea>
595
- </div>
596
- </div>
597
- <div class="suffix-wrapper">
598
- <div class="suffix-gutter"></div>
599
- <div class="editor-suffix" id="editorSuffix"></div>
600
- <button class="clear-btn" id="clearBtn" title="Clear editor">✕</button>
601
- </div>
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
- this.shadowRoot.innerHTML = styles + template;
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
- setupEventListeners() {
608
- const textarea = this.shadowRoot.getElementById('textarea');
609
- const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
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
- // Input handling with debounced highlight and auto-format
619
- textarea.addEventListener('input', () => {
620
- // Update placeholder visibility immediately (no debounce)
621
- this.updatePlaceholderVisibility();
622
-
623
- clearTimeout(this.highlightTimer);
624
- this.highlightTimer = setTimeout(() => {
625
- // Auto-format JSON content
626
- this.autoFormatContentWithCursor();
627
- this.updateHighlight();
628
- this.emitChange();
629
- }, 150);
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
- // Paste handling - trigger immediately without debounce
633
- textarea.addEventListener('paste', () => {
634
- // Clear any pending highlight timer to avoid duplicate processing
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
- // Gutter clicks (color indicators, boolean checkboxes, and collapse buttons)
650
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
651
- gutterContent.addEventListener('click', (e) => {
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
- if (e.target.classList.contains('color-indicator')) {
661
- const line = parseInt(e.target.dataset.line);
662
- const color = e.target.dataset.color;
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
- // Transfer wheel scroll from gutter to textarea
678
- const gutter = this.shadowRoot.querySelector('.gutter');
679
- gutter.addEventListener('wheel', (e) => {
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
- // Block editing in collapsed areas
685
- textarea.addEventListener('keydown', (e) => {
686
- this.handleKeydownInCollapsedArea(e);
472
+ // Cut handling
473
+ hiddenTextarea.addEventListener('cut', (e) => {
474
+ this.handleCut(e);
687
475
  });
688
476
 
689
- // Handle copy to include collapsed content
690
- textarea.addEventListener('copy', (e) => {
691
- this.handleCopyWithCollapsedContent(e);
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
- // Handle cut to include collapsed content
695
- textarea.addEventListener('cut', (e) => {
696
- this.handleCutWithCollapsedContent(e);
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
- // Update readonly state
498
+ // Initial readonly state
706
499
  this.updateReadonly();
707
500
  }
708
501
 
709
- syncGutterScroll(scrollTop) {
710
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
711
- gutterContent.style.transform = `translateY(-${scrollTop}px)`;
712
- }
713
-
714
- updateReadonly() {
715
- const textarea = this.shadowRoot.getElementById('textarea');
716
- if (textarea) {
717
- textarea.disabled = this.readonly;
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
- // Hide clear button in readonly mode
720
- const clearBtn = this.shadowRoot.getElementById('clearBtn');
721
- if (clearBtn) {
722
- clearBtn.hidden = this.readonly;
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
- escapeHtml(text) {
727
- if (!text) return '';
728
- const R = GeoJsonEditor.REGEX;
729
- return text
730
- .replace(R.ampersand, '&amp;')
731
- .replace(R.lessThan, '&lt;')
732
- .replace(R.greaterThan, '&gt;');
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
- updatePlaceholderVisibility() {
736
- const textarea = this.shadowRoot.getElementById('textarea');
737
- const placeholderLayer = this.shadowRoot.getElementById('placeholderLayer');
738
- if (textarea && placeholderLayer) {
739
- placeholderLayer.style.display = textarea.value ? 'none' : 'block';
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
- updatePlaceholderContent() {
744
- const placeholderLayer = this.shadowRoot.getElementById('placeholderLayer');
745
- if (placeholderLayer) {
746
- placeholderLayer.textContent = this.placeholder;
747
- }
748
- this.updatePlaceholderVisibility();
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
- updateValue(newValue) {
752
- const textarea = this.shadowRoot.getElementById('textarea');
753
- if (textarea && textarea.value !== newValue) {
754
- textarea.value = newValue || '';
755
-
756
- // Auto-format JSON content
757
- if (newValue) {
758
- try {
759
- // Wrap content in array brackets for validation and formatting
760
- const wrapped = '[' + newValue + ']';
761
- const parsed = JSON.parse(wrapped);
762
- const formatted = JSON.stringify(parsed, null, 2);
763
-
764
- // Remove first [ and last ] from formatted
765
- const lines = formatted.split('\n');
766
- if (lines.length > 2) {
767
- textarea.value = lines.slice(1, -1).join('\n');
768
- } else {
769
- textarea.value = '';
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
- this.updateHighlight();
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
- updateHighlight() {
806
- const textarea = this.shadowRoot.getElementById('textarea');
807
- const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
808
-
809
- if (!textarea || !highlightLayer) return;
810
-
811
- const text = textarea.value;
812
-
813
- // Update feature ranges for visibility tracking
814
- this.updateFeatureRanges();
815
-
816
- // Get hidden line ranges
817
- const hiddenRanges = this.getHiddenLineRanges();
818
-
819
- // Parse and highlight
820
- const { highlighted, colors, booleans, toggles } = this.highlightJSON(text, hiddenRanges);
821
-
822
- highlightLayer.innerHTML = highlighted;
823
- this.colorPositions = colors;
824
- this.booleanPositions = booleans;
825
- this.nodeTogglePositions = toggles;
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 = R.colorInLine.exec(line)) !== null) {
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 boolean values in properties
864
- R.booleanInLine.lastIndex = 0; // Reset for global regex
865
- let booleanMatch;
866
- while ((booleanMatch = R.booleanInLine.exec(line)) !== null) {
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
- // Detect collapsible nodes (all nodes are collapsible)
875
- const nodeMatch = line.match(R.collapsibleNode);
876
- if (nodeMatch) {
877
- const nodeKey = nodeMatch[2];
878
-
879
- // Check if this is a collapsed marker first
880
- const isCollapsed = line.includes('{...}') || line.includes('[...]');
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
- // Highlight the line with context
902
- const context = contextMap.get(lineIndex);
903
- let highlightedLine = this.highlightSyntax(line, context);
904
-
905
- // Wrap hidden lines with .line-hidden class
906
- if (isLineHidden(lineIndex)) {
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
- highlightedLines.push(highlightedLine);
911
- });
912
-
913
- return {
914
- highlighted: highlightedLines.join('\n'),
915
- colors,
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
- inString = !inString;
1010
- continue;
1011
- }
1012
-
1013
- // Skip everything inside strings (brackets, etc.)
1014
- if (inString) continue;
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
- // All known GeoJSON structural keys (always valid in GeoJSON)
1048
- // GeoJSON structural keys that are always valid (not user properties)
1049
- static GEOJSON_STRUCTURAL_KEYS = ['type', 'geometry', 'properties', 'coordinates', 'id'];
1050
-
1051
- highlightSyntax(text, context) {
1052
- if (!text.trim()) return '';
1053
-
1054
- // Get valid keys for current context
1055
- const validKeys = context ? GeoJsonEditor.VALID_KEYS_BY_CONTEXT[context] : null;
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
- // Helper to check if a key is valid in current context
1058
- const isKeyValid = (key) => {
1059
- // GeoJSON structural keys are always valid
1060
- if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) return true;
1061
- // No context or null validKeys means all keys are valid
1062
- if (!context || validKeys === null || validKeys === undefined) return true;
1063
- return validKeys.includes(key);
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
- // Helper to check if a type value is valid in current context
1067
- const isTypeValid = (typeValue) => {
1068
- // Unknown context - don't validate
1069
- if (!context) return true;
1070
- if (context === 'properties') return true; // Any type in properties
1071
- if (context === 'geometry' || GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(context)) {
1072
- return GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue);
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
- // In Feature context: accept Feature or geometry types
1075
- if (context === 'Feature') {
1076
- return typeValue === 'Feature' || GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue);
819
+
820
+ // Add hidden class if feature is hidden
821
+ if (lineData.meta?.isHidden) {
822
+ lineEl.classList.add('line-hidden');
1077
823
  }
1078
- return true; // Unknown context - accept any type
1079
- };
1080
-
1081
- const R = GeoJsonEditor.REGEX;
1082
-
1083
- return text
1084
- // Escape HTML first
1085
- .replace(R.ampersand, '&amp;')
1086
- .replace(R.lessThan, '&lt;')
1087
- .replace(R.greaterThan, '&gt;')
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
- const { key: foundKey, data: foundData } = found;
1144
- const { originalLine, content } = foundData;
1145
-
1146
- // Restore original line and content
1147
- lines[line] = originalLine;
1148
- lines.splice(line + 1, 0, ...content);
1149
-
1150
- // Remove from storage
1151
- this.collapsedData.delete(foundKey);
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
- // Collapse: read and store content
1154
- const match = currentLine.match(/^(\s*)"([^"]+)"\s*:\s*([{\[])/);
1155
- if (!match) return;
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
- // Update textarea
1165
- textarea.value = lines.join('\n');
1166
- this.updateHighlight();
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
- applyAutoCollapsed() {
1170
- const textarea = this.shadowRoot.getElementById('textarea');
1171
- if (!textarea || !textarea.value) return;
1172
-
1173
- const lines = textarea.value.split('\n');
1174
-
1175
- // Iterate backwards to avoid index issues when collapsing
1176
- for (let i = lines.length - 1; i >= 0; i--) {
1177
- const line = lines[i];
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 textarea = this.shadowRoot.getElementById('textarea');
1203
-
1204
- if (!textarea) return;
1205
-
1206
- // Use cached computed styles (computed once, reused)
1207
- if (this._cachedLineHeight === null) {
1208
- const styles = getComputedStyle(textarea);
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
- // Create gutter lines with DocumentFragment (single DOM update)
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
- lineElements.forEach((elements, line) => {
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
- gutterLine.style.top = `${paddingTop + line * lineHeight}px`;
1257
-
1258
- // Add visibility buttons first (leftmost)
1259
- elements.visibilityButtons.forEach(({ featureKey, isHidden }) => {
1260
- const button = document.createElement('button');
1261
- button.className = 'visibility-button' + (isHidden ? ' hidden' : '');
1262
- button.textContent = GeoJsonEditor.ICONS.visibility;
1263
- button.dataset.featureKey = featureKey;
1264
- button.title = isHidden ? 'Show feature in events' : 'Hide feature from events';
1265
- gutterLine.appendChild(button);
1266
- });
1267
-
1268
- // Add color indicators
1269
- elements.colors.forEach(({ color, attributeName }) => {
1270
- const indicator = document.createElement('div');
1271
- indicator.className = 'color-indicator';
1272
- indicator.style.backgroundColor = color;
1273
- indicator.dataset.line = line;
1274
- indicator.dataset.color = color;
1275
- indicator.dataset.attributeName = attributeName;
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
- existing.remove();
958
+ gutterLine.appendChild(collapseCol);
959
+
960
+ fragment.appendChild(gutterLine);
1319
961
  }
1320
-
1321
- // Create small color input positioned at the indicator
1322
- const colorInput = document.createElement('input');
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
- handleKeydownInCollapsedArea(e) {
1401
- // Allow navigation keys
1402
- const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Tab'];
1403
- if (navigationKeys.includes(e.key)) return;
1404
-
1405
- // Allow copy/cut/paste (handled separately)
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
- handleCopyWithCollapsedContent(e) {
1422
- const textarea = this.shadowRoot.getElementById('textarea');
1423
- const start = textarea.selectionStart;
1424
- const end = textarea.selectionEnd;
1425
-
1426
- if (start === end) return; // No selection
1427
-
1428
- const selectedText = textarea.value.substring(start, end);
1429
-
1430
- // Check if selection contains collapsed content
1431
- if (!selectedText.includes('{...}') && !selectedText.includes('[...]')) {
1432
- return; // No collapsed content, use default copy behavior
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
- let expandedText;
1436
-
1437
- // If selecting all content, use expandAllCollapsed directly (more reliable)
1438
- if (start === 0 && end === textarea.value.length) {
1439
- expandedText = this.expandAllCollapsed(selectedText);
1440
- } else {
1441
- // For partial selection, expand using line-by-line matching
1442
- expandedText = this.expandCollapsedMarkersInText(selectedText, start);
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
- // Put expanded text in clipboard
1446
- e.preventDefault();
1447
- e.clipboardData.setData('text/plain', expandedText);
1448
- }
1449
-
1450
- expandCollapsedMarkersInText(text, startPos) {
1451
- const textarea = this.shadowRoot.getElementById('textarea');
1452
- const beforeSelection = textarea.value.substring(0, startPos);
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
- emitChange() {
1517
- const textarea = this.shadowRoot.getElementById('textarea');
1518
-
1519
- // Expand ALL collapsed nodes to get full content
1520
- const editorContent = this.expandAllCollapsed(textarea.value);
1521
-
1522
- // Build complete value with prefix/suffix (fixed FeatureCollection wrapper)
1523
- const fullValue = this.prefix + editorContent + this.suffix;
1524
-
1525
- // Try to parse
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
- // Emit change event with parsed GeoJSON directly
1552
- this.dispatchEvent(new CustomEvent('change', {
1553
- detail: parsed,
1554
- bubbles: true,
1555
- composed: true
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
- this.hiddenFeatures.add(featureKey);
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
- this.updateHighlight();
1621
- this.updateGutter();
1622
- this.emitChange();
1623
- }
1624
-
1625
- // Parse JSON and extract feature ranges (line numbers for each Feature)
1626
- updateFeatureRanges() {
1627
- const textarea = this.shadowRoot.getElementById('textarea');
1628
- if (!textarea) return;
1629
-
1630
- const text = textarea.value;
1631
- this.featureRanges.clear();
1632
-
1633
- try {
1634
- // Expand collapsed content for parsing (collapsed markers like [...] are not valid JSON)
1635
- const expandedText = this.expandAllCollapsed(text);
1636
-
1637
- // Try to parse and find Features
1638
- const prefix = this.prefix;
1639
- const suffix = this.suffix;
1640
- const fullValue = prefix + expandedText + suffix;
1641
- const parsed = JSON.parse(fullValue);
1642
-
1643
- // parsed is always a FeatureCollection (from wrapper)
1644
- const features = parsed.features;
1645
-
1646
- // Now find each feature's line range in the text
1647
- const lines = text.split('\n');
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
- featureStartLine = startLine;
1679
- inFeature = true;
1680
-
1681
- // Start braceDepth at 1 since we're inside the Feature's opening brace
1682
- // Then count any additional braces from startLine to current line (ignoring strings)
1683
- braceDepth = 1;
1684
- for (let k = startLine; k <= i; k++) {
1685
- const scanLine = lines[k];
1686
- const counts = this._countBracketsOutsideStrings(scanLine, '{');
1687
- if (k === startLine) {
1688
- // Skip the first { we already counted
1689
- braceDepth += (counts.open - 1) - counts.close;
1690
- } else {
1691
- braceDepth += counts.open - counts.close;
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
- // Get the feature key
1696
- if (featureIndex < features.length) {
1697
- currentFeatureKey = this.getFeatureKey(features[featureIndex]);
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
- } else if (inFeature) {
1700
- // Count braces (ignoring those in strings)
1701
- const counts = this._countBracketsOutsideStrings(line, '{');
1702
- braceDepth += counts.open - counts.close;
1703
-
1704
- // Feature ends when braceDepth returns to 0
1705
- if (braceDepth <= 0) {
1706
- if (currentFeatureKey) {
1707
- this.featureRanges.set(currentFeatureKey, {
1708
- startLine: featureStartLine,
1709
- endLine: i,
1710
- featureIndex: featureIndex
1711
- });
1712
- }
1713
- featureIndex++;
1714
- inFeature = false;
1715
- currentFeatureKey = null;
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
- } catch (e) {
1720
- // Invalid JSON, can't extract feature ranges
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
- // Get hidden line ranges for highlighting
1725
- getHiddenLineRanges() {
1726
- const ranges = [];
1727
- for (const [featureKey, range] of this.featureRanges) {
1728
- if (this.hiddenFeatures.has(featureKey)) {
1729
- ranges.push(range);
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
- return ranges;
1272
+
1273
+ this.formatAndUpdate();
1733
1274
  }
1734
1275
 
1735
- // ========== GeoJSON Validation ==========
1736
-
1737
- // Validate GeoJSON structure and types
1738
- // context: 'root' | 'geometry' | 'properties'
1739
- validateGeoJSON(obj, path = '', context = 'root') {
1740
- const errors = [];
1741
-
1742
- if (!obj || typeof obj !== 'object') {
1743
- return errors;
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
- // Check for invalid type values based on context
1747
- if (context !== 'properties' && obj.type !== undefined) {
1748
- const typeValue = obj.type;
1749
- if (typeof typeValue === 'string') {
1750
- if (context === 'geometry') {
1751
- // In geometry: must be a geometry type
1752
- if (!GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue)) {
1753
- errors.push(`Invalid geometry type "${typeValue}" at ${path || 'root'} (expected: ${GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.join(', ')})`);
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
- // At root or in features: must be Feature
1757
- if (typeValue !== 'Feature') {
1758
- errors.push(`Invalid type "${typeValue}" at ${path || 'root'} (expected: Feature)`);
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
- for (const [key, value] of Object.entries(obj)) {
1771
- if (typeof value === 'object' && value !== null) {
1772
- const newPath = path ? `${path}.${key}` : key;
1773
- // Determine context for nested objects
1774
- let newContext = context;
1775
- if (key === 'properties') {
1776
- newContext = 'properties';
1777
- } else if (key === 'geometry' || key === 'geometries') {
1778
- newContext = 'geometry';
1779
- } else if (key === 'features') {
1780
- newContext = 'root'; // features contains Feature objects
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
- return errors;
1425
+
1426
+ this._lastStartIndex = -1;
1427
+ this._scrollToCursor();
1428
+ this.scheduleRender();
1788
1429
  }
1789
1430
 
1790
- // Helper: Count bracket depth change in a line, ignoring brackets inside strings
1791
- // Returns {open: count, close: count} for the specified bracket type
1792
- _countBracketsOutsideStrings(line, openBracket) {
1793
- const closeBracket = openBracket === '{' ? '}' : ']';
1794
- let openCount = 0;
1795
- let closeCount = 0;
1796
- let inString = false;
1797
- let escape = false;
1798
-
1799
- for (let i = 0; i < line.length; i++) {
1800
- const char = line[i];
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
- if (escape) {
1803
- escape = false;
1804
- continue;
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
- if (char === '\\' && inString) {
1808
- escape = true;
1809
- continue;
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
- if (char === '"') {
1813
- inString = !inString;
1814
- continue;
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
- if (!inString) {
1818
- if (char === openBracket) openCount++;
1819
- if (char === closeBracket) closeCount++;
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
- return { open: openCount, close: closeCount };
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
- // Helper: Check if bracket closes on same line (ignores brackets in strings)
1827
- bracketClosesOnSameLine(line, openBracket) {
1828
- const bracketPos = line.indexOf(openBracket);
1829
- if (bracketPos === -1) return false;
1830
-
1831
- const restOfLine = line.substring(bracketPos + 1);
1832
- const counts = this._countBracketsOutsideStrings(restOfLine, openBracket);
1833
-
1834
- // Depth starts at 1 (we're after the opening bracket)
1835
- // If closes equal or exceed opens + 1, the bracket closes on this line
1836
- return counts.close > counts.open;
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
- // Helper: Find closing bracket line starting from startLine
1840
- // Returns { endLine, content: string[] } or null if not found
1841
- _findClosingBracket(lines, startLine, openBracket) {
1842
- let depth = 1;
1843
- const content = [];
1844
-
1845
- // Count remaining brackets on the start line (after the opening bracket)
1846
- const startLineContent = lines[startLine];
1847
- const bracketPos = startLineContent.indexOf(openBracket);
1848
- if (bracketPos !== -1) {
1849
- const restOfStartLine = startLineContent.substring(bracketPos + 1);
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
- for (let i = startLine + 1; i < lines.length; i++) {
1858
- const scanLine = lines[i];
1859
- const counts = this._countBracketsOutsideStrings(scanLine, openBracket);
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
- return null; // Not found (malformed JSON)
1558
+ text += this.lines[end.line].substring(0, end.column);
1559
+
1560
+ return text;
1870
1561
  }
1871
1562
 
1872
1563
  /**
1873
- * Helper: Perform collapse operation on a node at given line
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
- _performCollapse(lines, lineIndex, nodeKey, indent, openBracket) {
1884
- const line = lines[lineIndex];
1885
- const closeBracket = openBracket === '{' ? '}' : ']';
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
- // Skip if bracket closes on same line
1888
- if (this.bracketClosesOnSameLine(line, openBracket)) return 0;
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
- // Find closing bracket
1891
- const result = this._findClosingBracket(lines, lineIndex, openBracket);
1892
- if (!result) return 0;
1590
+ /**
1591
+ * Clear the current selection
1592
+ */
1593
+ _clearSelection() {
1594
+ this.selectionStart = null;
1595
+ this.selectionEnd = null;
1596
+ }
1893
1597
 
1894
- const { endLine, content } = result;
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
- // Store the original data with unique key
1897
- const uniqueKey = `${lineIndex}-${nodeKey}`;
1898
- this.collapsedData.set(uniqueKey, {
1899
- originalLine: line,
1900
- content: content,
1901
- indent: indent.length,
1902
- nodeKey: nodeKey
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
- // Replace with marker
1906
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
1907
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1908
- lines[lineIndex] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
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
- // Remove content lines
1911
- const linesRemoved = endLine - lineIndex;
1912
- lines.splice(lineIndex + 1, linesRemoved);
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
- return linesRemoved;
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
- // Helper: Expand all collapsed markers and return expanded content
1918
- expandAllCollapsed(content) {
1919
- const R = GeoJsonEditor.REGEX;
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
- while (content.includes('{...}') || content.includes('[...]')) {
1922
- const lines = content.split('\n');
1923
- let expanded = false;
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
- for (let i = 0; i < lines.length; i++) {
1926
- const line = lines[i];
1927
- if (!line.includes('{...}') && !line.includes('[...]')) continue;
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
- const match = line.match(R.collapsedMarker);
1930
- if (!match) continue;
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
- const nodeKey = match[2];
1933
- const currentIndent = match[1].length;
1934
- const found = this._findCollapsedData(i, nodeKey, currentIndent);
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
- if (found) {
1937
- const { data: { originalLine, content: nodeContent } } = found;
1938
- lines[i] = originalLine;
1939
- lines.splice(i + 1, 0, ...nodeContent);
1940
- expanded = true;
1941
- break;
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
- if (!expanded) break;
1946
- content = lines.join('\n');
1947
- }
1948
- return content;
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
- // Helper: Format JSON content (always in FeatureCollection mode)
1952
- // Also applies default properties to features if configured
1953
- formatJSONContent(content) {
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
- const formatted = JSON.stringify(parsed, null, 2);
1963
- const lines = formatted.split('\n');
1964
- return lines.length > 2 ? lines.slice(1, -1).join('\n') : '';
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
- autoFormatContentWithCursor() {
1968
- const textarea = this.shadowRoot.getElementById('textarea');
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 formattedContent = this.formatJSONContent(content);
1988
-
1989
- if (formattedContent !== content) {
1990
- this.collapsedData.clear();
1991
- textarea.value = formattedContent;
1992
-
1993
- if (collapsedNodes.length > 0) {
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, don't format
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
- reapplyCollapsed(collapsedNodes) {
2015
- const textarea = this.shadowRoot.getElementById('textarea');
2016
- const lines = textarea.value.split('\n');
2017
-
2018
- // Group collapsed nodes by nodeKey+indent and count occurrences
2019
- const collapseMap = new Map();
2020
- collapsedNodes.forEach(({nodeKey, indent}) => {
2021
- const key = `${nodeKey}-${indent}`;
2022
- collapseMap.set(key, (collapseMap.get(key) || 0) + 1);
2023
- });
2024
-
2025
- // Track occurrences as we iterate
2026
- const occurrenceCount = new Map();
2027
-
2028
- // Iterate backwards to avoid index issues
2029
- for (let i = lines.length - 1; i >= 0; i--) {
2030
- const line = lines[i];
2031
- const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
2032
-
2033
- if (match) {
2034
- const nodeKey = match[2];
2035
- const currentIndent = match[1].length;
2036
- const key = `${nodeKey}-${currentIndent}`;
2037
-
2038
- if (collapseMap.has(key)) {
2039
- // Count this occurrence
2040
- occurrenceCount.set(key, (occurrenceCount.get(key) || 0) + 1);
2041
- const currentOccurrence = occurrenceCount.get(key);
2042
-
2043
- // Only collapse if this occurrence should be collapsed
2044
- if (currentOccurrence <= collapseMap.get(key)) {
2045
- const indent = match[1];
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
- // Parse selector and generate CSS rule for dark theme
2060
- parseSelectorToHostRule(selector) {
2061
- if (!selector || selector === '') {
2062
- // Fallback: use data attribute on host element
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
- // Check if it's a simple class on host (.dark)
2067
- if (selector.startsWith('.') && !selector.includes(' ')) {
2068
- return `:host(${selector})`;
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
- // Complex selector - use :host-context for parent elements
2072
- return `:host-context(${selector})`;
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
- // Generate and inject theme CSS based on dark selector
2031
+ // ========== Theme ==========
2032
+
2076
2033
  updateThemeCSS() {
2077
2034
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
2078
- const darkRule = this.parseSelectorToHostRule(darkSelector);
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
- // Helper to generate CSS variables from theme object
2089
- const generateVars = (themeObj) => {
2090
- return Object.entries(themeObj || {})
2091
- .map(([key, value]) => `--${GeoJsonEditor._toKebabCase(key)}: ${value};`)
2092
- .join('\n ');
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
- // Light theme: only overrides (defaults are in static CSS)
2096
- const lightVars = generateVars(this.themes.light);
2097
-
2098
- // Dark theme: ALWAYS generate with defaults + overrides (selector is dynamic)
2099
- const darkTheme = { ...GeoJsonEditor.DARK_THEME_DEFAULTS, ...this.themes.dark };
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
- // Public API: Theme management
2113
- setTheme(theme) {
2114
- if (theme.dark) {
2115
- this.themes.dark = { ...this.themes.dark, ...theme.dark };
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
- // Features API - Programmatic manipulation
2130
- // ========================================
2131
-
2132
- /**
2133
- * Normalize a Python-style index (supports negative values)
2134
- * @param {number} index - Index to normalize (negative = from end)
2135
- * @param {number} length - Length of the array
2136
- * @param {boolean} clamp - If true, clamp to valid range; if false, return -1 for out of bounds
2137
- * @returns {number} Normalized index, or -1 if out of bounds (when clamp=false)
2138
- * @private
2139
- */
2140
- _normalizeIndex(index, length, clamp = false) {
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 (idx < 0 || idx >= length) ? -1 : idx;
2115
+ return `hash:${geomType}:${hash.toString(36)}`;
2149
2116
  }
2150
2117
 
2151
- /**
2152
- * Parse current textarea content into an array of features
2153
- * @returns {Array} Array of feature objects
2154
- * @private
2155
- */
2156
- _parseFeatures() {
2157
- const textarea = this.shadowRoot.getElementById('textarea');
2158
- if (!textarea || !textarea.value.trim()) {
2159
- return [];
2160
- }
2161
-
2162
- try {
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
- * Update textarea with features array and trigger all updates
2175
- * @param {Array} features - Array of feature objects
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
- _setFeatures(features) {
2179
- const textarea = this.shadowRoot.getElementById('textarea');
2180
- if (!textarea) return;
2181
-
2182
- // Clear internal state when replacing features (prevent memory leaks)
2183
- this.collapsedData.clear();
2184
- this.hiddenFeatures.clear();
2185
-
2186
- if (!features || features.length === 0) {
2187
- textarea.value = '';
2188
- } else {
2189
- // Format each feature and join with comma
2190
- const formatted = features
2191
- .map(f => JSON.stringify(f, null, 2))
2192
- .join(',\n');
2193
-
2194
- textarea.value = formatted;
2195
- }
2196
-
2197
- // Trigger all updates
2198
- this.updateHighlight();
2199
- this.updatePlaceholderVisibility();
2200
-
2201
- // Auto-collapse coordinates
2202
- if (textarea.value) {
2203
- requestAnimationFrame(() => {
2204
- this.applyAutoCollapsed();
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
- // Emit change event
2209
- this.emitChange();
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
- * Validate a single feature object
2214
- * @param {Object} feature - Feature object to validate
2215
- * @returns {string[]} Array of validation error messages (empty if valid)
2216
- * @private
2217
- */
2218
- _validateFeature(feature) {
2219
- const errors = [];
2220
-
2221
- if (!feature || typeof feature !== 'object') {
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
- if (Array.isArray(feature)) {
2227
- errors.push('Feature cannot be an array');
2228
- return errors;
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
- // Check required type field
2232
- if (!('type' in feature)) {
2233
- errors.push('Feature must have a "type" property');
2234
- } else if (feature.type !== 'Feature') {
2235
- errors.push(`Feature type must be "Feature", got "${feature.type}"`);
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
- // Check geometry field exists (can be null for features without location)
2239
- if (!('geometry' in feature)) {
2240
- errors.push('Feature must have a "geometry" property (can be null)');
2241
- } else if (feature.geometry !== null) {
2242
- // Validate geometry if not null
2243
- if (typeof feature.geometry !== 'object' || Array.isArray(feature.geometry)) {
2244
- errors.push('Feature geometry must be an object or null');
2245
- } else {
2246
- // Check geometry has valid type
2247
- if (!('type' in feature.geometry)) {
2248
- errors.push('Geometry must have a "type" property');
2249
- } else if (!GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(feature.geometry.type)) {
2250
- errors.push(`Invalid geometry type "${feature.geometry.type}" (expected: ${GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.join(', ')})`);
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, '&amp;')
2257
+ .replace(/</g, '&lt;')
2258
+ .replace(/>/g, '&gt;');
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
- // Check geometry has coordinates
2254
- if (!('coordinates' in feature.geometry)) {
2255
- errors.push('Geometry must have a "coordinates" property');
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
- // Check properties field exists (can be null)
2261
- if (!('properties' in feature)) {
2262
- errors.push('Feature must have a "properties" property (can be null)');
2263
- } else if (feature.properties !== null && (typeof feature.properties !== 'object' || Array.isArray(feature.properties))) {
2264
- errors.push('Feature properties must be an object or null');
2265
- }
2266
-
2267
- return errors;
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
- * Replace all features with the given array
2272
- * @param {Array} features - Array of feature objects to set
2273
- * @throws {Error} If features is not an array or contains invalid features
2274
- */
2275
- set(features) {
2276
- if (!Array.isArray(features)) {
2277
- throw new Error('set() expects an array of features');
2278
- }
2279
-
2280
- // Validate each feature
2281
- const allErrors = [];
2282
- features.forEach((feature, index) => {
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
- if (allErrors.length > 0) {
2290
- throw new Error(`Invalid features: ${allErrors.join('; ')}`);
2291
- }
2292
-
2293
- // Apply default properties to each feature
2294
- const featuresWithDefaults = features.map(f => this._applyDefaultPropertiesToFeature(f));
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
- // Apply default properties before adding
2311
- features.push(this._applyDefaultPropertiesToFeature(feature));
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 = this._normalizeIndex(index, features.length, true);
2329
-
2330
- // Apply default properties before inserting
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
- if (features.length === 0) return undefined;
2343
-
2344
- const idx = this._normalizeIndex(index, features.length);
2345
- if (idx === -1) return undefined;
2346
-
2347
- const removed = features.splice(idx, 1)[0];
2348
- this._setFeatures(features);
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._setFeatures([]);
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
- if (features.length === 0) return undefined;
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;