@aprovan/patchwork-editor 0.1.1-dev.6bd527d → 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
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @aprovan/patchwork-editor@0.1.
|
|
2
|
+
> @aprovan/patchwork-editor@0.1.2 build /home/runner/work/patchwork/patchwork/packages/editor
|
|
3
3
|
> tsup && tsc --declaration --emitDeclarationOnly --outDir dist --jsx react-jsx --lib ES2022,DOM --skipLibCheck src/index.ts
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mCLI[39m Cleaning output folder
|
|
11
11
|
[34mESM[39m Build start
|
|
12
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
13
|
-
[32mESM[39m ⚡️ Build success in
|
|
12
|
+
[32mESM[39m [1mdist/index.js [22m[32m82.02 KB[39m
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 233ms
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import Placeholder from '@tiptap/extension-placeholder';
|
|
|
11
11
|
import Typography from '@tiptap/extension-typography';
|
|
12
12
|
import { Markdown as Markdown$1 } from 'tiptap-markdown';
|
|
13
13
|
import { TextSelection } from '@tiptap/pm/state';
|
|
14
|
+
import { createHighlighter } from 'shiki';
|
|
14
15
|
import { Bobbin, serializeChangesToYAML } from '@aprovan/bobbin';
|
|
15
16
|
import { clsx } from 'clsx';
|
|
16
17
|
import { twMerge } from 'tailwind-merge';
|
|
@@ -1066,7 +1067,7 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
1066
1067
|
"span",
|
|
1067
1068
|
{
|
|
1068
1069
|
onClick: handleUploadClick,
|
|
1069
|
-
className: "p-
|
|
1070
|
+
className: "p-1 hover:bg-primary/20 rounded cursor-pointer",
|
|
1070
1071
|
title: "Replace file",
|
|
1071
1072
|
children: /* @__PURE__ */ jsx(Upload, { className: "h-3 w-3 text-primary" })
|
|
1072
1073
|
}
|
|
@@ -1152,8 +1153,68 @@ function SaveConfirmDialog({
|
|
|
1152
1153
|
] })
|
|
1153
1154
|
] }) });
|
|
1154
1155
|
}
|
|
1156
|
+
var highlighterPromise = null;
|
|
1157
|
+
var COMMON_LANGUAGES = [
|
|
1158
|
+
"typescript",
|
|
1159
|
+
"javascript",
|
|
1160
|
+
"tsx",
|
|
1161
|
+
"jsx",
|
|
1162
|
+
"json",
|
|
1163
|
+
"html",
|
|
1164
|
+
"css",
|
|
1165
|
+
"markdown",
|
|
1166
|
+
"yaml",
|
|
1167
|
+
"python",
|
|
1168
|
+
"bash",
|
|
1169
|
+
"sql"
|
|
1170
|
+
];
|
|
1171
|
+
function getHighlighter() {
|
|
1172
|
+
if (!highlighterPromise) {
|
|
1173
|
+
highlighterPromise = createHighlighter({
|
|
1174
|
+
themes: ["github-light"],
|
|
1175
|
+
langs: COMMON_LANGUAGES
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
return highlighterPromise;
|
|
1179
|
+
}
|
|
1180
|
+
function normalizeLanguage(lang) {
|
|
1181
|
+
if (!lang) return "typescript";
|
|
1182
|
+
const normalized = lang.toLowerCase();
|
|
1183
|
+
const mapping = {
|
|
1184
|
+
ts: "typescript",
|
|
1185
|
+
tsx: "tsx",
|
|
1186
|
+
js: "javascript",
|
|
1187
|
+
jsx: "jsx",
|
|
1188
|
+
json: "json",
|
|
1189
|
+
html: "html",
|
|
1190
|
+
css: "css",
|
|
1191
|
+
md: "markdown",
|
|
1192
|
+
markdown: "markdown",
|
|
1193
|
+
yml: "yaml",
|
|
1194
|
+
yaml: "yaml",
|
|
1195
|
+
py: "python",
|
|
1196
|
+
python: "python",
|
|
1197
|
+
sh: "bash",
|
|
1198
|
+
bash: "bash",
|
|
1199
|
+
sql: "sql",
|
|
1200
|
+
typescript: "typescript",
|
|
1201
|
+
javascript: "javascript"
|
|
1202
|
+
};
|
|
1203
|
+
return mapping[normalized] || "typescript";
|
|
1204
|
+
}
|
|
1155
1205
|
function CodeBlockView({ content, language, editable = false, onChange }) {
|
|
1156
1206
|
const textareaRef = useRef(null);
|
|
1207
|
+
const containerRef = useRef(null);
|
|
1208
|
+
const [highlighter, setHighlighter] = useState(null);
|
|
1209
|
+
useEffect(() => {
|
|
1210
|
+
let mounted = true;
|
|
1211
|
+
getHighlighter().then((h) => {
|
|
1212
|
+
if (mounted) setHighlighter(h);
|
|
1213
|
+
});
|
|
1214
|
+
return () => {
|
|
1215
|
+
mounted = false;
|
|
1216
|
+
};
|
|
1217
|
+
}, []);
|
|
1157
1218
|
useEffect(() => {
|
|
1158
1219
|
if (textareaRef.current) {
|
|
1159
1220
|
textareaRef.current.style.height = "auto";
|
|
@@ -1184,23 +1245,60 @@ function CodeBlockView({ content, language, editable = false, onChange }) {
|
|
|
1184
1245
|
[onChange]
|
|
1185
1246
|
);
|
|
1186
1247
|
const langLabel = language || "text";
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
{
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1248
|
+
const shikiLang = useMemo(() => normalizeLanguage(language), [language]);
|
|
1249
|
+
const highlightedHtml = useMemo(() => {
|
|
1250
|
+
if (!highlighter) return null;
|
|
1251
|
+
try {
|
|
1252
|
+
return highlighter.codeToHtml(content, {
|
|
1253
|
+
lang: shikiLang,
|
|
1254
|
+
theme: "github-light"
|
|
1255
|
+
});
|
|
1256
|
+
} catch {
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
}, [highlighter, content, shikiLang]);
|
|
1260
|
+
return /* @__PURE__ */ jsxs("div", { className: "h-full flex flex-col bg-[#ffffff]", children: [
|
|
1261
|
+
/* @__PURE__ */ jsx("div", { className: "flex items-center justify-between px-4 py-2 bg-[#f6f8fa] border-b border-[#d0d7de] text-xs", children: /* @__PURE__ */ jsx("span", { className: "font-mono text-[#57606a]", children: langLabel }) }),
|
|
1262
|
+
/* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto overflow-x-hidden", children: editable ? /* @__PURE__ */ jsxs("div", { className: "relative min-h-full", children: [
|
|
1263
|
+
/* @__PURE__ */ jsx(
|
|
1264
|
+
"div",
|
|
1265
|
+
{
|
|
1266
|
+
ref: containerRef,
|
|
1267
|
+
className: "absolute top-0 left-0 right-0 pointer-events-none p-4",
|
|
1268
|
+
"aria-hidden": "true",
|
|
1269
|
+
children: highlightedHtml ? /* @__PURE__ */ jsx(
|
|
1270
|
+
"div",
|
|
1271
|
+
{
|
|
1272
|
+
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",
|
|
1273
|
+
dangerouslySetInnerHTML: { __html: highlightedHtml }
|
|
1274
|
+
}
|
|
1275
|
+
) : /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words text-[#24292f] m-0 leading-relaxed", children: /* @__PURE__ */ jsx("code", { children: content }) })
|
|
1201
1276
|
}
|
|
1277
|
+
),
|
|
1278
|
+
/* @__PURE__ */ jsx(
|
|
1279
|
+
"textarea",
|
|
1280
|
+
{
|
|
1281
|
+
ref: textareaRef,
|
|
1282
|
+
value: content,
|
|
1283
|
+
onChange: handleChange,
|
|
1284
|
+
onKeyDown: handleKeyDown,
|
|
1285
|
+
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",
|
|
1286
|
+
spellCheck: false,
|
|
1287
|
+
style: {
|
|
1288
|
+
tabSize: 2,
|
|
1289
|
+
caretColor: "#24292f",
|
|
1290
|
+
wordBreak: "break-word",
|
|
1291
|
+
overflowWrap: "break-word"
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
)
|
|
1295
|
+
] }) : /* @__PURE__ */ jsx("div", { className: "p-4", children: highlightedHtml ? /* @__PURE__ */ jsx(
|
|
1296
|
+
"div",
|
|
1297
|
+
{
|
|
1298
|
+
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",
|
|
1299
|
+
dangerouslySetInnerHTML: { __html: highlightedHtml }
|
|
1202
1300
|
}
|
|
1203
|
-
) : /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed", children: /* @__PURE__ */ jsx("code", { children: content }) })
|
|
1301
|
+
) : /* @__PURE__ */ jsx("pre", { className: "text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed text-[#24292f]", children: /* @__PURE__ */ jsx("code", { children: content }) }) }) })
|
|
1204
1302
|
] });
|
|
1205
1303
|
}
|
|
1206
1304
|
function formatFileSize(bytes) {
|
|
@@ -1208,10 +1306,16 @@ function formatFileSize(bytes) {
|
|
|
1208
1306
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1209
1307
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1210
1308
|
}
|
|
1309
|
+
function isUrl(content) {
|
|
1310
|
+
return content.startsWith("/") || content.startsWith("http://") || content.startsWith("https://") || content.startsWith("./") || content.startsWith("../");
|
|
1311
|
+
}
|
|
1211
1312
|
function getDataUrl(content, mimeType) {
|
|
1212
1313
|
if (content.startsWith("data:")) {
|
|
1213
1314
|
return content;
|
|
1214
1315
|
}
|
|
1316
|
+
if (isUrl(content)) {
|
|
1317
|
+
return content;
|
|
1318
|
+
}
|
|
1215
1319
|
return `data:${mimeType};base64,${content}`;
|
|
1216
1320
|
}
|
|
1217
1321
|
function MediaPreview({ content, mimeType, fileName }) {
|
|
@@ -1220,8 +1324,8 @@ function MediaPreview({ content, mimeType, fileName }) {
|
|
|
1220
1324
|
const dataUrl = getDataUrl(content, mimeType);
|
|
1221
1325
|
const isImage = isImageFile(fileName);
|
|
1222
1326
|
const isVideo = isVideoFile(fileName);
|
|
1223
|
-
content
|
|
1224
|
-
const estimatedBytes = content.startsWith("data:") ? Math.floor((content.split(",")[1]?.length ?? 0) * 0.75) : Math.floor(content.length * 0.75);
|
|
1327
|
+
const isUrlContent = isUrl(content);
|
|
1328
|
+
const estimatedBytes = isUrlContent ? null : content.startsWith("data:") ? Math.floor((content.split(",")[1]?.length ?? 0) * 0.75) : Math.floor(content.length * 0.75);
|
|
1225
1329
|
useEffect(() => {
|
|
1226
1330
|
setDimensions(null);
|
|
1227
1331
|
setError(null);
|
|
@@ -1281,7 +1385,7 @@ function MediaPreview({ content, mimeType, fileName }) {
|
|
|
1281
1385
|
dimensions.height,
|
|
1282
1386
|
" px"
|
|
1283
1387
|
] }),
|
|
1284
|
-
/* @__PURE__ */ jsx("span", { children: formatFileSize(estimatedBytes) }),
|
|
1388
|
+
estimatedBytes !== null && /* @__PURE__ */ jsx("span", { children: formatFileSize(estimatedBytes) }),
|
|
1285
1389
|
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground/60", children: mimeType })
|
|
1286
1390
|
] })
|
|
1287
1391
|
] })
|
|
@@ -1315,6 +1419,7 @@ function EditModal({
|
|
|
1315
1419
|
const [editInput, setEditInput] = useState("");
|
|
1316
1420
|
const [bobbinChanges, setBobbinChanges] = useState([]);
|
|
1317
1421
|
const [previewContainer, setPreviewContainer] = useState(null);
|
|
1422
|
+
const [pillContainer, setPillContainer] = useState(null);
|
|
1318
1423
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
1319
1424
|
const [isSaving, setIsSaving] = useState(false);
|
|
1320
1425
|
const [saveError, setSaveError] = useState(null);
|
|
@@ -1488,7 +1593,7 @@ ${bobbinYaml}
|
|
|
1488
1593
|
onReplaceFile: session.replaceFile
|
|
1489
1594
|
}
|
|
1490
1595
|
),
|
|
1491
|
-
/* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto", children: fileType.category === "compilable" && showPreview ? /* @__PURE__ */ jsxs("div", { className: "bg-white h-full relative", ref: setPreviewContainer, children: [
|
|
1596
|
+
/* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto", ref: setPillContainer, children: fileType.category === "compilable" && showPreview ? /* @__PURE__ */ jsxs("div", { className: "bg-white h-full relative", ref: setPreviewContainer, children: [
|
|
1492
1597
|
previewError && renderError ? renderError(previewError) : previewError ? /* @__PURE__ */ jsxs("div", { className: "p-4 text-sm text-destructive flex items-center gap-2", children: [
|
|
1493
1598
|
/* @__PURE__ */ jsx(AlertCircle, { className: "h-4 w-4 shrink-0" }),
|
|
1494
1599
|
/* @__PURE__ */ jsx("span", { children: previewError })
|
|
@@ -1500,7 +1605,7 @@ ${bobbinYaml}
|
|
|
1500
1605
|
Bobbin,
|
|
1501
1606
|
{
|
|
1502
1607
|
container: previewContainer,
|
|
1503
|
-
pillContainer
|
|
1608
|
+
pillContainer,
|
|
1504
1609
|
defaultActive: false,
|
|
1505
1610
|
showInspector: true,
|
|
1506
1611
|
onChanges: handleBobbinChanges,
|
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-dev.
|
|
28
|
-
"@aprovan/patchwork-compiler": "0.1.2-dev.
|
|
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",
|
|
@@ -1,4 +1,60 @@
|
|
|
1
|
-
import { useCallback, useRef, useEffect } from 'react';
|
|
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
|
+
}
|
|
2
58
|
|
|
3
59
|
export interface CodeBlockViewProps {
|
|
4
60
|
content: string;
|
|
@@ -9,7 +65,19 @@ export interface CodeBlockViewProps {
|
|
|
9
65
|
|
|
10
66
|
export function CodeBlockView({ content, language, editable = false, onChange }: CodeBlockViewProps) {
|
|
11
67
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
68
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
const [highlighter, setHighlighter] = useState<Highlighter | null>(null);
|
|
12
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
|
|
13
81
|
useEffect(() => {
|
|
14
82
|
if (textareaRef.current) {
|
|
15
83
|
textareaRef.current.style.height = 'auto';
|
|
@@ -43,30 +111,78 @@ export function CodeBlockView({ content, language, editable = false, onChange }:
|
|
|
43
111
|
);
|
|
44
112
|
|
|
45
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]);
|
|
46
129
|
|
|
47
130
|
return (
|
|
48
|
-
<div className="h-full flex flex-col bg-
|
|
49
|
-
<div className="flex items-center justify-between px-4 py-2 bg-
|
|
50
|
-
<span className="font-mono text-
|
|
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>
|
|
51
134
|
</div>
|
|
135
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
|
52
136
|
{editable ? (
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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>
|
|
65
171
|
) : (
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
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>
|
|
69
184
|
)}
|
|
185
|
+
</div>
|
|
70
186
|
</div>
|
|
71
187
|
);
|
|
72
188
|
}
|
|
@@ -72,6 +72,7 @@ export function EditModal({
|
|
|
72
72
|
const [editInput, setEditInput] = useState('');
|
|
73
73
|
const [bobbinChanges, setBobbinChanges] = useState<Change[]>([]);
|
|
74
74
|
const [previewContainer, setPreviewContainer] = useState<HTMLDivElement | null>(null);
|
|
75
|
+
const [pillContainer, setPillContainer] = useState<HTMLDivElement | null>(null);
|
|
75
76
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
76
77
|
const [isSaving, setIsSaving] = useState(false);
|
|
77
78
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
@@ -256,7 +257,7 @@ export function EditModal({
|
|
|
256
257
|
onReplaceFile={session.replaceFile}
|
|
257
258
|
/>
|
|
258
259
|
)}
|
|
259
|
-
<div className="flex-1 overflow-auto">
|
|
260
|
+
<div className="flex-1 overflow-auto" ref={setPillContainer}>
|
|
260
261
|
{fileType.category === 'compilable' && showPreview ? (
|
|
261
262
|
<div className="bg-white h-full relative" ref={setPreviewContainer}>
|
|
262
263
|
{previewError && renderError ? (
|
|
@@ -278,7 +279,7 @@ export function EditModal({
|
|
|
278
279
|
)}
|
|
279
280
|
{!renderLoading && !renderError && !previewLoading && <Bobbin
|
|
280
281
|
container={previewContainer}
|
|
281
|
-
pillContainer={
|
|
282
|
+
pillContainer={pillContainer}
|
|
282
283
|
defaultActive={false}
|
|
283
284
|
showInspector
|
|
284
285
|
onChanges={handleBobbinChanges}
|
|
@@ -150,7 +150,7 @@ function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth =
|
|
|
150
150
|
{showUpload && (
|
|
151
151
|
<span
|
|
152
152
|
onClick={handleUploadClick}
|
|
153
|
-
className="p-
|
|
153
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
154
154
|
title="Replace file"
|
|
155
155
|
>
|
|
156
156
|
<Upload className="h-3 w-3 text-primary" />
|
|
@@ -14,10 +14,25 @@ function formatFileSize(bytes: number): string {
|
|
|
14
14
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function isUrl(content: string): boolean {
|
|
18
|
+
// Check if content is a URL (absolute or relative path)
|
|
19
|
+
return content.startsWith('/') ||
|
|
20
|
+
content.startsWith('http://') ||
|
|
21
|
+
content.startsWith('https://') ||
|
|
22
|
+
content.startsWith('./') ||
|
|
23
|
+
content.startsWith('../');
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
function getDataUrl(content: string, mimeType: string): string {
|
|
27
|
+
// If already a data URL, return as-is
|
|
18
28
|
if (content.startsWith('data:')) {
|
|
19
29
|
return content;
|
|
20
30
|
}
|
|
31
|
+
// If content is a URL path, return it directly (browser can fetch it)
|
|
32
|
+
if (isUrl(content)) {
|
|
33
|
+
return content;
|
|
34
|
+
}
|
|
35
|
+
// Otherwise, treat as raw base64 data and construct a data URL
|
|
21
36
|
return `data:${mimeType};base64,${content}`;
|
|
22
37
|
}
|
|
23
38
|
|
|
@@ -28,10 +43,13 @@ export function MediaPreview({ content, mimeType, fileName }: MediaPreviewProps)
|
|
|
28
43
|
const dataUrl = getDataUrl(content, mimeType);
|
|
29
44
|
const isImage = isImageFile(fileName);
|
|
30
45
|
const isVideo = isVideoFile(fileName);
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
// For URLs, we can't estimate file size from the string
|
|
47
|
+
const isUrlContent = isUrl(content);
|
|
48
|
+
const estimatedBytes = isUrlContent
|
|
49
|
+
? null
|
|
50
|
+
: content.startsWith('data:')
|
|
51
|
+
? Math.floor((content.split(',')[1]?.length ?? 0) * 0.75)
|
|
52
|
+
: Math.floor(content.length * 0.75);
|
|
35
53
|
|
|
36
54
|
useEffect(() => {
|
|
37
55
|
setDimensions(null);
|
|
@@ -97,7 +115,7 @@ export function MediaPreview({ content, mimeType, fileName }: MediaPreviewProps)
|
|
|
97
115
|
{dimensions && (
|
|
98
116
|
<span>{dimensions.width} × {dimensions.height} px</span>
|
|
99
117
|
)}
|
|
100
|
-
<span>{formatFileSize(estimatedBytes)}</span>
|
|
118
|
+
{estimatedBytes !== null && <span>{formatFileSize(estimatedBytes)}</span>}
|
|
101
119
|
<span className="text-muted-foreground/60">{mimeType}</span>
|
|
102
120
|
</div>
|
|
103
121
|
</div>
|