-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhighlight_manager.ts
More file actions
284 lines (257 loc) · 8.7 KB
/
Copy pathhighlight_manager.ts
File metadata and controls
284 lines (257 loc) · 8.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
import { DEV } from 'esm-env';
import type { LexedSyntax, TokenTypeInfo } from './lexer.ts';
import { highlight_priorities } from './highlight_priorities.ts';
export type HighlightMode = 'auto' | 'ranges' | 'html';
/**
* Checks for CSS Highlights API support.
*/
export const supports_css_highlight_api = (): boolean =>
!!(globalThis.CSS?.highlights && globalThis.Highlight);
/**
* How a manager builds ranges for the Highlight API.
*
* `StaticRange` is preferred: it's an immutable snapshot the browser does *not*
* track across DOM mutations. Since highlights are rebuilt wholesale whenever
* content changes, liveness buys nothing and only costs per-mutation boundary
* bookkeeping and paint work — the main efficiency (and Safari-stability) lever.
* Falls back to a live `Range` where `StaticRange` is unavailable.
*/
type RangeKind = 'static' | 'live';
const detect_range_kind = (): RangeKind =>
typeof globalThis.StaticRange === 'function' ? 'static' : 'live';
/**
* Finds the first text node child of `element`, or `null` if there is none.
*
* The text node might not be `firstChild` because frameworks (e.g. Svelte) can
* insert comment/anchor nodes around it.
*/
const find_text_node = (element: Element): Node | null => {
for (const node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE) return node;
}
return null;
};
const push_range = (
ranges_by_name: Map<string, Array<AbstractRange>>,
name: string,
range: AbstractRange
): void => {
const existing = ranges_by_name.get(name);
if (existing) {
existing.push(range);
} else {
ranges_by_name.set(name, [range]);
}
};
/**
* Prefixed highlight names (`token_<name>` plus one per alias) per token type,
* computed once per type instead of once per token — the ranges-path analog of
* the registry's precomputed `open_tag`. Module-level so managers share it;
* keyed weakly so custom registries' infos don't leak.
*/
const highlight_names_cache: WeakMap<TokenTypeInfo, Array<string>> = new WeakMap();
const highlight_names_of = (info: TokenTypeInfo): Array<string> => {
let names = highlight_names_cache.get(info);
if (names === undefined) {
names = [`token_${info.name}`];
for (const alias of info.aliases) names.push(`token_${alias}`);
highlight_names_cache.set(info, names);
}
return names;
};
/**
* Manages CSS Custom Highlight API ranges for a single element's text node.
* Tracks ranges per element and only removes its own ranges when clearing,
* cooperating with other managers that share the global `CSS.highlights` registry.
*
* **Experimental** — limited browser support. Use `Code.svelte` for production
* block code; this powers the experimental `CodeHighlight` and `CodeTextarea`.
*
* @example
* ```ts
* const manager = new HighlightManager();
* manager.highlight_from_lexed(element, syntax_styler_global.lex(text, 'ts'));
* ```
*/
export class HighlightManager {
/**
* This manager's ranges, keyed by prefixed highlight name (e.g. `token_keyword`).
* A single range object may be shared across several names (a token type plus
* its aliases), since one range can belong to multiple `Highlight` sets.
*/
element_ranges: Map<string, Array<AbstractRange>>;
#range_kind: RangeKind;
constructor() {
if (!supports_css_highlight_api()) {
throw Error('CSS Highlights API not supported');
}
this.element_ranges = new Map();
this.#range_kind = detect_range_kind();
}
/**
* Highlights `element`'s text node from a lexed event stream. Ranges come
* straight from event offsets — no position reconstruction. Clears this
* manager's previous ranges first.
*
* A missing text node is a no-op. Offsets past the text node's length are
* clamped (a DOM/source mismatch is the caller's concern, not a throw here).
*/
highlight_from_lexed(element: Element, lexed: LexedSyntax): void {
this.clear_element_ranges();
const text_node = find_text_node(element);
if (!text_node) {
if (lexed.events_len > 0) {
if (DEV) {
throw new Error('no text node to highlight');
} else {
// eslint-disable-next-line no-console
console.error('[HighlightManager] tokens present but no text node to highlight');
}
}
return;
}
try {
this.#apply(text_node, lexed);
} catch (err) {
// some engines may reject `StaticRange` in `Highlight.add` -- fall back to
// live `Range` once rather than letting the throw escape into the effect
if (this.#range_kind === 'static') {
this.#range_kind = 'live';
this.clear_element_ranges(); // undo any partial application
this.#apply(text_node, lexed);
} else {
throw err;
}
}
}
#apply(text_node: Node, lexed: LexedSyntax): void {
const ranges_by_name: Map<string, Array<AbstractRange>> = new Map();
this.#collect_ranges(lexed, text_node, ranges_by_name);
// TODO: cross-instance coupling -- all managers share one global `Highlight`
// per token type, so re-highlighting one element (e.g. a textarea on every
// keystroke) mutates highlights that also hold ranges from every other code
// block on the page, forcing the browser to re-evaluate the shared set.
// Isolating per-instance needs unique highlight names + runtime-injected
// `::highlight()` CSS (≈50 token types × N instances), trading the static
// theme file for generated CSS. Only worth it with many concurrently-updating
// instances; revisit if profiling shows it.
for (const [name, ranges] of ranges_by_name) {
this.element_ranges.set(name, ranges);
let highlight = CSS.highlights.get(name);
if (!highlight) {
highlight = new Highlight();
// priority follows CSS cascade order (higher = later in CSS = wins)
highlight.priority = highlight_priorities[name as keyof typeof highlight_priorities] ?? 0;
CSS.highlights.set(name, highlight);
}
for (const range of ranges) {
highlight.add(range);
}
}
}
/**
* Clears only this manager's ranges from the shared highlights. Defensive:
* a highlight may already be gone (e.g. another manager removed the last
* range, or HMR reset the registry), which is a valid state, not an error.
*/
clear_element_ranges(): void {
for (const [name, ranges] of this.element_ranges) {
const highlight = CSS.highlights.get(name);
if (!highlight) continue;
for (const range of ranges) {
highlight.delete(range);
}
if (highlight.size === 0) {
CSS.highlights.delete(name);
}
}
this.element_ranges.clear();
}
destroy(): void {
this.clear_element_ranges();
}
#make_range(text_node: Node, start: number, end: number): AbstractRange {
if (this.#range_kind === 'static') {
return new StaticRange({
startContainer: text_node,
startOffset: start,
endContainer: text_node,
endOffset: end
});
}
const range = new Range();
range.setStart(text_node, start);
range.setEnd(text_node, end);
return range;
}
/**
* Walks the flat event stream, collecting one range per token (leaf or
* container, shared across its type and aliases) directly from event offsets.
* Offsets past the text node's length are clamped.
*/
#collect_ranges(
lexed: LexedSyntax,
text_node: Node,
ranges_by_name: Map<string, Array<AbstractRange>>
): void {
const { events, events_len } = lexed;
const { infos } = lexed.types;
const text_length = text_node.textContent?.length ?? 0;
// open containers awaiting their close, so a container's range spans
// `[open_start, close_end)`
const open_stack: Array<{ id: number; start: number }> = [];
let i = 0;
while (i < events_len) {
const tag = events[i]!;
if (tag > 0) {
this.#push_token(
infos[tag]!,
events[i + 1]!,
events[i + 2]!,
text_node,
ranges_by_name,
text_length
);
i += 3;
} else if (tag < 0) {
open_stack.push({ id: -tag, start: events[i + 1]! });
i += 2;
} else {
const open = open_stack.pop();
if (open) {
this.#push_token(
infos[open.id]!,
open.start,
events[i + 1]!,
text_node,
ranges_by_name,
text_length
);
}
i += 2;
}
}
}
/**
* Pushes one range for `[start, end)` (clamped to `text_length`), shared
* across the token type's own class and each of its aliases — the same range
* object can belong to multiple `Highlight` sets. Names come precomputed
* from `highlight_names_of`, so nothing allocates per token here except the
* range itself.
*/
#push_token(
info: TokenTypeInfo,
start: number,
end: number,
text_node: Node,
ranges_by_name: Map<string, Array<AbstractRange>>,
text_length: number
): void {
const safe_end = end > text_length ? text_length : end;
if (safe_end <= start) return;
const range = this.#make_range(text_node, start, safe_end);
for (const name of highlight_names_of(info)) {
push_range(ranges_by_name, name, range);
}
}
}