@particle-academy/fancy-code 0.11.0 → 0.12.1

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.d.cts CHANGED
@@ -73,6 +73,12 @@ interface CodeEditorProps {
73
73
  cursorLine?: number;
74
74
  /** 1-based column for {@link cursorLine} (default 1). */
75
75
  cursorColumn?: number;
76
+ /**
77
+ * 1-based lines to mark with a full-width band — "here is the line you were
78
+ * sent to". Purely visual and independent of the caret; lines outside the
79
+ * document are ignored. Sized for unwrapped lines, as the gutter is.
80
+ */
81
+ highlightLines?: readonly number[];
76
82
  /**
77
83
  * Baseline document for the diff gutter. When set, the line-number column
78
84
  * shows per-line change marks of the CURRENT value against this baseline —
@@ -158,6 +164,8 @@ interface CodeEditorContextValue {
158
164
  _minHeight?: number;
159
165
  /** @internal */
160
166
  _maxHeight?: number;
167
+ /** @internal — lines to band; see {@link CodeEditorProps.highlightLines}. */
168
+ _highlightLines?: readonly number[];
161
169
  }
162
170
  interface CodeEditorToolbarProps {
163
171
  children?: ReactNode;
@@ -191,7 +199,7 @@ declare namespace CodeEditorStatusBar {
191
199
  var displayName: string;
192
200
  }
193
201
 
194
- declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, }: CodeEditorProps): react.JSX.Element;
202
+ declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, }: CodeEditorProps): react.JSX.Element;
195
203
  declare namespace CodeEditorRoot {
196
204
  var displayName: string;
197
205
  }
@@ -328,6 +336,19 @@ interface FileViewerProps {
328
336
  lineNumbers?: boolean;
329
337
  /** Wrap long lines in the text editor (default `false`). */
330
338
  wordWrap?: boolean;
339
+ /**
340
+ * 1-based line to land on: scrolled to the middle of the view, caret placed,
341
+ * and highlighted. Applied on mount and again whenever it changes, so
342
+ * re-targeting an open file scrolls again. Text files only; does not steal focus.
343
+ */
344
+ line?: number;
345
+ /** 1-based column for {@link line} (default 1). */
346
+ column?: number;
347
+ /**
348
+ * Lines to highlight. Defaults to `[line]` when `line` is set; pass your own
349
+ * (a range, several hits) to override, or `[]` to land without highlighting.
350
+ */
351
+ highlightLines?: readonly number[];
331
352
  /** Editor height bounds (px). */
332
353
  minHeight?: number;
333
354
  maxHeight?: number;
@@ -353,7 +374,7 @@ interface FileViewerProps {
353
374
  * <FileViewer filename="logo.png" src={url} style={{ height: 360 }} />
354
375
  * ```
355
376
  */
356
- declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
377
+ declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
357
378
  declare namespace FileViewer {
358
379
  var displayName: string;
359
380
  }
package/dist/index.d.ts CHANGED
@@ -73,6 +73,12 @@ interface CodeEditorProps {
73
73
  cursorLine?: number;
74
74
  /** 1-based column for {@link cursorLine} (default 1). */
75
75
  cursorColumn?: number;
76
+ /**
77
+ * 1-based lines to mark with a full-width band — "here is the line you were
78
+ * sent to". Purely visual and independent of the caret; lines outside the
79
+ * document are ignored. Sized for unwrapped lines, as the gutter is.
80
+ */
81
+ highlightLines?: readonly number[];
76
82
  /**
77
83
  * Baseline document for the diff gutter. When set, the line-number column
78
84
  * shows per-line change marks of the CURRENT value against this baseline —
@@ -158,6 +164,8 @@ interface CodeEditorContextValue {
158
164
  _minHeight?: number;
159
165
  /** @internal */
160
166
  _maxHeight?: number;
167
+ /** @internal — lines to band; see {@link CodeEditorProps.highlightLines}. */
168
+ _highlightLines?: readonly number[];
161
169
  }
162
170
  interface CodeEditorToolbarProps {
163
171
  children?: ReactNode;
@@ -191,7 +199,7 @@ declare namespace CodeEditorStatusBar {
191
199
  var displayName: string;
192
200
  }
193
201
 
194
- declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, }: CodeEditorProps): react.JSX.Element;
202
+ declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, }: CodeEditorProps): react.JSX.Element;
195
203
  declare namespace CodeEditorRoot {
196
204
  var displayName: string;
197
205
  }
@@ -328,6 +336,19 @@ interface FileViewerProps {
328
336
  lineNumbers?: boolean;
329
337
  /** Wrap long lines in the text editor (default `false`). */
330
338
  wordWrap?: boolean;
339
+ /**
340
+ * 1-based line to land on: scrolled to the middle of the view, caret placed,
341
+ * and highlighted. Applied on mount and again whenever it changes, so
342
+ * re-targeting an open file scrolls again. Text files only; does not steal focus.
343
+ */
344
+ line?: number;
345
+ /** 1-based column for {@link line} (default 1). */
346
+ column?: number;
347
+ /**
348
+ * Lines to highlight. Defaults to `[line]` when `line` is set; pass your own
349
+ * (a range, several hits) to override, or `[]` to land without highlighting.
350
+ */
351
+ highlightLines?: readonly number[];
331
352
  /** Editor height bounds (px). */
332
353
  minHeight?: number;
333
354
  maxHeight?: number;
@@ -353,7 +374,7 @@ interface FileViewerProps {
353
374
  * <FileViewer filename="logo.png" src={url} style={{ height: 360 }} />
354
375
  * ```
355
376
  */
356
- declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
377
+ declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
357
378
  declare namespace FileViewer {
358
379
  var displayName: string;
359
380
  }
package/dist/index.js CHANGED
@@ -40,12 +40,14 @@ function CodeEditorPanel({ className }) {
40
40
  placeholder,
41
41
  _diffAnnotations: diffAnnotations,
42
42
  _maxHeight,
43
- _minHeight
43
+ _minHeight,
44
+ _highlightLines: highlightLines
44
45
  } = useCodeEditor();
45
46
  const lineNumberElements = useMemo(() => {
46
47
  if (!engine || !lineNumbers) return null;
47
48
  const { themeColors: themeColors2, lineCount: lineCount2, activeLine: activeLine2 } = engine;
48
49
  const removed = diffRemovedColor(themeColors2);
50
+ const highlighted = new Set(highlightLines ?? []);
49
51
  const lines = [];
50
52
  for (let i = 1; i <= lineCount2; i++) {
51
53
  const mark = gutterDiffMark(diffAnnotations, i);
@@ -57,8 +59,9 @@ function CodeEditorPanel({ className }) {
57
59
  "data-diff-deleted-above": mark?.deletedAbove ? "" : void 0,
58
60
  style: {
59
61
  position: "relative",
60
- color: i === activeLine2 ? themeColors2.foreground : themeColors2.gutterForeground,
61
- backgroundColor: i === activeLine2 ? themeColors2.activeLineBackground : void 0
62
+ color: i === activeLine2 || highlighted.has(i) ? themeColors2.foreground : themeColors2.gutterForeground,
63
+ backgroundColor: i === activeLine2 ? themeColors2.activeLineBackground : void 0,
64
+ fontWeight: highlighted.has(i) ? 600 : void 0
62
65
  },
63
66
  className: "pr-3 text-right text-[13px] leading-[1.5] select-none",
64
67
  children: [
@@ -118,7 +121,7 @@ function CodeEditorPanel({ className }) {
118
121
  );
119
122
  }
120
123
  return lines;
121
- }, [engine, lineNumbers, diffAnnotations]);
124
+ }, [engine, lineNumbers, diffAnnotations, highlightLines]);
122
125
  if (!engine) return null;
123
126
  const {
124
127
  textareaRef,
@@ -161,6 +164,21 @@ function CodeEditorPanel({ className }) {
161
164
  }
162
165
  ),
163
166
  /* @__PURE__ */ jsxs("div", { className: cn("relative flex-1", wordWrap && "min-w-0"), children: [
167
+ (highlightLines ?? []).filter((n) => Number.isInteger(n) && n >= 1 && n <= lineCount).map((n) => /* @__PURE__ */ jsx(
168
+ "div",
169
+ {
170
+ "data-fancy-code-highlight-line": n,
171
+ "aria-hidden": "true",
172
+ className: "pointer-events-none absolute inset-x-0 text-[13px] leading-[1.5]",
173
+ style: {
174
+ top: `calc(0.625rem + ${(n - 1) * 1.5}em)`,
175
+ height: "1.5em",
176
+ backgroundColor: themeColors.selectionBackground,
177
+ opacity: 0.55
178
+ }
179
+ },
180
+ `highlight-${n}`
181
+ )),
164
182
  /* @__PURE__ */ jsx(
165
183
  "pre",
166
184
  {
@@ -1516,7 +1534,8 @@ function CodeEditorRoot({
1516
1534
  maxHeight,
1517
1535
  cursorLine,
1518
1536
  cursorColumn,
1519
- diffBase
1537
+ diffBase,
1538
+ highlightLines
1520
1539
  }) {
1521
1540
  const [currentValue, setCurrentValue] = useControllableState(valueProp, defaultValue, onChange);
1522
1541
  const diffAnnotations = useMemo(
@@ -1609,9 +1628,10 @@ function CodeEditorRoot({
1609
1628
  _diffAnnotations: diffAnnotations,
1610
1629
  _engineReturn: engineReturn,
1611
1630
  _minHeight: minHeight,
1612
- _maxHeight: maxHeight
1631
+ _maxHeight: maxHeight,
1632
+ _highlightLines: highlightLines
1613
1633
  }),
1614
- [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight]
1634
+ [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight, highlightLines]
1615
1635
  );
1616
1636
  return /* @__PURE__ */ jsx(CodeEditorContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx(
1617
1637
  "div",
@@ -1716,6 +1736,9 @@ function FileViewer({
1716
1736
  fileKind,
1717
1737
  lineNumbers = true,
1718
1738
  wordWrap = false,
1739
+ line,
1740
+ column,
1741
+ highlightLines,
1719
1742
  minHeight,
1720
1743
  maxHeight,
1721
1744
  mediaProps,
@@ -1767,6 +1790,9 @@ function FileViewer({
1767
1790
  wordWrap,
1768
1791
  minHeight,
1769
1792
  maxHeight,
1793
+ cursorLine: line,
1794
+ cursorColumn: column,
1795
+ highlightLines: highlightLines ?? (line != null ? [line] : void 0),
1770
1796
  children: /* @__PURE__ */ jsx(CodeEditor.Panel, {})
1771
1797
  }
1772
1798
  )
@@ -1782,8 +1808,27 @@ function escapeHtml(s) {
1782
1808
  function escapeAttr(s) {
1783
1809
  return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1784
1810
  }
1811
+ var LINK_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
1812
+ function schemeOf(url) {
1813
+ let s = "";
1814
+ for (const ch of url) {
1815
+ const code = ch.codePointAt(0) ?? 0;
1816
+ if (code === 9 || code === 10 || code === 13) continue;
1817
+ if (s === "" && code <= 32) continue;
1818
+ s += ch;
1819
+ }
1820
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(s);
1821
+ return m ? m[1].toLowerCase() : null;
1822
+ }
1823
+ function safeUrl(url, kind) {
1824
+ const scheme = schemeOf(url);
1825
+ if (scheme === null) return true;
1826
+ if (scheme === "http" || scheme === "https") return true;
1827
+ if (kind === "link") return LINK_SCHEMES.has(scheme);
1828
+ return scheme === "data" && /^data:image\/(png|jpe?g|gif|webp|avif|bmp);/i.test(url.trim());
1829
+ }
1785
1830
  function inline(text) {
1786
- return text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, alt, src, title) => `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${title ? ` title="${escapeAttr(title)}"` : ""}>`).replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, t, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ""} rel="noopener">${t}</a>`).replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`).replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>").replace(/(\*|_)(?=\S)([\s\S]*?\S)\1/g, "<em>$2</em>");
1831
+ return text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, alt, src, title) => safeUrl(src, "image") ? `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${title ? ` title="${escapeAttr(title)}"` : ""}>` : alt).replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, t, href, title) => safeUrl(href, "link") ? `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ""} rel="noopener">${t}</a>` : t).replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`).replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>").replace(/(\*|_)(?=\S)([\s\S]*?\S)\1/g, "<em>$2</em>");
1787
1832
  }
1788
1833
  function renderMarkdown(markdown) {
1789
1834
  const lines = markdown.replace(/\r\n?/g, "\n").split("\n");