@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 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 >= 1.5`
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;