@particle-academy/fancy-code 0.6.0 → 0.8.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
@@ -44,6 +44,26 @@ 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
+ ## Diff gutter
48
+
49
+ Pass `diffBase` (the baseline document) and the line-number column shows live,
50
+ VS Code-style change marks of the **current value vs that baseline** — a green
51
+ bar for added lines, a blue bar for modified lines, and a red wedge where lines
52
+ were deleted. Recomputed as the user types (line-level, cheap — the engine is
53
+ [`@particle-academy/fancy-file-commons`](https://github.com/Particle-Academy/fancy-file-commons),
54
+ the same core `fancy-diff` renders).
55
+
56
+ ```tsx
57
+ <CodeEditor value={code} onChange={setCode} diffBase={savedCode} language="typescript">
58
+ <CodeEditor.Panel />
59
+ </CodeEditor>
60
+ ```
61
+
62
+ Totals are exposed on the context as `diffStats` (`{ added, modified, removed }`)
63
+ for a status-bar chip or save indicator, and every marked row carries stable
64
+ `data-diff` / `data-diff-deleted-above` handles. Themes may override the colors
65
+ via `diffAdded` / `diffModified` / `diffRemoved`.
66
+
47
67
  ## Unified file viewer
48
68
 
49
69
  Showing arbitrary files from a tree? `FileViewer` renders text in the
package/dist/index.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  var react = require('react');
4
4
  var reactFancy = require('@particle-academy/react-fancy');
5
+ var fancyFileCommons = require('@particle-academy/fancy-file-commons');
5
6
  var jsxRuntime = require('react/jsx-runtime');
6
7
 
7
8
  // src/components/CodeEditor/CodeEditor.tsx
@@ -13,6 +14,24 @@ function useCodeEditor() {
13
14
  }
14
15
  return ctx;
15
16
  }
17
+
18
+ // src/components/CodeEditor/diff-gutter.ts
19
+ function gutterDiffMark(annotations, line) {
20
+ if (!annotations) return null;
21
+ const a = annotations.byLine[line];
22
+ if (!a) return null;
23
+ return { type: a.type, deletedAbove: (a.deletedAbove ?? 0) > 0 };
24
+ }
25
+ function hasDeletedAtEnd(annotations) {
26
+ return (annotations?.deletedAtEnd ?? 0) > 0;
27
+ }
28
+ var FALLBACK = { added: "#3fb950", modified: "#4184e4", removed: "#f85149" };
29
+ function diffMarkColor(type, colors) {
30
+ return type === "added" ? colors.diffAdded ?? FALLBACK.added : colors.diffModified ?? FALLBACK.modified;
31
+ }
32
+ function diffRemovedColor(colors) {
33
+ return colors.diffRemoved ?? FALLBACK.removed;
34
+ }
16
35
  function CodeEditorPanel({ className }) {
17
36
  const {
18
37
  _engineReturn: engine,
@@ -20,6 +39,7 @@ function CodeEditorPanel({ className }) {
20
39
  wordWrap,
21
40
  readOnly,
22
41
  placeholder,
42
+ _diffAnnotations: diffAnnotations,
23
43
  _maxHeight,
24
44
  _minHeight
25
45
  } = useCodeEditor();
@@ -40,25 +60,80 @@ function CodeEditorPanel({ className }) {
40
60
  const gutterWidth = lineNumbers ? `${Math.max(String(lineCount).length, 2) * 0.75 + 1.5}em` : "0";
41
61
  const lineNumberElements = react.useMemo(() => {
42
62
  if (!lineNumbers) return null;
63
+ const removed = diffRemovedColor(themeColors);
43
64
  const lines = [];
44
65
  for (let i = 1; i <= lineCount; i++) {
66
+ const mark = gutterDiffMark(diffAnnotations, i);
45
67
  lines.push(
46
- /* @__PURE__ */ jsxRuntime.jsx(
68
+ /* @__PURE__ */ jsxRuntime.jsxs(
47
69
  "div",
48
70
  {
71
+ "data-diff": mark?.type,
72
+ "data-diff-deleted-above": mark?.deletedAbove ? "" : void 0,
49
73
  style: {
74
+ position: "relative",
50
75
  color: i === activeLine ? themeColors.foreground : themeColors.gutterForeground,
51
76
  backgroundColor: i === activeLine ? themeColors.activeLineBackground : void 0
52
77
  },
53
78
  className: "pr-3 text-right text-[13px] leading-[1.5] select-none",
54
- children: i
79
+ children: [
80
+ mark?.type && /* @__PURE__ */ jsxRuntime.jsx(
81
+ "span",
82
+ {
83
+ "aria-hidden": "true",
84
+ style: {
85
+ position: "absolute",
86
+ left: 0,
87
+ top: 0,
88
+ bottom: 0,
89
+ width: 3,
90
+ borderRadius: 2,
91
+ backgroundColor: diffMarkColor(mark.type, themeColors)
92
+ }
93
+ }
94
+ ),
95
+ mark?.deletedAbove && /* @__PURE__ */ jsxRuntime.jsx(
96
+ "span",
97
+ {
98
+ "aria-hidden": "true",
99
+ style: {
100
+ position: "absolute",
101
+ left: 0,
102
+ top: -4,
103
+ width: 0,
104
+ height: 0,
105
+ borderLeft: `8px solid ${removed}`,
106
+ borderTop: "4px solid transparent",
107
+ borderBottom: "4px solid transparent"
108
+ }
109
+ }
110
+ ),
111
+ i,
112
+ i === lineCount && hasDeletedAtEnd(diffAnnotations) && /* @__PURE__ */ jsxRuntime.jsx(
113
+ "span",
114
+ {
115
+ "aria-hidden": "true",
116
+ "data-diff-deleted-at-end": "",
117
+ style: {
118
+ position: "absolute",
119
+ left: 0,
120
+ bottom: -4,
121
+ width: 0,
122
+ height: 0,
123
+ borderLeft: `8px solid ${removed}`,
124
+ borderTop: "4px solid transparent",
125
+ borderBottom: "4px solid transparent"
126
+ }
127
+ }
128
+ )
129
+ ]
55
130
  },
56
131
  i
57
132
  )
58
133
  );
59
134
  }
60
135
  return lines;
61
- }, [lineNumbers, lineCount, activeLine, themeColors]);
136
+ }, [lineNumbers, lineCount, activeLine, themeColors, diffAnnotations]);
62
137
  const containerStyle = {
63
138
  backgroundColor: themeColors.background,
64
139
  color: themeColors.foreground,
@@ -1260,6 +1335,9 @@ registerTheme({
1260
1335
  activeLineBackground: "#f8fafc",
1261
1336
  selectionBackground: "#dbeafe",
1262
1337
  cursorColor: "#3b82f6",
1338
+ diffAdded: "#1a7f37",
1339
+ diffModified: "#0969da",
1340
+ diffRemoved: "#cf222e",
1263
1341
  keyword: "#8b5cf6",
1264
1342
  string: "#059669",
1265
1343
  comment: "#94a3b8",
@@ -1288,6 +1366,9 @@ registerTheme({
1288
1366
  activeLineBackground: "#27272a",
1289
1367
  selectionBackground: "#1e3a5f",
1290
1368
  cursorColor: "#60a5fa",
1369
+ diffAdded: "#3fb950",
1370
+ diffModified: "#4184e4",
1371
+ diffRemoved: "#f85149",
1291
1372
  keyword: "#c084fc",
1292
1373
  string: "#34d399",
1293
1374
  comment: "#71717a",
@@ -1346,6 +1427,19 @@ function highlightCode(source, tokens, colors) {
1346
1427
  return parts.join("");
1347
1428
  }
1348
1429
 
1430
+ // src/engine/position.ts
1431
+ function lineColumnToOffset(text, line, column = 1) {
1432
+ const lines = text.split("\n");
1433
+ const targetLine = Math.max(1, Math.min(Math.trunc(line) || 1, lines.length));
1434
+ let offset = 0;
1435
+ for (let i = 0; i < targetLine - 1; i++) {
1436
+ offset += lines[i].length + 1;
1437
+ }
1438
+ const lineText = lines[targetLine - 1] ?? "";
1439
+ const col = Math.max(1, Math.min(Math.trunc(column) || 1, lineText.length + 1));
1440
+ return offset + (col - 1);
1441
+ }
1442
+
1349
1443
  // src/hooks/use-editor-engine.ts
1350
1444
  var DEFAULT_COLORS = {
1351
1445
  background: "#18181b",
@@ -1460,6 +1554,28 @@ function useEditorEngine({
1460
1554
  setScrollTop(ta.scrollTop);
1461
1555
  setScrollLeft(ta.scrollLeft);
1462
1556
  }, []);
1557
+ const revealLine = react.useCallback(
1558
+ (line, column = 1, opts) => {
1559
+ const ta = textareaRef.current;
1560
+ if (!ta) return;
1561
+ const offset = lineColumnToOffset(ta.value, line, column);
1562
+ if (opts?.focus) ta.focus();
1563
+ ta.selectionStart = ta.selectionEnd = offset;
1564
+ updateCursorInfo();
1565
+ const panel = ta.closest("[data-fancy-code-panel]");
1566
+ if (!panel) return;
1567
+ const cs = getComputedStyle(ta);
1568
+ const lineHeight = parseFloat(cs.lineHeight) || 19.5;
1569
+ const paddingTop = parseFloat(cs.paddingTop) || 0;
1570
+ const lineIndex = (ta.value.slice(0, offset).match(/\n/g) || []).length;
1571
+ const textareaTopInPanel = ta.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
1572
+ const lineTop = textareaTopInPanel + paddingTop + lineIndex * lineHeight;
1573
+ const target = lineTop - panel.clientHeight / 2 + lineHeight / 2;
1574
+ const maxScroll = panel.scrollHeight - panel.clientHeight;
1575
+ panel.scrollTop = Math.max(0, Math.min(target, maxScroll));
1576
+ },
1577
+ [updateCursorInfo]
1578
+ );
1463
1579
  const handleKeyDown = react.useCallback(
1464
1580
  (e) => {
1465
1581
  if (readOnly) {
@@ -1546,6 +1662,7 @@ function useEditorEngine({
1546
1662
  handleInput,
1547
1663
  handleScroll,
1548
1664
  handleSelect,
1665
+ revealLine,
1549
1666
  scrollTop,
1550
1667
  scrollLeft
1551
1668
  };
@@ -1579,9 +1696,16 @@ function CodeEditorRoot({
1579
1696
  tabSize: tabSizeProp = 2,
1580
1697
  placeholder,
1581
1698
  minHeight,
1582
- maxHeight
1699
+ maxHeight,
1700
+ cursorLine,
1701
+ cursorColumn,
1702
+ diffBase
1583
1703
  }) {
1584
1704
  const [currentValue, setCurrentValue] = reactFancy.useControllableState(valueProp, defaultValue, onChange);
1705
+ const diffAnnotations = react.useMemo(
1706
+ () => diffBase == null ? null : fancyFileCommons.annotateLines(diffBase, currentValue),
1707
+ [diffBase, currentValue]
1708
+ );
1585
1709
  const isDark = useDarkMode();
1586
1710
  const resolvedTheme = theme === "auto" ? isDark ? "dark" : "light" : theme;
1587
1711
  const [currentLanguage, setCurrentLanguageState] = react.useState(() => {
@@ -1615,6 +1739,11 @@ function CodeEditorRoot({
1615
1739
  setSelectionLength(sel);
1616
1740
  }
1617
1741
  });
1742
+ const { revealLine: revealLineImpl } = engineReturn;
1743
+ react.useEffect(() => {
1744
+ if (cursorLine == null) return;
1745
+ revealLineImpl(cursorLine, cursorColumn ?? 1, { focus: false });
1746
+ }, [cursorLine, cursorColumn, revealLineImpl]);
1618
1747
  const contextValue = react.useMemo(
1619
1748
  () => ({
1620
1749
  getValue: () => engineReturn.textareaRef.current?.value ?? currentValue,
@@ -1636,6 +1765,8 @@ function CodeEditorRoot({
1636
1765
  setCurrentValue(ta.value);
1637
1766
  },
1638
1767
  focus: () => engineReturn.textareaRef.current?.focus(),
1768
+ revealLine: (line, column) => engineReturn.revealLine(line, column ?? 1, { focus: true }),
1769
+ setCursor: ({ line, column }) => engineReturn.revealLine(line, column ?? 1, { focus: true }),
1639
1770
  language: currentLanguage,
1640
1771
  setLanguage,
1641
1772
  theme: resolvedTheme,
@@ -1653,11 +1784,17 @@ function CodeEditorRoot({
1653
1784
  selectionLength,
1654
1785
  textareaRef: engineReturn.textareaRef,
1655
1786
  placeholder,
1787
+ diffStats: diffAnnotations ? {
1788
+ added: diffAnnotations.added,
1789
+ modified: diffAnnotations.modified,
1790
+ removed: diffAnnotations.removed
1791
+ } : null,
1792
+ _diffAnnotations: diffAnnotations,
1656
1793
  _engineReturn: engineReturn,
1657
1794
  _minHeight: minHeight,
1658
1795
  _maxHeight: maxHeight
1659
1796
  }),
1660
- [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, minHeight, maxHeight]
1797
+ [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight]
1661
1798
  );
1662
1799
  return /* @__PURE__ */ jsxRuntime.jsx(CodeEditorContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx(
1663
1800
  "div",
@@ -1833,48 +1970,10 @@ function MarkdownEditor({
1833
1970
  );
1834
1971
  }
1835
1972
  MarkdownEditor.displayName = "MarkdownEditor";
1836
- var EXT_LANGUAGE = {
1837
- js: "javascript",
1838
- mjs: "javascript",
1839
- cjs: "javascript",
1840
- jsx: "javascript",
1841
- json: "javascript",
1842
- // the JS tokenizer highlights JSON acceptably
1843
- jsonc: "javascript",
1844
- ts: "typescript",
1845
- mts: "typescript",
1846
- cts: "typescript",
1847
- tsx: "typescript",
1848
- html: "html",
1849
- htm: "html",
1850
- xml: "html",
1851
- xhtml: "html",
1852
- vue: "html",
1853
- php: "php",
1854
- phtml: "php",
1855
- py: "python",
1856
- pyw: "python",
1857
- go: "go",
1858
- md: "markdown",
1859
- markdown: "markdown",
1860
- mkd: "markdown",
1861
- mdx: "markdown"
1862
- };
1863
- function basename(filename) {
1864
- return filename.split(/[?#]/, 1)[0].split(/[\\/]/).pop() ?? "";
1865
- }
1866
- function languageFromFilename(filename) {
1867
- if (!filename) return "plaintext";
1868
- const seg = basename(filename);
1869
- const dot = seg.lastIndexOf(".");
1870
- if (dot <= 0) return "plaintext";
1871
- const ext = seg.slice(dot + 1).toLowerCase();
1872
- return EXT_LANGUAGE[ext] ?? "plaintext";
1873
- }
1874
1973
  function resolveFileKind({ filename, mime }) {
1875
1974
  const media = reactFancy.resolveMediaType({ mime, src: filename });
1876
1975
  if (media !== "unknown") return { kind: "media", mediaKind: media };
1877
- return { kind: "text", language: languageFromFilename(filename) };
1976
+ return { kind: "text", language: fancyFileCommons.languageFromFilename(filename) };
1878
1977
  }
1879
1978
  function FileViewer({
1880
1979
  filename,
@@ -1954,15 +2053,22 @@ function CodeEditorInputTag(props) {
1954
2053
  return /* @__PURE__ */ jsxRuntime.jsx(reactFancy.InputTag, { adapter, ...props });
1955
2054
  }
1956
2055
 
2056
+ Object.defineProperty(exports, "languageFromFilename", {
2057
+ enumerable: true,
2058
+ get: function () { return fancyFileCommons.languageFromFilename; }
2059
+ });
1957
2060
  exports.CodeEditor = CodeEditor;
1958
2061
  exports.CodeEditorInputTag = CodeEditorInputTag;
1959
2062
  exports.FileViewer = FileViewer;
1960
2063
  exports.MarkdownEditor = MarkdownEditor;
2064
+ exports.diffMarkColor = diffMarkColor;
2065
+ exports.diffRemovedColor = diffRemovedColor;
1961
2066
  exports.getLanguage = getLanguage;
1962
2067
  exports.getRegisteredLanguages = getRegisteredLanguages;
1963
2068
  exports.getRegisteredThemes = getRegisteredThemes;
1964
2069
  exports.getTheme = getTheme;
1965
- exports.languageFromFilename = languageFromFilename;
2070
+ exports.gutterDiffMark = gutterDiffMark;
2071
+ exports.hasDeletedAtEnd = hasDeletedAtEnd;
1966
2072
  exports.registerLanguage = registerLanguage;
1967
2073
  exports.registerTheme = registerTheme;
1968
2074
  exports.renderMarkdown = renderMarkdown;