Skip to content

Commit 83cce0a

Browse files
authored
feat: json utils update (#9)
* update json utils * minor refactor
1 parent aeef5ee commit 83cce0a

18 files changed

Lines changed: 5104 additions & 4646 deletions

package-lock.json

Lines changed: 3105 additions & 4402 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@
1515
"tauri:build": "tauri build",
1616
"test": "vite preview --port 3000 --host & sleep 5 && npx playwright test",
1717
"test:ci": "npx playwright install --with-deps chromium && vite preview --port 3000 --host & sleep 5 && npx playwright test --reporter=line",
18-
"cf-typegen": "wrangler types",
19-
"check": "tsc && vite build && wrangler deploy --dry-run",
20-
"deploy": "wrangler deploy",
18+
"check": "tsc && vite build",
2119
"build:dev": "vite build --mode development",
2220
"lint": "eslint .",
2321
"preview": "npm run build && vite preview"
@@ -76,7 +74,10 @@
7674
"fflate": "^0.8.2",
7775
"hono": "^4.11.7",
7876
"input-otp": "^1.2.4",
77+
"ajv": "^8.17.1",
78+
"ajv-formats": "^3.0.1",
7979
"js-yaml": "^4.1.0",
80+
"jsonlint-mod": "^1.7.6",
8081
"lucide-react": "^0.563.0",
8182
"next-themes": "^0.3.0",
8283
"react": "^18.3.1",
@@ -115,7 +116,6 @@
115116
"typescript": "^5.5.3",
116117
"typescript-eslint": "^8.0.1",
117118
"vite": "^7.0.5",
118-
"vite-plugin-pwa": "^1.2.0",
119-
"wrangler": "4.62.0"
119+
"vite-plugin-pwa": "^1.2.0"
120120
}
121121
}

src/components/CodeEditor.tsx

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { useRef, useMemo, useCallback } from "react";
2+
import { ScrollArea } from "@/components/ui/scroll-area";
3+
4+
interface CodeEditorProps {
5+
value: string;
6+
onChange?: (value: string) => void;
7+
readOnly?: boolean;
8+
placeholder?: string;
9+
errorLine?: number;
10+
minHeight?: string;
11+
onReadOnlySelectionChange?: (pos: { line: number; column: number }) => void;
12+
onSelectionChange?: (pos: { line: number; column: number }) => void;
13+
}
14+
15+
const LARGE_TEXT_THRESHOLD = 200_000;
16+
17+
function highlightJson(text: string): string {
18+
// Escape HTML, then wrap JSON tokens in spans
19+
const escaped = text
20+
.replace(/&/g, "&")
21+
.replace(/</g, "&lt;")
22+
.replace(/>/g, "&gt;");
23+
24+
return escaped.replace(
25+
/("(?:[^"\\]|\\.)*")\s*:/g, // keys
26+
'<span class="syntax-key">$1</span>:'
27+
).replace(
28+
/:\s*("(?:[^"\\]|\\.)*")/g, // string values
29+
': <span class="syntax-string">$1</span>'
30+
).replace(
31+
// standalone string values in arrays
32+
/(?<=[\[,]\s*)("(?:[^"\\]|\\.)*")(?=\s*[,\]])/g,
33+
'<span class="syntax-string">$1</span>'
34+
).replace(
35+
/\b(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b/g, // numbers
36+
'<span class="syntax-number">$1</span>'
37+
).replace(
38+
/\b(true|false)\b/g, // booleans
39+
'<span class="syntax-boolean">$1</span>'
40+
).replace(
41+
/\bnull\b/g, // null
42+
'<span class="syntax-null">null</span>'
43+
).replace(
44+
/([{}[\]])/g, // brackets
45+
'<span class="syntax-bracket">$1</span>'
46+
);
47+
}
48+
49+
export function CodeEditor({ value, onChange, readOnly = false, placeholder, errorLine, minHeight = "400px", onReadOnlySelectionChange, onSelectionChange }: CodeEditorProps) {
50+
const textareaRef = useRef<HTMLTextAreaElement>(null);
51+
const highlightRef = useRef<HTMLDivElement>(null);
52+
const readOnlyRef = useRef<HTMLPreElement>(null);
53+
const lines = useMemo(() => value.split("\n"), [value]);
54+
const isLarge = value.length > LARGE_TEXT_THRESHOLD;
55+
const sizeStyle = minHeight === "100%"
56+
? { height: "100%", minHeight: "100%" }
57+
: { height: minHeight, minHeight };
58+
59+
const highlighted = useMemo(() => (isLarge ? "" : highlightJson(value)), [value, isLarge]);
60+
61+
const syncScroll = useCallback(() => {
62+
if (textareaRef.current && highlightRef.current) {
63+
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
64+
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
65+
}
66+
}, []);
67+
68+
const updateReadOnlySelection = useCallback(() => {
69+
if (!onReadOnlySelectionChange || !readOnlyRef.current) return;
70+
const selection = window.getSelection();
71+
if (!selection || selection.rangeCount === 0) return;
72+
const range = selection.getRangeAt(0);
73+
if (!readOnlyRef.current.contains(range.startContainer)) return;
74+
75+
const fullText = readOnlyRef.current.innerText || "";
76+
const beforeRange = range.cloneRange();
77+
beforeRange.selectNodeContents(readOnlyRef.current);
78+
beforeRange.setEnd(range.startContainer, range.startOffset);
79+
const offset = beforeRange.toString().length;
80+
81+
const upToCursor = fullText.slice(0, offset);
82+
const lineParts = upToCursor.split("\n");
83+
const line = lineParts.length;
84+
const column = (lineParts[lineParts.length - 1]?.length ?? 0) + 1;
85+
onReadOnlySelectionChange({ line, column });
86+
}, [onReadOnlySelectionChange]);
87+
88+
if (readOnly) {
89+
return (
90+
<ScrollArea className="border border-border rounded-md bg-card" style={sizeStyle}>
91+
<div className="flex codeeditor-row">
92+
<div className="select-none text-right pr-2 pl-2 py-3 bg-muted/30 text-muted-foreground text-xs font-mono leading-[1.5rem] min-w-[3rem] border-r border-border sticky left-0">
93+
{lines.map((_, i) => (
94+
<div key={i} className={errorLine === i + 1 ? "text-destructive font-bold" : ""}>{i + 1}</div>
95+
))}
96+
</div>
97+
{isLarge ? (
98+
<pre
99+
ref={readOnlyRef}
100+
className="flex-1 p-3 font-mono text-sm leading-[1.5rem] whitespace-pre-wrap break-all select-text outline-none"
101+
tabIndex={0}
102+
onMouseUp={updateReadOnlySelection}
103+
onKeyUp={updateReadOnlySelection}
104+
>
105+
{value}
106+
</pre>
107+
) : (
108+
<pre
109+
ref={readOnlyRef}
110+
className="flex-1 p-3 font-mono text-sm leading-[1.5rem] whitespace-pre-wrap break-all syntax-highlight select-text outline-none"
111+
dangerouslySetInnerHTML={{ __html: highlighted }}
112+
tabIndex={0}
113+
onMouseUp={updateReadOnlySelection}
114+
onKeyUp={updateReadOnlySelection}
115+
/>
116+
)}
117+
</div>
118+
</ScrollArea>
119+
);
120+
}
121+
122+
return (
123+
<div className="border border-border rounded-md overflow-hidden bg-card" style={sizeStyle}>
124+
<div className="flex h-full">
125+
<div className="select-none text-right pr-2 pl-2 py-3 bg-muted/30 text-muted-foreground text-xs font-mono leading-[1.5rem] min-w-[3rem] border-r border-border">
126+
{lines.map((_, i) => (
127+
<div key={i} className={errorLine === i + 1 ? "text-destructive font-bold" : ""}>{i + 1}</div>
128+
))}
129+
</div>
130+
<div className="relative flex-1">
131+
{!isLarge && (
132+
<div
133+
ref={highlightRef}
134+
className="absolute inset-0 p-3 font-mono text-sm leading-[1.5rem] whitespace-pre-wrap break-all overflow-hidden pointer-events-none opacity-0"
135+
aria-hidden="true"
136+
dangerouslySetInnerHTML={{ __html: highlighted || `<span class="text-muted-foreground">${placeholder ?? ""}</span>` }}
137+
/>
138+
)}
139+
<textarea
140+
ref={textareaRef}
141+
value={value}
142+
onChange={(e) => onChange?.(e.target.value)}
143+
placeholder={!value ? placeholder : undefined}
144+
className="relative w-full h-full p-3 font-mono text-sm bg-transparent resize-none outline-none leading-[1.5rem] text-foreground caret-foreground placeholder:text-muted-foreground selection:text-foreground selection:bg-dev-primary/30 select-text cursor-text"
145+
style={sizeStyle}
146+
spellCheck={false}
147+
autoCapitalize="off"
148+
autoCorrect="off"
149+
autoComplete="off"
150+
inputMode="text"
151+
aria-label={placeholder ?? "Code editor"}
152+
onScroll={syncScroll}
153+
onSelect={(e) => {
154+
if (!onSelectionChange) return;
155+
const target = e.currentTarget;
156+
const start = target.selectionStart ?? 0;
157+
const before = target.value.slice(0, start);
158+
const parts = before.split("\n");
159+
const line = parts.length;
160+
const column = (parts[parts.length - 1]?.length ?? 0) + 1;
161+
onSelectionChange({ line, column });
162+
}}
163+
onMouseUp={(e) => {
164+
if (!onSelectionChange) return;
165+
const target = e.currentTarget;
166+
const start = target.selectionStart ?? 0;
167+
const before = target.value.slice(0, start);
168+
const parts = before.split("\n");
169+
const line = parts.length;
170+
const column = (parts[parts.length - 1]?.length ?? 0) + 1;
171+
onSelectionChange({ line, column });
172+
}}
173+
onKeyUp={(e) => {
174+
if (!onSelectionChange) return;
175+
const target = e.currentTarget as HTMLTextAreaElement;
176+
const start = target.selectionStart ?? 0;
177+
const before = target.value.slice(0, start);
178+
const parts = before.split("\n");
179+
const line = parts.length;
180+
const column = (parts[parts.length - 1]?.length ?? 0) + 1;
181+
onSelectionChange({ line, column });
182+
}}
183+
/>
184+
</div>
185+
</div>
186+
</div>
187+
);
188+
}

src/components/DesktopLayout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,14 +271,14 @@ export function DesktopLayout() {
271271
{/* Sidebar footer */}
272272
{!sidebarCollapsed && (
273273
<div className="border-t border-border/30 p-2 text-[10px] text-muted-foreground/50 text-center shrink-0">
274-
v0.1.0 · Desktop
274+
v0.1.3 · Desktop
275275
</div>
276276
)}
277277
</aside>
278278

279279
{/* Main content */}
280-
<main className="flex-1 overflow-hidden bg-background flex flex-col">
281-
<div className="px-4 py-3 flex-1 flex flex-col min-h-0 overflow-auto">
280+
<main className="flex-1 overflow-hidden bg-background flex flex-col min-h-0">
281+
<div className="px-4 py-3 flex-1 flex flex-col min-h-0 overflow-hidden">
282282
<Outlet />
283283
</div>
284284
</main>

0 commit comments

Comments
 (0)