@particle-academy/fancy-code 0.5.1 → 0.7.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/README.md +28 -1
- package/dist/index.cjs +161 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -4
- package/dist/index.d.ts +131 -4
- package/dist/index.js +161 -4
- package/dist/index.js.map +1 -1
- package/docs/FileViewer.md +78 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install @particle-academy/fancy-code
|
|
|
12
12
|
# or: yarn add @particle-academy/fancy-code
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
**Peer dependencies:** `react >= 18`, `react-dom >= 18`, `@particle-academy/react-fancy >=
|
|
15
|
+
**Peer dependencies:** `react >= 18`, `react-dom >= 18`, `@particle-academy/react-fancy >= 4.9` (the `FileViewer` delegates media to react-fancy's `<MediaViewer>`, added in 4.9)
|
|
16
16
|
|
|
17
17
|
## Setup
|
|
18
18
|
|
|
@@ -44,11 +44,38 @@ function App() {
|
|
|
44
44
|
|
|
45
45
|
The component ships with no default border or rounding — add your own via `className` for standalone use, or embed directly into IDE layouts without visual conflicts.
|
|
46
46
|
|
|
47
|
+
## Unified file viewer
|
|
48
|
+
|
|
49
|
+
Showing arbitrary files from a tree? `FileViewer` renders text in the
|
|
50
|
+
`CodeEditor` and delegates **media** (image / video / audio / PDF) to
|
|
51
|
+
react-fancy's `<MediaViewer>` — so an image no longer renders as binary text.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { FileViewer } from "@particle-academy/fancy-code";
|
|
55
|
+
|
|
56
|
+
// text → CodeEditor (read-only, language picked from the filename)
|
|
57
|
+
<FileViewer filename="app.tsx" value={source} />
|
|
58
|
+
|
|
59
|
+
// media → <MediaViewer> (pass the file URL as `src`)
|
|
60
|
+
<FileViewer filename="logo.png" src={url} style={{ height: 360 }} />
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Need to branch your own chrome (tabs, save buttons)? Call `resolveFileKind`
|
|
64
|
+
yourself — it's the same text-vs-media decision `FileViewer` makes internally:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { resolveFileKind } from "@particle-academy/fancy-code";
|
|
68
|
+
|
|
69
|
+
resolveFileKind({ filename: "logo.png" }); // { kind: "media", mediaKind: "image" }
|
|
70
|
+
resolveFileKind({ filename: "app.tsx" }); // { kind: "text", language: "typescript" }
|
|
71
|
+
```
|
|
72
|
+
|
|
47
73
|
## Documentation
|
|
48
74
|
|
|
49
75
|
| Topic | Doc |
|
|
50
76
|
|-------|-----|
|
|
51
77
|
| Full component API (props, sub-components, `useCodeEditor` hook) | [docs/CodeEditor.md](./docs/CodeEditor.md) |
|
|
78
|
+
| Unified file viewer (text + media), `resolveFileKind` | [docs/FileViewer.md](./docs/FileViewer.md) |
|
|
52
79
|
| Built-in languages + registering custom ones | [docs/languages.md](./docs/languages.md) |
|
|
53
80
|
| Built-in themes + custom theme registration | [docs/themes.md](./docs/themes.md) |
|
|
54
81
|
|
package/dist/index.cjs
CHANGED
|
@@ -1346,6 +1346,19 @@ function highlightCode(source, tokens, colors) {
|
|
|
1346
1346
|
return parts.join("");
|
|
1347
1347
|
}
|
|
1348
1348
|
|
|
1349
|
+
// src/engine/position.ts
|
|
1350
|
+
function lineColumnToOffset(text, line, column = 1) {
|
|
1351
|
+
const lines = text.split("\n");
|
|
1352
|
+
const targetLine = Math.max(1, Math.min(Math.trunc(line) || 1, lines.length));
|
|
1353
|
+
let offset = 0;
|
|
1354
|
+
for (let i = 0; i < targetLine - 1; i++) {
|
|
1355
|
+
offset += lines[i].length + 1;
|
|
1356
|
+
}
|
|
1357
|
+
const lineText = lines[targetLine - 1] ?? "";
|
|
1358
|
+
const col = Math.max(1, Math.min(Math.trunc(column) || 1, lineText.length + 1));
|
|
1359
|
+
return offset + (col - 1);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1349
1362
|
// src/hooks/use-editor-engine.ts
|
|
1350
1363
|
var DEFAULT_COLORS = {
|
|
1351
1364
|
background: "#18181b",
|
|
@@ -1460,6 +1473,28 @@ function useEditorEngine({
|
|
|
1460
1473
|
setScrollTop(ta.scrollTop);
|
|
1461
1474
|
setScrollLeft(ta.scrollLeft);
|
|
1462
1475
|
}, []);
|
|
1476
|
+
const revealLine = react.useCallback(
|
|
1477
|
+
(line, column = 1, opts) => {
|
|
1478
|
+
const ta = textareaRef.current;
|
|
1479
|
+
if (!ta) return;
|
|
1480
|
+
const offset = lineColumnToOffset(ta.value, line, column);
|
|
1481
|
+
if (opts?.focus) ta.focus();
|
|
1482
|
+
ta.selectionStart = ta.selectionEnd = offset;
|
|
1483
|
+
updateCursorInfo();
|
|
1484
|
+
const panel = ta.closest("[data-fancy-code-panel]");
|
|
1485
|
+
if (!panel) return;
|
|
1486
|
+
const cs = getComputedStyle(ta);
|
|
1487
|
+
const lineHeight = parseFloat(cs.lineHeight) || 19.5;
|
|
1488
|
+
const paddingTop = parseFloat(cs.paddingTop) || 0;
|
|
1489
|
+
const lineIndex = (ta.value.slice(0, offset).match(/\n/g) || []).length;
|
|
1490
|
+
const textareaTopInPanel = ta.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
|
|
1491
|
+
const lineTop = textareaTopInPanel + paddingTop + lineIndex * lineHeight;
|
|
1492
|
+
const target = lineTop - panel.clientHeight / 2 + lineHeight / 2;
|
|
1493
|
+
const maxScroll = panel.scrollHeight - panel.clientHeight;
|
|
1494
|
+
panel.scrollTop = Math.max(0, Math.min(target, maxScroll));
|
|
1495
|
+
},
|
|
1496
|
+
[updateCursorInfo]
|
|
1497
|
+
);
|
|
1463
1498
|
const handleKeyDown = react.useCallback(
|
|
1464
1499
|
(e) => {
|
|
1465
1500
|
if (readOnly) {
|
|
@@ -1546,6 +1581,7 @@ function useEditorEngine({
|
|
|
1546
1581
|
handleInput,
|
|
1547
1582
|
handleScroll,
|
|
1548
1583
|
handleSelect,
|
|
1584
|
+
revealLine,
|
|
1549
1585
|
scrollTop,
|
|
1550
1586
|
scrollLeft
|
|
1551
1587
|
};
|
|
@@ -1579,7 +1615,9 @@ function CodeEditorRoot({
|
|
|
1579
1615
|
tabSize: tabSizeProp = 2,
|
|
1580
1616
|
placeholder,
|
|
1581
1617
|
minHeight,
|
|
1582
|
-
maxHeight
|
|
1618
|
+
maxHeight,
|
|
1619
|
+
cursorLine,
|
|
1620
|
+
cursorColumn
|
|
1583
1621
|
}) {
|
|
1584
1622
|
const [currentValue, setCurrentValue] = reactFancy.useControllableState(valueProp, defaultValue, onChange);
|
|
1585
1623
|
const isDark = useDarkMode();
|
|
@@ -1615,6 +1653,11 @@ function CodeEditorRoot({
|
|
|
1615
1653
|
setSelectionLength(sel);
|
|
1616
1654
|
}
|
|
1617
1655
|
});
|
|
1656
|
+
const { revealLine: revealLineImpl } = engineReturn;
|
|
1657
|
+
react.useEffect(() => {
|
|
1658
|
+
if (cursorLine == null) return;
|
|
1659
|
+
revealLineImpl(cursorLine, cursorColumn ?? 1, { focus: false });
|
|
1660
|
+
}, [cursorLine, cursorColumn, revealLineImpl]);
|
|
1618
1661
|
const contextValue = react.useMemo(
|
|
1619
1662
|
() => ({
|
|
1620
1663
|
getValue: () => engineReturn.textareaRef.current?.value ?? currentValue,
|
|
@@ -1636,6 +1679,8 @@ function CodeEditorRoot({
|
|
|
1636
1679
|
setCurrentValue(ta.value);
|
|
1637
1680
|
},
|
|
1638
1681
|
focus: () => engineReturn.textareaRef.current?.focus(),
|
|
1682
|
+
revealLine: (line, column) => engineReturn.revealLine(line, column ?? 1, { focus: true }),
|
|
1683
|
+
setCursor: ({ line, column }) => engineReturn.revealLine(line, column ?? 1, { focus: true }),
|
|
1639
1684
|
language: currentLanguage,
|
|
1640
1685
|
setLanguage,
|
|
1641
1686
|
theme: resolvedTheme,
|
|
@@ -1833,6 +1878,118 @@ function MarkdownEditor({
|
|
|
1833
1878
|
);
|
|
1834
1879
|
}
|
|
1835
1880
|
MarkdownEditor.displayName = "MarkdownEditor";
|
|
1881
|
+
var EXT_LANGUAGE = {
|
|
1882
|
+
js: "javascript",
|
|
1883
|
+
mjs: "javascript",
|
|
1884
|
+
cjs: "javascript",
|
|
1885
|
+
jsx: "javascript",
|
|
1886
|
+
json: "javascript",
|
|
1887
|
+
// the JS tokenizer highlights JSON acceptably
|
|
1888
|
+
jsonc: "javascript",
|
|
1889
|
+
ts: "typescript",
|
|
1890
|
+
mts: "typescript",
|
|
1891
|
+
cts: "typescript",
|
|
1892
|
+
tsx: "typescript",
|
|
1893
|
+
html: "html",
|
|
1894
|
+
htm: "html",
|
|
1895
|
+
xml: "html",
|
|
1896
|
+
xhtml: "html",
|
|
1897
|
+
vue: "html",
|
|
1898
|
+
php: "php",
|
|
1899
|
+
phtml: "php",
|
|
1900
|
+
py: "python",
|
|
1901
|
+
pyw: "python",
|
|
1902
|
+
go: "go",
|
|
1903
|
+
md: "markdown",
|
|
1904
|
+
markdown: "markdown",
|
|
1905
|
+
mkd: "markdown",
|
|
1906
|
+
mdx: "markdown"
|
|
1907
|
+
};
|
|
1908
|
+
function basename(filename) {
|
|
1909
|
+
return filename.split(/[?#]/, 1)[0].split(/[\\/]/).pop() ?? "";
|
|
1910
|
+
}
|
|
1911
|
+
function languageFromFilename(filename) {
|
|
1912
|
+
if (!filename) return "plaintext";
|
|
1913
|
+
const seg = basename(filename);
|
|
1914
|
+
const dot = seg.lastIndexOf(".");
|
|
1915
|
+
if (dot <= 0) return "plaintext";
|
|
1916
|
+
const ext = seg.slice(dot + 1).toLowerCase();
|
|
1917
|
+
return EXT_LANGUAGE[ext] ?? "plaintext";
|
|
1918
|
+
}
|
|
1919
|
+
function resolveFileKind({ filename, mime }) {
|
|
1920
|
+
const media = reactFancy.resolveMediaType({ mime, src: filename });
|
|
1921
|
+
if (media !== "unknown") return { kind: "media", mediaKind: media };
|
|
1922
|
+
return { kind: "text", language: languageFromFilename(filename) };
|
|
1923
|
+
}
|
|
1924
|
+
function FileViewer({
|
|
1925
|
+
filename,
|
|
1926
|
+
mime,
|
|
1927
|
+
src,
|
|
1928
|
+
value = "",
|
|
1929
|
+
onChange,
|
|
1930
|
+
readOnly = true,
|
|
1931
|
+
theme = "auto",
|
|
1932
|
+
fileKind,
|
|
1933
|
+
lineNumbers = true,
|
|
1934
|
+
wordWrap = false,
|
|
1935
|
+
minHeight,
|
|
1936
|
+
maxHeight,
|
|
1937
|
+
mediaProps,
|
|
1938
|
+
className,
|
|
1939
|
+
style
|
|
1940
|
+
}) {
|
|
1941
|
+
const resolved = fileKind ?? resolveFileKind({ filename, mime });
|
|
1942
|
+
if (resolved.kind === "media") {
|
|
1943
|
+
const { className: mediaClassName, ...restMediaProps } = mediaProps ?? {};
|
|
1944
|
+
const fill = resolved.mediaKind !== "audio";
|
|
1945
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
1946
|
+
"div",
|
|
1947
|
+
{
|
|
1948
|
+
"data-fancy-file-viewer": "",
|
|
1949
|
+
"data-kind": "media",
|
|
1950
|
+
"data-media-kind": resolved.mediaKind,
|
|
1951
|
+
className: reactFancy.cn("overflow-hidden", className),
|
|
1952
|
+
style,
|
|
1953
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1954
|
+
reactFancy.MediaViewer,
|
|
1955
|
+
{
|
|
1956
|
+
src: src ?? "",
|
|
1957
|
+
mime,
|
|
1958
|
+
alt: filename,
|
|
1959
|
+
kind: resolved.mediaKind,
|
|
1960
|
+
className: reactFancy.cn(fill && "h-full w-full", mediaClassName),
|
|
1961
|
+
...restMediaProps
|
|
1962
|
+
}
|
|
1963
|
+
)
|
|
1964
|
+
}
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
1968
|
+
"div",
|
|
1969
|
+
{
|
|
1970
|
+
"data-fancy-file-viewer": "",
|
|
1971
|
+
"data-kind": "text",
|
|
1972
|
+
className,
|
|
1973
|
+
style,
|
|
1974
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1975
|
+
CodeEditor,
|
|
1976
|
+
{
|
|
1977
|
+
value,
|
|
1978
|
+
onChange: readOnly ? void 0 : onChange,
|
|
1979
|
+
language: resolved.language,
|
|
1980
|
+
theme,
|
|
1981
|
+
readOnly,
|
|
1982
|
+
lineNumbers,
|
|
1983
|
+
wordWrap,
|
|
1984
|
+
minHeight,
|
|
1985
|
+
maxHeight,
|
|
1986
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(CodeEditor.Panel, {})
|
|
1987
|
+
}
|
|
1988
|
+
)
|
|
1989
|
+
}
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
FileViewer.displayName = "FileViewer";
|
|
1836
1993
|
function useCodeEditorInputTagAdapter() {
|
|
1837
1994
|
const ctx = useCodeEditor();
|
|
1838
1995
|
return react.useMemo(() => reactFancy.textareaAdapter(ctx.textareaRef), [ctx.textareaRef]);
|
|
@@ -1844,14 +2001,17 @@ function CodeEditorInputTag(props) {
|
|
|
1844
2001
|
|
|
1845
2002
|
exports.CodeEditor = CodeEditor;
|
|
1846
2003
|
exports.CodeEditorInputTag = CodeEditorInputTag;
|
|
2004
|
+
exports.FileViewer = FileViewer;
|
|
1847
2005
|
exports.MarkdownEditor = MarkdownEditor;
|
|
1848
2006
|
exports.getLanguage = getLanguage;
|
|
1849
2007
|
exports.getRegisteredLanguages = getRegisteredLanguages;
|
|
1850
2008
|
exports.getRegisteredThemes = getRegisteredThemes;
|
|
1851
2009
|
exports.getTheme = getTheme;
|
|
2010
|
+
exports.languageFromFilename = languageFromFilename;
|
|
1852
2011
|
exports.registerLanguage = registerLanguage;
|
|
1853
2012
|
exports.registerTheme = registerTheme;
|
|
1854
2013
|
exports.renderMarkdown = renderMarkdown;
|
|
2014
|
+
exports.resolveFileKind = resolveFileKind;
|
|
1855
2015
|
exports.tokenizeMarkdown = tokenizeMarkdown;
|
|
1856
2016
|
exports.useCodeEditor = useCodeEditor;
|
|
1857
2017
|
exports.useCodeEditorInputTagAdapter = useCodeEditorInputTagAdapter;
|