@aprovan/patchwork-editor 0.1.2-dev.03aaf5b → 0.1.2-dev.ba8f277
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/.turbo/turbo-build.log +2 -2
- package/dist/components/CodePreview.d.ts +10 -1
- package/dist/components/MarkdownPreview.d.ts +8 -0
- package/dist/components/SaveStatusButton.d.ts +9 -0
- package/dist/components/ServicesInspector.d.ts +3 -4
- package/dist/components/WidgetPreview.d.ts +8 -0
- package/dist/components/edit/EditModal.d.ts +2 -1
- package/dist/components/edit/FileTree.d.ts +22 -3
- package/dist/components/edit/fileTypes.d.ts +2 -0
- package/dist/components/edit/useEditSession.d.ts +1 -0
- package/dist/components/index.d.ts +7 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +904 -176
- package/package.json +3 -3
- package/src/components/CodePreview.tsx +118 -160
- package/src/components/MarkdownPreview.tsx +147 -0
- package/src/components/SaveStatusButton.tsx +55 -0
- package/src/components/ServicesInspector.tsx +101 -37
- package/src/components/WidgetPreview.tsx +102 -0
- package/src/components/edit/EditModal.tsx +83 -26
- package/src/components/edit/FileTree.tsx +523 -28
- package/src/components/edit/api.ts +6 -1
- package/src/components/edit/fileTypes.ts +8 -0
- package/src/components/edit/useEditSession.ts +13 -3
- package/src/components/index.ts +7 -5
- package/src/index.ts +10 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aprovan/patchwork-editor",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.ba8f277",
|
|
4
4
|
"description": "Components for facilitating widget generation and editing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"shiki": "^3.22.0",
|
|
26
26
|
"tailwind-merge": "^3.4.0",
|
|
27
27
|
"tiptap-markdown": "^0.9.0",
|
|
28
|
-
"@aprovan/
|
|
29
|
-
"@aprovan/
|
|
28
|
+
"@aprovan/patchwork-compiler": "0.1.2-dev.ba8f277",
|
|
29
|
+
"@aprovan/bobbin": "0.1.0-dev.ba8f277"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
2
|
-
import { Code, Eye,
|
|
3
|
-
import type { Compiler,
|
|
2
|
+
import { Code, Eye, Pencil, RotateCcw, MessageSquare } from 'lucide-react';
|
|
3
|
+
import type { Compiler, Manifest } from '@aprovan/patchwork-compiler';
|
|
4
4
|
import { createSingleFileProject } from '@aprovan/patchwork-compiler';
|
|
5
|
-
import { EditModal, type CompileFn } from './edit';
|
|
5
|
+
import { EditModal, type CompileFn, CodeBlockView, MediaPreview, getFileType } from './edit';
|
|
6
|
+
import { SaveStatusButton, type SaveStatus } from './SaveStatusButton';
|
|
7
|
+
import { WidgetPreview } from './WidgetPreview';
|
|
8
|
+
import { MarkdownPreview } from './MarkdownPreview';
|
|
6
9
|
import { saveProject, getVFSConfig, loadFile, subscribeToChanges } from '../lib/vfs';
|
|
7
|
-
|
|
8
|
-
type SaveStatus = 'unsaved' | 'saving' | 'saved' | 'error';
|
|
10
|
+
import type { VirtualProject } from '@aprovan/patchwork-compiler';
|
|
9
11
|
|
|
10
12
|
interface CodePreviewProps {
|
|
11
13
|
code: string;
|
|
@@ -16,6 +18,14 @@ interface CodePreviewProps {
|
|
|
16
18
|
services?: string[];
|
|
17
19
|
/** Optional file path from code block attributes (e.g., "components/calculator.tsx") */
|
|
18
20
|
filePath?: string;
|
|
21
|
+
/** Optional callback to open a shared edit session outside this component */
|
|
22
|
+
onOpenEditSession?: (session: {
|
|
23
|
+
projectId: string;
|
|
24
|
+
entryFile: string;
|
|
25
|
+
filePath?: string;
|
|
26
|
+
initialCode: string;
|
|
27
|
+
initialProject: VirtualProject;
|
|
28
|
+
}) => void;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
function createManifest(services?: string[]): Manifest {
|
|
@@ -28,76 +38,19 @@ function createManifest(services?: string[]): Manifest {
|
|
|
28
38
|
};
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
let cancelled = false;
|
|
41
|
-
|
|
42
|
-
async function compileAndMount() {
|
|
43
|
-
if (!containerRef.current || !compiler) return;
|
|
44
|
-
|
|
45
|
-
setLoading(true);
|
|
46
|
-
setError(null);
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
if (mountedRef.current) {
|
|
50
|
-
compiler.unmount(mountedRef.current);
|
|
51
|
-
mountedRef.current = null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const widget = await compiler.compile(
|
|
55
|
-
code,
|
|
56
|
-
createManifest(services),
|
|
57
|
-
{ typescript: true }
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
if (cancelled) {
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const mounted = await compiler.mount(widget, {
|
|
65
|
-
target: containerRef.current,
|
|
66
|
-
mode: 'embedded'
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
mountedRef.current = mounted;
|
|
70
|
-
} catch (err) {
|
|
71
|
-
if (!cancelled) {
|
|
72
|
-
setError(err instanceof Error ? err.message : 'Failed to render JSX');
|
|
73
|
-
}
|
|
74
|
-
} finally {
|
|
75
|
-
if (!cancelled) {
|
|
76
|
-
setLoading(false);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
compileAndMount();
|
|
82
|
-
|
|
83
|
-
return () => {
|
|
84
|
-
cancelled = true;
|
|
85
|
-
if (mountedRef.current && compiler) {
|
|
86
|
-
compiler.unmount(mountedRef.current);
|
|
87
|
-
mountedRef.current = null;
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
}, [code, compiler, enabled, services]);
|
|
91
|
-
|
|
92
|
-
return { containerRef, loading, error };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function CodePreview({ code: originalCode, compiler, services, filePath, entrypoint = 'index.ts' }: CodePreviewProps) {
|
|
41
|
+
export function CodePreview({
|
|
42
|
+
code: originalCode,
|
|
43
|
+
compiler,
|
|
44
|
+
services,
|
|
45
|
+
filePath,
|
|
46
|
+
entrypoint = 'index.ts',
|
|
47
|
+
onOpenEditSession,
|
|
48
|
+
}: CodePreviewProps) {
|
|
96
49
|
const [isEditing, setIsEditing] = useState(false);
|
|
97
50
|
const [showPreview, setShowPreview] = useState(true);
|
|
98
51
|
const [currentCode, setCurrentCode] = useState(originalCode);
|
|
99
52
|
const [editCount, setEditCount] = useState(0);
|
|
100
|
-
const [saveStatus, setSaveStatus] = useState<SaveStatus>('
|
|
53
|
+
const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved');
|
|
101
54
|
const [lastSavedCode, setLastSavedCode] = useState(originalCode);
|
|
102
55
|
const [vfsPath, setVfsPath] = useState<string | null>(null);
|
|
103
56
|
const currentCodeRef = useRef(currentCode);
|
|
@@ -145,6 +98,15 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
145
98
|
isEditingRef.current = isEditing;
|
|
146
99
|
}, [isEditing]);
|
|
147
100
|
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (saveStatus === 'saving') return;
|
|
103
|
+
if (currentCode === lastSavedCode) {
|
|
104
|
+
if (saveStatus !== 'saved') setSaveStatus('saved');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (saveStatus === 'saved') setSaveStatus('unsaved');
|
|
108
|
+
}, [currentCode, lastSavedCode, saveStatus]);
|
|
109
|
+
|
|
148
110
|
useEffect(() => {
|
|
149
111
|
let active = true;
|
|
150
112
|
void (async () => {
|
|
@@ -201,15 +163,13 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
201
163
|
}
|
|
202
164
|
}, [currentCode, getProjectId, getEntryFile]);
|
|
203
165
|
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
showPreview && !isEditing,
|
|
208
|
-
services
|
|
209
|
-
);
|
|
166
|
+
const previewPath = filePath ?? entrypoint;
|
|
167
|
+
const fileType = useMemo(() => getFileType(previewPath), [previewPath]);
|
|
168
|
+
const canRenderWidget = fileType.category === 'compilable';
|
|
210
169
|
|
|
211
170
|
const compile: CompileFn = useCallback(
|
|
212
171
|
async (code: string) => {
|
|
172
|
+
if (!canRenderWidget) return { success: true };
|
|
213
173
|
if (!compiler) return { success: true };
|
|
214
174
|
|
|
215
175
|
// Capture console.error outputs during compilation
|
|
@@ -238,7 +198,7 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
238
198
|
console.error = originalError;
|
|
239
199
|
}
|
|
240
200
|
},
|
|
241
|
-
[compiler, services]
|
|
201
|
+
[canRenderWidget, compiler, services]
|
|
242
202
|
);
|
|
243
203
|
|
|
244
204
|
const handleRevert = () => {
|
|
@@ -248,9 +208,65 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
248
208
|
|
|
249
209
|
const hasChanges = currentCode !== originalCode;
|
|
250
210
|
|
|
211
|
+
const previewBody = useMemo(() => {
|
|
212
|
+
if (canRenderWidget) {
|
|
213
|
+
return (
|
|
214
|
+
<WidgetPreview
|
|
215
|
+
code={currentCode}
|
|
216
|
+
compiler={compiler}
|
|
217
|
+
services={services}
|
|
218
|
+
enabled={showPreview && !isEditing}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (fileType.category === 'media') {
|
|
224
|
+
return (
|
|
225
|
+
<MediaPreview
|
|
226
|
+
content={currentCode}
|
|
227
|
+
mimeType={fileType.mimeType}
|
|
228
|
+
fileName={previewPath}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (fileType.language === 'markdown') {
|
|
234
|
+
return (
|
|
235
|
+
<div className="p-4 prose prose-sm dark:prose-invert max-w-none">
|
|
236
|
+
<MarkdownPreview value={currentCode} />
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<CodeBlockView
|
|
243
|
+
content={currentCode}
|
|
244
|
+
language={fileType.language}
|
|
245
|
+
/>
|
|
246
|
+
);
|
|
247
|
+
}, [canRenderWidget, compiler, currentCode, fileType, isEditing, previewPath, services, showPreview]);
|
|
248
|
+
|
|
249
|
+
const handleOpenEditor = useCallback(async () => {
|
|
250
|
+
if (!onOpenEditSession) {
|
|
251
|
+
setIsEditing(true);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const projectId = await getProjectId();
|
|
256
|
+
const entryFile = getEntryFile();
|
|
257
|
+
const initialProject = createSingleFileProject(currentCode, entryFile, projectId);
|
|
258
|
+
onOpenEditSession({
|
|
259
|
+
projectId,
|
|
260
|
+
entryFile,
|
|
261
|
+
filePath,
|
|
262
|
+
initialCode: currentCode,
|
|
263
|
+
initialProject,
|
|
264
|
+
});
|
|
265
|
+
}, [onOpenEditSession, getProjectId, getEntryFile, currentCode, filePath]);
|
|
266
|
+
|
|
251
267
|
return (
|
|
252
268
|
<>
|
|
253
|
-
<div className="
|
|
269
|
+
<div className="border rounded-lg overflow-hidden min-w-0">
|
|
254
270
|
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b rounded-t-lg">
|
|
255
271
|
<Code className="h-4 w-4 text-muted-foreground" />
|
|
256
272
|
{editCount > 0 && (
|
|
@@ -259,30 +275,6 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
259
275
|
{editCount} edit{editCount !== 1 ? 's' : ''}
|
|
260
276
|
</span>
|
|
261
277
|
)}
|
|
262
|
-
{/* Save status indicator */}
|
|
263
|
-
<button
|
|
264
|
-
onClick={handleSave}
|
|
265
|
-
disabled={saveStatus === 'saving'}
|
|
266
|
-
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${
|
|
267
|
-
saveStatus === 'saved'
|
|
268
|
-
? 'text-green-600'
|
|
269
|
-
: saveStatus === 'error'
|
|
270
|
-
? 'text-destructive hover:bg-muted'
|
|
271
|
-
: 'text-muted-foreground hover:bg-muted'
|
|
272
|
-
}`}
|
|
273
|
-
title={saveStatus === 'saved' ? 'Saved to disk' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'error' ? 'Save failed - click to retry' : 'Click to save'}
|
|
274
|
-
>
|
|
275
|
-
{saveStatus === 'saving' ? (
|
|
276
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
277
|
-
) : (
|
|
278
|
-
<span className="relative">
|
|
279
|
-
<Cloud className="h-3 w-3" />
|
|
280
|
-
{saveStatus === 'saved' && (
|
|
281
|
-
<Check className="h-2 w-2 absolute -bottom-0.5 -right-0.5 stroke-[3]" />
|
|
282
|
-
)}
|
|
283
|
-
</span>
|
|
284
|
-
)}
|
|
285
|
-
</button>
|
|
286
278
|
<div className="ml-auto flex gap-1">
|
|
287
279
|
{hasChanges && (
|
|
288
280
|
<button
|
|
@@ -294,15 +286,21 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
294
286
|
</button>
|
|
295
287
|
)}
|
|
296
288
|
<button
|
|
297
|
-
onClick={() =>
|
|
289
|
+
onClick={() => void handleOpenEditor()}
|
|
298
290
|
className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-muted"
|
|
299
291
|
title="Edit component"
|
|
300
292
|
>
|
|
301
293
|
<Pencil className="h-3 w-3" />
|
|
302
294
|
</button>
|
|
295
|
+
<SaveStatusButton
|
|
296
|
+
status={saveStatus}
|
|
297
|
+
onClick={handleSave}
|
|
298
|
+
disabled={saveStatus === 'saving'}
|
|
299
|
+
tone="muted"
|
|
300
|
+
/>
|
|
303
301
|
<button
|
|
304
302
|
onClick={() => setShowPreview(!showPreview)}
|
|
305
|
-
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${showPreview ? 'bg-primary text-primary-foreground' : 'hover:bg-primary/20 text-primary'}`}
|
|
303
|
+
className={`w-[5rem] px-2 py-1 text-xs rounded flex items-center gap-1 ${showPreview ? 'bg-primary text-primary-foreground' : 'hover:bg-primary/20 text-primary'}`}
|
|
306
304
|
>
|
|
307
305
|
{showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
|
|
308
306
|
{showPreview ? 'Preview' : 'Code'}
|
|
@@ -311,29 +309,15 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
311
309
|
</div>
|
|
312
310
|
|
|
313
311
|
{showPreview ? (
|
|
314
|
-
<div className="bg-white">
|
|
315
|
-
{
|
|
316
|
-
<div className="p-3 text-sm text-destructive flex items-center gap-2">
|
|
317
|
-
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
318
|
-
<span>{error}</span>
|
|
319
|
-
</div>
|
|
320
|
-
) : loading ? (
|
|
321
|
-
<div className="p-3 flex items-center gap-2 text-muted-foreground">
|
|
322
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
323
|
-
<span className="text-sm">Rendering preview...</span>
|
|
324
|
-
</div>
|
|
325
|
-
) : !compiler ? (
|
|
326
|
-
<div className="p-3 text-sm text-muted-foreground">
|
|
327
|
-
Compiler not initialized
|
|
328
|
-
</div>
|
|
329
|
-
) : null}
|
|
330
|
-
<div ref={containerRef} />
|
|
312
|
+
<div className="bg-white overflow-y-auto overflow-x-hidden max-h-[60vh]">
|
|
313
|
+
{previewBody}
|
|
331
314
|
</div>
|
|
332
315
|
) : (
|
|
333
|
-
<div className="
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
316
|
+
<div className="bg-muted/30 overflow-auto max-h-[60vh]">
|
|
317
|
+
<CodeBlockView
|
|
318
|
+
content={currentCode}
|
|
319
|
+
language={fileType.language}
|
|
320
|
+
/>
|
|
337
321
|
</div>
|
|
338
322
|
)}
|
|
339
323
|
</div>
|
|
@@ -354,6 +338,7 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
354
338
|
const entryFile = getEntryFile();
|
|
355
339
|
const project = createSingleFileProject(finalCode, entryFile, projectId);
|
|
356
340
|
await saveProject(project);
|
|
341
|
+
setLastSavedCode(finalCode);
|
|
357
342
|
setSaveStatus('saved');
|
|
358
343
|
} catch (err) {
|
|
359
344
|
console.warn('[VFS] Failed to save project:', err);
|
|
@@ -364,41 +349,14 @@ export function CodePreview({ code: originalCode, compiler, services, filePath,
|
|
|
364
349
|
}}
|
|
365
350
|
originalCode={currentCode}
|
|
366
351
|
compile={compile}
|
|
367
|
-
renderPreview={(code) =>
|
|
352
|
+
renderPreview={(code) => (
|
|
353
|
+
<WidgetPreview
|
|
354
|
+
code={code}
|
|
355
|
+
compiler={compiler}
|
|
356
|
+
services={services}
|
|
357
|
+
/>
|
|
358
|
+
)}
|
|
368
359
|
/>
|
|
369
360
|
</>
|
|
370
361
|
);
|
|
371
362
|
}
|
|
372
|
-
|
|
373
|
-
function ModalPreview({
|
|
374
|
-
code,
|
|
375
|
-
compiler,
|
|
376
|
-
services,
|
|
377
|
-
}: {
|
|
378
|
-
code: string;
|
|
379
|
-
compiler: Compiler | null;
|
|
380
|
-
services?: string[];
|
|
381
|
-
}) {
|
|
382
|
-
const { containerRef, loading, error } = useCodeCompiler(compiler, code, true, services);
|
|
383
|
-
|
|
384
|
-
return (
|
|
385
|
-
<>
|
|
386
|
-
{error && (
|
|
387
|
-
<div className="text-sm text-destructive flex items-center gap-2">
|
|
388
|
-
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
389
|
-
<span>{error}</span>
|
|
390
|
-
</div>
|
|
391
|
-
)}
|
|
392
|
-
{loading && (
|
|
393
|
-
<div className="flex items-center gap-2 text-muted-foreground">
|
|
394
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
395
|
-
<span className="text-sm">Rendering preview...</span>
|
|
396
|
-
</div>
|
|
397
|
-
)}
|
|
398
|
-
{!compiler && !loading && !error && (
|
|
399
|
-
<div className="text-sm text-muted-foreground">Compiler not initialized</div>
|
|
400
|
-
)}
|
|
401
|
-
<div ref={containerRef} />
|
|
402
|
-
</>
|
|
403
|
-
);
|
|
404
|
-
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useEditor, EditorContent } from '@tiptap/react';
|
|
2
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
3
|
+
import Typography from '@tiptap/extension-typography';
|
|
4
|
+
import { Markdown } from 'tiptap-markdown';
|
|
5
|
+
import { useEffect, useCallback, useRef, useState } from 'react';
|
|
6
|
+
import { CodeBlockExtension } from './CodeBlockExtension';
|
|
7
|
+
|
|
8
|
+
function parseFrontmatter(content: string): { frontmatter: string; body: string } {
|
|
9
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
10
|
+
if (!match) return { frontmatter: '', body: content };
|
|
11
|
+
return { frontmatter: match[1], body: match[2] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assembleFrontmatter(frontmatter: string, body: string): string {
|
|
15
|
+
if (!frontmatter.trim()) return body;
|
|
16
|
+
return `---\n${frontmatter}\n---\n${body}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MarkdownPreviewProps {
|
|
20
|
+
value: string;
|
|
21
|
+
onChange?: (value: string) => void;
|
|
22
|
+
editable?: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MarkdownPreview({
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
editable = false,
|
|
30
|
+
className = '',
|
|
31
|
+
}: MarkdownPreviewProps) {
|
|
32
|
+
const { frontmatter, body } = parseFrontmatter(value);
|
|
33
|
+
const [fm, setFm] = useState(frontmatter);
|
|
34
|
+
const fmRef = useRef(frontmatter);
|
|
35
|
+
const bodyRef = useRef(body);
|
|
36
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const parsed = parseFrontmatter(value);
|
|
40
|
+
fmRef.current = parsed.frontmatter;
|
|
41
|
+
bodyRef.current = parsed.body;
|
|
42
|
+
setFm(parsed.frontmatter);
|
|
43
|
+
}, [value]);
|
|
44
|
+
|
|
45
|
+
const emitChange = useCallback(
|
|
46
|
+
(newFm: string, newBody: string) => {
|
|
47
|
+
onChange?.(assembleFrontmatter(newFm, newBody));
|
|
48
|
+
},
|
|
49
|
+
[onChange]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const handleFmChange = useCallback(
|
|
53
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
54
|
+
const newFm = e.target.value;
|
|
55
|
+
setFm(newFm);
|
|
56
|
+
fmRef.current = newFm;
|
|
57
|
+
emitChange(newFm, bodyRef.current);
|
|
58
|
+
},
|
|
59
|
+
[emitChange]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Auto-resize frontmatter textarea
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (textareaRef.current) {
|
|
65
|
+
textareaRef.current.style.height = 'auto';
|
|
66
|
+
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
67
|
+
}
|
|
68
|
+
}, [fm]);
|
|
69
|
+
|
|
70
|
+
const editor = useEditor({
|
|
71
|
+
extensions: [
|
|
72
|
+
StarterKit.configure({
|
|
73
|
+
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
|
74
|
+
bulletList: { keepMarks: true, keepAttributes: false },
|
|
75
|
+
orderedList: { keepMarks: true, keepAttributes: false },
|
|
76
|
+
codeBlock: false,
|
|
77
|
+
code: {
|
|
78
|
+
HTMLAttributes: {
|
|
79
|
+
class: 'bg-muted rounded px-1 py-0.5 font-mono text-sm',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
blockquote: {
|
|
83
|
+
HTMLAttributes: {
|
|
84
|
+
class: 'border-l-4 border-muted-foreground/30 pl-4 italic',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
hardBreak: { keepMarks: false },
|
|
88
|
+
}),
|
|
89
|
+
CodeBlockExtension,
|
|
90
|
+
Typography,
|
|
91
|
+
Markdown.configure({
|
|
92
|
+
html: false,
|
|
93
|
+
transformPastedText: true,
|
|
94
|
+
transformCopiedText: true,
|
|
95
|
+
}),
|
|
96
|
+
],
|
|
97
|
+
content: body,
|
|
98
|
+
editable,
|
|
99
|
+
editorProps: {
|
|
100
|
+
attributes: {
|
|
101
|
+
class: `outline-none ${className}`,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
onUpdate: ({ editor }) => {
|
|
105
|
+
const markdownStorage = (editor.storage as any).markdown;
|
|
106
|
+
const newBody = markdownStorage?.getMarkdown?.() ?? editor.getText();
|
|
107
|
+
bodyRef.current = newBody;
|
|
108
|
+
emitChange(fmRef.current, newBody);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
editor?.setEditable(editable);
|
|
114
|
+
}, [editor, editable]);
|
|
115
|
+
|
|
116
|
+
// Sync external body changes
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!editor) return;
|
|
119
|
+
const parsed = parseFrontmatter(value);
|
|
120
|
+
const markdownStorage = (editor.storage as any).markdown;
|
|
121
|
+
const current = markdownStorage?.getMarkdown?.() ?? editor.getText();
|
|
122
|
+
if (parsed.body !== current) {
|
|
123
|
+
editor.commands.setContent(parsed.body);
|
|
124
|
+
}
|
|
125
|
+
}, [editor, value]);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="markdown-preview">
|
|
129
|
+
{frontmatter && (
|
|
130
|
+
<div className="mb-4 rounded-md border border-border bg-muted/40 overflow-hidden">
|
|
131
|
+
<div className="px-3 py-1.5 text-xs font-mono text-muted-foreground border-b border-border bg-muted/60 select-none">
|
|
132
|
+
yml
|
|
133
|
+
</div>
|
|
134
|
+
<textarea
|
|
135
|
+
ref={textareaRef}
|
|
136
|
+
value={fm}
|
|
137
|
+
onChange={handleFmChange}
|
|
138
|
+
readOnly={!editable}
|
|
139
|
+
className="w-full bg-transparent px-3 py-2 font-mono text-sm outline-none resize-none"
|
|
140
|
+
spellCheck={false}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
<EditorContent editor={editor} className="markdown-editor" />
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { AlertTriangle, Loader2, Save } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
export type SaveStatus = 'unsaved' | 'saving' | 'saved' | 'error';
|
|
4
|
+
|
|
5
|
+
interface SaveStatusButtonProps {
|
|
6
|
+
status: SaveStatus;
|
|
7
|
+
onClick: () => void;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
tone: 'muted' | 'primary';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getToneClass(tone: 'muted' | 'primary', status: SaveStatus): string {
|
|
13
|
+
if (status === 'error') {
|
|
14
|
+
return tone === 'muted'
|
|
15
|
+
? 'text-destructive hover:bg-muted'
|
|
16
|
+
: 'text-destructive hover:bg-destructive/10';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (status === 'saved') {
|
|
20
|
+
return tone === 'muted'
|
|
21
|
+
? 'text-muted-foreground/50 hover:bg-muted'
|
|
22
|
+
: 'text-primary/50 hover:bg-primary/10';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return tone === 'muted'
|
|
26
|
+
? 'text-muted-foreground hover:bg-muted'
|
|
27
|
+
: 'text-primary hover:bg-primary/20';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SaveStatusButton({
|
|
31
|
+
status,
|
|
32
|
+
onClick,
|
|
33
|
+
disabled = false,
|
|
34
|
+
tone,
|
|
35
|
+
}: SaveStatusButtonProps) {
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
onClick={onClick}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
className={`px-2 py-1 text-xs rounded flex items-center gap-1 disabled:opacity-50 ${getToneClass(tone, status)}`}
|
|
41
|
+
title="Save"
|
|
42
|
+
>
|
|
43
|
+
<span className="inline-flex h-3 w-3 items-center justify-center shrink-0">
|
|
44
|
+
{status === 'saving' ? (
|
|
45
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
46
|
+
) : status === 'error' ? (
|
|
47
|
+
<AlertTriangle className="h-3 w-3" />
|
|
48
|
+
) : (
|
|
49
|
+
<Save className={`h-3 w-3 ${status === 'saved' ? 'opacity-60' : 'opacity-100'}`} />
|
|
50
|
+
)}
|
|
51
|
+
</span>
|
|
52
|
+
Save
|
|
53
|
+
</button>
|
|
54
|
+
);
|
|
55
|
+
}
|