@aprovan/patchwork-editor 0.1.0 → 0.1.2-dev.03aaf5b
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 +4 -7
- package/dist/components/CodeBlockExtension.d.ts +2 -0
- package/dist/components/CodePreview.d.ts +13 -0
- package/dist/components/MarkdownEditor.d.ts +10 -0
- package/dist/components/ServicesInspector.d.ts +49 -0
- package/dist/components/edit/CodeBlockView.d.ts +7 -0
- package/dist/components/edit/EditHistory.d.ts +10 -0
- package/dist/components/edit/EditModal.d.ts +20 -0
- package/dist/components/edit/FileTree.d.ts +8 -0
- package/dist/components/edit/MediaPreview.d.ts +6 -0
- package/dist/components/edit/SaveConfirmDialog.d.ts +9 -0
- package/dist/components/edit/api.d.ts +8 -0
- package/dist/components/edit/fileTypes.d.ts +14 -0
- package/dist/components/edit/index.d.ts +10 -0
- package/dist/components/edit/types.d.ts +41 -0
- package/dist/components/edit/useEditSession.d.ts +9 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/index.d.ts +3 -331
- package/dist/index.js +812 -142
- package/dist/lib/code-extractor.d.ts +44 -0
- package/dist/lib/diff.d.ts +90 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/vfs.d.ts +36 -0
- package/package.json +5 -4
- package/src/components/CodeBlockExtension.tsx +1 -1
- package/src/components/CodePreview.tsx +64 -4
- package/src/components/edit/CodeBlockView.tsx +188 -0
- package/src/components/edit/EditModal.tsx +172 -30
- package/src/components/edit/FileTree.tsx +67 -13
- package/src/components/edit/MediaPreview.tsx +124 -0
- package/src/components/edit/SaveConfirmDialog.tsx +60 -0
- package/src/components/edit/fileTypes.ts +125 -0
- package/src/components/edit/index.ts +4 -0
- package/src/components/edit/types.ts +3 -0
- package/src/components/edit/useEditSession.ts +56 -11
- package/src/index.ts +17 -0
- package/src/lib/diff.ts +2 -1
- package/src/lib/vfs.ts +28 -10
- package/tsup.config.ts +10 -5
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { VirtualProject } from '@aprovan/patchwork-compiler';
|
|
2
|
+
export type TextPart = {
|
|
3
|
+
type: 'text';
|
|
4
|
+
content: string;
|
|
5
|
+
};
|
|
6
|
+
export type CodePart = {
|
|
7
|
+
type: 'code' | string;
|
|
8
|
+
content: string;
|
|
9
|
+
language: 'jsx' | 'tsx' | string;
|
|
10
|
+
attributes?: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
export type ParsedPart = TextPart | CodePart;
|
|
13
|
+
export interface ExtractOptions {
|
|
14
|
+
/** Only extract these languages (default: all) */
|
|
15
|
+
filterLanguages?: Set<string>;
|
|
16
|
+
/** Include unclosed code blocks at the end (for streaming) */
|
|
17
|
+
includeUnclosed?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Extract code blocks from markdown text.
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractCodeBlocks(text: string, options?: ExtractOptions): ParsedPart[];
|
|
23
|
+
/**
|
|
24
|
+
* Find the first JSX/TSX block in the text.
|
|
25
|
+
* Returns null if no JSX block is found.
|
|
26
|
+
*/
|
|
27
|
+
export declare function findFirstCodeBlock(text: string): CodePart | null;
|
|
28
|
+
/**
|
|
29
|
+
* Check if text contains any JSX/TSX code blocks.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hasCodeBlock(text: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Get all unique languages found in code blocks.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getCodeBlockLanguages(text: string): Set<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Extract code blocks as a VirtualProject.
|
|
38
|
+
* Groups files with path attributes into a multi-file project.
|
|
39
|
+
* Files without paths are treated as the main entry file.
|
|
40
|
+
*/
|
|
41
|
+
export declare function extractProject(text: string, options?: ExtractOptions): {
|
|
42
|
+
project: VirtualProject;
|
|
43
|
+
textParts: TextPart[];
|
|
44
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export interface CodeBlockAttributes {
|
|
2
|
+
/** Progress note for UI display (optional but encouraged, comes first) */
|
|
3
|
+
note?: string;
|
|
4
|
+
/** Virtual file path for multi-file generation (uses \@/ prefix) */
|
|
5
|
+
path?: string;
|
|
6
|
+
/** Additional arbitrary attributes */
|
|
7
|
+
[key: string]: string | undefined;
|
|
8
|
+
}
|
|
9
|
+
export interface CodeBlock {
|
|
10
|
+
/** Language identifier (e.g., tsx, json, diff) */
|
|
11
|
+
language: string;
|
|
12
|
+
/** Parsed attributes from the fence line */
|
|
13
|
+
attributes: CodeBlockAttributes;
|
|
14
|
+
/** Raw content between the fence markers */
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
export interface DiffBlock {
|
|
18
|
+
search: string;
|
|
19
|
+
replace: string;
|
|
20
|
+
/** Progress note from the code fence attributes */
|
|
21
|
+
note?: string;
|
|
22
|
+
/** Target file path for multi-file edits */
|
|
23
|
+
path?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Parse attributes from a code fence line.
|
|
27
|
+
*
|
|
28
|
+
* Example input: 'note="Adding handler" path="\@/components/Button.tsx"'
|
|
29
|
+
*
|
|
30
|
+
* Returns: an object with note, path, and any other attributes
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseCodeBlockAttributes(attrString: string): CodeBlockAttributes;
|
|
33
|
+
/**
|
|
34
|
+
* Parse all code blocks from text, extracting language and attributes.
|
|
35
|
+
* Returns blocks in order of appearance.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseCodeBlocks(text: string): CodeBlock[];
|
|
38
|
+
/**
|
|
39
|
+
* Check if text contains any diff markers.
|
|
40
|
+
* Returns the first marker found, or null if clean.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findDiffMarkers(text: string): string | null;
|
|
43
|
+
/**
|
|
44
|
+
* Remove stray diff markers from text.
|
|
45
|
+
* Use as a fallback when markers leak into output.
|
|
46
|
+
*/
|
|
47
|
+
export declare function sanitizeDiffMarkers(text: string): string;
|
|
48
|
+
export interface ParsedEditResponse {
|
|
49
|
+
/** Progress notes extracted from code block attributes (in order of appearance) */
|
|
50
|
+
progressNotes: string[];
|
|
51
|
+
/** Parsed diff blocks with their attributes */
|
|
52
|
+
diffs: DiffBlock[];
|
|
53
|
+
/** Summary markdown text (content outside of code blocks) */
|
|
54
|
+
summary: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse progress notes and diffs from an edit response.
|
|
58
|
+
*
|
|
59
|
+
* ```diff note="Adding handler" path="@/components/Button.tsx"
|
|
60
|
+
* <<<<<<< SEARCH
|
|
61
|
+
* exact code
|
|
62
|
+
* =======
|
|
63
|
+
* replacement
|
|
64
|
+
* >>>>>>> REPLACE
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* Summary markdown is everything outside of code blocks.
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseEditResponse(text: string): ParsedEditResponse;
|
|
70
|
+
/**
|
|
71
|
+
* Parse diff blocks from text, extracting attributes from code fences.
|
|
72
|
+
* Supports both fenced code blocks with attributes and raw diff markers.
|
|
73
|
+
*/
|
|
74
|
+
export declare function parseDiffs(text: string): DiffBlock[];
|
|
75
|
+
export declare function applyDiffs(code: string, diffs: DiffBlock[], options?: {
|
|
76
|
+
sanitize?: boolean;
|
|
77
|
+
}): {
|
|
78
|
+
code: string;
|
|
79
|
+
applied: number;
|
|
80
|
+
failed: string[];
|
|
81
|
+
warning?: string;
|
|
82
|
+
};
|
|
83
|
+
export declare function hasDiffBlocks(text: string): boolean;
|
|
84
|
+
export declare function extractTextWithoutDiffs(text: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* Extract the summary markdown from an edit response.
|
|
87
|
+
* Removes code blocks (with their attributes), and any leading/trailing whitespace.
|
|
88
|
+
* Preserves regular markdown prose outside of code blocks.
|
|
89
|
+
*/
|
|
90
|
+
export declare function extractSummary(text: string): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { VFSStore, type ChangeRecord, type VirtualProject } from '@aprovan/patchwork-compiler';
|
|
2
|
+
/**
|
|
3
|
+
* Get VFS configuration from the server.
|
|
4
|
+
* Caches the result for subsequent calls.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getVFSConfig(): Promise<{
|
|
7
|
+
usePaths: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
/**
|
|
10
|
+
* Get the VFS store instance (creates one if needed).
|
|
11
|
+
* Store uses HttpBackend to persist to the stitchery server.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getVFSStore(): VFSStore;
|
|
14
|
+
/**
|
|
15
|
+
* Save a virtual project to disk via the stitchery server.
|
|
16
|
+
* Projects are saved under their ID in the VFS directory.
|
|
17
|
+
*/
|
|
18
|
+
export declare function saveProject(project: VirtualProject): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Load a project from disk by ID.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadProject(id: string): Promise<VirtualProject | null>;
|
|
23
|
+
/**
|
|
24
|
+
* List all stored project IDs.
|
|
25
|
+
*/
|
|
26
|
+
export declare function listProjects(): Promise<string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Save a single file to the VFS.
|
|
29
|
+
*/
|
|
30
|
+
export declare function saveFile(path: string, content: string): Promise<void>;
|
|
31
|
+
export declare function loadFile(path: string, encoding?: 'utf8' | 'base64'): Promise<string>;
|
|
32
|
+
export declare function subscribeToChanges(callback: (record: ChangeRecord) => void): () => void;
|
|
33
|
+
/**
|
|
34
|
+
* Check if VFS is available (stitchery server is running with vfs-dir enabled).
|
|
35
|
+
*/
|
|
36
|
+
export declare function isVFSAvailable(): Promise<boolean>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aprovan/patchwork-editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2-dev.03aaf5b",
|
|
4
4
|
"description": "Components for facilitating widget generation and editing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
"lucide-react": "^0.511.0",
|
|
23
23
|
"react-markdown": "^10.1.0",
|
|
24
24
|
"remark-gfm": "^4.0.1",
|
|
25
|
+
"shiki": "^3.22.0",
|
|
25
26
|
"tailwind-merge": "^3.4.0",
|
|
26
27
|
"tiptap-markdown": "^0.9.0",
|
|
27
|
-
"@aprovan/bobbin": "0.1.0",
|
|
28
|
-
"@aprovan/patchwork-compiler": "0.1.
|
|
28
|
+
"@aprovan/bobbin": "0.1.0-dev.03aaf5b",
|
|
29
|
+
"@aprovan/patchwork-compiler": "0.1.2-dev.03aaf5b"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
32
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"typescript": "^5.9.3"
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
41
|
-
"build": "tsup",
|
|
42
|
+
"build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --jsx react-jsx --lib ES2022,DOM --skipLibCheck src/index.ts",
|
|
42
43
|
"dev": "tsup --watch",
|
|
43
44
|
"typecheck": "tsc --noEmit"
|
|
44
45
|
}
|
|
@@ -115,7 +115,7 @@ export const CodeBlockExtension = Node.create({
|
|
|
115
115
|
default: null,
|
|
116
116
|
parseHTML: (element: HTMLElement) => {
|
|
117
117
|
const { languageClassPrefix } = this.options;
|
|
118
|
-
const classNames =
|
|
118
|
+
const classNames = Array.from(element.firstElementChild?.classList || []);
|
|
119
119
|
const languages = classNames
|
|
120
120
|
.filter((className) => className.startsWith(languageClassPrefix))
|
|
121
121
|
.map((className) => className.replace(languageClassPrefix, ''));
|
|
@@ -3,13 +3,15 @@ import { Code, Eye, AlertCircle, Loader2, Pencil, RotateCcw, MessageSquare, Clou
|
|
|
3
3
|
import type { Compiler, MountedWidget, Manifest } from '@aprovan/patchwork-compiler';
|
|
4
4
|
import { createSingleFileProject } from '@aprovan/patchwork-compiler';
|
|
5
5
|
import { EditModal, type CompileFn } from './edit';
|
|
6
|
-
import { saveProject, getVFSConfig } from '../lib/vfs';
|
|
6
|
+
import { saveProject, getVFSConfig, loadFile, subscribeToChanges } from '../lib/vfs';
|
|
7
7
|
|
|
8
8
|
type SaveStatus = 'unsaved' | 'saving' | 'saved' | 'error';
|
|
9
9
|
|
|
10
10
|
interface CodePreviewProps {
|
|
11
11
|
code: string;
|
|
12
12
|
compiler: Compiler | null;
|
|
13
|
+
/** Optional entrypoint file for the widget (default: "index.ts") */
|
|
14
|
+
entrypoint?: string;
|
|
13
15
|
/** Available service namespaces for widget calls */
|
|
14
16
|
services?: string[];
|
|
15
17
|
/** Optional file path from code block attributes (e.g., "components/calculator.tsx") */
|
|
@@ -90,12 +92,17 @@ function useCodeCompiler(compiler: Compiler | null, code: string, enabled: boole
|
|
|
90
92
|
return { containerRef, loading, error };
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
export function CodePreview({ code: originalCode, compiler, services, filePath }: CodePreviewProps) {
|
|
95
|
+
export function CodePreview({ code: originalCode, compiler, services, filePath, entrypoint = 'index.ts' }: CodePreviewProps) {
|
|
94
96
|
const [isEditing, setIsEditing] = useState(false);
|
|
95
97
|
const [showPreview, setShowPreview] = useState(true);
|
|
96
98
|
const [currentCode, setCurrentCode] = useState(originalCode);
|
|
97
99
|
const [editCount, setEditCount] = useState(0);
|
|
98
100
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('unsaved');
|
|
101
|
+
const [lastSavedCode, setLastSavedCode] = useState(originalCode);
|
|
102
|
+
const [vfsPath, setVfsPath] = useState<string | null>(null);
|
|
103
|
+
const currentCodeRef = useRef(currentCode);
|
|
104
|
+
const lastSavedRef = useRef(lastSavedCode);
|
|
105
|
+
const isEditingRef = useRef(isEditing);
|
|
99
106
|
|
|
100
107
|
// Stable project ID for this widget instance (fallback when not using paths)
|
|
101
108
|
const fallbackId = useMemo(() => crypto.randomUUID(), []);
|
|
@@ -121,11 +128,63 @@ export function CodePreview({ code: originalCode, compiler, services, filePath }
|
|
|
121
128
|
const getEntryFile = useCallback(() => {
|
|
122
129
|
if (filePath) {
|
|
123
130
|
const parts = filePath.split('/');
|
|
124
|
-
return parts[parts.length - 1] ||
|
|
131
|
+
return parts[parts.length - 1] || entrypoint;
|
|
125
132
|
}
|
|
126
|
-
return
|
|
133
|
+
return entrypoint;
|
|
127
134
|
}, [filePath]);
|
|
128
135
|
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
currentCodeRef.current = currentCode;
|
|
138
|
+
}, [currentCode]);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
lastSavedRef.current = lastSavedCode;
|
|
142
|
+
}, [lastSavedCode]);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
isEditingRef.current = isEditing;
|
|
146
|
+
}, [isEditing]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
let active = true;
|
|
150
|
+
void (async () => {
|
|
151
|
+
const projectId = await getProjectId();
|
|
152
|
+
const entryFile = getEntryFile();
|
|
153
|
+
if (!active) return;
|
|
154
|
+
setVfsPath(`${projectId}/${entryFile}`);
|
|
155
|
+
})();
|
|
156
|
+
return () => {
|
|
157
|
+
active = false;
|
|
158
|
+
};
|
|
159
|
+
}, [getProjectId, getEntryFile]);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!vfsPath) return;
|
|
163
|
+
const unsubscribe = subscribeToChanges(async (record) => {
|
|
164
|
+
if (record.path !== vfsPath) return;
|
|
165
|
+
if (record.type === 'delete') {
|
|
166
|
+
setSaveStatus('unsaved');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (isEditingRef.current) return;
|
|
170
|
+
try {
|
|
171
|
+
const remote = await loadFile(vfsPath);
|
|
172
|
+
if (currentCodeRef.current !== lastSavedRef.current) {
|
|
173
|
+
setSaveStatus('unsaved');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (remote !== currentCodeRef.current) {
|
|
177
|
+
setCurrentCode(remote);
|
|
178
|
+
setLastSavedCode(remote);
|
|
179
|
+
setSaveStatus('saved');
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
setSaveStatus('error');
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
return () => unsubscribe();
|
|
186
|
+
}, [vfsPath]);
|
|
187
|
+
|
|
129
188
|
// Manual save handler
|
|
130
189
|
const handleSave = useCallback(async () => {
|
|
131
190
|
setSaveStatus('saving');
|
|
@@ -134,6 +193,7 @@ export function CodePreview({ code: originalCode, compiler, services, filePath }
|
|
|
134
193
|
const entryFile = getEntryFile();
|
|
135
194
|
const project = createSingleFileProject(currentCode, entryFile, projectId);
|
|
136
195
|
await saveProject(project);
|
|
196
|
+
setLastSavedCode(currentCode);
|
|
137
197
|
setSaveStatus('saved');
|
|
138
198
|
} catch (err) {
|
|
139
199
|
console.warn('[VFS] Failed to save project:', err);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
|
2
|
+
import { createHighlighter, type Highlighter, type BundledLanguage } from 'shiki';
|
|
3
|
+
|
|
4
|
+
// Singleton highlighter instance
|
|
5
|
+
let highlighterPromise: Promise<Highlighter> | null = null;
|
|
6
|
+
|
|
7
|
+
const COMMON_LANGUAGES: BundledLanguage[] = [
|
|
8
|
+
'typescript',
|
|
9
|
+
'javascript',
|
|
10
|
+
'tsx',
|
|
11
|
+
'jsx',
|
|
12
|
+
'json',
|
|
13
|
+
'html',
|
|
14
|
+
'css',
|
|
15
|
+
'markdown',
|
|
16
|
+
'yaml',
|
|
17
|
+
'python',
|
|
18
|
+
'bash',
|
|
19
|
+
'sql',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function getHighlighter(): Promise<Highlighter> {
|
|
23
|
+
if (!highlighterPromise) {
|
|
24
|
+
highlighterPromise = createHighlighter({
|
|
25
|
+
themes: ['github-light'],
|
|
26
|
+
langs: COMMON_LANGUAGES,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return highlighterPromise;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Map common file extensions/language names to shiki language identifiers
|
|
33
|
+
function normalizeLanguage(lang: string | null): BundledLanguage {
|
|
34
|
+
if (!lang) return 'typescript';
|
|
35
|
+
const normalized = lang.toLowerCase();
|
|
36
|
+
const mapping: Record<string, BundledLanguage> = {
|
|
37
|
+
ts: 'typescript',
|
|
38
|
+
tsx: 'tsx',
|
|
39
|
+
js: 'javascript',
|
|
40
|
+
jsx: 'jsx',
|
|
41
|
+
json: 'json',
|
|
42
|
+
html: 'html',
|
|
43
|
+
css: 'css',
|
|
44
|
+
md: 'markdown',
|
|
45
|
+
markdown: 'markdown',
|
|
46
|
+
yml: 'yaml',
|
|
47
|
+
yaml: 'yaml',
|
|
48
|
+
py: 'python',
|
|
49
|
+
python: 'python',
|
|
50
|
+
sh: 'bash',
|
|
51
|
+
bash: 'bash',
|
|
52
|
+
sql: 'sql',
|
|
53
|
+
typescript: 'typescript',
|
|
54
|
+
javascript: 'javascript',
|
|
55
|
+
};
|
|
56
|
+
return mapping[normalized] || 'typescript';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CodeBlockViewProps {
|
|
60
|
+
content: string;
|
|
61
|
+
language: string | null;
|
|
62
|
+
editable?: boolean;
|
|
63
|
+
onChange?: (content: string) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function CodeBlockView({ content, language, editable = false, onChange }: CodeBlockViewProps) {
|
|
67
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
68
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
const [highlighter, setHighlighter] = useState<Highlighter | null>(null);
|
|
70
|
+
|
|
71
|
+
// Load the highlighter
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
let mounted = true;
|
|
74
|
+
getHighlighter().then((h) => {
|
|
75
|
+
if (mounted) setHighlighter(h);
|
|
76
|
+
});
|
|
77
|
+
return () => { mounted = false; };
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
// Auto-resize textarea
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (textareaRef.current) {
|
|
83
|
+
textareaRef.current.style.height = 'auto';
|
|
84
|
+
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
85
|
+
}
|
|
86
|
+
}, [content]);
|
|
87
|
+
|
|
88
|
+
const handleChange = useCallback(
|
|
89
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
90
|
+
onChange?.(e.target.value);
|
|
91
|
+
},
|
|
92
|
+
[onChange]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const handleKeyDown = useCallback(
|
|
96
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
97
|
+
if (e.key === 'Tab') {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
const target = e.target as HTMLTextAreaElement;
|
|
100
|
+
const start = target.selectionStart;
|
|
101
|
+
const end = target.selectionEnd;
|
|
102
|
+
const value = target.value;
|
|
103
|
+
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
|
104
|
+
onChange?.(newValue);
|
|
105
|
+
requestAnimationFrame(() => {
|
|
106
|
+
target.selectionStart = target.selectionEnd = start + 2;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[onChange]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const langLabel = language || 'text';
|
|
114
|
+
const shikiLang = useMemo(() => normalizeLanguage(language), [language]);
|
|
115
|
+
|
|
116
|
+
// Generate highlighted HTML
|
|
117
|
+
const highlightedHtml = useMemo(() => {
|
|
118
|
+
if (!highlighter) return null;
|
|
119
|
+
try {
|
|
120
|
+
return highlighter.codeToHtml(content, {
|
|
121
|
+
lang: shikiLang,
|
|
122
|
+
theme: 'github-light',
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// Fallback if language is not supported
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}, [highlighter, content, shikiLang]);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="h-full flex flex-col bg-[#ffffff]">
|
|
132
|
+
<div className="flex items-center justify-between px-4 py-2 bg-[#f6f8fa] border-b border-[#d0d7de] text-xs">
|
|
133
|
+
<span className="font-mono text-[#57606a]">{langLabel}</span>
|
|
134
|
+
</div>
|
|
135
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
|
136
|
+
{editable ? (
|
|
137
|
+
<div className="relative min-h-full">
|
|
138
|
+
{/* Highlighted code layer (background) - scrolls with content */}
|
|
139
|
+
<div
|
|
140
|
+
ref={containerRef}
|
|
141
|
+
className="absolute top-0 left-0 right-0 pointer-events-none p-4"
|
|
142
|
+
aria-hidden="true"
|
|
143
|
+
>
|
|
144
|
+
{highlightedHtml ? (
|
|
145
|
+
<div
|
|
146
|
+
className="highlighted-code font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_code]:!bg-transparent [&_code]:whitespace-pre-wrap [&_code]:break-words"
|
|
147
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
148
|
+
/>
|
|
149
|
+
) : (
|
|
150
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words text-[#24292f] m-0 leading-relaxed">
|
|
151
|
+
<code>{content}</code>
|
|
152
|
+
</pre>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
{/* Editable textarea layer (foreground) */}
|
|
156
|
+
<textarea
|
|
157
|
+
ref={textareaRef}
|
|
158
|
+
value={content}
|
|
159
|
+
onChange={handleChange}
|
|
160
|
+
onKeyDown={handleKeyDown}
|
|
161
|
+
className="relative w-full min-h-full font-mono text-xs leading-relaxed bg-transparent border-none outline-none resize-none p-4 text-transparent whitespace-pre-wrap break-words"
|
|
162
|
+
spellCheck={false}
|
|
163
|
+
style={{
|
|
164
|
+
tabSize: 2,
|
|
165
|
+
caretColor: '#24292f',
|
|
166
|
+
wordBreak: 'break-word',
|
|
167
|
+
overflowWrap: 'break-word',
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
) : (
|
|
172
|
+
<div className="p-4">
|
|
173
|
+
{highlightedHtml ? (
|
|
174
|
+
<div
|
|
175
|
+
className="highlighted-code font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_code]:!bg-transparent [&_code]:whitespace-pre-wrap [&_code]:break-words"
|
|
176
|
+
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
177
|
+
/>
|
|
178
|
+
) : (
|
|
179
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed text-[#24292f]">
|
|
180
|
+
<code>{content}</code>
|
|
181
|
+
</pre>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|