@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @aprovan/patchwork-editor@0.1.1 build /home/runner/work/patchwork/patchwork/packages/editor
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
  CLI Building entry: src/index.ts
@@ -9,5 +9,5 @@
9
9
  CLI Target: es2022
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
- ESM dist/index.js 78.44 KB
13
- ESM ⚡️ Build success in 242ms
12
+ ESM dist/index.js 82.02 KB
13
+ ESM ⚡️ 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-0.5 hover:bg-primary/20 rounded cursor-pointer",
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
- return /* @__PURE__ */ jsxs("div", { className: "h-full flex flex-col bg-muted/10", children: [
1188
- /* @__PURE__ */ jsx("div", { className: "flex items-center justify-between px-4 py-2 bg-muted/30 border-b text-xs", children: /* @__PURE__ */ jsx("span", { className: "font-mono text-muted-foreground", children: langLabel }) }),
1189
- editable ? /* @__PURE__ */ jsx(
1190
- "textarea",
1191
- {
1192
- ref: textareaRef,
1193
- value: content,
1194
- onChange: handleChange,
1195
- onKeyDown: handleKeyDown,
1196
- className: "w-full min-h-full font-mono text-xs leading-relaxed bg-transparent border-none outline-none resize-none",
1197
- spellCheck: false,
1198
- style: {
1199
- tabSize: 2,
1200
- WebkitTextFillColor: "inherit"
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.length;
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: previewContainer,
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.1-dev.6bd527d",
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.6bd527d",
28
- "@aprovan/patchwork-compiler": "0.1.2-dev.6bd527d"
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-muted/10">
49
- <div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b text-xs">
50
- <span className="font-mono text-muted-foreground">{langLabel}</span>
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
- <textarea
54
- ref={textareaRef}
55
- value={content}
56
- onChange={handleChange}
57
- onKeyDown={handleKeyDown}
58
- className="w-full min-h-full font-mono text-xs leading-relaxed bg-transparent border-none outline-none resize-none"
59
- spellCheck={false}
60
- style={{
61
- tabSize: 2,
62
- WebkitTextFillColor: 'inherit',
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
- <pre className="text-xs font-mono whitespace-pre-wrap break-words m-0 leading-relaxed">
67
- <code>{content}</code>
68
- </pre>
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={previewContainer}
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-0.5 hover:bg-primary/20 rounded cursor-pointer"
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
- const contentSize = content.length;
32
- const estimatedBytes = content.startsWith('data:')
33
- ? Math.floor((content.split(',')[1]?.length ?? 0) * 0.75)
34
- : Math.floor(content.length * 0.75);
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>