Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/moody-socks-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fuzdev/fuz_code': patch
---

feat: add `CodeTextarea`
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"devDependencies": {
"@changesets/changelog-git": "^0.2.1",
"@fuzdev/blake3_wasm": "^0.1.1",
"@fuzdev/fuz_css": "^0.63.0",
"@fuzdev/fuz_css": "^0.63.2",
"@fuzdev/fuz_ui": "^0.205.0",
"@fuzdev/fuz_util": "^0.65.1",
"@fuzdev/gro": "^0.204.0",
Expand Down
64 changes: 14 additions & 50 deletions src/lib/CodeHighlight.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,13 @@
* Requires importing `theme_highlight.css` instead of `theme.css`.
*/

import {onDestroy, type Snippet} from 'svelte';
import {DEV} from 'esm-env';
import type {Snippet} from 'svelte';
import type {SvelteHTMLElements} from 'svelte/elements';

import {syntax_styler_global} from './syntax_styler_global.js';
import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
import {tokenize_syntax} from './tokenize_syntax.js';
import {
HighlightManager,
supports_css_highlight_api,
type HighlightMode,
} from './highlight_manager.js';
import {supports_css_highlight_api, type HighlightMode} from './highlight_manager.js';
import {create_range_highlighting} from './range_highlighting.svelte.js';

const {
content,
Expand Down Expand Up @@ -121,58 +116,27 @@

const supports_ranges = supports_css_highlight_api();

const highlight_manager = supports_ranges ? new HighlightManager() : null;

const use_ranges = $derived(supports_ranges && (mode === 'ranges' || mode === 'auto'));

const language_supported = $derived(lang !== null && !!syntax_styler.langs[lang]);

const highlighting_disabled = $derived(lang === null || (!language_supported && !grammar));

// DEV-only validation warnings
if (DEV) {
$effect(() => {
if (lang && !language_supported && !grammar) {
const langs = Object.keys(syntax_styler.langs).join(', ');
// eslint-disable-next-line no-console
console.error(
`[CodeHighlight] Language "${lang}" is not supported and no custom grammar provided. ` +
`Highlighting disabled. Supported: ${langs}`,
);
}
});
}
const rh = create_range_highlighting({
element: () => code_element,
text: () => content,
enabled: () => use_ranges,
lang: () => lang,
grammar: () => grammar,
syntax_styler: () => syntax_styler,
dev_label: 'CodeHighlight',
});

// Generate HTML markup for syntax highlighting in non-range mode
const html_content = $derived.by(() => {
if (use_ranges || !content || highlighting_disabled) {
if (use_ranges || !content || rh.highlighting_disabled) {
return '';
}

return syntax_styler.stylize(content, lang!, grammar); // ! is safe bc of the `highlighting_disabled` calculation
});

// Apply highlights for range mode
if (highlight_manager) {
$effect(() => {
if (!code_element || !content || !use_ranges || highlighting_disabled) {
highlight_manager.clear_element_ranges();
return;
}

// Get tokens from syntax styler
const tokens = tokenize_syntax(content, grammar || syntax_styler.get_lang(lang!)); // ! is safe bc of the `highlighting_disabled` calculation

// Apply highlights
highlight_manager.highlight_from_syntax_tokens(code_element, tokens);
});
}

onDestroy(() => {
highlight_manager?.destroy();
});

// TODO use intersect attachment from fuz_ui to optimize ranges
// TODO do syntax styling at compile-time in the normal case, and don't import these at runtime
// TODO @html making me nervous
</script>
Expand All @@ -182,7 +146,7 @@
<code {...rest} class:inline class:wrap data-lang={lang} bind:this={code_element}
>{#if use_ranges && children}{@render children(
content,
)}{:else if use_ranges || highlighting_disabled}{content}{:else if children}{@render children(
)}{:else if use_ranges || rh.highlighting_disabled}{content}{:else if children}{@render children(
html_content,
)}{:else}{@html html_content}{/if}</code
>
Expand Down
149 changes: 149 additions & 0 deletions src/lib/CodeTextarea.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<script lang="ts">
/**
* A `<textarea>` with live syntax highlighting via the CSS Custom Highlight API.
*
* The text is rendered twice: an editable, visually-transparent `<textarea>`
* on top, and a backdrop `<pre>` mirror underneath whose text node receives
* the highlight ranges. The two share identical box metrics so characters line
* up exactly, and the backdrop is scroll-synced to the textarea.
*
* **Minimal by design**: this is a highlighted input, not a full editor. It
* does not provide line numbers, tab-to-indent, auto-resize, or undo handling
* — compose those on top via the spread `...rest` props and `bind:value`.
*
* **Experimental** — the Highlight API has limited browser support and cannot
* render font-weight/font-style. Requires importing `theme_highlight.css`.
*/

import type {SvelteHTMLElements} from 'svelte/elements';

import {syntax_styler_global} from './syntax_styler_global.js';
import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
import {create_range_highlighting} from './range_highlighting.svelte.js';

let {
value = $bindable(''),
lang = 'svelte',
grammar,
syntax_styler = syntax_styler_global,
wrapper_attrs,
...rest
}: SvelteHTMLElements['textarea'] & {
/** The editable source code. Bindable. */
value?: string;
/**
* Language identifier (e.g. 'ts', 'css', 'svelte'). `null` disables
* highlighting; `undefined` falls back to the default ('svelte').
*/
lang?: string | null;
/** Optional custom grammar; takes precedence over `lang` for tokenization. */
grammar?: SyntaxGrammar | undefined;
/** Custom `SyntaxStyler` instance (defaults to the global one). */
syntax_styler?: SyntaxStyler;
/**
* Attributes for the wrapper `<div>` — the layout box that the textarea
* fills and `resize` grows. Use it for sizing/layout classes, `style`,
* `id`, or container-level handlers. Its `class` is merged with the
* internal `code_textarea` class; `data-lang` stays component-controlled.
* (`...rest` spreads onto the `<textarea>`; the backdrop `<pre>` is
* internal and intentionally not exposed.)
*/
wrapper_attrs?: SvelteHTMLElements['div'];
} = $props();

// the backdrop <pre> holds the text node that gets highlighted *and* is the
// scroll container kept in sync with the textarea
let backdrop: HTMLElement | undefined = $state.raw();
let textarea: HTMLTextAreaElement | undefined = $state.raw();

// A trailing newline keeps the backdrop's last line aligned with the textarea:
// a textarea shows an empty final line after a trailing "\n", which a <pre>
// would otherwise collapse. Rendered as a single expression -> one text node,
// and tokenized as-is so range positions match the text node exactly.
const display_text = $derived(value + '\n');

create_range_highlighting({
element: () => backdrop,
text: () => display_text,
lang: () => lang,
grammar: () => grammar,
syntax_styler: () => syntax_styler,
dev_label: 'CodeTextarea',
});

// keep the (overflow-hidden) backdrop aligned with the textarea's scroll position
const sync_scroll = () => {
if (!backdrop || !textarea) return;
backdrop.scrollTop = textarea.scrollTop;
backdrop.scrollLeft = textarea.scrollLeft;
};
</script>

<div {...wrapper_attrs} class={['code_textarea', wrapper_attrs?.class]} data-lang={lang}>
<pre class="code_textarea_backdrop" aria-hidden="true" bind:this={backdrop}>{display_text}</pre>
<textarea
bind:this={textarea}
spellcheck="false"
{...rest}
bind:value
onscroll={(e) => {
sync_scroll();
rest.onscroll?.(e); // preserve a consumer-supplied handler
}}
></textarea>
</div>

<style>
.code_textarea {
position: relative;
width: 100%;
}

/* metrics shared by both layers so characters align exactly */
.code_textarea_backdrop,
.code_textarea textarea {
margin: 0;
box-sizing: border-box;
width: 100%;
padding: var(--space_xs3) var(--space_xs);
border: 1px solid transparent;
border-radius: var(--radius_xs, 2px);
font-family: var(--font_family_mono, monospace);
font-size: var(--font_size_sm, 0.9rem);
line-height: var(--line_height_md, 1.5);
letter-spacing: inherit;
tab-size: 2;
white-space: pre-wrap;
overflow-wrap: break-word;
overflow: auto;
/* reserve gutter on both layers so the textarea's scrollbar doesn't shrink
its wrap width relative to the backdrop, which would drift highlights */
scrollbar-gutter: stable;
}

/* the backdrop is taken out of flow so the in-flow textarea (its `rows`/resize)
defines the box; `inset: 0` makes the backdrop fill and clip to that box,
scrolled programmatically to match the textarea */
.code_textarea_backdrop {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
overflow: hidden;
color: var(--text_color, currentColor);
}

.code_textarea textarea {
/* sits above the backdrop so the caret and selection are visible; the
textarea is the only in-flow layer, so it sizes the container */
position: relative;
z-index: 1;
display: block;
background-color: transparent;
/* the textarea's own text is invisible; the backdrop shows through */
color: transparent;
caret-color: var(--text_color, currentColor);
border-color: var(--border_color, currentColor);
resize: vertical;
}
</style>
Loading
Loading