@softwarity/geojson-editor 1.0.9 → 1.0.11

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,2116 +1,2091 @@
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
- .visibility-button {
437
- width: 14px;
438
- height: 14px;
439
- background: transparent;
440
- color: var(--control-color, #000080);
441
- border: none;
442
- cursor: pointer;
443
- display: flex;
444
- align-items: center;
445
- justify-content: center;
446
- transition: all 0.1s;
447
- flex-shrink: 0;
448
- opacity: 0.7;
449
- padding: 0;
450
- font-size: 11px;
451
- }
452
- .visibility-button:hover { opacity: 1; transform: scale(1.15); }
453
- .visibility-button.hidden { opacity: 0.35; }
454
-
455
- .line-hidden { opacity: 0.35; filter: grayscale(50%); }
456
-
457
- .editor-content {
458
- position: relative;
459
- flex: 1;
460
- overflow: hidden;
461
- }
462
-
463
- .highlight-layer, textarea, .placeholder-layer {
464
- position: absolute;
465
- inset: 0;
466
- padding: 8px 12px;
467
- white-space: pre-wrap;
468
- word-wrap: break-word;
469
- }
470
-
471
- .highlight-layer {
472
- overflow: auto;
473
- pointer-events: none;
474
- z-index: 1;
475
- color: var(--text-color, #000);
476
- }
477
- .highlight-layer::-webkit-scrollbar { display: none; }
478
-
479
- textarea {
480
- margin: 0;
481
- border: none;
482
- outline: none;
483
- background: transparent;
484
- color: transparent;
485
- caret-color: var(--caret-color, #000);
486
- resize: none;
487
- overflow: auto;
488
- z-index: 2;
489
- }
490
- textarea::selection { background: rgba(51,153,255,0.3); }
491
- textarea::placeholder { color: transparent; }
492
- textarea:disabled { cursor: not-allowed; opacity: 0.6; }
493
-
494
- .placeholder-layer {
495
- color: #6a6a6a;
496
- pointer-events: none;
497
- z-index: 0;
498
- overflow: hidden;
499
- }
500
-
501
- .json-key { color: var(--json-key, #660e7a); }
502
- .json-string { color: var(--json-string, #008000); }
503
- .json-number { color: var(--json-number, #00f); }
504
- .json-boolean, .json-null { color: var(--json-boolean, #000080); }
505
- .json-punctuation { color: var(--json-punct, #000); }
506
- .json-key-invalid { color: var(--json-key-invalid, #f00); }
507
-
508
- .geojson-key { color: var(--geojson-key, #660e7a); font-weight: 600; }
509
- .geojson-type { color: var(--geojson-type, #008000); font-weight: 600; }
510
- .geojson-type-invalid { color: var(--geojson-type-invalid, #f00); font-weight: 600; }
511
-
512
- .prefix-wrapper, .suffix-wrapper {
513
- display: flex;
514
- flex-shrink: 0;
515
- background: var(--bg-color, #fff);
516
- }
283
+ attributeChangedCallback(name, oldValue, newValue) {
284
+ if (oldValue === newValue) return;
517
285
 
518
- .prefix-gutter, .suffix-gutter {
519
- width: 24px;
520
- background: var(--gutter-bg, #f0f0f0);
521
- border-right: 1px solid var(--gutter-border, #e0e0e0);
522
- flex-shrink: 0;
523
- }
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
+ }
524
323
 
525
- .editor-prefix, .editor-suffix {
526
- flex: 1;
527
- padding: 4px 12px;
528
- color: var(--text-color, #000);
529
- background: var(--bg-color, #fff);
530
- user-select: none;
531
- white-space: pre-wrap;
532
- word-wrap: break-word;
533
- 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;
534
351
  }
535
-
536
- .prefix-wrapper { border-bottom: 1px solid rgba(255,255,255,0.1); }
537
- .suffix-wrapper { border-top: 1px solid rgba(255,255,255,0.1); position: relative; }
538
-
539
- .clear-btn {
540
- position: absolute;
541
- right: 0.5rem;
542
- top: 50%;
543
- transform: translateY(-50%);
544
- background: transparent;
545
- border: none;
546
- color: var(--text-color, #000);
547
- opacity: 0.3;
548
- cursor: pointer;
549
- font-size: 0.65rem;
550
- width: 1rem;
551
- height: 1rem;
552
- padding: 0.15rem 0 0 0;
553
- border-radius: 3px;
554
- display: flex;
555
- align-items: center;
556
- justify-content: center;
557
- 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;
558
362
  }
559
- .clear-btn:hover { opacity: 0.7; background: rgba(255,255,255,0.1); }
560
- .clear-btn[hidden] { display: none; }
561
-
562
- textarea::-webkit-scrollbar { width: 10px; height: 10px; }
563
- textarea::-webkit-scrollbar-track { background: var(--control-bg, #e8e8e8); }
564
- textarea::-webkit-scrollbar-thumb { background: var(--control-border, #c0c0c0); border-radius: 5px; }
565
- textarea::-webkit-scrollbar-thumb:hover { background: var(--control-color, #000080); }
566
- textarea { scrollbar-width: thin; scrollbar-color: var(--control-border, #c0c0c0) var(--control-bg, #e8e8e8); }
567
- </style>
568
- `;
569
-
570
- const template = `
571
- <div class="prefix-wrapper">
572
- <div class="prefix-gutter"></div>
573
- <div class="editor-prefix" id="editorPrefix"></div>
574
- </div>
575
- <div class="editor-wrapper">
576
- <div class="gutter">
577
- <div class="gutter-content" id="gutterContent"></div>
578
- </div>
579
- <div class="editor-content">
580
- <div class="placeholder-layer" id="placeholderLayer">${this.escapeHtml(this.placeholder)}</div>
581
- <div class="highlight-layer" id="highlightLayer"></div>
582
- <textarea
583
- id="textarea"
584
- spellcheck="false"
585
- autocomplete="off"
586
- autocorrect="off"
587
- autocapitalize="off"
588
- ></textarea>
589
- </div>
590
- </div>
591
- <div class="suffix-wrapper">
592
- <div class="suffix-gutter"></div>
593
- <div class="editor-suffix" id="editorSuffix"></div>
594
- <button class="clear-btn" id="clearBtn" title="Clear editor">✕</button>
595
- </div>
596
- `;
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
+ });
597
421
 
598
- this.shadowRoot.innerHTML = styles + template;
599
- }
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
+ });
600
428
 
601
- setupEventListeners() {
602
- const textarea = this.shadowRoot.getElementById('textarea');
603
- const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
604
-
605
- // Sync scroll between textarea and highlight layer
606
- textarea.addEventListener('scroll', () => {
607
- highlightLayer.scrollTop = textarea.scrollTop;
608
- highlightLayer.scrollLeft = textarea.scrollLeft;
609
- 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();
610
433
  });
611
434
 
612
- // Input handling with debounced highlight and auto-format
613
- textarea.addEventListener('input', () => {
614
- // Update placeholder visibility immediately (no debounce)
615
- this.updatePlaceholderVisibility();
616
-
617
- clearTimeout(this.highlightTimer);
618
- this.highlightTimer = setTimeout(() => {
619
- // Auto-format JSON content
620
- this.autoFormatContentWithCursor();
621
- this.updateHighlight();
622
- this.emitChange();
623
- }, 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
+ }
624
451
  });
625
452
 
626
- // Paste handling - trigger immediately without debounce
627
- textarea.addEventListener('paste', () => {
628
- // Clear any pending highlight timer to avoid duplicate processing
629
- clearTimeout(this.highlightTimer);
630
-
631
- // Use a short delay to let the paste complete
632
- setTimeout(() => {
633
- this.updatePlaceholderVisibility();
634
- // Auto-format JSON content
635
- this.autoFormatContentWithCursor();
636
- this.updateHighlight();
637
- this.emitChange();
638
- // Auto-collapse coordinates after paste
639
- this.applyAutoCollapsed();
640
- }, 10);
453
+ // Input handling (hidden textarea)
454
+ hiddenTextarea.addEventListener('input', () => {
455
+ this.handleInput();
641
456
  });
642
457
 
643
- // Gutter clicks (color indicators, boolean checkboxes, and collapse buttons)
644
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
645
- gutterContent.addEventListener('click', (e) => {
646
- // Check for visibility button (may click on SVG inside button)
647
- const visibilityButton = e.target.closest('.visibility-button');
648
- if (visibilityButton) {
649
- const featureKey = visibilityButton.dataset.featureKey;
650
- this.toggleFeatureVisibility(featureKey);
651
- return;
652
- }
458
+ hiddenTextarea.addEventListener('keydown', (e) => {
459
+ this.handleKeydown(e);
460
+ });
653
461
 
654
- if (e.target.classList.contains('color-indicator')) {
655
- const line = parseInt(e.target.dataset.line);
656
- const color = e.target.dataset.color;
657
- const attributeName = e.target.dataset.attributeName;
658
- this.showColorPicker(e.target, line, color, attributeName);
659
- } else if (e.target.classList.contains('boolean-checkbox')) {
660
- const line = parseInt(e.target.dataset.line);
661
- const attributeName = e.target.dataset.attributeName;
662
- const newValue = e.target.checked;
663
- this.updateBooleanValue(line, newValue, attributeName);
664
- } else if (e.target.classList.contains('collapse-button')) {
665
- const nodeKey = e.target.dataset.nodeKey;
666
- const line = parseInt(e.target.dataset.line);
667
- this.toggleCollapse(nodeKey, line);
668
- }
462
+ // Paste handling
463
+ hiddenTextarea.addEventListener('paste', (e) => {
464
+ this.handlePaste(e);
669
465
  });
670
466
 
671
- // Transfer wheel scroll from gutter to textarea
672
- const gutter = this.shadowRoot.querySelector('.gutter');
673
- gutter.addEventListener('wheel', (e) => {
674
- e.preventDefault();
675
- textarea.scrollTop += e.deltaY;
467
+ // Copy handling
468
+ hiddenTextarea.addEventListener('copy', (e) => {
469
+ this.handleCopy(e);
676
470
  });
677
471
 
678
- // Block editing in collapsed areas
679
- textarea.addEventListener('keydown', (e) => {
680
- this.handleKeydownInCollapsedArea(e);
472
+ // Cut handling
473
+ hiddenTextarea.addEventListener('cut', (e) => {
474
+ this.handleCut(e);
681
475
  });
682
476
 
683
- // Handle copy to include collapsed content
684
- textarea.addEventListener('copy', (e) => {
685
- 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();
686
485
  });
687
486
 
688
- // Handle cut to include collapsed content
689
- textarea.addEventListener('cut', (e) => {
690
- this.handleCutWithCollapsedContent(e);
487
+ // Wheel on gutter -> scroll viewport
488
+ gutter.addEventListener('wheel', (e) => {
489
+ e.preventDefault();
490
+ viewport.scrollTop += e.deltaY;
691
491
  });
692
492
 
693
493
  // Clear button
694
- const clearBtn = this.shadowRoot.getElementById('clearBtn');
695
494
  clearBtn.addEventListener('click', () => {
696
495
  this.removeAll();
697
496
  });
698
497
 
699
- // Update readonly state
498
+ // Initial readonly state
700
499
  this.updateReadonly();
701
500
  }
702
501
 
703
- syncGutterScroll(scrollTop) {
704
- const gutterContent = this.shadowRoot.getElementById('gutterContent');
705
- gutterContent.style.transform = `translateY(-${scrollTop}px)`;
706
- }
707
-
708
- updateReadonly() {
709
- const textarea = this.shadowRoot.getElementById('textarea');
710
- if (textarea) {
711
- 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
+ }
712
523
  }
713
- // Hide clear button in readonly mode
714
- const clearBtn = this.shadowRoot.getElementById('clearBtn');
715
- if (clearBtn) {
716
- 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
+ });
717
542
  }
543
+
544
+ this.emitChange();
718
545
  }
719
546
 
720
- escapeHtml(text) {
721
- if (!text) return '';
722
- const R = GeoJsonEditor.REGEX;
723
- return text
724
- .replace(R.ampersand, '&amp;')
725
- .replace(R.lessThan, '&lt;')
726
- .replace(R.greaterThan, '&gt;');
547
+ /**
548
+ * Get full content as string (expanded, no hidden markers)
549
+ */
550
+ getContent() {
551
+ return this.lines.join('\n');
727
552
  }
728
553
 
729
- updatePlaceholderVisibility() {
730
- const textarea = this.shadowRoot.getElementById('textarea');
731
- const placeholderLayer = this.shadowRoot.getElementById('placeholderLayer');
732
- if (textarea && placeholderLayer) {
733
- placeholderLayer.style.display = textarea.value ? 'none' : 'block';
734
- }
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();
735
565
  }
736
566
 
737
- updatePlaceholderContent() {
738
- const placeholderLayer = this.shadowRoot.getElementById('placeholderLayer');
739
- if (placeholderLayer) {
740
- placeholderLayer.textContent = this.placeholder;
741
- }
742
- 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();
743
574
  }
744
575
 
745
- updateValue(newValue) {
746
- const textarea = this.shadowRoot.getElementById('textarea');
747
- if (textarea && textarea.value !== newValue) {
748
- textarea.value = newValue || '';
749
-
750
- // Auto-format JSON content
751
- if (newValue) {
752
- try {
753
- // Wrap content in array brackets for validation and formatting
754
- const wrapped = '[' + newValue + ']';
755
- const parsed = JSON.parse(wrapped);
756
- const formatted = JSON.stringify(parsed, null, 2);
757
-
758
- // Remove first [ and last ] from formatted
759
- const lines = formatted.split('\n');
760
- if (lines.length > 2) {
761
- textarea.value = lines.slice(1, -1).join('\n');
762
- } else {
763
- textarea.value = '';
764
- }
765
- } catch (e) {
766
- // Invalid JSON, keep as-is
767
- }
768
- }
769
-
770
- this.updateHighlight();
771
- this.updatePlaceholderVisibility();
772
-
773
- // Auto-collapse coordinates nodes after value is set
774
- if (textarea.value) {
775
- requestAnimationFrame(() => {
776
- this.applyAutoCollapsed();
777
- });
778
- }
779
-
780
- // Emit change/error event for programmatic value changes
781
- this.emitChange();
782
- }
783
- }
784
-
785
- updatePrefixSuffix() {
786
- const prefixEl = this.shadowRoot.getElementById('editorPrefix');
787
- const suffixEl = this.shadowRoot.getElementById('editorSuffix');
788
-
789
- // Always show prefix/suffix (always in FeatureCollection mode)
790
- if (prefixEl) {
791
- prefixEl.textContent = this.prefix;
792
- }
793
-
794
- if (suffixEl) {
795
- suffixEl.textContent = this.suffix;
796
- }
797
- }
798
-
799
- updateHighlight() {
800
- const textarea = this.shadowRoot.getElementById('textarea');
801
- const highlightLayer = this.shadowRoot.getElementById('highlightLayer');
802
-
803
- if (!textarea || !highlightLayer) return;
804
-
805
- const text = textarea.value;
806
-
807
- // Update feature ranges for visibility tracking
808
- this.updateFeatureRanges();
809
-
810
- // Get hidden line ranges
811
- const hiddenRanges = this.getHiddenLineRanges();
812
-
813
- // Parse and highlight
814
- const { highlighted, colors, booleans, toggles } = this.highlightJSON(text, hiddenRanges);
815
-
816
- highlightLayer.innerHTML = highlighted;
817
- this.colorPositions = colors;
818
- this.booleanPositions = booleans;
819
- this.nodeTogglePositions = toggles;
820
-
821
- // Update gutter with color indicators
822
- this.updateGutter();
823
- }
824
-
825
- highlightJSON(text, hiddenRanges = []) {
826
- if (!text.trim()) {
827
- return { highlighted: '', colors: [], booleans: [], toggles: [] };
828
- }
829
-
830
- const lines = text.split('\n');
831
- const colors = [];
832
- const booleans = [];
833
- const toggles = [];
834
- let highlightedLines = [];
835
-
836
- // Build context map for validation
837
- const contextMap = this.buildContextMap(text);
838
-
839
- // Helper to check if a line is in a hidden range
840
- const isLineHidden = (lineIndex) => {
841
- return hiddenRanges.some(range => lineIndex >= range.startLine && lineIndex <= range.endLine);
842
- };
843
-
844
- lines.forEach((line, lineIndex) => {
845
- // Detect any hex color (6 digits) in string values
846
- const R = GeoJsonEditor.REGEX;
847
- R.colorInLine.lastIndex = 0; // Reset for global regex
848
- let colorMatch;
849
- while ((colorMatch = R.colorInLine.exec(line)) !== null) {
850
- colors.push({
851
- line: lineIndex,
852
- color: colorMatch[2], // The hex color
853
- attributeName: colorMatch[1] // The attribute name
854
- });
855
- }
856
-
857
- // Detect boolean values in properties
858
- R.booleanInLine.lastIndex = 0; // Reset for global regex
859
- let booleanMatch;
860
- while ((booleanMatch = R.booleanInLine.exec(line)) !== null) {
861
- booleans.push({
862
- line: lineIndex,
863
- value: booleanMatch[2] === 'true', // The boolean value
864
- attributeName: booleanMatch[1] // The attribute name
865
- });
866
- }
867
-
868
- // Detect collapsible nodes (all nodes are collapsible)
869
- const nodeMatch = line.match(R.collapsibleNode);
870
- if (nodeMatch) {
871
- const nodeKey = nodeMatch[2];
872
-
873
- // Check if this is a collapsed marker first
874
- const isCollapsed = line.includes('{...}') || line.includes('[...]');
875
-
876
- if (isCollapsed) {
877
- // It's collapsed, always show button
878
- toggles.push({
879
- line: lineIndex,
880
- nodeKey,
881
- isCollapsed: true
882
- });
883
- } else {
884
- // Not collapsed - only add toggle button if it doesn't close on same line
885
- if (!this.bracketClosesOnSameLine(line, nodeMatch[3])) {
886
- toggles.push({
887
- line: lineIndex,
888
- nodeKey,
889
- isCollapsed: false
890
- });
891
- }
892
- }
893
- }
894
-
895
- // Highlight the line with context
896
- const context = contextMap.get(lineIndex);
897
- let highlightedLine = this.highlightSyntax(line, context);
898
-
899
- // Wrap hidden lines with .line-hidden class
900
- if (isLineHidden(lineIndex)) {
901
- highlightedLine = `<span class="line-hidden">${highlightedLine}</span>`;
902
- }
903
-
904
- highlightedLines.push(highlightedLine);
905
- });
906
-
907
- return {
908
- highlighted: highlightedLines.join('\n'),
909
- colors,
910
- booleans,
911
- toggles
912
- };
913
- }
914
-
915
- // GeoJSON type constants (consolidated)
916
- static GEOJSON = {
917
- GEOMETRY_TYPES: ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'],
918
- };
919
-
920
- // Valid keys per context (null = any key is valid)
921
- static VALID_KEYS_BY_CONTEXT = {
922
- Feature: ['type', 'geometry', 'properties', 'id'],
923
- properties: null, // Any key valid in properties
924
- geometry: ['type', 'coordinates'], // Generic geometry context
925
- };
926
-
927
- // Keys that change context for their value
928
- static CONTEXT_CHANGING_KEYS = {
929
- geometry: 'geometry',
930
- properties: 'properties',
931
- features: 'Feature', // Array of Features
932
- };
933
-
934
- // Build context map for each line by analyzing JSON structure
935
- buildContextMap(text) {
936
- const lines = text.split('\n');
937
- const contextMap = new Map(); // line index -> context
938
- const contextStack = []; // Stack of {context, isArray}
939
- let pendingContext = null; // Context for next object/array
940
-
941
- // Root context is always 'Feature' (always in FeatureCollection mode)
942
- const rootContext = 'Feature';
943
-
944
- for (let i = 0; i < lines.length; i++) {
945
- const line = lines[i];
946
-
947
- // Record context at START of line (for key validation)
948
- const lineContext = contextStack.length > 0
949
- ? contextStack[contextStack.length - 1]?.context
950
- : rootContext;
951
- contextMap.set(i, lineContext);
952
-
953
- // Process each character to track brackets for subsequent lines
954
- // Track string state to ignore brackets inside strings
955
- let inString = false;
956
- let escape = false;
957
-
958
- for (let j = 0; j < line.length; j++) {
959
- const char = line[j];
960
-
961
- // Handle escape sequences
962
- if (escape) {
963
- escape = false;
964
- continue;
965
- }
966
- if (char === '\\' && inString) {
967
- escape = true;
968
- continue;
969
- }
970
-
971
- // Track string boundaries
972
- if (char === '"') {
973
- if (!inString) {
974
- // Entering string - check for special patterns before toggling
975
- const keyMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"\s*:/);
976
- if (keyMatch) {
977
- const keyName = keyMatch[1];
978
- if (GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName]) {
979
- pendingContext = GeoJsonEditor.CONTEXT_CHANGING_KEYS[keyName];
980
- }
981
- j += keyMatch[0].length - 1; // Skip past the key
982
- continue;
983
- }
984
-
985
- // Check for type value to refine context: "type": "Point"
986
- if (contextStack.length > 0) {
987
- const typeMatch = line.substring(0, j).match(/"type"\s*:\s*$/);
988
- if (typeMatch) {
989
- const valueMatch = line.substring(j).match(/^"([^"\\]*(?:\\.[^"\\]*)*)"/);
990
- const validTypes = ['Feature', ...GeoJsonEditor.GEOJSON.GEOMETRY_TYPES];
991
- if (valueMatch && validTypes.includes(valueMatch[1])) {
992
- const currentCtx = contextStack[contextStack.length - 1];
993
- if (currentCtx) {
994
- currentCtx.context = valueMatch[1];
995
- }
996
- }
997
- // Skip past this string value
998
- j += valueMatch ? valueMatch[0].length - 1 : 0;
999
- continue;
1000
- }
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;
1001
606
  }
1002
607
  }
1003
- inString = !inString;
1004
- continue;
1005
- }
1006
-
1007
- // Skip everything inside strings (brackets, etc.)
1008
- if (inString) continue;
1009
-
1010
- // Opening bracket - push context
1011
- if (char === '{' || char === '[') {
1012
- let newContext;
1013
- if (pendingContext) {
1014
- newContext = pendingContext;
1015
- pendingContext = null;
1016
- } else if (contextStack.length === 0) {
1017
- newContext = rootContext;
1018
- } else {
1019
- const parent = contextStack[contextStack.length - 1];
1020
- if (parent && parent.isArray) {
1021
- newContext = parent.context;
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;
1022
617
  } else {
1023
- newContext = null;
618
+ braceDepth += counts.open - counts.close;
1024
619
  }
1025
620
  }
1026
- contextStack.push({ context: newContext, isArray: char === '[' });
1027
- }
1028
-
1029
- // Closing bracket - pop context
1030
- if (char === '}' || char === ']') {
1031
- if (contextStack.length > 0) {
1032
- contextStack.pop();
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;
1033
640
  }
1034
641
  }
1035
642
  }
643
+ } catch (e) {
644
+ // Invalid JSON
1036
645
  }
1037
-
1038
- return contextMap;
1039
646
  }
1040
647
 
1041
- // All known GeoJSON structural keys (always valid in GeoJSON)
1042
- // GeoJSON structural keys that are always valid (not user properties)
1043
- static GEOJSON_STRUCTURAL_KEYS = ['type', 'geometry', 'properties', 'coordinates', 'id'];
1044
-
1045
- highlightSyntax(text, context) {
1046
- if (!text.trim()) return '';
1047
-
1048
- // Get valid keys for current context
1049
- const validKeys = context ? GeoJsonEditor.VALID_KEYS_BY_CONTEXT[context] : null;
1050
-
1051
- // Helper to check if a key is valid in current context
1052
- const isKeyValid = (key) => {
1053
- // GeoJSON structural keys are always valid
1054
- if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) return true;
1055
- // No context or null validKeys means all keys are valid
1056
- if (!context || validKeys === null || validKeys === undefined) return true;
1057
- return validKeys.includes(key);
1058
- };
1059
-
1060
- // Helper to check if a type value is valid in current context
1061
- const isTypeValid = (typeValue) => {
1062
- // Unknown context - don't validate
1063
- if (!context) return true;
1064
- if (context === 'properties') return true; // Any type in properties
1065
- if (context === 'geometry' || GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(context)) {
1066
- return GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue);
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;
670
+ let colorMatch;
671
+ while ((colorMatch = colorRegex.exec(line)) !== null) {
672
+ meta.colors.push({ attributeName: colorMatch[1], color: colorMatch[2] });
1067
673
  }
1068
- // In Feature context: accept Feature or geometry types
1069
- if (context === 'Feature') {
1070
- return typeValue === 'Feature' || GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue);
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' });
1071
680
  }
1072
- return true; // Unknown context - accept any type
1073
- };
1074
-
1075
- const R = GeoJsonEditor.REGEX;
1076
-
1077
- return text
1078
- // Escape HTML first
1079
- .replace(R.ampersand, '&amp;')
1080
- .replace(R.lessThan, '&lt;')
1081
- .replace(R.greaterThan, '&gt;')
1082
- // All JSON keys - validate against context
1083
- .replace(R.jsonKey, (_, key) => {
1084
- // Inside properties - all keys are regular user keys
1085
- if (context === 'properties') {
1086
- return `<span class="json-key">"${key}"</span>:`;
1087
- }
1088
- // GeoJSON structural keys - highlighted as geojson-key
1089
- if (GeoJsonEditor.GEOJSON_STRUCTURAL_KEYS.includes(key)) {
1090
- return `<span class="geojson-key">"${key}"</span>:`;
1091
- }
1092
- // Regular key - validate against context
1093
- if (isKeyValid(key)) {
1094
- return `<span class="json-key">"${key}"</span>:`;
1095
- } else {
1096
- return `<span class="json-key-invalid">"${key}"</span>:`;
1097
- }
1098
- })
1099
- // GeoJSON "type" values - validate based on context
1100
- .replace(R.typeValue, (_, typeValue) => {
1101
- if (isTypeValid(typeValue)) {
1102
- return `<span class="geojson-key">"type"</span>: <span class="geojson-type">"${typeValue}"</span>`;
1103
- } else {
1104
- return `<span class="geojson-key">"type"</span>: <span class="geojson-type-invalid">"${typeValue}"</span>`;
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
+ };
690
+ }
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;
698
+ }
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;
706
+ }
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
+ };
713
+ }
714
+ break;
1105
715
  }
1106
- })
1107
- // Generic string values
1108
- .replace(R.stringValue, (match, value) => {
1109
- // Skip if already highlighted (has span)
1110
- if (match.includes('<span')) return match;
1111
- return `: <span class="json-string">"${value}"</span>`;
1112
- })
1113
- .replace(R.numberAfterColon, ': <span class="json-number">$1</span>')
1114
- .replace(R.boolean, ': <span class="json-boolean">$1</span>')
1115
- .replace(R.nullValue, ': <span class="json-null">$1</span>')
1116
- .replace(R.allNumbers, '<span class="json-number">$1</span>')
1117
- .replace(R.punctuation, '<span class="json-punctuation">$1</span>');
1118
- }
1119
-
1120
- toggleCollapse(nodeKey, line) {
1121
- const textarea = this.shadowRoot.getElementById('textarea');
1122
- const lines = textarea.value.split('\n');
1123
- const currentLine = lines[line];
1124
-
1125
- // Check if line has collapse marker
1126
- const hasMarker = currentLine.includes('{...}') || currentLine.includes('[...]');
1127
-
1128
- if (hasMarker) {
1129
- // Expand: find the correct collapsed data
1130
- const currentIndent = currentLine.match(/^(\s*)/)[1].length;
1131
- const found = this._findCollapsedData(line, nodeKey, currentIndent);
1132
-
1133
- if (!found) {
1134
- return;
1135
716
  }
1136
-
1137
- const { key: foundKey, data: foundData } = found;
1138
- const { originalLine, content } = foundData;
1139
-
1140
- // Restore original line and content
1141
- lines[line] = originalLine;
1142
- lines.splice(line + 1, 0, ...content);
1143
-
1144
- // Remove from storage
1145
- this.collapsedData.delete(foundKey);
1146
- } else {
1147
- // Collapse: read and store content
1148
- const match = currentLine.match(/^(\s*)"([^"]+)"\s*:\s*([{\[])/);
1149
- if (!match) return;
1150
-
1151
- const indent = match[1];
1152
- const openBracket = match[3];
1153
-
1154
- // Use common collapse helper
1155
- if (this._performCollapse(lines, line, nodeKey, indent, openBracket) === 0) return;
717
+
718
+ this.lineMetadata.set(i, meta);
1156
719
  }
1157
-
1158
- // Update textarea
1159
- textarea.value = lines.join('\n');
1160
- this.updateHighlight();
1161
720
  }
1162
721
 
1163
- applyAutoCollapsed() {
1164
- const textarea = this.shadowRoot.getElementById('textarea');
1165
- if (!textarea || !textarea.value) return;
1166
-
1167
- const lines = textarea.value.split('\n');
1168
-
1169
- // Iterate backwards to avoid index issues when collapsing
1170
- for (let i = lines.length - 1; i >= 0; i--) {
1171
- const line = lines[i];
1172
- const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
1173
-
1174
- if (match) {
1175
- const nodeKey = match[2];
1176
-
1177
- // Check if this node should be auto-collapsed (coordinates only)
1178
- if (nodeKey === 'coordinates') {
1179
- const indent = match[1];
1180
- const openBracket = match[3];
1181
-
1182
- // Use common collapse helper
1183
- this._performCollapse(lines, i, nodeKey, indent, openBracket);
1184
- }
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
+ });
1185
736
  }
1186
737
  }
1187
-
1188
- // Update textarea
1189
- textarea.value = lines.join('\n');
1190
- this.updateHighlight();
738
+
739
+ // Reset render cache to force re-render
740
+ this._lastStartIndex = -1;
741
+ this._lastEndIndex = -1;
742
+ this._lastTotalLines = -1;
1191
743
  }
1192
744
 
745
+ // ========== Rendering ==========
746
+
747
+ scheduleRender() {
748
+ if (this.renderTimer) return;
749
+ this.renderTimer = requestAnimationFrame(() => {
750
+ this.renderTimer = null;
751
+ this.renderViewport();
752
+ });
753
+ }
1193
754
 
1194
- updateGutter() {
755
+ renderViewport() {
756
+ const viewport = this.shadowRoot.getElementById('viewport');
757
+ const linesContainer = this.shadowRoot.getElementById('linesContainer');
758
+ const scrollContent = this.shadowRoot.getElementById('scrollContent');
1195
759
  const gutterContent = this.shadowRoot.getElementById('gutterContent');
1196
- const textarea = this.shadowRoot.getElementById('textarea');
1197
-
1198
- if (!textarea) return;
1199
-
1200
- // Use cached computed styles (computed once, reused)
1201
- if (this._cachedLineHeight === null) {
1202
- const styles = getComputedStyle(textarea);
1203
- this._cachedLineHeight = parseFloat(styles.lineHeight);
1204
- this._cachedPaddingTop = parseFloat(styles.paddingTop);
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`;
1205
771
  }
1206
- const lineHeight = this._cachedLineHeight;
1207
- const paddingTop = this._cachedPaddingTop;
1208
-
1209
- // Clear gutter
1210
- gutterContent.textContent = '';
1211
-
1212
- // Create a map of line -> elements (color, boolean, collapse button, visibility button)
1213
- const lineElements = new Map();
1214
-
1215
- // Helper to ensure line entry exists
1216
- const ensureLine = (line) => {
1217
- if (!lineElements.has(line)) {
1218
- lineElements.set(line, { colors: [], booleans: [], buttons: [], visibilityButtons: [] });
1219
- }
1220
- return lineElements.get(line);
1221
- };
1222
-
1223
- // Add color indicators
1224
- this.colorPositions.forEach(({ line, color, attributeName }) => {
1225
- ensureLine(line).colors.push({ color, attributeName });
1226
- });
1227
-
1228
- // Add boolean checkboxes
1229
- this.booleanPositions.forEach(({ line, value, attributeName }) => {
1230
- ensureLine(line).booleans.push({ value, attributeName });
1231
- });
1232
-
1233
- // Add collapse buttons
1234
- this.nodeTogglePositions.forEach(({ line, nodeKey, isCollapsed }) => {
1235
- ensureLine(line).buttons.push({ nodeKey, isCollapsed });
1236
- });
1237
-
1238
- // Add visibility buttons for Features (on the opening brace line)
1239
- for (const [featureKey, range] of this.featureRanges) {
1240
- const isHidden = this.hiddenFeatures.has(featureKey);
1241
- ensureLine(range.startLine).visibilityButtons.push({ featureKey, isHidden });
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;
1242
784
  }
1243
-
1244
- // Create gutter lines with DocumentFragment (single DOM update)
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
1245
801
  const fragment = document.createDocumentFragment();
1246
-
1247
- lineElements.forEach((elements, line) => {
1248
- const gutterLine = document.createElement('div');
1249
- gutterLine.className = 'gutter-line';
1250
- gutterLine.style.top = `${paddingTop + line * lineHeight}px`;
1251
-
1252
- // Add visibility buttons first (leftmost)
1253
- elements.visibilityButtons.forEach(({ featureKey, isHidden }) => {
1254
- const button = document.createElement('button');
1255
- button.className = 'visibility-button' + (isHidden ? ' hidden' : '');
1256
- button.textContent = GeoJsonEditor.ICONS.visibility;
1257
- button.dataset.featureKey = featureKey;
1258
- button.title = isHidden ? 'Show feature in events' : 'Hide feature from events';
1259
- gutterLine.appendChild(button);
1260
- });
1261
-
1262
- // Add color indicators
1263
- elements.colors.forEach(({ color, attributeName }) => {
1264
- const indicator = document.createElement('div');
1265
- indicator.className = 'color-indicator';
1266
- indicator.style.backgroundColor = color;
1267
- indicator.dataset.line = line;
1268
- indicator.dataset.color = color;
1269
- indicator.dataset.attributeName = attributeName;
1270
- indicator.title = `${attributeName}: ${color}`;
1271
- gutterLine.appendChild(indicator);
1272
- });
1273
-
1274
- // Add boolean checkboxes
1275
- elements.booleans.forEach(({ value, attributeName }) => {
1276
- const checkbox = document.createElement('input');
1277
- checkbox.type = 'checkbox';
1278
- checkbox.className = 'boolean-checkbox';
1279
- checkbox.checked = value;
1280
- checkbox.dataset.line = line;
1281
- checkbox.dataset.attributeName = attributeName;
1282
- checkbox.title = `${attributeName}: ${value}`;
1283
- gutterLine.appendChild(checkbox);
1284
- });
1285
-
1286
- // Add collapse buttons
1287
- elements.buttons.forEach(({ nodeKey, isCollapsed }) => {
1288
- const button = document.createElement('div');
1289
- button.className = isCollapsed ? 'collapse-button collapsed' : 'collapse-button';
1290
- button.textContent = isCollapsed ? GeoJsonEditor.ICONS.collapsed : GeoJsonEditor.ICONS.expanded;
1291
- button.dataset.line = line;
1292
- button.dataset.nodeKey = nodeKey;
1293
- button.title = isCollapsed ? 'Expand' : 'Collapse';
1294
- gutterLine.appendChild(button);
1295
- });
1296
-
1297
- fragment.appendChild(gutterLine);
1298
- });
1299
-
1300
- // Single DOM insertion
1301
- gutterContent.appendChild(fragment);
1302
- }
1303
-
1304
- showColorPicker(indicator, line, currentColor, attributeName) {
1305
- // Remove existing picker and clean up its listener
1306
- const existing = document.querySelector('.geojson-color-picker-input');
1307
- if (existing) {
1308
- // Clean up the stored listener before removing
1309
- if (existing._closeListener) {
1310
- document.removeEventListener('click', existing._closeListener, true);
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
+ }
1311
818
  }
1312
- existing.remove();
1313
- }
1314
-
1315
- // Create small color input positioned at the indicator
1316
- const colorInput = document.createElement('input');
1317
- colorInput.type = 'color';
1318
- colorInput.value = currentColor;
1319
- colorInput.className = 'geojson-color-picker-input';
1320
-
1321
- // Get indicator position in viewport
1322
- const rect = indicator.getBoundingClientRect();
1323
-
1324
- colorInput.style.position = 'fixed';
1325
- colorInput.style.left = `${rect.left}px`;
1326
- colorInput.style.top = `${rect.top}px`;
1327
- colorInput.style.width = '12px';
1328
- colorInput.style.height = '12px';
1329
- colorInput.style.opacity = '0.01';
1330
- colorInput.style.border = 'none';
1331
- colorInput.style.padding = '0';
1332
- colorInput.style.zIndex = '9999';
1333
-
1334
- colorInput.addEventListener('input', (e) => {
1335
- // User is actively changing the color - update in real-time
1336
- this.updateColorValue(line, e.target.value, attributeName);
1337
- });
1338
-
1339
- colorInput.addEventListener('change', (e) => {
1340
- // Picker closed with validation
1341
- this.updateColorValue(line, e.target.value, attributeName);
1342
- });
1343
-
1344
- // Close picker when clicking anywhere else
1345
- const closeOnClickOutside = (e) => {
1346
- if (e.target !== colorInput && !colorInput.contains(e.target)) {
1347
- document.removeEventListener('click', closeOnClickOutside, true);
1348
- colorInput.remove();
819
+
820
+ // Add hidden class if feature is hidden
821
+ if (lineData.meta?.isHidden) {
822
+ lineEl.classList.add('line-hidden');
1349
823
  }
1350
- };
1351
-
1352
- // Store the listener reference on the element for cleanup
1353
- colorInput._closeListener = closeOnClickOutside;
1354
-
1355
- // Add to document body with fixed positioning
1356
- document.body.appendChild(colorInput);
1357
-
1358
- // Add click listener after a short delay to avoid immediate close
1359
- setTimeout(() => {
1360
- document.addEventListener('click', closeOnClickOutside, true);
1361
- }, 100);
1362
-
1363
- // Open the picker and focus it
1364
- colorInput.focus();
1365
- colorInput.click();
1366
- }
1367
-
1368
- updateColorValue(line, newColor, attributeName) {
1369
- const textarea = this.shadowRoot.getElementById('textarea');
1370
- const lines = textarea.value.split('\n');
1371
-
1372
- // Replace color value on the specified line for the specific attribute (supports #rgb and #rrggbb)
1373
- const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})"`);
1374
- lines[line] = lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
1375
-
1376
- textarea.value = lines.join('\n');
1377
- this.updateHighlight();
1378
- this.emitChange();
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);
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);
1379
849
  }
1380
-
1381
- updateBooleanValue(line, newValue, attributeName) {
1382
- const textarea = this.shadowRoot.getElementById('textarea');
1383
- const lines = textarea.value.split('\n');
1384
-
1385
- // Replace boolean value on the specified line for the specific attribute
1386
- const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
1387
- lines[line] = lines[line].replace(regex, `"${attributeName}": ${newValue}`);
1388
-
1389
- textarea.value = lines.join('\n');
1390
- this.updateHighlight();
1391
- this.emitChange();
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>`;
1392
860
  }
1393
861
 
1394
- handleKeydownInCollapsedArea(e) {
1395
- // Allow navigation keys
1396
- const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Tab'];
1397
- if (navigationKeys.includes(e.key)) return;
1398
-
1399
- // Allow copy/cut/paste (handled separately)
1400
- if (e.ctrlKey || e.metaKey) return;
1401
-
1402
- const textarea = this.shadowRoot.getElementById('textarea');
1403
- const cursorPos = textarea.selectionStart;
1404
- const textBeforeCursor = textarea.value.substring(0, cursorPos);
1405
- const currentLineNum = textBeforeCursor.split('\n').length - 1;
1406
- const lines = textarea.value.split('\n');
1407
- const currentLine = lines[currentLineNum];
1408
-
1409
- // Check if current line is collapsed (contains {...} or [...])
1410
- if (currentLine && (currentLine.includes('{...}') || currentLine.includes('[...]'))) {
1411
- e.preventDefault();
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;
887
+ } else {
888
+ // Entire line is selected
889
+ selStart = 0;
890
+ selEnd = content.length;
1412
891
  }
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;
1413
899
  }
1414
-
1415
- handleCopyWithCollapsedContent(e) {
1416
- const textarea = this.shadowRoot.getElementById('textarea');
1417
- const start = textarea.selectionStart;
1418
- const end = textarea.selectionEnd;
1419
-
1420
- if (start === end) return; // No selection
1421
-
1422
- const selectedText = textarea.value.substring(start, end);
1423
-
1424
- // Check if selection contains collapsed content
1425
- if (!selectedText.includes('{...}') && !selectedText.includes('[...]')) {
1426
- return; // No collapsed content, use default copy behavior
1427
- }
1428
-
1429
- let expandedText;
1430
-
1431
- // If selecting all content, use expandAllCollapsed directly (more reliable)
1432
- if (start === 0 && end === textarea.value.length) {
1433
- expandedText = this.expandAllCollapsed(selectedText);
1434
- } else {
1435
- // For partial selection, expand using line-by-line matching
1436
- expandedText = this.expandCollapsedMarkersInText(selectedText, start);
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;
1437
910
  }
1438
-
1439
- // Put expanded text in clipboard
1440
- e.preventDefault();
1441
- e.clipboardData.setData('text/plain', expandedText);
911
+ return this._charWidth;
1442
912
  }
1443
913
 
1444
- expandCollapsedMarkersInText(text, startPos) {
1445
- const textarea = this.shadowRoot.getElementById('textarea');
1446
- const beforeSelection = textarea.value.substring(0, startPos);
1447
- const startLineNum = beforeSelection.split('\n').length - 1;
1448
- const R = GeoJsonEditor.REGEX;
1449
-
1450
- const lines = text.split('\n');
1451
- const expandedLines = [];
1452
-
1453
- lines.forEach((line, relativeLineNum) => {
1454
- const absoluteLineNum = startLineNum + relativeLineNum;
1455
-
1456
- // Check if this line has a collapsed marker
1457
- if (line.includes('{...}') || line.includes('[...]')) {
1458
- const match = line.match(R.collapsedMarker);
1459
- if (match) {
1460
- const nodeKey = match[2];
1461
- const currentIndent = match[1].length;
1462
-
1463
- // Try to find collapsed data using helper
1464
- const found = this._findCollapsedData(absoluteLineNum, nodeKey, currentIndent);
1465
- if (found) {
1466
- expandedLines.push(found.data.originalLine);
1467
- expandedLines.push(...found.data.content);
1468
- return;
1469
- }
1470
-
1471
- // Fallback: search by nodeKey only (line numbers may have shifted)
1472
- for (const [, collapsed] of this.collapsedData.entries()) {
1473
- if (collapsed.nodeKey === nodeKey) {
1474
- expandedLines.push(collapsed.originalLine);
1475
- expandedLines.push(...collapsed.content);
1476
- return;
1477
- }
1478
- }
1479
- }
1480
-
1481
- // Line not found in collapsed data, keep as-is
1482
- expandedLines.push(line);
1483
- } else {
1484
- expandedLines.push(line);
914
+ renderGutter(startIndex, endIndex) {
915
+ const gutterContent = this.shadowRoot.getElementById('gutterContent');
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`;
923
+ }
924
+
925
+ // Position gutter content using transform
926
+ const offsetY = startIndex * this.lineHeight;
927
+ gutterContent.style.transform = `translateY(${offsetY}px)`;
928
+
929
+ const fragment = document.createDocumentFragment();
930
+
931
+ for (let i = startIndex; i < endIndex; i++) {
932
+ const lineData = this.visibleLines[i];
933
+ if (!lineData) continue;
934
+
935
+ const gutterLine = document.createElement('div');
936
+ gutterLine.className = 'gutter-line';
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);
1485
957
  }
1486
- });
1487
-
1488
- return expandedLines.join('\n');
958
+ gutterLine.appendChild(collapseCol);
959
+
960
+ fragment.appendChild(gutterLine);
961
+ }
962
+
963
+ gutterContent.innerHTML = '';
964
+ gutterContent.appendChild(fragment);
1489
965
  }
1490
966
 
1491
- handleCutWithCollapsedContent(e) {
1492
- // First copy with expanded content
1493
- this.handleCopyWithCollapsedContent(e);
1494
-
1495
- // Then delete the selection normally
1496
- const textarea = this.shadowRoot.getElementById('textarea');
1497
- const start = textarea.selectionStart;
1498
- const end = textarea.selectionEnd;
1499
-
1500
- if (start !== end) {
1501
- const value = textarea.value;
1502
- textarea.value = value.substring(0, start) + value.substring(end);
1503
- textarea.selectionStart = textarea.selectionEnd = start;
1504
- this.updateHighlight();
1505
- this.updatePlaceholderVisibility();
1506
- this.emitChange();
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;
1507
973
  }
1508
974
  }
1509
975
 
1510
- emitChange() {
1511
- const textarea = this.shadowRoot.getElementById('textarea');
1512
-
1513
- // Expand ALL collapsed nodes to get full content
1514
- const editorContent = this.expandAllCollapsed(textarea.value);
1515
-
1516
- // Build complete value with prefix/suffix (fixed FeatureCollection wrapper)
1517
- const fullValue = this.prefix + editorContent + this.suffix;
1518
-
1519
- // Try to parse
1520
- try {
1521
- let parsed = JSON.parse(fullValue);
1522
-
1523
- // Filter out hidden features before emitting
1524
- parsed = this.filterHiddenFeatures(parsed);
1525
-
1526
- // Validate GeoJSON types (validate only features, not the wrapper)
1527
- let validationErrors = [];
1528
- parsed.features.forEach((feature, index) => {
1529
- validationErrors.push(...this.validateGeoJSON(feature, `features[${index}]`, 'root'));
1530
- });
1531
-
1532
- if (validationErrors.length > 0) {
1533
- // Emit error event for GeoJSON validation errors
1534
- this.dispatchEvent(new CustomEvent('error', {
1535
- detail: {
1536
- timestamp: new Date().toISOString(),
1537
- error: `GeoJSON validation: ${validationErrors.join('; ')}`,
1538
- errors: validationErrors,
1539
- content: editorContent
1540
- },
1541
- bubbles: true,
1542
- composed: true
1543
- }));
1544
- } else {
1545
- // Emit change event with parsed GeoJSON directly
1546
- this.dispatchEvent(new CustomEvent('change', {
1547
- detail: parsed,
1548
- bubbles: true,
1549
- composed: true
1550
- }));
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;
988
+ }
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;
1551
998
  }
1552
- } catch (e) {
1553
- // Emit error event for invalid JSON
1554
- this.dispatchEvent(new CustomEvent('error', {
1555
- detail: {
1556
- timestamp: new Date().toISOString(),
1557
- error: e.message,
1558
- content: editorContent // Raw content for debugging
1559
- },
1560
- bubbles: true,
1561
- composed: true
1562
- }));
1563
999
  }
1564
- }
1565
-
1566
- // Filter hidden features from parsed GeoJSON before emitting events
1567
- filterHiddenFeatures(parsed) {
1568
- if (!parsed || this.hiddenFeatures.size === 0) return parsed;
1569
-
1570
- // parsed is always a FeatureCollection (from wrapper)
1571
- const visibleFeatures = parsed.features.filter((feature) => {
1572
- const key = this.getFeatureKey(feature);
1573
- return !this.hiddenFeatures.has(key);
1574
- });
1575
- return { ...parsed, features: visibleFeatures };
1576
- }
1577
-
1578
- // ========== Feature Visibility Management ==========
1579
-
1580
- // Generate a unique key for a Feature to track visibility state
1581
- getFeatureKey(feature) {
1582
- if (!feature || typeof feature !== 'object') return null;
1583
-
1584
- // 1. Use GeoJSON id if present (most stable)
1585
- if (feature.id !== undefined) return `id:${feature.id}`;
1586
-
1587
- // 2. Use properties.id if present
1588
- if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
1589
-
1590
- // 3. Fallback: hash based on geometry type + ALL coordinates
1591
- const geomType = feature.geometry?.type || 'null';
1592
- const coords = JSON.stringify(feature.geometry?.coordinates || []);
1593
- return `hash:${geomType}:${this.simpleHash(coords)}`;
1594
- }
1595
-
1596
- // Simple hash function for string
1597
- simpleHash(str) {
1598
- let hash = 0;
1599
- for (let i = 0; i < str.length; i++) {
1600
- const char = str.charCodeAt(i);
1601
- hash = ((hash << 5) - hash) + char;
1602
- hash = hash & hash; // Convert to 32bit integer
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;
1009
+ }
1603
1010
  }
1604
- return hash.toString(36);
1605
- }
1606
-
1607
- // Toggle feature visibility
1608
- toggleFeatureVisibility(featureKey) {
1609
- if (this.hiddenFeatures.has(featureKey)) {
1610
- this.hiddenFeatures.delete(featureKey);
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;
1023
+ } else {
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;
1033
+ }
1611
1034
  } else {
1612
- 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;
1613
1040
  }
1614
- this.updateHighlight();
1615
- this.updateGutter();
1616
- this.emitChange();
1617
- }
1618
-
1619
- // Parse JSON and extract feature ranges (line numbers for each Feature)
1620
- updateFeatureRanges() {
1621
- const textarea = this.shadowRoot.getElementById('textarea');
1622
- if (!textarea) return;
1623
-
1624
- const text = textarea.value;
1625
- this.featureRanges.clear();
1626
-
1627
- try {
1628
- // Expand collapsed content for parsing (collapsed markers like [...] are not valid JSON)
1629
- const expandedText = this.expandAllCollapsed(text);
1630
-
1631
- // Try to parse and find Features
1632
- const prefix = this.prefix;
1633
- const suffix = this.suffix;
1634
- const fullValue = prefix + expandedText + suffix;
1635
- const parsed = JSON.parse(fullValue);
1636
-
1637
- // parsed is always a FeatureCollection (from wrapper)
1638
- const features = parsed.features;
1639
-
1640
- // Now find each feature's line range in the text
1641
- const lines = text.split('\n');
1642
- let featureIndex = 0;
1643
- let braceDepth = 0;
1644
- let inFeature = false;
1645
- let featureStartLine = -1;
1646
- let currentFeatureKey = null;
1647
-
1648
- for (let i = 0; i < lines.length; i++) {
1649
- const line = lines[i];
1650
-
1651
- // Detect start of a Feature object (not FeatureCollection)
1652
- // Use regex to match exact "Feature" value, not "FeatureCollection"
1653
- const isFeatureTypeLine = /"type"\s*:\s*"Feature"/.test(line);
1654
- if (!inFeature && isFeatureTypeLine) {
1655
- // Find the opening brace for this Feature
1656
- // Look backwards for a line that starts with just '{' (the Feature's opening brace)
1657
- // Not a line like '"geometry": {' which contains other content before the brace
1658
- let startLine = i;
1659
- for (let j = i; j >= 0; j--) {
1660
- const trimmed = lines[j].trim();
1661
- // Line is just '{' or '{' followed by nothing significant (opening brace only)
1662
- if (trimmed === '{' || trimmed === '{,') {
1663
- startLine = j;
1664
- break;
1665
- }
1666
- // Also handle case where Feature starts on same line: { "type": "Feature"
1667
- if (trimmed.startsWith('{') && !trimmed.includes(':')) {
1668
- startLine = j;
1669
- break;
1670
- }
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;
1671
1069
  }
1672
- featureStartLine = startLine;
1673
- inFeature = true;
1674
-
1675
- // Start braceDepth at 1 since we're inside the Feature's opening brace
1676
- // Then count any additional braces from startLine to current line (ignoring strings)
1677
- braceDepth = 1;
1678
- for (let k = startLine; k <= i; k++) {
1679
- const scanLine = lines[k];
1680
- const counts = this._countBracketsOutsideStrings(scanLine, '{');
1681
- if (k === startLine) {
1682
- // Skip the first { we already counted
1683
- braceDepth += (counts.open - 1) - counts.close;
1684
- } else {
1685
- braceDepth += counts.open - counts.close;
1686
- }
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;
1687
1094
  }
1688
-
1689
- // Get the feature key
1690
- if (featureIndex < features.length) {
1691
- 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;
1692
1147
  }
1693
- } else if (inFeature) {
1694
- // Count braces (ignoring those in strings)
1695
- const counts = this._countBracketsOutsideStrings(line, '{');
1696
- braceDepth += counts.open - counts.close;
1697
-
1698
- // Feature ends when braceDepth returns to 0
1699
- if (braceDepth <= 0) {
1700
- if (currentFeatureKey) {
1701
- this.featureRanges.set(currentFeatureKey, {
1702
- startLine: featureStartLine,
1703
- endLine: i,
1704
- featureIndex: featureIndex
1705
- });
1706
- }
1707
- featureIndex++;
1708
- inFeature = false;
1709
- 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();
1710
1204
  }
1205
+ return;
1711
1206
  }
1712
- }
1713
- } catch (e) {
1714
- // 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;
1715
1221
  }
1716
1222
  }
1717
1223
 
1718
- // Get hidden line ranges for highlighting
1719
- getHiddenLineRanges() {
1720
- const ranges = [];
1721
- for (const [featureKey, range] of this.featureRanges) {
1722
- if (this.hiddenFeatures.has(featureKey)) {
1723
- 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);
1724
1270
  }
1725
1271
  }
1726
- return ranges;
1272
+
1273
+ this.formatAndUpdate();
1727
1274
  }
1728
1275
 
1729
- // ========== GeoJSON Validation ==========
1730
-
1731
- // Validate GeoJSON structure and types
1732
- // context: 'root' | 'geometry' | 'properties'
1733
- validateGeoJSON(obj, path = '', context = 'root') {
1734
- const errors = [];
1735
-
1736
- if (!obj || typeof obj !== 'object') {
1737
- 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;
1738
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
+ }
1739
1306
 
1740
- // Check for invalid type values based on context
1741
- if (context !== 'properties' && obj.type !== undefined) {
1742
- const typeValue = obj.type;
1743
- if (typeof typeValue === 'string') {
1744
- if (context === 'geometry') {
1745
- // In geometry: must be a geometry type
1746
- if (!GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(typeValue)) {
1747
- 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;
1748
1327
  }
1749
1328
  } else {
1750
- // At root or in features: must be Feature
1751
- if (typeValue !== 'Feature') {
1752
- 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;
1753
1356
  }
1754
1357
  }
1358
+ } else {
1359
+ this.cursorColumn++;
1755
1360
  }
1756
- }
1757
-
1758
- // Recursively validate nested objects
1759
- if (Array.isArray(obj)) {
1760
- obj.forEach((item, index) => {
1761
- errors.push(...this.validateGeoJSON(item, `${path}[${index}]`, context));
1762
- });
1763
1361
  } else {
1764
- for (const [key, value] of Object.entries(obj)) {
1765
- if (typeof value === 'object' && value !== null) {
1766
- const newPath = path ? `${path}.${key}` : key;
1767
- // Determine context for nested objects
1768
- let newContext = context;
1769
- if (key === 'properties') {
1770
- newContext = 'properties';
1771
- } else if (key === 'geometry' || key === 'geometries') {
1772
- newContext = 'geometry';
1773
- } else if (key === 'features') {
1774
- 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;
1775
1421
  }
1776
- errors.push(...this.validateGeoJSON(value, newPath, newContext));
1777
1422
  }
1778
1423
  }
1779
1424
  }
1780
-
1781
- return errors;
1425
+
1426
+ this._lastStartIndex = -1;
1427
+ this._scrollToCursor();
1428
+ this.scheduleRender();
1782
1429
  }
1783
1430
 
1784
- // Helper: Count bracket depth change in a line, ignoring brackets inside strings
1785
- // Returns {open: count, close: count} for the specified bracket type
1786
- _countBracketsOutsideStrings(line, openBracket) {
1787
- const closeBracket = openBracket === '{' ? '}' : ']';
1788
- let openCount = 0;
1789
- let closeCount = 0;
1790
- let inString = false;
1791
- let escape = false;
1792
-
1793
- for (let i = 0; i < line.length; i++) {
1794
- 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
+ }
1795
1455
 
1796
- if (escape) {
1797
- escape = false;
1798
- continue;
1799
- }
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
+ }
1800
1466
 
1801
- if (char === '\\' && inString) {
1802
- escape = true;
1803
- continue;
1804
- }
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
+ }
1805
1492
 
1806
- if (char === '"') {
1807
- inString = !inString;
1808
- 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;
1809
1505
  }
1810
-
1811
- if (!inString) {
1812
- if (char === openBracket) openCount++;
1813
- 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;
1814
1510
  }
1815
1511
  }
1816
-
1817
- 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();
1818
1524
  }
1819
1525
 
1820
- // Helper: Check if bracket closes on same line (ignores brackets in strings)
1821
- bracketClosesOnSameLine(line, openBracket) {
1822
- const bracketPos = line.indexOf(openBracket);
1823
- if (bracketPos === -1) return false;
1824
-
1825
- const restOfLine = line.substring(bracketPos + 1);
1826
- const counts = this._countBracketsOutsideStrings(restOfLine, openBracket);
1827
-
1828
- // Depth starts at 1 (we're after the opening bracket)
1829
- // If closes equal or exceed opens + 1, the bracket closes on this line
1830
- 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();
1831
1539
  }
1832
1540
 
1833
- // Helper: Find closing bracket line starting from startLine
1834
- // Returns { endLine, content: string[] } or null if not found
1835
- _findClosingBracket(lines, startLine, openBracket) {
1836
- let depth = 1;
1837
- const content = [];
1838
-
1839
- // Count remaining brackets on the start line (after the opening bracket)
1840
- const startLineContent = lines[startLine];
1841
- const bracketPos = startLineContent.indexOf(openBracket);
1842
- if (bracketPos !== -1) {
1843
- const restOfStartLine = startLineContent.substring(bracketPos + 1);
1844
- const startCounts = this._countBracketsOutsideStrings(restOfStartLine, openBracket);
1845
- depth += startCounts.open - startCounts.close;
1846
- if (depth === 0) {
1847
- return { endLine: startLine, content: [] };
1848
- }
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);
1849
1552
  }
1850
-
1851
- for (let i = startLine + 1; i < lines.length; i++) {
1852
- const scanLine = lines[i];
1853
- const counts = this._countBracketsOutsideStrings(scanLine, openBracket);
1854
- depth += counts.open - counts.close;
1855
-
1856
- content.push(scanLine);
1857
-
1858
- if (depth === 0) {
1859
- return { endLine: i, content };
1860
- }
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';
1861
1557
  }
1862
-
1863
- return null; // Not found (malformed JSON)
1558
+ text += this.lines[end.line].substring(0, end.column);
1559
+
1560
+ return text;
1864
1561
  }
1865
1562
 
1866
1563
  /**
1867
- * Helper: Perform collapse operation on a node at given line
1868
- * Stores data in collapsedData, replaces line with marker, removes content lines
1869
- * @param {string[]} lines - Array of lines (modified in place)
1870
- * @param {number} lineIndex - Index of line to collapse
1871
- * @param {string} nodeKey - Key of the node (e.g., 'coordinates')
1872
- * @param {string} indent - Indentation string
1873
- * @param {string} openBracket - Opening bracket character ('{' or '[')
1874
- * @returns {number} Number of lines removed, or 0 if collapse failed
1875
- * @private
1564
+ * Normalize selection so start is before end
1876
1565
  */
1877
- _performCollapse(lines, lineIndex, nodeKey, indent, openBracket) {
1878
- const line = lines[lineIndex];
1879
- 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
+ }
1880
1580
 
1881
- // Skip if bracket closes on same line
1882
- 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
+ }
1883
1589
 
1884
- // Find closing bracket
1885
- const result = this._findClosingBracket(lines, lineIndex, openBracket);
1886
- if (!result) return 0;
1590
+ /**
1591
+ * Clear the current selection
1592
+ */
1593
+ _clearSelection() {
1594
+ this.selectionStart = null;
1595
+ this.selectionEnd = null;
1596
+ }
1887
1597
 
1888
- 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
+ }
1889
1625
 
1890
- // Store the original data with unique key
1891
- const uniqueKey = `${lineIndex}-${nodeKey}`;
1892
- this.collapsedData.set(uniqueKey, {
1893
- originalLine: line,
1894
- content: content,
1895
- indent: indent.length,
1896
- nodeKey: nodeKey
1897
- });
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
+ if (this.cursorLine < this.lines.length) {
1652
+ const line = this.lines[this.cursorLine];
1653
+ this.lines[this.cursorLine] = line.substring(0, this.cursorColumn) + text + line.substring(this.cursorColumn);
1654
+ this.cursorColumn += text.length;
1655
+ }
1656
+ this.formatAndUpdate();
1657
+ }
1898
1658
 
1899
- // Replace with marker
1900
- const beforeBracket = line.substring(0, line.indexOf(openBracket));
1901
- const hasTrailingComma = lines[endLine] && lines[endLine].trim().endsWith(',');
1902
- lines[lineIndex] = `${beforeBracket}${openBracket}...${closeBracket}${hasTrailingComma ? ',' : ''}`;
1659
+ handlePaste(e) {
1660
+ e.preventDefault();
1661
+ const text = e.clipboardData.getData('text/plain');
1662
+ if (text) {
1663
+ this.insertText(text);
1664
+ }
1665
+ }
1903
1666
 
1904
- // Remove content lines
1905
- const linesRemoved = endLine - lineIndex;
1906
- lines.splice(lineIndex + 1, linesRemoved);
1667
+ handleCopy(e) {
1668
+ e.preventDefault();
1669
+ // Copy selected text if there's a selection, otherwise copy all
1670
+ if (this._hasSelection()) {
1671
+ e.clipboardData.setData('text/plain', this._getSelectedText());
1672
+ } else {
1673
+ e.clipboardData.setData('text/plain', this.getContent());
1674
+ }
1675
+ }
1907
1676
 
1908
- return linesRemoved;
1677
+ handleCut(e) {
1678
+ e.preventDefault();
1679
+ if (this._hasSelection()) {
1680
+ e.clipboardData.setData('text/plain', this._getSelectedText());
1681
+ this._deleteSelection();
1682
+ this.formatAndUpdate();
1683
+ } else {
1684
+ // Cut all content
1685
+ e.clipboardData.setData('text/plain', this.getContent());
1686
+ this.lines = [];
1687
+ this.cursorLine = 0;
1688
+ this.cursorColumn = 0;
1689
+ this.formatAndUpdate();
1690
+ }
1909
1691
  }
1910
1692
 
1911
- // Helper: Expand all collapsed markers and return expanded content
1912
- expandAllCollapsed(content) {
1913
- const R = GeoJsonEditor.REGEX;
1693
+ /**
1694
+ * Get line/column position from mouse event
1695
+ */
1696
+ _getPositionFromClick(e) {
1697
+ const viewport = this.shadowRoot.getElementById('viewport');
1698
+ const rect = viewport.getBoundingClientRect();
1699
+
1700
+ const paddingTop = 8;
1701
+ const paddingLeft = 12;
1702
+
1703
+ const y = e.clientY - rect.top + viewport.scrollTop - paddingTop;
1704
+ const x = e.clientX - rect.left - paddingLeft;
1705
+
1706
+ const visibleLineIndex = Math.floor(y / this.lineHeight);
1707
+
1708
+ let line = 0;
1709
+ let column = 0;
1710
+
1711
+ if (visibleLineIndex >= 0 && visibleLineIndex < this.visibleLines.length) {
1712
+ const lineData = this.visibleLines[visibleLineIndex];
1713
+ line = lineData.index;
1714
+
1715
+ const charWidth = this._getCharWidth();
1716
+ const rawColumn = Math.round(x / charWidth);
1717
+ const lineLength = lineData.content?.length || 0;
1718
+ column = Math.max(0, Math.min(rawColumn, lineLength));
1719
+ }
1720
+
1721
+ return { line, column };
1722
+ }
1914
1723
 
1915
- while (content.includes('{...}') || content.includes('[...]')) {
1916
- const lines = content.split('\n');
1917
- let expanded = false;
1724
+ // ========== Gutter Interactions ==========
1725
+
1726
+ handleGutterClick(e) {
1727
+ // Visibility button in gutter
1728
+ const visBtn = e.target.closest('.visibility-button');
1729
+ if (visBtn) {
1730
+ this.toggleFeatureVisibility(visBtn.dataset.featureKey);
1731
+ return;
1732
+ }
1733
+
1734
+ // Collapse button in gutter
1735
+ if (e.target.classList.contains('collapse-button')) {
1736
+ const nodeId = e.target.dataset.nodeId;
1737
+ this.toggleCollapse(nodeId);
1738
+ return;
1739
+ }
1740
+ }
1741
+
1742
+ handleEditorClick(e) {
1743
+ // Line-level visibility button (pseudo-element ::before on .line.has-visibility)
1744
+ const lineEl = e.target.closest('.line.has-visibility');
1745
+ if (lineEl) {
1746
+ const rect = lineEl.getBoundingClientRect();
1747
+ const clickX = e.clientX - rect.left;
1748
+ // Pseudo-element is at the start of the line, check first ~14px
1749
+ if (clickX < 14) {
1750
+ e.preventDefault();
1751
+ e.stopPropagation();
1752
+ this.toggleFeatureVisibility(lineEl.dataset.featureKey);
1753
+ return;
1754
+ }
1755
+ }
1756
+
1757
+ // Inline color swatch (pseudo-element positioned with left: -8px)
1758
+ if (e.target.classList.contains('json-color')) {
1759
+ const rect = e.target.getBoundingClientRect();
1760
+ const clickX = e.clientX - rect.left;
1761
+ // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
1762
+ if (clickX < 0 && clickX >= -8) {
1763
+ e.preventDefault();
1764
+ e.stopPropagation();
1765
+ const color = e.target.dataset.color;
1766
+ const targetLineEl = e.target.closest('.line');
1767
+ if (targetLineEl) {
1768
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
1769
+ const line = this.lines[lineIndex];
1770
+ const match = line.match(/"([\w-]+)"\s*:\s*"#/);
1771
+ if (match) {
1772
+ this.showColorPicker(e.target, lineIndex, color, match[1]);
1773
+ }
1774
+ }
1775
+ return;
1776
+ }
1777
+ }
1778
+
1779
+ // Inline boolean checkbox (pseudo-element positioned with left: -8px)
1780
+ if (e.target.classList.contains('json-boolean')) {
1781
+ const rect = e.target.getBoundingClientRect();
1782
+ const clickX = e.clientX - rect.left;
1783
+ // Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
1784
+ if (clickX < 0 && clickX >= -8) {
1785
+ e.preventDefault();
1786
+ e.stopPropagation();
1787
+ const targetLineEl = e.target.closest('.line');
1788
+ if (targetLineEl) {
1789
+ const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
1790
+ const line = this.lines[lineIndex];
1791
+ const match = line.match(/"([\w-]+)"\s*:\s*(true|false)/);
1792
+ if (match) {
1793
+ const currentValue = match[2] === 'true';
1794
+ this.updateBooleanValue(lineIndex, !currentValue, match[1]);
1795
+ }
1796
+ }
1797
+ return;
1798
+ }
1799
+ }
1800
+ }
1918
1801
 
1919
- for (let i = 0; i < lines.length; i++) {
1920
- const line = lines[i];
1921
- if (!line.includes('{...}') && !line.includes('[...]')) continue;
1802
+ // ========== Collapse/Expand ==========
1803
+
1804
+ toggleCollapse(nodeId) {
1805
+ if (this.collapsedNodes.has(nodeId)) {
1806
+ this.collapsedNodes.delete(nodeId);
1807
+ } else {
1808
+ this.collapsedNodes.add(nodeId);
1809
+ }
1810
+
1811
+ // Use updateView - don't rebuild nodeId mappings since content didn't change
1812
+ this.updateView();
1813
+ this._lastStartIndex = -1; // Force re-render
1814
+ this.scheduleRender();
1815
+ }
1922
1816
 
1923
- const match = line.match(R.collapsedMarker);
1924
- if (!match) continue;
1817
+ autoCollapseCoordinates() {
1818
+ const ranges = this._findCollapsibleRanges();
1819
+
1820
+ for (const range of ranges) {
1821
+ if (range.nodeKey === 'coordinates') {
1822
+ this.collapsedNodes.add(range.nodeId);
1823
+ }
1824
+ }
1825
+
1826
+ // Use updateView since nodeIds were just assigned by updateModel/setValue
1827
+ this.updateView();
1828
+ this.scheduleRender();
1829
+ }
1925
1830
 
1926
- const nodeKey = match[2];
1927
- const currentIndent = match[1].length;
1928
- const found = this._findCollapsedData(i, nodeKey, currentIndent);
1831
+ // ========== Feature Visibility ==========
1832
+
1833
+ toggleFeatureVisibility(featureKey) {
1834
+ if (this.hiddenFeatures.has(featureKey)) {
1835
+ this.hiddenFeatures.delete(featureKey);
1836
+ } else {
1837
+ this.hiddenFeatures.add(featureKey);
1838
+ }
1839
+
1840
+ // Use updateView - content didn't change, just visibility
1841
+ this.updateView();
1842
+ this.scheduleRender();
1843
+ this.emitChange();
1844
+ }
1929
1845
 
1930
- if (found) {
1931
- const { data: { originalLine, content: nodeContent } } = found;
1932
- lines[i] = originalLine;
1933
- lines.splice(i + 1, 0, ...nodeContent);
1934
- expanded = true;
1935
- break;
1936
- }
1846
+ // ========== Color Picker ==========
1847
+
1848
+ showColorPicker(indicator, line, currentColor, attributeName) {
1849
+ // Remove existing picker and anchor
1850
+ const existing = document.querySelector('.geojson-color-picker-anchor');
1851
+ if (existing) {
1852
+ existing.remove();
1853
+ }
1854
+
1855
+ // Create an anchor element at the pseudo-element position
1856
+ // The browser will position the color picker popup relative to this
1857
+ const anchor = document.createElement('div');
1858
+ anchor.className = 'geojson-color-picker-anchor';
1859
+ const rect = indicator.getBoundingClientRect();
1860
+ anchor.style.cssText = `
1861
+ position: fixed;
1862
+ left: ${rect.left - 8}px;
1863
+ top: ${rect.top + rect.height}px;
1864
+ width: 10px;
1865
+ height: 10px;
1866
+ z-index: 9998;
1867
+ `;
1868
+ document.body.appendChild(anchor);
1869
+
1870
+ const colorInput = document.createElement('input');
1871
+ colorInput.type = 'color';
1872
+ colorInput.value = currentColor;
1873
+ colorInput.className = 'geojson-color-picker-input';
1874
+
1875
+ // Position the color input inside the anchor
1876
+ colorInput.style.cssText = `
1877
+ position: absolute;
1878
+ left: 0;
1879
+ top: 0;
1880
+ width: 10px;
1881
+ height: 10px;
1882
+ opacity: 0;
1883
+ border: none;
1884
+ padding: 0;
1885
+ cursor: pointer;
1886
+ `;
1887
+ anchor.appendChild(colorInput);
1888
+
1889
+ colorInput.addEventListener('input', (e) => {
1890
+ this.updateColorValue(line, e.target.value, attributeName);
1891
+ });
1892
+
1893
+ const closeOnClickOutside = (e) => {
1894
+ if (e.target !== colorInput) {
1895
+ document.removeEventListener('click', closeOnClickOutside, true);
1896
+ anchor.remove(); // Remove anchor (which contains the input)
1937
1897
  }
1898
+ };
1899
+
1900
+ colorInput._closeListener = closeOnClickOutside;
1901
+
1902
+ setTimeout(() => {
1903
+ document.addEventListener('click', closeOnClickOutside, true);
1904
+ }, 100);
1905
+
1906
+ colorInput.focus();
1907
+ colorInput.click();
1908
+ }
1938
1909
 
1939
- if (!expanded) break;
1940
- content = lines.join('\n');
1941
- }
1942
- return content;
1910
+ updateColorValue(line, newColor, attributeName) {
1911
+ const regex = new RegExp(`"${attributeName}"\\s*:\\s*"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})"`);
1912
+ this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
1913
+
1914
+ // Use updateView to preserve collapsed state (line count didn't change)
1915
+ this.updateView();
1916
+ this.scheduleRender();
1917
+ this.emitChange();
1943
1918
  }
1944
1919
 
1945
- // Helper: Format JSON content (always in FeatureCollection mode)
1946
- // Also applies default properties to features if configured
1947
- formatJSONContent(content) {
1948
- const wrapped = '[' + content + ']';
1949
- let parsed = JSON.parse(wrapped);
1950
-
1951
- // Apply default properties to each feature in the array
1952
- if (Array.isArray(parsed)) {
1953
- parsed = parsed.map(f => this._applyDefaultPropertiesToFeature(f));
1954
- }
1920
+ updateBooleanValue(line, newValue, attributeName) {
1921
+ const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
1922
+ this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": ${newValue}`);
1955
1923
 
1956
- const formatted = JSON.stringify(parsed, null, 2);
1957
- const lines = formatted.split('\n');
1958
- return lines.length > 2 ? lines.slice(1, -1).join('\n') : '';
1924
+ // Use updateView to preserve collapsed state (line count didn't change)
1925
+ this.updateView();
1926
+ this.scheduleRender();
1927
+ this.emitChange();
1959
1928
  }
1960
1929
 
1961
- autoFormatContentWithCursor() {
1962
- const textarea = this.shadowRoot.getElementById('textarea');
1963
-
1964
- // Save cursor position
1965
- const cursorPos = textarea.selectionStart;
1966
- const textBeforeCursor = textarea.value.substring(0, cursorPos);
1967
- const linesBeforeCursor = textBeforeCursor.split('\n');
1968
- const cursorLine = linesBeforeCursor.length - 1;
1969
- const cursorColumn = linesBeforeCursor[linesBeforeCursor.length - 1].length;
1970
-
1971
- // Save collapsed node details
1972
- const collapsedNodes = Array.from(this.collapsedData.values()).map(data => ({
1973
- nodeKey: data.nodeKey,
1974
- indent: data.indent
1975
- }));
1976
-
1977
- // Expand and format
1978
- const content = this.expandAllCollapsed(textarea.value);
1979
-
1930
+ // ========== Format and Update ==========
1931
+
1932
+ formatAndUpdate() {
1980
1933
  try {
1981
- const formattedContent = this.formatJSONContent(content);
1982
-
1983
- if (formattedContent !== content) {
1984
- this.collapsedData.clear();
1985
- textarea.value = formattedContent;
1986
-
1987
- if (collapsedNodes.length > 0) {
1988
- this.reapplyCollapsed(collapsedNodes);
1989
- }
1990
-
1991
- // Restore cursor position
1992
- const newLines = textarea.value.split('\n');
1993
- if (cursorLine < newLines.length) {
1994
- const newColumn = Math.min(cursorColumn, newLines[cursorLine].length);
1995
- let newPos = 0;
1996
- for (let i = 0; i < cursorLine; i++) {
1997
- newPos += newLines[i].length + 1;
1998
- }
1999
- newPos += newColumn;
2000
- textarea.setSelectionRange(newPos, newPos);
2001
- }
2002
- }
1934
+ const content = this.lines.join('\n');
1935
+ const wrapped = '[' + content + ']';
1936
+ const parsed = JSON.parse(wrapped);
1937
+
1938
+ const formatted = JSON.stringify(parsed, null, 2);
1939
+ const lines = formatted.split('\n');
1940
+ this.lines = lines.slice(1, -1); // Remove wrapper brackets
2003
1941
  } catch (e) {
2004
- // Invalid JSON, don't format
1942
+ // Invalid JSON, keep as-is
2005
1943
  }
1944
+
1945
+ this.updateModel();
1946
+ this.scheduleRender();
1947
+ this.updatePlaceholderVisibility();
1948
+ this.emitChange();
2006
1949
  }
2007
1950
 
2008
- reapplyCollapsed(collapsedNodes) {
2009
- const textarea = this.shadowRoot.getElementById('textarea');
2010
- const lines = textarea.value.split('\n');
2011
-
2012
- // Group collapsed nodes by nodeKey+indent and count occurrences
2013
- const collapseMap = new Map();
2014
- collapsedNodes.forEach(({nodeKey, indent}) => {
2015
- const key = `${nodeKey}-${indent}`;
2016
- collapseMap.set(key, (collapseMap.get(key) || 0) + 1);
2017
- });
2018
-
2019
- // Track occurrences as we iterate
2020
- const occurrenceCount = new Map();
2021
-
2022
- // Iterate backwards to avoid index issues
2023
- for (let i = lines.length - 1; i >= 0; i--) {
2024
- const line = lines[i];
2025
- const match = line.match(/^(\s*)"(\w+)"\s*:\s*([{\[])/);
2026
-
2027
- if (match) {
2028
- const nodeKey = match[2];
2029
- const currentIndent = match[1].length;
2030
- const key = `${nodeKey}-${currentIndent}`;
2031
-
2032
- if (collapseMap.has(key)) {
2033
- // Count this occurrence
2034
- occurrenceCount.set(key, (occurrenceCount.get(key) || 0) + 1);
2035
- const currentOccurrence = occurrenceCount.get(key);
2036
-
2037
- // Only collapse if this occurrence should be collapsed
2038
- if (currentOccurrence <= collapseMap.get(key)) {
2039
- const indent = match[1];
2040
- const openBracket = match[3];
2041
-
2042
- // Use common collapse helper
2043
- this._performCollapse(lines, i, nodeKey, indent, openBracket);
2044
- }
2045
- }
1951
+ // ========== Event Emission ==========
1952
+
1953
+ emitChange() {
1954
+ const content = this.getContent();
1955
+ const fullValue = this.prefix + content + this.suffix;
1956
+
1957
+ try {
1958
+ let parsed = JSON.parse(fullValue);
1959
+
1960
+ // Filter hidden features
1961
+ if (this.hiddenFeatures.size > 0) {
1962
+ parsed.features = parsed.features.filter((feature) => {
1963
+ const key = this._getFeatureKey(feature);
1964
+ return !this.hiddenFeatures.has(key);
1965
+ });
1966
+ }
1967
+
1968
+ // Validate
1969
+ const errors = this._validateGeoJSON(parsed);
1970
+
1971
+ if (errors.length > 0) {
1972
+ this.dispatchEvent(new CustomEvent('error', {
1973
+ detail: { error: errors.join('; '), errors, content },
1974
+ bubbles: true,
1975
+ composed: true
1976
+ }));
1977
+ } else {
1978
+ this.dispatchEvent(new CustomEvent('change', {
1979
+ detail: parsed,
1980
+ bubbles: true,
1981
+ composed: true
1982
+ }));
2046
1983
  }
1984
+ } catch (e) {
1985
+ this.dispatchEvent(new CustomEvent('error', {
1986
+ detail: { error: e.message, content },
1987
+ bubbles: true,
1988
+ composed: true
1989
+ }));
2047
1990
  }
2048
-
2049
- textarea.value = lines.join('\n');
2050
1991
  }
2051
1992
 
1993
+ // ========== UI Updates ==========
1994
+
1995
+ updateReadonly() {
1996
+ const textarea = this.shadowRoot.getElementById('hiddenTextarea');
1997
+ const clearBtn = this.shadowRoot.getElementById('clearBtn');
1998
+
1999
+ // Use readOnly instead of disabled to allow text selection for copying
2000
+ if (textarea) textarea.readOnly = this.readonly;
2001
+ if (clearBtn) clearBtn.hidden = this.readonly;
2002
+ }
2052
2003
 
2053
- // Parse selector and generate CSS rule for dark theme
2054
- parseSelectorToHostRule(selector) {
2055
- if (!selector || selector === '') {
2056
- // Fallback: use data attribute on host element
2057
- return ':host([data-color-scheme="dark"])';
2004
+ updatePlaceholderVisibility() {
2005
+ const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2006
+ if (placeholder) {
2007
+ placeholder.style.display = this.lines.length > 0 ? 'none' : 'block';
2058
2008
  }
2009
+ }
2059
2010
 
2060
- // Check if it's a simple class on host (.dark)
2061
- if (selector.startsWith('.') && !selector.includes(' ')) {
2062
- return `:host(${selector})`;
2011
+ updatePlaceholderContent() {
2012
+ const placeholder = this.shadowRoot.getElementById('placeholderLayer');
2013
+ if (placeholder) {
2014
+ placeholder.textContent = this.placeholder;
2063
2015
  }
2016
+ this.updatePlaceholderVisibility();
2017
+ }
2064
2018
 
2065
- // Complex selector - use :host-context for parent elements
2066
- return `:host-context(${selector})`;
2019
+ updatePrefixSuffix() {
2020
+ const prefix = this.shadowRoot.getElementById('editorPrefix');
2021
+ const suffix = this.shadowRoot.getElementById('editorSuffix');
2022
+
2023
+ if (prefix) prefix.textContent = this.prefix;
2024
+ if (suffix) suffix.textContent = this.suffix;
2067
2025
  }
2068
2026
 
2069
- // Generate and inject theme CSS based on dark selector
2027
+ // ========== Theme ==========
2028
+
2070
2029
  updateThemeCSS() {
2071
2030
  const darkSelector = this.getAttribute('dark-selector') || '.dark';
2072
- const darkRule = this.parseSelectorToHostRule(darkSelector);
2073
-
2074
- // Find or create theme style element
2031
+ const darkRule = this._parseSelectorToHostRule(darkSelector);
2032
+
2075
2033
  let themeStyle = this.shadowRoot.getElementById('theme-styles');
2076
2034
  if (!themeStyle) {
2077
2035
  themeStyle = document.createElement('style');
2078
2036
  themeStyle.id = 'theme-styles';
2079
2037
  this.shadowRoot.insertBefore(themeStyle, this.shadowRoot.firstChild);
2080
2038
  }
2081
-
2082
- // Helper to generate CSS variables from theme object
2083
- const generateVars = (themeObj) => {
2084
- return Object.entries(themeObj || {})
2085
- .map(([key, value]) => `--${GeoJsonEditor._toKebabCase(key)}: ${value};`)
2086
- .join('\n ');
2039
+
2040
+ const darkDefaults = {
2041
+ bgColor: '#2b2b2b',
2042
+ textColor: '#a9b7c6',
2043
+ caretColor: '#bbbbbb',
2044
+ gutterBg: '#313335',
2045
+ gutterBorder: '#3c3f41',
2046
+ gutterText: '#606366',
2047
+ jsonKey: '#9876aa',
2048
+ jsonString: '#6a8759',
2049
+ jsonNumber: '#6897bb',
2050
+ jsonBoolean: '#cc7832',
2051
+ jsonNull: '#cc7832',
2052
+ jsonPunct: '#a9b7c6',
2053
+ jsonError: '#ff6b68',
2054
+ controlColor: '#cc7832',
2055
+ controlBg: '#3c3f41',
2056
+ controlBorder: '#5a5a5a',
2057
+ geojsonKey: '#9876aa',
2058
+ geojsonType: '#6a8759',
2059
+ geojsonTypeInvalid: '#ff6b68',
2060
+ jsonKeyInvalid: '#ff6b68'
2087
2061
  };
2088
-
2089
- // Light theme: only overrides (defaults are in static CSS)
2090
- const lightVars = generateVars(this.themes.light);
2091
-
2092
- // Dark theme: ALWAYS generate with defaults + overrides (selector is dynamic)
2093
- const darkTheme = { ...GeoJsonEditor.DARK_THEME_DEFAULTS, ...this.themes.dark };
2062
+
2063
+ const toKebab = (str) => str.replace(/([A-Z])/g, '-$1').toLowerCase();
2064
+ const generateVars = (obj) => Object.entries(obj)
2065
+ .map(([k, v]) => `--${toKebab(k)}: ${v};`)
2066
+ .join('\n ');
2067
+
2068
+ const lightVars = generateVars(this.themes.light || {});
2069
+ const darkTheme = { ...darkDefaults, ...this.themes.dark };
2094
2070
  const darkVars = generateVars(darkTheme);
2095
-
2096
- let css = '';
2097
- if (lightVars) {
2098
- css += `:host {\n ${lightVars}\n }\n`;
2099
- }
2100
- // Dark theme is always generated (selector is configurable)
2071
+
2072
+ let css = lightVars ? `:host {\n ${lightVars}\n }\n` : '';
2101
2073
  css += `${darkRule} {\n ${darkVars}\n }`;
2102
-
2074
+
2103
2075
  themeStyle.textContent = css;
2104
2076
  }
2105
2077
 
2106
- // Public API: Theme management
2107
- setTheme(theme) {
2108
- if (theme.dark) {
2109
- this.themes.dark = { ...this.themes.dark, ...theme.dark };
2110
- }
2111
- if (theme.light) {
2112
- this.themes.light = { ...this.themes.light, ...theme.light };
2078
+ _parseSelectorToHostRule(selector) {
2079
+ if (!selector) return ':host([data-color-scheme="dark"])';
2080
+ if (selector.startsWith('.') && !selector.includes(' ')) {
2081
+ return `:host(${selector})`;
2113
2082
  }
2083
+ return `:host-context(${selector})`;
2084
+ }
2085
+
2086
+ setTheme(theme) {
2087
+ if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
2088
+ if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
2114
2089
  this.updateThemeCSS();
2115
2090
  }
2116
2091
 
@@ -2119,272 +2094,363 @@ class GeoJsonEditor extends HTMLElement {
2119
2094
  this.updateThemeCSS();
2120
2095
  }
2121
2096
 
2122
- // ========================================
2123
- // Features API - Programmatic manipulation
2124
- // ========================================
2125
-
2126
- /**
2127
- * Normalize a Python-style index (supports negative values)
2128
- * @param {number} index - Index to normalize (negative = from end)
2129
- * @param {number} length - Length of the array
2130
- * @param {boolean} clamp - If true, clamp to valid range; if false, return -1 for out of bounds
2131
- * @returns {number} Normalized index, or -1 if out of bounds (when clamp=false)
2132
- * @private
2133
- */
2134
- _normalizeIndex(index, length, clamp = false) {
2135
- let idx = index;
2136
- if (idx < 0) {
2137
- idx = length + idx;
2138
- }
2139
- if (clamp) {
2140
- return Math.max(0, Math.min(idx, length));
2097
+ // ========== Helper Methods ==========
2098
+
2099
+ _getFeatureKey(feature) {
2100
+ if (!feature) return null;
2101
+ if (feature.id !== undefined) return `id:${feature.id}`;
2102
+ if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
2103
+
2104
+ const geomType = feature.geometry?.type || 'null';
2105
+ const coords = JSON.stringify(feature.geometry?.coordinates || []);
2106
+ let hash = 0;
2107
+ for (let i = 0; i < coords.length; i++) {
2108
+ hash = ((hash << 5) - hash) + coords.charCodeAt(i);
2109
+ hash = hash & hash;
2141
2110
  }
2142
- return (idx < 0 || idx >= length) ? -1 : idx;
2111
+ return `hash:${geomType}:${hash.toString(36)}`;
2143
2112
  }
2144
2113
 
2145
- /**
2146
- * Parse current textarea content into an array of features
2147
- * @returns {Array} Array of feature objects
2148
- * @private
2149
- */
2150
- _parseFeatures() {
2151
- const textarea = this.shadowRoot.getElementById('textarea');
2152
- if (!textarea || !textarea.value.trim()) {
2153
- return [];
2154
- }
2155
-
2156
- try {
2157
- // Expand collapsed nodes to get full content
2158
- const content = this.expandAllCollapsed(textarea.value);
2159
- // Wrap in array brackets and parse
2160
- const wrapped = '[' + content + ']';
2161
- return JSON.parse(wrapped);
2162
- } catch (e) {
2163
- return [];
2114
+ _countBrackets(line, openBracket) {
2115
+ const closeBracket = openBracket === '{' ? '}' : ']';
2116
+ let open = 0, close = 0, inString = false, escape = false;
2117
+
2118
+ for (const char of line) {
2119
+ if (escape) { escape = false; continue; }
2120
+ if (char === '\\' && inString) { escape = true; continue; }
2121
+ if (char === '"') { inString = !inString; continue; }
2122
+ if (!inString) {
2123
+ if (char === openBracket) open++;
2124
+ if (char === closeBracket) close++;
2125
+ }
2164
2126
  }
2127
+
2128
+ return { open, close };
2165
2129
  }
2166
2130
 
2167
2131
  /**
2168
- * Update textarea with features array and trigger all updates
2169
- * @param {Array} features - Array of feature objects
2170
- * @private
2132
+ * Find all collapsible ranges using the mappings built by _rebuildNodeIdMappings
2133
+ * This method only READS the existing mappings, it doesn't create new IDs
2171
2134
  */
2172
- _setFeatures(features) {
2173
- const textarea = this.shadowRoot.getElementById('textarea');
2174
- if (!textarea) return;
2175
-
2176
- // Clear internal state when replacing features (prevent memory leaks)
2177
- this.collapsedData.clear();
2178
- this.hiddenFeatures.clear();
2179
-
2180
- if (!features || features.length === 0) {
2181
- textarea.value = '';
2182
- } else {
2183
- // Format each feature and join with comma
2184
- const formatted = features
2185
- .map(f => JSON.stringify(f, null, 2))
2186
- .join(',\n');
2187
-
2188
- textarea.value = formatted;
2189
- }
2190
-
2191
- // Trigger all updates
2192
- this.updateHighlight();
2193
- this.updatePlaceholderVisibility();
2194
-
2195
- // Auto-collapse coordinates
2196
- if (textarea.value) {
2197
- requestAnimationFrame(() => {
2198
- this.applyAutoCollapsed();
2135
+ _findCollapsibleRanges() {
2136
+ const ranges = [];
2137
+
2138
+ // Simply iterate through the existing mappings
2139
+ for (const [lineIndex, nodeId] of this._lineToNodeId) {
2140
+ const rangeInfo = this._nodeIdToLines.get(nodeId);
2141
+ if (!rangeInfo) continue;
2142
+
2143
+ const line = this.lines[lineIndex];
2144
+ if (!line) continue;
2145
+
2146
+ // Match "key": { or "key": [
2147
+ const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
2148
+ // Also match standalone { or [ (root Feature objects)
2149
+ const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
2150
+
2151
+ if (!kvMatch && !rootMatch) continue;
2152
+
2153
+ const openBracket = kvMatch ? kvMatch[2] : rootMatch[1];
2154
+
2155
+ ranges.push({
2156
+ startLine: rangeInfo.startLine,
2157
+ endLine: rangeInfo.endLine,
2158
+ nodeKey: rangeInfo.nodeKey || (kvMatch ? kvMatch[1] : `__root_${lineIndex}`),
2159
+ nodeId,
2160
+ openBracket,
2161
+ isRootFeature: !!rootMatch
2199
2162
  });
2200
2163
  }
2201
-
2202
- // Emit change event
2203
- this.emitChange();
2164
+
2165
+ // Sort by startLine for consistent ordering
2166
+ ranges.sort((a, b) => a.startLine - b.startLine);
2167
+
2168
+ return ranges;
2204
2169
  }
2205
2170
 
2206
- /**
2207
- * Validate a single feature object
2208
- * @param {Object} feature - Feature object to validate
2209
- * @returns {string[]} Array of validation error messages (empty if valid)
2210
- * @private
2211
- */
2212
- _validateFeature(feature) {
2213
- const errors = [];
2214
-
2215
- if (!feature || typeof feature !== 'object') {
2216
- errors.push('Feature must be an object');
2217
- return errors;
2171
+ _findClosingLine(startLine, openBracket) {
2172
+ let depth = 1;
2173
+ const line = this.lines[startLine];
2174
+ const bracketPos = line.indexOf(openBracket);
2175
+
2176
+ if (bracketPos !== -1) {
2177
+ const rest = line.substring(bracketPos + 1);
2178
+ const counts = this._countBrackets(rest, openBracket);
2179
+ depth += counts.open - counts.close;
2180
+ if (depth === 0) return startLine;
2218
2181
  }
2219
-
2220
- if (Array.isArray(feature)) {
2221
- errors.push('Feature cannot be an array');
2222
- return errors;
2182
+
2183
+ for (let i = startLine + 1; i < this.lines.length; i++) {
2184
+ const counts = this._countBrackets(this.lines[i], openBracket);
2185
+ depth += counts.open - counts.close;
2186
+ if (depth === 0) return i;
2223
2187
  }
2188
+
2189
+ return -1;
2190
+ }
2224
2191
 
2225
- // Check required type field
2226
- if (!('type' in feature)) {
2227
- errors.push('Feature must have a "type" property');
2228
- } else if (feature.type !== 'Feature') {
2229
- errors.push(`Feature type must be "Feature", got "${feature.type}"`);
2192
+ _buildContextMap() {
2193
+ const contextMap = new Map();
2194
+ const contextStack = [];
2195
+ let pendingContext = null;
2196
+
2197
+ for (let i = 0; i < this.lines.length; i++) {
2198
+ const line = this.lines[i];
2199
+ const currentContext = contextStack[contextStack.length - 1]?.context || 'Feature';
2200
+ contextMap.set(i, currentContext);
2201
+
2202
+ // Check for context-changing keys
2203
+ if (/"geometry"\s*:/.test(line)) pendingContext = 'geometry';
2204
+ else if (/"properties"\s*:/.test(line)) pendingContext = 'properties';
2205
+ else if (/"features"\s*:/.test(line)) pendingContext = 'Feature';
2206
+
2207
+ // Track brackets
2208
+ const openBraces = (line.match(/\{/g) || []).length;
2209
+ const closeBraces = (line.match(/\}/g) || []).length;
2210
+ const openBrackets = (line.match(/\[/g) || []).length;
2211
+ const closeBrackets = (line.match(/\]/g) || []).length;
2212
+
2213
+ for (let j = 0; j < openBraces + openBrackets; j++) {
2214
+ contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
2215
+ pendingContext = null;
2216
+ }
2217
+
2218
+ for (let j = 0; j < closeBraces + closeBrackets && contextStack.length > 0; j++) {
2219
+ contextStack.pop();
2220
+ }
2230
2221
  }
2222
+
2223
+ return contextMap;
2224
+ }
2231
2225
 
2232
- // Check geometry field exists (can be null for features without location)
2233
- if (!('geometry' in feature)) {
2234
- errors.push('Feature must have a "geometry" property (can be null)');
2235
- } else if (feature.geometry !== null) {
2236
- // Validate geometry if not null
2237
- if (typeof feature.geometry !== 'object' || Array.isArray(feature.geometry)) {
2238
- errors.push('Feature geometry must be an object or null');
2239
- } else {
2240
- // Check geometry has valid type
2241
- if (!('type' in feature.geometry)) {
2242
- errors.push('Geometry must have a "type" property');
2243
- } else if (!GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.includes(feature.geometry.type)) {
2244
- errors.push(`Invalid geometry type "${feature.geometry.type}" (expected: ${GeoJsonEditor.GEOJSON.GEOMETRY_TYPES.join(', ')})`);
2226
+ _highlightSyntax(text, context, meta) {
2227
+ if (!text) return '';
2228
+
2229
+ // For collapsed nodes, truncate the text at the opening bracket
2230
+ let displayText = text;
2231
+ let collapsedBracket = null;
2232
+
2233
+ if (meta?.collapseButton?.isCollapsed) {
2234
+ // Match "key": { or "key": [
2235
+ const bracketMatch = text.match(/^(\s*"[^"]+"\s*:\s*)([{\[])/);
2236
+ // Also match standalone { or [ (root Feature objects)
2237
+ const rootMatch = !bracketMatch && text.match(/^(\s*)([{\[]),?\s*$/);
2238
+
2239
+ if (bracketMatch) {
2240
+ // Keep only the part up to and including the opening bracket
2241
+ displayText = bracketMatch[1] + bracketMatch[2];
2242
+ collapsedBracket = bracketMatch[2];
2243
+ } else if (rootMatch) {
2244
+ // Root object - just keep the bracket
2245
+ displayText = rootMatch[1] + rootMatch[2];
2246
+ collapsedBracket = rootMatch[2];
2247
+ }
2248
+ }
2249
+
2250
+ // Escape HTML first
2251
+ let result = displayText
2252
+ .replace(/&/g, '&amp;')
2253
+ .replace(/</g, '&lt;')
2254
+ .replace(/>/g, '&gt;');
2255
+
2256
+ // Punctuation FIRST (before other replacements can interfere)
2257
+ result = result.replace(/([{}[\],:])/g, '<span class="json-punctuation">$1</span>');
2258
+
2259
+ // JSON keys - match "key" followed by :
2260
+ // In properties context, all keys are treated as regular JSON keys
2261
+ result = result.replace(/"([^"]+)"(<span class="json-punctuation">:<\/span>)/g, (match, key, colon) => {
2262
+ if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
2263
+ return `<span class="geojson-key">"${key}"</span>${colon}`;
2264
+ }
2265
+ return `<span class="json-key">"${key}"</span>${colon}`;
2266
+ });
2267
+
2268
+ // Type values - "type": "Value" - but NOT inside properties context
2269
+ if (context !== 'properties') {
2270
+ result = result.replace(
2271
+ /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>\s*"([^"]*)"/g,
2272
+ (match, type) => {
2273
+ const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
2274
+ const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
2275
+ return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span> <span class="${cls}">"${type}"</span>`;
2245
2276
  }
2246
-
2247
- // Check geometry has coordinates
2248
- if (!('coordinates' in feature.geometry)) {
2249
- errors.push('Geometry must have a "coordinates" property');
2277
+ );
2278
+ }
2279
+
2280
+ // String values (not already wrapped in spans)
2281
+ result = result.replace(
2282
+ /(<span class="json-punctuation">:<\/span>)\s*"([^"]*)"/g,
2283
+ (match, colon, val) => {
2284
+ // Don't double-wrap if already has a span after colon
2285
+ if (match.includes('geojson-type') || match.includes('json-string')) return match;
2286
+
2287
+ // Check if it's a color value (hex) - use ::before for swatch via CSS class
2288
+ if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(val)) {
2289
+ return `${colon} <span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
2250
2290
  }
2291
+
2292
+ return `${colon} <span class="json-string">"${val}"</span>`;
2251
2293
  }
2294
+ );
2295
+
2296
+ // Numbers after colon
2297
+ result = result.replace(
2298
+ /(<span class="json-punctuation">:<\/span>)\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2299
+ '$1 <span class="json-number">$2</span>'
2300
+ );
2301
+
2302
+ // Numbers in arrays (after [ or ,)
2303
+ result = result.replace(
2304
+ /(<span class="json-punctuation">[\[,]<\/span>)\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi,
2305
+ '$1<span class="json-number">$2</span>'
2306
+ );
2307
+
2308
+ // Standalone numbers at start of line (coordinates arrays)
2309
+ result = result.replace(
2310
+ /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim,
2311
+ '$1<span class="json-number">$2</span>'
2312
+ );
2313
+
2314
+ // Booleans - use ::before for checkbox via CSS class
2315
+ result = result.replace(
2316
+ /(<span class="json-punctuation">:<\/span>)\s*(true|false)/g,
2317
+ (match, colon, val) => {
2318
+ const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
2319
+ return `${colon} <span class="json-boolean${checkedClass}">${val}</span>`;
2320
+ }
2321
+ );
2322
+
2323
+ // Null
2324
+ result = result.replace(
2325
+ /(<span class="json-punctuation">:<\/span>)\s*(null)/g,
2326
+ '$1 <span class="json-null">$2</span>'
2327
+ );
2328
+
2329
+ // Collapsed bracket indicator - just add the class, CSS ::after adds the "...]" or "...}"
2330
+ if (collapsedBracket) {
2331
+ const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
2332
+ // Replace the last punctuation span (the opening bracket) with collapsed style class
2333
+ result = result.replace(
2334
+ new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
2335
+ `<span class="${bracketClass}">${collapsedBracket}</span>`
2336
+ );
2252
2337
  }
2253
-
2254
- // Check properties field exists (can be null)
2255
- if (!('properties' in feature)) {
2256
- errors.push('Feature must have a "properties" property (can be null)');
2257
- } else if (feature.properties !== null && (typeof feature.properties !== 'object' || Array.isArray(feature.properties))) {
2258
- errors.push('Feature properties must be an object or null');
2259
- }
2260
-
2261
- return errors;
2338
+
2339
+ // Mark unrecognized text as error - text that's not inside a span and not just whitespace
2340
+ // This catches invalid JSON like unquoted strings, malformed values, etc.
2341
+ result = result.replace(
2342
+ /(<\/span>|^)([^<]+)(<span|$)/g,
2343
+ (match, before, text, after) => {
2344
+ // Skip if text is only whitespace or empty
2345
+ if (!text || /^\s*$/.test(text)) return match;
2346
+ // Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
2347
+ // Keep whitespace as-is, wrap any non-whitespace unrecognized token
2348
+ const parts = text.split(/(\s+)/);
2349
+ let hasError = false;
2350
+ const processed = parts.map(part => {
2351
+ // If it's whitespace, keep it
2352
+ if (/^\s*$/.test(part)) return part;
2353
+ // Mark as error
2354
+ hasError = true;
2355
+ return `<span class="json-error">${part}</span>`;
2356
+ }).join('');
2357
+ return hasError ? before + processed + after : match;
2358
+ }
2359
+ );
2360
+
2361
+ // Note: visibility is now handled at line level (has-visibility class on .line element)
2362
+
2363
+ return result;
2262
2364
  }
2263
2365
 
2264
- /**
2265
- * Replace all features with the given array
2266
- * @param {Array} features - Array of feature objects to set
2267
- * @throws {Error} If features is not an array or contains invalid features
2268
- */
2269
- set(features) {
2270
- if (!Array.isArray(features)) {
2271
- throw new Error('set() expects an array of features');
2272
- }
2273
-
2274
- // Validate each feature
2275
- const allErrors = [];
2276
- features.forEach((feature, index) => {
2277
- const errors = this._validateFeature(feature);
2278
- if (errors.length > 0) {
2279
- allErrors.push(`Feature[${index}]: ${errors.join(', ')}`);
2366
+ _validateGeoJSON(parsed) {
2367
+ const errors = [];
2368
+
2369
+ if (!parsed.features) return errors;
2370
+
2371
+ parsed.features.forEach((feature, i) => {
2372
+ if (feature.type !== 'Feature') {
2373
+ errors.push(`features[${i}]: type must be "Feature"`);
2374
+ }
2375
+ if (feature.geometry && feature.geometry.type) {
2376
+ if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
2377
+ errors.push(`features[${i}].geometry: invalid type "${feature.geometry.type}"`);
2378
+ }
2280
2379
  }
2281
2380
  });
2381
+
2382
+ return errors;
2383
+ }
2282
2384
 
2283
- if (allErrors.length > 0) {
2284
- throw new Error(`Invalid features: ${allErrors.join('; ')}`);
2285
- }
2286
-
2287
- // Apply default properties to each feature
2288
- const featuresWithDefaults = features.map(f => this._applyDefaultPropertiesToFeature(f));
2289
- this._setFeatures(featuresWithDefaults);
2385
+ // ========== Public API ==========
2386
+
2387
+ set(features) {
2388
+ if (!Array.isArray(features)) throw new Error('set() expects an array');
2389
+ const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
2390
+ this.setValue(formatted);
2290
2391
  }
2291
2392
 
2292
- /**
2293
- * Add a feature at the end of the list
2294
- * @param {Object} feature - Feature object to add
2295
- * @throws {Error} If feature is invalid
2296
- */
2297
2393
  add(feature) {
2298
- const errors = this._validateFeature(feature);
2299
- if (errors.length > 0) {
2300
- throw new Error(`Invalid feature: ${errors.join(', ')}`);
2301
- }
2302
-
2303
2394
  const features = this._parseFeatures();
2304
- // Apply default properties before adding
2305
- features.push(this._applyDefaultPropertiesToFeature(feature));
2306
- this._setFeatures(features);
2395
+ features.push(feature);
2396
+ this.set(features);
2307
2397
  }
2308
2398
 
2309
- /**
2310
- * Insert a feature at the specified index
2311
- * @param {Object} feature - Feature object to insert
2312
- * @param {number} index - Index to insert at (negative = from end)
2313
- * @throws {Error} If feature is invalid
2314
- */
2315
2399
  insertAt(feature, index) {
2316
- const errors = this._validateFeature(feature);
2317
- if (errors.length > 0) {
2318
- throw new Error(`Invalid feature: ${errors.join(', ')}`);
2319
- }
2320
-
2321
2400
  const features = this._parseFeatures();
2322
- const idx = this._normalizeIndex(index, features.length, true);
2323
-
2324
- // Apply default properties before inserting
2325
- features.splice(idx, 0, this._applyDefaultPropertiesToFeature(feature));
2326
- this._setFeatures(features);
2401
+ const idx = index < 0 ? features.length + index : index;
2402
+ features.splice(Math.max(0, Math.min(idx, features.length)), 0, feature);
2403
+ this.set(features);
2327
2404
  }
2328
2405
 
2329
- /**
2330
- * Remove the feature at the specified index
2331
- * @param {number} index - Index to remove (negative = from end)
2332
- * @returns {Object|undefined} The removed feature, or undefined if index out of bounds
2333
- */
2334
2406
  removeAt(index) {
2335
2407
  const features = this._parseFeatures();
2336
- if (features.length === 0) return undefined;
2337
-
2338
- const idx = this._normalizeIndex(index, features.length);
2339
- if (idx === -1) return undefined;
2340
-
2341
- const removed = features.splice(idx, 1)[0];
2342
- this._setFeatures(features);
2343
- return removed;
2408
+ const idx = index < 0 ? features.length + index : index;
2409
+ if (idx >= 0 && idx < features.length) {
2410
+ const removed = features.splice(idx, 1)[0];
2411
+ this.set(features);
2412
+ return removed;
2413
+ }
2414
+ return undefined;
2344
2415
  }
2345
2416
 
2346
- /**
2347
- * Remove all features
2348
- * @returns {Array} Array of removed features
2349
- */
2350
2417
  removeAll() {
2351
2418
  const removed = this._parseFeatures();
2352
- this._setFeatures([]);
2419
+ this.lines = [];
2420
+ this.collapsedNodes.clear();
2421
+ this.hiddenFeatures.clear();
2422
+ this.updateModel();
2423
+ this.scheduleRender();
2424
+ this.updatePlaceholderVisibility();
2425
+ this.emitChange();
2353
2426
  return removed;
2354
2427
  }
2355
2428
 
2356
- /**
2357
- * Get the feature at the specified index
2358
- * @param {number} index - Index to get (negative = from end)
2359
- * @returns {Object|undefined} The feature, or undefined if index out of bounds
2360
- */
2361
2429
  get(index) {
2362
2430
  const features = this._parseFeatures();
2363
- if (features.length === 0) return undefined;
2364
-
2365
- const idx = this._normalizeIndex(index, features.length);
2366
- if (idx === -1) return undefined;
2367
-
2431
+ const idx = index < 0 ? features.length + index : index;
2368
2432
  return features[idx];
2369
2433
  }
2370
2434
 
2371
- /**
2372
- * Get all features as an array
2373
- * @returns {Array} Array of all feature objects
2374
- */
2375
2435
  getAll() {
2376
2436
  return this._parseFeatures();
2377
2437
  }
2378
2438
 
2379
- /**
2380
- * Emit the current document on the change event
2381
- */
2382
2439
  emit() {
2383
2440
  this.emitChange();
2384
2441
  }
2442
+
2443
+ _parseFeatures() {
2444
+ try {
2445
+ const content = this.lines.join('\n');
2446
+ if (!content.trim()) return [];
2447
+ return JSON.parse('[' + content + ']');
2448
+ } catch (e) {
2449
+ return [];
2450
+ }
2451
+ }
2385
2452
  }
2386
2453
 
2387
- // Register the custom element
2388
2454
  customElements.define('geojson-editor', GeoJsonEditor);
2389
2455
 
2390
2456
  export default GeoJsonEditor;