skir-codemirror-plugin 0.9.0

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.
Files changed (72) hide show
  1. package/README.md +126 -0
  2. package/dist/codemirror/create_editor_state.d.ts +20 -0
  3. package/dist/codemirror/create_editor_state.d.ts.map +1 -0
  4. package/dist/codemirror/create_editor_state.js +252 -0
  5. package/dist/codemirror/create_editor_state.js.map +1 -0
  6. package/dist/codemirror/enter_key_handler.d.ts +8 -0
  7. package/dist/codemirror/enter_key_handler.d.ts.map +1 -0
  8. package/dist/codemirror/enter_key_handler.js +181 -0
  9. package/dist/codemirror/enter_key_handler.js.map +1 -0
  10. package/dist/codemirror/json_completion.d.ts +4 -0
  11. package/dist/codemirror/json_completion.d.ts.map +1 -0
  12. package/dist/codemirror/json_completion.js +150 -0
  13. package/dist/codemirror/json_completion.js.map +1 -0
  14. package/dist/codemirror/json_linter.d.ts +4 -0
  15. package/dist/codemirror/json_linter.d.ts.map +1 -0
  16. package/dist/codemirror/json_linter.js +277 -0
  17. package/dist/codemirror/json_linter.js.map +1 -0
  18. package/dist/codemirror/json_state.d.ts +16 -0
  19. package/dist/codemirror/json_state.d.ts.map +1 -0
  20. package/dist/codemirror/json_state.js +124 -0
  21. package/dist/codemirror/json_state.js.map +1 -0
  22. package/dist/codemirror/status_bar.d.ts +3 -0
  23. package/dist/codemirror/status_bar.d.ts.map +1 -0
  24. package/dist/codemirror/status_bar.js +123 -0
  25. package/dist/codemirror/status_bar.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +2 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/json/json_parser.d.ts +3 -0
  31. package/dist/json/json_parser.d.ts.map +1 -0
  32. package/dist/json/json_parser.js +414 -0
  33. package/dist/json/json_parser.js.map +1 -0
  34. package/dist/json/json_parser.test.d.ts +2 -0
  35. package/dist/json/json_parser.test.d.ts.map +1 -0
  36. package/dist/json/json_parser.test.js +337 -0
  37. package/dist/json/json_parser.test.js.map +1 -0
  38. package/dist/json/schema_validator.d.ts +3 -0
  39. package/dist/json/schema_validator.d.ts.map +1 -0
  40. package/dist/json/schema_validator.js +525 -0
  41. package/dist/json/schema_validator.js.map +1 -0
  42. package/dist/json/schema_validator.test.d.ts +2 -0
  43. package/dist/json/schema_validator.test.d.ts.map +1 -0
  44. package/dist/json/schema_validator.test.js +212 -0
  45. package/dist/json/schema_validator.test.js.map +1 -0
  46. package/dist/json/to_json.d.ts +6 -0
  47. package/dist/json/to_json.d.ts.map +1 -0
  48. package/dist/json/to_json.js +61 -0
  49. package/dist/json/to_json.js.map +1 -0
  50. package/dist/json/to_json.test.d.ts +2 -0
  51. package/dist/json/to_json.test.d.ts.map +1 -0
  52. package/dist/json/to_json.test.js +128 -0
  53. package/dist/json/to_json.test.js.map +1 -0
  54. package/dist/json/types.d.ts +170 -0
  55. package/dist/json/types.d.ts.map +1 -0
  56. package/dist/json/types.js +2 -0
  57. package/dist/json/types.js.map +1 -0
  58. package/package.json +85 -0
  59. package/src/codemirror/create_editor_state.ts +278 -0
  60. package/src/codemirror/enter_key_handler.ts +232 -0
  61. package/src/codemirror/json_completion.ts +182 -0
  62. package/src/codemirror/json_linter.ts +358 -0
  63. package/src/codemirror/json_state.ts +170 -0
  64. package/src/codemirror/status_bar.ts +137 -0
  65. package/src/index.ts +6 -0
  66. package/src/json/json_parser.test.ts +360 -0
  67. package/src/json/json_parser.ts +461 -0
  68. package/src/json/schema_validator.test.ts +230 -0
  69. package/src/json/schema_validator.ts +558 -0
  70. package/src/json/to_json.test.ts +150 -0
  71. package/src/json/to_json.ts +70 -0
  72. package/src/json/types.ts +254 -0
@@ -0,0 +1,182 @@
1
+ import { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
2
+ import {
3
+ JsonObject,
4
+ JsonValue,
5
+ RecordDefinition,
6
+ Segment,
7
+ StructDefinition,
8
+ TypeDefinition,
9
+ } from "../json/types";
10
+ import { ensureJsonState, jsonStateField } from "./json_state";
11
+
12
+ export function jsonCompletion(
13
+ schema: TypeDefinition,
14
+ ): (context: CompletionContext) => CompletionResult | null {
15
+ // Index record definitions by record id.
16
+ const idToRecordDef: { [id: string]: RecordDefinition } = {};
17
+ for (const record of schema.records) {
18
+ idToRecordDef[record.id] = record;
19
+ }
20
+
21
+ function doCompleteJson(
22
+ jsonValue: JsonValue,
23
+ position: number,
24
+ ): CompletionResult | null {
25
+ if (!inSegment(position, jsonValue.segment)) {
26
+ return null;
27
+ }
28
+ const { expectedType } = jsonValue;
29
+ if (!expectedType) {
30
+ return null;
31
+ }
32
+ const actualType =
33
+ expectedType.kind === "optional" ? expectedType.value : expectedType;
34
+ void actualType;
35
+ switch (jsonValue.kind) {
36
+ case "array": {
37
+ if (expectedType.kind !== "array") {
38
+ return null;
39
+ }
40
+ for (const element of jsonValue.values) {
41
+ if (position < element.firstToken.start) {
42
+ return null; // No need to continue (optimization)
43
+ }
44
+ const maybeResult = doCompleteJson(element, position);
45
+ if (maybeResult) {
46
+ return maybeResult;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+ case "object": {
52
+ if (expectedType.kind !== "record") {
53
+ return null;
54
+ }
55
+ const recordDef = idToRecordDef[expectedType.value];
56
+ if (recordDef.kind === "struct") {
57
+ for (const key of jsonValue.allKeys) {
58
+ // First, see if the current position is within the key.
59
+ const { keySegment } = key;
60
+ if (inSegment(position, keySegment)) {
61
+ const missingFieldNames = collectMissingFieldNames(
62
+ jsonValue,
63
+ recordDef,
64
+ );
65
+ return {
66
+ from: keySegment.start + 1,
67
+ to: keySegment.end - 1,
68
+ options: missingFieldNames.map((name) => ({
69
+ label: name,
70
+ })),
71
+ };
72
+ }
73
+ // Then, check the value.
74
+ const keyValue = jsonValue.keyValues[key.key];
75
+ if (keyValue) {
76
+ const maybeResult = doCompleteJson(keyValue.value, position);
77
+ if (maybeResult) {
78
+ return maybeResult;
79
+ }
80
+ }
81
+ }
82
+ } else {
83
+ const kindKv = jsonValue.keyValues["kind"];
84
+ if (
85
+ kindKv &&
86
+ inSegment(position, kindKv.value.firstToken) &&
87
+ kindKv.value.kind === "literal" &&
88
+ kindKv.value.type === "string"
89
+ ) {
90
+ const options = recordDef.variants
91
+ .filter((v) => v.type)
92
+ .map((v) => ({
93
+ label: v.name,
94
+ }));
95
+ return {
96
+ from: kindKv.value.firstToken.start + 1,
97
+ to: kindKv.value.firstToken.end - 1,
98
+ options: options,
99
+ };
100
+ }
101
+ const valueKv = jsonValue.keyValues["value"];
102
+ if (valueKv) {
103
+ const maybeResult = doCompleteJson(valueKv?.value, position);
104
+ if (maybeResult) {
105
+ return maybeResult;
106
+ }
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+ case "literal": {
112
+ if (expectedType.kind !== "record") {
113
+ return null;
114
+ }
115
+ const recordDef = idToRecordDef[expectedType.value];
116
+ if (recordDef.kind !== "enum") {
117
+ return null;
118
+ }
119
+ const options = recordDef.variants
120
+ .filter((v) => !v.type)
121
+ .map((v) => ({
122
+ label: v.name,
123
+ }))
124
+ .concat({
125
+ label: "UNKNOWN",
126
+ });
127
+ return {
128
+ from: jsonValue.firstToken.start + 1,
129
+ to: jsonValue.firstToken.end - 1,
130
+ options: options,
131
+ };
132
+ }
133
+ }
134
+ }
135
+
136
+ function completeJson(context: CompletionContext): CompletionResult | null {
137
+ const position = context.pos;
138
+
139
+ // Ensure JSON state is up-to-date
140
+ if (!context.view) {
141
+ return null;
142
+ }
143
+
144
+ // Check if character before cursor is a quote
145
+ const charBeforeCursor =
146
+ position > 0 ? context.state.doc.sliceString(position - 1, position) : "";
147
+ const shouldEnsureState = context.explicit || charBeforeCursor === '"';
148
+
149
+ const jsonState = shouldEnsureState
150
+ ? ensureJsonState(context.view, schema)
151
+ : context.state.field(jsonStateField, false);
152
+
153
+ if (!jsonState) {
154
+ return null;
155
+ }
156
+ const parseResult = jsonState.parseResult;
157
+ if (!parseResult.value) {
158
+ return null;
159
+ }
160
+ return doCompleteJson(parseResult.value, position);
161
+ }
162
+
163
+ return completeJson;
164
+ }
165
+
166
+ function inSegment(position: number, segment: Segment): boolean {
167
+ return position >= segment.start && position < segment.end;
168
+ }
169
+
170
+ function collectMissingFieldNames(
171
+ object: JsonObject,
172
+ recordDef: StructDefinition,
173
+ ): string[] {
174
+ const result: string[] = [];
175
+ for (const field of recordDef.fields) {
176
+ if (object.keyValues[field.name]) {
177
+ continue;
178
+ }
179
+ result.push(field.name);
180
+ }
181
+ return result;
182
+ }
@@ -0,0 +1,358 @@
1
+ import { Diagnostic } from "@codemirror/lint";
2
+ import { EditorView } from "@codemirror/view";
3
+ import { Hint, JsonError, JsonLiteral, TypeHint } from "../json/types";
4
+ import { ensureJsonState, jsonStateField } from "./json_state";
5
+
6
+ export function jsonLinter(
7
+ editable: "editable" | "read-only",
8
+ ): (view: EditorView) => Diagnostic[] {
9
+ function lintJson(view: EditorView): Diagnostic[] {
10
+ const jsonState = view.state.field(jsonStateField, false);
11
+ if (!jsonState) {
12
+ return [];
13
+ }
14
+
15
+ const parseResult = jsonState.parseResult;
16
+ if (parseResult.errors.length) {
17
+ return parseResult.errors.map(errorToDiagnostic);
18
+ }
19
+
20
+ if (!jsonState.validationResult) {
21
+ return [];
22
+ }
23
+
24
+ const { errors, hints } = jsonState.validationResult;
25
+ return errors
26
+ .map(errorToDiagnostic)
27
+ .concat(hints.map((hint) => hintToDiagnostic(hint, editable)));
28
+ }
29
+
30
+ return lintJson;
31
+ }
32
+
33
+ function errorToDiagnostic(error: JsonError): Diagnostic {
34
+ return {
35
+ from: error.segment.start,
36
+ to: error.segment.end,
37
+ message: error.message,
38
+ severity: "error",
39
+ renderMessage: (): Node => {
40
+ const wrapper = document.createElement("div");
41
+ wrapper.className = "cm-diagnostic-wrapper";
42
+ wrapper.textContent = error.message;
43
+ return wrapper;
44
+ },
45
+ };
46
+ }
47
+
48
+ function getStringControlRows(
49
+ view: EditorView,
50
+ typeHint: TypeHint,
51
+ value: JsonLiteral,
52
+ editable: "editable" | "read-only",
53
+ ): HTMLDivElement[] {
54
+ const headerRow = document.createElement("div");
55
+ headerRow.textContent = typeHint.message as string;
56
+
57
+ const jsonCode = value.jsonCode;
58
+ const parsedValue = JSON.parse(jsonCode) as string;
59
+
60
+ const controlsRow = document.createElement("div");
61
+ controlsRow.className = "cm-diagnostic-controls";
62
+
63
+ const label = document.createElement("span");
64
+ // label.className = "cm-diagnostic-label";
65
+ label.textContent = "Value:";
66
+
67
+ const textarea = document.createElement("textarea");
68
+ textarea.className = "cm-diagnostic-textarea";
69
+ textarea.value = parsedValue;
70
+ textarea.rows = 1;
71
+ const isReadOnly = editable === "read-only";
72
+ textarea.readOnly = isReadOnly;
73
+
74
+ textarea.addEventListener("keydown", (e) => {
75
+ if (e.key === "Enter" && !e.shiftKey && !isReadOnly) {
76
+ e.preventDefault();
77
+ const newValue = textarea.value;
78
+ const newJsonString = JSON.stringify(newValue);
79
+
80
+ view.dispatch({
81
+ changes: {
82
+ from: typeHint.segment.start,
83
+ to: typeHint.segment.end,
84
+ insert: newJsonString,
85
+ },
86
+ });
87
+ }
88
+ });
89
+
90
+ controlsRow.appendChild(label);
91
+ controlsRow.appendChild(textarea);
92
+
93
+ return [headerRow, controlsRow];
94
+ }
95
+
96
+ function getTimestampControlRows(
97
+ view: EditorView,
98
+ typeHint: TypeHint,
99
+ ): HTMLDivElement[] {
100
+ const headerRow = document.createElement("div");
101
+ headerRow.textContent = typeHint.message as string;
102
+
103
+ let unixMillis: number | undefined;
104
+ {
105
+ const value = typeHint.valueContext!.value;
106
+ if (value.kind === "object") {
107
+ const unixMillisKv = value.keyValues["unix_millis"];
108
+ if (
109
+ unixMillisKv &&
110
+ unixMillisKv.value.kind === "literal" &&
111
+ unixMillisKv.value.type === "number"
112
+ ) {
113
+ unixMillis = JSON.parse(unixMillisKv.value.jsonCode) as number;
114
+ if (unixMillis < -8640000000000000 || 8640000000000000 < unixMillis) {
115
+ unixMillis = undefined;
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Row 1: unix_millis input
122
+ const row1 = document.createElement("div");
123
+ row1.className = "cm-diagnostic-controls";
124
+
125
+ const label1 = document.createElement("span");
126
+ label1.className = "cm-diagnostic-label";
127
+ label1.textContent = "unix_millis:";
128
+
129
+ const field1 = document.createElement("div");
130
+ field1.className = "cm-timestamp-field";
131
+
132
+ const unixMillisInput = document.createElement("input");
133
+ unixMillisInput.type = "text";
134
+ unixMillisInput.className = "cm-diagnostic-input";
135
+ unixMillisInput.value = unixMillis !== undefined ? String(unixMillis) : "";
136
+
137
+ const error1 = document.createElement("div");
138
+ error1.className = "cm-diagnostic-error-message";
139
+ error1.style.display = "none";
140
+
141
+ field1.appendChild(unixMillisInput);
142
+ field1.appendChild(error1);
143
+ row1.appendChild(label1);
144
+ row1.appendChild(field1);
145
+
146
+ // Row 2: ISO 8601 date string input
147
+ const row2 = document.createElement("div");
148
+ row2.className = "cm-diagnostic-controls";
149
+
150
+ const label2 = document.createElement("span");
151
+ label2.className = "cm-diagnostic-label";
152
+ label2.textContent = "ISO 8601:";
153
+
154
+ const field2 = document.createElement("div");
155
+ field2.className = "cm-timestamp-field";
156
+
157
+ const isoInput = document.createElement("input");
158
+ isoInput.type = "text";
159
+ isoInput.className = "cm-diagnostic-input";
160
+ if (unixMillis !== undefined) {
161
+ isoInput.value = new Date(unixMillis).toISOString();
162
+ }
163
+
164
+ const error2 = document.createElement("div");
165
+ error2.className = "cm-diagnostic-error-message";
166
+ error2.style.display = "none";
167
+
168
+ field2.appendChild(isoInput);
169
+ field2.appendChild(error2);
170
+ row2.appendChild(label2);
171
+ row2.appendChild(field2);
172
+
173
+ // Row 3: Now button
174
+ const row3 = document.createElement("div");
175
+ row3.className = "cm-diagnostic-controls";
176
+
177
+ const nowButton = document.createElement("button");
178
+ nowButton.className = "cm-diagnostic-button";
179
+ nowButton.textContent = "Now";
180
+
181
+ row3.appendChild(nowButton);
182
+
183
+ // Helper function to update the editor
184
+ const updateEditor = (millis: number, formatted: string): void => {
185
+ const value = typeHint.valueContext!.value;
186
+
187
+ // Check if both keys exist in the object
188
+ if (value.kind === "object") {
189
+ const unixMillisKv = value.keyValues["unix_millis"];
190
+ const formattedKv = value.keyValues["formatted"];
191
+
192
+ if (unixMillisKv && formattedKv) {
193
+ // Update both values individually
194
+ view.dispatch({
195
+ changes: [
196
+ {
197
+ from: unixMillisKv.value.segment.start,
198
+ to: unixMillisKv.value.segment.end,
199
+ insert: String(millis),
200
+ },
201
+ {
202
+ from: formattedKv.value.segment.start,
203
+ to: formattedKv.value.segment.end,
204
+ insert: JSON.stringify(formatted),
205
+ },
206
+ ],
207
+ });
208
+
209
+ return;
210
+ }
211
+ }
212
+
213
+ // Default behavior: replace the whole object
214
+ const newJsonString = `{"unix_millis": ${millis}, "formatted": "${formatted}"}`;
215
+
216
+ const segment = typeHint.valueContext!.value.segment;
217
+ view.dispatch({
218
+ changes: {
219
+ from: segment.start,
220
+ to: segment.end,
221
+ insert: newJsonString,
222
+ },
223
+ });
224
+
225
+ ensureJsonState(view, (view as any).schema);
226
+ };
227
+
228
+ // Enter key handler for unix_millis input
229
+ unixMillisInput.addEventListener("keydown", (e) => {
230
+ if (e.key === "Enter") {
231
+ e.preventDefault();
232
+ const value = unixMillisInput.value.trim();
233
+ const millis = Number(value);
234
+
235
+ // Validate
236
+ if (value === "" || isNaN(millis) || !Number.isInteger(millis)) {
237
+ error1.textContent = "Must be an integer";
238
+ error1.style.display = "block";
239
+ return;
240
+ }
241
+
242
+ if (millis < -8640000000000000 || millis > 8640000000000000) {
243
+ error1.textContent = "Timestamp out of range";
244
+ error1.style.display = "block";
245
+ return;
246
+ }
247
+
248
+ error1.style.display = "none";
249
+ error2.style.display = "none";
250
+
251
+ // Update ISO field and editor
252
+ const formatted = new Date(millis).toISOString();
253
+ isoInput.value = formatted;
254
+ updateEditor(millis, formatted);
255
+ }
256
+ });
257
+
258
+ // Enter key handler for ISO 8601 input
259
+ isoInput.addEventListener("keydown", (e) => {
260
+ if (e.key === "Enter") {
261
+ e.preventDefault();
262
+ const value = isoInput.value.trim();
263
+ const millis = Date.parse(value);
264
+
265
+ // Validate
266
+ if (isNaN(millis)) {
267
+ error2.textContent = "Invalid ISO 8601 date string";
268
+ error2.style.display = "block";
269
+ return;
270
+ }
271
+
272
+ error1.style.display = "none";
273
+ error2.style.display = "none";
274
+
275
+ // Update unix_millis field and editor
276
+ const formatted = new Date(millis).toISOString();
277
+ unixMillisInput.value = String(millis);
278
+ updateEditor(millis, formatted);
279
+ }
280
+ });
281
+
282
+ // Now button handler
283
+ nowButton.addEventListener("click", () => {
284
+ const now = Date.now();
285
+ const formatted = new Date(now).toISOString();
286
+
287
+ unixMillisInput.value = String(now);
288
+ isoInput.value = formatted;
289
+ error1.style.display = "none";
290
+ error2.style.display = "none";
291
+
292
+ updateEditor(now, formatted);
293
+ });
294
+
295
+ const controlsRow = document.createElement("div");
296
+ controlsRow.appendChild(row1);
297
+ controlsRow.appendChild(row2);
298
+ controlsRow.appendChild(row3);
299
+
300
+ return [headerRow, controlsRow];
301
+ }
302
+
303
+ function hintToDiagnostic(
304
+ typeHint: Hint,
305
+ editable: "editable" | "read-only",
306
+ ): Diagnostic {
307
+ const { message } = typeHint;
308
+ return {
309
+ from: typeHint.segment.start,
310
+ to: typeHint.segment.end,
311
+ message: "",
312
+ severity: "info",
313
+ renderMessage: (view): Node => {
314
+ const wrapper = document.createElement("div");
315
+ wrapper.className = "cm-diagnostic-wrapper";
316
+
317
+ let rows: HTMLDivElement[];
318
+ if (
319
+ (message === "string" || message === "string?") &&
320
+ typeHint.valueContext &&
321
+ typeHint.valueContext.value.kind === "literal" &&
322
+ typeHint.valueContext.value.type === "string"
323
+ ) {
324
+ // Render a string editing control for string hints.
325
+ rows = getStringControlRows(
326
+ view,
327
+ typeHint,
328
+ typeHint.valueContext.value,
329
+ editable,
330
+ );
331
+ } else if (
332
+ (message === "timestamp" || message === "timestamp?") &&
333
+ typeHint.valueContext &&
334
+ typeHint.valueContext.value.kind === "object" &&
335
+ editable === "editable"
336
+ ) {
337
+ // Render a timestamp editing control for timestamp hints.
338
+ rows = getTimestampControlRows(view, typeHint);
339
+ } else {
340
+ // Display the message for non-string types
341
+ const pieces = typeof message === "string" ? [message] : message;
342
+ rows = pieces.map((piece) => {
343
+ const row = document.createElement("div");
344
+ row.textContent = piece;
345
+ return row;
346
+ });
347
+ }
348
+
349
+ for (const row of rows) {
350
+ row.classList.add("diagnostic-row");
351
+ wrapper.appendChild(row);
352
+ }
353
+
354
+ return wrapper;
355
+ },
356
+ markClass: "",
357
+ };
358
+ }
@@ -0,0 +1,170 @@
1
+ import {
2
+ Extension,
3
+ StateEffect,
4
+ StateField,
5
+ Transaction,
6
+ } from "@codemirror/state";
7
+ import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
8
+ import { parseJsonValue } from "../json/json_parser";
9
+ import { validateSchema } from "../json/schema_validator";
10
+ import type {
11
+ JsonParseResult,
12
+ TypeDefinition,
13
+ ValidationResult,
14
+ } from "../json/types";
15
+
16
+ export interface JsonState {
17
+ readonly parseResult: JsonParseResult;
18
+ readonly validationResult?: ValidationResult;
19
+ readonly source: string;
20
+ }
21
+
22
+ const updateJsonState = StateEffect.define<JsonState>();
23
+
24
+ export const jsonStateField = StateField.define<JsonState | null>({
25
+ create(): JsonState | null {
26
+ return null;
27
+ },
28
+ update(value, tr): JsonState | null {
29
+ for (const effect of tr.effects) {
30
+ if (effect.is(updateJsonState)) {
31
+ return effect.value;
32
+ }
33
+ }
34
+ return value;
35
+ },
36
+ });
37
+
38
+ /**
39
+ * Ensures the JSON state is up-to-date with the current document.
40
+ * If the state is stale or missing, triggers an immediate parse and returns the updated state.
41
+ */
42
+ export function ensureJsonState(
43
+ view: EditorView,
44
+ schema: TypeDefinition,
45
+ ): JsonState {
46
+ const currentState = view.state.field(jsonStateField, false);
47
+ const source = view.state.doc.toString();
48
+
49
+ // If the source hasn't changed, return the current state
50
+ if (currentState && currentState.source === source) {
51
+ return currentState;
52
+ }
53
+
54
+ // Parse and validate immediately
55
+ const parseResult = parseJsonValue(source);
56
+ let validationResult: ValidationResult | undefined;
57
+ if (parseResult.value) {
58
+ validationResult = validateSchema(parseResult.value, schema);
59
+ }
60
+
61
+ const newState: JsonState = { parseResult, validationResult, source };
62
+
63
+ // Update the state if it's different
64
+ if (!currentState || currentState !== newState) {
65
+ view.dispatch({
66
+ effects: updateJsonState.of(newState),
67
+ });
68
+ }
69
+
70
+ return newState;
71
+ }
72
+
73
+ export function debouncedJsonParser(schema: TypeDefinition): Extension[] {
74
+ return [
75
+ jsonStateField,
76
+ ViewPlugin.fromClass(
77
+ class {
78
+ timeout: number | null = null;
79
+ view: EditorView;
80
+
81
+ constructor(view: EditorView) {
82
+ this.view = view;
83
+ this.scheduleUpdate();
84
+ }
85
+
86
+ update(update: ViewUpdate): void {
87
+ if (update.docChanged) {
88
+ const isUndo = update.transactions.some(
89
+ (tr) =>
90
+ tr.annotation(Transaction.userEvent) === "undo" ||
91
+ tr.annotation(Transaction.userEvent) === "redo",
92
+ );
93
+ this.scheduleUpdate(isUndo ? "from-undo" : undefined);
94
+ }
95
+ }
96
+
97
+ scheduleUpdate(fromUndo?: "from-undo"): void {
98
+ if (this.timeout !== null) {
99
+ clearTimeout(this.timeout);
100
+ }
101
+ this.timeout = window.setTimeout(() => {
102
+ this.parseJson(fromUndo);
103
+ this.timeout = null;
104
+ }, 200);
105
+ }
106
+
107
+ parseJson(fromUndo: "from-undo" | undefined): void {
108
+ const source = this.view.state.doc.toString();
109
+ const parseResult = parseJsonValue(source);
110
+
111
+ let validationResult: ValidationResult | undefined;
112
+ if (parseResult.value) {
113
+ validationResult = validateSchema(parseResult.value, schema);
114
+ }
115
+
116
+ const cursorInsideEdit = (): boolean => {
117
+ const cursorPos = this.view.state.selection.main.head;
118
+ return parseResult.edits.some(
119
+ (edit) =>
120
+ edit.segment.start <= cursorPos &&
121
+ cursorPos <= edit.segment.end,
122
+ );
123
+ };
124
+
125
+ // Apply edits if all these conditions are satisfied:
126
+ // - no error
127
+ // - the cursor is not inside any of the edited segments, to avoid
128
+ // disrupting the user while they're typing
129
+ // - the update is not triggered by an undo operation
130
+ if (
131
+ !fromUndo &&
132
+ parseResult.edits.length &&
133
+ parseResult.errors.length <= 0 &&
134
+ !cursorInsideEdit()
135
+ ) {
136
+ const changes = parseResult.edits.map((edit) => ({
137
+ from: edit.segment.start,
138
+ to: edit.segment.end,
139
+ insert: edit.replacement,
140
+ }));
141
+
142
+ this.view.dispatch({
143
+ changes,
144
+ effects: updateJsonState.of({
145
+ parseResult,
146
+ validationResult,
147
+ source,
148
+ }),
149
+ scrollIntoView: true,
150
+ });
151
+ } else {
152
+ this.view.dispatch({
153
+ effects: updateJsonState.of({
154
+ parseResult,
155
+ validationResult,
156
+ source,
157
+ }),
158
+ });
159
+ }
160
+ }
161
+
162
+ destroy(): void {
163
+ if (this.timeout !== null) {
164
+ clearTimeout(this.timeout);
165
+ }
166
+ }
167
+ },
168
+ ),
169
+ ];
170
+ }