@djangocfg/widget-code 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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +119 -0
- package/src/diff-viewer/DiffViewer.tsx +168 -0
- package/src/diff-viewer/README.md +33 -0
- package/src/diff-viewer/components/CopyButton.tsx +49 -0
- package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
- package/src/diff-viewer/components/SplitView.tsx +270 -0
- package/src/diff-viewer/components/UnifiedView.tsx +189 -0
- package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
- package/src/diff-viewer/hooks/useDiff.ts +47 -0
- package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
- package/src/diff-viewer/index.ts +22 -0
- package/src/diff-viewer/lazy.tsx +25 -0
- package/src/diff-viewer/types.ts +125 -0
- package/src/diff-viewer/utils/computeDiff.ts +171 -0
- package/src/editor/README.md +187 -0
- package/src/editor/components/DiffEditor.tsx +158 -0
- package/src/editor/components/Editor.tsx +293 -0
- package/src/editor/components/index.ts +2 -0
- package/src/editor/context/EditorProvider.tsx +212 -0
- package/src/editor/context/index.ts +1 -0
- package/src/editor/editor.stories.tsx +397 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useEditor.ts +36 -0
- package/src/editor/hooks/useEditorTheme.ts +91 -0
- package/src/editor/hooks/useLanguage.ts +29 -0
- package/src/editor/hooks/useMonaco.ts +79 -0
- package/src/editor/index.ts +16 -0
- package/src/editor/lazy.tsx +76 -0
- package/src/editor/lib/index.ts +2 -0
- package/src/editor/lib/languages.ts +227 -0
- package/src/editor/lib/themes.ts +42 -0
- package/src/editor/types/index.ts +137 -0
- package/src/editor/workers/index.ts +1 -0
- package/src/editor/workers/setup.ts +58 -0
- package/src/index.ts +10 -0
- package/src/json-editor/JsonEditor.tsx +115 -0
- package/src/json-editor/index.ts +1 -0
- package/src/json-editor/lazy.tsx +24 -0
- package/src/markdown-message/ActionRow.tsx +53 -0
- package/src/markdown-message/ChatMessageRow.tsx +103 -0
- package/src/markdown-message/CodeBlock.tsx +137 -0
- package/src/markdown-message/CollapseToggle.tsx +69 -0
- package/src/markdown-message/MarkdownMessage.tsx +376 -0
- package/src/markdown-message/README.md +221 -0
- package/src/markdown-message/builtinRules.tsx +92 -0
- package/src/markdown-message/components.tsx +224 -0
- package/src/markdown-message/index.ts +22 -0
- package/src/markdown-message/linkRules.ts +83 -0
- package/src/markdown-message/plainText.ts +83 -0
- package/src/markdown-message/sanitize.ts +88 -0
- package/src/markdown-message/streamingBlocks.ts +168 -0
- package/src/markdown-message/types.ts +168 -0
- package/src/panel-header/CodePanelHeader.tsx +121 -0
- package/src/panel-header/index.ts +1 -0
- package/src/pretty-code/PrettyCode.client.tsx +402 -0
- package/src/pretty-code/README.md +81 -0
- package/src/pretty-code/index.tsx +72 -0
- package/src/pretty-code/lazy.tsx +69 -0
- package/src/pretty-code/pretty-code.stories.tsx +353 -0
- package/src/pretty-code/registerPrismLanguages.ts +116 -0
- package/src/role-tokens.ts +39 -0
- package/src/use-collapsible-content.ts +236 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { Editor, DiffEditor } from './lazy';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Samples
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const TS_SAMPLE = `import { useState, useCallback, useMemo } from 'react';
|
|
9
|
+
|
|
10
|
+
interface User {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
email: string;
|
|
14
|
+
role: 'admin' | 'user' | 'viewer';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useUsers() {
|
|
18
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
19
|
+
|
|
20
|
+
const addUser = useCallback((user: Omit<User, 'id'>) => {
|
|
21
|
+
setUsers((prev) => [...prev, { ...user, id: crypto.randomUUID() }]);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const admins = useMemo(
|
|
25
|
+
() => users.filter((u) => u.role === 'admin'),
|
|
26
|
+
[users],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return { users, admins, addUser };
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const JS_SAMPLE = `export function debounce(fn, ms = 200) {
|
|
34
|
+
let t;
|
|
35
|
+
return (...args) => {
|
|
36
|
+
clearTimeout(t);
|
|
37
|
+
t = setTimeout(() => fn(...args), ms);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const PY_SAMPLE = `from dataclasses import dataclass, field
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
from typing import Iterable
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class User:
|
|
49
|
+
id: str
|
|
50
|
+
name: str
|
|
51
|
+
email: str
|
|
52
|
+
role: str = "user"
|
|
53
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def admins(users: Iterable[User]) -> list[User]:
|
|
57
|
+
return [u for u in users if u.role == "admin"]
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const JSON_SAMPLE = `{
|
|
61
|
+
"name": "@djangocfg/ui-tools",
|
|
62
|
+
"version": "2.1.0",
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"monaco-editor": "^0.55.1",
|
|
65
|
+
"react": "^19.1.0"
|
|
66
|
+
},
|
|
67
|
+
"exports": {
|
|
68
|
+
".": "./src/index.ts",
|
|
69
|
+
"./code-editor": "./src/tools/CodeEditor/index.ts"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const CSS_SAMPLE = `:root {
|
|
75
|
+
--radius: 0.5rem;
|
|
76
|
+
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.editor-container {
|
|
80
|
+
position: relative;
|
|
81
|
+
border-radius: var(--radius);
|
|
82
|
+
border: 1px solid var(--border);
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.editor-container:focus-within {
|
|
87
|
+
outline: 2px solid var(--ring);
|
|
88
|
+
outline-offset: 2px;
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const HTML_SAMPLE = `<!DOCTYPE html>
|
|
93
|
+
<html lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8" />
|
|
96
|
+
<title>Hello</title>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<main>
|
|
100
|
+
<h1>Hello world</h1>
|
|
101
|
+
</main>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const MARKDOWN_SAMPLE = `# CodeEditor
|
|
107
|
+
|
|
108
|
+
Monaco-based editor wrapped for React.
|
|
109
|
+
|
|
110
|
+
- Syntax highlighting
|
|
111
|
+
- Diff view
|
|
112
|
+
- Auto-height
|
|
113
|
+
|
|
114
|
+
\`\`\`ts
|
|
115
|
+
const x: number = 1;
|
|
116
|
+
\`\`\`
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const SQL_SAMPLE = `SELECT u.id, u.name, count(o.id) AS orders
|
|
120
|
+
FROM users AS u
|
|
121
|
+
LEFT JOIN orders AS o ON o.user_id = u.id
|
|
122
|
+
WHERE u.created_at > now() - interval '30 days'
|
|
123
|
+
GROUP BY u.id, u.name
|
|
124
|
+
ORDER BY orders DESC
|
|
125
|
+
LIMIT 50;
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const YAML_SAMPLE = `version: "3.9"
|
|
129
|
+
services:
|
|
130
|
+
api:
|
|
131
|
+
image: ghcr.io/djangocfg/api:latest
|
|
132
|
+
ports:
|
|
133
|
+
- "8000:8000"
|
|
134
|
+
environment:
|
|
135
|
+
DATABASE_URL: postgres://app:app@db:5432/app
|
|
136
|
+
db:
|
|
137
|
+
image: postgres:16
|
|
138
|
+
volumes:
|
|
139
|
+
- pgdata:/var/lib/postgresql/data
|
|
140
|
+
volumes:
|
|
141
|
+
pgdata: {}
|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
const DOCKERFILE_SAMPLE = `FROM node:20-alpine AS deps
|
|
145
|
+
WORKDIR /app
|
|
146
|
+
COPY package.json pnpm-lock.yaml ./
|
|
147
|
+
RUN corepack enable && pnpm install --frozen-lockfile
|
|
148
|
+
|
|
149
|
+
FROM node:20-alpine AS runner
|
|
150
|
+
WORKDIR /app
|
|
151
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
152
|
+
COPY . .
|
|
153
|
+
CMD ["pnpm", "start"]
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
const SAMPLES: Record<string, string> = {
|
|
157
|
+
typescript: TS_SAMPLE,
|
|
158
|
+
javascript: JS_SAMPLE,
|
|
159
|
+
python: PY_SAMPLE,
|
|
160
|
+
json: JSON_SAMPLE,
|
|
161
|
+
css: CSS_SAMPLE,
|
|
162
|
+
html: HTML_SAMPLE,
|
|
163
|
+
markdown: MARKDOWN_SAMPLE,
|
|
164
|
+
sql: SQL_SAMPLE,
|
|
165
|
+
yaml: YAML_SAMPLE,
|
|
166
|
+
dockerfile: DOCKERFILE_SAMPLE,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// 200+ line sample for LargeFile preset.
|
|
170
|
+
const LARGE_SAMPLE = Array.from({ length: 220 }, (_, i) => {
|
|
171
|
+
const n = i + 1;
|
|
172
|
+
if (n % 20 === 0) return `// ── section ${n / 20} ──────────────────────────────`;
|
|
173
|
+
if (n % 5 === 0) return `export const value${n} = computeValue(${n});`;
|
|
174
|
+
return `const line${n} = ${n} * Math.PI; // line ${n}`;
|
|
175
|
+
}).join('\n');
|
|
176
|
+
|
|
177
|
+
const DIFF_ORIGINAL = `function greet(name: string) {
|
|
178
|
+
console.log("Hello, " + name);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
greet("World");
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
const DIFF_MODIFIED = `function greet(name: string, greeting = "Hello") {
|
|
185
|
+
console.log(\`\${greeting}, \${name}!\`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function farewell(name: string) {
|
|
189
|
+
console.log(\`Goodbye, \${name}!\`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
greet("World");
|
|
193
|
+
farewell("World");
|
|
194
|
+
`;
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Meta
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The playground flattens Monaco's nested `options` into top-level controls —
|
|
202
|
+
* Storybook cannot drive a nested object with radios and number inputs. Those
|
|
203
|
+
* knobs are therefore the props of a real harness component rather than a
|
|
204
|
+
* story-only args type, so `satisfies Meta<typeof EditorPlayground>` checks them.
|
|
205
|
+
*/
|
|
206
|
+
interface EditorPlaygroundProps {
|
|
207
|
+
language: keyof typeof SAMPLES;
|
|
208
|
+
theme: 'vs-dark' | 'vs-light';
|
|
209
|
+
readOnly: boolean;
|
|
210
|
+
minimap: boolean;
|
|
211
|
+
lineNumbers: 'on' | 'off' | 'relative' | 'interval';
|
|
212
|
+
wordWrap: 'off' | 'on' | 'wordWrapColumn' | 'bounded';
|
|
213
|
+
fontSize: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function EditorPlayground({
|
|
217
|
+
language,
|
|
218
|
+
theme,
|
|
219
|
+
readOnly,
|
|
220
|
+
minimap,
|
|
221
|
+
lineNumbers,
|
|
222
|
+
wordWrap,
|
|
223
|
+
fontSize,
|
|
224
|
+
}: EditorPlaygroundProps) {
|
|
225
|
+
return (
|
|
226
|
+
<div className="h-[460px] w-[760px] overflow-hidden rounded-md border">
|
|
227
|
+
<Editor
|
|
228
|
+
language={language}
|
|
229
|
+
value={SAMPLES[language]}
|
|
230
|
+
options={{ theme, readOnly, minimap, lineNumbers, wordWrap, fontSize }}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const meta = {
|
|
237
|
+
title: 'Widgets/Code/Editor',
|
|
238
|
+
component: EditorPlayground,
|
|
239
|
+
tags: ['autodocs'],
|
|
240
|
+
parameters: {
|
|
241
|
+
layout: 'centered',
|
|
242
|
+
docs: {
|
|
243
|
+
description: {
|
|
244
|
+
component:
|
|
245
|
+
'Monaco-based code editor (~550KB). Heavy — load behind a route or lazy boundary in real apps. Wrap in a fixed-height container unless `autoHeight` is set.',
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
argTypes: {
|
|
250
|
+
language: {
|
|
251
|
+
control: 'select',
|
|
252
|
+
options: Object.keys(SAMPLES),
|
|
253
|
+
table: { category: 'Content' },
|
|
254
|
+
},
|
|
255
|
+
theme: {
|
|
256
|
+
control: 'inline-radio',
|
|
257
|
+
options: ['vs-dark', 'vs-light'],
|
|
258
|
+
table: { category: 'Appearance' },
|
|
259
|
+
},
|
|
260
|
+
fontSize: {
|
|
261
|
+
control: { type: 'number', min: 10, max: 24, step: 1 },
|
|
262
|
+
table: { category: 'Appearance' },
|
|
263
|
+
},
|
|
264
|
+
minimap: { control: 'boolean', table: { category: 'Appearance' } },
|
|
265
|
+
lineNumbers: {
|
|
266
|
+
control: 'inline-radio',
|
|
267
|
+
options: ['on', 'off', 'relative', 'interval'],
|
|
268
|
+
table: { category: 'Appearance' },
|
|
269
|
+
},
|
|
270
|
+
wordWrap: {
|
|
271
|
+
control: 'inline-radio',
|
|
272
|
+
options: ['off', 'on', 'wordWrapColumn', 'bounded'],
|
|
273
|
+
table: { category: 'Behaviour' },
|
|
274
|
+
},
|
|
275
|
+
readOnly: { control: 'boolean', table: { category: 'Behaviour' } },
|
|
276
|
+
},
|
|
277
|
+
args: {
|
|
278
|
+
language: 'typescript',
|
|
279
|
+
theme: 'vs-dark',
|
|
280
|
+
readOnly: false,
|
|
281
|
+
minimap: true,
|
|
282
|
+
lineNumbers: 'on',
|
|
283
|
+
wordWrap: 'on',
|
|
284
|
+
fontSize: 14,
|
|
285
|
+
},
|
|
286
|
+
} satisfies Meta<typeof EditorPlayground>;
|
|
287
|
+
|
|
288
|
+
export default meta;
|
|
289
|
+
|
|
290
|
+
type Story = StoryObj<typeof meta>;
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Playground
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
export const Playground: Story = {};
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Curated presets
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
export const TypeScript: Story = {
|
|
303
|
+
render: () => (
|
|
304
|
+
<div className="h-[420px] w-[760px] overflow-hidden rounded-md border">
|
|
305
|
+
<Editor language="typescript" value={TS_SAMPLE} />
|
|
306
|
+
</div>
|
|
307
|
+
),
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const Json: Story = {
|
|
311
|
+
render: () => (
|
|
312
|
+
<div className="h-[360px] w-[760px] overflow-hidden rounded-md border">
|
|
313
|
+
<Editor language="json" value={JSON_SAMPLE} />
|
|
314
|
+
</div>
|
|
315
|
+
),
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
export const Python: Story = {
|
|
319
|
+
render: () => (
|
|
320
|
+
<div className="h-[420px] w-[760px] overflow-hidden rounded-md border">
|
|
321
|
+
<Editor language="python" value={PY_SAMPLE} />
|
|
322
|
+
</div>
|
|
323
|
+
),
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
export const ReadOnly: Story = {
|
|
327
|
+
render: () => (
|
|
328
|
+
<div className="h-[420px] w-[760px] overflow-hidden rounded-md border">
|
|
329
|
+
<Editor
|
|
330
|
+
language="typescript"
|
|
331
|
+
value={TS_SAMPLE}
|
|
332
|
+
options={{ readOnly: true, lineNumbers: 'on' }}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
export const LargeFile: Story = {
|
|
339
|
+
parameters: {
|
|
340
|
+
docs: {
|
|
341
|
+
description: {
|
|
342
|
+
story: '220-line file — minimap and scroll behaviour become relevant.',
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
render: () => (
|
|
347
|
+
<div className="h-[520px] w-[760px] overflow-hidden rounded-md border">
|
|
348
|
+
<Editor
|
|
349
|
+
language="typescript"
|
|
350
|
+
value={LARGE_SAMPLE}
|
|
351
|
+
options={{ minimap: true, lineNumbers: 'on' }}
|
|
352
|
+
/>
|
|
353
|
+
</div>
|
|
354
|
+
),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export const Diff: Story = {
|
|
358
|
+
parameters: {
|
|
359
|
+
docs: {
|
|
360
|
+
description: {
|
|
361
|
+
story: 'Side-by-side diff view via `DiffEditor`. Always read-only.',
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
render: () => (
|
|
366
|
+
<div className="h-[420px] w-[920px] overflow-hidden rounded-md border">
|
|
367
|
+
<DiffEditor
|
|
368
|
+
original={DIFF_ORIGINAL}
|
|
369
|
+
modified={DIFF_MODIFIED}
|
|
370
|
+
language="typescript"
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
),
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
export const Minimal: Story = {
|
|
377
|
+
parameters: {
|
|
378
|
+
docs: {
|
|
379
|
+
description: {
|
|
380
|
+
story: 'No minimap, no line numbers, no word-wrap — minimal chrome for embeds.',
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
render: () => (
|
|
385
|
+
<div className="h-[300px] w-[640px] overflow-hidden rounded-md border">
|
|
386
|
+
<Editor
|
|
387
|
+
language="markdown"
|
|
388
|
+
value={MARKDOWN_SAMPLE}
|
|
389
|
+
options={{
|
|
390
|
+
minimap: false,
|
|
391
|
+
lineNumbers: 'off',
|
|
392
|
+
wordWrap: 'off',
|
|
393
|
+
}}
|
|
394
|
+
/>
|
|
395
|
+
</div>
|
|
396
|
+
),
|
|
397
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import type * as monaco from 'monaco-editor';
|
|
5
|
+
|
|
6
|
+
import type { UseEditorReturn } from '../types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hook to manage editor instance reference
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const { editor, isReady, setEditor } = useEditor();
|
|
14
|
+
*
|
|
15
|
+
* // Pass setEditor to Editor component's onMount
|
|
16
|
+
* <Editor onMount={setEditor} />
|
|
17
|
+
*
|
|
18
|
+
* // Use editor when ready
|
|
19
|
+
* if (isReady) {
|
|
20
|
+
* editor.getModel()?.getValue();
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function useEditor(): UseEditorReturn {
|
|
25
|
+
const [editor, setEditorState] = useState<monaco.editor.IStandaloneCodeEditor | null>(null);
|
|
26
|
+
|
|
27
|
+
const setEditor = useCallback((editorInstance: monaco.editor.IStandaloneCodeEditor | null) => {
|
|
28
|
+
setEditorState(editorInstance);
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
editor,
|
|
33
|
+
isReady: editor !== null,
|
|
34
|
+
setEditor,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Background used for the editor and its loading/error placeholders. */
|
|
6
|
+
export const EDITOR_BACKGROUND = '#0a0a0a';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hook that registers and returns the Monaco editor theme.
|
|
10
|
+
*
|
|
11
|
+
* The editor is always dark (Chrome devtools / VSCode convention) — its
|
|
12
|
+
* palette does not follow the host app theme. Pass `themeOverride` only
|
|
13
|
+
* if you need a specific Monaco built-in (e.g. `'hc-black'`).
|
|
14
|
+
*
|
|
15
|
+
* @param monaco - Monaco namespace (null during loading)
|
|
16
|
+
* @param themeOverride - Optional explicit theme name
|
|
17
|
+
* @returns Resolved Monaco theme name to pass to editor options
|
|
18
|
+
*/
|
|
19
|
+
export function useEditorTheme(
|
|
20
|
+
monaco: typeof import('monaco-editor') | null,
|
|
21
|
+
themeOverride?: string,
|
|
22
|
+
): string {
|
|
23
|
+
// State (not ref) so consumers re-render once the custom theme is registered.
|
|
24
|
+
const [registered, setRegistered] = useState(false);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!monaco) return;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const colors = readEditorColors();
|
|
31
|
+
|
|
32
|
+
monaco.editor.defineTheme('app-dark', {
|
|
33
|
+
base: 'vs-dark',
|
|
34
|
+
inherit: true,
|
|
35
|
+
rules: [
|
|
36
|
+
{ token: 'comment', foreground: '6A9955', fontStyle: 'italic' },
|
|
37
|
+
{ token: 'keyword', foreground: 'C586C0' },
|
|
38
|
+
{ token: 'string', foreground: 'CE9178' },
|
|
39
|
+
{ token: 'number', foreground: 'B5CEA8' },
|
|
40
|
+
{ token: 'type', foreground: '4EC9B0' },
|
|
41
|
+
{ token: 'function', foreground: 'DCDCAA' },
|
|
42
|
+
{ token: 'variable', foreground: '9CDCFE' },
|
|
43
|
+
],
|
|
44
|
+
colors: {
|
|
45
|
+
'editor.background': colors.background,
|
|
46
|
+
'editor.foreground': colors.foreground,
|
|
47
|
+
'editor.lineHighlightBackground': colors.lineHighlight,
|
|
48
|
+
'editor.selectionBackground': colors.selection,
|
|
49
|
+
'editorCursor.foreground': colors.foreground,
|
|
50
|
+
'editorLineNumber.foreground': colors.mutedForeground,
|
|
51
|
+
'editorWidget.background': colors.card,
|
|
52
|
+
'editorWidget.border': colors.border,
|
|
53
|
+
'input.background': colors.card,
|
|
54
|
+
'dropdown.background': colors.card,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
setRegistered(true);
|
|
59
|
+
} catch {
|
|
60
|
+
// Fallback — use the built-in dark theme.
|
|
61
|
+
setRegistered(false);
|
|
62
|
+
}
|
|
63
|
+
}, [monaco]);
|
|
64
|
+
|
|
65
|
+
if (themeOverride) return themeOverride;
|
|
66
|
+
return registered ? 'app-dark' : 'vs-dark';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Editor colors — hard-coded dark palette.
|
|
71
|
+
*
|
|
72
|
+
* Previously we resolved against CSS variables (`--background`, `--card`,
|
|
73
|
+
* …) so the editor matched the host theme, but the editor MUST be dark
|
|
74
|
+
* regardless of host theme (Chrome devtools / VSCode / Insomnia
|
|
75
|
+
* convention). On a light-themed app `--background` resolves to white,
|
|
76
|
+
* which Monaco then paints under `base: 'vs-dark'` — giving the broken
|
|
77
|
+
* "white Monaco" look. Hard-coding keeps the dark contract honest and
|
|
78
|
+
* removes a layout-time DOM probe.
|
|
79
|
+
*/
|
|
80
|
+
function readEditorColors() {
|
|
81
|
+
return {
|
|
82
|
+
background: EDITOR_BACKGROUND,
|
|
83
|
+
foreground: '#f5f5f5',
|
|
84
|
+
card: '#141414',
|
|
85
|
+
border: '#262626',
|
|
86
|
+
mutedForeground: '#858585',
|
|
87
|
+
lineHighlight: '#1a1a1a',
|
|
88
|
+
selection: '#264F78',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { getLanguageByFilename } from '../lib/languages';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hook to detect language from filename
|
|
8
|
+
*
|
|
9
|
+
* @param filename - File name or path
|
|
10
|
+
* @returns Monaco language ID
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const language = useLanguage('app.tsx');
|
|
15
|
+
* // Returns: 'typescript'
|
|
16
|
+
*
|
|
17
|
+
* const language2 = useLanguage('/path/to/Dockerfile');
|
|
18
|
+
* // Returns: 'dockerfile'
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function useLanguage(filename: string | undefined): string {
|
|
22
|
+
return useMemo(() => {
|
|
23
|
+
if (!filename) return 'plaintext';
|
|
24
|
+
|
|
25
|
+
// Extract basename from path
|
|
26
|
+
const basename = filename.split('/').pop() || filename;
|
|
27
|
+
return getLanguageByFilename(basename);
|
|
28
|
+
}, [filename]);
|
|
29
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import type * as MonacoEditor from 'monaco-editor';
|
|
5
|
+
|
|
6
|
+
import { setupMonacoWorkers } from '../workers/setup';
|
|
7
|
+
import type { UseMonacoReturn } from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared, module-level Monaco loader.
|
|
11
|
+
*
|
|
12
|
+
* Monaco is ~550KB — we load it exactly once per page and hand the same
|
|
13
|
+
* module to every `useMonaco()` consumer. Without this each Editor /
|
|
14
|
+
* DiffEditor / EditorProvider would manage its own loading state and
|
|
15
|
+
* briefly flash a spinner even though the module is already in memory.
|
|
16
|
+
*/
|
|
17
|
+
let monacoPromise: Promise<typeof MonacoEditor> | null = null;
|
|
18
|
+
|
|
19
|
+
function loadMonaco(): Promise<typeof MonacoEditor> {
|
|
20
|
+
if (!monacoPromise) {
|
|
21
|
+
monacoPromise = (async () => {
|
|
22
|
+
setupMonacoWorkers();
|
|
23
|
+
return import('monaco-editor');
|
|
24
|
+
})().catch((err) => {
|
|
25
|
+
// Reset so a later mount can retry instead of being stuck on a
|
|
26
|
+
// permanently-rejected promise.
|
|
27
|
+
monacoPromise = null;
|
|
28
|
+
throw err;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return monacoPromise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Hook to load and access Monaco Editor instance
|
|
36
|
+
*
|
|
37
|
+
* Handles:
|
|
38
|
+
* - Dynamic import of Monaco (client-side only, loaded once and shared)
|
|
39
|
+
* - Web worker configuration
|
|
40
|
+
* - Loading state management
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* const { monaco, isLoading, error } = useMonaco();
|
|
45
|
+
*
|
|
46
|
+
* if (isLoading) return <Spinner />;
|
|
47
|
+
* if (error) return <Error message={error.message} />;
|
|
48
|
+
*
|
|
49
|
+
* // Use monaco API
|
|
50
|
+
* monaco.editor.create(...)
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function useMonaco(): UseMonacoReturn {
|
|
54
|
+
const [monaco, setMonaco] = useState<typeof MonacoEditor | null>(null);
|
|
55
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
56
|
+
const [error, setError] = useState<Error | null>(null);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
let mounted = true;
|
|
60
|
+
|
|
61
|
+
loadMonaco()
|
|
62
|
+
.then((monacoModule) => {
|
|
63
|
+
if (!mounted) return;
|
|
64
|
+
setMonaco(monacoModule);
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
})
|
|
67
|
+
.catch((err) => {
|
|
68
|
+
if (!mounted) return;
|
|
69
|
+
setError(err instanceof Error ? err : new Error('Failed to load Monaco Editor'));
|
|
70
|
+
setIsLoading(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
mounted = false;
|
|
75
|
+
};
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
return { monaco, isLoading, error };
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// CodeEditor - Monaco Editor Integration (~550KB)
|
|
3
|
+
// Code editor components with Next.js support
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
// Components
|
|
7
|
+
export * from './components';
|
|
8
|
+
|
|
9
|
+
// Hooks
|
|
10
|
+
export * from './hooks';
|
|
11
|
+
|
|
12
|
+
// Context
|
|
13
|
+
export * from './context';
|
|
14
|
+
|
|
15
|
+
// Types
|
|
16
|
+
export * from './types';
|