@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.
- package/README.md +9 -63
- package/dist/geojson-editor.js +2 -2
- package/package.json +2 -1
- package/src/geojson-editor.css +453 -0
- package/src/geojson-editor.js +2152 -2086
- package/src/geojson-editor.template.js +57 -0
package/src/geojson-editor.js
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
this.
|
|
8
|
-
this.
|
|
9
|
-
this.
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
// ========== Model (Source of Truth) ==========
|
|
18
|
+
this.lines = []; // Array of line strings
|
|
19
|
+
this.collapsedNodes = new Set(); // Set of unique node IDs that are collapsed
|
|
20
|
+
this.hiddenFeatures = new Set(); // Set of feature keys hidden from events
|
|
21
|
+
|
|
22
|
+
// ========== Node ID Management ==========
|
|
23
|
+
this._nodeIdCounter = 0; // Counter for generating unique node IDs
|
|
24
|
+
this._lineToNodeId = new Map(); // lineIndex -> nodeId (for collapsible lines)
|
|
25
|
+
this._nodeIdToLines = new Map(); // nodeId -> {startLine, endLine} (range of collapsed content)
|
|
26
|
+
|
|
27
|
+
// ========== Derived State (computed from model) ==========
|
|
28
|
+
this.visibleLines = []; // Lines to render (after collapse filter)
|
|
29
|
+
this.lineMetadata = new Map(); // lineIndex -> {colors, booleans, collapse, visibility, hidden, featureKey}
|
|
12
30
|
this.featureRanges = new Map(); // featureKey -> {startLine, endLine, featureIndex}
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
this.
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
|
|
32
|
+
// ========== View State ==========
|
|
33
|
+
this.scrollTop = 0;
|
|
34
|
+
this.viewportHeight = 0;
|
|
35
|
+
this.lineHeight = 19.5; // CSS: line-height * font-size = 1.5 * 13px
|
|
36
|
+
this.bufferLines = 5; // Extra lines to render above/below viewport
|
|
37
|
+
|
|
38
|
+
// ========== Render Cache ==========
|
|
39
|
+
this._lastStartIndex = -1;
|
|
40
|
+
this._lastEndIndex = -1;
|
|
41
|
+
this._lastTotalLines = -1;
|
|
42
|
+
this._scrollRaf = null;
|
|
43
|
+
|
|
44
|
+
// ========== Cursor/Selection ==========
|
|
45
|
+
this.cursorLine = 0;
|
|
46
|
+
this.cursorColumn = 0;
|
|
47
|
+
this.selectionStart = null; // {line, column}
|
|
48
|
+
this.selectionEnd = null; // {line, column}
|
|
49
|
+
|
|
50
|
+
// ========== Debounce ==========
|
|
51
|
+
this.renderTimer = null;
|
|
52
|
+
this.inputTimer = null;
|
|
53
|
+
|
|
54
|
+
// ========== Theme ==========
|
|
22
55
|
this.themes = { dark: {}, light: {} };
|
|
23
56
|
}
|
|
24
57
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// Parsed default properties rules (cache)
|
|
30
|
-
_defaultPropertiesRules = null;
|
|
31
|
-
|
|
32
|
-
// Helper: Convert camelCase to kebab-case
|
|
33
|
-
static _toKebabCase(str) {
|
|
34
|
-
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Dark theme defaults - IntelliJ Darcula (light defaults are CSS fallbacks)
|
|
38
|
-
static DARK_THEME_DEFAULTS = {
|
|
39
|
-
bgColor: '#2b2b2b',
|
|
40
|
-
textColor: '#a9b7c6',
|
|
41
|
-
caretColor: '#bbbbbb',
|
|
42
|
-
gutterBg: '#313335',
|
|
43
|
-
gutterBorder: '#3c3f41',
|
|
44
|
-
jsonKey: '#9876aa',
|
|
45
|
-
jsonString: '#6a8759',
|
|
46
|
-
jsonNumber: '#6897bb',
|
|
47
|
-
jsonBoolean: '#cc7832',
|
|
48
|
-
jsonNull: '#cc7832',
|
|
49
|
-
jsonPunct: '#a9b7c6',
|
|
50
|
-
controlColor: '#cc7832',
|
|
51
|
-
controlBg: '#3c3f41',
|
|
52
|
-
controlBorder: '#5a5a5a',
|
|
53
|
-
geojsonKey: '#9876aa',
|
|
54
|
-
geojsonType: '#6a8759',
|
|
55
|
-
geojsonTypeInvalid: '#ff6b68',
|
|
56
|
-
jsonKeyInvalid: '#ff6b68'
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
// Pre-compiled regex patterns (avoid recompilation on each call)
|
|
60
|
-
static REGEX = {
|
|
61
|
-
// HTML escaping
|
|
62
|
-
ampersand: /&/g,
|
|
63
|
-
lessThan: /</g,
|
|
64
|
-
greaterThan: />/g,
|
|
65
|
-
// JSON structure
|
|
66
|
-
jsonKey: /"([^"]+)"\s*:/g,
|
|
67
|
-
typeValue: /<span class="geojson-key">"type"<\/span>:\s*"([^"]*)"/g,
|
|
68
|
-
stringValue: /:\s*"([^"]*)"/g,
|
|
69
|
-
numberAfterColon: /:\s*(-?\d+\.?\d*)/g,
|
|
70
|
-
boolean: /:\s*(true|false)/g,
|
|
71
|
-
nullValue: /:\s*(null)/g,
|
|
72
|
-
allNumbers: /\b(-?\d+\.?\d*)\b/g,
|
|
73
|
-
punctuation: /([{}[\],])/g,
|
|
74
|
-
// Highlighting detection
|
|
75
|
-
colorInLine: /"([\w-]+)"\s*:\s*"(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))"/g,
|
|
76
|
-
booleanInLine: /"([\w-]+)"\s*:\s*(true|false)/g,
|
|
77
|
-
collapsibleNode: /^(\s*)"(\w+)"\s*:\s*([{\[])/,
|
|
78
|
-
collapsedMarker: /^(\s*)"(\w+)"\s*:\s*([{\[])\.\.\.([\]\}])/
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Icons used in the gutter
|
|
82
|
-
static ICONS = {
|
|
83
|
-
expanded: '⌄', // Chevron down (collapse button when expanded)
|
|
84
|
-
collapsed: '›', // Chevron right (expand button when collapsed)
|
|
85
|
-
visibility: '👁' // Eye icon for visibility toggle
|
|
86
|
-
};
|
|
58
|
+
// ========== Unique ID Generation ==========
|
|
59
|
+
_generateNodeId() {
|
|
60
|
+
return `node_${++this._nodeIdCounter}`;
|
|
61
|
+
}
|
|
87
62
|
|
|
88
63
|
/**
|
|
89
|
-
*
|
|
90
|
-
* @param {number} lineIndex -
|
|
91
|
-
* @
|
|
92
|
-
* @param {number} indent - Indentation level to match
|
|
93
|
-
* @returns {{key: string, data: Object}|null} Found key and data, or null
|
|
94
|
-
* @private
|
|
64
|
+
* Check if a line is inside a collapsed node (hidden lines between opening and closing)
|
|
65
|
+
* @param {number} lineIndex - The line index to check
|
|
66
|
+
* @returns {Object|null} - The collapsed range info or null
|
|
95
67
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Search for any key with this nodeKey and matching indent
|
|
104
|
-
for (const [key, data] of this.collapsedData.entries()) {
|
|
105
|
-
if (data.nodeKey === nodeKey && data.indent === indent) {
|
|
106
|
-
return { key, data };
|
|
68
|
+
_getCollapsedRangeForLine(lineIndex) {
|
|
69
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
70
|
+
// Lines strictly between opening and closing are hidden
|
|
71
|
+
if (this.collapsedNodes.has(nodeId) && lineIndex > info.startLine && lineIndex < info.endLine) {
|
|
72
|
+
return { nodeId, ...info };
|
|
107
73
|
}
|
|
108
74
|
}
|
|
109
|
-
|
|
110
75
|
return null;
|
|
111
76
|
}
|
|
112
77
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// Parse default properties rules
|
|
124
|
-
this._parseDefaultProperties();
|
|
125
|
-
|
|
126
|
-
// Initialize textarea with value attribute (attributeChangedCallback fires before render)
|
|
127
|
-
if (this.value) {
|
|
128
|
-
this.updateValue(this.value);
|
|
78
|
+
/**
|
|
79
|
+
* Check if cursor is on the closing line of a collapsed node
|
|
80
|
+
* @param {number} lineIndex - The line index to check
|
|
81
|
+
* @returns {Object|null} - The collapsed range info or null
|
|
82
|
+
*/
|
|
83
|
+
_getCollapsedClosingLine(lineIndex) {
|
|
84
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
85
|
+
if (this.collapsedNodes.has(nodeId) && lineIndex === info.endLine) {
|
|
86
|
+
return { nodeId, ...info };
|
|
87
|
+
}
|
|
129
88
|
}
|
|
130
|
-
|
|
89
|
+
return null;
|
|
131
90
|
}
|
|
132
91
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (this.highlightTimer) {
|
|
143
|
-
clearTimeout(this.highlightTimer);
|
|
144
|
-
this.highlightTimer = null;
|
|
145
|
-
}
|
|
92
|
+
/**
|
|
93
|
+
* Get the position of the closing bracket on a line
|
|
94
|
+
* @param {string} line - The line content
|
|
95
|
+
* @returns {number} - Position of bracket or -1
|
|
96
|
+
*/
|
|
97
|
+
_getClosingBracketPos(line) {
|
|
98
|
+
// Find the last ] or } on the line
|
|
99
|
+
const lastBracket = Math.max(line.lastIndexOf(']'), line.lastIndexOf('}'));
|
|
100
|
+
return lastBracket;
|
|
146
101
|
}
|
|
147
102
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
this.
|
|
157
|
-
|
|
158
|
-
this.updateThemeCSS();
|
|
159
|
-
} else if (name === 'default-properties') {
|
|
160
|
-
// Re-parse the default properties rules
|
|
161
|
-
this._parseDefaultProperties();
|
|
103
|
+
/**
|
|
104
|
+
* Check if cursor is on the opening line of a collapsed node
|
|
105
|
+
* @param {number} lineIndex - The line index to check
|
|
106
|
+
* @returns {Object|null} - The collapsed range info or null
|
|
107
|
+
*/
|
|
108
|
+
_getCollapsedNodeAtLine(lineIndex) {
|
|
109
|
+
const nodeId = this._lineToNodeId.get(lineIndex);
|
|
110
|
+
if (nodeId && this.collapsedNodes.has(nodeId)) {
|
|
111
|
+
const info = this._nodeIdToLines.get(nodeId);
|
|
112
|
+
return { nodeId, ...info };
|
|
162
113
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// Properties
|
|
166
|
-
get readonly() {
|
|
167
|
-
return this.hasAttribute('readonly');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
get value() {
|
|
172
|
-
return this.getAttribute('value') || '';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
get placeholder() {
|
|
176
|
-
return this.getAttribute('placeholder') || '';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Always in FeatureCollection mode - prefix/suffix are constant
|
|
180
|
-
get prefix() {
|
|
181
|
-
return '{"type": "FeatureCollection", "features": [';
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
get suffix() {
|
|
185
|
-
return ']}';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
get defaultProperties() {
|
|
189
|
-
return this.getAttribute('default-properties') || '';
|
|
114
|
+
return null;
|
|
190
115
|
}
|
|
191
116
|
|
|
192
117
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* 2. Conditional array: [{"match": {"geometry.type": "Polygon"}, "values": {...}}, ...]
|
|
197
|
-
*
|
|
198
|
-
* Returns an array of rules: [{match: null|object, values: object}]
|
|
118
|
+
* Check if cursor is on a line that has a collapsible node (expanded or collapsed)
|
|
119
|
+
* @param {number} lineIndex - The line index to check
|
|
120
|
+
* @returns {Object|null} - The node info with isCollapsed flag or null
|
|
199
121
|
*/
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
const parsed = JSON.parse(attr);
|
|
209
|
-
|
|
210
|
-
if (Array.isArray(parsed)) {
|
|
211
|
-
// Conditional format: array of rules
|
|
212
|
-
this._defaultPropertiesRules = parsed.map(rule => ({
|
|
213
|
-
match: rule.match || null,
|
|
214
|
-
values: rule.values || {}
|
|
215
|
-
}));
|
|
216
|
-
} else if (typeof parsed === 'object' && parsed !== null) {
|
|
217
|
-
// Simple format: single object of properties for all features
|
|
218
|
-
this._defaultPropertiesRules = [{ match: null, values: parsed }];
|
|
219
|
-
} else {
|
|
220
|
-
this._defaultPropertiesRules = [];
|
|
221
|
-
}
|
|
222
|
-
} catch (e) {
|
|
223
|
-
console.warn('geojson-editor: Invalid default-properties JSON:', e.message);
|
|
224
|
-
this._defaultPropertiesRules = [];
|
|
122
|
+
_getCollapsibleNodeAtLine(lineIndex) {
|
|
123
|
+
const nodeId = this._lineToNodeId.get(lineIndex);
|
|
124
|
+
if (nodeId) {
|
|
125
|
+
const info = this._nodeIdToLines.get(nodeId);
|
|
126
|
+
const isCollapsed = this.collapsedNodes.has(nodeId);
|
|
127
|
+
return { nodeId, isCollapsed, ...info };
|
|
225
128
|
}
|
|
226
|
-
|
|
227
|
-
return this._defaultPropertiesRules;
|
|
129
|
+
return null;
|
|
228
130
|
}
|
|
229
131
|
|
|
230
132
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* -
|
|
234
|
-
* -
|
|
133
|
+
* Find the innermost expanded node that contains the given line
|
|
134
|
+
* Used for Shift+Tab to collapse the parent node from anywhere inside it
|
|
135
|
+
* @param {number} lineIndex - The line index to check
|
|
136
|
+
* @returns {Object|null} - The containing node info or null
|
|
235
137
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
for (const [
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
138
|
+
_getContainingExpandedNode(lineIndex) {
|
|
139
|
+
let bestMatch = null;
|
|
140
|
+
|
|
141
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
142
|
+
// Skip collapsed nodes
|
|
143
|
+
if (this.collapsedNodes.has(nodeId)) continue;
|
|
144
|
+
|
|
145
|
+
// Check if line is within this node's range
|
|
146
|
+
if (lineIndex >= info.startLine && lineIndex <= info.endLine) {
|
|
147
|
+
// Prefer the innermost (smallest) containing node
|
|
148
|
+
if (!bestMatch || (info.endLine - info.startLine) < (bestMatch.endLine - bestMatch.startLine)) {
|
|
149
|
+
bestMatch = { nodeId, ...info };
|
|
150
|
+
}
|
|
243
151
|
}
|
|
244
152
|
}
|
|
245
|
-
|
|
153
|
+
|
|
154
|
+
return bestMatch;
|
|
246
155
|
}
|
|
247
156
|
|
|
248
157
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
158
|
+
* Delete an entire collapsed node (opening line to closing line)
|
|
159
|
+
* @param {Object} range - The range info {startLine, endLine}
|
|
251
160
|
*/
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
161
|
+
_deleteCollapsedNode(range) {
|
|
162
|
+
// Remove all lines from startLine to endLine
|
|
163
|
+
const count = range.endLine - range.startLine + 1;
|
|
164
|
+
this.lines.splice(range.startLine, count);
|
|
165
|
+
|
|
166
|
+
// Position cursor at the line where the node was
|
|
167
|
+
this.cursorLine = Math.min(range.startLine, this.lines.length - 1);
|
|
168
|
+
this.cursorColumn = 0;
|
|
169
|
+
|
|
170
|
+
this.formatAndUpdate();
|
|
260
171
|
}
|
|
261
172
|
|
|
262
173
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* Returns a new feature object (doesn't mutate original).
|
|
174
|
+
* Rebuild nodeId mappings after content changes
|
|
175
|
+
* Preserves collapsed state by matching nodeKey + sequential occurrence
|
|
266
176
|
*/
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
177
|
+
_rebuildNodeIdMappings() {
|
|
178
|
+
// Save old state to try to preserve collapsed nodes
|
|
179
|
+
const oldCollapsed = new Set(this.collapsedNodes);
|
|
180
|
+
const oldNodeKeyMap = new Map(); // nodeId -> nodeKey
|
|
181
|
+
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
182
|
+
if (info.nodeKey) oldNodeKeyMap.set(nodeId, info.nodeKey);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Build list of collapsed nodeKeys for matching
|
|
186
|
+
const collapsedNodeKeys = [];
|
|
187
|
+
for (const nodeId of oldCollapsed) {
|
|
188
|
+
const nodeKey = oldNodeKeyMap.get(nodeId);
|
|
189
|
+
if (nodeKey) collapsedNodeKeys.push(nodeKey);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reset mappings
|
|
193
|
+
this._nodeIdCounter = 0;
|
|
194
|
+
this._lineToNodeId.clear();
|
|
195
|
+
this._nodeIdToLines.clear();
|
|
196
|
+
this.collapsedNodes.clear();
|
|
197
|
+
|
|
198
|
+
// Track occurrences of each nodeKey for matching
|
|
199
|
+
const nodeKeyOccurrences = new Map();
|
|
200
|
+
|
|
201
|
+
// Assign fresh IDs to all collapsible nodes
|
|
202
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
203
|
+
const line = this.lines[i];
|
|
204
|
+
|
|
205
|
+
// Match "key": { or "key": [
|
|
206
|
+
const kvMatch = line.match(/^\s*"([^"]+)"\s*:\s*([{\[])/);
|
|
207
|
+
// Also match standalone { or {, (root Feature objects)
|
|
208
|
+
const rootMatch = !kvMatch && line.match(/^\s*([{\[]),?\s*$/);
|
|
209
|
+
|
|
210
|
+
if (!kvMatch && !rootMatch) continue;
|
|
211
|
+
|
|
212
|
+
let nodeKey, openBracket;
|
|
213
|
+
|
|
214
|
+
if (kvMatch) {
|
|
215
|
+
nodeKey = kvMatch[1];
|
|
216
|
+
openBracket = kvMatch[2];
|
|
217
|
+
} else {
|
|
218
|
+
// Root object - use special key based on line number and bracket type
|
|
219
|
+
openBracket = rootMatch[1];
|
|
220
|
+
nodeKey = `__root_${openBracket}_${i}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if closes on same line
|
|
224
|
+
const rest = line.substring(line.indexOf(openBracket) + 1);
|
|
225
|
+
const counts = this._countBrackets(rest, openBracket);
|
|
226
|
+
if (counts.close > counts.open) continue;
|
|
227
|
+
|
|
228
|
+
const endLine = this._findClosingLine(i, openBracket);
|
|
229
|
+
if (endLine === -1 || endLine === i) continue;
|
|
230
|
+
|
|
231
|
+
// Generate unique ID for this node
|
|
232
|
+
const nodeId = this._generateNodeId();
|
|
233
|
+
|
|
234
|
+
this._lineToNodeId.set(i, nodeId);
|
|
235
|
+
this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, isRootFeature: !!rootMatch });
|
|
236
|
+
|
|
237
|
+
// Track occurrence of this nodeKey
|
|
238
|
+
const occurrence = nodeKeyOccurrences.get(nodeKey) || 0;
|
|
239
|
+
nodeKeyOccurrences.set(nodeKey, occurrence + 1);
|
|
240
|
+
|
|
241
|
+
// Check if this nodeKey was previously collapsed
|
|
242
|
+
const keyIndex = collapsedNodeKeys.indexOf(nodeKey);
|
|
243
|
+
if (keyIndex !== -1) {
|
|
244
|
+
// Remove from list so we don't match it again
|
|
245
|
+
collapsedNodeKeys.splice(keyIndex, 1);
|
|
246
|
+
this.collapsedNodes.add(nodeId);
|
|
277
247
|
}
|
|
278
248
|
}
|
|
249
|
+
}
|
|
279
250
|
|
|
280
|
-
|
|
251
|
+
// ========== Observed Attributes ==========
|
|
252
|
+
static get observedAttributes() {
|
|
253
|
+
return ['readonly', 'value', 'placeholder', 'dark-selector'];
|
|
254
|
+
}
|
|
281
255
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
256
|
+
// ========== Lifecycle ==========
|
|
257
|
+
connectedCallback() {
|
|
258
|
+
this.render();
|
|
259
|
+
this.setupEventListeners();
|
|
260
|
+
this.updatePrefixSuffix();
|
|
261
|
+
this.updateThemeCSS();
|
|
262
|
+
|
|
263
|
+
if (this.value) {
|
|
264
|
+
this.setValue(this.value);
|
|
265
|
+
}
|
|
266
|
+
this.updatePlaceholderVisibility();
|
|
267
|
+
}
|
|
286
268
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
269
|
+
disconnectedCallback() {
|
|
270
|
+
if (this.renderTimer) clearTimeout(this.renderTimer);
|
|
271
|
+
if (this.inputTimer) clearTimeout(this.inputTimer);
|
|
272
|
+
|
|
273
|
+
// Cleanup color picker
|
|
274
|
+
const colorPicker = document.querySelector('.geojson-color-picker-input');
|
|
275
|
+
if (colorPicker) {
|
|
276
|
+
if (colorPicker._closeListener) {
|
|
277
|
+
document.removeEventListener('click', colorPicker._closeListener, true);
|
|
291
278
|
}
|
|
279
|
+
colorPicker.remove();
|
|
292
280
|
}
|
|
293
|
-
|
|
294
|
-
if (!hasChanges) return feature;
|
|
295
|
-
|
|
296
|
-
return { ...feature, properties: newProps };
|
|
297
281
|
}
|
|
298
282
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
<style>
|
|
302
|
-
/* Base reset - protect against inherited styles */
|
|
303
|
-
:host *, :host *::before, :host *::after {
|
|
304
|
-
box-sizing: border-box;
|
|
305
|
-
font: normal normal 13px/1.5 'Courier New', Courier, monospace;
|
|
306
|
-
font-variant: normal;
|
|
307
|
-
letter-spacing: 0;
|
|
308
|
-
word-spacing: 0;
|
|
309
|
-
text-transform: none;
|
|
310
|
-
text-decoration: none;
|
|
311
|
-
text-indent: 0;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
:host {
|
|
315
|
-
display: flex;
|
|
316
|
-
flex-direction: column;
|
|
317
|
-
position: relative;
|
|
318
|
-
width: 100%;
|
|
319
|
-
height: 400px;
|
|
320
|
-
border-radius: 4px;
|
|
321
|
-
overflow: hidden;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
:host([readonly]) .editor-wrapper::after {
|
|
325
|
-
content: '';
|
|
326
|
-
position: absolute;
|
|
327
|
-
inset: 0;
|
|
328
|
-
pointer-events: none;
|
|
329
|
-
background: repeating-linear-gradient(-45deg, rgba(128,128,128,0.08), rgba(128,128,128,0.08) 3px, transparent 3px, transparent 12px);
|
|
330
|
-
z-index: 1;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
:host([readonly]) textarea { cursor: text; }
|
|
334
|
-
|
|
335
|
-
.editor-wrapper {
|
|
336
|
-
position: relative;
|
|
337
|
-
width: 100%;
|
|
338
|
-
flex: 1;
|
|
339
|
-
background: var(--bg-color, #fff);
|
|
340
|
-
display: flex;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
.gutter {
|
|
344
|
-
width: 24px;
|
|
345
|
-
height: 100%;
|
|
346
|
-
background: var(--gutter-bg, #f0f0f0);
|
|
347
|
-
border-right: 1px solid var(--gutter-border, #e0e0e0);
|
|
348
|
-
overflow: hidden;
|
|
349
|
-
flex-shrink: 0;
|
|
350
|
-
position: relative;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
.gutter-content {
|
|
354
|
-
position: absolute;
|
|
355
|
-
top: 0;
|
|
356
|
-
left: 0;
|
|
357
|
-
width: 100%;
|
|
358
|
-
padding: 8px 4px;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
.gutter-line {
|
|
362
|
-
position: absolute;
|
|
363
|
-
left: 0;
|
|
364
|
-
width: 100%;
|
|
365
|
-
height: 1.5em;
|
|
366
|
-
display: flex;
|
|
367
|
-
align-items: center;
|
|
368
|
-
justify-content: center;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
.color-indicator, .collapse-button, .boolean-checkbox {
|
|
372
|
-
width: 12px;
|
|
373
|
-
height: 12px;
|
|
374
|
-
border-radius: 2px;
|
|
375
|
-
cursor: pointer;
|
|
376
|
-
transition: transform 0.1s;
|
|
377
|
-
flex-shrink: 0;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
.color-indicator {
|
|
381
|
-
border: 1px solid #555;
|
|
382
|
-
}
|
|
383
|
-
.color-indicator:hover {
|
|
384
|
-
transform: scale(1.2);
|
|
385
|
-
border-color: #fff;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
.boolean-checkbox {
|
|
389
|
-
appearance: none;
|
|
390
|
-
-webkit-appearance: none;
|
|
391
|
-
background: transparent;
|
|
392
|
-
border: 1.5px solid var(--control-border, #c0c0c0);
|
|
393
|
-
border-radius: 2px;
|
|
394
|
-
margin: 0;
|
|
395
|
-
position: relative;
|
|
396
|
-
}
|
|
397
|
-
.boolean-checkbox:checked {
|
|
398
|
-
border-color: var(--control-color, #000080);
|
|
399
|
-
}
|
|
400
|
-
.boolean-checkbox:checked::after {
|
|
401
|
-
content: '✔';
|
|
402
|
-
color: var(--control-color, #000080);
|
|
403
|
-
font-size: 11px;
|
|
404
|
-
font-weight: bold;
|
|
405
|
-
position: absolute;
|
|
406
|
-
top: -3px;
|
|
407
|
-
right: -1px;
|
|
408
|
-
}
|
|
409
|
-
.boolean-checkbox:hover {
|
|
410
|
-
transform: scale(1.2);
|
|
411
|
-
border-color: var(--control-color, #000080);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
.collapse-button {
|
|
415
|
-
background: transparent;
|
|
416
|
-
border: none;
|
|
417
|
-
color: var(--json-punct, #a9b7c6);
|
|
418
|
-
font-size: 10px;
|
|
419
|
-
display: flex;
|
|
420
|
-
align-items: center;
|
|
421
|
-
justify-content: center;
|
|
422
|
-
user-select: none;
|
|
423
|
-
opacity: 0;
|
|
424
|
-
transition: opacity 0.15s;
|
|
425
|
-
}
|
|
426
|
-
.collapse-button.collapsed {
|
|
427
|
-
opacity: 1;
|
|
428
|
-
}
|
|
429
|
-
.gutter:hover .collapse-button {
|
|
430
|
-
opacity: 1;
|
|
431
|
-
}
|
|
432
|
-
.collapse-button:hover {
|
|
433
|
-
transform: scale(1.2);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
.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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
this.
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
//
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
//
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
//
|
|
679
|
-
|
|
680
|
-
this.
|
|
472
|
+
// Cut handling
|
|
473
|
+
hiddenTextarea.addEventListener('cut', (e) => {
|
|
474
|
+
this.handleCut(e);
|
|
681
475
|
});
|
|
682
476
|
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
this.
|
|
477
|
+
// Gutter interactions
|
|
478
|
+
gutterContent.addEventListener('click', (e) => {
|
|
479
|
+
this.handleGutterClick(e);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Prevent gutter from stealing focus
|
|
483
|
+
gutter.addEventListener('mousedown', (e) => {
|
|
484
|
+
e.preventDefault();
|
|
686
485
|
});
|
|
687
486
|
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
//
|
|
498
|
+
// Initial readonly state
|
|
700
499
|
this.updateReadonly();
|
|
701
500
|
}
|
|
702
501
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
.replace(R.lessThan, '<')
|
|
726
|
-
.replace(R.greaterThan, '>');
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
this.
|
|
567
|
+
/**
|
|
568
|
+
* Update view state without rebuilding nodeId mappings
|
|
569
|
+
* Used for collapse/expand operations where content doesn't change
|
|
570
|
+
*/
|
|
571
|
+
updateView() {
|
|
572
|
+
this.computeLineMetadata();
|
|
573
|
+
this.computeVisibleLines();
|
|
743
574
|
}
|
|
744
575
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
618
|
+
braceDepth += counts.open - counts.close;
|
|
1024
619
|
}
|
|
1025
620
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
if (
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
-
//
|
|
1189
|
-
|
|
1190
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
const
|
|
1214
|
-
|
|
1215
|
-
//
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
1445
|
-
const
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
958
|
+
gutterLine.appendChild(collapseCol);
|
|
959
|
+
|
|
960
|
+
fragment.appendChild(gutterLine);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
gutterContent.innerHTML = '';
|
|
964
|
+
gutterContent.appendChild(fragment);
|
|
1489
965
|
}
|
|
1490
966
|
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
this.
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
const
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
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
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
-
|
|
1272
|
+
|
|
1273
|
+
this.formatAndUpdate();
|
|
1727
1274
|
}
|
|
1728
1275
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
-
//
|
|
1751
|
-
|
|
1752
|
-
|
|
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
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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
|
-
|
|
1425
|
+
|
|
1426
|
+
this._lastStartIndex = -1;
|
|
1427
|
+
this._scrollToCursor();
|
|
1428
|
+
this.scheduleRender();
|
|
1782
1429
|
}
|
|
1783
1430
|
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
if (
|
|
1843
|
-
|
|
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
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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
|
-
|
|
1558
|
+
text += this.lines[end.line].substring(0, end.column);
|
|
1559
|
+
|
|
1560
|
+
return text;
|
|
1864
1561
|
}
|
|
1865
1562
|
|
|
1866
1563
|
/**
|
|
1867
|
-
*
|
|
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
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
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
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1590
|
+
/**
|
|
1591
|
+
* Clear the current selection
|
|
1592
|
+
*/
|
|
1593
|
+
_clearSelection() {
|
|
1594
|
+
this.selectionStart = null;
|
|
1595
|
+
this.selectionEnd = null;
|
|
1596
|
+
}
|
|
1887
1597
|
|
|
1888
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
1892
|
-
this.
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
1901
|
-
const
|
|
1902
|
-
|
|
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
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
-
|
|
1940
|
-
|
|
1941
|
-
}
|
|
1942
|
-
|
|
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
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
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
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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,
|
|
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
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
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
|
-
|
|
2054
|
-
|
|
2055
|
-
if (
|
|
2056
|
-
|
|
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
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
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
|
-
|
|
2066
|
-
|
|
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
|
-
//
|
|
2027
|
+
// ========== Theme ==========
|
|
2028
|
+
|
|
2070
2029
|
updateThemeCSS() {
|
|
2071
2030
|
const darkSelector = this.getAttribute('dark-selector') || '.dark';
|
|
2072
|
-
const darkRule = this.
|
|
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
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
-
|
|
2090
|
-
const
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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
|
-
|
|
2107
|
-
|
|
2108
|
-
if (
|
|
2109
|
-
|
|
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
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
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 (
|
|
2111
|
+
return `hash:${geomType}:${hash.toString(36)}`;
|
|
2143
2112
|
}
|
|
2144
2113
|
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
-
*
|
|
2169
|
-
*
|
|
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
|
-
|
|
2173
|
-
const
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
//
|
|
2184
|
-
const
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
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
|
-
//
|
|
2203
|
-
|
|
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
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
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
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
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
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
-
|
|
2233
|
-
if (!
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
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, '&')
|
|
2253
|
+
.replace(/</g, '<')
|
|
2254
|
+
.replace(/>/g, '>');
|
|
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
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
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
|
-
//
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
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
|
-
|
|
2305
|
-
|
|
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 =
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|