@softwarity/geojson-editor 1.0.17 → 1.0.19
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 +34 -5
- package/dist/geojson-editor.js +2 -2
- package/package.json +2 -2
- package/src/constants.ts +74 -0
- package/src/geojson-editor.css +61 -4
- package/src/geojson-editor.d.ts +155 -0
- package/src/geojson-editor.template.ts +5 -0
- package/src/geojson-editor.ts +1371 -856
- package/src/internal-types.ts +101 -0
- package/src/syntax-highlighter.ts +197 -0
- package/src/types.ts +48 -0
- package/src/utils.ts +59 -0
- package/src/validation.ts +90 -0
- package/types/geojson-editor.d.ts +146 -488
- package/types/types.d.ts +44 -0
- package/types/geojson-editor.template.d.ts +0 -4
package/src/geojson-editor.ts
CHANGED
|
@@ -1,177 +1,56 @@
|
|
|
1
1
|
import styles from './geojson-editor.css?inline';
|
|
2
2
|
import { getTemplate } from './geojson-editor.template.js';
|
|
3
|
-
import type { Feature
|
|
4
|
-
|
|
5
|
-
// ==========
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export interface ThemeSettings {
|
|
55
|
-
dark?: ThemeConfig;
|
|
56
|
-
light?: ThemeConfig;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Color metadata for a line */
|
|
60
|
-
interface ColorMeta {
|
|
61
|
-
attributeName: string;
|
|
62
|
-
color: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Boolean metadata for a line */
|
|
66
|
-
interface BooleanMeta {
|
|
67
|
-
attributeName: string;
|
|
68
|
-
value: boolean;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Collapse button metadata */
|
|
72
|
-
interface CollapseButtonMeta {
|
|
73
|
-
nodeKey: string;
|
|
74
|
-
nodeId: string;
|
|
75
|
-
isCollapsed: boolean;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Visibility button metadata */
|
|
79
|
-
interface VisibilityButtonMeta {
|
|
80
|
-
featureKey: string;
|
|
81
|
-
isHidden: boolean;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Line metadata */
|
|
85
|
-
interface LineMeta {
|
|
86
|
-
colors: ColorMeta[];
|
|
87
|
-
booleans: BooleanMeta[];
|
|
88
|
-
collapseButton: CollapseButtonMeta | null;
|
|
89
|
-
visibilityButton: VisibilityButtonMeta | null;
|
|
90
|
-
isHidden: boolean;
|
|
91
|
-
isCollapsed: boolean;
|
|
92
|
-
featureKey: string | null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Visible line data */
|
|
96
|
-
interface VisibleLine {
|
|
97
|
-
index: number;
|
|
98
|
-
content: string;
|
|
99
|
-
meta: LineMeta | undefined;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Feature range in the editor */
|
|
103
|
-
interface FeatureRange {
|
|
104
|
-
startLine: number;
|
|
105
|
-
endLine: number;
|
|
106
|
-
featureIndex: number;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Node range info */
|
|
110
|
-
interface NodeRangeInfo {
|
|
111
|
-
startLine: number;
|
|
112
|
-
endLine: number;
|
|
113
|
-
nodeKey?: string;
|
|
114
|
-
isRootFeature?: boolean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Collapsible range info */
|
|
118
|
-
interface CollapsibleRange extends NodeRangeInfo {
|
|
119
|
-
nodeId: string;
|
|
120
|
-
openBracket: string;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Editor state snapshot for undo/redo */
|
|
124
|
-
interface EditorSnapshot {
|
|
125
|
-
lines: string[];
|
|
126
|
-
cursorLine: number;
|
|
127
|
-
cursorColumn: number;
|
|
128
|
-
timestamp: number;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Bracket count result */
|
|
132
|
-
interface BracketCount {
|
|
133
|
-
open: number;
|
|
134
|
-
close: number;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Context stack item */
|
|
138
|
-
interface ContextStackItem {
|
|
139
|
-
context: string;
|
|
140
|
-
isArray: boolean;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Input types accepted by API methods */
|
|
144
|
-
export type FeatureInput = Feature | Feature[] | FeatureCollection;
|
|
145
|
-
|
|
146
|
-
// Version injected by Vite build from package.json
|
|
147
|
-
const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'dev';
|
|
148
|
-
|
|
149
|
-
// GeoJSON constants
|
|
150
|
-
const GEOJSON_KEYS: string[] = ['type', 'geometry', 'properties', 'coordinates', 'id', 'features'];
|
|
151
|
-
const GEOMETRY_TYPES: GeometryType[] = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon'];
|
|
152
|
-
|
|
153
|
-
// Pre-compiled regex patterns for performance (avoid re-creation on each call)
|
|
154
|
-
const RE_CONTEXT_GEOMETRY = /"geometry"\s*:/;
|
|
155
|
-
const RE_CONTEXT_PROPERTIES = /"properties"\s*:/;
|
|
156
|
-
const RE_CONTEXT_FEATURES = /"features"\s*:/;
|
|
157
|
-
const RE_COLLAPSED_BRACKET = /^(\s*"[^"]+"\s*:\s*)([{\[])/;
|
|
158
|
-
const RE_COLLAPSED_ROOT = /^(\s*)([{\[]),?\s*$/;
|
|
159
|
-
const RE_ESCAPE_AMP = /&/g;
|
|
160
|
-
const RE_ESCAPE_LT = /</g;
|
|
161
|
-
const RE_ESCAPE_GT = />/g;
|
|
162
|
-
const RE_PUNCTUATION = /([{}[\],:])/g;
|
|
163
|
-
const RE_JSON_KEYS = /"([^"]+)"(<span class="json-punctuation">:<\/span>)/g;
|
|
164
|
-
const RE_TYPE_VALUES = /<span class="geojson-key">"type"<\/span><span class="json-punctuation">:<\/span>(\s*)"([^"]*)"/g;
|
|
165
|
-
const RE_STRING_VALUES = /(<span class="json-punctuation">:<\/span>)(\s*)"([^"]*)"/g;
|
|
166
|
-
const RE_COLOR_HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
167
|
-
const RE_NUMBERS_COLON = /(<span class="json-punctuation">:<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
|
|
168
|
-
const RE_NUMBERS_ARRAY = /(<span class="json-punctuation">[\[,]<\/span>)(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi;
|
|
169
|
-
const RE_NUMBERS_START = /^(\s*)(-?\d+\.?\d*(?:e[+-]?\d+)?)/gim;
|
|
170
|
-
const RE_BOOLEANS = /(<span class="json-punctuation">:<\/span>)(\s*)(true|false)/g;
|
|
171
|
-
const RE_NULL = /(<span class="json-punctuation">:<\/span>)(\s*)(null)/g;
|
|
172
|
-
const RE_UNRECOGNIZED = /(<\/span>|^)([^<]+)(<span|$)/g;
|
|
173
|
-
const RE_WHITESPACE_ONLY = /^\s*$/;
|
|
174
|
-
const RE_WHITESPACE_SPLIT = /(\s+)/;
|
|
3
|
+
import type { Feature } from 'geojson';
|
|
4
|
+
|
|
5
|
+
// ========== Imports from extracted modules ==========
|
|
6
|
+
import type {
|
|
7
|
+
SetOptions,
|
|
8
|
+
ThemeSettings
|
|
9
|
+
} from './types.js';
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CursorPosition,
|
|
13
|
+
FeatureInput,
|
|
14
|
+
LineMeta,
|
|
15
|
+
VisibleLine,
|
|
16
|
+
FeatureRange,
|
|
17
|
+
NodeRangeInfo,
|
|
18
|
+
EditorSnapshot,
|
|
19
|
+
CollapsedZoneContext,
|
|
20
|
+
CollapsedNodeInfo
|
|
21
|
+
} from './internal-types.js';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
VERSION,
|
|
25
|
+
RE_CONTEXT_GEOMETRY,
|
|
26
|
+
RE_CONTEXT_PROPERTIES,
|
|
27
|
+
RE_CONTEXT_FEATURES,
|
|
28
|
+
RE_ATTR_VALUE,
|
|
29
|
+
RE_ATTR_VALUE_SINGLE,
|
|
30
|
+
RE_NORMALIZE_COLOR,
|
|
31
|
+
RE_COLOR_HEX,
|
|
32
|
+
RE_IS_FEATURE,
|
|
33
|
+
RE_KV_MATCH,
|
|
34
|
+
RE_ROOT_MATCH,
|
|
35
|
+
RE_BRACKET_POS,
|
|
36
|
+
RE_IS_WORD_CHAR,
|
|
37
|
+
RE_ATTR_AND_BOOL_VALUE,
|
|
38
|
+
RE_TO_KEBAB,
|
|
39
|
+
RE_OPEN_BRACES,
|
|
40
|
+
RE_CLOSE_BRACES,
|
|
41
|
+
RE_OPEN_BRACKETS,
|
|
42
|
+
RE_CLOSE_BRACKET
|
|
43
|
+
} from './constants.js';
|
|
44
|
+
|
|
45
|
+
import { createElement, getFeatureKey, countBrackets, parseSelectorToHostRule } from './utils.js';
|
|
46
|
+
import { validateGeoJSON, normalizeToFeatures } from './validation.js';
|
|
47
|
+
import { highlightSyntax, namedColorToHex, isNamedColor } from './syntax-highlighter.js';
|
|
48
|
+
|
|
49
|
+
// Re-export public types
|
|
50
|
+
export type { SetOptions, ThemeConfig, ThemeSettings } from './types.js';
|
|
51
|
+
|
|
52
|
+
// Alias for minification
|
|
53
|
+
const _ce = createElement;
|
|
175
54
|
|
|
176
55
|
/**
|
|
177
56
|
* GeoJSON Editor Web Component
|
|
@@ -187,6 +66,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
187
66
|
private _nodeIdCounter: number = 0;
|
|
188
67
|
private _lineToNodeId: Map<number, string> = new Map();
|
|
189
68
|
private _nodeIdToLines: Map<string, NodeRangeInfo> = new Map();
|
|
69
|
+
private _openedNodeKeys: Set<string> = new Set(); // UniqueKeys (nodeKey:occurrence) that user opened
|
|
190
70
|
|
|
191
71
|
// ========== Derived State (computed from model) ==========
|
|
192
72
|
visibleLines: VisibleLine[] = [];
|
|
@@ -211,8 +91,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
211
91
|
selectionEnd: CursorPosition | null = null;
|
|
212
92
|
|
|
213
93
|
// ========== Debounce ==========
|
|
214
|
-
private renderTimer:
|
|
215
|
-
private inputTimer:
|
|
94
|
+
private renderTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
95
|
+
private inputTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
216
96
|
|
|
217
97
|
// ========== Theme ==========
|
|
218
98
|
themes: ThemeSettings = { dark: {}, light: {} };
|
|
@@ -229,19 +109,43 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
229
109
|
private _isSelecting: boolean = false;
|
|
230
110
|
private _isComposing: boolean = false;
|
|
231
111
|
private _blockRender: boolean = false;
|
|
112
|
+
private _insertMode: boolean = true; // true = insert, false = overwrite
|
|
232
113
|
private _charWidth: number | null = null;
|
|
233
114
|
private _contextMapCache: Map<number, string> | null = null;
|
|
234
115
|
private _contextMapLinesLength: number = 0;
|
|
235
116
|
private _contextMapFirstLine: string | undefined = undefined;
|
|
236
117
|
private _contextMapLastLine: string | undefined = undefined;
|
|
118
|
+
private _errorLinesCache: Set<number> | null = null;
|
|
119
|
+
|
|
120
|
+
// ========== Cached DOM Elements ==========
|
|
121
|
+
private _viewport: HTMLElement | null = null;
|
|
122
|
+
private _linesContainer: HTMLElement | null = null;
|
|
123
|
+
private _scrollContent: HTMLElement | null = null;
|
|
124
|
+
private _hiddenTextarea: HTMLTextAreaElement | null = null;
|
|
125
|
+
private _gutterContent: HTMLElement | null = null;
|
|
126
|
+
private _gutterScrollContent: HTMLElement | null = null;
|
|
127
|
+
private _gutterScroll: HTMLElement | null = null;
|
|
128
|
+
private _gutter: HTMLElement | null = null;
|
|
129
|
+
private _clearBtn: HTMLButtonElement | null = null;
|
|
130
|
+
private _editorWrapper: HTMLElement | null = null;
|
|
131
|
+
private _placeholderLayer: HTMLElement | null = null;
|
|
132
|
+
private _editorPrefix: HTMLElement | null = null;
|
|
133
|
+
private _editorSuffix: HTMLElement | null = null;
|
|
134
|
+
private _errorNav: HTMLElement | null = null;
|
|
135
|
+
private _errorCount: HTMLElement | null = null;
|
|
136
|
+
private _prevErrorBtn: HTMLButtonElement | null = null;
|
|
137
|
+
private _nextErrorBtn: HTMLButtonElement | null = null;
|
|
237
138
|
|
|
238
139
|
constructor() {
|
|
239
140
|
super();
|
|
240
141
|
this.attachShadow({ mode: 'open' });
|
|
241
142
|
}
|
|
242
143
|
|
|
144
|
+
// Alias for shadowRoot.getElementById (minification)
|
|
145
|
+
private _id(id: string) { return this.shadowRoot!.getElementById(id); }
|
|
146
|
+
|
|
243
147
|
// ========== Render Cache ==========
|
|
244
|
-
_invalidateRenderCache() {
|
|
148
|
+
private _invalidateRenderCache() {
|
|
245
149
|
this._lastStartIndex = -1;
|
|
246
150
|
this._lastEndIndex = -1;
|
|
247
151
|
this._lastTotalLines = -1;
|
|
@@ -253,7 +157,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
253
157
|
* Create a snapshot of current editor state
|
|
254
158
|
* @returns {Object} State snapshot
|
|
255
159
|
*/
|
|
256
|
-
_createSnapshot() {
|
|
160
|
+
private _createSnapshot() {
|
|
257
161
|
return {
|
|
258
162
|
lines: [...this.lines],
|
|
259
163
|
cursorLine: this.cursorLine,
|
|
@@ -264,9 +168,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
264
168
|
|
|
265
169
|
/**
|
|
266
170
|
* Restore editor state from snapshot
|
|
267
|
-
* @param {Object} snapshot - State to restore
|
|
268
171
|
*/
|
|
269
|
-
_restoreSnapshot(snapshot) {
|
|
172
|
+
private _restoreSnapshot(snapshot: EditorSnapshot): void {
|
|
270
173
|
this.lines = [...snapshot.lines];
|
|
271
174
|
this.cursorLine = snapshot.cursorLine;
|
|
272
175
|
this.cursorColumn = snapshot.cursorColumn;
|
|
@@ -281,7 +184,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
281
184
|
* Save current state to undo stack before making changes
|
|
282
185
|
* @param {string} actionType - Type of action (insert, delete, paste, etc.)
|
|
283
186
|
*/
|
|
284
|
-
_saveToHistory(actionType = 'edit') {
|
|
187
|
+
private _saveToHistory(actionType = 'edit') {
|
|
285
188
|
const now = Date.now();
|
|
286
189
|
const shouldGroup = (
|
|
287
190
|
actionType === this._lastActionType &&
|
|
@@ -310,7 +213,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
310
213
|
* Undo last action
|
|
311
214
|
* @returns {boolean} True if undo was performed
|
|
312
215
|
*/
|
|
313
|
-
undo() {
|
|
216
|
+
undo(): boolean {
|
|
314
217
|
if (this._undoStack.length === 0) return false;
|
|
315
218
|
|
|
316
219
|
// Save current state to redo stack
|
|
@@ -318,7 +221,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
318
221
|
|
|
319
222
|
// Restore previous state
|
|
320
223
|
const previousState = this._undoStack.pop();
|
|
321
|
-
this._restoreSnapshot(previousState);
|
|
224
|
+
if (previousState) this._restoreSnapshot(previousState);
|
|
322
225
|
|
|
323
226
|
// Reset action tracking
|
|
324
227
|
this._lastActionType = null;
|
|
@@ -331,7 +234,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
331
234
|
* Redo previously undone action
|
|
332
235
|
* @returns {boolean} True if redo was performed
|
|
333
236
|
*/
|
|
334
|
-
redo() {
|
|
237
|
+
redo(): boolean {
|
|
335
238
|
if (this._redoStack.length === 0) return false;
|
|
336
239
|
|
|
337
240
|
// Save current state to undo stack
|
|
@@ -339,7 +242,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
339
242
|
|
|
340
243
|
// Restore next state
|
|
341
244
|
const nextState = this._redoStack.pop();
|
|
342
|
-
this._restoreSnapshot(nextState);
|
|
245
|
+
if (nextState) this._restoreSnapshot(nextState);
|
|
343
246
|
|
|
344
247
|
// Reset action tracking
|
|
345
248
|
this._lastActionType = null;
|
|
@@ -351,7 +254,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
351
254
|
/**
|
|
352
255
|
* Clear undo/redo history
|
|
353
256
|
*/
|
|
354
|
-
clearHistory() {
|
|
257
|
+
clearHistory(): void {
|
|
355
258
|
this._undoStack = [];
|
|
356
259
|
this._redoStack = [];
|
|
357
260
|
this._lastActionType = null;
|
|
@@ -362,7 +265,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
362
265
|
* Check if undo is available
|
|
363
266
|
* @returns {boolean}
|
|
364
267
|
*/
|
|
365
|
-
canUndo() {
|
|
268
|
+
canUndo(): boolean {
|
|
366
269
|
return this._undoStack.length > 0;
|
|
367
270
|
}
|
|
368
271
|
|
|
@@ -370,21 +273,19 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
370
273
|
* Check if redo is available
|
|
371
274
|
* @returns {boolean}
|
|
372
275
|
*/
|
|
373
|
-
canRedo() {
|
|
276
|
+
canRedo(): boolean {
|
|
374
277
|
return this._redoStack.length > 0;
|
|
375
278
|
}
|
|
376
279
|
|
|
377
280
|
// ========== Unique ID Generation ==========
|
|
378
|
-
_generateNodeId() {
|
|
281
|
+
private _generateNodeId() {
|
|
379
282
|
return `node_${++this._nodeIdCounter}`;
|
|
380
283
|
}
|
|
381
284
|
|
|
382
285
|
/**
|
|
383
286
|
* Check if a line is inside a collapsed node (hidden lines between opening and closing)
|
|
384
|
-
* @param {number} lineIndex - The line index to check
|
|
385
|
-
* @returns {Object|null} - The collapsed range info or null
|
|
386
287
|
*/
|
|
387
|
-
_getCollapsedRangeForLine(lineIndex) {
|
|
288
|
+
private _getCollapsedRangeForLine(lineIndex: number): CollapsedNodeInfo | null {
|
|
388
289
|
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
389
290
|
// Lines strictly between opening and closing are hidden
|
|
390
291
|
if (this.collapsedNodes.has(nodeId) && lineIndex > info.startLine && lineIndex < info.endLine) {
|
|
@@ -396,10 +297,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
396
297
|
|
|
397
298
|
/**
|
|
398
299
|
* Check if cursor is on the closing line of a collapsed node
|
|
399
|
-
* @param {number} lineIndex - The line index to check
|
|
400
|
-
* @returns {Object|null} - The collapsed range info or null
|
|
401
300
|
*/
|
|
402
|
-
_getCollapsedClosingLine(lineIndex) {
|
|
301
|
+
private _getCollapsedClosingLine(lineIndex: number): CollapsedNodeInfo | null {
|
|
403
302
|
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
404
303
|
if (this.collapsedNodes.has(nodeId) && lineIndex === info.endLine) {
|
|
405
304
|
return { nodeId, ...info };
|
|
@@ -410,10 +309,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
410
309
|
|
|
411
310
|
/**
|
|
412
311
|
* Get the position of the closing bracket on a line
|
|
413
|
-
* @param {string} line - The line content
|
|
414
|
-
* @returns {number} - Position of bracket or -1
|
|
415
312
|
*/
|
|
416
|
-
_getClosingBracketPos(line) {
|
|
313
|
+
private _getClosingBracketPos(line: string): number {
|
|
417
314
|
// Find the last ] or } on the line
|
|
418
315
|
const lastBracket = Math.max(line.lastIndexOf(']'), line.lastIndexOf('}'));
|
|
419
316
|
return lastBracket;
|
|
@@ -421,29 +318,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
421
318
|
|
|
422
319
|
/**
|
|
423
320
|
* Check if cursor is on the opening line of a collapsed node
|
|
424
|
-
* @param {number} lineIndex - The line index to check
|
|
425
|
-
* @returns {Object|null} - The collapsed range info or null
|
|
426
321
|
*/
|
|
427
|
-
_getCollapsedNodeAtLine(lineIndex) {
|
|
322
|
+
private _getCollapsedNodeAtLine(lineIndex: number): CollapsedNodeInfo | null {
|
|
428
323
|
const nodeId = this._lineToNodeId.get(lineIndex);
|
|
429
324
|
if (nodeId && this.collapsedNodes.has(nodeId)) {
|
|
430
325
|
const info = this._nodeIdToLines.get(nodeId);
|
|
431
|
-
return { nodeId, ...info };
|
|
432
|
-
}
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Check if cursor is on a line that has a collapsible node (expanded or collapsed)
|
|
438
|
-
* @param {number} lineIndex - The line index to check
|
|
439
|
-
* @returns {Object|null} - The node info with isCollapsed flag or null
|
|
440
|
-
*/
|
|
441
|
-
_getCollapsibleNodeAtLine(lineIndex) {
|
|
442
|
-
const nodeId = this._lineToNodeId.get(lineIndex);
|
|
443
|
-
if (nodeId) {
|
|
444
|
-
const info = this._nodeIdToLines.get(nodeId);
|
|
445
|
-
const isCollapsed = this.collapsedNodes.has(nodeId);
|
|
446
|
-
return { nodeId, isCollapsed, ...info };
|
|
326
|
+
if (info) return { nodeId, ...info };
|
|
447
327
|
}
|
|
448
328
|
return null;
|
|
449
329
|
}
|
|
@@ -451,16 +331,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
451
331
|
/**
|
|
452
332
|
* Find the innermost expanded node that contains the given line
|
|
453
333
|
* Used for Shift+Tab to collapse the parent node from anywhere inside it
|
|
454
|
-
* @param {number} lineIndex - The line index to check
|
|
455
|
-
* @returns {Object|null} - The containing node info or null
|
|
456
334
|
*/
|
|
457
|
-
_getContainingExpandedNode(lineIndex) {
|
|
458
|
-
let bestMatch = null;
|
|
459
|
-
|
|
335
|
+
private _getContainingExpandedNode(lineIndex: number): CollapsedNodeInfo | null {
|
|
336
|
+
let bestMatch: CollapsedNodeInfo | null = null;
|
|
337
|
+
|
|
460
338
|
for (const [nodeId, info] of this._nodeIdToLines) {
|
|
461
339
|
// Skip collapsed nodes
|
|
462
340
|
if (this.collapsedNodes.has(nodeId)) continue;
|
|
463
|
-
|
|
341
|
+
|
|
464
342
|
// Check if line is within this node's range
|
|
465
343
|
if (lineIndex >= info.startLine && lineIndex <= info.endLine) {
|
|
466
344
|
// Prefer the innermost (smallest) containing node
|
|
@@ -469,15 +347,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
469
347
|
}
|
|
470
348
|
}
|
|
471
349
|
}
|
|
472
|
-
|
|
350
|
+
|
|
473
351
|
return bestMatch;
|
|
474
352
|
}
|
|
475
353
|
|
|
476
354
|
/**
|
|
477
355
|
* Delete an entire collapsed node (opening line to closing line)
|
|
478
|
-
* @param {Object} range - The range info {startLine, endLine}
|
|
479
356
|
*/
|
|
480
|
-
_deleteCollapsedNode(range) {
|
|
357
|
+
private _deleteCollapsedNode(range: CollapsedNodeInfo): void {
|
|
481
358
|
this._saveToHistory('delete');
|
|
482
359
|
|
|
483
360
|
// Remove all lines from startLine to endLine
|
|
@@ -495,75 +372,67 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
495
372
|
* Rebuild nodeId mappings after content changes
|
|
496
373
|
* Preserves collapsed state by matching nodeKey + sequential occurrence
|
|
497
374
|
*/
|
|
498
|
-
_rebuildNodeIdMappings() {
|
|
499
|
-
// Save
|
|
500
|
-
const
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
if (info
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Build list of collapsed nodeKeys for matching
|
|
507
|
-
const collapsedNodeKeys = [];
|
|
508
|
-
for (const nodeId of oldCollapsed) {
|
|
509
|
-
const nodeKey = oldNodeKeyMap.get(nodeId);
|
|
510
|
-
if (nodeKey) collapsedNodeKeys.push(nodeKey);
|
|
375
|
+
private _rebuildNodeIdMappings() {
|
|
376
|
+
// Save collapsed uniqueKeys from old state
|
|
377
|
+
const collapsedUniqueKeys = new Set<string>();
|
|
378
|
+
for (const nodeId of this.collapsedNodes) {
|
|
379
|
+
const info = this._nodeIdToLines.get(nodeId);
|
|
380
|
+
if (info?.uniqueKey) collapsedUniqueKeys.add(info.uniqueKey);
|
|
511
381
|
}
|
|
512
|
-
|
|
382
|
+
|
|
513
383
|
// Reset mappings
|
|
514
384
|
this._nodeIdCounter = 0;
|
|
515
385
|
this._lineToNodeId.clear();
|
|
516
386
|
this._nodeIdToLines.clear();
|
|
517
387
|
this.collapsedNodes.clear();
|
|
518
|
-
|
|
519
|
-
// Track occurrences of each nodeKey
|
|
520
|
-
const nodeKeyOccurrences = new Map();
|
|
521
|
-
|
|
388
|
+
|
|
389
|
+
// Track occurrences of each nodeKey
|
|
390
|
+
const nodeKeyOccurrences = new Map<string, number>();
|
|
391
|
+
|
|
522
392
|
// Assign fresh IDs to all collapsible nodes
|
|
523
393
|
for (let i = 0; i < this.lines.length; i++) {
|
|
524
394
|
const line = this.lines[i];
|
|
525
|
-
|
|
395
|
+
|
|
526
396
|
// Match "key": { or "key": [
|
|
527
|
-
const kvMatch = line.match(
|
|
397
|
+
const kvMatch = line.match(RE_KV_MATCH);
|
|
528
398
|
// Also match standalone { or {, (root Feature objects)
|
|
529
|
-
const rootMatch = !kvMatch && line.match(
|
|
530
|
-
|
|
399
|
+
const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
|
|
400
|
+
|
|
531
401
|
if (!kvMatch && !rootMatch) continue;
|
|
532
|
-
|
|
533
|
-
let nodeKey
|
|
534
|
-
|
|
402
|
+
|
|
403
|
+
let nodeKey: string;
|
|
404
|
+
let openBracket: string;
|
|
405
|
+
|
|
535
406
|
if (kvMatch) {
|
|
536
407
|
nodeKey = kvMatch[1];
|
|
537
408
|
openBracket = kvMatch[2];
|
|
538
|
-
} else {
|
|
409
|
+
} else if (rootMatch) {
|
|
539
410
|
// Root object - use special key based on line number and bracket type
|
|
540
411
|
openBracket = rootMatch[1];
|
|
541
412
|
nodeKey = `__root_${openBracket}_${i}`;
|
|
413
|
+
} else {
|
|
414
|
+
continue;
|
|
542
415
|
}
|
|
543
|
-
|
|
416
|
+
|
|
544
417
|
// Check if closes on same line
|
|
545
418
|
const rest = line.substring(line.indexOf(openBracket) + 1);
|
|
546
|
-
const counts =
|
|
419
|
+
const counts = countBrackets(rest, openBracket);
|
|
547
420
|
if (counts.close > counts.open) continue;
|
|
548
|
-
|
|
421
|
+
|
|
549
422
|
const endLine = this._findClosingLine(i, openBracket);
|
|
550
423
|
if (endLine === -1 || endLine === i) continue;
|
|
551
|
-
|
|
552
|
-
// Generate unique ID for this node
|
|
424
|
+
|
|
425
|
+
// Generate unique ID and unique key for this node
|
|
553
426
|
const nodeId = this._generateNodeId();
|
|
554
|
-
|
|
555
|
-
this._lineToNodeId.set(i, nodeId);
|
|
556
|
-
this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, isRootFeature: !!rootMatch });
|
|
557
|
-
|
|
558
|
-
// Track occurrence of this nodeKey
|
|
559
427
|
const occurrence = nodeKeyOccurrences.get(nodeKey) || 0;
|
|
560
428
|
nodeKeyOccurrences.set(nodeKey, occurrence + 1);
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
429
|
+
const uniqueKey = `${nodeKey}:${occurrence}`;
|
|
430
|
+
|
|
431
|
+
this._lineToNodeId.set(i, nodeId);
|
|
432
|
+
this._nodeIdToLines.set(nodeId, { startLine: i, endLine, nodeKey, uniqueKey, isRootFeature: !!rootMatch });
|
|
433
|
+
|
|
434
|
+
// Restore collapsed state if was collapsed and not explicitly opened
|
|
435
|
+
if (collapsedUniqueKeys.has(uniqueKey) && !this._openedNodeKeys.has(uniqueKey)) {
|
|
567
436
|
this.collapsedNodes.add(nodeId);
|
|
568
437
|
}
|
|
569
438
|
}
|
|
@@ -577,10 +446,11 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
577
446
|
// ========== Lifecycle ==========
|
|
578
447
|
connectedCallback() {
|
|
579
448
|
this.render();
|
|
449
|
+
this._cacheElements();
|
|
580
450
|
this.setupEventListeners();
|
|
581
451
|
this.updatePrefixSuffix();
|
|
582
452
|
this.updateThemeCSS();
|
|
583
|
-
|
|
453
|
+
|
|
584
454
|
if (this.value) {
|
|
585
455
|
this.setValue(this.value);
|
|
586
456
|
}
|
|
@@ -601,7 +471,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
601
471
|
}
|
|
602
472
|
}
|
|
603
473
|
|
|
604
|
-
attributeChangedCallback(name, oldValue, newValue) {
|
|
474
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
|
605
475
|
if (oldValue === newValue) return;
|
|
606
476
|
|
|
607
477
|
switch (name) {
|
|
@@ -629,27 +499,52 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
629
499
|
|
|
630
500
|
// ========== Initial Render ==========
|
|
631
501
|
render() {
|
|
632
|
-
const
|
|
502
|
+
const shadowRoot = this.shadowRoot!;
|
|
503
|
+
const styleEl = _ce('style');
|
|
633
504
|
styleEl.textContent = styles;
|
|
634
|
-
|
|
635
|
-
const template =
|
|
505
|
+
|
|
506
|
+
const template = _ce('div');
|
|
636
507
|
template.innerHTML = getTemplate(this.placeholder, VERSION);
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
508
|
+
|
|
509
|
+
shadowRoot.innerHTML = '';
|
|
510
|
+
shadowRoot.appendChild(styleEl);
|
|
640
511
|
while (template.firstChild) {
|
|
641
|
-
|
|
642
|
-
}
|
|
512
|
+
shadowRoot.appendChild(template.firstChild);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ========== DOM Element Cache ==========
|
|
517
|
+
private _cacheElements() {
|
|
518
|
+
this._viewport = this._id('viewport');
|
|
519
|
+
this._linesContainer = this._id('linesContainer');
|
|
520
|
+
this._scrollContent = this._id('scrollContent');
|
|
521
|
+
this._hiddenTextarea = this._id('hiddenTextarea') as HTMLTextAreaElement;
|
|
522
|
+
this._gutterContent = this._id('gutterContent');
|
|
523
|
+
this._gutterScrollContent = this._id('gutterScrollContent');
|
|
524
|
+
this._gutterScroll = this._id('gutterScroll');
|
|
525
|
+
this._gutter = this.shadowRoot!.querySelector('.gutter');
|
|
526
|
+
this._clearBtn = this._id('clearBtn') as HTMLButtonElement;
|
|
527
|
+
this._editorWrapper = this.shadowRoot!.querySelector('.editor-wrapper');
|
|
528
|
+
this._placeholderLayer = this._id('placeholderLayer');
|
|
529
|
+
this._editorPrefix = this._id('editorPrefix');
|
|
530
|
+
this._editorSuffix = this._id('editorSuffix');
|
|
531
|
+
this._errorNav = this._id('errorNav');
|
|
532
|
+
this._errorCount = this._id('errorCount');
|
|
533
|
+
this._prevErrorBtn = this._id('prevErrorBtn') as HTMLButtonElement;
|
|
534
|
+
this._nextErrorBtn = this._id('nextErrorBtn') as HTMLButtonElement;
|
|
643
535
|
}
|
|
644
536
|
|
|
645
537
|
// ========== Event Listeners ==========
|
|
646
538
|
setupEventListeners() {
|
|
647
|
-
const hiddenTextarea = this.
|
|
648
|
-
const viewport = this.
|
|
649
|
-
const gutterContent = this.
|
|
650
|
-
const gutter = this.
|
|
651
|
-
const clearBtn = this.
|
|
652
|
-
const editorWrapper = this.
|
|
539
|
+
const hiddenTextarea = this._hiddenTextarea;
|
|
540
|
+
const viewport = this._viewport;
|
|
541
|
+
const gutterContent = this._gutterContent;
|
|
542
|
+
const gutter = this._gutter;
|
|
543
|
+
const clearBtn = this._clearBtn;
|
|
544
|
+
const editorWrapper = this._editorWrapper;
|
|
545
|
+
|
|
546
|
+
// Guard: all elements must exist
|
|
547
|
+
if (!hiddenTextarea || !viewport || !gutterContent || !gutter || !clearBtn || !editorWrapper) return;
|
|
653
548
|
|
|
654
549
|
// Mouse selection state
|
|
655
550
|
this._isSelecting = false;
|
|
@@ -658,7 +553,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
658
553
|
// Editor inline control clicks (color swatches, checkboxes, visibility icons)
|
|
659
554
|
// Use capture phase to intercept before mousedown
|
|
660
555
|
viewport.addEventListener('click', (e) => {
|
|
661
|
-
this.handleEditorClick(e);
|
|
556
|
+
this.handleEditorClick(e as MouseEvent);
|
|
662
557
|
}, true);
|
|
663
558
|
|
|
664
559
|
viewport.addEventListener('mousedown', (e: MouseEvent) => {
|
|
@@ -687,13 +582,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
687
582
|
return;
|
|
688
583
|
}
|
|
689
584
|
}
|
|
690
|
-
|
|
585
|
+
|
|
691
586
|
// Prevent default to avoid losing focus after click
|
|
692
587
|
e.preventDefault();
|
|
693
|
-
|
|
588
|
+
|
|
694
589
|
// Calculate click position
|
|
695
590
|
const pos = this._getPositionFromClick(e);
|
|
696
|
-
|
|
591
|
+
|
|
697
592
|
if (e.shiftKey && this.selectionStart) {
|
|
698
593
|
// Shift+click: extend selection
|
|
699
594
|
this.selectionEnd = pos;
|
|
@@ -707,7 +602,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
707
602
|
this.selectionEnd = null;
|
|
708
603
|
this._isSelecting = true;
|
|
709
604
|
}
|
|
710
|
-
|
|
605
|
+
|
|
711
606
|
// Focus textarea
|
|
712
607
|
hiddenTextarea.focus();
|
|
713
608
|
this._invalidateRenderCache();
|
|
@@ -715,19 +610,18 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
715
610
|
});
|
|
716
611
|
|
|
717
612
|
// Mouse move for drag selection
|
|
718
|
-
viewport.addEventListener('mousemove', (e) => {
|
|
613
|
+
viewport.addEventListener('mousemove', (e: MouseEvent) => {
|
|
719
614
|
if (!this._isSelecting) return;
|
|
720
|
-
|
|
721
615
|
const pos = this._getPositionFromClick(e);
|
|
722
616
|
this.selectionEnd = pos;
|
|
723
617
|
this.cursorLine = pos.line;
|
|
724
618
|
this.cursorColumn = pos.column;
|
|
725
|
-
|
|
619
|
+
|
|
726
620
|
// Auto-scroll when near edges
|
|
727
621
|
const rect = viewport.getBoundingClientRect();
|
|
728
622
|
const scrollMargin = 30; // pixels from edge to start scrolling
|
|
729
623
|
const scrollSpeed = 20; // pixels to scroll per frame
|
|
730
|
-
|
|
624
|
+
|
|
731
625
|
if (e.clientY < rect.top + scrollMargin) {
|
|
732
626
|
// Near top edge, scroll up
|
|
733
627
|
viewport.scrollTop -= scrollSpeed;
|
|
@@ -735,7 +629,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
735
629
|
// Near bottom edge, scroll down
|
|
736
630
|
viewport.scrollTop += scrollSpeed;
|
|
737
631
|
}
|
|
738
|
-
|
|
632
|
+
|
|
739
633
|
this._invalidateRenderCache();
|
|
740
634
|
this.scheduleRender();
|
|
741
635
|
});
|
|
@@ -763,7 +657,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
763
657
|
viewport.addEventListener('scroll', () => {
|
|
764
658
|
if (isRendering) return;
|
|
765
659
|
this.syncGutterScroll();
|
|
766
|
-
|
|
660
|
+
|
|
767
661
|
// Use requestAnimationFrame to batch scroll updates
|
|
768
662
|
if (!this._scrollRaf) {
|
|
769
663
|
this._scrollRaf = requestAnimationFrame(() => {
|
|
@@ -794,38 +688,38 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
794
688
|
});
|
|
795
689
|
|
|
796
690
|
hiddenTextarea.addEventListener('keydown', (e) => {
|
|
797
|
-
this.handleKeydown(e);
|
|
691
|
+
this.handleKeydown(e as KeyboardEvent);
|
|
798
692
|
});
|
|
799
693
|
|
|
800
694
|
// Paste handling
|
|
801
695
|
hiddenTextarea.addEventListener('paste', (e) => {
|
|
802
|
-
this.handlePaste(e);
|
|
696
|
+
this.handlePaste(e as ClipboardEvent);
|
|
803
697
|
});
|
|
804
698
|
|
|
805
699
|
// Copy handling
|
|
806
700
|
hiddenTextarea.addEventListener('copy', (e) => {
|
|
807
|
-
this.handleCopy(e);
|
|
701
|
+
this.handleCopy(e as ClipboardEvent);
|
|
808
702
|
});
|
|
809
703
|
|
|
810
704
|
// Cut handling
|
|
811
705
|
hiddenTextarea.addEventListener('cut', (e) => {
|
|
812
|
-
this.handleCut(e);
|
|
706
|
+
this.handleCut(e as ClipboardEvent);
|
|
813
707
|
});
|
|
814
708
|
|
|
815
709
|
// Gutter interactions
|
|
816
710
|
gutterContent.addEventListener('click', (e) => {
|
|
817
|
-
this.handleGutterClick(e);
|
|
711
|
+
this.handleGutterClick(e as MouseEvent);
|
|
818
712
|
});
|
|
819
|
-
|
|
713
|
+
|
|
820
714
|
// Prevent gutter from stealing focus
|
|
821
715
|
gutter.addEventListener('mousedown', (e) => {
|
|
822
716
|
e.preventDefault();
|
|
823
717
|
});
|
|
824
718
|
|
|
825
719
|
// Wheel on gutter -> scroll viewport
|
|
826
|
-
gutter.addEventListener('wheel', (e
|
|
720
|
+
gutter.addEventListener('wheel', (e) => {
|
|
827
721
|
e.preventDefault();
|
|
828
|
-
viewport.scrollTop += e.deltaY;
|
|
722
|
+
viewport.scrollTop += (e as WheelEvent).deltaY;
|
|
829
723
|
});
|
|
830
724
|
|
|
831
725
|
// Clear button
|
|
@@ -833,6 +727,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
833
727
|
this.removeAll();
|
|
834
728
|
});
|
|
835
729
|
|
|
730
|
+
// Error navigation buttons
|
|
731
|
+
this._prevErrorBtn?.addEventListener('click', () => {
|
|
732
|
+
this.goToPrevError();
|
|
733
|
+
});
|
|
734
|
+
this._nextErrorBtn?.addEventListener('click', () => {
|
|
735
|
+
this.goToNextError();
|
|
736
|
+
});
|
|
737
|
+
|
|
836
738
|
// Initial readonly state
|
|
837
739
|
this.updateReadonly();
|
|
838
740
|
}
|
|
@@ -842,7 +744,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
842
744
|
/**
|
|
843
745
|
* Set the editor content from a string value
|
|
844
746
|
*/
|
|
845
|
-
setValue(value, autoCollapse = true) {
|
|
747
|
+
setValue(value: string | null, autoCollapse = true): void {
|
|
846
748
|
// Save to history only if there's existing content
|
|
847
749
|
if (this.lines.length > 0) {
|
|
848
750
|
this._saveToHistory('setValue');
|
|
@@ -868,6 +770,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
868
770
|
// Clear state for new content
|
|
869
771
|
this.collapsedNodes.clear();
|
|
870
772
|
this.hiddenFeatures.clear();
|
|
773
|
+
this._openedNodeKeys.clear();
|
|
871
774
|
this._lineToNodeId.clear();
|
|
872
775
|
this._nodeIdToLines.clear();
|
|
873
776
|
this.cursorLine = 0;
|
|
@@ -899,8 +802,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
899
802
|
* Rebuilds line-to-nodeId mapping while preserving collapsed state
|
|
900
803
|
*/
|
|
901
804
|
updateModel() {
|
|
902
|
-
// Invalidate
|
|
805
|
+
// Invalidate caches since content changed
|
|
903
806
|
this._contextMapCache = null;
|
|
807
|
+
this._errorLinesCache = null;
|
|
904
808
|
|
|
905
809
|
// Rebuild lineToNodeId mapping (may shift due to edits)
|
|
906
810
|
this._rebuildNodeIdMappings();
|
|
@@ -941,7 +845,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
941
845
|
for (let i = 0; i < this.lines.length; i++) {
|
|
942
846
|
const line = this.lines[i];
|
|
943
847
|
|
|
944
|
-
if (!inFeature &&
|
|
848
|
+
if (!inFeature && RE_IS_FEATURE.test(line)) {
|
|
945
849
|
// Find opening brace
|
|
946
850
|
let startLine = i;
|
|
947
851
|
for (let j = i; j >= 0; j--) {
|
|
@@ -957,7 +861,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
957
861
|
|
|
958
862
|
// Count braces from start to current line
|
|
959
863
|
for (let k = startLine; k <= i; k++) {
|
|
960
|
-
const counts =
|
|
864
|
+
const counts = countBrackets(this.lines[k], '{');
|
|
961
865
|
if (k === startLine) {
|
|
962
866
|
braceDepth += (counts.open - 1) - counts.close;
|
|
963
867
|
} else {
|
|
@@ -966,10 +870,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
966
870
|
}
|
|
967
871
|
|
|
968
872
|
if (featureIndex < parsed.features.length) {
|
|
969
|
-
currentFeatureKey =
|
|
873
|
+
currentFeatureKey = getFeatureKey(parsed.features[featureIndex]);
|
|
970
874
|
}
|
|
971
875
|
} else if (inFeature) {
|
|
972
|
-
const counts =
|
|
876
|
+
const counts = countBrackets(line, '{');
|
|
973
877
|
braceDepth += counts.open - counts.close;
|
|
974
878
|
|
|
975
879
|
if (braceDepth <= 0) {
|
|
@@ -996,35 +900,45 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
996
900
|
*/
|
|
997
901
|
computeLineMetadata() {
|
|
998
902
|
this.lineMetadata.clear();
|
|
999
|
-
|
|
903
|
+
|
|
1000
904
|
const collapsibleRanges = this._findCollapsibleRanges();
|
|
1001
|
-
|
|
905
|
+
|
|
906
|
+
// Compute error lines once (cached)
|
|
907
|
+
const errorLines = this._computeErrorLines();
|
|
908
|
+
|
|
1002
909
|
for (let i = 0; i < this.lines.length; i++) {
|
|
1003
910
|
const line = this.lines[i];
|
|
1004
|
-
const meta = {
|
|
911
|
+
const meta: LineMeta = {
|
|
1005
912
|
colors: [],
|
|
1006
913
|
booleans: [],
|
|
1007
914
|
collapseButton: null,
|
|
1008
915
|
visibilityButton: null,
|
|
1009
916
|
isHidden: false,
|
|
1010
917
|
isCollapsed: false,
|
|
1011
|
-
featureKey: null
|
|
918
|
+
featureKey: null,
|
|
919
|
+
hasError: errorLines.has(i)
|
|
1012
920
|
};
|
|
1013
|
-
|
|
1014
|
-
// Detect colors
|
|
1015
|
-
|
|
1016
|
-
let
|
|
1017
|
-
while ((
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
921
|
+
|
|
922
|
+
// Detect colors and booleans in a single pass
|
|
923
|
+
RE_ATTR_VALUE.lastIndex = 0;
|
|
924
|
+
let match: RegExpExecArray | null;
|
|
925
|
+
while ((match = RE_ATTR_VALUE.exec(line)) !== null) {
|
|
926
|
+
const [, attributeName, strValue, boolValue] = match;
|
|
927
|
+
if (boolValue) {
|
|
928
|
+
// Boolean value
|
|
929
|
+
meta.booleans.push({ attributeName, value: boolValue === 'true' });
|
|
930
|
+
} else if (strValue) {
|
|
931
|
+
// String value - check if it's a color
|
|
932
|
+
if (RE_COLOR_HEX.test(strValue)) {
|
|
933
|
+
// Hex color (#fff or #ffffff)
|
|
934
|
+
meta.colors.push({ attributeName, color: strValue });
|
|
935
|
+
} else if (isNamedColor(strValue)) {
|
|
936
|
+
// Named CSS color (red, blue, etc.) - validated via browser
|
|
937
|
+
meta.colors.push({ attributeName, color: strValue });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
1026
940
|
}
|
|
1027
|
-
|
|
941
|
+
|
|
1028
942
|
// Check if line starts a collapsible node
|
|
1029
943
|
const collapsible = collapsibleRanges.find(r => r.startLine === i);
|
|
1030
944
|
if (collapsible) {
|
|
@@ -1034,15 +948,15 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1034
948
|
isCollapsed: this.collapsedNodes.has(collapsible.nodeId)
|
|
1035
949
|
};
|
|
1036
950
|
}
|
|
1037
|
-
|
|
951
|
+
|
|
1038
952
|
// Check if line is inside a collapsed node (exclude closing bracket line)
|
|
1039
|
-
const insideCollapsed = collapsibleRanges.find(r =>
|
|
953
|
+
const insideCollapsed = collapsibleRanges.find(r =>
|
|
1040
954
|
this.collapsedNodes.has(r.nodeId) && i > r.startLine && i < r.endLine
|
|
1041
955
|
);
|
|
1042
956
|
if (insideCollapsed) {
|
|
1043
957
|
meta.isCollapsed = true;
|
|
1044
958
|
}
|
|
1045
|
-
|
|
959
|
+
|
|
1046
960
|
// Check if line belongs to a hidden feature
|
|
1047
961
|
for (const [featureKey, range] of this.featureRanges) {
|
|
1048
962
|
if (i >= range.startLine && i <= range.endLine) {
|
|
@@ -1060,11 +974,152 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1060
974
|
break;
|
|
1061
975
|
}
|
|
1062
976
|
}
|
|
1063
|
-
|
|
977
|
+
|
|
1064
978
|
this.lineMetadata.set(i, meta);
|
|
1065
979
|
}
|
|
1066
980
|
}
|
|
1067
981
|
|
|
982
|
+
/**
|
|
983
|
+
* Compute error lines (syntax highlighting + structural errors)
|
|
984
|
+
* Called once per model update, result is used by computeLineMetadata
|
|
985
|
+
*/
|
|
986
|
+
private _computeErrorLines(): Set<number> {
|
|
987
|
+
if (this._errorLinesCache !== null) {
|
|
988
|
+
return this._errorLinesCache;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const errorLines = new Set<number>();
|
|
992
|
+
|
|
993
|
+
// Check syntax highlighting errors for each line
|
|
994
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
995
|
+
const highlighted = highlightSyntax(this.lines[i], '', undefined);
|
|
996
|
+
if (highlighted.includes('json-error')) {
|
|
997
|
+
errorLines.add(i);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Check structural error from JSON.parse
|
|
1002
|
+
try {
|
|
1003
|
+
const content = this.lines.join('\n');
|
|
1004
|
+
const wrapped = '[' + content + ']';
|
|
1005
|
+
JSON.parse(wrapped);
|
|
1006
|
+
} catch (e) {
|
|
1007
|
+
if (e instanceof Error) {
|
|
1008
|
+
// Try to extract line number from error message
|
|
1009
|
+
// Chrome/Node: "... at line X column Y"
|
|
1010
|
+
const lineMatch = e.message.match(/line (\d+)/);
|
|
1011
|
+
if (lineMatch) {
|
|
1012
|
+
// Subtract 1 because we wrapped with '[' on first line
|
|
1013
|
+
const errorLine = Math.max(0, parseInt(lineMatch[1], 10) - 1);
|
|
1014
|
+
errorLines.add(errorLine);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
this._errorLinesCache = errorLines;
|
|
1020
|
+
return errorLines;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Get all lines that have errors (for navigation and counting)
|
|
1025
|
+
* Returns array of line indices sorted by line number
|
|
1026
|
+
*/
|
|
1027
|
+
private _getErrorLines(): number[] {
|
|
1028
|
+
const errorLines: number[] = [];
|
|
1029
|
+
for (const [lineIndex, meta] of this.lineMetadata) {
|
|
1030
|
+
if (meta.hasError) {
|
|
1031
|
+
errorLines.push(lineIndex);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return errorLines.sort((a, b) => a - b);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Navigate to the next error line
|
|
1039
|
+
*/
|
|
1040
|
+
goToNextError(): boolean {
|
|
1041
|
+
const errorLines = this._getErrorLines();
|
|
1042
|
+
if (errorLines.length === 0) return false;
|
|
1043
|
+
|
|
1044
|
+
// Find next error after current cursor position
|
|
1045
|
+
const nextError = errorLines.find(line => line > this.cursorLine);
|
|
1046
|
+
const targetLine = nextError !== undefined ? nextError : errorLines[0]; // Wrap to first
|
|
1047
|
+
|
|
1048
|
+
return this._goToErrorLine(targetLine);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Navigate to the previous error line
|
|
1053
|
+
*/
|
|
1054
|
+
goToPrevError(): boolean {
|
|
1055
|
+
const errorLines = this._getErrorLines();
|
|
1056
|
+
if (errorLines.length === 0) return false;
|
|
1057
|
+
|
|
1058
|
+
// Find previous error before current cursor position
|
|
1059
|
+
const prevErrors = errorLines.filter(line => line < this.cursorLine);
|
|
1060
|
+
const targetLine = prevErrors.length > 0 ? prevErrors[prevErrors.length - 1] : errorLines[errorLines.length - 1]; // Wrap to last
|
|
1061
|
+
|
|
1062
|
+
return this._goToErrorLine(targetLine);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Expand all collapsed nodes containing a specific line
|
|
1067
|
+
* Returns true if any nodes were expanded
|
|
1068
|
+
*/
|
|
1069
|
+
private _expandNodesContainingLine(lineIndex: number): boolean {
|
|
1070
|
+
let expanded = false;
|
|
1071
|
+
for (const [nodeId, nodeInfo] of this._nodeIdToLines) {
|
|
1072
|
+
if (this.collapsedNodes.has(nodeId) && lineIndex > nodeInfo.startLine && lineIndex <= nodeInfo.endLine) {
|
|
1073
|
+
this.collapsedNodes.delete(nodeId);
|
|
1074
|
+
// Track that this node was opened - don't re-collapse during edits
|
|
1075
|
+
if (nodeInfo.uniqueKey) {
|
|
1076
|
+
this._openedNodeKeys.add(nodeInfo.uniqueKey);
|
|
1077
|
+
}
|
|
1078
|
+
expanded = true;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return expanded;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Navigate to a specific error line
|
|
1086
|
+
*/
|
|
1087
|
+
private _goToErrorLine(lineIndex: number): boolean {
|
|
1088
|
+
if (this._expandNodesContainingLine(lineIndex)) {
|
|
1089
|
+
this.updateView();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
this.cursorLine = lineIndex;
|
|
1093
|
+
this.cursorColumn = 0;
|
|
1094
|
+
this._invalidateRenderCache();
|
|
1095
|
+
this._scrollToCursor(true); // Center the error line
|
|
1096
|
+
this.renderViewport();
|
|
1097
|
+
this._updateErrorDisplay();
|
|
1098
|
+
|
|
1099
|
+
// Focus the editor
|
|
1100
|
+
this._hiddenTextarea?.focus();
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Expand all collapsed nodes that contain error lines
|
|
1106
|
+
*/
|
|
1107
|
+
private _expandErrorNodes(): void {
|
|
1108
|
+
const errorLines = this._getErrorLines();
|
|
1109
|
+
if (errorLines.length === 0) return;
|
|
1110
|
+
|
|
1111
|
+
let expanded = false;
|
|
1112
|
+
for (const errorLine of errorLines) {
|
|
1113
|
+
if (this._expandNodesContainingLine(errorLine)) {
|
|
1114
|
+
expanded = true;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (expanded) {
|
|
1119
|
+
this.updateView();
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1068
1123
|
/**
|
|
1069
1124
|
* Compute which lines are visible (not inside collapsed nodes)
|
|
1070
1125
|
*/
|
|
@@ -1084,8 +1139,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1084
1139
|
|
|
1085
1140
|
// Reset render cache to force re-render
|
|
1086
1141
|
this._invalidateRenderCache();
|
|
1087
|
-
this._lastEndIndex = -1;
|
|
1088
|
-
this._lastTotalLines = -1;
|
|
1089
1142
|
}
|
|
1090
1143
|
|
|
1091
1144
|
// ========== Rendering ==========
|
|
@@ -1093,7 +1146,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1093
1146
|
scheduleRender() {
|
|
1094
1147
|
if (this.renderTimer) return;
|
|
1095
1148
|
this.renderTimer = requestAnimationFrame(() => {
|
|
1096
|
-
this.renderTimer =
|
|
1149
|
+
this.renderTimer = undefined;
|
|
1097
1150
|
this.renderViewport();
|
|
1098
1151
|
});
|
|
1099
1152
|
}
|
|
@@ -1103,10 +1156,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1103
1156
|
if (this._blockRender) {
|
|
1104
1157
|
return;
|
|
1105
1158
|
}
|
|
1106
|
-
const viewport = this.
|
|
1107
|
-
const linesContainer = this.
|
|
1108
|
-
const scrollContent = this.
|
|
1109
|
-
const gutterContent = this.shadowRoot.getElementById('gutterContent');
|
|
1159
|
+
const viewport = this._viewport;
|
|
1160
|
+
const linesContainer = this._linesContainer;
|
|
1161
|
+
const scrollContent = this._scrollContent;
|
|
1110
1162
|
|
|
1111
1163
|
if (!viewport || !linesContainer) return;
|
|
1112
1164
|
|
|
@@ -1114,10 +1166,15 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1114
1166
|
|
|
1115
1167
|
const totalLines = this.visibleLines.length;
|
|
1116
1168
|
const totalHeight = totalLines * this.lineHeight;
|
|
1117
|
-
|
|
1118
|
-
// Set total scrollable
|
|
1169
|
+
|
|
1170
|
+
// Set total scrollable dimensions (height and width based on content)
|
|
1119
1171
|
if (scrollContent) {
|
|
1120
1172
|
scrollContent.style.height = `${totalHeight}px`;
|
|
1173
|
+
// Calculate max line width to update horizontal scroll
|
|
1174
|
+
const charWidth = this._getCharWidth();
|
|
1175
|
+
const maxLineLength = this.lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
1176
|
+
const minWidth = maxLineLength * charWidth + 20; // 20px padding
|
|
1177
|
+
scrollContent.style.minWidth = `${minWidth}px`;
|
|
1121
1178
|
}
|
|
1122
1179
|
|
|
1123
1180
|
// Calculate visible range based on scroll position
|
|
@@ -1142,17 +1199,16 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1142
1199
|
|
|
1143
1200
|
// Build context map for syntax highlighting
|
|
1144
1201
|
const contextMap = this._buildContextMap();
|
|
1145
|
-
|
|
1202
|
+
|
|
1146
1203
|
// Check if editor is focused (for cursor display)
|
|
1147
|
-
const
|
|
1148
|
-
const isFocused = editorWrapper?.classList.contains('focused');
|
|
1204
|
+
const isFocused = this._editorWrapper?.classList.contains('focused');
|
|
1149
1205
|
|
|
1150
1206
|
// Render visible lines
|
|
1151
1207
|
const fragment = document.createDocumentFragment();
|
|
1152
1208
|
|
|
1153
1209
|
// Handle empty editor: render an empty line with cursor
|
|
1154
1210
|
if (totalLines === 0) {
|
|
1155
|
-
const lineEl =
|
|
1211
|
+
const lineEl = _ce('div');
|
|
1156
1212
|
lineEl.className = 'line empty-line';
|
|
1157
1213
|
lineEl.dataset.lineIndex = '0';
|
|
1158
1214
|
if (isFocused) {
|
|
@@ -1169,7 +1225,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1169
1225
|
const lineData = this.visibleLines[i];
|
|
1170
1226
|
if (!lineData) continue;
|
|
1171
1227
|
|
|
1172
|
-
const lineEl =
|
|
1228
|
+
const lineEl = _ce('div');
|
|
1173
1229
|
lineEl.className = 'line';
|
|
1174
1230
|
lineEl.dataset.lineIndex = String(lineData.index);
|
|
1175
1231
|
|
|
@@ -1188,8 +1244,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1188
1244
|
}
|
|
1189
1245
|
|
|
1190
1246
|
// Highlight syntax and add cursor if this is the cursor line and editor is focused
|
|
1191
|
-
const context = contextMap.get(lineData.index);
|
|
1192
|
-
let html =
|
|
1247
|
+
const context = contextMap.get(lineData.index) || 'Feature';
|
|
1248
|
+
let html = highlightSyntax(lineData.content, context, lineData.meta);
|
|
1193
1249
|
|
|
1194
1250
|
// Add selection highlight if line is in selection
|
|
1195
1251
|
if (isFocused && this._hasSelection()) {
|
|
@@ -1216,20 +1272,27 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1216
1272
|
/**
|
|
1217
1273
|
* Insert cursor element at the specified column position
|
|
1218
1274
|
* Uses absolute positioning to avoid affecting text layout
|
|
1275
|
+
* In overwrite mode, cursor is a block covering the next character
|
|
1219
1276
|
*/
|
|
1220
|
-
_insertCursor(column) {
|
|
1221
|
-
// Calculate cursor position in pixels using character width
|
|
1277
|
+
private _insertCursor(column: number): string {
|
|
1222
1278
|
const charWidth = this._getCharWidth();
|
|
1223
1279
|
const left = column * charWidth;
|
|
1224
|
-
|
|
1280
|
+
if (this._insertMode) {
|
|
1281
|
+
// Insert mode: thin line cursor
|
|
1282
|
+
return `<span class="cursor" style="left: ${left}px"></span>`;
|
|
1283
|
+
} else {
|
|
1284
|
+
// Overwrite mode: block cursor covering the character
|
|
1285
|
+
return `<span class="cursor cursor-block" style="left: ${left}px; width: ${charWidth}px"></span>`;
|
|
1286
|
+
}
|
|
1225
1287
|
}
|
|
1226
1288
|
|
|
1227
1289
|
/**
|
|
1228
1290
|
* Add selection highlight to a line
|
|
1229
1291
|
*/
|
|
1230
|
-
_addSelectionHighlight(html, lineIndex, content) {
|
|
1231
|
-
const
|
|
1232
|
-
if (!
|
|
1292
|
+
private _addSelectionHighlight(html: string, lineIndex: number, content: string): string {
|
|
1293
|
+
const sel = this._normalizeSelection();
|
|
1294
|
+
if (!sel) return html;
|
|
1295
|
+
const { start, end } = sel;
|
|
1233
1296
|
|
|
1234
1297
|
// Check if this line is in the selection
|
|
1235
1298
|
if (lineIndex < start.line || lineIndex > end.line) return html;
|
|
@@ -1266,20 +1329,25 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1266
1329
|
/**
|
|
1267
1330
|
* Get character width for monospace font
|
|
1268
1331
|
*/
|
|
1269
|
-
_getCharWidth() {
|
|
1332
|
+
private _getCharWidth(): number {
|
|
1270
1333
|
if (!this._charWidth) {
|
|
1271
|
-
const canvas =
|
|
1334
|
+
const canvas = _ce('canvas') as HTMLCanvasElement;
|
|
1272
1335
|
const ctx = canvas.getContext('2d');
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1336
|
+
if (ctx) {
|
|
1337
|
+
// Use exact same font as CSS: 'Courier New', Courier, monospace at 13px
|
|
1338
|
+
ctx.font = "13px 'Courier New', Courier, monospace";
|
|
1339
|
+
this._charWidth = ctx.measureText('M').width;
|
|
1340
|
+
} else {
|
|
1341
|
+
// Fallback to approximate monospace character width
|
|
1342
|
+
this._charWidth = 7.8;
|
|
1343
|
+
}
|
|
1276
1344
|
}
|
|
1277
1345
|
return this._charWidth;
|
|
1278
1346
|
}
|
|
1279
1347
|
|
|
1280
|
-
renderGutter(startIndex, endIndex) {
|
|
1281
|
-
const gutterContent = this.
|
|
1282
|
-
const gutterScrollContent = this.
|
|
1348
|
+
renderGutter(startIndex: number, endIndex: number): void {
|
|
1349
|
+
const gutterContent = this._gutterContent;
|
|
1350
|
+
const gutterScrollContent = this._gutterScrollContent;
|
|
1283
1351
|
if (!gutterContent) return;
|
|
1284
1352
|
|
|
1285
1353
|
// Set total height for gutter scroll
|
|
@@ -1297,23 +1365,28 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1297
1365
|
for (let i = startIndex; i < endIndex; i++) {
|
|
1298
1366
|
const lineData = this.visibleLines[i];
|
|
1299
1367
|
if (!lineData) continue;
|
|
1300
|
-
|
|
1301
|
-
const gutterLine =
|
|
1368
|
+
|
|
1369
|
+
const gutterLine = _ce('div');
|
|
1302
1370
|
gutterLine.className = 'gutter-line';
|
|
1303
|
-
|
|
1371
|
+
|
|
1304
1372
|
const meta = lineData.meta;
|
|
1305
|
-
|
|
1373
|
+
|
|
1374
|
+
// Add error indicator class
|
|
1375
|
+
if (meta?.hasError) {
|
|
1376
|
+
gutterLine.classList.add('has-error');
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1306
1379
|
// Line number first
|
|
1307
|
-
const lineNum =
|
|
1380
|
+
const lineNum = _ce('span');
|
|
1308
1381
|
lineNum.className = 'line-number';
|
|
1309
1382
|
lineNum.textContent = String(lineData.index + 1);
|
|
1310
1383
|
gutterLine.appendChild(lineNum);
|
|
1311
|
-
|
|
1384
|
+
|
|
1312
1385
|
// Collapse column (always present for alignment)
|
|
1313
|
-
const collapseCol =
|
|
1386
|
+
const collapseCol = _ce('div');
|
|
1314
1387
|
collapseCol.className = 'collapse-column';
|
|
1315
1388
|
if (meta?.collapseButton) {
|
|
1316
|
-
const btn =
|
|
1389
|
+
const btn = _ce('div');
|
|
1317
1390
|
btn.className = 'collapse-button' + (meta.collapseButton.isCollapsed ? ' collapsed' : '');
|
|
1318
1391
|
btn.textContent = meta.collapseButton.isCollapsed ? '›' : '⌄';
|
|
1319
1392
|
btn.dataset.line = String(lineData.index);
|
|
@@ -1322,7 +1395,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1322
1395
|
collapseCol.appendChild(btn);
|
|
1323
1396
|
}
|
|
1324
1397
|
gutterLine.appendChild(collapseCol);
|
|
1325
|
-
|
|
1398
|
+
|
|
1326
1399
|
fragment.appendChild(gutterLine);
|
|
1327
1400
|
}
|
|
1328
1401
|
|
|
@@ -1331,28 +1404,31 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1331
1404
|
}
|
|
1332
1405
|
|
|
1333
1406
|
syncGutterScroll() {
|
|
1334
|
-
|
|
1335
|
-
const viewport = this.shadowRoot.getElementById('viewport');
|
|
1336
|
-
if (gutterScroll && viewport) {
|
|
1407
|
+
if (this._gutterScroll && this._viewport) {
|
|
1337
1408
|
// Sync gutter scroll position with viewport
|
|
1338
|
-
|
|
1409
|
+
this._gutterScroll.scrollTop = this._viewport.scrollTop;
|
|
1339
1410
|
}
|
|
1340
1411
|
}
|
|
1341
1412
|
|
|
1342
1413
|
// ========== Input Handling ==========
|
|
1343
1414
|
|
|
1344
1415
|
handleInput(): void {
|
|
1345
|
-
const textarea = this.
|
|
1346
|
-
const inputValue = textarea
|
|
1347
|
-
|
|
1416
|
+
const textarea = this._hiddenTextarea;
|
|
1417
|
+
const inputValue = textarea?.value;
|
|
1418
|
+
|
|
1348
1419
|
if (!inputValue) return;
|
|
1349
|
-
|
|
1420
|
+
|
|
1421
|
+
// Delete selection first if any (replace selection with input)
|
|
1422
|
+
if (this._hasSelection()) {
|
|
1423
|
+
this._deleteSelection();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1350
1426
|
// Block input in hidden collapsed zones
|
|
1351
1427
|
if (this._getCollapsedRangeForLine(this.cursorLine)) {
|
|
1352
1428
|
textarea.value = '';
|
|
1353
1429
|
return;
|
|
1354
1430
|
}
|
|
1355
|
-
|
|
1431
|
+
|
|
1356
1432
|
// On closing line, only allow after bracket
|
|
1357
1433
|
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
1358
1434
|
if (onClosingLine) {
|
|
@@ -1363,31 +1439,40 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1363
1439
|
return;
|
|
1364
1440
|
}
|
|
1365
1441
|
}
|
|
1366
|
-
|
|
1442
|
+
|
|
1367
1443
|
// On collapsed opening line, only allow before bracket
|
|
1368
1444
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1369
1445
|
if (onCollapsed) {
|
|
1370
1446
|
const line = this.lines[this.cursorLine];
|
|
1371
|
-
const bracketPos = line.search(
|
|
1447
|
+
const bracketPos = line.search(RE_BRACKET_POS);
|
|
1372
1448
|
if (this.cursorColumn > bracketPos) {
|
|
1373
1449
|
textarea.value = '';
|
|
1374
1450
|
return;
|
|
1375
1451
|
}
|
|
1376
1452
|
}
|
|
1377
|
-
|
|
1378
|
-
// Insert the input at cursor position
|
|
1453
|
+
|
|
1454
|
+
// Insert or overwrite the input at cursor position
|
|
1379
1455
|
if (this.cursorLine < this.lines.length) {
|
|
1380
1456
|
const line = this.lines[this.cursorLine];
|
|
1381
1457
|
const before = line.substring(0, this.cursorColumn);
|
|
1382
|
-
|
|
1383
|
-
|
|
1458
|
+
|
|
1384
1459
|
// Handle newlines in input
|
|
1385
1460
|
const inputLines = inputValue.split('\n');
|
|
1386
1461
|
if (inputLines.length === 1) {
|
|
1387
|
-
|
|
1462
|
+
// Single line input: insert or overwrite mode
|
|
1463
|
+
if (this._insertMode) {
|
|
1464
|
+
// Insert mode: keep text after cursor
|
|
1465
|
+
const after = line.substring(this.cursorColumn);
|
|
1466
|
+
this.lines[this.cursorLine] = before + inputValue + after;
|
|
1467
|
+
} else {
|
|
1468
|
+
// Overwrite mode: replace characters after cursor
|
|
1469
|
+
const after = line.substring(this.cursorColumn + inputValue.length);
|
|
1470
|
+
this.lines[this.cursorLine] = before + inputValue + after;
|
|
1471
|
+
}
|
|
1388
1472
|
this.cursorColumn += inputValue.length;
|
|
1389
1473
|
} else {
|
|
1390
|
-
// Multi-line input
|
|
1474
|
+
// Multi-line input: always insert mode
|
|
1475
|
+
const after = line.substring(this.cursorColumn);
|
|
1391
1476
|
this.lines[this.cursorLine] = before + inputLines[0];
|
|
1392
1477
|
for (let i = 1; i < inputLines.length - 1; i++) {
|
|
1393
1478
|
this.lines.splice(this.cursorLine + i, 0, inputLines[i]);
|
|
@@ -1415,17 +1500,17 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1415
1500
|
}, 150);
|
|
1416
1501
|
}
|
|
1417
1502
|
|
|
1418
|
-
handleKeydown(e) {
|
|
1503
|
+
handleKeydown(e: KeyboardEvent): void {
|
|
1419
1504
|
// Build context for collapsed zone detection
|
|
1420
|
-
const ctx = {
|
|
1505
|
+
const ctx: CollapsedZoneContext = {
|
|
1421
1506
|
inCollapsedZone: this._getCollapsedRangeForLine(this.cursorLine),
|
|
1422
1507
|
onCollapsedNode: this._getCollapsedNodeAtLine(this.cursorLine),
|
|
1423
1508
|
onClosingLine: this._getCollapsedClosingLine(this.cursorLine)
|
|
1424
1509
|
};
|
|
1425
1510
|
|
|
1426
1511
|
// Lookup table for key handlers
|
|
1427
|
-
const keyHandlers = {
|
|
1428
|
-
'Enter': () => this._handleEnter(ctx),
|
|
1512
|
+
const keyHandlers: Record<string, () => void> = {
|
|
1513
|
+
'Enter': () => this._handleEnter(e.shiftKey, ctx),
|
|
1429
1514
|
'Backspace': () => this._handleBackspace(ctx),
|
|
1430
1515
|
'Delete': () => this._handleDelete(ctx),
|
|
1431
1516
|
'ArrowUp': () => this._handleArrowKey(-1, 0, e.shiftKey, e.ctrlKey || e.metaKey),
|
|
@@ -1434,11 +1519,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1434
1519
|
'ArrowRight': () => this._handleArrowKey(0, 1, e.shiftKey, e.ctrlKey || e.metaKey),
|
|
1435
1520
|
'Home': () => this._handleHomeEnd('home', e.shiftKey, ctx.onClosingLine),
|
|
1436
1521
|
'End': () => this._handleHomeEnd('end', e.shiftKey, ctx.onClosingLine),
|
|
1437
|
-
'
|
|
1522
|
+
'PageUp': () => this._handlePageUpDown('up', e.shiftKey),
|
|
1523
|
+
'PageDown': () => this._handlePageUpDown('down', e.shiftKey),
|
|
1524
|
+
'Tab': () => this._handleTab(e.shiftKey, ctx),
|
|
1525
|
+
'Insert': () => { this._insertMode = !this._insertMode; this.scheduleRender(); }
|
|
1438
1526
|
};
|
|
1439
1527
|
|
|
1440
1528
|
// Modifier key handlers (Ctrl/Cmd)
|
|
1441
|
-
const modifierHandlers = {
|
|
1529
|
+
const modifierHandlers: Record<string, () => void | boolean | Promise<boolean>> = {
|
|
1442
1530
|
'a': () => this._selectAll(),
|
|
1443
1531
|
'z': () => e.shiftKey ? this.redo() : this.undo(),
|
|
1444
1532
|
'y': () => this.redo(),
|
|
@@ -1460,21 +1548,46 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1460
1548
|
}
|
|
1461
1549
|
}
|
|
1462
1550
|
|
|
1463
|
-
_handleEnter(ctx) {
|
|
1464
|
-
//
|
|
1465
|
-
if (
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1551
|
+
private _handleEnter(isShiftKey: boolean, ctx: CollapsedZoneContext): void {
|
|
1552
|
+
// Shift+Enter: collapse the containing expanded node
|
|
1553
|
+
if (isShiftKey) {
|
|
1554
|
+
const containingNode = this._getContainingExpandedNode(this.cursorLine);
|
|
1555
|
+
if (containingNode) {
|
|
1556
|
+
const startLine = this.lines[containingNode.startLine];
|
|
1557
|
+
const bracketPos = startLine.search(RE_BRACKET_POS);
|
|
1558
|
+
this.toggleCollapse(containingNode.nodeId);
|
|
1559
|
+
this.cursorLine = containingNode.startLine;
|
|
1560
|
+
this.cursorColumn = bracketPos >= 0 ? bracketPos + 1 : startLine.length;
|
|
1561
|
+
this._clearSelection();
|
|
1562
|
+
this._scrollToCursor();
|
|
1472
1563
|
}
|
|
1564
|
+
return;
|
|
1473
1565
|
}
|
|
1474
|
-
|
|
1566
|
+
|
|
1567
|
+
// Enter on collapsed node: expand it
|
|
1568
|
+
if (ctx.onCollapsedNode) {
|
|
1569
|
+
this.toggleCollapse(ctx.onCollapsedNode.nodeId);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Enter on closing line of collapsed node: expand it
|
|
1574
|
+
if (ctx.onClosingLine) {
|
|
1575
|
+
const line = this.lines[this.cursorLine];
|
|
1576
|
+
const bracketPos = this._getClosingBracketPos(line);
|
|
1577
|
+
// If cursor is before or on bracket, expand
|
|
1578
|
+
if (bracketPos >= 0 && this.cursorColumn <= bracketPos) {
|
|
1579
|
+
this.toggleCollapse(ctx.onClosingLine.nodeId);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Block in collapsed zones
|
|
1585
|
+
if (ctx.inCollapsedZone) return;
|
|
1586
|
+
|
|
1587
|
+
// Enter anywhere else: do nothing (JSON structure is managed automatically)
|
|
1475
1588
|
}
|
|
1476
1589
|
|
|
1477
|
-
_handleBackspace(ctx) {
|
|
1590
|
+
private _handleBackspace(ctx: CollapsedZoneContext): void {
|
|
1478
1591
|
// Delete selection if any
|
|
1479
1592
|
if (this._hasSelection()) {
|
|
1480
1593
|
this._deleteSelection();
|
|
@@ -1502,7 +1615,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1502
1615
|
// On opening line, allow editing before bracket
|
|
1503
1616
|
if (ctx.onCollapsedNode) {
|
|
1504
1617
|
const line = this.lines[this.cursorLine];
|
|
1505
|
-
const bracketPos = line.search(
|
|
1618
|
+
const bracketPos = line.search(RE_BRACKET_POS);
|
|
1506
1619
|
if (this.cursorColumn > bracketPos + 1) {
|
|
1507
1620
|
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1508
1621
|
return;
|
|
@@ -1511,7 +1624,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1511
1624
|
this.deleteBackward();
|
|
1512
1625
|
}
|
|
1513
1626
|
|
|
1514
|
-
_handleDelete(ctx) {
|
|
1627
|
+
private _handleDelete(ctx: CollapsedZoneContext): void {
|
|
1515
1628
|
// Delete selection if any
|
|
1516
1629
|
if (this._hasSelection()) {
|
|
1517
1630
|
this._deleteSelection();
|
|
@@ -1532,7 +1645,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1532
1645
|
// If on collapsed node opening line
|
|
1533
1646
|
if (ctx.onCollapsedNode) {
|
|
1534
1647
|
const line = this.lines[this.cursorLine];
|
|
1535
|
-
const bracketPos = line.search(
|
|
1648
|
+
const bracketPos = line.search(RE_BRACKET_POS);
|
|
1536
1649
|
if (this.cursorColumn > bracketPos) {
|
|
1537
1650
|
this._deleteCollapsedNode(ctx.onCollapsedNode);
|
|
1538
1651
|
return;
|
|
@@ -1543,29 +1656,328 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1543
1656
|
this.deleteForward();
|
|
1544
1657
|
}
|
|
1545
1658
|
|
|
1546
|
-
_handleTab(isShiftKey,
|
|
1547
|
-
// Shift+Tab:
|
|
1659
|
+
private _handleTab(isShiftKey: boolean, _ctx: CollapsedZoneContext): void {
|
|
1660
|
+
// Tab/Shift+Tab: navigate between attributes (key and value)
|
|
1548
1661
|
if (isShiftKey) {
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1662
|
+
this._navigateToPrevAttribute();
|
|
1663
|
+
} else {
|
|
1664
|
+
this._navigateToNextAttribute();
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Navigate to the next attribute (key or value) in the JSON
|
|
1670
|
+
* Also stops on collapsed node brackets to allow expansion with Enter
|
|
1671
|
+
*/
|
|
1672
|
+
private _navigateToNextAttribute(): void {
|
|
1673
|
+
const totalLines = this.visibleLines.length;
|
|
1674
|
+
let currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
|
|
1675
|
+
if (currentVisibleIdx < 0) currentVisibleIdx = 0;
|
|
1676
|
+
|
|
1677
|
+
// Search from current position forward
|
|
1678
|
+
for (let i = currentVisibleIdx; i < totalLines; i++) {
|
|
1679
|
+
const vl = this.visibleLines[i];
|
|
1680
|
+
const line = this.lines[vl.index];
|
|
1681
|
+
const startCol = (i === currentVisibleIdx) ? this.cursorColumn : 0;
|
|
1682
|
+
|
|
1683
|
+
const pos = this._findNextAttributeOrBracket(line, startCol, vl.index);
|
|
1684
|
+
if (pos !== null) {
|
|
1685
|
+
this.cursorLine = vl.index;
|
|
1686
|
+
this.cursorColumn = pos.start;
|
|
1687
|
+
// Select the attribute key or value (not brackets)
|
|
1688
|
+
if (!pos.isBracket) {
|
|
1689
|
+
this.selectionStart = { line: vl.index, column: pos.start };
|
|
1690
|
+
this.selectionEnd = { line: vl.index, column: pos.end };
|
|
1691
|
+
} else {
|
|
1692
|
+
this._clearSelection();
|
|
1693
|
+
}
|
|
1557
1694
|
this._scrollToCursor();
|
|
1695
|
+
this._invalidateRenderCache();
|
|
1696
|
+
this.scheduleRender();
|
|
1697
|
+
return;
|
|
1558
1698
|
}
|
|
1559
|
-
return;
|
|
1560
1699
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1700
|
+
|
|
1701
|
+
// Wrap to beginning
|
|
1702
|
+
for (let i = 0; i < currentVisibleIdx; i++) {
|
|
1703
|
+
const vl = this.visibleLines[i];
|
|
1704
|
+
const line = this.lines[vl.index];
|
|
1705
|
+
const pos = this._findNextAttributeOrBracket(line, 0, vl.index);
|
|
1706
|
+
if (pos !== null) {
|
|
1707
|
+
this.cursorLine = vl.index;
|
|
1708
|
+
this.cursorColumn = pos.start;
|
|
1709
|
+
if (!pos.isBracket) {
|
|
1710
|
+
this.selectionStart = { line: vl.index, column: pos.start };
|
|
1711
|
+
this.selectionEnd = { line: vl.index, column: pos.end };
|
|
1712
|
+
} else {
|
|
1713
|
+
this._clearSelection();
|
|
1714
|
+
}
|
|
1715
|
+
this._scrollToCursor();
|
|
1716
|
+
this._invalidateRenderCache();
|
|
1717
|
+
this.scheduleRender();
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1565
1720
|
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Navigate to the previous attribute (key or value) in the JSON
|
|
1725
|
+
* Also stops on collapsed node brackets to allow expansion with Enter
|
|
1726
|
+
*/
|
|
1727
|
+
private _navigateToPrevAttribute(): void {
|
|
1728
|
+
const totalLines = this.visibleLines.length;
|
|
1729
|
+
let currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
|
|
1730
|
+
if (currentVisibleIdx < 0) currentVisibleIdx = totalLines - 1;
|
|
1731
|
+
|
|
1732
|
+
// Search from current position backward
|
|
1733
|
+
for (let i = currentVisibleIdx; i >= 0; i--) {
|
|
1734
|
+
const vl = this.visibleLines[i];
|
|
1735
|
+
const line = this.lines[vl.index];
|
|
1736
|
+
const endCol = (i === currentVisibleIdx) ? this.cursorColumn : line.length;
|
|
1737
|
+
|
|
1738
|
+
const pos = this._findPrevAttributeOrBracket(line, endCol, vl.index);
|
|
1739
|
+
if (pos !== null) {
|
|
1740
|
+
this.cursorLine = vl.index;
|
|
1741
|
+
this.cursorColumn = pos.start;
|
|
1742
|
+
if (!pos.isBracket) {
|
|
1743
|
+
this.selectionStart = { line: vl.index, column: pos.start };
|
|
1744
|
+
this.selectionEnd = { line: vl.index, column: pos.end };
|
|
1745
|
+
} else {
|
|
1746
|
+
this._clearSelection();
|
|
1747
|
+
}
|
|
1748
|
+
this._scrollToCursor();
|
|
1749
|
+
this._invalidateRenderCache();
|
|
1750
|
+
this.scheduleRender();
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Wrap to end
|
|
1756
|
+
for (let i = totalLines - 1; i > currentVisibleIdx; i--) {
|
|
1757
|
+
const vl = this.visibleLines[i];
|
|
1758
|
+
const line = this.lines[vl.index];
|
|
1759
|
+
const pos = this._findPrevAttributeOrBracket(line, line.length, vl.index);
|
|
1760
|
+
if (pos !== null) {
|
|
1761
|
+
this.cursorLine = vl.index;
|
|
1762
|
+
this.cursorColumn = pos.start;
|
|
1763
|
+
if (!pos.isBracket) {
|
|
1764
|
+
this.selectionStart = { line: vl.index, column: pos.start };
|
|
1765
|
+
this.selectionEnd = { line: vl.index, column: pos.end };
|
|
1766
|
+
} else {
|
|
1767
|
+
this._clearSelection();
|
|
1768
|
+
}
|
|
1769
|
+
this._scrollToCursor();
|
|
1770
|
+
this._invalidateRenderCache();
|
|
1771
|
+
this.scheduleRender();
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Find next attribute position in a line after startCol
|
|
1779
|
+
* Returns {start, end} for the key or value, or null if none found
|
|
1780
|
+
* Also finds standalone values (numbers in arrays, etc.)
|
|
1781
|
+
*/
|
|
1782
|
+
private _findNextAttributeInLine(line: string, startCol: number): { start: number; end: number } | null {
|
|
1783
|
+
// Collect all navigable positions
|
|
1784
|
+
const positions: { start: number; end: number }[] = [];
|
|
1785
|
+
|
|
1786
|
+
// Pattern for "key": value pairs
|
|
1787
|
+
const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
|
|
1788
|
+
let match;
|
|
1789
|
+
|
|
1790
|
+
while ((match = keyValueRe.exec(line)) !== null) {
|
|
1791
|
+
const keyStart = match.index + 1; // Skip opening quote
|
|
1792
|
+
const keyEnd = keyStart + match[1].length;
|
|
1793
|
+
positions.push({ start: keyStart, end: keyEnd });
|
|
1794
|
+
|
|
1795
|
+
// Check if there's a value (string, number, boolean, null)
|
|
1796
|
+
if (match[2] !== undefined) {
|
|
1797
|
+
// String value
|
|
1798
|
+
const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
|
|
1799
|
+
if (valueMatch) {
|
|
1800
|
+
const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
|
|
1801
|
+
const valueEnd = valueStart + match[2].length;
|
|
1802
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1803
|
+
}
|
|
1804
|
+
} else if (match[3] !== undefined) {
|
|
1805
|
+
// Number value after colon
|
|
1806
|
+
const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
|
|
1807
|
+
if (numMatch) {
|
|
1808
|
+
const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
|
|
1809
|
+
const valueEnd = valueStart + numMatch[1].length;
|
|
1810
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1811
|
+
}
|
|
1812
|
+
} else {
|
|
1813
|
+
// Boolean or null
|
|
1814
|
+
const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
|
|
1815
|
+
if (boolMatch) {
|
|
1816
|
+
const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
|
|
1817
|
+
const valueEnd = valueStart + boolMatch[1].length;
|
|
1818
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// Also find standalone numbers (not after a colon) - for array elements
|
|
1824
|
+
const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
|
|
1825
|
+
while ((match = standaloneNumRe.exec(line)) !== null) {
|
|
1826
|
+
const numStr = match[1];
|
|
1827
|
+
const numStart = match.index + match[0].indexOf(numStr);
|
|
1828
|
+
const numEnd = numStart + numStr.length;
|
|
1829
|
+
// Avoid duplicates (numbers already captured by key-value pattern)
|
|
1830
|
+
if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
|
|
1831
|
+
positions.push({ start: numStart, end: numEnd });
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Sort by start position and find first after startCol
|
|
1836
|
+
positions.sort((a, b) => a.start - b.start);
|
|
1837
|
+
for (const pos of positions) {
|
|
1838
|
+
if (pos.start > startCol) {
|
|
1839
|
+
return pos;
|
|
1840
|
+
}
|
|
1568
1841
|
}
|
|
1842
|
+
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Find previous attribute position in a line before endCol
|
|
1848
|
+
* Also finds standalone values (numbers in arrays, etc.)
|
|
1849
|
+
*/
|
|
1850
|
+
private _findPrevAttributeInLine(line: string, endCol: number): { start: number; end: number } | null {
|
|
1851
|
+
// Collect all navigable positions
|
|
1852
|
+
const positions: { start: number; end: number }[] = [];
|
|
1853
|
+
|
|
1854
|
+
// Pattern for "key": value pairs
|
|
1855
|
+
const keyValueRe = /"([^"]+)"(?:\s*:\s*(?:"([^"]*)"|(-?\d+\.?\d*(?:e[+-]?\d+)?)|true|false|null))?/gi;
|
|
1856
|
+
let match;
|
|
1857
|
+
|
|
1858
|
+
while ((match = keyValueRe.exec(line)) !== null) {
|
|
1859
|
+
const keyStart = match.index + 1;
|
|
1860
|
+
const keyEnd = keyStart + match[1].length;
|
|
1861
|
+
positions.push({ start: keyStart, end: keyEnd });
|
|
1862
|
+
|
|
1863
|
+
// Check for value
|
|
1864
|
+
if (match[2] !== undefined) {
|
|
1865
|
+
const valueMatch = line.substring(match.index).match(/:\s*"([^"]*)"/);
|
|
1866
|
+
if (valueMatch) {
|
|
1867
|
+
const valueStart = match.index + (valueMatch.index || 0) + valueMatch[0].indexOf('"') + 1;
|
|
1868
|
+
const valueEnd = valueStart + match[2].length;
|
|
1869
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1870
|
+
}
|
|
1871
|
+
} else if (match[3] !== undefined) {
|
|
1872
|
+
const numMatch = line.substring(match.index).match(/:\s*(-?\d+\.?\d*(?:e[+-]?\d+)?)/i);
|
|
1873
|
+
if (numMatch) {
|
|
1874
|
+
const valueStart = match.index + (numMatch.index || 0) + numMatch[0].indexOf(numMatch[1]);
|
|
1875
|
+
const valueEnd = valueStart + numMatch[1].length;
|
|
1876
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1877
|
+
}
|
|
1878
|
+
} else {
|
|
1879
|
+
const boolMatch = line.substring(match.index).match(/:\s*(true|false|null)/);
|
|
1880
|
+
if (boolMatch) {
|
|
1881
|
+
const valueStart = match.index + (boolMatch.index || 0) + boolMatch[0].indexOf(boolMatch[1]);
|
|
1882
|
+
const valueEnd = valueStart + boolMatch[1].length;
|
|
1883
|
+
positions.push({ start: valueStart, end: valueEnd });
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Also find standalone numbers (not after a colon) - for array elements
|
|
1889
|
+
const standaloneNumRe = /(?:^|[\[,\s])(-?\d+\.?\d*(?:e[+-]?\d+)?)\s*(?:[,\]]|$)/gi;
|
|
1890
|
+
while ((match = standaloneNumRe.exec(line)) !== null) {
|
|
1891
|
+
const numStr = match[1];
|
|
1892
|
+
const numStart = match.index + match[0].indexOf(numStr);
|
|
1893
|
+
const numEnd = numStart + numStr.length;
|
|
1894
|
+
// Avoid duplicates
|
|
1895
|
+
if (!positions.some(p => p.start === numStart && p.end === numEnd)) {
|
|
1896
|
+
positions.push({ start: numStart, end: numEnd });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Sort by start position and find last that ends before endCol
|
|
1901
|
+
positions.sort((a, b) => a.start - b.start);
|
|
1902
|
+
for (let i = positions.length - 1; i >= 0; i--) {
|
|
1903
|
+
if (positions[i].end < endCol) {
|
|
1904
|
+
return positions[i];
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Find bracket position in a line (opening bracket for collapsible nodes)
|
|
1913
|
+
* Looks for { or [ at end of line (for both expanded and collapsed nodes)
|
|
1914
|
+
* Returns position AFTER the bracket, or null if not found
|
|
1915
|
+
*/
|
|
1916
|
+
private _findBracketInLine(line: string): number | null {
|
|
1917
|
+
// Look for { or [ at end of line (indicates a collapsible node)
|
|
1918
|
+
// Works for both expanded and collapsed nodes - collapsed nodes still have
|
|
1919
|
+
// the bracket in raw text, the "..." is only added visually via CSS
|
|
1920
|
+
const bracketMatch = line.match(/[\[{]\s*$/);
|
|
1921
|
+
if (bracketMatch && bracketMatch.index !== undefined) {
|
|
1922
|
+
return bracketMatch.index + 1; // Position after bracket
|
|
1923
|
+
}
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Find next attribute or bracket position in a line
|
|
1929
|
+
* Returns position with isBracket flag to indicate if it's a bracket
|
|
1930
|
+
* For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
|
|
1931
|
+
* Stops on ALL opening brackets to allow collapse/expand navigation
|
|
1932
|
+
*/
|
|
1933
|
+
private _findNextAttributeOrBracket(line: string, startCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
|
|
1934
|
+
// First check for regular attributes
|
|
1935
|
+
const attrPos = this._findNextAttributeInLine(line, startCol);
|
|
1936
|
+
|
|
1937
|
+
// Find opening bracket position (collapsed or expanded)
|
|
1938
|
+
const bracketPos = this._findBracketInLine(line);
|
|
1939
|
+
|
|
1940
|
+
// Return whichever comes first after startCol
|
|
1941
|
+
if (attrPos !== null && bracketPos !== null) {
|
|
1942
|
+
if (bracketPos > startCol && (bracketPos < attrPos.start)) {
|
|
1943
|
+
return { start: bracketPos, end: bracketPos, isBracket: true };
|
|
1944
|
+
}
|
|
1945
|
+
return { ...attrPos, isBracket: false };
|
|
1946
|
+
} else if (attrPos !== null) {
|
|
1947
|
+
return { ...attrPos, isBracket: false };
|
|
1948
|
+
} else if (bracketPos !== null && bracketPos > startCol) {
|
|
1949
|
+
return { start: bracketPos, end: bracketPos, isBracket: true };
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
return null;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Find previous attribute or bracket position in a line
|
|
1957
|
+
* Returns position with isBracket flag to indicate if it's a bracket
|
|
1958
|
+
* For brackets, cursor is placed AFTER the bracket (where Enter/Shift+Enter works)
|
|
1959
|
+
* Stops on ALL opening brackets to allow collapse/expand navigation
|
|
1960
|
+
*/
|
|
1961
|
+
private _findPrevAttributeOrBracket(line: string, endCol: number, _lineIndex: number): { start: number; end: number; isBracket: boolean } | null {
|
|
1962
|
+
// First check for regular attributes
|
|
1963
|
+
const attrPos = this._findPrevAttributeInLine(line, endCol);
|
|
1964
|
+
|
|
1965
|
+
// Find opening bracket position (collapsed or expanded)
|
|
1966
|
+
const bracketPos = this._findBracketInLine(line);
|
|
1967
|
+
|
|
1968
|
+
// Return whichever comes last STRICTLY BEFORE endCol (to avoid staying in place)
|
|
1969
|
+
if (attrPos !== null && bracketPos !== null) {
|
|
1970
|
+
if (bracketPos < endCol && bracketPos > attrPos.end) {
|
|
1971
|
+
return { start: bracketPos, end: bracketPos, isBracket: true };
|
|
1972
|
+
}
|
|
1973
|
+
return { ...attrPos, isBracket: false };
|
|
1974
|
+
} else if (attrPos !== null) {
|
|
1975
|
+
return { ...attrPos, isBracket: false };
|
|
1976
|
+
} else if (bracketPos !== null && bracketPos < endCol) {
|
|
1977
|
+
return { start: bracketPos, end: bracketPos, isBracket: true };
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
return null;
|
|
1569
1981
|
}
|
|
1570
1982
|
|
|
1571
1983
|
insertNewline() {
|
|
@@ -1629,7 +2041,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1629
2041
|
/**
|
|
1630
2042
|
* Move cursor vertically, skipping hidden collapsed lines only
|
|
1631
2043
|
*/
|
|
1632
|
-
moveCursorSkipCollapsed(deltaLine) {
|
|
2044
|
+
moveCursorSkipCollapsed(deltaLine: number): void {
|
|
1633
2045
|
let targetLine = this.cursorLine + deltaLine;
|
|
1634
2046
|
|
|
1635
2047
|
// Skip over hidden collapsed zones only (not opening/closing lines)
|
|
@@ -1642,8 +2054,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1642
2054
|
} else {
|
|
1643
2055
|
targetLine = collapsed.startLine; // Jump to opening line
|
|
1644
2056
|
}
|
|
2057
|
+
} else {
|
|
2058
|
+
break; // Not in a collapsed zone, stop
|
|
1645
2059
|
}
|
|
1646
|
-
break;
|
|
1647
2060
|
}
|
|
1648
2061
|
|
|
1649
2062
|
this.cursorLine = Math.max(0, Math.min(this.lines.length - 1, targetLine));
|
|
@@ -1660,7 +2073,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1660
2073
|
/**
|
|
1661
2074
|
* Move cursor horizontally with smart navigation around collapsed nodes
|
|
1662
2075
|
*/
|
|
1663
|
-
moveCursorHorizontal(delta) {
|
|
2076
|
+
moveCursorHorizontal(delta: number): void {
|
|
1664
2077
|
if (delta > 0) {
|
|
1665
2078
|
this._moveCursorRight();
|
|
1666
2079
|
} else {
|
|
@@ -1671,7 +2084,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1671
2084
|
this.scheduleRender();
|
|
1672
2085
|
}
|
|
1673
2086
|
|
|
1674
|
-
_moveCursorRight() {
|
|
2087
|
+
private _moveCursorRight() {
|
|
1675
2088
|
const line = this.lines[this.cursorLine];
|
|
1676
2089
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1677
2090
|
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
@@ -1689,7 +2102,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1689
2102
|
this.cursorColumn++;
|
|
1690
2103
|
}
|
|
1691
2104
|
} else if (onCollapsed) {
|
|
1692
|
-
const bracketPos = line.search(
|
|
2105
|
+
const bracketPos = line.search(RE_BRACKET_POS);
|
|
1693
2106
|
if (this.cursorColumn < bracketPos) {
|
|
1694
2107
|
this.cursorColumn++;
|
|
1695
2108
|
} else if (this.cursorColumn === bracketPos) {
|
|
@@ -1713,7 +2126,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1713
2126
|
}
|
|
1714
2127
|
}
|
|
1715
2128
|
|
|
1716
|
-
_moveCursorLeft() {
|
|
2129
|
+
private _moveCursorLeft() {
|
|
1717
2130
|
const line = this.lines[this.cursorLine];
|
|
1718
2131
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
1719
2132
|
const onClosingLine = this._getCollapsedClosingLine(this.cursorLine);
|
|
@@ -1726,10 +2139,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1726
2139
|
// Jump to opening line after bracket
|
|
1727
2140
|
this.cursorLine = onClosingLine.startLine;
|
|
1728
2141
|
const openLine = this.lines[this.cursorLine];
|
|
1729
|
-
this.cursorColumn = openLine.search(
|
|
2142
|
+
this.cursorColumn = openLine.search(RE_BRACKET_POS) + 1;
|
|
1730
2143
|
}
|
|
1731
2144
|
} else if (onCollapsed) {
|
|
1732
|
-
const bracketPos = line.search(
|
|
2145
|
+
const bracketPos = line.search(RE_BRACKET_POS);
|
|
1733
2146
|
if (this.cursorColumn > bracketPos + 1) {
|
|
1734
2147
|
this.cursorColumn = bracketPos + 1;
|
|
1735
2148
|
} else if (this.cursorColumn === bracketPos + 1) {
|
|
@@ -1752,7 +2165,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1752
2165
|
if (collapsed) {
|
|
1753
2166
|
this.cursorLine = collapsed.startLine;
|
|
1754
2167
|
const openLine = this.lines[this.cursorLine];
|
|
1755
|
-
this.cursorColumn = openLine.search(
|
|
2168
|
+
this.cursorColumn = openLine.search(RE_BRACKET_POS) + 1;
|
|
1756
2169
|
} else {
|
|
1757
2170
|
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
1758
2171
|
}
|
|
@@ -1762,33 +2175,41 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1762
2175
|
|
|
1763
2176
|
/**
|
|
1764
2177
|
* Scroll viewport to ensure cursor is visible
|
|
2178
|
+
* @param center - if true, center the cursor line in the viewport
|
|
1765
2179
|
*/
|
|
1766
|
-
_scrollToCursor() {
|
|
1767
|
-
const viewport = this.
|
|
2180
|
+
private _scrollToCursor(center = false) {
|
|
2181
|
+
const viewport = this._viewport;
|
|
1768
2182
|
if (!viewport) return;
|
|
1769
|
-
|
|
2183
|
+
|
|
1770
2184
|
// Find the visible line index for the cursor
|
|
1771
2185
|
const visibleIndex = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
|
|
1772
2186
|
if (visibleIndex === -1) return;
|
|
1773
|
-
|
|
2187
|
+
|
|
1774
2188
|
const cursorY = visibleIndex * this.lineHeight;
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
2189
|
+
const viewportHeight = viewport.clientHeight;
|
|
2190
|
+
|
|
2191
|
+
if (center) {
|
|
2192
|
+
// Center the cursor line in the viewport
|
|
2193
|
+
viewport.scrollTop = Math.max(0, cursorY - viewportHeight / 2 + this.lineHeight / 2);
|
|
2194
|
+
} else {
|
|
2195
|
+
const viewportTop = viewport.scrollTop;
|
|
2196
|
+
const viewportBottom = viewportTop + viewportHeight;
|
|
2197
|
+
|
|
2198
|
+
// Scroll up if cursor is above viewport
|
|
2199
|
+
if (cursorY < viewportTop) {
|
|
2200
|
+
viewport.scrollTop = cursorY;
|
|
2201
|
+
}
|
|
2202
|
+
// Scroll down if cursor is below viewport
|
|
2203
|
+
else if (cursorY + this.lineHeight > viewportBottom) {
|
|
2204
|
+
viewport.scrollTop = cursorY + this.lineHeight - viewportHeight;
|
|
2205
|
+
}
|
|
1785
2206
|
}
|
|
1786
2207
|
}
|
|
1787
2208
|
|
|
1788
2209
|
/**
|
|
1789
2210
|
* Handle arrow key with optional selection and word jump
|
|
1790
2211
|
*/
|
|
1791
|
-
_handleArrowKey(deltaLine, deltaCol, isShift, isCtrl = false) {
|
|
2212
|
+
private _handleArrowKey(deltaLine: number, deltaCol: number, isShift: boolean, isCtrl = false): void {
|
|
1792
2213
|
// Start selection if shift is pressed and no selection exists
|
|
1793
2214
|
if (isShift && !this.selectionStart) {
|
|
1794
2215
|
this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
|
|
@@ -1822,10 +2243,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1822
2243
|
* - Ctrl+Right: move to end of current word, or start of next word
|
|
1823
2244
|
* - Ctrl+Left: move to start of current word, or start of previous word
|
|
1824
2245
|
*/
|
|
1825
|
-
_moveCursorByWord(direction) {
|
|
2246
|
+
private _moveCursorByWord(direction: number): void {
|
|
1826
2247
|
const line = this.lines[this.cursorLine] || '';
|
|
1827
2248
|
// Word character: alphanumeric, underscore, or hyphen (for kebab-case identifiers)
|
|
1828
|
-
const isWordChar = (ch) =>
|
|
2249
|
+
const isWordChar = (ch: string) => RE_IS_WORD_CHAR.test(ch);
|
|
1829
2250
|
|
|
1830
2251
|
// Check if we're on a collapsed node's opening line
|
|
1831
2252
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
@@ -1836,7 +2257,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1836
2257
|
|
|
1837
2258
|
// If on collapsed node opening line and cursor is at/after the bracket, jump to closing line
|
|
1838
2259
|
if (onCollapsed) {
|
|
1839
|
-
const bracketPos = line.search(
|
|
2260
|
+
const bracketPos = line.search(RE_BRACKET_POS);
|
|
1840
2261
|
if (bracketPos >= 0 && pos >= bracketPos) {
|
|
1841
2262
|
this.cursorLine = onCollapsed.endLine;
|
|
1842
2263
|
this.cursorColumn = (this.lines[this.cursorLine] || '').length;
|
|
@@ -1884,7 +2305,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1884
2305
|
// Jump to opening line, after the bracket
|
|
1885
2306
|
this.cursorLine = onClosingLine.startLine;
|
|
1886
2307
|
const openLine = this.lines[this.cursorLine] || '';
|
|
1887
|
-
const openBracketPos = openLine.search(
|
|
2308
|
+
const openBracketPos = openLine.search(RE_BRACKET_POS);
|
|
1888
2309
|
this.cursorColumn = openBracketPos >= 0 ? openBracketPos : 0;
|
|
1889
2310
|
this._invalidateRenderCache();
|
|
1890
2311
|
this._scrollToCursor();
|
|
@@ -1931,23 +2352,37 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1931
2352
|
/**
|
|
1932
2353
|
* Handle Home/End with optional selection
|
|
1933
2354
|
*/
|
|
1934
|
-
_handleHomeEnd(key, isShift, onClosingLine) {
|
|
2355
|
+
private _handleHomeEnd(key: string, isShift: boolean, onClosingLine: CollapsedNodeInfo | null): void {
|
|
1935
2356
|
// Start selection if shift is pressed and no selection exists
|
|
1936
2357
|
if (isShift && !this.selectionStart) {
|
|
1937
2358
|
this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
|
|
1938
2359
|
}
|
|
1939
|
-
|
|
2360
|
+
|
|
1940
2361
|
if (key === 'home') {
|
|
1941
2362
|
if (onClosingLine) {
|
|
2363
|
+
// On closing line of collapsed node: go to start line
|
|
1942
2364
|
this.cursorLine = onClosingLine.startLine;
|
|
2365
|
+
this.cursorColumn = 0;
|
|
2366
|
+
} else if (this.cursorColumn === 0) {
|
|
2367
|
+
// Already at start of line: go to start of document
|
|
2368
|
+
this.cursorLine = 0;
|
|
2369
|
+
this.cursorColumn = 0;
|
|
2370
|
+
} else {
|
|
2371
|
+
// Go to start of line
|
|
2372
|
+
this.cursorColumn = 0;
|
|
1943
2373
|
}
|
|
1944
|
-
this.cursorColumn = 0;
|
|
1945
2374
|
} else {
|
|
1946
|
-
|
|
1947
|
-
|
|
2375
|
+
const lineLength = this.lines[this.cursorLine]?.length || 0;
|
|
2376
|
+
if (this.cursorColumn === lineLength) {
|
|
2377
|
+
// Already at end of line: go to end of document
|
|
2378
|
+
this.cursorLine = this.lines.length - 1;
|
|
2379
|
+
this.cursorColumn = this.lines[this.cursorLine]?.length || 0;
|
|
2380
|
+
} else {
|
|
2381
|
+
// Go to end of line
|
|
2382
|
+
this.cursorColumn = lineLength;
|
|
1948
2383
|
}
|
|
1949
2384
|
}
|
|
1950
|
-
|
|
2385
|
+
|
|
1951
2386
|
// Update selection end if shift is pressed
|
|
1952
2387
|
if (isShift) {
|
|
1953
2388
|
this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
|
|
@@ -1955,7 +2390,50 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1955
2390
|
this.selectionStart = null;
|
|
1956
2391
|
this.selectionEnd = null;
|
|
1957
2392
|
}
|
|
1958
|
-
|
|
2393
|
+
|
|
2394
|
+
this._invalidateRenderCache();
|
|
2395
|
+
this._scrollToCursor();
|
|
2396
|
+
this.scheduleRender();
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
/**
|
|
2400
|
+
* Handle PageUp/PageDown
|
|
2401
|
+
*/
|
|
2402
|
+
private _handlePageUpDown(direction: 'up' | 'down', isShift: boolean): void {
|
|
2403
|
+
// Start selection if shift is pressed and no selection exists
|
|
2404
|
+
if (isShift && !this.selectionStart) {
|
|
2405
|
+
this.selectionStart = { line: this.cursorLine, column: this.cursorColumn };
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const viewport = this._viewport;
|
|
2409
|
+
if (!viewport) return;
|
|
2410
|
+
|
|
2411
|
+
// Calculate how many lines fit in the viewport
|
|
2412
|
+
const linesPerPage = Math.floor(viewport.clientHeight / this.lineHeight);
|
|
2413
|
+
|
|
2414
|
+
if (direction === 'up') {
|
|
2415
|
+
// Find current visible index and move up by page
|
|
2416
|
+
const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
|
|
2417
|
+
const newVisibleIdx = Math.max(0, currentVisibleIdx - linesPerPage);
|
|
2418
|
+
this.cursorLine = this.visibleLines[newVisibleIdx]?.index || 0;
|
|
2419
|
+
} else {
|
|
2420
|
+
// Find current visible index and move down by page
|
|
2421
|
+
const currentVisibleIdx = this.visibleLines.findIndex(vl => vl.index === this.cursorLine);
|
|
2422
|
+
const newVisibleIdx = Math.min(this.visibleLines.length - 1, currentVisibleIdx + linesPerPage);
|
|
2423
|
+
this.cursorLine = this.visibleLines[newVisibleIdx]?.index || this.lines.length - 1;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Clamp cursor column to line length
|
|
2427
|
+
this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
|
|
2428
|
+
|
|
2429
|
+
// Update selection end if shift is pressed
|
|
2430
|
+
if (isShift) {
|
|
2431
|
+
this.selectionEnd = { line: this.cursorLine, column: this.cursorColumn };
|
|
2432
|
+
} else {
|
|
2433
|
+
this.selectionStart = null;
|
|
2434
|
+
this.selectionEnd = null;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
1959
2437
|
this._invalidateRenderCache();
|
|
1960
2438
|
this._scrollToCursor();
|
|
1961
2439
|
this.scheduleRender();
|
|
@@ -1964,26 +2442,25 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
1964
2442
|
/**
|
|
1965
2443
|
* Select all content
|
|
1966
2444
|
*/
|
|
1967
|
-
_selectAll() {
|
|
2445
|
+
private _selectAll() {
|
|
1968
2446
|
this.selectionStart = { line: 0, column: 0 };
|
|
1969
2447
|
const lastLine = this.lines.length - 1;
|
|
1970
2448
|
this.selectionEnd = { line: lastLine, column: this.lines[lastLine]?.length || 0 };
|
|
1971
2449
|
this.cursorLine = lastLine;
|
|
1972
2450
|
this.cursorColumn = this.lines[lastLine]?.length || 0;
|
|
1973
|
-
|
|
2451
|
+
|
|
1974
2452
|
this._invalidateRenderCache();
|
|
1975
|
-
|
|
2453
|
+
// Don't scroll - viewport should stay in place when selecting all
|
|
1976
2454
|
this.scheduleRender();
|
|
1977
2455
|
}
|
|
1978
2456
|
|
|
1979
2457
|
/**
|
|
1980
2458
|
* Get selected text
|
|
1981
2459
|
*/
|
|
1982
|
-
_getSelectedText() {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
const { start, end } =
|
|
1986
|
-
if (!start || !end) return '';
|
|
2460
|
+
private _getSelectedText(): string {
|
|
2461
|
+
const sel = this._normalizeSelection();
|
|
2462
|
+
if (!sel) return '';
|
|
2463
|
+
const { start, end } = sel;
|
|
1987
2464
|
|
|
1988
2465
|
if (start.line === end.line) {
|
|
1989
2466
|
return this.lines[start.line].substring(start.column, end.column);
|
|
@@ -2001,14 +2478,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2001
2478
|
/**
|
|
2002
2479
|
* Normalize selection so start is before end
|
|
2003
2480
|
*/
|
|
2004
|
-
_normalizeSelection() {
|
|
2481
|
+
private _normalizeSelection(): { start: CursorPosition; end: CursorPosition } | null {
|
|
2005
2482
|
if (!this.selectionStart || !this.selectionEnd) {
|
|
2006
|
-
return
|
|
2483
|
+
return null;
|
|
2007
2484
|
}
|
|
2008
|
-
|
|
2485
|
+
|
|
2009
2486
|
const s = this.selectionStart;
|
|
2010
2487
|
const e = this.selectionEnd;
|
|
2011
|
-
|
|
2488
|
+
|
|
2012
2489
|
if (s.line < e.line || (s.line === e.line && s.column <= e.column)) {
|
|
2013
2490
|
return { start: s, end: e };
|
|
2014
2491
|
} else {
|
|
@@ -2019,7 +2496,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2019
2496
|
/**
|
|
2020
2497
|
* Check if there is an active selection
|
|
2021
2498
|
*/
|
|
2022
|
-
_hasSelection() {
|
|
2499
|
+
private _hasSelection() {
|
|
2023
2500
|
if (!this.selectionStart || !this.selectionEnd) return false;
|
|
2024
2501
|
return this.selectionStart.line !== this.selectionEnd.line ||
|
|
2025
2502
|
this.selectionStart.column !== this.selectionEnd.column;
|
|
@@ -2028,7 +2505,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2028
2505
|
/**
|
|
2029
2506
|
* Clear the current selection
|
|
2030
2507
|
*/
|
|
2031
|
-
_clearSelection() {
|
|
2508
|
+
private _clearSelection() {
|
|
2032
2509
|
this.selectionStart = null;
|
|
2033
2510
|
this.selectionEnd = null;
|
|
2034
2511
|
}
|
|
@@ -2036,13 +2513,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2036
2513
|
/**
|
|
2037
2514
|
* Delete selected text
|
|
2038
2515
|
*/
|
|
2039
|
-
_deleteSelection() {
|
|
2040
|
-
|
|
2516
|
+
private _deleteSelection(): boolean {
|
|
2517
|
+
const sel = this._normalizeSelection();
|
|
2518
|
+
if (!sel) return false;
|
|
2519
|
+
const { start, end } = sel;
|
|
2041
2520
|
|
|
2042
2521
|
this._saveToHistory('delete');
|
|
2043
2522
|
|
|
2044
|
-
const { start, end } = this._normalizeSelection();
|
|
2045
|
-
|
|
2046
2523
|
if (start.line === end.line) {
|
|
2047
2524
|
// Single line selection
|
|
2048
2525
|
const line = this.lines[start.line];
|
|
@@ -2063,7 +2540,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2063
2540
|
return true;
|
|
2064
2541
|
}
|
|
2065
2542
|
|
|
2066
|
-
insertText(text) {
|
|
2543
|
+
insertText(text: string): void {
|
|
2067
2544
|
// Delete selection first if any
|
|
2068
2545
|
if (this._hasSelection()) {
|
|
2069
2546
|
this._deleteSelection();
|
|
@@ -2084,7 +2561,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2084
2561
|
const onCollapsed = this._getCollapsedNodeAtLine(this.cursorLine);
|
|
2085
2562
|
if (onCollapsed) {
|
|
2086
2563
|
const line = this.lines[this.cursorLine];
|
|
2087
|
-
const bracketPos = line.search(
|
|
2564
|
+
const bracketPos = line.search(RE_BRACKET_POS);
|
|
2088
2565
|
if (this.cursorColumn > bracketPos) return;
|
|
2089
2566
|
}
|
|
2090
2567
|
|
|
@@ -2106,9 +2583,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2106
2583
|
this.formatAndUpdate();
|
|
2107
2584
|
}
|
|
2108
2585
|
|
|
2109
|
-
handlePaste(e) {
|
|
2586
|
+
handlePaste(e: ClipboardEvent): void {
|
|
2110
2587
|
e.preventDefault();
|
|
2111
|
-
const text = e.clipboardData
|
|
2588
|
+
const text = e.clipboardData?.getData('text/plain');
|
|
2112
2589
|
if (!text) return;
|
|
2113
2590
|
|
|
2114
2591
|
const wasEmpty = this.lines.length === 0;
|
|
@@ -2116,7 +2593,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2116
2593
|
// Try to parse as GeoJSON and normalize
|
|
2117
2594
|
try {
|
|
2118
2595
|
const parsed = JSON.parse(text);
|
|
2119
|
-
const features =
|
|
2596
|
+
const features = normalizeToFeatures(parsed);
|
|
2120
2597
|
// Valid GeoJSON - insert formatted features
|
|
2121
2598
|
const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
|
|
2122
2599
|
this.insertText(formatted);
|
|
@@ -2125,19 +2602,27 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2125
2602
|
this.insertText(text);
|
|
2126
2603
|
}
|
|
2127
2604
|
|
|
2605
|
+
// Cancel any pending render from insertText/formatAndUpdate
|
|
2606
|
+
if (this.renderTimer) {
|
|
2607
|
+
cancelAnimationFrame(this.renderTimer);
|
|
2608
|
+
this.renderTimer = undefined;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2128
2611
|
// Auto-collapse coordinates after pasting into empty editor
|
|
2129
2612
|
if (wasEmpty && this.lines.length > 0) {
|
|
2130
|
-
// Cancel pending render, collapse first, then render once
|
|
2131
|
-
if (this.renderTimer) {
|
|
2132
|
-
cancelAnimationFrame(this.renderTimer);
|
|
2133
|
-
this.renderTimer = null;
|
|
2134
|
-
}
|
|
2135
2613
|
this.autoCollapseCoordinates();
|
|
2136
2614
|
}
|
|
2615
|
+
|
|
2616
|
+
// Expand any collapsed nodes that contain errors
|
|
2617
|
+
this._expandErrorNodes();
|
|
2618
|
+
|
|
2619
|
+
// Force immediate render (not via RAF) to ensure content displays instantly
|
|
2620
|
+
this.renderViewport();
|
|
2137
2621
|
}
|
|
2138
2622
|
|
|
2139
|
-
handleCopy(e) {
|
|
2623
|
+
handleCopy(e: ClipboardEvent): void {
|
|
2140
2624
|
e.preventDefault();
|
|
2625
|
+
if (!e.clipboardData) return;
|
|
2141
2626
|
// Copy selected text if there's a selection, otherwise copy all
|
|
2142
2627
|
if (this._hasSelection()) {
|
|
2143
2628
|
e.clipboardData.setData('text/plain', this._getSelectedText());
|
|
@@ -2146,8 +2631,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2146
2631
|
}
|
|
2147
2632
|
}
|
|
2148
2633
|
|
|
2149
|
-
handleCut(e) {
|
|
2634
|
+
handleCut(e: ClipboardEvent): void {
|
|
2150
2635
|
e.preventDefault();
|
|
2636
|
+
if (!e.clipboardData) return;
|
|
2151
2637
|
if (this._hasSelection()) {
|
|
2152
2638
|
e.clipboardData.setData('text/plain', this._getSelectedText());
|
|
2153
2639
|
this._saveToHistory('cut');
|
|
@@ -2167,9 +2653,10 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2167
2653
|
/**
|
|
2168
2654
|
* Get line/column position from mouse event
|
|
2169
2655
|
*/
|
|
2170
|
-
_getPositionFromClick(e) {
|
|
2171
|
-
const viewport = this.
|
|
2172
|
-
const linesContainer = this.
|
|
2656
|
+
private _getPositionFromClick(e: MouseEvent): { line: number; column: number } {
|
|
2657
|
+
const viewport = this._viewport;
|
|
2658
|
+
const linesContainer = this._linesContainer;
|
|
2659
|
+
if (!viewport) return { line: 0, column: 0 };
|
|
2173
2660
|
const rect = viewport.getBoundingClientRect();
|
|
2174
2661
|
|
|
2175
2662
|
const paddingTop = 8;
|
|
@@ -2210,28 +2697,34 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2210
2697
|
|
|
2211
2698
|
// ========== Gutter Interactions ==========
|
|
2212
2699
|
|
|
2213
|
-
handleGutterClick(e) {
|
|
2700
|
+
handleGutterClick(e: MouseEvent): void {
|
|
2701
|
+
const target = e.target as HTMLElement;
|
|
2702
|
+
if (!target) return;
|
|
2703
|
+
|
|
2214
2704
|
// Visibility button in gutter
|
|
2215
|
-
const visBtn =
|
|
2705
|
+
const visBtn = target.closest('.visibility-button') as HTMLElement | null;
|
|
2216
2706
|
if (visBtn) {
|
|
2217
2707
|
this.toggleFeatureVisibility(visBtn.dataset.featureKey);
|
|
2218
2708
|
return;
|
|
2219
2709
|
}
|
|
2220
|
-
|
|
2710
|
+
|
|
2221
2711
|
// Collapse button in gutter
|
|
2222
|
-
if (
|
|
2223
|
-
const nodeId =
|
|
2224
|
-
this.toggleCollapse(nodeId);
|
|
2712
|
+
if (target.classList.contains('collapse-button')) {
|
|
2713
|
+
const nodeId = target.dataset.nodeId;
|
|
2714
|
+
if (nodeId) this.toggleCollapse(nodeId);
|
|
2225
2715
|
return;
|
|
2226
2716
|
}
|
|
2227
2717
|
}
|
|
2228
2718
|
|
|
2229
|
-
handleEditorClick(e) {
|
|
2719
|
+
handleEditorClick(e: MouseEvent): void {
|
|
2720
|
+
const target = e.target as HTMLElement;
|
|
2721
|
+
if (!target) return;
|
|
2722
|
+
|
|
2230
2723
|
// Unblock render now that click is being processed
|
|
2231
2724
|
this._blockRender = false;
|
|
2232
2725
|
|
|
2233
2726
|
// Line-level visibility button (pseudo-element ::before on .line.has-visibility)
|
|
2234
|
-
const lineEl =
|
|
2727
|
+
const lineEl = target.closest('.line.has-visibility') as HTMLElement | null;
|
|
2235
2728
|
if (lineEl) {
|
|
2236
2729
|
const rect = lineEl.getBoundingClientRect();
|
|
2237
2730
|
const clickX = e.clientX - rect.left;
|
|
@@ -2245,42 +2738,44 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2245
2738
|
return;
|
|
2246
2739
|
}
|
|
2247
2740
|
}
|
|
2248
|
-
|
|
2741
|
+
|
|
2249
2742
|
// Inline color swatch (pseudo-element positioned with left: -8px)
|
|
2250
|
-
if (
|
|
2251
|
-
const rect =
|
|
2743
|
+
if (target.classList.contains('json-color')) {
|
|
2744
|
+
const rect = target.getBoundingClientRect();
|
|
2252
2745
|
const clickX = e.clientX - rect.left;
|
|
2253
2746
|
// Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
|
|
2254
2747
|
if (clickX < 0 && clickX >= -8) {
|
|
2255
2748
|
e.preventDefault();
|
|
2256
2749
|
e.stopPropagation();
|
|
2257
|
-
const color =
|
|
2258
|
-
const targetLineEl =
|
|
2750
|
+
const color = target.dataset.color;
|
|
2751
|
+
const targetLineEl = target.closest('.line') as HTMLElement | null;
|
|
2259
2752
|
if (targetLineEl) {
|
|
2260
|
-
const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
|
|
2753
|
+
const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
|
|
2261
2754
|
const line = this.lines[lineIndex];
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2755
|
+
// Match any string attribute (hex or named color)
|
|
2756
|
+
// RE_ATTR_VALUE_SINGLE captures: [1] attributeName, [2] stringValue
|
|
2757
|
+
const match = line.match(RE_ATTR_VALUE_SINGLE);
|
|
2758
|
+
if (match && match[1] && color) {
|
|
2759
|
+
this.showColorPicker(target, lineIndex, color, match[1]);
|
|
2265
2760
|
}
|
|
2266
2761
|
}
|
|
2267
2762
|
return;
|
|
2268
2763
|
}
|
|
2269
2764
|
}
|
|
2270
|
-
|
|
2765
|
+
|
|
2271
2766
|
// Inline boolean checkbox (pseudo-element positioned with left: -8px)
|
|
2272
|
-
if (
|
|
2273
|
-
const rect =
|
|
2767
|
+
if (target.classList.contains('json-boolean')) {
|
|
2768
|
+
const rect = target.getBoundingClientRect();
|
|
2274
2769
|
const clickX = e.clientX - rect.left;
|
|
2275
2770
|
// Pseudo-element is at left: -8px, so clickX will be negative when clicking on it
|
|
2276
2771
|
if (clickX < 0 && clickX >= -8) {
|
|
2277
2772
|
e.preventDefault();
|
|
2278
2773
|
e.stopPropagation();
|
|
2279
|
-
const targetLineEl =
|
|
2774
|
+
const targetLineEl = target.closest('.line') as HTMLElement | null;
|
|
2280
2775
|
if (targetLineEl) {
|
|
2281
|
-
const lineIndex = parseInt(targetLineEl.dataset.lineIndex);
|
|
2776
|
+
const lineIndex = parseInt(targetLineEl.dataset.lineIndex || '0');
|
|
2282
2777
|
const line = this.lines[lineIndex];
|
|
2283
|
-
const match = line.match(
|
|
2778
|
+
const match = line.match(RE_ATTR_AND_BOOL_VALUE);
|
|
2284
2779
|
if (match) {
|
|
2285
2780
|
const currentValue = match[2] === 'true';
|
|
2286
2781
|
this.updateBooleanValue(lineIndex, !currentValue, match[1]);
|
|
@@ -2292,14 +2787,23 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2292
2787
|
}
|
|
2293
2788
|
|
|
2294
2789
|
// ========== Collapse/Expand ==========
|
|
2295
|
-
|
|
2296
|
-
toggleCollapse(nodeId) {
|
|
2790
|
+
|
|
2791
|
+
toggleCollapse(nodeId: string): void {
|
|
2792
|
+
const nodeInfo = this._nodeIdToLines.get(nodeId);
|
|
2297
2793
|
if (this.collapsedNodes.has(nodeId)) {
|
|
2298
2794
|
this.collapsedNodes.delete(nodeId);
|
|
2795
|
+
// Track that user opened this node - don't re-collapse during edits
|
|
2796
|
+
if (nodeInfo?.uniqueKey) {
|
|
2797
|
+
this._openedNodeKeys.add(nodeInfo.uniqueKey);
|
|
2798
|
+
}
|
|
2299
2799
|
} else {
|
|
2300
2800
|
this.collapsedNodes.add(nodeId);
|
|
2801
|
+
// User closed it - allow re-collapse
|
|
2802
|
+
if (nodeInfo?.uniqueKey) {
|
|
2803
|
+
this._openedNodeKeys.delete(nodeInfo.uniqueKey);
|
|
2804
|
+
}
|
|
2301
2805
|
}
|
|
2302
|
-
|
|
2806
|
+
|
|
2303
2807
|
// Use updateView - don't rebuild nodeId mappings since content didn't change
|
|
2304
2808
|
this.updateView();
|
|
2305
2809
|
this._invalidateRenderCache(); // Force re-render
|
|
@@ -2307,15 +2811,47 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2307
2811
|
}
|
|
2308
2812
|
|
|
2309
2813
|
autoCollapseCoordinates() {
|
|
2814
|
+
// Don't collapse if there are errors - they should remain visible
|
|
2815
|
+
if (this._hasErrors()) {
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2310
2818
|
this._applyCollapsedOption(['coordinates']);
|
|
2311
2819
|
}
|
|
2312
2820
|
|
|
2821
|
+
/**
|
|
2822
|
+
* Check if current content has any errors (JSON parse errors or syntax highlighting errors)
|
|
2823
|
+
*/
|
|
2824
|
+
private _hasErrors(): boolean {
|
|
2825
|
+
// Check JSON parse errors
|
|
2826
|
+
try {
|
|
2827
|
+
const content = this.lines.join('\n');
|
|
2828
|
+
const wrapped = '[' + content + ']';
|
|
2829
|
+
JSON.parse(wrapped);
|
|
2830
|
+
} catch {
|
|
2831
|
+
return true;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Check for syntax highlighting errors (json-error class)
|
|
2835
|
+
for (const line of this.lines) {
|
|
2836
|
+
const highlighted = highlightSyntax(line, '', undefined);
|
|
2837
|
+
if (highlighted.includes('json-error')) {
|
|
2838
|
+
return true;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
return false;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2313
2845
|
/**
|
|
2314
2846
|
* Helper to apply collapsed option from API methods
|
|
2315
|
-
*
|
|
2316
|
-
* @param {array} features - Features array for function mode
|
|
2847
|
+
* Does not collapse if there are errors (so they remain visible)
|
|
2317
2848
|
*/
|
|
2318
|
-
_applyCollapsedFromOptions(options, features) {
|
|
2849
|
+
private _applyCollapsedFromOptions(options: SetOptions, features: Feature[]): void {
|
|
2850
|
+
// Don't collapse if there are errors - they should remain visible
|
|
2851
|
+
if (this._hasErrors()) {
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2319
2855
|
const collapsed = options.collapsed !== undefined ? options.collapsed : ['coordinates'];
|
|
2320
2856
|
if (collapsed && (Array.isArray(collapsed) ? collapsed.length > 0 : true)) {
|
|
2321
2857
|
this._applyCollapsedOption(collapsed, features);
|
|
@@ -2324,10 +2860,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2324
2860
|
|
|
2325
2861
|
/**
|
|
2326
2862
|
* Apply collapsed option to nodes
|
|
2327
|
-
* @param {string[]|function} collapsed - Attributes to collapse or function returning them
|
|
2328
|
-
* @param {array} features - Features array for function mode (optional)
|
|
2329
2863
|
*/
|
|
2330
|
-
_applyCollapsedOption(collapsed, features = null) {
|
|
2864
|
+
private _applyCollapsedOption(collapsed: string[] | ((feature: Feature | null, index: number) => string[]), features: Feature[] | null = null): void {
|
|
2331
2865
|
const ranges = this._findCollapsibleRanges();
|
|
2332
2866
|
|
|
2333
2867
|
// Group ranges by feature (root nodes)
|
|
@@ -2372,7 +2906,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2372
2906
|
|
|
2373
2907
|
// ========== Feature Visibility ==========
|
|
2374
2908
|
|
|
2375
|
-
toggleFeatureVisibility(featureKey) {
|
|
2909
|
+
toggleFeatureVisibility(featureKey: string | undefined): void {
|
|
2910
|
+
if (!featureKey) return;
|
|
2376
2911
|
if (this.hiddenFeatures.has(featureKey)) {
|
|
2377
2912
|
this.hiddenFeatures.delete(featureKey);
|
|
2378
2913
|
} else {
|
|
@@ -2386,17 +2921,17 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2386
2921
|
}
|
|
2387
2922
|
|
|
2388
2923
|
// ========== Color Picker ==========
|
|
2389
|
-
|
|
2390
|
-
showColorPicker(indicator, line, currentColor, attributeName) {
|
|
2924
|
+
|
|
2925
|
+
showColorPicker(indicator: HTMLElement, line: number, currentColor: string, attributeName: string) {
|
|
2391
2926
|
// Remove existing picker and anchor
|
|
2392
2927
|
const existing = document.querySelector('.geojson-color-picker-anchor');
|
|
2393
2928
|
if (existing) {
|
|
2394
2929
|
existing.remove();
|
|
2395
2930
|
}
|
|
2396
|
-
|
|
2931
|
+
|
|
2397
2932
|
// Create an anchor element at the pseudo-element position
|
|
2398
2933
|
// The browser will position the color picker popup relative to this
|
|
2399
|
-
const anchor =
|
|
2934
|
+
const anchor = _ce('div');
|
|
2400
2935
|
anchor.className = 'geojson-color-picker-anchor';
|
|
2401
2936
|
const rect = indicator.getBoundingClientRect();
|
|
2402
2937
|
anchor.style.cssText = `
|
|
@@ -2408,10 +2943,19 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2408
2943
|
z-index: 9998;
|
|
2409
2944
|
`;
|
|
2410
2945
|
document.body.appendChild(anchor);
|
|
2411
|
-
|
|
2412
|
-
const colorInput =
|
|
2946
|
+
|
|
2947
|
+
const colorInput = _ce('input') as HTMLInputElement & { _closeListener?: EventListener };
|
|
2413
2948
|
colorInput.type = 'color';
|
|
2414
|
-
|
|
2949
|
+
// Convert color to hex format for the color picker
|
|
2950
|
+
let hexColor = currentColor;
|
|
2951
|
+
if (!currentColor.startsWith('#')) {
|
|
2952
|
+
// Named color - convert to hex
|
|
2953
|
+
hexColor = namedColorToHex(currentColor) || '#000000';
|
|
2954
|
+
} else {
|
|
2955
|
+
// Expand 3-char hex to 6-char (#abc -> #aabbcc)
|
|
2956
|
+
hexColor = currentColor.replace(RE_NORMALIZE_COLOR, '#$1$1$2$2$3$3');
|
|
2957
|
+
}
|
|
2958
|
+
colorInput.value = hexColor;
|
|
2415
2959
|
colorInput.className = 'geojson-color-picker-input';
|
|
2416
2960
|
|
|
2417
2961
|
// Position the color input inside the anchor
|
|
@@ -2428,7 +2972,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2428
2972
|
`;
|
|
2429
2973
|
anchor.appendChild(colorInput);
|
|
2430
2974
|
|
|
2431
|
-
colorInput.addEventListener('input', (e
|
|
2975
|
+
colorInput.addEventListener('input', (e) => {
|
|
2432
2976
|
this.updateColorValue(line, (e.target as HTMLInputElement).value, attributeName);
|
|
2433
2977
|
});
|
|
2434
2978
|
|
|
@@ -2440,7 +2984,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2440
2984
|
};
|
|
2441
2985
|
|
|
2442
2986
|
colorInput._closeListener = closeOnClickOutside;
|
|
2443
|
-
|
|
2987
|
+
|
|
2444
2988
|
setTimeout(() => {
|
|
2445
2989
|
document.addEventListener('click', closeOnClickOutside, true);
|
|
2446
2990
|
}, 100);
|
|
@@ -2449,17 +2993,18 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2449
2993
|
colorInput.click();
|
|
2450
2994
|
}
|
|
2451
2995
|
|
|
2452
|
-
updateColorValue(line, newColor, attributeName) {
|
|
2453
|
-
|
|
2996
|
+
updateColorValue(line: number, newColor: string, attributeName: string) {
|
|
2997
|
+
// Match both hex colors (#xxx, #xxxxxx) and named colors (red, blue, etc.)
|
|
2998
|
+
const regex = new RegExp(`"${attributeName}"\\s*:\\s*"(?:#[0-9a-fA-F]{3,6}|[a-zA-Z]+)"`);
|
|
2454
2999
|
this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": "${newColor}"`);
|
|
2455
|
-
|
|
3000
|
+
|
|
2456
3001
|
// Use updateView to preserve collapsed state (line count didn't change)
|
|
2457
3002
|
this.updateView();
|
|
2458
3003
|
this.scheduleRender();
|
|
2459
3004
|
this.emitChange();
|
|
2460
3005
|
}
|
|
2461
3006
|
|
|
2462
|
-
updateBooleanValue(line, newValue, attributeName) {
|
|
3007
|
+
updateBooleanValue(line: number, newValue: boolean, attributeName: string): void {
|
|
2463
3008
|
const regex = new RegExp(`"${attributeName}"\\s*:\\s*(true|false)`);
|
|
2464
3009
|
this.lines[line] = this.lines[line].replace(regex, `"${attributeName}": ${newValue}`);
|
|
2465
3010
|
|
|
@@ -2470,23 +3015,223 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2470
3015
|
}
|
|
2471
3016
|
|
|
2472
3017
|
// ========== Format and Update ==========
|
|
2473
|
-
|
|
3018
|
+
|
|
3019
|
+
/**
|
|
3020
|
+
* Best-effort formatting for invalid JSON
|
|
3021
|
+
* Splits on structural characters and indents as much as possible
|
|
3022
|
+
* @param content The content to format
|
|
3023
|
+
* @param skipLineIndex Optional line index to skip (keep as-is)
|
|
3024
|
+
*/
|
|
3025
|
+
private _bestEffortFormat(content: string, skipLineIndex?: number): string[] {
|
|
3026
|
+
const sourceLines = content.split('\n');
|
|
3027
|
+
|
|
3028
|
+
// If we have a line to skip, handle it specially
|
|
3029
|
+
if (skipLineIndex !== undefined && skipLineIndex >= 0 && skipLineIndex < sourceLines.length) {
|
|
3030
|
+
const skippedLine = sourceLines[skipLineIndex];
|
|
3031
|
+
|
|
3032
|
+
// Format content before the skipped line
|
|
3033
|
+
const beforeContent = sourceLines.slice(0, skipLineIndex).join('\n');
|
|
3034
|
+
const beforeLines = beforeContent.trim() ? this._formatChunk(beforeContent) : [];
|
|
3035
|
+
|
|
3036
|
+
// Keep skipped line exactly as-is (don't re-indent, user is typing on it)
|
|
3037
|
+
const depthBefore = this._computeDepthAtEnd(beforeLines);
|
|
3038
|
+
|
|
3039
|
+
// Compute depth after the skipped line (including its brackets)
|
|
3040
|
+
const depthAfterSkipped = depthBefore + this._computeBracketDelta(skippedLine);
|
|
3041
|
+
|
|
3042
|
+
// Format content after the skipped line, starting at correct depth
|
|
3043
|
+
const afterContent = sourceLines.slice(skipLineIndex + 1).join('\n');
|
|
3044
|
+
const afterLines = afterContent.trim() ? this._formatChunk(afterContent, depthAfterSkipped) : [];
|
|
3045
|
+
|
|
3046
|
+
return [...beforeLines, skippedLine, ...afterLines];
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// No line to skip - format everything
|
|
3050
|
+
return this._formatChunk(content);
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
/**
|
|
3054
|
+
* Compute the net bracket delta for a line (opens - closes)
|
|
3055
|
+
*/
|
|
3056
|
+
private _computeBracketDelta(line: string): number {
|
|
3057
|
+
let delta = 0;
|
|
3058
|
+
let inString = false;
|
|
3059
|
+
let escaped = false;
|
|
3060
|
+
for (const char of line) {
|
|
3061
|
+
if (escaped) { escaped = false; continue; }
|
|
3062
|
+
if (char === '\\' && inString) { escaped = true; continue; }
|
|
3063
|
+
if (char === '"') { inString = !inString; continue; }
|
|
3064
|
+
if (inString) continue;
|
|
3065
|
+
if (char === '{' || char === '[') delta++;
|
|
3066
|
+
else if (char === '}' || char === ']') delta--;
|
|
3067
|
+
}
|
|
3068
|
+
return delta;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
/**
|
|
3072
|
+
* Compute the bracket depth at the end of formatted lines
|
|
3073
|
+
* Starts at 1 to account for FeatureCollection wrapper
|
|
3074
|
+
*/
|
|
3075
|
+
private _computeDepthAtEnd(lines: string[]): number {
|
|
3076
|
+
let depth = 1; // Start at 1 for FeatureCollection wrapper
|
|
3077
|
+
for (const line of lines) {
|
|
3078
|
+
for (const char of line) {
|
|
3079
|
+
if (char === '{' || char === '[') depth++;
|
|
3080
|
+
else if (char === '}' || char === ']') depth = Math.max(0, depth - 1);
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
return depth;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
/**
|
|
3087
|
+
* Format a chunk of JSON content
|
|
3088
|
+
* @param content The content to format
|
|
3089
|
+
* @param initialDepth Starting indentation depth (default 1 for FeatureCollection wrapper)
|
|
3090
|
+
*/
|
|
3091
|
+
private _formatChunk(content: string, initialDepth: number = 1): string[] {
|
|
3092
|
+
const result: string[] = [];
|
|
3093
|
+
let currentLine = '';
|
|
3094
|
+
let depth = initialDepth;
|
|
3095
|
+
let inString = false;
|
|
3096
|
+
let escaped = false;
|
|
3097
|
+
|
|
3098
|
+
for (let i = 0; i < content.length; i++) {
|
|
3099
|
+
const char = content[i];
|
|
3100
|
+
|
|
3101
|
+
// Track escape sequences inside strings
|
|
3102
|
+
if (escaped) {
|
|
3103
|
+
currentLine += char;
|
|
3104
|
+
escaped = false;
|
|
3105
|
+
continue;
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
if (char === '\\' && inString) {
|
|
3109
|
+
currentLine += char;
|
|
3110
|
+
escaped = true;
|
|
3111
|
+
continue;
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// Track if we're inside a string
|
|
3115
|
+
if (char === '"') {
|
|
3116
|
+
inString = !inString;
|
|
3117
|
+
currentLine += char;
|
|
3118
|
+
continue;
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// Inside string - just append
|
|
3122
|
+
if (inString) {
|
|
3123
|
+
currentLine += char;
|
|
3124
|
+
continue;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
// Outside string - handle structural characters
|
|
3128
|
+
if (char === '{' || char === '[') {
|
|
3129
|
+
currentLine += char;
|
|
3130
|
+
result.push(' '.repeat(depth) + currentLine.trim());
|
|
3131
|
+
depth++;
|
|
3132
|
+
currentLine = '';
|
|
3133
|
+
} else if (char === '}' || char === ']') {
|
|
3134
|
+
if (currentLine.trim()) {
|
|
3135
|
+
result.push(' '.repeat(depth) + currentLine.trim());
|
|
3136
|
+
}
|
|
3137
|
+
depth = Math.max(0, depth - 1);
|
|
3138
|
+
currentLine = char;
|
|
3139
|
+
} else if (char === ',') {
|
|
3140
|
+
currentLine += char;
|
|
3141
|
+
result.push(' '.repeat(depth) + currentLine.trim());
|
|
3142
|
+
currentLine = '';
|
|
3143
|
+
} else if (char === ':') {
|
|
3144
|
+
currentLine += ': '; // Add space after colon for readability
|
|
3145
|
+
i++; // Skip if next char is space
|
|
3146
|
+
if (content[i] === ' ') continue;
|
|
3147
|
+
i--; // Not a space, go back
|
|
3148
|
+
} else if (char === '\n' || char === '\r') {
|
|
3149
|
+
// Ignore existing newlines
|
|
3150
|
+
continue;
|
|
3151
|
+
} else {
|
|
3152
|
+
currentLine += char;
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// Don't forget last line
|
|
3157
|
+
if (currentLine.trim()) {
|
|
3158
|
+
result.push(' '.repeat(depth) + currentLine.trim());
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
return result;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
2474
3164
|
formatAndUpdate() {
|
|
3165
|
+
// Save cursor position
|
|
3166
|
+
const oldCursorLine = this.cursorLine;
|
|
3167
|
+
const oldCursorColumn = this.cursorColumn;
|
|
3168
|
+
const oldContent = this.lines.join('\n');
|
|
3169
|
+
|
|
2475
3170
|
try {
|
|
2476
|
-
const
|
|
2477
|
-
const wrapped = '[' + content + ']';
|
|
3171
|
+
const wrapped = '[' + oldContent + ']';
|
|
2478
3172
|
const parsed = JSON.parse(wrapped);
|
|
2479
|
-
|
|
3173
|
+
|
|
2480
3174
|
const formatted = JSON.stringify(parsed, null, 2);
|
|
2481
3175
|
const lines = formatted.split('\n');
|
|
2482
3176
|
this.lines = lines.slice(1, -1); // Remove wrapper brackets
|
|
2483
|
-
} catch
|
|
2484
|
-
// Invalid JSON
|
|
3177
|
+
} catch {
|
|
3178
|
+
// Invalid JSON - apply best-effort formatting
|
|
3179
|
+
if (oldContent.trim()) {
|
|
3180
|
+
// Skip the cursor line only for small content (typing, not paste)
|
|
3181
|
+
// This avoids text jumping while user is typing
|
|
3182
|
+
// For paste/large insertions, format everything for proper structure
|
|
3183
|
+
const cursorLineContent = this.lines[oldCursorLine] || '';
|
|
3184
|
+
// If cursor line is short, likely typing. Long lines = paste
|
|
3185
|
+
const isSmallEdit = cursorLineContent.length < 80;
|
|
3186
|
+
const skipLine = isSmallEdit ? oldCursorLine : undefined;
|
|
3187
|
+
this.lines = this._bestEffortFormat(oldContent, skipLine);
|
|
3188
|
+
}
|
|
2485
3189
|
}
|
|
2486
|
-
|
|
3190
|
+
|
|
3191
|
+
const newContent = this.lines.join('\n');
|
|
3192
|
+
|
|
3193
|
+
// If content didn't change, keep cursor exactly where it was
|
|
3194
|
+
if (newContent === oldContent) {
|
|
3195
|
+
this.cursorLine = oldCursorLine;
|
|
3196
|
+
this.cursorColumn = oldCursorColumn;
|
|
3197
|
+
} else {
|
|
3198
|
+
// Content changed due to reformatting
|
|
3199
|
+
// The cursor position (this.cursorLine, this.cursorColumn) was set by the calling
|
|
3200
|
+
// operation (insertText, insertNewline, etc.) BEFORE formatAndUpdate was called.
|
|
3201
|
+
// We need to adjust for indentation changes while keeping the logical position.
|
|
3202
|
+
|
|
3203
|
+
// If cursor is at column 0 (e.g., after newline), keep it there
|
|
3204
|
+
// This preserves expected behavior for newline insertion
|
|
3205
|
+
if (this.cursorColumn === 0) {
|
|
3206
|
+
// Just keep line, column 0 - indentation will be handled by auto-indent
|
|
3207
|
+
} else {
|
|
3208
|
+
// For other cases, try to maintain position relative to content (not indentation)
|
|
3209
|
+
const oldLines = oldContent.split('\n');
|
|
3210
|
+
const oldLineContent = oldLines[oldCursorLine] || '';
|
|
3211
|
+
const oldLeadingSpaces = oldLineContent.length - oldLineContent.trimStart().length;
|
|
3212
|
+
const oldColumnInContent = Math.max(0, oldCursorColumn - oldLeadingSpaces);
|
|
3213
|
+
|
|
3214
|
+
// Apply same offset to new line's indentation
|
|
3215
|
+
if (this.cursorLine < this.lines.length) {
|
|
3216
|
+
const newLineContent = this.lines[this.cursorLine];
|
|
3217
|
+
const newLeadingSpaces = newLineContent.length - newLineContent.trimStart().length;
|
|
3218
|
+
this.cursorColumn = newLeadingSpaces + oldColumnInContent;
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
// Clamp cursor to valid range
|
|
3224
|
+
this.cursorLine = Math.min(this.cursorLine, Math.max(0, this.lines.length - 1));
|
|
3225
|
+
this.cursorColumn = Math.min(this.cursorColumn, this.lines[this.cursorLine]?.length || 0);
|
|
3226
|
+
|
|
2487
3227
|
this.updateModel();
|
|
3228
|
+
|
|
3229
|
+
// Expand any nodes that contain errors (prevents closing edited nodes with typos)
|
|
3230
|
+
this._expandErrorNodes();
|
|
3231
|
+
|
|
2488
3232
|
this.scheduleRender();
|
|
2489
3233
|
this.updatePlaceholderVisibility();
|
|
3234
|
+
this._updateErrorDisplay();
|
|
2490
3235
|
this.emitChange();
|
|
2491
3236
|
}
|
|
2492
3237
|
|
|
@@ -2501,14 +3246,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2501
3246
|
|
|
2502
3247
|
// Filter hidden features
|
|
2503
3248
|
if (this.hiddenFeatures.size > 0) {
|
|
2504
|
-
parsed.features = parsed.features.filter((feature) => {
|
|
2505
|
-
const key =
|
|
2506
|
-
return !this.hiddenFeatures.has(key);
|
|
3249
|
+
parsed.features = parsed.features.filter((feature: Feature) => {
|
|
3250
|
+
const key = getFeatureKey(feature);
|
|
3251
|
+
return key ? !this.hiddenFeatures.has(key) : true;
|
|
2507
3252
|
});
|
|
2508
3253
|
}
|
|
2509
3254
|
|
|
2510
3255
|
// Validate
|
|
2511
|
-
const errors =
|
|
3256
|
+
const errors = validateGeoJSON(parsed);
|
|
2512
3257
|
|
|
2513
3258
|
if (errors.length > 0) {
|
|
2514
3259
|
this.dispatchEvent(new CustomEvent('error', {
|
|
@@ -2525,7 +3270,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2525
3270
|
}
|
|
2526
3271
|
} catch (e) {
|
|
2527
3272
|
this.dispatchEvent(new CustomEvent('error', {
|
|
2528
|
-
detail: { error: e.message, content },
|
|
3273
|
+
detail: { error: e instanceof Error ? e.message : 'Unknown error', content },
|
|
2529
3274
|
bubbles: true,
|
|
2530
3275
|
composed: true
|
|
2531
3276
|
}));
|
|
@@ -2533,56 +3278,63 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2533
3278
|
}
|
|
2534
3279
|
|
|
2535
3280
|
// ========== UI Updates ==========
|
|
2536
|
-
|
|
2537
|
-
updateReadonly() {
|
|
2538
|
-
const textarea = this.shadowRoot.getElementById('hiddenTextarea');
|
|
2539
|
-
const clearBtn = this.shadowRoot!.getElementById('clearBtn') as HTMLButtonElement;
|
|
2540
3281
|
|
|
3282
|
+
updateReadonly() {
|
|
2541
3283
|
// Use readOnly instead of disabled to allow text selection for copying
|
|
2542
|
-
if (
|
|
2543
|
-
if (
|
|
3284
|
+
if (this._hiddenTextarea) this._hiddenTextarea.readOnly = this.readonly;
|
|
3285
|
+
if (this._clearBtn) this._clearBtn.hidden = this.readonly;
|
|
2544
3286
|
}
|
|
2545
3287
|
|
|
2546
3288
|
updatePlaceholderVisibility() {
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
3289
|
+
if (this._placeholderLayer) {
|
|
3290
|
+
this._placeholderLayer.style.display = this.lines.length > 0 ? 'none' : 'block';
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
/**
|
|
3295
|
+
* Update error display (counter and navigation visibility)
|
|
3296
|
+
*/
|
|
3297
|
+
private _updateErrorDisplay() {
|
|
3298
|
+
const errorLines = this._getErrorLines();
|
|
3299
|
+
const count = errorLines.length;
|
|
3300
|
+
|
|
3301
|
+
if (this._errorNav) {
|
|
3302
|
+
this._errorNav.classList.toggle('visible', count > 0);
|
|
3303
|
+
}
|
|
3304
|
+
if (this._errorCount) {
|
|
3305
|
+
this._errorCount.textContent = count > 0 ? String(count) : '';
|
|
2550
3306
|
}
|
|
2551
3307
|
}
|
|
2552
3308
|
|
|
2553
3309
|
updatePlaceholderContent() {
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
placeholder.textContent = this.placeholder;
|
|
3310
|
+
if (this._placeholderLayer) {
|
|
3311
|
+
this._placeholderLayer.textContent = this.placeholder;
|
|
2557
3312
|
}
|
|
2558
3313
|
this.updatePlaceholderVisibility();
|
|
2559
3314
|
}
|
|
2560
3315
|
|
|
2561
3316
|
updatePrefixSuffix() {
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
if (prefix) prefix.textContent = this.prefix;
|
|
2566
|
-
if (suffix) suffix.textContent = this.suffix;
|
|
3317
|
+
if (this._editorPrefix) this._editorPrefix.textContent = this.prefix;
|
|
3318
|
+
if (this._editorSuffix) this._editorSuffix.textContent = this.suffix;
|
|
2567
3319
|
}
|
|
2568
3320
|
|
|
2569
3321
|
// ========== Theme ==========
|
|
2570
3322
|
|
|
2571
3323
|
updateThemeCSS() {
|
|
2572
3324
|
const darkSelector = this.getAttribute('dark-selector') || '.dark';
|
|
2573
|
-
const darkRule =
|
|
2574
|
-
|
|
2575
|
-
let themeStyle = this.
|
|
3325
|
+
const darkRule = parseSelectorToHostRule(darkSelector);
|
|
3326
|
+
|
|
3327
|
+
let themeStyle = this._id('theme-styles') as HTMLStyleElement;
|
|
2576
3328
|
if (!themeStyle) {
|
|
2577
|
-
themeStyle =
|
|
3329
|
+
themeStyle = _ce('style') as HTMLStyleElement;
|
|
2578
3330
|
themeStyle.id = 'theme-styles';
|
|
2579
|
-
this.shadowRoot
|
|
3331
|
+
this.shadowRoot!.insertBefore(themeStyle, this.shadowRoot!.firstChild);
|
|
2580
3332
|
}
|
|
2581
3333
|
|
|
2582
3334
|
const darkDefaults = {
|
|
2583
3335
|
bgColor: '#2b2b2b',
|
|
2584
3336
|
textColor: '#a9b7c6',
|
|
2585
|
-
caretColor: '#
|
|
3337
|
+
caretColor: '#bbb',
|
|
2586
3338
|
gutterBg: '#313335',
|
|
2587
3339
|
gutterBorder: '#3c3f41',
|
|
2588
3340
|
gutterText: '#606366',
|
|
@@ -2602,14 +3354,16 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2602
3354
|
jsonKeyInvalid: '#ff6b68'
|
|
2603
3355
|
};
|
|
2604
3356
|
|
|
2605
|
-
|
|
2606
|
-
const
|
|
3357
|
+
RE_TO_KEBAB.lastIndex = 0;
|
|
3358
|
+
const toKebab = (str: string) => str.replace(RE_TO_KEBAB, '-$1').toLowerCase();
|
|
3359
|
+
const generateVars = (obj: Record<string, string | undefined>) => Object.entries(obj)
|
|
3360
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
2607
3361
|
.map(([k, v]) => `--${toKebab(k)}: ${v};`)
|
|
2608
3362
|
.join('\n ');
|
|
2609
3363
|
|
|
2610
|
-
const lightVars = generateVars(this.themes.light || {});
|
|
3364
|
+
const lightVars = generateVars(this.themes.light as Record<string, string | undefined> || {});
|
|
2611
3365
|
const darkTheme = { ...darkDefaults, ...this.themes.dark };
|
|
2612
|
-
const darkVars = generateVars(darkTheme);
|
|
3366
|
+
const darkVars = generateVars(darkTheme as Record<string, string | undefined>);
|
|
2613
3367
|
|
|
2614
3368
|
let css = lightVars ? `:host {\n ${lightVars}\n }\n` : '';
|
|
2615
3369
|
css += `${darkRule} {\n ${darkVars}\n }`;
|
|
@@ -2617,14 +3371,6 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2617
3371
|
themeStyle.textContent = css;
|
|
2618
3372
|
}
|
|
2619
3373
|
|
|
2620
|
-
_parseSelectorToHostRule(selector) {
|
|
2621
|
-
if (!selector) return ':host([data-color-scheme="dark"])';
|
|
2622
|
-
if (selector.startsWith('.') && !selector.includes(' ')) {
|
|
2623
|
-
return `:host(${selector})`;
|
|
2624
|
-
}
|
|
2625
|
-
return `:host-context(${selector})`;
|
|
2626
|
-
}
|
|
2627
|
-
|
|
2628
3374
|
setTheme(theme: ThemeSettings): void {
|
|
2629
3375
|
if (theme.dark) this.themes.dark = { ...this.themes.dark, ...theme.dark };
|
|
2630
3376
|
if (theme.light) this.themes.light = { ...this.themes.light, ...theme.light };
|
|
@@ -2636,45 +3382,15 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2636
3382
|
this.updateThemeCSS();
|
|
2637
3383
|
}
|
|
2638
3384
|
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
_getFeatureKey(feature) {
|
|
2642
|
-
if (!feature) return null;
|
|
2643
|
-
if (feature.id !== undefined) return `id:${feature.id}`;
|
|
2644
|
-
if (feature.properties?.id !== undefined) return `prop:${feature.properties.id}`;
|
|
2645
|
-
|
|
2646
|
-
const geomType = feature.geometry?.type || 'null';
|
|
2647
|
-
const coords = JSON.stringify(feature.geometry?.coordinates || []);
|
|
2648
|
-
let hash = 0;
|
|
2649
|
-
for (let i = 0; i < coords.length; i++) {
|
|
2650
|
-
hash = ((hash << 5) - hash) + coords.charCodeAt(i);
|
|
2651
|
-
hash = hash & hash;
|
|
2652
|
-
}
|
|
2653
|
-
return `hash:${geomType}:${hash.toString(36)}`;
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
_countBrackets(line, openBracket) {
|
|
2657
|
-
const closeBracket = openBracket === '{' ? '}' : ']';
|
|
2658
|
-
let open = 0, close = 0, inString = false, escape = false;
|
|
2659
|
-
|
|
2660
|
-
for (const char of line) {
|
|
2661
|
-
if (escape) { escape = false; continue; }
|
|
2662
|
-
if (char === '\\' && inString) { escape = true; continue; }
|
|
2663
|
-
if (char === '"') { inString = !inString; continue; }
|
|
2664
|
-
if (!inString) {
|
|
2665
|
-
if (char === openBracket) open++;
|
|
2666
|
-
if (char === closeBracket) close++;
|
|
2667
|
-
}
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
return { open, close };
|
|
3385
|
+
getTheme(): ThemeSettings {
|
|
3386
|
+
return { ...this.themes };
|
|
2671
3387
|
}
|
|
2672
3388
|
|
|
2673
3389
|
/**
|
|
2674
3390
|
* Find all collapsible ranges using the mappings built by _rebuildNodeIdMappings
|
|
2675
3391
|
* This method only READS the existing mappings, it doesn't create new IDs
|
|
2676
3392
|
*/
|
|
2677
|
-
_findCollapsibleRanges() {
|
|
3393
|
+
private _findCollapsibleRanges() {
|
|
2678
3394
|
const ranges = [];
|
|
2679
3395
|
|
|
2680
3396
|
// Simply iterate through the existing mappings
|
|
@@ -2686,13 +3402,13 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2686
3402
|
if (!line) continue;
|
|
2687
3403
|
|
|
2688
3404
|
// Match "key": { or "key": [
|
|
2689
|
-
const kvMatch = line.match(
|
|
3405
|
+
const kvMatch = line.match(RE_KV_MATCH);
|
|
2690
3406
|
// Also match standalone { or [ (root Feature objects)
|
|
2691
|
-
const rootMatch = !kvMatch && line.match(
|
|
3407
|
+
const rootMatch = !kvMatch && line.match(RE_ROOT_MATCH);
|
|
2692
3408
|
|
|
2693
3409
|
if (!kvMatch && !rootMatch) continue;
|
|
2694
|
-
|
|
2695
|
-
const openBracket = kvMatch ? kvMatch[2] : rootMatch[1];
|
|
3410
|
+
|
|
3411
|
+
const openBracket = kvMatch ? kvMatch[2] : (rootMatch ? rootMatch[1] : '{');
|
|
2696
3412
|
|
|
2697
3413
|
ranges.push({
|
|
2698
3414
|
startLine: rangeInfo.startLine,
|
|
@@ -2710,20 +3426,20 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2710
3426
|
return ranges;
|
|
2711
3427
|
}
|
|
2712
3428
|
|
|
2713
|
-
_findClosingLine(startLine, openBracket) {
|
|
3429
|
+
private _findClosingLine(startLine: number, openBracket: string): number {
|
|
2714
3430
|
let depth = 1;
|
|
2715
3431
|
const line = this.lines[startLine];
|
|
2716
3432
|
const bracketPos = line.indexOf(openBracket);
|
|
2717
3433
|
|
|
2718
3434
|
if (bracketPos !== -1) {
|
|
2719
3435
|
const rest = line.substring(bracketPos + 1);
|
|
2720
|
-
const counts =
|
|
3436
|
+
const counts = countBrackets(rest, openBracket);
|
|
2721
3437
|
depth += counts.open - counts.close;
|
|
2722
3438
|
if (depth === 0) return startLine;
|
|
2723
3439
|
}
|
|
2724
3440
|
|
|
2725
3441
|
for (let i = startLine + 1; i < this.lines.length; i++) {
|
|
2726
|
-
const counts =
|
|
3442
|
+
const counts = countBrackets(this.lines[i], openBracket);
|
|
2727
3443
|
depth += counts.open - counts.close;
|
|
2728
3444
|
if (depth === 0) return i;
|
|
2729
3445
|
}
|
|
@@ -2731,7 +3447,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2731
3447
|
return -1;
|
|
2732
3448
|
}
|
|
2733
3449
|
|
|
2734
|
-
_buildContextMap() {
|
|
3450
|
+
private _buildContextMap() {
|
|
2735
3451
|
// Memoization: return cached result if content hasn't changed
|
|
2736
3452
|
const linesLength = this.lines.length;
|
|
2737
3453
|
if (this._contextMapCache &&
|
|
@@ -2741,9 +3457,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2741
3457
|
return this._contextMapCache;
|
|
2742
3458
|
}
|
|
2743
3459
|
|
|
2744
|
-
const contextMap = new Map();
|
|
2745
|
-
const contextStack = [];
|
|
2746
|
-
let pendingContext = null;
|
|
3460
|
+
const contextMap = new Map<number, string>();
|
|
3461
|
+
const contextStack: { context: string; isArray: boolean }[] = [];
|
|
3462
|
+
let pendingContext: string | null = null;
|
|
2747
3463
|
|
|
2748
3464
|
for (let i = 0; i < linesLength; i++) {
|
|
2749
3465
|
const line = this.lines[i];
|
|
@@ -2756,10 +3472,14 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2756
3472
|
else if (RE_CONTEXT_FEATURES.test(line)) pendingContext = 'Feature';
|
|
2757
3473
|
|
|
2758
3474
|
// Track brackets
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
3475
|
+
RE_OPEN_BRACES.lastIndex = 0;
|
|
3476
|
+
RE_CLOSE_BRACES.lastIndex = 0;
|
|
3477
|
+
RE_OPEN_BRACKETS.lastIndex = 0;
|
|
3478
|
+
RE_CLOSE_BRACKET.lastIndex = 0;
|
|
3479
|
+
const openBraces = (line.match(RE_OPEN_BRACES) || []).length;
|
|
3480
|
+
const closeBraces = (line.match(RE_CLOSE_BRACES) || []).length;
|
|
3481
|
+
const openBrackets = (line.match(RE_OPEN_BRACKETS) || []).length;
|
|
3482
|
+
const closeBrackets = (line.match(RE_CLOSE_BRACKET) || []).length;
|
|
2763
3483
|
|
|
2764
3484
|
for (let j = 0; j < openBraces + openBrackets; j++) {
|
|
2765
3485
|
contextStack.push({ context: pendingContext || currentContext, isArray: j >= openBraces });
|
|
@@ -2780,214 +3500,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
2780
3500
|
return contextMap;
|
|
2781
3501
|
}
|
|
2782
3502
|
|
|
2783
|
-
|
|
2784
|
-
if (!text) return '';
|
|
2785
|
-
|
|
2786
|
-
// For collapsed nodes, truncate the text at the opening bracket
|
|
2787
|
-
let displayText = text;
|
|
2788
|
-
let collapsedBracket = null;
|
|
2789
|
-
|
|
2790
|
-
if (meta?.collapseButton?.isCollapsed) {
|
|
2791
|
-
// Match "key": { or "key": [
|
|
2792
|
-
const bracketMatch = text.match(RE_COLLAPSED_BRACKET);
|
|
2793
|
-
// Also match standalone { or [ (root Feature objects)
|
|
2794
|
-
const rootMatch = !bracketMatch && text.match(RE_COLLAPSED_ROOT);
|
|
2795
|
-
|
|
2796
|
-
if (bracketMatch) {
|
|
2797
|
-
displayText = bracketMatch[1] + bracketMatch[2];
|
|
2798
|
-
collapsedBracket = bracketMatch[2];
|
|
2799
|
-
} else if (rootMatch) {
|
|
2800
|
-
displayText = rootMatch[1] + rootMatch[2];
|
|
2801
|
-
collapsedBracket = rootMatch[2];
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
// Escape HTML first
|
|
2806
|
-
let result = displayText
|
|
2807
|
-
.replace(RE_ESCAPE_AMP, '&')
|
|
2808
|
-
.replace(RE_ESCAPE_LT, '<')
|
|
2809
|
-
.replace(RE_ESCAPE_GT, '>');
|
|
2810
|
-
|
|
2811
|
-
// Punctuation FIRST (before other replacements can interfere)
|
|
2812
|
-
result = result.replace(RE_PUNCTUATION, '<span class="json-punctuation">$1</span>');
|
|
2813
|
-
|
|
2814
|
-
// JSON keys - match "key" followed by :
|
|
2815
|
-
// In properties context, all keys are treated as regular JSON keys
|
|
2816
|
-
RE_JSON_KEYS.lastIndex = 0;
|
|
2817
|
-
result = result.replace(RE_JSON_KEYS, (match, key, colon) => {
|
|
2818
|
-
if (context !== 'properties' && GEOJSON_KEYS.includes(key)) {
|
|
2819
|
-
return `<span class="geojson-key">"${key}"</span>${colon}`;
|
|
2820
|
-
}
|
|
2821
|
-
return `<span class="json-key">"${key}"</span>${colon}`;
|
|
2822
|
-
});
|
|
2823
|
-
|
|
2824
|
-
// Type values - "type": "Value" - but NOT inside properties context
|
|
2825
|
-
if (context !== 'properties') {
|
|
2826
|
-
RE_TYPE_VALUES.lastIndex = 0;
|
|
2827
|
-
result = result.replace(RE_TYPE_VALUES, (match, space, type) => {
|
|
2828
|
-
const isValid = type === 'Feature' || type === 'FeatureCollection' || GEOMETRY_TYPES.includes(type);
|
|
2829
|
-
const cls = isValid ? 'geojson-type' : 'geojson-type-invalid';
|
|
2830
|
-
return `<span class="geojson-key">"type"</span><span class="json-punctuation">:</span>${space}<span class="${cls}">"${type}"</span>`;
|
|
2831
|
-
});
|
|
2832
|
-
}
|
|
2833
|
-
|
|
2834
|
-
// String values (not already wrapped in spans)
|
|
2835
|
-
RE_STRING_VALUES.lastIndex = 0;
|
|
2836
|
-
result = result.replace(RE_STRING_VALUES, (match, colon, space, val) => {
|
|
2837
|
-
if (match.includes('geojson-type') || match.includes('json-string')) return match;
|
|
2838
|
-
if (RE_COLOR_HEX.test(val)) {
|
|
2839
|
-
return `${colon}${space}<span class="json-string json-color" data-color="${val}" style="--swatch-color: ${val}">"${val}"</span>`;
|
|
2840
|
-
}
|
|
2841
|
-
return `${colon}${space}<span class="json-string">"${val}"</span>`;
|
|
2842
|
-
});
|
|
2843
|
-
|
|
2844
|
-
// Numbers after colon
|
|
2845
|
-
RE_NUMBERS_COLON.lastIndex = 0;
|
|
2846
|
-
result = result.replace(RE_NUMBERS_COLON, '$1$2<span class="json-number">$3</span>');
|
|
2847
|
-
|
|
2848
|
-
// Numbers in arrays (after [ or ,)
|
|
2849
|
-
RE_NUMBERS_ARRAY.lastIndex = 0;
|
|
2850
|
-
result = result.replace(RE_NUMBERS_ARRAY, '$1$2<span class="json-number">$3</span>');
|
|
2851
|
-
|
|
2852
|
-
// Standalone numbers at start of line (coordinates arrays)
|
|
2853
|
-
RE_NUMBERS_START.lastIndex = 0;
|
|
2854
|
-
result = result.replace(RE_NUMBERS_START, '$1<span class="json-number">$2</span>');
|
|
2855
|
-
|
|
2856
|
-
// Booleans - use ::before for checkbox via CSS class
|
|
2857
|
-
RE_BOOLEANS.lastIndex = 0;
|
|
2858
|
-
result = result.replace(RE_BOOLEANS, (match, colon, space, val) => {
|
|
2859
|
-
const checkedClass = val === 'true' ? ' json-bool-true' : ' json-bool-false';
|
|
2860
|
-
return `${colon}${space}<span class="json-boolean${checkedClass}">${val}</span>`;
|
|
2861
|
-
});
|
|
2862
|
-
|
|
2863
|
-
// Null
|
|
2864
|
-
RE_NULL.lastIndex = 0;
|
|
2865
|
-
result = result.replace(RE_NULL, '$1$2<span class="json-null">$3</span>');
|
|
2866
|
-
|
|
2867
|
-
// Collapsed bracket indicator
|
|
2868
|
-
if (collapsedBracket) {
|
|
2869
|
-
const bracketClass = collapsedBracket === '[' ? 'collapsed-bracket-array' : 'collapsed-bracket-object';
|
|
2870
|
-
result = result.replace(
|
|
2871
|
-
new RegExp(`<span class="json-punctuation">\\${collapsedBracket}<\\/span>$`),
|
|
2872
|
-
`<span class="${bracketClass}">${collapsedBracket}</span>`
|
|
2873
|
-
);
|
|
2874
|
-
}
|
|
2875
|
-
|
|
2876
|
-
// Mark unrecognized text as error
|
|
2877
|
-
RE_UNRECOGNIZED.lastIndex = 0;
|
|
2878
|
-
result = result.replace(RE_UNRECOGNIZED, (match, before, text, after) => {
|
|
2879
|
-
if (!text || RE_WHITESPACE_ONLY.test(text)) return match;
|
|
2880
|
-
// Check for unrecognized words/tokens (not whitespace, not just spaces/commas)
|
|
2881
|
-
// Keep whitespace as-is, wrap any non-whitespace unrecognized token
|
|
2882
|
-
const parts = text.split(RE_WHITESPACE_SPLIT);
|
|
2883
|
-
let hasError = false;
|
|
2884
|
-
const processed = parts.map(part => {
|
|
2885
|
-
// If it's whitespace, keep it
|
|
2886
|
-
if (RE_WHITESPACE_ONLY.test(part)) return part;
|
|
2887
|
-
// Mark as error
|
|
2888
|
-
hasError = true;
|
|
2889
|
-
return `<span class="json-error">${part}</span>`;
|
|
2890
|
-
}).join('');
|
|
2891
|
-
return hasError ? before + processed + after : match;
|
|
2892
|
-
});
|
|
2893
|
-
|
|
2894
|
-
// Note: visibility is now handled at line level (has-visibility class on .line element)
|
|
2895
|
-
|
|
2896
|
-
return result;
|
|
2897
|
-
}
|
|
2898
|
-
|
|
2899
|
-
_validateGeoJSON(parsed) {
|
|
2900
|
-
const errors = [];
|
|
2901
|
-
|
|
2902
|
-
if (!parsed.features) return errors;
|
|
2903
|
-
|
|
2904
|
-
parsed.features.forEach((feature, i) => {
|
|
2905
|
-
if (feature.type !== 'Feature') {
|
|
2906
|
-
errors.push(`features[${i}]: type must be "Feature"`);
|
|
2907
|
-
}
|
|
2908
|
-
if (feature.geometry && feature.geometry.type) {
|
|
2909
|
-
if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
|
|
2910
|
-
errors.push(`features[${i}].geometry: invalid type "${feature.geometry.type}"`);
|
|
2911
|
-
}
|
|
2912
|
-
}
|
|
2913
|
-
});
|
|
2914
|
-
|
|
2915
|
-
return errors;
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
/**
|
|
2919
|
-
* Validate a single feature object
|
|
2920
|
-
* @param {object} feature - The feature to validate
|
|
2921
|
-
* @throws {Error} If the feature is invalid
|
|
2922
|
-
*/
|
|
2923
|
-
_validateFeature(feature) {
|
|
2924
|
-
if (!feature || typeof feature !== 'object') {
|
|
2925
|
-
throw new Error('Feature must be an object');
|
|
2926
|
-
}
|
|
2927
|
-
if (feature.type !== 'Feature') {
|
|
2928
|
-
throw new Error('Feature type must be "Feature"');
|
|
2929
|
-
}
|
|
2930
|
-
if (!('geometry' in feature)) {
|
|
2931
|
-
throw new Error('Feature must have a geometry property');
|
|
2932
|
-
}
|
|
2933
|
-
if (!('properties' in feature)) {
|
|
2934
|
-
throw new Error('Feature must have a properties property');
|
|
2935
|
-
}
|
|
2936
|
-
if (feature.geometry !== null) {
|
|
2937
|
-
if (!feature.geometry || typeof feature.geometry !== 'object') {
|
|
2938
|
-
throw new Error('Feature geometry must be an object or null');
|
|
2939
|
-
}
|
|
2940
|
-
if (!feature.geometry.type) {
|
|
2941
|
-
throw new Error('Feature geometry must have a type');
|
|
2942
|
-
}
|
|
2943
|
-
if (!GEOMETRY_TYPES.includes(feature.geometry.type)) {
|
|
2944
|
-
throw new Error(`Invalid geometry type: "${feature.geometry.type}"`);
|
|
2945
|
-
}
|
|
2946
|
-
if (!('coordinates' in feature.geometry)) {
|
|
2947
|
-
throw new Error('Feature geometry must have coordinates');
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
if (feature.properties !== null && typeof feature.properties !== 'object') {
|
|
2951
|
-
throw new Error('Feature properties must be an object or null');
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
|
-
/**
|
|
2956
|
-
* Normalize input to an array of features
|
|
2957
|
-
* Accepts: FeatureCollection, Feature[], or single Feature
|
|
2958
|
-
* @param {object|array} input - Input to normalize
|
|
2959
|
-
* @returns {array} Array of features
|
|
2960
|
-
* @throws {Error} If input is invalid
|
|
2961
|
-
*/
|
|
2962
|
-
_normalizeToFeatures(input) {
|
|
2963
|
-
let features = [];
|
|
2964
|
-
|
|
2965
|
-
if (Array.isArray(input)) {
|
|
2966
|
-
// Array of features
|
|
2967
|
-
features = input;
|
|
2968
|
-
} else if (input && typeof input === 'object') {
|
|
2969
|
-
if (input.type === 'FeatureCollection' && Array.isArray(input.features)) {
|
|
2970
|
-
// FeatureCollection
|
|
2971
|
-
features = input.features;
|
|
2972
|
-
} else if (input.type === 'Feature') {
|
|
2973
|
-
// Single Feature
|
|
2974
|
-
features = [input];
|
|
2975
|
-
} else {
|
|
2976
|
-
throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
|
|
2977
|
-
}
|
|
2978
|
-
} else {
|
|
2979
|
-
throw new Error('Input must be a Feature, array of Features, or FeatureCollection');
|
|
2980
|
-
}
|
|
2981
|
-
|
|
2982
|
-
// Validate each feature
|
|
2983
|
-
for (const feature of features) {
|
|
2984
|
-
this._validateFeature(feature);
|
|
2985
|
-
}
|
|
2986
|
-
|
|
2987
|
-
return features;
|
|
2988
|
-
}
|
|
2989
|
-
|
|
2990
|
-
// ========== Public API ==========
|
|
3503
|
+
// ========== Public API ==========
|
|
2991
3504
|
|
|
2992
3505
|
/**
|
|
2993
3506
|
* Replace all features in the editor
|
|
@@ -3001,10 +3514,8 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
3001
3514
|
* @throws {Error} If input is invalid
|
|
3002
3515
|
*/
|
|
3003
3516
|
set(input: FeatureInput, options: SetOptions = {}): void {
|
|
3004
|
-
const features =
|
|
3005
|
-
|
|
3006
|
-
this.setValue(formatted, false); // Don't auto-collapse coordinates
|
|
3007
|
-
this._applyCollapsedFromOptions(options, features);
|
|
3517
|
+
const features = normalizeToFeatures(input);
|
|
3518
|
+
this._setFeaturesInternal(features, options);
|
|
3008
3519
|
}
|
|
3009
3520
|
|
|
3010
3521
|
/**
|
|
@@ -3016,12 +3527,9 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
3016
3527
|
* @throws {Error} If input is invalid
|
|
3017
3528
|
*/
|
|
3018
3529
|
add(input: FeatureInput, options: SetOptions = {}): void {
|
|
3019
|
-
const newFeatures =
|
|
3020
|
-
const
|
|
3021
|
-
|
|
3022
|
-
const formatted = allFeatures.map(f => JSON.stringify(f, null, 2)).join(',\n');
|
|
3023
|
-
this.setValue(formatted, false); // Don't auto-collapse coordinates
|
|
3024
|
-
this._applyCollapsedFromOptions(options, allFeatures);
|
|
3530
|
+
const newFeatures = normalizeToFeatures(input);
|
|
3531
|
+
const allFeatures = [...this._parseFeatures(), ...newFeatures];
|
|
3532
|
+
this._setFeaturesInternal(allFeatures, options);
|
|
3025
3533
|
}
|
|
3026
3534
|
|
|
3027
3535
|
/**
|
|
@@ -3034,12 +3542,19 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
3034
3542
|
* @throws {Error} If input is invalid
|
|
3035
3543
|
*/
|
|
3036
3544
|
insertAt(input: FeatureInput, index: number, options: SetOptions = {}): void {
|
|
3037
|
-
const newFeatures =
|
|
3545
|
+
const newFeatures = normalizeToFeatures(input);
|
|
3038
3546
|
const features = this._parseFeatures();
|
|
3039
3547
|
const idx = index < 0 ? features.length + index : index;
|
|
3040
3548
|
features.splice(Math.max(0, Math.min(idx, features.length)), 0, ...newFeatures);
|
|
3549
|
+
this._setFeaturesInternal(features, options);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
/**
|
|
3553
|
+
* Internal method to set features with formatting and collapse options
|
|
3554
|
+
*/
|
|
3555
|
+
private _setFeaturesInternal(features: Feature[], options: SetOptions): void {
|
|
3041
3556
|
const formatted = features.map(f => JSON.stringify(f, null, 2)).join(',\n');
|
|
3042
|
-
this.setValue(formatted, false);
|
|
3557
|
+
this.setValue(formatted, false);
|
|
3043
3558
|
this._applyCollapsedFromOptions(options, features);
|
|
3044
3559
|
}
|
|
3045
3560
|
|
|
@@ -3097,7 +3612,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
3097
3612
|
const blob = new Blob([json], { type: 'application/geo+json' });
|
|
3098
3613
|
const url = URL.createObjectURL(blob);
|
|
3099
3614
|
|
|
3100
|
-
const a =
|
|
3615
|
+
const a = _ce('a') as HTMLAnchorElement;
|
|
3101
3616
|
a.href = url;
|
|
3102
3617
|
a.download = filename;
|
|
3103
3618
|
document.body.appendChild(a);
|
|
@@ -3120,12 +3635,12 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
3120
3635
|
*/
|
|
3121
3636
|
open(options: SetOptions = {}): Promise<boolean> {
|
|
3122
3637
|
return new Promise((resolve) => {
|
|
3123
|
-
const input =
|
|
3638
|
+
const input = _ce('input') as HTMLInputElement;
|
|
3124
3639
|
input.type = 'file';
|
|
3125
3640
|
input.accept = '.geojson,.json,application/geo+json,application/json';
|
|
3126
3641
|
input.style.display = 'none';
|
|
3127
3642
|
|
|
3128
|
-
input.addEventListener('change', (e
|
|
3643
|
+
input.addEventListener('change', (e) => {
|
|
3129
3644
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
3130
3645
|
if (!file) {
|
|
3131
3646
|
document.body.removeChild(input);
|
|
@@ -3140,7 +3655,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
3140
3655
|
const parsed = JSON.parse(content);
|
|
3141
3656
|
|
|
3142
3657
|
// Normalize and validate features
|
|
3143
|
-
const features =
|
|
3658
|
+
const features = normalizeToFeatures(parsed);
|
|
3144
3659
|
|
|
3145
3660
|
// Load features into editor
|
|
3146
3661
|
this._saveToHistory('open');
|
|
@@ -3173,7 +3688,7 @@ class GeoJsonEditor extends HTMLElement {
|
|
|
3173
3688
|
});
|
|
3174
3689
|
}
|
|
3175
3690
|
|
|
3176
|
-
_parseFeatures() {
|
|
3691
|
+
private _parseFeatures() {
|
|
3177
3692
|
try {
|
|
3178
3693
|
const content = this.lines.join('\n');
|
|
3179
3694
|
if (!content.trim()) return [];
|