@fuzdev/fuz_code 0.46.0 → 0.46.2
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/CodeHighlight.svelte +14 -50
- package/dist/CodeHighlight.svelte.d.ts +1 -1
- package/dist/CodeHighlight.svelte.d.ts.map +1 -1
- package/dist/CodeTextarea.svelte +156 -0
- package/dist/CodeTextarea.svelte.d.ts +43 -0
- package/dist/CodeTextarea.svelte.d.ts.map +1 -0
- package/dist/highlight_manager.d.ts +20 -6
- package/dist/highlight_manager.d.ts.map +1 -1
- package/dist/highlight_manager.js +128 -72
- package/dist/range_highlighting.svelte.d.ts +39 -0
- package/dist/range_highlighting.svelte.d.ts.map +1 -0
- package/dist/range_highlighting.svelte.js +57 -0
- package/dist/syntax_styler.d.ts +12 -0
- package/dist/syntax_styler.d.ts.map +1 -1
- package/dist/syntax_styler.js +49 -13
- package/package.json +6 -5
- package/src/lib/highlight_manager.ts +152 -82
- package/src/lib/range_highlighting.svelte.ts +100 -0
- package/src/lib/syntax_styler.ts +63 -14
|
@@ -6,18 +6,13 @@
|
|
|
6
6
|
* Requires importing `theme_highlight.css` instead of `theme.css`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import
|
|
10
|
-
import {DEV} from 'esm-env';
|
|
9
|
+
import type {Snippet} from 'svelte';
|
|
11
10
|
import type {SvelteHTMLElements} from 'svelte/elements';
|
|
12
11
|
|
|
13
12
|
import {syntax_styler_global} from './syntax_styler_global.js';
|
|
14
13
|
import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
HighlightManager,
|
|
18
|
-
supports_css_highlight_api,
|
|
19
|
-
type HighlightMode,
|
|
20
|
-
} from './highlight_manager.js';
|
|
14
|
+
import {supports_css_highlight_api, type HighlightMode} from './highlight_manager.js';
|
|
15
|
+
import {create_range_highlighting} from './range_highlighting.svelte.js';
|
|
21
16
|
|
|
22
17
|
const {
|
|
23
18
|
content,
|
|
@@ -121,58 +116,27 @@
|
|
|
121
116
|
|
|
122
117
|
const supports_ranges = supports_css_highlight_api();
|
|
123
118
|
|
|
124
|
-
const highlight_manager = supports_ranges ? new HighlightManager() : null;
|
|
125
|
-
|
|
126
119
|
const use_ranges = $derived(supports_ranges && (mode === 'ranges' || mode === 'auto'));
|
|
127
120
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// eslint-disable-next-line no-console
|
|
138
|
-
console.error(
|
|
139
|
-
`[CodeHighlight] Language "${lang}" is not supported and no custom grammar provided. ` +
|
|
140
|
-
`Highlighting disabled. Supported: ${langs}`,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
}
|
|
121
|
+
const rh = create_range_highlighting({
|
|
122
|
+
element: () => code_element,
|
|
123
|
+
text: () => content,
|
|
124
|
+
enabled: () => use_ranges,
|
|
125
|
+
lang: () => lang,
|
|
126
|
+
grammar: () => grammar,
|
|
127
|
+
syntax_styler: () => syntax_styler,
|
|
128
|
+
dev_label: 'CodeHighlight',
|
|
129
|
+
});
|
|
145
130
|
|
|
146
131
|
// Generate HTML markup for syntax highlighting in non-range mode
|
|
147
132
|
const html_content = $derived.by(() => {
|
|
148
|
-
if (use_ranges || !content || highlighting_disabled) {
|
|
133
|
+
if (use_ranges || !content || rh.highlighting_disabled) {
|
|
149
134
|
return '';
|
|
150
135
|
}
|
|
151
136
|
|
|
152
137
|
return syntax_styler.stylize(content, lang!, grammar); // ! is safe bc of the `highlighting_disabled` calculation
|
|
153
138
|
});
|
|
154
139
|
|
|
155
|
-
// Apply highlights for range mode
|
|
156
|
-
if (highlight_manager) {
|
|
157
|
-
$effect(() => {
|
|
158
|
-
if (!code_element || !content || !use_ranges || highlighting_disabled) {
|
|
159
|
-
highlight_manager.clear_element_ranges();
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Get tokens from syntax styler
|
|
164
|
-
const tokens = tokenize_syntax(content, grammar || syntax_styler.get_lang(lang!)); // ! is safe bc of the `highlighting_disabled` calculation
|
|
165
|
-
|
|
166
|
-
// Apply highlights
|
|
167
|
-
highlight_manager.highlight_from_syntax_tokens(code_element, tokens);
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
onDestroy(() => {
|
|
172
|
-
highlight_manager?.destroy();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// TODO use intersect attachment from fuz_ui to optimize ranges
|
|
176
140
|
// TODO do syntax styling at compile-time in the normal case, and don't import these at runtime
|
|
177
141
|
// TODO @html making me nervous
|
|
178
142
|
</script>
|
|
@@ -182,7 +146,7 @@
|
|
|
182
146
|
<code {...rest} class:inline class:wrap data-lang={lang} bind:this={code_element}
|
|
183
147
|
>{#if use_ranges && children}{@render children(
|
|
184
148
|
content,
|
|
185
|
-
)}{:else if use_ranges || highlighting_disabled}{content}{:else if children}{@render children(
|
|
149
|
+
)}{:else if use_ranges || rh.highlighting_disabled}{content}{:else if children}{@render children(
|
|
186
150
|
html_content,
|
|
187
151
|
)}{:else}{@html html_content}{/if}</code
|
|
188
152
|
>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Requires importing `theme_highlight.css` instead of `theme.css`.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import type { Snippet } from 'svelte';
|
|
8
8
|
import type { SvelteHTMLElements } from 'svelte/elements';
|
|
9
9
|
import type { SyntaxStyler, SyntaxGrammar } from './syntax_styler.js';
|
|
10
10
|
import { type HighlightMode } from './highlight_manager.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CodeHighlight.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CodeHighlight.svelte"],"names":[],"mappings":"AAGA;;;;;OAKI;AACJ,OAAO,
|
|
1
|
+
{"version":3,"file":"CodeHighlight.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CodeHighlight.svelte"],"names":[],"mappings":"AAGA;;;;;OAKI;AACJ,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AACpC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AACpE,OAAO,EAA6B,KAAK,aAAa,EAAC,MAAM,wBAAwB,CAAC;AAGrF,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,MAAM,CAAC,GAAG;IACrD,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrC,CAAC;AAiEH,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A `<textarea>` with live syntax highlighting via the CSS Custom Highlight API.
|
|
4
|
+
*
|
|
5
|
+
* The text is rendered twice: an editable, visually-transparent `<textarea>`
|
|
6
|
+
* on top, and a backdrop `<pre>` mirror underneath whose text node receives
|
|
7
|
+
* the highlight ranges. The two share identical box metrics so characters line
|
|
8
|
+
* up exactly, and the backdrop is scroll-synced to the textarea.
|
|
9
|
+
*
|
|
10
|
+
* **Minimal by design**: this is a highlighted input, not a full editor. It
|
|
11
|
+
* does not provide line numbers, tab-to-indent, auto-resize, or undo handling
|
|
12
|
+
* — compose those on top via the spread `...rest` props and `bind:value`.
|
|
13
|
+
*
|
|
14
|
+
* **Experimental** — the Highlight API has limited browser support and cannot
|
|
15
|
+
* render font-weight/font-style. Requires importing `theme_highlight.css`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {SvelteHTMLElements} from 'svelte/elements';
|
|
19
|
+
|
|
20
|
+
import {syntax_styler_global} from './syntax_styler_global.js';
|
|
21
|
+
import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
|
|
22
|
+
import {create_range_highlighting} from './range_highlighting.svelte.js';
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
value = $bindable(''),
|
|
26
|
+
lang = 'svelte',
|
|
27
|
+
grammar,
|
|
28
|
+
syntax_styler = syntax_styler_global,
|
|
29
|
+
wrapper_attrs,
|
|
30
|
+
...rest
|
|
31
|
+
}: SvelteHTMLElements['textarea'] & {
|
|
32
|
+
/** The editable source code. Bindable. */
|
|
33
|
+
value?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Language identifier (e.g. 'ts', 'css', 'svelte'). `null` disables
|
|
36
|
+
* highlighting; `undefined` falls back to the default ('svelte').
|
|
37
|
+
*/
|
|
38
|
+
lang?: string | null;
|
|
39
|
+
/** Optional custom grammar; takes precedence over `lang` for tokenization. */
|
|
40
|
+
grammar?: SyntaxGrammar | undefined;
|
|
41
|
+
/** Custom `SyntaxStyler` instance (defaults to the global one). */
|
|
42
|
+
syntax_styler?: SyntaxStyler;
|
|
43
|
+
/**
|
|
44
|
+
* Attributes for the wrapper `<div>` — the layout box that the textarea
|
|
45
|
+
* fills and `resize` grows. Use it for sizing/layout classes, `style`,
|
|
46
|
+
* `id`, or container-level handlers. Its `class` is merged with the
|
|
47
|
+
* internal `code_textarea` class; `data-lang` stays component-controlled.
|
|
48
|
+
* (`...rest` spreads onto the `<textarea>`; the backdrop `<pre>` is
|
|
49
|
+
* internal and intentionally not exposed.)
|
|
50
|
+
*/
|
|
51
|
+
wrapper_attrs?: SvelteHTMLElements['div'];
|
|
52
|
+
} = $props();
|
|
53
|
+
|
|
54
|
+
// the backdrop <pre> holds the text node that gets highlighted *and* is the
|
|
55
|
+
// scroll container kept in sync with the textarea
|
|
56
|
+
let backdrop: HTMLElement | undefined = $state.raw();
|
|
57
|
+
let textarea: HTMLTextAreaElement | undefined = $state.raw();
|
|
58
|
+
|
|
59
|
+
// A trailing newline keeps the backdrop's last line aligned with the textarea:
|
|
60
|
+
// a textarea shows an empty final line after a trailing "\n", which a <pre>
|
|
61
|
+
// would otherwise collapse. Rendered as a single expression -> one text node,
|
|
62
|
+
// and tokenized as-is so range positions match the text node exactly.
|
|
63
|
+
const display_text = $derived(value + '\n');
|
|
64
|
+
|
|
65
|
+
create_range_highlighting({
|
|
66
|
+
element: () => backdrop,
|
|
67
|
+
text: () => display_text,
|
|
68
|
+
lang: () => lang,
|
|
69
|
+
grammar: () => grammar,
|
|
70
|
+
syntax_styler: () => syntax_styler,
|
|
71
|
+
dev_label: 'CodeTextarea',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// keep the (overflow-hidden) backdrop aligned with the textarea's scroll position
|
|
75
|
+
const sync_scroll = () => {
|
|
76
|
+
if (!backdrop || !textarea) return;
|
|
77
|
+
backdrop.scrollTop = textarea.scrollTop;
|
|
78
|
+
backdrop.scrollLeft = textarea.scrollLeft;
|
|
79
|
+
};
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<div {...wrapper_attrs} class={['code_textarea', wrapper_attrs?.class]} data-lang={lang}>
|
|
83
|
+
<pre class="code_textarea_backdrop" aria-hidden="true" bind:this={backdrop}>{display_text}</pre>
|
|
84
|
+
<textarea
|
|
85
|
+
bind:this={textarea}
|
|
86
|
+
spellcheck="false"
|
|
87
|
+
{...rest}
|
|
88
|
+
bind:value
|
|
89
|
+
onscroll={(e) => {
|
|
90
|
+
sync_scroll();
|
|
91
|
+
rest.onscroll?.(e); // preserve a consumer-supplied handler
|
|
92
|
+
}}
|
|
93
|
+
></textarea>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<style>
|
|
97
|
+
.code_textarea {
|
|
98
|
+
position: relative;
|
|
99
|
+
width: 100%;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Metrics shared by both layers so characters align exactly. fuz_css styles
|
|
103
|
+
`pre` and `textarea` differently, so each declaration here equalizes them;
|
|
104
|
+
anything fuz_css already applies identically (box-sizing, the reset margin,
|
|
105
|
+
colors) is left to it. */
|
|
106
|
+
.code_textarea_backdrop,
|
|
107
|
+
.code_textarea textarea {
|
|
108
|
+
/* the backdrop `pre` isn't the last child, so fuz_css's flow rule would give
|
|
109
|
+
it a bottom margin that shrinks its absolute box and drifts the last line */
|
|
110
|
+
margin: 0;
|
|
111
|
+
width: 100%;
|
|
112
|
+
/* fuz_css pads the textarea but not `pre` — pin both the same */
|
|
113
|
+
padding: var(--space_xs3) var(--space_xs);
|
|
114
|
+
/* fuz_css borders the textarea but not `pre`; a transparent border on both
|
|
115
|
+
keeps the box metrics identical (the textarea's is colored below) */
|
|
116
|
+
border: 1px solid transparent;
|
|
117
|
+
border-radius: var(--radius_xs, 2px);
|
|
118
|
+
/* the textarea would otherwise inherit the page's proportional font and
|
|
119
|
+
`line-height: normal`; the backdrop `pre` is already mono */
|
|
120
|
+
font-family: var(--font_family_mono);
|
|
121
|
+
font-size: var(--font_size_sm);
|
|
122
|
+
line-height: var(--line_height_md);
|
|
123
|
+
tab-size: 2;
|
|
124
|
+
/* fuz_css sets `pre` to `white-space: pre`; both must wrap to stay aligned */
|
|
125
|
+
white-space: pre-wrap;
|
|
126
|
+
overflow-wrap: break-word;
|
|
127
|
+
overflow: auto;
|
|
128
|
+
/* reserve gutter on both layers so the textarea's scrollbar doesn't shrink
|
|
129
|
+
its wrap width relative to the backdrop, which would drift highlights */
|
|
130
|
+
scrollbar-gutter: stable;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* the backdrop is taken out of flow so the in-flow textarea (its `rows`/resize)
|
|
134
|
+
defines the box; `inset: 0` makes the backdrop fill and clip to that box,
|
|
135
|
+
scrolled programmatically to match the textarea */
|
|
136
|
+
.code_textarea_backdrop {
|
|
137
|
+
position: absolute;
|
|
138
|
+
inset: 0;
|
|
139
|
+
pointer-events: none;
|
|
140
|
+
user-select: none;
|
|
141
|
+
overflow: hidden;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.code_textarea textarea {
|
|
145
|
+
/* sits above the backdrop so the caret and selection are visible; the
|
|
146
|
+
textarea is the only in-flow layer, so it sizes the container */
|
|
147
|
+
position: relative;
|
|
148
|
+
z-index: 1;
|
|
149
|
+
/* the textarea's own text is invisible; the backdrop (a styled `pre`) shows
|
|
150
|
+
through, so restore a visible caret and border over the transparent ones */
|
|
151
|
+
background-color: transparent;
|
|
152
|
+
color: transparent;
|
|
153
|
+
caret-color: var(--text_color);
|
|
154
|
+
border-color: var(--border_color);
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `<textarea>` with live syntax highlighting via the CSS Custom Highlight API.
|
|
3
|
+
*
|
|
4
|
+
* The text is rendered twice: an editable, visually-transparent `<textarea>`
|
|
5
|
+
* on top, and a backdrop `<pre>` mirror underneath whose text node receives
|
|
6
|
+
* the highlight ranges. The two share identical box metrics so characters line
|
|
7
|
+
* up exactly, and the backdrop is scroll-synced to the textarea.
|
|
8
|
+
*
|
|
9
|
+
* **Minimal by design**: this is a highlighted input, not a full editor. It
|
|
10
|
+
* does not provide line numbers, tab-to-indent, auto-resize, or undo handling
|
|
11
|
+
* — compose those on top via the spread `...rest` props and `bind:value`.
|
|
12
|
+
*
|
|
13
|
+
* **Experimental** — the Highlight API has limited browser support and cannot
|
|
14
|
+
* render font-weight/font-style. Requires importing `theme_highlight.css`.
|
|
15
|
+
*/
|
|
16
|
+
import type { SvelteHTMLElements } from 'svelte/elements';
|
|
17
|
+
import type { SyntaxStyler, SyntaxGrammar } from './syntax_styler.js';
|
|
18
|
+
type $$ComponentProps = SvelteHTMLElements['textarea'] & {
|
|
19
|
+
/** The editable source code. Bindable. */
|
|
20
|
+
value?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Language identifier (e.g. 'ts', 'css', 'svelte'). `null` disables
|
|
23
|
+
* highlighting; `undefined` falls back to the default ('svelte').
|
|
24
|
+
*/
|
|
25
|
+
lang?: string | null;
|
|
26
|
+
/** Optional custom grammar; takes precedence over `lang` for tokenization. */
|
|
27
|
+
grammar?: SyntaxGrammar | undefined;
|
|
28
|
+
/** Custom `SyntaxStyler` instance (defaults to the global one). */
|
|
29
|
+
syntax_styler?: SyntaxStyler;
|
|
30
|
+
/**
|
|
31
|
+
* Attributes for the wrapper `<div>` — the layout box that the textarea
|
|
32
|
+
* fills and `resize` grows. Use it for sizing/layout classes, `style`,
|
|
33
|
+
* `id`, or container-level handlers. Its `class` is merged with the
|
|
34
|
+
* internal `code_textarea` class; `data-lang` stays component-controlled.
|
|
35
|
+
* (`...rest` spreads onto the `<textarea>`; the backdrop `<pre>` is
|
|
36
|
+
* internal and intentionally not exposed.)
|
|
37
|
+
*/
|
|
38
|
+
wrapper_attrs?: SvelteHTMLElements['div'];
|
|
39
|
+
};
|
|
40
|
+
declare const CodeTextarea: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
41
|
+
type CodeTextarea = ReturnType<typeof CodeTextarea>;
|
|
42
|
+
export default CodeTextarea;
|
|
43
|
+
//# sourceMappingURL=CodeTextarea.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CodeTextarea.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CodeTextarea.svelte"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;OAcI;AACJ,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAGnE,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,UAAU,CAAC,GAAG;IACzD,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACpC,mEAAmE;IACnE,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAC,KAAK,CAAC,CAAC;CAC1C,CAAC;AA2DH,QAAA,MAAM,YAAY,2DAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -5,10 +5,12 @@ export type HighlightMode = 'auto' | 'ranges' | 'html';
|
|
|
5
5
|
*/
|
|
6
6
|
export declare const supports_css_highlight_api: () => boolean;
|
|
7
7
|
/**
|
|
8
|
-
* Manages CSS Custom Highlight API ranges for a single element.
|
|
9
|
-
* Tracks ranges per element and only removes its own ranges when clearing
|
|
8
|
+
* Manages CSS Custom Highlight API ranges for a single element's text node.
|
|
9
|
+
* Tracks ranges per element and only removes its own ranges when clearing,
|
|
10
|
+
* cooperating with other managers that share the global `CSS.highlights` registry.
|
|
10
11
|
*
|
|
11
|
-
* **Experimental** — limited browser support. Use `Code.svelte` for production
|
|
12
|
+
* **Experimental** — limited browser support. Use `Code.svelte` for production
|
|
13
|
+
* block code; this powers the experimental `CodeHighlight` and `CodeTextarea`.
|
|
12
14
|
*
|
|
13
15
|
* @example
|
|
14
16
|
* ```ts
|
|
@@ -18,14 +20,26 @@ export declare const supports_css_highlight_api: () => boolean;
|
|
|
18
20
|
*/
|
|
19
21
|
export declare class HighlightManager {
|
|
20
22
|
#private;
|
|
21
|
-
|
|
23
|
+
/**
|
|
24
|
+
* This manager's ranges, keyed by prefixed highlight name (e.g. `token_keyword`).
|
|
25
|
+
* A single range object may be shared across several names (a token type plus
|
|
26
|
+
* its aliases), since one range can belong to multiple `Highlight` sets.
|
|
27
|
+
*/
|
|
28
|
+
element_ranges: Map<string, Array<AbstractRange>>;
|
|
22
29
|
constructor();
|
|
23
30
|
/**
|
|
24
|
-
* Highlights from a `SyntaxTokenStream` produced by
|
|
31
|
+
* Highlights `element`'s text node from a `SyntaxTokenStream` produced by
|
|
32
|
+
* `tokenize_syntax`. Clears this manager's previous ranges first.
|
|
33
|
+
*
|
|
34
|
+
* In production this never throws on a tokenizer/DOM mismatch: out-of-bounds
|
|
35
|
+
* tokens are clamped and a missing text node is a no-op. In DEV the same
|
|
36
|
+
* conditions throw loudly to surface grammar bugs.
|
|
25
37
|
*/
|
|
26
38
|
highlight_from_syntax_tokens(element: Element, tokens: SyntaxTokenStream): void;
|
|
27
39
|
/**
|
|
28
|
-
* Clears only this
|
|
40
|
+
* Clears only this manager's ranges from the shared highlights. Defensive:
|
|
41
|
+
* a highlight may already be gone (e.g. another manager removed the last
|
|
42
|
+
* range, or HMR reset the registry), which is a valid state, not an error.
|
|
29
43
|
*/
|
|
30
44
|
clear_element_ranges(): void;
|
|
31
45
|
destroy(): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"highlight_manager.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/highlight_manager.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"highlight_manager.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/highlight_manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAGzD,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEvD;;GAEG;AACH,eAAO,MAAM,0BAA0B,QAAO,OACS,CAAC;AA6CxD;;;;;;;;;;;;;GAaG;AACH,qBAAa,gBAAgB;;IAC5B;;;;OAIG;IACH,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC;;IAYlD;;;;;;;OAOG;IACH,4BAA4B,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAqE/E;;;;OAIG;IACH,oBAAoB,IAAI,IAAI;IAiB5B,OAAO,IAAI,IAAI;CAwEf"}
|
|
@@ -1,13 +1,40 @@
|
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
1
2
|
import { highlight_priorities } from './highlight_priorities.js';
|
|
2
3
|
/**
|
|
3
4
|
* Checks for CSS Highlights API support.
|
|
4
5
|
*/
|
|
5
6
|
export const supports_css_highlight_api = () => !!(globalThis.CSS?.highlights && globalThis.Highlight);
|
|
7
|
+
const detect_range_kind = () => typeof globalThis.StaticRange === 'function' ? 'static' : 'live';
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
* Tracks ranges per element and only removes its own ranges when clearing.
|
|
9
|
+
* Finds the first text node child of `element`, or `null` if there is none.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
+
* The text node might not be `firstChild` because frameworks (e.g. Svelte) can
|
|
12
|
+
* insert comment/anchor nodes around it.
|
|
13
|
+
*/
|
|
14
|
+
const find_text_node = (element) => {
|
|
15
|
+
for (const node of element.childNodes) {
|
|
16
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
const has_tokens = (tokens) => tokens.some((t) => typeof t !== 'string');
|
|
22
|
+
const push_range = (ranges_by_name, name, range) => {
|
|
23
|
+
const existing = ranges_by_name.get(name);
|
|
24
|
+
if (existing) {
|
|
25
|
+
existing.push(range);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
ranges_by_name.set(name, [range]);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Manages CSS Custom Highlight API ranges for a single element's text node.
|
|
33
|
+
* Tracks ranges per element and only removes its own ranges when clearing,
|
|
34
|
+
* cooperating with other managers that share the global `CSS.highlights` registry.
|
|
35
|
+
*
|
|
36
|
+
* **Experimental** — limited browser support. Use `Code.svelte` for production
|
|
37
|
+
* block code; this powers the experimental `CodeHighlight` and `CodeTextarea`.
|
|
11
38
|
*
|
|
12
39
|
* @example
|
|
13
40
|
* ```ts
|
|
@@ -16,65 +43,100 @@ export const supports_css_highlight_api = () => !!(globalThis.CSS?.highlights &&
|
|
|
16
43
|
* ```
|
|
17
44
|
*/
|
|
18
45
|
export class HighlightManager {
|
|
46
|
+
/**
|
|
47
|
+
* This manager's ranges, keyed by prefixed highlight name (e.g. `token_keyword`).
|
|
48
|
+
* A single range object may be shared across several names (a token type plus
|
|
49
|
+
* its aliases), since one range can belong to multiple `Highlight` sets.
|
|
50
|
+
*/
|
|
19
51
|
element_ranges;
|
|
52
|
+
#range_kind;
|
|
20
53
|
constructor() {
|
|
21
54
|
if (!supports_css_highlight_api()) {
|
|
22
55
|
throw Error('CSS Highlights API not supported');
|
|
23
56
|
}
|
|
24
57
|
this.element_ranges = new Map();
|
|
58
|
+
this.#range_kind = detect_range_kind();
|
|
25
59
|
}
|
|
26
60
|
/**
|
|
27
|
-
* Highlights from a `SyntaxTokenStream` produced by
|
|
61
|
+
* Highlights `element`'s text node from a `SyntaxTokenStream` produced by
|
|
62
|
+
* `tokenize_syntax`. Clears this manager's previous ranges first.
|
|
63
|
+
*
|
|
64
|
+
* In production this never throws on a tokenizer/DOM mismatch: out-of-bounds
|
|
65
|
+
* tokens are clamped and a missing text node is a no-op. In DEV the same
|
|
66
|
+
* conditions throw loudly to surface grammar bugs.
|
|
28
67
|
*/
|
|
29
68
|
highlight_from_syntax_tokens(element, tokens) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
69
|
+
this.clear_element_ranges();
|
|
70
|
+
const text_node = find_text_node(element);
|
|
71
|
+
if (!text_node) {
|
|
72
|
+
if (has_tokens(tokens)) {
|
|
73
|
+
if (DEV) {
|
|
74
|
+
throw new Error('no text node to highlight');
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// eslint-disable-next-line no-console
|
|
78
|
+
console.error('[HighlightManager] tokens present but no text node to highlight');
|
|
79
|
+
}
|
|
36
80
|
}
|
|
81
|
+
return;
|
|
37
82
|
}
|
|
38
|
-
|
|
39
|
-
|
|
83
|
+
try {
|
|
84
|
+
this.#apply(text_node, tokens);
|
|
40
85
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
86
|
+
catch (err) {
|
|
87
|
+
// some engines may reject `StaticRange` in `Highlight.add` -- fall back to
|
|
88
|
+
// live `Range` once rather than letting the throw escape into the effect
|
|
89
|
+
if (this.#range_kind === 'static') {
|
|
90
|
+
this.#range_kind = 'live';
|
|
91
|
+
this.clear_element_ranges(); // undo any partial application
|
|
92
|
+
this.#apply(text_node, tokens);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
#apply(text_node, tokens) {
|
|
100
|
+
const ranges_by_name = new Map();
|
|
101
|
+
const final_pos = this.#collect_ranges(tokens, text_node, ranges_by_name, 0);
|
|
102
|
+
if (DEV) {
|
|
103
|
+
const text_length = text_node.textContent?.length ?? 0;
|
|
104
|
+
if (final_pos !== text_length) {
|
|
105
|
+
throw new Error(`Token stream length mismatch: tokens covered ${final_pos} chars but text node has ${text_length} chars`);
|
|
106
|
+
}
|
|
48
107
|
}
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
108
|
+
// TODO: cross-instance coupling -- all managers share one global `Highlight`
|
|
109
|
+
// per token type, so re-highlighting one element (e.g. a textarea on every
|
|
110
|
+
// keystroke) mutates highlights that also hold ranges from every other code
|
|
111
|
+
// block on the page, forcing the browser to re-evaluate the shared set.
|
|
112
|
+
// Isolating per-instance needs unique highlight names + runtime-injected
|
|
113
|
+
// `::highlight()` CSS (≈50 token types × N instances), trading the static
|
|
114
|
+
// theme file for generated CSS. Only worth it with many concurrently-updating
|
|
115
|
+
// instances; revisit if profiling shows it.
|
|
116
|
+
for (const [name, ranges] of ranges_by_name) {
|
|
117
|
+
this.element_ranges.set(name, ranges);
|
|
118
|
+
let highlight = CSS.highlights.get(name);
|
|
56
119
|
if (!highlight) {
|
|
57
120
|
highlight = new Highlight();
|
|
58
|
-
//
|
|
59
|
-
highlight.priority =
|
|
60
|
-
|
|
61
|
-
CSS.highlights.set(prefixed_type, highlight);
|
|
121
|
+
// priority follows CSS cascade order (higher = later in CSS = wins)
|
|
122
|
+
highlight.priority = highlight_priorities[name] ?? 0;
|
|
123
|
+
CSS.highlights.set(name, highlight);
|
|
62
124
|
}
|
|
63
|
-
// Add all ranges to the highlight
|
|
64
125
|
for (const range of ranges) {
|
|
65
126
|
highlight.add(range);
|
|
66
127
|
}
|
|
67
128
|
}
|
|
68
129
|
}
|
|
69
130
|
/**
|
|
70
|
-
* Clears only this
|
|
131
|
+
* Clears only this manager's ranges from the shared highlights. Defensive:
|
|
132
|
+
* a highlight may already be gone (e.g. another manager removed the last
|
|
133
|
+
* range, or HMR reset the registry), which is a valid state, not an error.
|
|
71
134
|
*/
|
|
72
135
|
clear_element_ranges() {
|
|
73
136
|
for (const [name, ranges] of this.element_ranges) {
|
|
74
137
|
const highlight = CSS.highlights.get(name);
|
|
75
|
-
if (!highlight)
|
|
76
|
-
|
|
77
|
-
}
|
|
138
|
+
if (!highlight)
|
|
139
|
+
continue;
|
|
78
140
|
for (const range of ranges) {
|
|
79
141
|
highlight.delete(range);
|
|
80
142
|
}
|
|
@@ -87,10 +149,25 @@ export class HighlightManager {
|
|
|
87
149
|
destroy() {
|
|
88
150
|
this.clear_element_ranges();
|
|
89
151
|
}
|
|
152
|
+
#make_range(text_node, start, end) {
|
|
153
|
+
if (this.#range_kind === 'static') {
|
|
154
|
+
return new StaticRange({
|
|
155
|
+
startContainer: text_node,
|
|
156
|
+
startOffset: start,
|
|
157
|
+
endContainer: text_node,
|
|
158
|
+
endOffset: end,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const range = new Range();
|
|
162
|
+
range.setStart(text_node, start);
|
|
163
|
+
range.setEnd(text_node, end);
|
|
164
|
+
return range;
|
|
165
|
+
}
|
|
90
166
|
/**
|
|
91
|
-
*
|
|
167
|
+
* Walks the token tree, collecting one range per non-empty token (shared
|
|
168
|
+
* across its type and aliases). Returns the end position covered.
|
|
92
169
|
*/
|
|
93
|
-
#
|
|
170
|
+
#collect_ranges(tokens, text_node, ranges_by_name, offset) {
|
|
94
171
|
const text_length = text_node.textContent?.length ?? 0;
|
|
95
172
|
let pos = offset;
|
|
96
173
|
for (const token of tokens) {
|
|
@@ -98,49 +175,28 @@ export class HighlightManager {
|
|
|
98
175
|
pos += token.length;
|
|
99
176
|
continue;
|
|
100
177
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
// Validate positions are within text node bounds before creating ranges
|
|
104
|
-
if (end_pos > text_length) {
|
|
178
|
+
const end_pos = pos + token.length;
|
|
179
|
+
if (DEV && end_pos > text_length) {
|
|
105
180
|
throw new Error(`Token ${token.type} extends beyond text node: position ${end_pos} > length ${text_length}`);
|
|
106
181
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
range
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
ranges_by_type.set(type, []);
|
|
115
|
-
}
|
|
116
|
-
ranges_by_type.get(type).push(range);
|
|
117
|
-
// Also add range for any aliases (alias is always an array)
|
|
182
|
+
// production-safe: clamp rather than throw on a tokenizer edge case
|
|
183
|
+
const safe_end = end_pos > text_length ? text_length : end_pos;
|
|
184
|
+
if (safe_end > pos) {
|
|
185
|
+
// one range shared across the token type and all its aliases --
|
|
186
|
+
// the same range object can belong to multiple `Highlight` sets
|
|
187
|
+
const range = this.#make_range(text_node, pos, safe_end);
|
|
188
|
+
push_range(ranges_by_name, `token_${token.type}`, range);
|
|
118
189
|
for (const alias of token.alias) {
|
|
119
|
-
|
|
120
|
-
ranges_by_type.set(alias, []);
|
|
121
|
-
}
|
|
122
|
-
// Create a new range for each alias (ranges can't be reused)
|
|
123
|
-
const alias_range = new Range();
|
|
124
|
-
alias_range.setStart(text_node, pos);
|
|
125
|
-
alias_range.setEnd(text_node, end_pos);
|
|
126
|
-
ranges_by_type.get(alias).push(alias_range);
|
|
190
|
+
push_range(ranges_by_name, `token_${alias}`, range);
|
|
127
191
|
}
|
|
128
192
|
}
|
|
129
|
-
catch (e) {
|
|
130
|
-
throw new Error(`Failed to create range for ${token.type}: ${e}`);
|
|
131
|
-
}
|
|
132
|
-
// Process nested tokens
|
|
133
193
|
if (Array.isArray(token.content)) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
throw new Error(`Token ${token.type} length mismatch: claimed ${length} chars (${pos}-${end_pos}) but nested content covered ${actual_end_pos - pos} chars (${pos}-${actual_end_pos})`);
|
|
194
|
+
const nested_end = this.#collect_ranges(token.content, text_node, ranges_by_name, pos);
|
|
195
|
+
if (DEV && nested_end !== end_pos) {
|
|
196
|
+
throw new Error(`Token ${token.type} length mismatch: claimed ${token.length} chars (${pos}-${end_pos}) but nested content covered ${nested_end - pos} chars (${pos}-${nested_end})`);
|
|
138
197
|
}
|
|
139
|
-
pos = actual_end_pos;
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
pos = end_pos;
|
|
143
198
|
}
|
|
199
|
+
pos = end_pos;
|
|
144
200
|
}
|
|
145
201
|
return pos;
|
|
146
202
|
}
|