@optilogic/core 1.0.0-beta.9 → 1.0.0
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/dist/index.cjs +1115 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +326 -1
- package/dist/index.d.ts +326 -1
- package/dist/index.js +1097 -46
- package/dist/index.js.map +1 -1
- package/dist/styles.css +22 -0
- package/dist/tailwind-preset.cjs +17 -2
- package/dist/tailwind-preset.cjs.map +1 -1
- package/dist/tailwind-preset.js +17 -2
- package/dist/tailwind-preset.js.map +1 -1
- package/package.json +15 -1
- package/src/components/autocomplete.tsx +2 -1
- package/src/components/button.tsx +10 -8
- package/src/components/calendar.tsx +7 -7
- package/src/components/data-grid/DataGrid.tsx +6 -1
- package/src/components/data-grid/components/CellEditor.tsx +3 -3
- package/src/components/data-grid/hooks/useDataGridState.ts +18 -3
- package/src/components/data-grid/types.ts +4 -0
- package/src/components/data-grid/utils/dataProcessing.ts +40 -11
- package/src/components/date-picker.tsx +2 -1
- package/src/components/dropdown-menu.tsx +1 -1
- package/src/components/file-view/FileView.tsx +147 -0
- package/src/components/file-view/components/CodeRenderer.tsx +97 -0
- package/src/components/file-view/components/CsvRenderer.tsx +127 -0
- package/src/components/file-view/components/HtmlRenderer.tsx +24 -0
- package/src/components/file-view/components/ImageRenderer.tsx +67 -0
- package/src/components/file-view/components/MarkdownRenderer.tsx +304 -0
- package/src/components/file-view/components/PlainTextRenderer.tsx +27 -0
- package/src/components/file-view/components/index.ts +4 -0
- package/src/components/file-view/hooks/index.ts +5 -0
- package/src/components/file-view/hooks/useContentType.ts +34 -0
- package/src/components/file-view/hooks/useDarkMode.ts +62 -0
- package/src/components/file-view/hooks/useHighlightedTokens.ts +83 -0
- package/src/components/file-view/hooks/useShikiHighlighter.ts +69 -0
- package/src/components/file-view/index.ts +47 -0
- package/src/components/file-view/types.ts +180 -0
- package/src/components/file-view/utils/contentTypeDetection.ts +157 -0
- package/src/components/file-view/utils/index.ts +12 -0
- package/src/components/file-view/utils/languageMapping.ts +78 -0
- package/src/components/file-view/utils/rendererRegistry.ts +42 -0
- package/src/components/input.tsx +1 -1
- package/src/components/popover.tsx +1 -1
- package/src/components/select.tsx +1 -1
- package/src/components/switch.tsx +5 -3
- package/src/components/textarea.tsx +1 -1
- package/src/index.ts +39 -0
- package/src/styles.css +22 -0
- package/src/tailwind-preset.ts +17 -1
- package/src/theme/index.ts +5 -0
- package/src/theme/presets.ts +112 -2
- package/src/theme/types.ts +35 -0
- package/src/theme/utils.ts +231 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import ReactMarkdownImport from "react-markdown";
|
|
3
|
+
import remarkGfmImport from "remark-gfm";
|
|
4
|
+
import { cn } from "../../../utils/cn";
|
|
5
|
+
import { useHighlightedTokens } from "../hooks/useHighlightedTokens";
|
|
6
|
+
import type { FileRendererProps, ResolveImageUrl } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders a fenced code block with shiki syntax highlighting.
|
|
10
|
+
* Falls back to plain text if shiki is unavailable or the language isn't supported.
|
|
11
|
+
*/
|
|
12
|
+
function HighlightedCodeBlock({
|
|
13
|
+
children,
|
|
14
|
+
className: codeClassName,
|
|
15
|
+
...props
|
|
16
|
+
}: React.HTMLAttributes<HTMLElement>) {
|
|
17
|
+
// Extract language from className (e.g., "language-javascript" -> "javascript")
|
|
18
|
+
const language = React.useMemo(() => {
|
|
19
|
+
if (!codeClassName) return "text";
|
|
20
|
+
const match = codeClassName.match(/language-(\S+)/);
|
|
21
|
+
return match ? match[1] : "text";
|
|
22
|
+
}, [codeClassName]);
|
|
23
|
+
|
|
24
|
+
const code = React.useMemo(() => {
|
|
25
|
+
const raw = String(children);
|
|
26
|
+
// react-markdown adds a trailing newline to code blocks — strip it
|
|
27
|
+
return raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
28
|
+
}, [children]);
|
|
29
|
+
|
|
30
|
+
const { lines } = useHighlightedTokens(code, language);
|
|
31
|
+
|
|
32
|
+
if (lines) {
|
|
33
|
+
return (
|
|
34
|
+
<code className={cn("bg-transparent p-0", codeClassName)} {...props}>
|
|
35
|
+
{lines.map((tokens, i) => (
|
|
36
|
+
<React.Fragment key={i}>
|
|
37
|
+
{i > 0 && "\n"}
|
|
38
|
+
{tokens.map((token, ti) => (
|
|
39
|
+
<span key={ti} style={{ color: token.color }}>
|
|
40
|
+
{token.content}
|
|
41
|
+
</span>
|
|
42
|
+
))}
|
|
43
|
+
</React.Fragment>
|
|
44
|
+
))}
|
|
45
|
+
</code>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<code className={cn("bg-transparent p-0", codeClassName)} {...props}>
|
|
51
|
+
{children}
|
|
52
|
+
</code>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Renders an image in markdown, with optional async URL resolution
|
|
58
|
+
* for relative paths via the `resolveImageUrl` callback.
|
|
59
|
+
*
|
|
60
|
+
* - Remote URLs (http/https) and data URIs render directly.
|
|
61
|
+
* - If no `resolveImageUrl` is provided, renders the src as-is.
|
|
62
|
+
* - Otherwise, calls `resolveImageUrl(src)` and manages loading/error/resolved states.
|
|
63
|
+
* - Cleans up blob URLs on unmount.
|
|
64
|
+
*/
|
|
65
|
+
function MarkdownImage({
|
|
66
|
+
src,
|
|
67
|
+
alt,
|
|
68
|
+
resolveImageUrl,
|
|
69
|
+
...props
|
|
70
|
+
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
|
71
|
+
resolveImageUrl?: ResolveImageUrl;
|
|
72
|
+
}) {
|
|
73
|
+
const needsResolution =
|
|
74
|
+
!!src &&
|
|
75
|
+
!!resolveImageUrl &&
|
|
76
|
+
!src.startsWith("http://") &&
|
|
77
|
+
!src.startsWith("https://") &&
|
|
78
|
+
!src.startsWith("data:");
|
|
79
|
+
|
|
80
|
+
const [resolvedUrl, setResolvedUrl] = React.useState<string | null>(null);
|
|
81
|
+
const [error, setError] = React.useState(false);
|
|
82
|
+
const [loading, setLoading] = React.useState(needsResolution);
|
|
83
|
+
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
if (!needsResolution || !src || !resolveImageUrl) return;
|
|
86
|
+
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
let blobUrl: string | null = null;
|
|
89
|
+
|
|
90
|
+
setLoading(true);
|
|
91
|
+
setError(false);
|
|
92
|
+
setResolvedUrl(null);
|
|
93
|
+
|
|
94
|
+
Promise.resolve(resolveImageUrl(src))
|
|
95
|
+
.then((url) => {
|
|
96
|
+
if (cancelled) {
|
|
97
|
+
// Revoke immediately if we're already unmounted/stale
|
|
98
|
+
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (url.startsWith("blob:")) blobUrl = url;
|
|
102
|
+
setResolvedUrl(url);
|
|
103
|
+
setLoading(false);
|
|
104
|
+
})
|
|
105
|
+
.catch(() => {
|
|
106
|
+
if (!cancelled) {
|
|
107
|
+
setError(true);
|
|
108
|
+
setLoading(false);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
115
|
+
};
|
|
116
|
+
}, [needsResolution, src, resolveImageUrl]);
|
|
117
|
+
|
|
118
|
+
// No resolution needed — render directly (current behavior)
|
|
119
|
+
if (!needsResolution) {
|
|
120
|
+
return (
|
|
121
|
+
<img src={src} alt={alt} className="max-w-full rounded" {...props} />
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Loading
|
|
126
|
+
if (loading) {
|
|
127
|
+
return (
|
|
128
|
+
<span className="my-2 inline-block h-24 w-40 animate-pulse rounded bg-muted" />
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Error — fall back to raw src (browser's broken image)
|
|
133
|
+
if (error || !resolvedUrl) {
|
|
134
|
+
return (
|
|
135
|
+
<img src={src} alt={alt} className="max-w-full rounded" {...props} />
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Resolved
|
|
140
|
+
return (
|
|
141
|
+
<img
|
|
142
|
+
src={resolvedUrl}
|
|
143
|
+
alt={alt}
|
|
144
|
+
className="max-w-full rounded"
|
|
145
|
+
{...props}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* MarkdownRenderer
|
|
152
|
+
*
|
|
153
|
+
* Renders markdown content using react-markdown + remark-gfm.
|
|
154
|
+
* Fenced code blocks use shiki syntax highlighting when available.
|
|
155
|
+
* Styled with Tailwind using semantic CSS variables for theme support.
|
|
156
|
+
*
|
|
157
|
+
* Requires `react-markdown` and `remark-gfm` as peer dependencies of @optilogic/core.
|
|
158
|
+
*/
|
|
159
|
+
export function MarkdownRenderer({
|
|
160
|
+
content,
|
|
161
|
+
className,
|
|
162
|
+
resolveImageUrl,
|
|
163
|
+
}: FileRendererProps) {
|
|
164
|
+
const markdownComponents = React.useMemo(
|
|
165
|
+
() => ({
|
|
166
|
+
code: ({
|
|
167
|
+
children,
|
|
168
|
+
className: codeClassName,
|
|
169
|
+
...props
|
|
170
|
+
}: React.HTMLAttributes<HTMLElement> & {
|
|
171
|
+
inline?: boolean;
|
|
172
|
+
node?: unknown;
|
|
173
|
+
}) => {
|
|
174
|
+
// Detect inline vs block: block code is wrapped in <pre> by react-markdown
|
|
175
|
+
const isInline = !codeClassName;
|
|
176
|
+
if (isInline) {
|
|
177
|
+
return (
|
|
178
|
+
<code
|
|
179
|
+
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[85%]"
|
|
180
|
+
{...props}
|
|
181
|
+
>
|
|
182
|
+
{children}
|
|
183
|
+
</code>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return (
|
|
187
|
+
<HighlightedCodeBlock className={codeClassName} {...props}>
|
|
188
|
+
{children}
|
|
189
|
+
</HighlightedCodeBlock>
|
|
190
|
+
);
|
|
191
|
+
},
|
|
192
|
+
pre: ({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) => (
|
|
193
|
+
<pre
|
|
194
|
+
className="my-4 overflow-auto rounded-md border border-border bg-muted p-4 font-mono text-sm leading-relaxed"
|
|
195
|
+
{...props}
|
|
196
|
+
>
|
|
197
|
+
{children}
|
|
198
|
+
</pre>
|
|
199
|
+
),
|
|
200
|
+
blockquote: ({
|
|
201
|
+
children,
|
|
202
|
+
...props
|
|
203
|
+
}: React.HTMLAttributes<HTMLQuoteElement>) => (
|
|
204
|
+
<blockquote
|
|
205
|
+
className="my-4 border-l-4 border-border pl-4 text-muted-foreground"
|
|
206
|
+
{...props}
|
|
207
|
+
>
|
|
208
|
+
{children}
|
|
209
|
+
</blockquote>
|
|
210
|
+
),
|
|
211
|
+
a: ({
|
|
212
|
+
children,
|
|
213
|
+
href,
|
|
214
|
+
...props
|
|
215
|
+
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
|
216
|
+
<a
|
|
217
|
+
href={href}
|
|
218
|
+
target="_blank"
|
|
219
|
+
rel="noopener noreferrer"
|
|
220
|
+
className="text-primary hover:underline"
|
|
221
|
+
{...props}
|
|
222
|
+
>
|
|
223
|
+
{children}
|
|
224
|
+
</a>
|
|
225
|
+
),
|
|
226
|
+
table: ({
|
|
227
|
+
children,
|
|
228
|
+
...props
|
|
229
|
+
}: React.HTMLAttributes<HTMLTableElement>) => (
|
|
230
|
+
<div className="my-4 overflow-auto">
|
|
231
|
+
<table
|
|
232
|
+
className="w-full border-collapse border border-border text-sm"
|
|
233
|
+
{...props}
|
|
234
|
+
>
|
|
235
|
+
{children}
|
|
236
|
+
</table>
|
|
237
|
+
</div>
|
|
238
|
+
),
|
|
239
|
+
th: ({
|
|
240
|
+
children,
|
|
241
|
+
...props
|
|
242
|
+
}: React.HTMLAttributes<HTMLTableCellElement>) => (
|
|
243
|
+
<th
|
|
244
|
+
className="border border-border bg-muted px-3 py-2 text-left font-semibold"
|
|
245
|
+
{...props}
|
|
246
|
+
>
|
|
247
|
+
{children}
|
|
248
|
+
</th>
|
|
249
|
+
),
|
|
250
|
+
td: ({
|
|
251
|
+
children,
|
|
252
|
+
...props
|
|
253
|
+
}: React.HTMLAttributes<HTMLTableCellElement>) => (
|
|
254
|
+
<td className="border border-border px-3 py-2" {...props}>
|
|
255
|
+
{children}
|
|
256
|
+
</td>
|
|
257
|
+
),
|
|
258
|
+
img: ({
|
|
259
|
+
src,
|
|
260
|
+
alt,
|
|
261
|
+
...props
|
|
262
|
+
}: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
|
263
|
+
<MarkdownImage
|
|
264
|
+
src={src}
|
|
265
|
+
alt={alt}
|
|
266
|
+
resolveImageUrl={resolveImageUrl}
|
|
267
|
+
{...props}
|
|
268
|
+
/>
|
|
269
|
+
),
|
|
270
|
+
}),
|
|
271
|
+
[resolveImageUrl],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div
|
|
276
|
+
className={cn(
|
|
277
|
+
"h-full w-full overflow-auto bg-background p-6 text-sm leading-relaxed text-foreground",
|
|
278
|
+
// Heading styles
|
|
279
|
+
"[&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:border-b [&_h1]:border-border [&_h1]:pb-2 [&_h1]:text-2xl [&_h1]:font-bold",
|
|
280
|
+
"[&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-2 [&_h2]:text-xl [&_h2]:font-semibold",
|
|
281
|
+
"[&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold",
|
|
282
|
+
"[&_h4]:mb-3 [&_h4]:mt-4 [&_h4]:text-base [&_h4]:font-semibold",
|
|
283
|
+
// Paragraph and list styles
|
|
284
|
+
"[&_p]:mb-4 [&_p]:leading-relaxed",
|
|
285
|
+
"[&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6",
|
|
286
|
+
"[&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6",
|
|
287
|
+
"[&_li]:mb-1",
|
|
288
|
+
// Horizontal rule
|
|
289
|
+
"[&_hr]:my-6 [&_hr]:border-border",
|
|
290
|
+
"scrollbar-thin",
|
|
291
|
+
className,
|
|
292
|
+
)}
|
|
293
|
+
>
|
|
294
|
+
<ReactMarkdownImport
|
|
295
|
+
remarkPlugins={[remarkGfmImport]}
|
|
296
|
+
components={markdownComponents}
|
|
297
|
+
>
|
|
298
|
+
{content ?? ""}
|
|
299
|
+
</ReactMarkdownImport>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
MarkdownRenderer.displayName = "MarkdownRenderer";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import type { FileRendererProps } from "../types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PlainTextRenderer
|
|
7
|
+
*
|
|
8
|
+
* Renders plain text content without line numbers.
|
|
9
|
+
* Used for .txt, .log, and as the ultimate fallback renderer.
|
|
10
|
+
*/
|
|
11
|
+
export function PlainTextRenderer({ content, className }: FileRendererProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className={cn(
|
|
15
|
+
"h-full w-full overflow-auto rounded-md border border-border bg-background p-4",
|
|
16
|
+
"scrollbar-thin",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
>
|
|
20
|
+
<pre className="m-0 whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">
|
|
21
|
+
{content ?? ""}
|
|
22
|
+
</pre>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
PlainTextRenderer.displayName = "PlainTextRenderer";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { ContentType, ContentTypeDetectionResult } from "../types";
|
|
3
|
+
import { detectContentType } from "../utils/contentTypeDetection";
|
|
4
|
+
|
|
5
|
+
export interface UseContentTypeOptions {
|
|
6
|
+
fileName: string;
|
|
7
|
+
contentTypeOverride?: ContentType;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseContentTypeReturn extends ContentTypeDetectionResult {
|
|
11
|
+
/** Whether the type was explicitly overridden. */
|
|
12
|
+
isOverridden: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hook to determine the content type for a file.
|
|
17
|
+
* Uses explicit override if provided, otherwise auto-detects from fileName.
|
|
18
|
+
*/
|
|
19
|
+
export function useContentType({
|
|
20
|
+
fileName,
|
|
21
|
+
contentTypeOverride,
|
|
22
|
+
}: UseContentTypeOptions): UseContentTypeReturn {
|
|
23
|
+
return useMemo(() => {
|
|
24
|
+
if (contentTypeOverride) {
|
|
25
|
+
return {
|
|
26
|
+
type: contentTypeOverride,
|
|
27
|
+
extension: null,
|
|
28
|
+
isOverridden: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const detected = detectContentType(fileName);
|
|
32
|
+
return { ...detected, isOverridden: false };
|
|
33
|
+
}, [fileName, contentTypeOverride]);
|
|
34
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse the lightness percentage from a `--background` CSS variable value.
|
|
5
|
+
* Expects HSL format like `"222 84% 5%"` or `"0 0% 100%"`.
|
|
6
|
+
* Returns the lightness as a number (0-100), or `null` if unparseable.
|
|
7
|
+
*/
|
|
8
|
+
function getBackgroundLightness(el: HTMLElement): number | null {
|
|
9
|
+
const value = getComputedStyle(el).getPropertyValue("--background").trim();
|
|
10
|
+
if (!value) return null;
|
|
11
|
+
|
|
12
|
+
// Match the last percentage in the HSL string (lightness)
|
|
13
|
+
const match = value.match(/(\d+(?:\.\d+)?)%\s*$/);
|
|
14
|
+
return match ? parseFloat(match[1]) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detects whether the current theme is "dark" by examining the
|
|
19
|
+
* `--background` CSS variable's lightness.
|
|
20
|
+
*
|
|
21
|
+
* Works with:
|
|
22
|
+
* - The theme system (`applyTheme` sets `--background` as inline style)
|
|
23
|
+
* - The `.dark` class (which also changes `--background` via CSS)
|
|
24
|
+
* - Any mechanism that updates `--background`
|
|
25
|
+
*
|
|
26
|
+
* SSR-safe: defaults to `false` when `document` is unavailable.
|
|
27
|
+
*/
|
|
28
|
+
export function useDarkMode(): boolean {
|
|
29
|
+
const checkDark = useCallback((): boolean => {
|
|
30
|
+
if (typeof document === "undefined") return false;
|
|
31
|
+
const lightness = getBackgroundLightness(document.documentElement);
|
|
32
|
+
// If we can't read the variable, fall back to checking .dark class
|
|
33
|
+
if (lightness === null) {
|
|
34
|
+
return document.documentElement.classList.contains("dark");
|
|
35
|
+
}
|
|
36
|
+
return lightness < 50;
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const [isDark, setIsDark] = useState(checkDark);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const el = document.documentElement;
|
|
43
|
+
|
|
44
|
+
const update = () => {
|
|
45
|
+
setIsDark(checkDark());
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Watch both class and style attribute changes
|
|
49
|
+
const observer = new MutationObserver(update);
|
|
50
|
+
observer.observe(el, {
|
|
51
|
+
attributes: true,
|
|
52
|
+
attributeFilter: ["class", "style"],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Sync in case the value changed between render and effect
|
|
56
|
+
update();
|
|
57
|
+
|
|
58
|
+
return () => observer.disconnect();
|
|
59
|
+
}, [checkDark]);
|
|
60
|
+
|
|
61
|
+
return isDark;
|
|
62
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { useShikiHighlighter } from "./useShikiHighlighter";
|
|
3
|
+
import { useDarkMode } from "./useDarkMode";
|
|
4
|
+
|
|
5
|
+
export interface HighlightToken {
|
|
6
|
+
content: string;
|
|
7
|
+
color?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseHighlightedTokensResult {
|
|
11
|
+
lines: HighlightToken[][] | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Combines the shiki highlighter + dark mode detection to produce
|
|
16
|
+
* highlighted tokens for a given code string and language.
|
|
17
|
+
*
|
|
18
|
+
* - Loads the language on demand (with try/catch for unsupported languages)
|
|
19
|
+
* - Returns `null` while loading or if shiki is unavailable (signals "use plain text")
|
|
20
|
+
* - Re-tokenizes when dark mode toggles
|
|
21
|
+
*/
|
|
22
|
+
export function useHighlightedTokens(
|
|
23
|
+
code: string,
|
|
24
|
+
language: string,
|
|
25
|
+
): UseHighlightedTokensResult {
|
|
26
|
+
const { highlighter, isReady } = useShikiHighlighter();
|
|
27
|
+
const isDark = useDarkMode();
|
|
28
|
+
const [lines, setLines] = useState<HighlightToken[][] | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!isReady || !highlighter || !code) {
|
|
32
|
+
setLines(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
const theme = isDark ? "github-dark" : "github-light";
|
|
38
|
+
|
|
39
|
+
(async () => {
|
|
40
|
+
try {
|
|
41
|
+
// Load language on demand — skip if it's plaintext
|
|
42
|
+
if (language !== "text") {
|
|
43
|
+
const loadedLangs = highlighter.getLoadedLanguages();
|
|
44
|
+
if (!loadedLangs.includes(language as never)) {
|
|
45
|
+
await highlighter.loadLanguage(language as Parameters<typeof highlighter.loadLanguage>[0]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Language not supported by shiki — fall back to plain text
|
|
50
|
+
if (!cancelled) setLines(null);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (cancelled) return;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = highlighter.codeToTokens(code, {
|
|
58
|
+
lang: language as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
|
|
59
|
+
theme,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!cancelled) {
|
|
63
|
+
setLines(
|
|
64
|
+
result.tokens.map((line) =>
|
|
65
|
+
line.map((token) => ({
|
|
66
|
+
content: token.content,
|
|
67
|
+
color: token.color,
|
|
68
|
+
})),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
if (!cancelled) setLines(null);
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
cancelled = true;
|
|
79
|
+
};
|
|
80
|
+
}, [highlighter, isReady, code, language, isDark]);
|
|
81
|
+
|
|
82
|
+
return { lines };
|
|
83
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
type Highlighter = Awaited<ReturnType<typeof import("shiki")["createHighlighter"]>>;
|
|
4
|
+
|
|
5
|
+
interface ShikiHighlighterState {
|
|
6
|
+
highlighter: Highlighter | null;
|
|
7
|
+
isReady: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Singleton: one global highlighter instance shared across all components. */
|
|
11
|
+
let globalHighlighter: Highlighter | null = null;
|
|
12
|
+
let initPromise: Promise<Highlighter | null> | null = null;
|
|
13
|
+
|
|
14
|
+
async function getOrCreateHighlighter(): Promise<Highlighter | null> {
|
|
15
|
+
if (globalHighlighter) return globalHighlighter;
|
|
16
|
+
|
|
17
|
+
if (!initPromise) {
|
|
18
|
+
initPromise = (async () => {
|
|
19
|
+
try {
|
|
20
|
+
const { createHighlighter } = await import("shiki");
|
|
21
|
+
globalHighlighter = await createHighlighter({
|
|
22
|
+
themes: ["github-light", "github-dark"],
|
|
23
|
+
langs: [],
|
|
24
|
+
});
|
|
25
|
+
return globalHighlighter;
|
|
26
|
+
} catch {
|
|
27
|
+
// shiki not installed or failed to load — fall back to plain text
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return initPromise;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Singleton async initialization of the shiki highlighter.
|
|
38
|
+
*
|
|
39
|
+
* - Dynamically imports shiki so it's an optional peer dependency
|
|
40
|
+
* - Creates one global `Highlighter` instance with github-light and github-dark themes
|
|
41
|
+
* - Languages are loaded on demand per-highlight call (not at init)
|
|
42
|
+
* - Returns `{ highlighter, isReady }`
|
|
43
|
+
*/
|
|
44
|
+
export function useShikiHighlighter(): ShikiHighlighterState {
|
|
45
|
+
const [state, setState] = useState<ShikiHighlighterState>({
|
|
46
|
+
highlighter: globalHighlighter,
|
|
47
|
+
isReady: globalHighlighter !== null,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (globalHighlighter) {
|
|
52
|
+
setState({ highlighter: globalHighlighter, isReady: true });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let cancelled = false;
|
|
57
|
+
getOrCreateHighlighter().then((h) => {
|
|
58
|
+
if (!cancelled) {
|
|
59
|
+
setState({ highlighter: h, isReady: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true;
|
|
65
|
+
};
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileView Module
|
|
3
|
+
*
|
|
4
|
+
* A configurable file viewer with pluggable renderers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Main component
|
|
8
|
+
export { FileView } from "./FileView";
|
|
9
|
+
|
|
10
|
+
// Types
|
|
11
|
+
export type {
|
|
12
|
+
BuiltInContentType,
|
|
13
|
+
ContentType,
|
|
14
|
+
ContentTypeDetectionResult,
|
|
15
|
+
FileRendererProps,
|
|
16
|
+
FileRenderer,
|
|
17
|
+
RendererRegistry,
|
|
18
|
+
FileViewError,
|
|
19
|
+
FileViewProps,
|
|
20
|
+
ResolveImageUrl,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
// Sub-components (for custom composition or standalone use)
|
|
24
|
+
export { CodeRenderer } from "./components/CodeRenderer";
|
|
25
|
+
export { MarkdownRenderer } from "./components/MarkdownRenderer";
|
|
26
|
+
export { ImageRenderer } from "./components/ImageRenderer";
|
|
27
|
+
export { PlainTextRenderer } from "./components/PlainTextRenderer";
|
|
28
|
+
export { CsvRenderer } from "./components/CsvRenderer";
|
|
29
|
+
export { HtmlRenderer } from "./components/HtmlRenderer";
|
|
30
|
+
|
|
31
|
+
// Hooks
|
|
32
|
+
export { useContentType } from "./hooks/useContentType";
|
|
33
|
+
export type {
|
|
34
|
+
UseContentTypeOptions,
|
|
35
|
+
UseContentTypeReturn,
|
|
36
|
+
} from "./hooks/useContentType";
|
|
37
|
+
|
|
38
|
+
// Utilities
|
|
39
|
+
export {
|
|
40
|
+
detectContentType,
|
|
41
|
+
getFileExtension,
|
|
42
|
+
isTextContentType,
|
|
43
|
+
isUrlContentType,
|
|
44
|
+
DEFAULT_RENDERERS,
|
|
45
|
+
mergeRenderers,
|
|
46
|
+
resolveRenderer,
|
|
47
|
+
} from "./utils";
|