@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aprovan/patchwork-editor",
3
- "version": "0.1.2-dev.03aaf5b",
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/bobbin": "0.1.0-dev.03aaf5b",
29
- "@aprovan/patchwork-compiler": "0.1.2-dev.03aaf5b"
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, AlertCircle, Loader2, Pencil, RotateCcw, MessageSquare, Cloud, Check } from 'lucide-react';
3
- import type { Compiler, MountedWidget, Manifest } from '@aprovan/patchwork-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 useCodeCompiler(compiler: Compiler | null, code: string, enabled: boolean, services?: string[]) {
32
- const [loading, setLoading] = useState(false);
33
- const [error, setError] = useState<string | null>(null);
34
- const containerRef = useRef<HTMLDivElement>(null);
35
- const mountedRef = useRef<MountedWidget | null>(null);
36
-
37
- useEffect(() => {
38
- if (!enabled || !compiler || !containerRef.current) return;
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>('unsaved');
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 { containerRef, loading, error } = useCodeCompiler(
205
- compiler,
206
- currentCode,
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="my-3 border rounded-lg">
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={() => setIsEditing(true)}
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
- {error ? (
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="p-3 bg-muted/30 overflow-auto max-h-96">
334
- <pre className="text-xs whitespace-pre-wrap break-words m-0">
335
- <code>{currentCode}</code>
336
- </pre>
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) => <ModalPreview code={code} compiler={compiler} services={services} />}
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
+ }