From 94193828cea5ae1faf724af9617d4b4e29eec9dc Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Tue, 18 Aug 2026 10:57:33 -0400 Subject: [PATCH 1/2] refactor: replace the `create_range_highlighting` getter factory with a `RangeHighlighting` class --- .changeset/range-highlighting-class.md | 5 ++ src/lib/CodeHighlight.svelte | 4 +- src/lib/CodeTextarea.svelte | 4 +- src/lib/range_highlighting.svelte.ts | 96 +++++++++++++------------- 4 files changed, 57 insertions(+), 52 deletions(-) create mode 100644 .changeset/range-highlighting-class.md diff --git a/.changeset/range-highlighting-class.md b/.changeset/range-highlighting-class.md new file mode 100644 index 00000000..db11c644 --- /dev/null +++ b/.changeset/range-highlighting-class.md @@ -0,0 +1,5 @@ +--- +'@fuzdev/fuz_code': minor +--- + +refactor: replace the `create_range_highlighting` getter factory with a `RangeHighlighting` class (the interface of that name is replaced by the class; construct during component init) diff --git a/src/lib/CodeHighlight.svelte b/src/lib/CodeHighlight.svelte index 6c850b20..d044aa72 100644 --- a/src/lib/CodeHighlight.svelte +++ b/src/lib/CodeHighlight.svelte @@ -13,7 +13,7 @@ import { syntax_styler_global } from './syntax_styler_global.ts'; import type { SyntaxStyler } from './syntax_styler.ts'; import { supports_css_highlight_api, type HighlightMode } from './highlight_manager.ts'; - import { create_range_highlighting } from './range_highlighting.svelte.ts'; + import { RangeHighlighting } from './range_highlighting.svelte.ts'; const { content, @@ -80,7 +80,7 @@ const use_ranges = $derived(supports_ranges && (mode === 'ranges' || mode === 'auto')); - const rh = create_range_highlighting({ + const rh = new RangeHighlighting({ element: () => code_element, text: () => content, enabled: () => use_ranges, diff --git a/src/lib/CodeTextarea.svelte b/src/lib/CodeTextarea.svelte index 85fc481e..6016a4bd 100644 --- a/src/lib/CodeTextarea.svelte +++ b/src/lib/CodeTextarea.svelte @@ -19,7 +19,7 @@ import { syntax_styler_global } from './syntax_styler_global.ts'; import type { SyntaxStyler } from './syntax_styler.ts'; - import { create_range_highlighting } from './range_highlighting.svelte.ts'; + import { RangeHighlighting } from './range_highlighting.svelte.ts'; let { value = $bindable(''), @@ -59,7 +59,7 @@ // and tokenized as-is so range positions match the text node exactly. const display_text = $derived(value + '\n'); - create_range_highlighting({ + new RangeHighlighting({ element: () => backdrop, text: () => display_text, lang: () => lang, diff --git a/src/lib/range_highlighting.svelte.ts b/src/lib/range_highlighting.svelte.ts index 184dc864..2ad9e5d2 100644 --- a/src/lib/range_highlighting.svelte.ts +++ b/src/lib/range_highlighting.svelte.ts @@ -5,8 +5,8 @@ import type { SyntaxStyler } from './syntax_styler.ts'; import { HighlightManager, supports_css_highlight_api } from './highlight_manager.ts'; /** - * Reactive inputs for `create_range_highlighting`. All values are getters so the - * helper can track the consuming component's reactive state across the call + * Reactive inputs for `RangeHighlighting`. All values are getters so the + * class can track the consuming component's reactive state across the call * boundary (the Svelte 5 getter-injection pattern). */ export interface RangeHighlightingOptions { @@ -27,67 +27,67 @@ export interface RangeHighlightingOptions { dev_label: string; } -/** Reactive outputs from `create_range_highlighting`. */ -export interface RangeHighlighting { - readonly highlighting_disabled: boolean; -} - /** * Wires up CSS Custom Highlight API range highlighting for a single element's * text node, shared by `CodeHighlight` and `CodeTextarea`. Creates a * `HighlightManager`, memoizes tokenization, applies/clears ranges in an effect, * emits DEV warnings for unsupported languages, and tears down on destroy. * - * Must be called during component initialization (it uses `$effect`/`onDestroy`). + * Must be constructed during component initialization (it uses `$effect`/`onDestroy`). */ -export const create_range_highlighting = (options: RangeHighlightingOptions): RangeHighlighting => { - const manager = supports_css_highlight_api() ? new HighlightManager() : null; - const is_enabled = options.enabled ?? (() => true); +export class RangeHighlighting { + readonly #options: RangeHighlightingOptions; + readonly #manager: HighlightManager | null; + readonly #is_enabled: () => boolean; + + readonly #language_supported: boolean = $derived.by(() => { + const lang = this.#options.lang(); + return lang !== null && this.#options.syntax_styler().has_lang(lang); + }); - const language_supported = $derived( - options.lang() !== null && options.syntax_styler().has_lang(options.lang()!) + readonly highlighting_disabled: boolean = $derived.by( + () => this.#options.lang() === null || !this.#language_supported ); - const highlighting_disabled = $derived(options.lang() === null || !language_supported); // lex once per (text, lang) change -- memoized so unrelated reactivity doesn't // trigger a full re-lex (`! safe bc of `highlighting_disabled`) - const range_lexed = $derived.by(() => { - if (!manager || !is_enabled() || highlighting_disabled) return null; - const text = options.text(); + readonly #range_lexed = $derived.by(() => { + if (!this.#manager || !this.#is_enabled() || this.highlighting_disabled) return null; + const text = this.#options.text(); if (!text) return null; - return options.syntax_styler().lex(text, options.lang()!); + return this.#options.syntax_styler().lex(text, this.#options.lang()!); }); - if (manager) { - $effect(() => { - const element = options.element(); - if (!element || !range_lexed) { - manager.clear_element_ranges(); - return; - } - manager.highlight_from_lexed(element, range_lexed); - }); - } + constructor(options: RangeHighlightingOptions) { + this.#options = options; + this.#is_enabled = options.enabled ?? (() => true); + const manager = (this.#manager = supports_css_highlight_api() ? new HighlightManager() : null); - if (DEV) { - $effect(() => { - // a lang was requested but we can't highlight it (unknown id) - if (options.lang() && highlighting_disabled) { - const langs = [...options.syntax_styler().langs.keys()].join(', '); - // eslint-disable-next-line no-console - console.error( - `[${options.dev_label}] Language "${options.lang()}" is not supported. ` + - `Highlighting disabled. Supported: ${langs}` - ); - } - }); - } - - onDestroy(() => manager?.destroy()); + if (manager) { + $effect(() => { + const element = options.element(); + if (!element || !this.#range_lexed) { + manager.clear_element_ranges(); + return; + } + manager.highlight_from_lexed(element, this.#range_lexed); + }); + } - return { - get highlighting_disabled() { - return highlighting_disabled; + if (DEV) { + $effect(() => { + // a lang was requested but we can't highlight it (unknown id) + if (options.lang() && this.highlighting_disabled) { + const langs = [...options.syntax_styler().langs.keys()].join(', '); + // eslint-disable-next-line no-console + console.error( + `[${options.dev_label}] Language "${options.lang()}" is not supported. ` + + `Highlighting disabled. Supported: ${langs}` + ); + } + }); } - }; -}; + + onDestroy(() => manager?.destroy()); + } +} From 1206c142c2383d356ad5e084527ddfe63e1d01e6 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Tue, 18 Aug 2026 11:01:37 -0400 Subject: [PATCH 2/2] lint --- src/lib/CodeTextarea.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/CodeTextarea.svelte b/src/lib/CodeTextarea.svelte index 6016a4bd..ed854444 100644 --- a/src/lib/CodeTextarea.svelte +++ b/src/lib/CodeTextarea.svelte @@ -59,6 +59,7 @@ // and tokenized as-is so range positions match the text node exactly. const display_text = $derived(value + '\n'); + // eslint-disable-next-line no-new new RangeHighlighting({ element: () => backdrop, text: () => display_text,