@kognitivedev/json-editor 0.1.1

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.
@@ -0,0 +1,2 @@
1
+
2
+ $ tsc
@@ -0,0 +1,15 @@
1
+ $ vitest run
2
+
3
+ RUN v3.2.4 /Users/vserifsaglam/work/memory-experiment/packages/json-editor
4
+
5
+ ✓ src/__tests__/json-editor.test.tsx (6 tests) 3657ms
6
+ ✓ JsonEditor > renders a controlled JSON value and responds to external updates 695ms
7
+ ✓ JsonEditor > emits valid parse metadata for valid JSON edits 677ms
8
+ ✓ JsonEditor > emits invalid parse metadata for malformed JSON without crashing 798ms
9
+ ✓ JsonEditor > formats valid JSON and leaves invalid JSON untouched 1076ms
10
+
11
+ Test Files 1 passed (1)
12
+ Tests 6 passed (6)
13
+ Start at 12:40:03
14
+ Duration 6.28s (transform 183ms, setup 760ms, collect 665ms, tests 3.66s, environment 511ms, prepare 163ms)
15
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @kognitivedev/json-editor
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - release
@@ -0,0 +1 @@
1
+ export { JsonEditor, type JsonEditorProps, type JsonEditorChangeMeta, type JsonEditorToolbarAction, type JsonEditorToolbarActionContext, } from "./json-editor";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JsonEditor = void 0;
4
+ var json_editor_1 = require("./json-editor");
5
+ Object.defineProperty(exports, "JsonEditor", { enumerable: true, get: function () { return json_editor_1.JsonEditor; } });
@@ -0,0 +1,27 @@
1
+ export type JsonEditorChangeMeta = {
2
+ isValidJson: boolean;
3
+ parsedValue?: unknown;
4
+ errorMessage?: string;
5
+ };
6
+ export type JsonEditorToolbarActionContext = {
7
+ value: string;
8
+ meta: JsonEditorChangeMeta;
9
+ replaceValue: (nextValue: string) => void;
10
+ };
11
+ export type JsonEditorToolbarAction = {
12
+ id: string;
13
+ label: string;
14
+ onAction: (context: JsonEditorToolbarActionContext) => void;
15
+ disabled?: boolean | ((context: JsonEditorToolbarActionContext) => boolean);
16
+ title?: string;
17
+ };
18
+ export type JsonEditorProps = {
19
+ value: string;
20
+ onChange?: (nextValue: string, meta: JsonEditorChangeMeta) => void;
21
+ readOnly?: boolean;
22
+ minHeight?: number;
23
+ placeholder?: string;
24
+ className?: string;
25
+ toolbarActions?: JsonEditorToolbarAction[];
26
+ };
27
+ export declare function JsonEditor({ value, onChange, readOnly, minHeight, placeholder, className, toolbarActions, }: JsonEditorProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ "use client";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.JsonEditor = JsonEditor;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const state_1 = require("@codemirror/state");
7
+ const language_1 = require("@codemirror/language");
8
+ const lang_json_1 = require("@codemirror/lang-json");
9
+ const view_1 = require("@codemirror/view");
10
+ const commands_1 = require("@codemirror/commands");
11
+ const codemirror_1 = require("codemirror");
12
+ const react_1 = require("react");
13
+ function cn(...values) {
14
+ return values.filter(Boolean).join(" ");
15
+ }
16
+ function parseJsonText(value) {
17
+ const trimmed = value.trim();
18
+ if (!trimmed) {
19
+ return {
20
+ isValidJson: false,
21
+ errorMessage: "Enter a JSON object or array.",
22
+ };
23
+ }
24
+ try {
25
+ return {
26
+ isValidJson: true,
27
+ parsedValue: JSON.parse(value),
28
+ };
29
+ }
30
+ catch (error) {
31
+ return {
32
+ isValidJson: false,
33
+ errorMessage: error instanceof Error ? error.message : "Invalid JSON.",
34
+ };
35
+ }
36
+ }
37
+ function clampSelection(state, nextLength) {
38
+ return state_1.EditorSelection.create(state.selection.ranges.map((range) => {
39
+ const anchor = Math.min(range.anchor, nextLength);
40
+ const head = Math.min(range.head, nextLength);
41
+ return state_1.EditorSelection.range(anchor, head);
42
+ }), state.selection.mainIndex);
43
+ }
44
+ const editorTheme = view_1.EditorView.theme({
45
+ "&": {
46
+ backgroundColor: "transparent",
47
+ color: "inherit",
48
+ height: "100%",
49
+ fontFamily: "var(--font-geist-mono, var(--font-space-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace))",
50
+ fontSize: "12px",
51
+ lineHeight: "1.6",
52
+ },
53
+ ".cm-scroller": {
54
+ minHeight: "var(--json-editor-min-height, 240px)",
55
+ overflow: "auto",
56
+ },
57
+ ".cm-content": {
58
+ minHeight: "var(--json-editor-min-height, 240px)",
59
+ padding: "12px 0",
60
+ caretColor: "currentColor",
61
+ },
62
+ ".cm-line": {
63
+ padding: "0 12px",
64
+ },
65
+ ".cm-gutters": {
66
+ border: "none",
67
+ backgroundColor: "transparent",
68
+ color: "rgb(113 113 122)",
69
+ },
70
+ ".cm-activeLineGutter": {
71
+ backgroundColor: "transparent",
72
+ },
73
+ ".cm-activeLine": {
74
+ backgroundColor: "rgba(148, 163, 184, 0.08)",
75
+ },
76
+ ".cm-matchingBracket": {
77
+ backgroundColor: "rgba(59, 130, 246, 0.18)",
78
+ outline: "1px solid rgba(59, 130, 246, 0.35)",
79
+ },
80
+ ".cm-cursor, .cm-dropCursor": {
81
+ borderLeftColor: "currentColor",
82
+ },
83
+ ".cm-selectionBackground, ::selection": {
84
+ backgroundColor: "rgba(59, 130, 246, 0.28)",
85
+ },
86
+ ".cm-focused": {
87
+ outline: "none",
88
+ },
89
+ ".cm-placeholder": {
90
+ color: "rgb(113 113 122)",
91
+ fontStyle: "italic",
92
+ },
93
+ });
94
+ function JsonEditor({ value, onChange, readOnly = false, minHeight = 240, placeholder, className, toolbarActions = [], }) {
95
+ const containerRef = (0, react_1.useRef)(null);
96
+ const viewRef = (0, react_1.useRef)(null);
97
+ const onChangeRef = (0, react_1.useRef)(onChange);
98
+ const editableCompartmentRef = (0, react_1.useRef)(new state_1.Compartment());
99
+ const readOnlyCompartmentRef = (0, react_1.useRef)(new state_1.Compartment());
100
+ const placeholderCompartmentRef = (0, react_1.useRef)(new state_1.Compartment());
101
+ const [meta, setMeta] = (0, react_1.useState)(() => parseJsonText(value));
102
+ (0, react_1.useEffect)(() => {
103
+ onChangeRef.current = onChange;
104
+ }, [onChange]);
105
+ (0, react_1.useEffect)(() => {
106
+ if (!containerRef.current || viewRef.current)
107
+ return;
108
+ const state = state_1.EditorState.create({
109
+ doc: value,
110
+ extensions: [
111
+ codemirror_1.basicSetup,
112
+ (0, lang_json_1.json)(),
113
+ (0, language_1.bracketMatching)(),
114
+ (0, language_1.syntaxHighlighting)(language_1.defaultHighlightStyle, { fallback: true }),
115
+ view_1.keymap.of([commands_1.indentWithTab]),
116
+ view_1.EditorView.lineWrapping,
117
+ editorTheme,
118
+ editableCompartmentRef.current.of(view_1.EditorView.editable.of(!readOnly)),
119
+ readOnlyCompartmentRef.current.of(state_1.EditorState.readOnly.of(readOnly)),
120
+ placeholderCompartmentRef.current.of(placeholder ? (0, view_1.placeholder)(placeholder) : []),
121
+ view_1.EditorView.updateListener.of((update) => {
122
+ var _a;
123
+ if (!update.docChanged)
124
+ return;
125
+ const nextValue = update.state.doc.toString();
126
+ const nextMeta = parseJsonText(nextValue);
127
+ setMeta(nextMeta);
128
+ (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, nextValue, nextMeta);
129
+ }),
130
+ ],
131
+ });
132
+ viewRef.current = new view_1.EditorView({
133
+ state,
134
+ parent: containerRef.current,
135
+ });
136
+ return () => {
137
+ var _a;
138
+ (_a = viewRef.current) === null || _a === void 0 ? void 0 : _a.destroy();
139
+ viewRef.current = null;
140
+ };
141
+ }, [placeholder, readOnly, value]);
142
+ (0, react_1.useEffect)(() => {
143
+ const view = viewRef.current;
144
+ if (!view)
145
+ return;
146
+ const nextEditable = view_1.EditorView.editable.of(!readOnly);
147
+ const nextReadOnly = state_1.EditorState.readOnly.of(readOnly);
148
+ view.dispatch({
149
+ effects: [
150
+ editableCompartmentRef.current.reconfigure(nextEditable),
151
+ readOnlyCompartmentRef.current.reconfigure(nextReadOnly),
152
+ ],
153
+ });
154
+ }, [readOnly]);
155
+ (0, react_1.useEffect)(() => {
156
+ const view = viewRef.current;
157
+ if (!view)
158
+ return;
159
+ view.dispatch({
160
+ effects: placeholderCompartmentRef.current.reconfigure(placeholder ? (0, view_1.placeholder)(placeholder) : []),
161
+ });
162
+ }, [placeholder]);
163
+ (0, react_1.useEffect)(() => {
164
+ const nextMeta = parseJsonText(value);
165
+ setMeta(nextMeta);
166
+ const view = viewRef.current;
167
+ if (!view)
168
+ return;
169
+ if (view.state.doc.toString() === value)
170
+ return;
171
+ view.dispatch({
172
+ changes: {
173
+ from: 0,
174
+ to: view.state.doc.length,
175
+ insert: value,
176
+ },
177
+ selection: clampSelection(view.state, value.length),
178
+ });
179
+ }, [value]);
180
+ function replaceValue(nextValue) {
181
+ var _a;
182
+ const view = viewRef.current;
183
+ if (!view) {
184
+ const nextMeta = parseJsonText(nextValue);
185
+ setMeta(nextMeta);
186
+ (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, nextValue, nextMeta);
187
+ return;
188
+ }
189
+ view.dispatch({
190
+ changes: {
191
+ from: 0,
192
+ to: view.state.doc.length,
193
+ insert: nextValue,
194
+ },
195
+ selection: clampSelection(view.state, nextValue.length),
196
+ scrollIntoView: true,
197
+ });
198
+ }
199
+ function handleFormatJson() {
200
+ if (readOnly || !meta.isValidJson)
201
+ return;
202
+ replaceValue(JSON.stringify(meta.parsedValue, null, 2));
203
+ }
204
+ const actionContext = (0, react_1.useMemo)(() => ({
205
+ value,
206
+ meta,
207
+ replaceValue,
208
+ }), [meta, value]);
209
+ return ((0, jsx_runtime_1.jsxs)("div", { className: cn("overflow-hidden rounded-[1rem] border border-zinc-200 bg-white text-zinc-950 shadow-sm dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100", className), children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200/80 px-3 py-2 dark:border-zinc-800/90", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 items-center gap-2 text-[11px]", children: [(0, jsx_runtime_1.jsx)("span", { className: cn("rounded-full px-2 py-1 font-medium uppercase tracking-[0.14em]", meta.isValidJson
210
+ ? "bg-emerald-500/12 text-emerald-600 dark:bg-emerald-500/18 dark:text-emerald-300"
211
+ : "bg-amber-500/12 text-amber-700 dark:bg-amber-500/18 dark:text-amber-300"), children: meta.isValidJson ? "Valid JSON" : "Template / Invalid JSON" }), !meta.isValidJson && meta.errorMessage ? ((0, jsx_runtime_1.jsx)("span", { className: "truncate text-zinc-500 dark:text-zinc-400", children: meta.errorMessage })) : null] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleFormatJson, disabled: readOnly || !meta.isValidJson, className: "inline-flex items-center rounded-full border border-zinc-300 px-3 py-1.5 text-[11px] font-medium uppercase tracking-[0.14em] text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:border-zinc-600 dark:hover:bg-zinc-900", children: "Format JSON" }), toolbarActions.map((action) => {
212
+ const disabled = readOnly || (typeof action.disabled === "function"
213
+ ? action.disabled(actionContext)
214
+ : Boolean(action.disabled));
215
+ return ((0, jsx_runtime_1.jsx)("button", { type: "button", title: action.title, onClick: () => action.onAction(actionContext), disabled: disabled, className: "inline-flex items-center rounded-full border border-zinc-300 px-3 py-1.5 text-[11px] font-medium uppercase tracking-[0.14em] text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:border-zinc-600 dark:hover:bg-zinc-900", children: action.label }, action.id));
216
+ })] })] }), (0, jsx_runtime_1.jsx)("div", { ref: containerRef, style: { "--json-editor-min-height": `${minHeight}px` } })] }));
217
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@kognitivedev/json-editor",
3
+ "version": "0.1.1",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc -w --noCheck",
12
+ "prepublishOnly": "npm run build",
13
+ "test": "vitest run"
14
+ },
15
+ "dependencies": {
16
+ "@codemirror/lang-json": "^6.0.1",
17
+ "@codemirror/language": "^6.10.0",
18
+ "@codemirror/commands": "^6.8.1",
19
+ "@codemirror/state": "^6.5.2",
20
+ "@codemirror/view": "^6.38.6",
21
+ "codemirror": "^6.0.2"
22
+ },
23
+ "peerDependencies": {
24
+ "react": ">=19.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@testing-library/jest-dom": "^6.8.0",
28
+ "@testing-library/react": "^16.3.0",
29
+ "@testing-library/user-event": "^14.6.1",
30
+ "@types/node": "^20.0.0",
31
+ "@types/react": "^19",
32
+ "@types/react-dom": "^19",
33
+ "jsdom": "^26.1.0",
34
+ "react": "^19.0.0",
35
+ "react-dom": "^19.0.0",
36
+ "typescript": "^5.0.0",
37
+ "vitest": "^3.0.0"
38
+ },
39
+ "description": "Reusable JSON editing surface for Kognitive apps",
40
+ "keywords": [
41
+ "json",
42
+ "editor",
43
+ "codemirror",
44
+ "react",
45
+ "kognitive"
46
+ ],
47
+ "license": "MIT"
48
+ }
@@ -0,0 +1,166 @@
1
+ import { useState } from "react";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import {
6
+ JsonEditor,
7
+ type JsonEditorChangeMeta,
8
+ type JsonEditorToolbarAction,
9
+ } from "../index";
10
+
11
+ function getEditable(container: HTMLElement) {
12
+ const editable = container.querySelector(".cm-content");
13
+ if (!(editable instanceof HTMLElement)) {
14
+ throw new Error("Unable to find CodeMirror editable content.");
15
+ }
16
+ return editable;
17
+ }
18
+
19
+ function Harness({
20
+ initialValue,
21
+ readOnly = false,
22
+ toolbarActions,
23
+ placeholder,
24
+ }: {
25
+ initialValue: string;
26
+ readOnly?: boolean;
27
+ toolbarActions?: JsonEditorToolbarAction[];
28
+ placeholder?: string;
29
+ }) {
30
+ const [value, setValue] = useState(initialValue);
31
+ const [meta, setMeta] = useState<JsonEditorChangeMeta | null>(null);
32
+
33
+ return (
34
+ <div>
35
+ <JsonEditor
36
+ value={value}
37
+ readOnly={readOnly}
38
+ placeholder={placeholder}
39
+ toolbarActions={toolbarActions}
40
+ onChange={(nextValue, nextMeta) => {
41
+ setValue(nextValue);
42
+ setMeta(nextMeta);
43
+ }}
44
+ />
45
+ <button type="button" onClick={() => setValue('{"external":true}')}>
46
+ Replace Externally
47
+ </button>
48
+ <pre data-testid="value">{value}</pre>
49
+ <pre data-testid="meta">{meta ? JSON.stringify(meta) : ""}</pre>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ describe("JsonEditor", () => {
55
+ it("renders a controlled JSON value and responds to external updates", async () => {
56
+ const user = userEvent.setup();
57
+ const { container } = render(<Harness initialValue='{"count":1}' />);
58
+
59
+ expect(getEditable(container).textContent).toContain('{"count":1}');
60
+
61
+ await user.click(screen.getByRole("button", { name: "Replace Externally" }));
62
+
63
+ await waitFor(() => {
64
+ expect(getEditable(container).textContent).toContain('{"external":true}');
65
+ });
66
+ });
67
+
68
+ it("emits valid parse metadata for valid JSON edits", async () => {
69
+ const user = userEvent.setup();
70
+ render(
71
+ <Harness
72
+ initialValue=""
73
+ placeholder="{}"
74
+ toolbarActions={[{
75
+ id: "inject-valid",
76
+ label: "Inject Valid",
77
+ onAction: ({ replaceValue }) => replaceValue('{"name":"Ada"}'),
78
+ }]}
79
+ />,
80
+ );
81
+
82
+ await user.click(screen.getByRole("button", { name: "Inject Valid" }));
83
+
84
+ await waitFor(() => {
85
+ expect(screen.getByTestId("meta")).toHaveTextContent('"isValidJson":true');
86
+ expect(screen.getByTestId("meta")).toHaveTextContent('"name":"Ada"');
87
+ });
88
+ });
89
+
90
+ it("emits invalid parse metadata for malformed JSON without crashing", async () => {
91
+ const user = userEvent.setup();
92
+ render(
93
+ <Harness
94
+ initialValue=""
95
+ toolbarActions={[{
96
+ id: "inject-invalid",
97
+ label: "Inject Invalid",
98
+ onAction: ({ replaceValue }) => replaceValue("{"),
99
+ }]}
100
+ />,
101
+ );
102
+
103
+ await user.click(screen.getByRole("button", { name: "Inject Invalid" }));
104
+
105
+ await waitFor(() => {
106
+ expect(screen.getByTestId("meta")).toHaveTextContent('"isValidJson":false');
107
+ expect(screen.getByText("Template / Invalid JSON")).toBeInTheDocument();
108
+ });
109
+ });
110
+
111
+ it("formats valid JSON and leaves invalid JSON untouched", async () => {
112
+ const user = userEvent.setup();
113
+ render(<Harness initialValue='{"count":1,"items":[1,2]}' />);
114
+
115
+ await user.click(screen.getByRole("button", { name: "Format JSON" }));
116
+
117
+ await waitFor(() => {
118
+ expect(screen.getByTestId("value").textContent).toBe('{\n "count": 1,\n "items": [\n 1,\n 2\n ]\n}');
119
+ });
120
+
121
+ render(<Harness initialValue="{" />);
122
+ await user.click(screen.getAllByRole("button", { name: "Format JSON" })[1]);
123
+
124
+ await waitFor(() => {
125
+ expect(screen.getAllByTestId("value")[1].textContent).toBe("{");
126
+ });
127
+ });
128
+
129
+ it("blocks edits in read-only mode", async () => {
130
+ const user = userEvent.setup();
131
+ const { container } = render(<Harness initialValue='{"count":1}' readOnly />);
132
+
133
+ const editable = getEditable(container);
134
+ expect(editable.getAttribute("contenteditable")).toBe("false");
135
+
136
+ await user.click(editable);
137
+ await user.keyboard("2");
138
+
139
+ expect(screen.getByTestId("value").textContent).toBe('{"count":1}');
140
+ });
141
+
142
+ it("supports custom toolbar actions without teaching the editor about schemas", async () => {
143
+ const user = userEvent.setup();
144
+ const actionSpy = vi.fn((context: { replaceValue: (nextValue: string) => void }) => {
145
+ context.replaceValue('{"reset":true}');
146
+ });
147
+
148
+ render(
149
+ <Harness
150
+ initialValue='{"count":1}'
151
+ toolbarActions={[{
152
+ id: "reset",
153
+ label: "Reset",
154
+ onAction: actionSpy,
155
+ }]}
156
+ />,
157
+ );
158
+
159
+ await user.click(screen.getByRole("button", { name: "Reset" }));
160
+
161
+ expect(actionSpy).toHaveBeenCalledTimes(1);
162
+ await waitFor(() => {
163
+ expect(screen.getByTestId("value").textContent).toBe('{"reset":true}');
164
+ });
165
+ });
166
+ });
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export {
2
+ JsonEditor,
3
+ type JsonEditorProps,
4
+ type JsonEditorChangeMeta,
5
+ type JsonEditorToolbarAction,
6
+ type JsonEditorToolbarActionContext,
7
+ } from "./json-editor";
@@ -0,0 +1,344 @@
1
+ "use client";
2
+
3
+ import {
4
+ Compartment,
5
+ EditorSelection,
6
+ EditorState,
7
+ type Extension,
8
+ } from "@codemirror/state";
9
+ import {
10
+ bracketMatching,
11
+ defaultHighlightStyle,
12
+ syntaxHighlighting,
13
+ } from "@codemirror/language";
14
+ import { json } from "@codemirror/lang-json";
15
+ import {
16
+ EditorView,
17
+ keymap,
18
+ placeholder as placeholderExtension,
19
+ } from "@codemirror/view";
20
+ import { indentWithTab } from "@codemirror/commands";
21
+ import { basicSetup } from "codemirror";
22
+ import {
23
+ useEffect,
24
+ useMemo,
25
+ useRef,
26
+ useState,
27
+ type CSSProperties,
28
+ } from "react";
29
+
30
+ export type JsonEditorChangeMeta = {
31
+ isValidJson: boolean;
32
+ parsedValue?: unknown;
33
+ errorMessage?: string;
34
+ };
35
+
36
+ export type JsonEditorToolbarActionContext = {
37
+ value: string;
38
+ meta: JsonEditorChangeMeta;
39
+ replaceValue: (nextValue: string) => void;
40
+ };
41
+
42
+ export type JsonEditorToolbarAction = {
43
+ id: string;
44
+ label: string;
45
+ onAction: (context: JsonEditorToolbarActionContext) => void;
46
+ disabled?: boolean | ((context: JsonEditorToolbarActionContext) => boolean);
47
+ title?: string;
48
+ };
49
+
50
+ export type JsonEditorProps = {
51
+ value: string;
52
+ onChange?: (nextValue: string, meta: JsonEditorChangeMeta) => void;
53
+ readOnly?: boolean;
54
+ minHeight?: number;
55
+ placeholder?: string;
56
+ className?: string;
57
+ toolbarActions?: JsonEditorToolbarAction[];
58
+ };
59
+
60
+ function cn(...values: Array<string | undefined | false | null>) {
61
+ return values.filter(Boolean).join(" ");
62
+ }
63
+
64
+ function parseJsonText(value: string): JsonEditorChangeMeta {
65
+ const trimmed = value.trim();
66
+ if (!trimmed) {
67
+ return {
68
+ isValidJson: false,
69
+ errorMessage: "Enter a JSON object or array.",
70
+ };
71
+ }
72
+
73
+ try {
74
+ return {
75
+ isValidJson: true,
76
+ parsedValue: JSON.parse(value),
77
+ };
78
+ } catch (error) {
79
+ return {
80
+ isValidJson: false,
81
+ errorMessage: error instanceof Error ? error.message : "Invalid JSON.",
82
+ };
83
+ }
84
+ }
85
+
86
+ function clampSelection(state: EditorState, nextLength: number) {
87
+ return EditorSelection.create(
88
+ state.selection.ranges.map((range) => {
89
+ const anchor = Math.min(range.anchor, nextLength);
90
+ const head = Math.min(range.head, nextLength);
91
+ return EditorSelection.range(anchor, head);
92
+ }),
93
+ state.selection.mainIndex,
94
+ );
95
+ }
96
+
97
+ const editorTheme = EditorView.theme({
98
+ "&": {
99
+ backgroundColor: "transparent",
100
+ color: "inherit",
101
+ height: "100%",
102
+ fontFamily: "var(--font-geist-mono, var(--font-space-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace))",
103
+ fontSize: "12px",
104
+ lineHeight: "1.6",
105
+ },
106
+ ".cm-scroller": {
107
+ minHeight: "var(--json-editor-min-height, 240px)",
108
+ overflow: "auto",
109
+ },
110
+ ".cm-content": {
111
+ minHeight: "var(--json-editor-min-height, 240px)",
112
+ padding: "12px 0",
113
+ caretColor: "currentColor",
114
+ },
115
+ ".cm-line": {
116
+ padding: "0 12px",
117
+ },
118
+ ".cm-gutters": {
119
+ border: "none",
120
+ backgroundColor: "transparent",
121
+ color: "rgb(113 113 122)",
122
+ },
123
+ ".cm-activeLineGutter": {
124
+ backgroundColor: "transparent",
125
+ },
126
+ ".cm-activeLine": {
127
+ backgroundColor: "rgba(148, 163, 184, 0.08)",
128
+ },
129
+ ".cm-matchingBracket": {
130
+ backgroundColor: "rgba(59, 130, 246, 0.18)",
131
+ outline: "1px solid rgba(59, 130, 246, 0.35)",
132
+ },
133
+ ".cm-cursor, .cm-dropCursor": {
134
+ borderLeftColor: "currentColor",
135
+ },
136
+ ".cm-selectionBackground, ::selection": {
137
+ backgroundColor: "rgba(59, 130, 246, 0.28)",
138
+ },
139
+ ".cm-focused": {
140
+ outline: "none",
141
+ },
142
+ ".cm-placeholder": {
143
+ color: "rgb(113 113 122)",
144
+ fontStyle: "italic",
145
+ },
146
+ });
147
+
148
+ export function JsonEditor({
149
+ value,
150
+ onChange,
151
+ readOnly = false,
152
+ minHeight = 240,
153
+ placeholder,
154
+ className,
155
+ toolbarActions = [],
156
+ }: JsonEditorProps) {
157
+ const containerRef = useRef<HTMLDivElement | null>(null);
158
+ const viewRef = useRef<EditorView | null>(null);
159
+ const onChangeRef = useRef(onChange);
160
+ const editableCompartmentRef = useRef(new Compartment());
161
+ const readOnlyCompartmentRef = useRef(new Compartment());
162
+ const placeholderCompartmentRef = useRef(new Compartment());
163
+ const [meta, setMeta] = useState<JsonEditorChangeMeta>(() => parseJsonText(value));
164
+
165
+ useEffect(() => {
166
+ onChangeRef.current = onChange;
167
+ }, [onChange]);
168
+
169
+ useEffect(() => {
170
+ if (!containerRef.current || viewRef.current) return;
171
+
172
+ const state = EditorState.create({
173
+ doc: value,
174
+ extensions: [
175
+ basicSetup,
176
+ json(),
177
+ bracketMatching(),
178
+ syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
179
+ keymap.of([indentWithTab]),
180
+ EditorView.lineWrapping,
181
+ editorTheme,
182
+ editableCompartmentRef.current.of(EditorView.editable.of(!readOnly)),
183
+ readOnlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
184
+ placeholderCompartmentRef.current.of(
185
+ placeholder ? placeholderExtension(placeholder) : [],
186
+ ),
187
+ EditorView.updateListener.of((update) => {
188
+ if (!update.docChanged) return;
189
+ const nextValue = update.state.doc.toString();
190
+ const nextMeta = parseJsonText(nextValue);
191
+ setMeta(nextMeta);
192
+ onChangeRef.current?.(nextValue, nextMeta);
193
+ }),
194
+ ] satisfies Extension[],
195
+ });
196
+
197
+ viewRef.current = new EditorView({
198
+ state,
199
+ parent: containerRef.current,
200
+ });
201
+
202
+ return () => {
203
+ viewRef.current?.destroy();
204
+ viewRef.current = null;
205
+ };
206
+ }, [placeholder, readOnly, value]);
207
+
208
+ useEffect(() => {
209
+ const view = viewRef.current;
210
+ if (!view) return;
211
+
212
+ const nextEditable = EditorView.editable.of(!readOnly);
213
+ const nextReadOnly = EditorState.readOnly.of(readOnly);
214
+
215
+ view.dispatch({
216
+ effects: [
217
+ editableCompartmentRef.current.reconfigure(nextEditable),
218
+ readOnlyCompartmentRef.current.reconfigure(nextReadOnly),
219
+ ],
220
+ });
221
+ }, [readOnly]);
222
+
223
+ useEffect(() => {
224
+ const view = viewRef.current;
225
+ if (!view) return;
226
+
227
+ view.dispatch({
228
+ effects: placeholderCompartmentRef.current.reconfigure(
229
+ placeholder ? placeholderExtension(placeholder) : [],
230
+ ),
231
+ });
232
+ }, [placeholder]);
233
+
234
+ useEffect(() => {
235
+ const nextMeta = parseJsonText(value);
236
+ setMeta(nextMeta);
237
+
238
+ const view = viewRef.current;
239
+ if (!view) return;
240
+ if (view.state.doc.toString() === value) return;
241
+
242
+ view.dispatch({
243
+ changes: {
244
+ from: 0,
245
+ to: view.state.doc.length,
246
+ insert: value,
247
+ },
248
+ selection: clampSelection(view.state, value.length),
249
+ });
250
+ }, [value]);
251
+
252
+ function replaceValue(nextValue: string) {
253
+ const view = viewRef.current;
254
+ if (!view) {
255
+ const nextMeta = parseJsonText(nextValue);
256
+ setMeta(nextMeta);
257
+ onChangeRef.current?.(nextValue, nextMeta);
258
+ return;
259
+ }
260
+
261
+ view.dispatch({
262
+ changes: {
263
+ from: 0,
264
+ to: view.state.doc.length,
265
+ insert: nextValue,
266
+ },
267
+ selection: clampSelection(view.state, nextValue.length),
268
+ scrollIntoView: true,
269
+ });
270
+ }
271
+
272
+ function handleFormatJson() {
273
+ if (readOnly || !meta.isValidJson) return;
274
+ replaceValue(JSON.stringify(meta.parsedValue, null, 2));
275
+ }
276
+
277
+ const actionContext = useMemo<JsonEditorToolbarActionContext>(() => ({
278
+ value,
279
+ meta,
280
+ replaceValue,
281
+ }), [meta, value]);
282
+
283
+ return (
284
+ <div
285
+ className={cn(
286
+ "overflow-hidden rounded-[1rem] border border-zinc-200 bg-white text-zinc-950 shadow-sm dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100",
287
+ className,
288
+ )}
289
+ >
290
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200/80 px-3 py-2 dark:border-zinc-800/90">
291
+ <div className="flex min-w-0 items-center gap-2 text-[11px]">
292
+ <span
293
+ className={cn(
294
+ "rounded-full px-2 py-1 font-medium uppercase tracking-[0.14em]",
295
+ meta.isValidJson
296
+ ? "bg-emerald-500/12 text-emerald-600 dark:bg-emerald-500/18 dark:text-emerald-300"
297
+ : "bg-amber-500/12 text-amber-700 dark:bg-amber-500/18 dark:text-amber-300",
298
+ )}
299
+ >
300
+ {meta.isValidJson ? "Valid JSON" : "Template / Invalid JSON"}
301
+ </span>
302
+ {!meta.isValidJson && meta.errorMessage ? (
303
+ <span className="truncate text-zinc-500 dark:text-zinc-400">{meta.errorMessage}</span>
304
+ ) : null}
305
+ </div>
306
+
307
+ <div className="flex flex-wrap items-center justify-end gap-2">
308
+ <button
309
+ type="button"
310
+ onClick={handleFormatJson}
311
+ disabled={readOnly || !meta.isValidJson}
312
+ className="inline-flex items-center rounded-full border border-zinc-300 px-3 py-1.5 text-[11px] font-medium uppercase tracking-[0.14em] text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:border-zinc-600 dark:hover:bg-zinc-900"
313
+ >
314
+ Format JSON
315
+ </button>
316
+
317
+ {toolbarActions.map((action) => {
318
+ const disabled = readOnly || (typeof action.disabled === "function"
319
+ ? action.disabled(actionContext)
320
+ : Boolean(action.disabled));
321
+
322
+ return (
323
+ <button
324
+ key={action.id}
325
+ type="button"
326
+ title={action.title}
327
+ onClick={() => action.onAction(actionContext)}
328
+ disabled={disabled}
329
+ className="inline-flex items-center rounded-full border border-zinc-300 px-3 py-1.5 text-[11px] font-medium uppercase tracking-[0.14em] text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:border-zinc-600 dark:hover:bg-zinc-900"
330
+ >
331
+ {action.label}
332
+ </button>
333
+ );
334
+ })}
335
+ </div>
336
+ </div>
337
+
338
+ <div
339
+ ref={containerRef}
340
+ style={{ "--json-editor-min-height": `${minHeight}px` } as CSSProperties}
341
+ />
342
+ </div>
343
+ );
344
+ }
@@ -0,0 +1,44 @@
1
+ import "@testing-library/jest-dom/vitest";
2
+ import { cleanup } from "@testing-library/react";
3
+ import { afterEach, vi } from "vitest";
4
+
5
+ afterEach(() => {
6
+ cleanup();
7
+ });
8
+
9
+ class ResizeObserverMock {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ }
14
+
15
+ const emptyDomRect = {
16
+ x: 0,
17
+ y: 0,
18
+ width: 0,
19
+ height: 0,
20
+ top: 0,
21
+ right: 0,
22
+ bottom: 0,
23
+ left: 0,
24
+ toJSON() {
25
+ return this;
26
+ },
27
+ };
28
+
29
+ if (!globalThis.ResizeObserver) {
30
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
31
+ }
32
+
33
+ if (typeof Range !== "undefined") {
34
+ if (!Range.prototype.getBoundingClientRect) {
35
+ Range.prototype.getBoundingClientRect = () => emptyDomRect as DOMRect;
36
+ }
37
+ if (!Range.prototype.getClientRects) {
38
+ Range.prototype.getClientRects = () => ({
39
+ item: () => null,
40
+ length: 0,
41
+ [Symbol.iterator]: function* iterator() {},
42
+ }) as DOMRectList;
43
+ }
44
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "rootDir": "src",
6
+ "outDir": "dist",
7
+ "declaration": true,
8
+ "noEmit": false,
9
+ "incremental": false,
10
+ "jsx": "react-jsx"
11
+ },
12
+ "include": [
13
+ "src"
14
+ ],
15
+ "exclude": [
16
+ "src/__tests__",
17
+ "src/test"
18
+ ]
19
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "jsdom",
7
+ setupFiles: ["./src/test/setup.ts"],
8
+ },
9
+ });